From 3c10d262896e6199771881f94f27752dda4f1f27 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 27 Dec 2025 14:37:49 -0800 Subject: [PATCH 01/20] Initial impl! --- bun.lock | 25 ++- cli/src/commands/command-registry.ts | 10 + cli/src/commands/router.ts | 18 ++ cli/src/components/claude-connect-banner.tsx | 141 ++++++++++++ cli/src/components/input-mode-banner.tsx | 2 + cli/src/data/slash-commands.ts | 6 + cli/src/utils/claude-oauth.ts | 221 +++++++++++++++++++ cli/src/utils/input-modes.ts | 9 + common/src/constants/claude-oauth.ts | 105 +++++++++ sdk/package.json | 1 + sdk/src/credentials.ts | 136 ++++++++++++ sdk/src/env.ts | 9 + sdk/src/impl/llm.ts | 207 ++++++----------- sdk/src/impl/model-provider.ts | 213 ++++++++++++++++++ 14 files changed, 958 insertions(+), 145 deletions(-) create mode 100644 cli/src/components/claude-connect-banner.tsx create mode 100644 cli/src/utils/claude-oauth.ts create mode 100644 common/src/constants/claude-oauth.ts create mode 100644 sdk/src/impl/model-provider.ts diff --git a/bun.lock b/bun.lock index 646394b2d..1703cf30a 100644 --- a/bun.lock +++ b/bun.lock @@ -84,7 +84,7 @@ "@types/pg": "^8.11.10", "@types/readable-stream": "^4.0.18", "@types/seedrandom": "^3.0.8", - "ai": "^5.0.0", + "ai": "5.0.0", "ignore": "5.3.2", "lodash": "4.17.21", "next-auth": "^4.24.11", @@ -214,9 +214,10 @@ "name": "@codebuff/sdk", "version": "0.10.2", "dependencies": { + "@ai-sdk/anthropic": "2.0.50", "@jitl/quickjs-wasmfile-release-sync": "0.31.0", "@vscode/tree-sitter-wasm": "0.1.4", - "ai": "^5.0.0", + "ai": "5.0.0", "diff": "8.0.2", "ignore": "7.0.5", "micromatch": "^4.0.8", @@ -339,7 +340,9 @@ "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.23", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-qmX7afPRszUqG5hryHF3UN8ITPIRSGmDW6VYCmByzjoUkgm3MekzSx2hMV1wr0P+llDeuXb378SjqUfpvWJulg=="], + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.50", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-21PaHfoLmouOXXNINTsZJsMw+wE5oLR2He/1kq/sKokTVKyq7ObGT1LDk6ahwxaz/GoaNaGankMh+EgVcdv2Cw=="], + + "@ai-sdk/gateway": ["@ai-sdk/gateway@1.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-VEm87DyRx1yIPywbTy8ntoyh4jEDv1rJ88m+2I7zOm08jJI5BhFtAWh0OF6YzZu1Vu4NxhOWO4ssGdsqydDQ3A=="], "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.25", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-VPylb5ytkOu9Bs1UnVmz4x0wr1VtS30Pw6ghh6GxpGH6lo4GOWqVnYuB+8M755dkof74c5LULZq5C1n/1J4Kvg=="], @@ -1391,8 +1394,6 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], - "@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="], - "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="], "@vscode/tree-sitter-wasm": ["@vscode/tree-sitter-wasm@0.1.4", "", {}, "sha512-kQVVg/CamCYDM+/XYCZuNTQyixjZd8ts/Gf84UzjEY0eRnbg6kiy5I9z2/2i3XdqwhI87iG07rkMR2KwhqcSbA=="], @@ -1427,7 +1428,7 @@ "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "ai": ["ai@5.0.116", "", { "dependencies": { "@ai-sdk/gateway": "2.0.23", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+2hYJ80/NcDWuv9K2/MLP3cTCFgwWHmHlS1tOpFUKKcmLbErAAlE/S2knsKboc3PNAu8pQkDr2N3K/Vle7ENgQ=="], + "ai": ["ai@5.0.0", "", { "dependencies": { "@ai-sdk/gateway": "1.0.0", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-F4jOhOSeiZD8lXpF4l1hRqyM1jbqoLKGVZNxAP467wmQCsWUtElMa3Ki5PrDMq6qvUNC3deUKfERDAsfj7IDlg=="], "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], @@ -3567,6 +3568,10 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.18", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ=="], + + "@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="], + "@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.15", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kOc6Pxb7CsRlNt+sLZKL7/VGQUd7ccl3/tIK+Bqf5/QhHR0Qm3qRBMz1IwU1RmjJEZA73x+KB5cUckbDl2WF7Q=="], "@auth/core/jose": ["jose@6.1.0", "", {}, "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA=="], @@ -3759,6 +3764,8 @@ "accepts/mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="], + "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="], + "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "app-path/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -4115,6 +4122,10 @@ "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@ai-sdk/gateway/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@codebuff/web/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.46.2", "", { "dependencies": { "@typescript-eslint/types": "8.46.2", "@typescript-eslint/visitor-keys": "8.46.2" } }, "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA=="], @@ -4287,6 +4298,8 @@ "accepts/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "ai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "app-path/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "app-path/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index e3c6a5821..5d97a4952 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -423,6 +423,16 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ return { openPublishMode: true } }, }), + defineCommand({ + name: 'connect:claude', + aliases: ['claude'], + handler: (params) => { + // Enter connect:claude mode to show the OAuth banner + useChatStore.getState().setInputMode('connect:claude') + params.saveToHistory(params.inputValue.trim()) + clearInput(params) + }, + }), ] export function findCommand(cmd: string): CommandDefinition | undefined { diff --git a/cli/src/commands/router.ts b/cli/src/commands/router.ts index 083e13c7e..82d8f3868 100644 --- a/cli/src/commands/router.ts +++ b/cli/src/commands/router.ts @@ -14,6 +14,7 @@ import { extractReferralCode, normalizeReferralCode, } from './router-utils' +import { handleClaudeAuthCode } from '../components/claude-connect-banner' import { getProjectRoot } from '../project-files' import { useChatStore } from '../state/chat-store' import { @@ -282,6 +283,23 @@ export async function routeUserPrompt( return } + // Handle connect:claude mode input (authorization code) + if (inputMode === 'connect:claude') { + const code = trimmed + if (code) { + const result = await handleClaudeAuthCode(code) + setMessages((prev) => [ + ...prev, + getUserMessage(trimmed), + getSystemMessage(result.message), + ]) + } + saveToHistory(trimmed) + setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false }) + setInputMode('default') + return + } + // Handle referral mode input if (inputMode === 'referral') { // Validate the referral code (3-50 alphanumeric chars with optional dashes) diff --git a/cli/src/components/claude-connect-banner.tsx b/cli/src/components/claude-connect-banner.tsx new file mode 100644 index 000000000..fdc8770a7 --- /dev/null +++ b/cli/src/components/claude-connect-banner.tsx @@ -0,0 +1,141 @@ +import React, { useState, useEffect } from 'react' + +import { BottomBanner } from './bottom-banner' +import { Button } from './button' +import { useChatStore } from '../state/chat-store' +import { + openOAuthInBrowser, + exchangeCodeForTokens, + disconnectClaudeOAuth, + getClaudeOAuthStatus, +} from '../utils/claude-oauth' +import { useTheme } from '../hooks/use-theme' + +type FlowState = 'checking' | 'not-connected' | 'waiting-for-code' | 'connected' | 'error' + +export const ClaudeConnectBanner = () => { + const setInputMode = useChatStore((state) => state.setInputMode) + const theme = useTheme() + const [flowState, setFlowState] = useState('checking') + const [error, setError] = useState(null) + const [isDisconnectHovered, setIsDisconnectHovered] = useState(false) + + // Check initial connection status + useEffect(() => { + const status = getClaudeOAuthStatus() + if (status.connected) { + setFlowState('connected') + } else { + setFlowState('not-connected') + } + }, []) + + const handleConnect = async () => { + try { + setFlowState('waiting-for-code') + await openOAuthInBrowser() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to open browser') + setFlowState('error') + } + } + + const handleDisconnect = () => { + disconnectClaudeOAuth() + setFlowState('not-connected') + } + + const handleClose = () => { + setInputMode('default') + } + + // Connected state + if (flowState === 'connected') { + const status = getClaudeOAuthStatus() + const connectedDate = status.connectedAt + ? new Date(status.connectedAt).toLocaleDateString() + : 'Unknown' + + return ( + + + + โœ“ Connected to Claude (since {connectedDate}) + + + + + ) + } + + // Error state + if (flowState === 'error') { + return ( + + ) + } + + // Waiting for code state + if (flowState === 'waiting-for-code') { + return ( + + + + Browser opened. Sign in with your Claude account, then paste the authorization code below. + + + Type the code in the input box above and press Enter. + + + + ) + } + + // Not connected / checking state - show connect button + return ( + + + + Connect your Claude Pro/Max subscription to use Claude models directly. + + + + + ) +} + +/** + * Handle the authorization code input from the user. + * This is called when the user pastes their code in connect:claude mode. + */ +export async function handleClaudeAuthCode(code: string): Promise<{ + success: boolean + message: string +}> { + try { + await exchangeCodeForTokens(code) + return { + success: true, + message: 'Successfully connected to Claude! Your Claude models will now use your subscription.', + } + } catch (err) { + return { + success: false, + message: err instanceof Error ? err.message : 'Failed to exchange authorization code', + } + } +} diff --git a/cli/src/components/input-mode-banner.tsx b/cli/src/components/input-mode-banner.tsx index d04738080..bc1b1c1f6 100644 --- a/cli/src/components/input-mode-banner.tsx +++ b/cli/src/components/input-mode-banner.tsx @@ -1,5 +1,6 @@ import React from 'react' +import { ClaudeConnectBanner } from './claude-connect-banner' import { HelpBanner } from './help-banner' import { PendingImagesBanner } from './pending-images-banner' import { ReferralBanner } from './referral-banner' @@ -24,6 +25,7 @@ const BANNER_REGISTRY: Record< usage: ({ showTime }) => , referral: () => , help: () => , + 'connect:claude': () => , } /** diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts index 4ff6b8497..e8519aa02 100644 --- a/cli/src/data/slash-commands.ts +++ b/cli/src/data/slash-commands.ts @@ -93,5 +93,11 @@ export const SLASH_COMMANDS: SlashCommand[] = [ label: 'publish', description: 'Publish agents to the agent store', }, + { + id: 'connect:claude', + label: 'connect:claude', + description: 'Connect your Claude Pro/Max subscription', + aliases: ['claude'], + }, ...MODE_COMMANDS, ] diff --git a/cli/src/utils/claude-oauth.ts b/cli/src/utils/claude-oauth.ts new file mode 100644 index 000000000..4aa9a72ca --- /dev/null +++ b/cli/src/utils/claude-oauth.ts @@ -0,0 +1,221 @@ +/** + * Claude OAuth PKCE flow implementation for connecting to user's Claude Pro/Max subscription. + */ + +import crypto from 'crypto' +import open from 'open' +import { + CLAUDE_OAUTH_CLIENT_ID, + CLAUDE_OAUTH_AUTHORIZE_URL, + CLAUDE_OAUTH_TOKEN_URL, +} from '@codebuff/common/constants/claude-oauth' +import { + saveClaudeOAuthCredentials, + clearClaudeOAuthCredentials, + getClaudeOAuthCredentials, + isClaudeOAuthValid, +} from '@codebuff/sdk' + +import type { ClaudeOAuthCredentials } from '@codebuff/sdk' + +// PKCE code verifier and challenge generation +function generateCodeVerifier(): string { + // Generate 32 random bytes and encode as base64url + const buffer = crypto.randomBytes(32) + return buffer + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') +} + +function generateCodeChallenge(verifier: string): string { + // SHA256 hash of the verifier, encoded as base64url + const hash = crypto.createHash('sha256').update(verifier).digest() + return hash + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') +} + +// Store the code verifier and state during the OAuth flow +let pendingCodeVerifier: string | null = null +let pendingState: string | null = null + +/** + * Start the OAuth authorization flow. + * Opens the browser to Anthropic's authorization page. + * @returns The code verifier to be used when exchanging the authorization code + */ +export function startOAuthFlow(): { codeVerifier: string; authUrl: string } { + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + + // Generate a random state parameter for CSRF protection + const state = crypto.randomBytes(16).toString('hex') + + // Store the code verifier and state for later use + pendingCodeVerifier = codeVerifier + pendingState = state + + // Build the authorization URL + // Use claude.ai for Max subscription (same as opencode) + const authUrl = new URL('https://claude.ai/oauth/authorize') + authUrl.searchParams.set('code', 'true') + authUrl.searchParams.set('client_id', CLAUDE_OAUTH_CLIENT_ID) + authUrl.searchParams.set('response_type', 'code') + authUrl.searchParams.set('redirect_uri', 'https://console.anthropic.com/oauth/code/callback') + authUrl.searchParams.set('scope', 'org:create_api_key user:profile user:inference') + authUrl.searchParams.set('code_challenge', codeChallenge) + authUrl.searchParams.set('code_challenge_method', 'S256') + authUrl.searchParams.set('state', codeVerifier) // opencode uses verifier as state + + return { codeVerifier, authUrl: authUrl.toString() } +} + +/** + * Open the browser to start OAuth flow. + */ +export async function openOAuthInBrowser(): Promise { + const { authUrl, codeVerifier } = startOAuthFlow() + await open(authUrl) + return codeVerifier +} + +/** + * Exchange an authorization code for access and refresh tokens. + */ +export async function exchangeCodeForTokens( + authorizationCode: string, + codeVerifier?: string, +): Promise { + const verifier = codeVerifier ?? pendingCodeVerifier + if (!verifier) { + throw new Error('No code verifier found. Please start the OAuth flow again.') + } + + // The authorization code from claude.ai comes in format: code#state + // We need to split it and send both parts + const splits = authorizationCode.trim().split('#') + const code = splits[0] + const state = splits[1] + + // Use the v1 OAuth token endpoint (same as opencode) + const response = await fetch('https://console.anthropic.com/v1/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + code: code, + state: state, + grant_type: 'authorization_code', + client_id: CLAUDE_OAUTH_CLIENT_ID, + redirect_uri: 'https://console.anthropic.com/oauth/code/callback', + code_verifier: verifier, + }), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Failed to exchange code for tokens: ${errorText}`) + } + + const data = await response.json() + + // Clear the pending code verifier + pendingCodeVerifier = null + + const credentials: ClaudeOAuthCredentials = { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: Date.now() + data.expires_in * 1000, + connectedAt: Date.now(), + } + + // Save credentials to file + saveClaudeOAuthCredentials(credentials) + + return credentials +} + +/** + * Refresh the access token using the refresh token. + */ +export async function refreshAccessToken(): Promise { + const credentials = getClaudeOAuthCredentials() + if (!credentials?.refreshToken) { + return null + } + + try { + // Use the v1 OAuth token endpoint (same as opencode) + const response = await fetch('https://console.anthropic.com/v1/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: credentials.refreshToken, + client_id: CLAUDE_OAUTH_CLIENT_ID, + }), + }) + + if (!response.ok) { + // Refresh failed, clear credentials + clearClaudeOAuthCredentials() + return null + } + + const data = await response.json() + + const newCredentials: ClaudeOAuthCredentials = { + accessToken: data.access_token, + refreshToken: data.refresh_token ?? credentials.refreshToken, + expiresAt: Date.now() + data.expires_in * 1000, + connectedAt: credentials.connectedAt, + } + + // Save updated credentials + saveClaudeOAuthCredentials(newCredentials) + + return newCredentials + } catch { + // Refresh failed, clear credentials + clearClaudeOAuthCredentials() + return null + } +} + +/** + * Disconnect from Claude OAuth (clear credentials). + */ +export function disconnectClaudeOAuth(): void { + clearClaudeOAuthCredentials() +} + +/** + * Get the current Claude OAuth connection status. + */ +export function getClaudeOAuthStatus(): { + connected: boolean + expiresAt?: number + connectedAt?: number +} { + if (!isClaudeOAuthValid()) { + return { connected: false } + } + + const credentials = getClaudeOAuthCredentials() + if (!credentials) { + return { connected: false } + } + + return { + connected: true, + expiresAt: credentials.expiresAt, + connectedAt: credentials.connectedAt, + } +} diff --git a/cli/src/utils/input-modes.ts b/cli/src/utils/input-modes.ts index 552e92dbc..209f12f5c 100644 --- a/cli/src/utils/input-modes.ts +++ b/cli/src/utils/input-modes.ts @@ -11,6 +11,7 @@ export type InputMode = | 'usage' | 'image' | 'help' + | 'connect:claude' // Theme color keys that are valid color values (must match ChatTheme keys) export type ThemeColorKey = @@ -96,6 +97,14 @@ export const INPUT_MODE_CONFIGS: Record = { showAgentModeToggle: true, disableSlashSuggestions: false, }, + 'connect:claude': { + icon: '๐Ÿ”—', + color: 'info', + placeholder: 'paste authorization code here...', + widthAdjustment: 3, // emoji width + padding + showAgentModeToggle: false, + disableSlashSuggestions: true, + }, } export function getInputModeConfig(mode: InputMode): InputModeConfig { diff --git a/common/src/constants/claude-oauth.ts b/common/src/constants/claude-oauth.ts new file mode 100644 index 000000000..e57a09991 --- /dev/null +++ b/common/src/constants/claude-oauth.ts @@ -0,0 +1,105 @@ +/** + * Claude Code OAuth constants for connecting to user's Claude Pro/Max subscription. + * These are used by the CLI for the OAuth PKCE flow and by the SDK for direct Anthropic API calls. + */ + +// OAuth client ID used by Claude Code and third-party apps like opencode +export const CLAUDE_OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e' + +// Anthropic OAuth endpoints +export const CLAUDE_OAUTH_AUTHORIZE_URL = 'https://console.anthropic.com/oauth/authorize' +export const CLAUDE_OAUTH_TOKEN_URL = 'https://console.anthropic.com/oauth/token' + +// Anthropic API endpoint for direct calls +export const ANTHROPIC_API_BASE_URL = 'https://api.anthropic.com' + +// Environment variable for OAuth token override +export const CLAUDE_OAUTH_TOKEN_ENV_VAR = 'CODEBUFF_CLAUDE_OAUTH_TOKEN' + +// Required Anthropic API version header +export const ANTHROPIC_API_VERSION = '2023-06-01' + +/** + * Model ID mapping from OpenRouter format to Anthropic format. + * OpenRouter uses prefixed IDs like "anthropic/claude-sonnet-4", + * while Anthropic uses versioned IDs like "claude-3-5-haiku-20241022". + * + * IMPORTANT: Claude 4.x models (Sonnet 4, Opus 4, etc.) are restricted by Anthropic + * to only work with the official Claude Code CLI. Third-party OAuth only works with + * Claude 3.x models (Haiku 3.5, etc.). + */ +export const OPENROUTER_TO_ANTHROPIC_MODEL_MAP: Record = { + // Claude 3.x models - WORK with third-party OAuth + 'anthropic/claude-3.5-haiku-20241022': 'claude-3-5-haiku-20241022', + 'anthropic/claude-3.5-haiku': 'claude-3-5-haiku-20241022', + 'anthropic/claude-3-5-haiku': 'claude-3-5-haiku-20241022', + 'anthropic/claude-3-haiku': 'claude-3-haiku-20240307', + 'anthropic/claude-3-opus': 'claude-3-opus-20240229', + 'claude-3.5-haiku': 'claude-3-5-haiku-20241022', + 'claude-3-5-haiku': 'claude-3-5-haiku-20241022', + 'claude-3-haiku': 'claude-3-haiku-20240307', + 'claude-3-opus': 'claude-3-opus-20240229', + + // Claude 4.x models - RESTRICTED to Claude Code only (will fail with OAuth) + // Keeping these mappings for future compatibility if Anthropic lifts restrictions + 'anthropic/claude-sonnet-4.5': 'claude-sonnet-4-5-20250929', + 'anthropic/claude-sonnet-4': 'claude-sonnet-4-20250514', + 'anthropic/claude-opus-4.5': 'claude-opus-4-5-20251101', + 'anthropic/claude-opus-4.1': 'claude-opus-4-1-20250805', + 'anthropic/claude-opus-4': 'claude-opus-4-1-20250805', + 'claude-sonnet-4.5': 'claude-sonnet-4-5-20250929', + 'claude-sonnet-4': 'claude-sonnet-4-20250514', + 'claude-opus-4.5': 'claude-opus-4-5-20251101', + 'claude-opus-4.1': 'claude-opus-4-1-20250805', + 'claude-opus-4': 'claude-opus-4-1-20250805', +} + +/** + * Models that are known to work with third-party OAuth. + * Claude 4.x models are restricted to Claude Code only. + */ +export const OAUTH_COMPATIBLE_MODELS = new Set([ + 'claude-3-5-haiku-20241022', + 'claude-3-haiku-20240307', + 'claude-3-opus-20240229', +]) + +/** + * Check if a model is compatible with third-party OAuth. + * Returns false for Claude 4.x models which are restricted to Claude Code. + */ +export function isOAuthCompatibleModel(model: string): boolean { + const anthropicModelId = toAnthropicModelId(model) + return OAUTH_COMPATIBLE_MODELS.has(anthropicModelId) +} + +/** + * Check if a model is a Claude/Anthropic model that can use OAuth. + */ +export function isClaudeModel(model: string): boolean { + return model.startsWith('anthropic/') || model.startsWith('claude-') +} + +/** + * Convert an OpenRouter model ID to an Anthropic model ID. + * Returns the original if no mapping exists. + */ +export function toAnthropicModelId(openrouterModel: string): string { + // If it's already an Anthropic model ID (no prefix), return as-is + if (!openrouterModel.includes('/')) { + return openrouterModel + } + + // Check the mapping table + const mapped = OPENROUTER_TO_ANTHROPIC_MODEL_MAP[openrouterModel] + if (mapped) { + return mapped + } + + // Fallback: strip the "anthropic/" prefix if present + if (openrouterModel.startsWith('anthropic/')) { + return openrouterModel.replace('anthropic/', '') + } + + return openrouterModel +} diff --git a/sdk/package.json b/sdk/package.json index 8b881b311..8b36c205b 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -58,6 +58,7 @@ "url": "https://github.com/CodebuffAI/codebuff/issues" }, "dependencies": { + "@ai-sdk/anthropic": "2.0.50", "@jitl/quickjs-wasmfile-release-sync": "0.31.0", "@vscode/tree-sitter-wasm": "0.1.4", "ai": "^5.0.0", diff --git a/sdk/src/credentials.ts b/sdk/src/credentials.ts index 02d59ca16..6aced7d1c 100644 --- a/sdk/src/credentials.ts +++ b/sdk/src/credentials.ts @@ -6,6 +6,8 @@ import { env } from '@codebuff/common/env' import { userSchema } from '@codebuff/common/util/credentials' import { z } from 'zod/v4' +import { getClaudeOAuthTokenFromEnv } from './env' + import type { ClientEnv } from '@codebuff/common/types/contracts/env' import type { User } from '@codebuff/common/util/credentials' @@ -75,3 +77,137 @@ export const getUserCredentials = (clientEnv: ClientEnv = env): User | null => { return null } } + +/** + * Claude OAuth credentials stored in the credentials file. + */ +export interface ClaudeOAuthCredentials { + accessToken: string + refreshToken: string + expiresAt: number // Unix timestamp in milliseconds + connectedAt: number // Unix timestamp in milliseconds +} + +/** + * Schema for Claude OAuth credentials in the credentials file. + */ +const claudeOAuthSchema = z.object({ + accessToken: z.string(), + refreshToken: z.string(), + expiresAt: z.number(), + connectedAt: z.number(), +}) + +/** + * Extended credentials file schema that includes Claude OAuth. + */ +const extendedCredentialsSchema = z.object({ + default: userSchema.optional(), + claudeOAuth: claudeOAuthSchema.optional(), +}).catchall(z.unknown()) + +/** + * Get Claude OAuth credentials from file or environment variable. + * Environment variable takes precedence. + * @returns OAuth credentials or null if not found + */ +export const getClaudeOAuthCredentials = ( + clientEnv: ClientEnv = env, +): ClaudeOAuthCredentials | null => { + // Check environment variable first + const envToken = getClaudeOAuthTokenFromEnv() + if (envToken) { + // Return a synthetic credentials object for env var tokens + // These tokens are assumed to be valid and non-expiring for simplicity + return { + accessToken: envToken, + refreshToken: '', + expiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000, // 1 year from now + connectedAt: Date.now(), + } + } + + const credentialsPath = getCredentialsPath(clientEnv) + if (!fs.existsSync(credentialsPath)) { + return null + } + + try { + const credentialsFile = fs.readFileSync(credentialsPath, 'utf8') + const parsed = extendedCredentialsSchema.safeParse(JSON.parse(credentialsFile)) + if (!parsed.success || !parsed.data.claudeOAuth) { + return null + } + return parsed.data.claudeOAuth + } catch (error) { + console.error('Error reading Claude OAuth credentials', error) + return null + } +} + +/** + * Save Claude OAuth credentials to the credentials file. + * Preserves existing user credentials. + */ +export const saveClaudeOAuthCredentials = ( + credentials: ClaudeOAuthCredentials, + clientEnv: ClientEnv = env, +): void => { + const configDir = getConfigDir(clientEnv) + const credentialsPath = getCredentialsPath(clientEnv) + + ensureDirectoryExistsSync(configDir) + + let existingData: Record = {} + if (fs.existsSync(credentialsPath)) { + try { + existingData = JSON.parse(fs.readFileSync(credentialsPath, 'utf8')) + } catch { + // Ignore parse errors, start fresh + } + } + + const updatedData = { + ...existingData, + claudeOAuth: credentials, + } + + fs.writeFileSync(credentialsPath, JSON.stringify(updatedData, null, 2)) +} + +/** + * Clear Claude OAuth credentials from the credentials file. + * Preserves other credentials. + */ +export const clearClaudeOAuthCredentials = ( + clientEnv: ClientEnv = env, +): void => { + const credentialsPath = getCredentialsPath(clientEnv) + if (!fs.existsSync(credentialsPath)) { + return + } + + try { + const existingData = JSON.parse(fs.readFileSync(credentialsPath, 'utf8')) + delete existingData.claudeOAuth + fs.writeFileSync(credentialsPath, JSON.stringify(existingData, null, 2)) + } catch { + // Ignore errors + } +} + +/** + * Check if Claude OAuth credentials are valid (not expired). + * Returns true if credentials exist and haven't expired. + */ +export const isClaudeOAuthValid = ( + clientEnv: ClientEnv = env, +): boolean => { + const credentials = getClaudeOAuthCredentials(clientEnv) + if (!credentials) { + return false + } + // Add 5 minute buffer before expiry + const bufferMs = 5 * 60 * 1000 + return credentials.expiresAt > Date.now() + bufferMs +} diff --git a/sdk/src/env.ts b/sdk/src/env.ts index 3e6855544..56d01040d 100644 --- a/sdk/src/env.ts +++ b/sdk/src/env.ts @@ -7,6 +7,7 @@ import { getBaseEnv } from '@codebuff/common/env-process' import { BYOK_OPENROUTER_ENV_VAR } from '@codebuff/common/constants/byok' +import { CLAUDE_OAUTH_TOKEN_ENV_VAR } from '@codebuff/common/constants/claude-oauth' import { API_KEY_ENV_VAR } from '@codebuff/common/old-constants' import type { SdkEnv } from './types/env' @@ -40,3 +41,11 @@ export const getSystemProcessEnv = (): NodeJS.ProcessEnv => { export const getByokOpenrouterApiKeyFromEnv = (): string | undefined => { return process.env[BYOK_OPENROUTER_ENV_VAR] } + +/** + * Get Claude OAuth token from environment variable. + * This allows users to provide their Claude Pro/Max OAuth token for direct Anthropic API access. + */ +export const getClaudeOAuthTokenFromEnv = (): string | undefined => { + return process.env[CLAUDE_OAUTH_TOKEN_ENV_VAR] +} diff --git a/sdk/src/impl/llm.ts b/sdk/src/impl/llm.ts index cfc982811..798b4bbc1 100644 --- a/sdk/src/impl/llm.ts +++ b/sdk/src/impl/llm.ts @@ -1,23 +1,13 @@ -import path from 'path' - import { checkLiveUserInput, getLiveUserInputIds, } from '@codebuff/agent-runtime/live-user-inputs' -import { getByokOpenrouterApiKeyFromEnv } from '../env' -import { - BYOK_OPENROUTER_HEADER, -} from '@codebuff/common/constants/byok' import { models, PROFIT_MARGIN } from '@codebuff/common/old-constants' import { buildArray } from '@codebuff/common/util/array' import { getErrorObject } from '@codebuff/common/util/error' import { convertCbToModelMessages } from '@codebuff/common/util/messages' import { isExplicitlyDefinedModel } from '@codebuff/common/util/model-utils' import { StopSequenceHandler } from '@codebuff/common/util/stop-sequence' -import { - OpenAICompatibleChatLanguageModel, - VERSION, -} from '@codebuff/internal/openai-compatible/index' import { streamText, generateText, @@ -29,8 +19,8 @@ import { TypeValidationError, } from 'ai' -import { WEBSITE_URL } from '../constants' -import type { LanguageModelV2 } from '@ai-sdk/provider' +import { getModelForRequest } from './model-provider' + import type { OpenRouterProviderRoutingOptions } from '@codebuff/common/types/agent-template' import type { PromptAiSdkFn, @@ -43,14 +33,6 @@ import type { JSONObject } from '@codebuff/common/types/json' import type { OpenRouterProviderOptions } from '@codebuff/internal/openrouter-ai-sdk' import type z from 'zod/v4' -// Forked from https://github.com/OpenRouterTeam/ai-sdk-provider/ -type OpenRouterUsageAccounting = { - cost: number | null - costDetails: { - upstreamInferenceCost: number | null - } -} - // Provider routing documentation: https://openrouter.ai/docs/features/provider-routing const providerOrder = { [models.openrouter_claude_sonnet_4]: [ @@ -121,74 +103,12 @@ function getProviderOptions(params: { } } -function getAiSdkModel(params: { - apiKey: string - model: string -}): LanguageModelV2 { - const { apiKey, model } = params - - const openrouterUsage: OpenRouterUsageAccounting = { - cost: null, - costDetails: { - upstreamInferenceCost: null, - }, +// Usage accounting type for OpenRouter/Codebuff backend responses +type OpenRouterUsageAccounting = { + cost: number | null + costDetails: { + upstreamInferenceCost: number | null } - - const openrouterApiKey = getByokOpenrouterApiKeyFromEnv() - const codebuffBackendModel = new OpenAICompatibleChatLanguageModel(model, { - provider: 'codebuff', - url: ({ path: endpoint }) => - new URL(path.join('/api/v1', endpoint), WEBSITE_URL).toString(), - headers: () => ({ - Authorization: `Bearer ${apiKey}`, - 'user-agent': `ai-sdk/openai-compatible/${VERSION}/codebuff`, - ...(openrouterApiKey && { [BYOK_OPENROUTER_HEADER]: openrouterApiKey }), - }), - metadataExtractor: { - extractMetadata: async ({ parsedBody }: { parsedBody: any }) => { - if (openrouterApiKey !== undefined) { - return { codebuff: { usage: openrouterUsage } } - } - - if (typeof parsedBody?.usage?.cost === 'number') { - openrouterUsage.cost = parsedBody.usage.cost - } - if ( - typeof parsedBody?.usage?.cost_details?.upstream_inference_cost === - 'number' - ) { - openrouterUsage.costDetails.upstreamInferenceCost = - parsedBody.usage.cost_details.upstream_inference_cost - } - return { codebuff: { usage: openrouterUsage } } - }, - createStreamExtractor: () => ({ - processChunk: (parsedChunk: any) => { - if (openrouterApiKey !== undefined) { - return - } - - if (typeof parsedChunk?.usage?.cost === 'number') { - openrouterUsage.cost = parsedChunk.usage.cost - } - if ( - typeof parsedChunk?.usage?.cost_details?.upstream_inference_cost === - 'number' - ) { - openrouterUsage.costDetails.upstreamInferenceCost = - parsedChunk.usage.cost_details.upstream_inference_cost - } - }, - buildMetadata: () => { - return { codebuff: { usage: openrouterUsage } } - }, - }), - }, - fetch: undefined, - includeUsage: undefined, - supportsStructuredOutputs: true, - }) - return codebuffBackendModel } export async function* promptAiSdkStream( @@ -212,7 +132,7 @@ export async function* promptAiSdkStream( return null } - let aiSDKModel = getAiSdkModel(params) + const { model: aiSDKModel, isClaudeOAuth } = getModelForRequest(params) const response = streamText({ ...params, @@ -455,27 +375,30 @@ export async function* promptAiSdkStream( } } - const providerMetadata = (await response.providerMetadata) ?? {} + const messageId = (await response.response).id - let costOverrideDollars: number | undefined - if (providerMetadata.codebuff) { - if (providerMetadata.codebuff.usage) { - const openrouterUsage = providerMetadata.codebuff - .usage as OpenRouterUsageAccounting + // Skip cost tracking for Claude OAuth (user is on their own subscription) + if (!isClaudeOAuth) { + const providerMetadata = (await response.providerMetadata) ?? {} - costOverrideDollars = - (openrouterUsage.cost ?? 0) + - (openrouterUsage.costDetails?.upstreamInferenceCost ?? 0) - } - } + let costOverrideDollars: number | undefined + if (providerMetadata.codebuff) { + if (providerMetadata.codebuff.usage) { + const openrouterUsage = providerMetadata.codebuff + .usage as OpenRouterUsageAccounting - const messageId = (await response.response).id + costOverrideDollars = + (openrouterUsage.cost ?? 0) + + (openrouterUsage.costDetails?.upstreamInferenceCost ?? 0) + } + } - // Call the cost callback if provided - if (params.onCostCalculated && costOverrideDollars) { - await params.onCostCalculated( - calculateUsedCredits({ costDollars: costOverrideDollars }), - ) + // Call the cost callback if provided + if (params.onCostCalculated && costOverrideDollars) { + await params.onCostCalculated( + calculateUsedCredits({ costDollars: costOverrideDollars }), + ) + } } return messageId @@ -498,7 +421,7 @@ export async function promptAiSdk( return '' } - let aiSDKModel = getAiSdkModel(params) + const { model: aiSDKModel, isClaudeOAuth } = getModelForRequest(params) const response = await generateText({ ...params, @@ -512,24 +435,27 @@ export async function promptAiSdk( }) const content = response.text - const providerMetadata = response.providerMetadata ?? {} - let costOverrideDollars: number | undefined - if (providerMetadata.codebuff) { - if (providerMetadata.codebuff.usage) { - const openrouterUsage = providerMetadata.codebuff - .usage as OpenRouterUsageAccounting - - costOverrideDollars = - (openrouterUsage.cost ?? 0) + - (openrouterUsage.costDetails?.upstreamInferenceCost ?? 0) + // Skip cost tracking for Claude OAuth (user is on their own subscription) + if (!isClaudeOAuth) { + const providerMetadata = response.providerMetadata ?? {} + let costOverrideDollars: number | undefined + if (providerMetadata.codebuff) { + if (providerMetadata.codebuff.usage) { + const openrouterUsage = providerMetadata.codebuff + .usage as OpenRouterUsageAccounting + + costOverrideDollars = + (openrouterUsage.cost ?? 0) + + (openrouterUsage.costDetails?.upstreamInferenceCost ?? 0) + } } - } - // Call the cost callback if provided - if (params.onCostCalculated && costOverrideDollars) { - await params.onCostCalculated( - calculateUsedCredits({ costDollars: costOverrideDollars }), - ) + // Call the cost callback if provided + if (params.onCostCalculated && costOverrideDollars) { + await params.onCostCalculated( + calculateUsedCredits({ costDollars: costOverrideDollars }), + ) + } } return content @@ -551,7 +477,7 @@ export async function promptAiSdkStructured( ) return {} as T } - let aiSDKModel = getAiSdkModel(params) + const { model: aiSDKModel, isClaudeOAuth } = getModelForRequest(params) const response = await generateObject, 'object'>({ ...params, @@ -567,24 +493,27 @@ export async function promptAiSdkStructured( const content = response.object - const providerMetadata = response.providerMetadata ?? {} - let costOverrideDollars: number | undefined - if (providerMetadata.codebuff) { - if (providerMetadata.codebuff.usage) { - const openrouterUsage = providerMetadata.codebuff - .usage as OpenRouterUsageAccounting - - costOverrideDollars = - (openrouterUsage.cost ?? 0) + - (openrouterUsage.costDetails?.upstreamInferenceCost ?? 0) + // Skip cost tracking for Claude OAuth (user is on their own subscription) + if (!isClaudeOAuth) { + const providerMetadata = response.providerMetadata ?? {} + let costOverrideDollars: number | undefined + if (providerMetadata.codebuff) { + if (providerMetadata.codebuff.usage) { + const openrouterUsage = providerMetadata.codebuff + .usage as OpenRouterUsageAccounting + + costOverrideDollars = + (openrouterUsage.cost ?? 0) + + (openrouterUsage.costDetails?.upstreamInferenceCost ?? 0) + } } - } - // Call the cost callback if provided - if (params.onCostCalculated && costOverrideDollars) { - await params.onCostCalculated( - calculateUsedCredits({ costDollars: costOverrideDollars }), - ) + // Call the cost callback if provided + if (params.onCostCalculated && costOverrideDollars) { + await params.onCostCalculated( + calculateUsedCredits({ costDollars: costOverrideDollars }), + ) + } } return content diff --git a/sdk/src/impl/model-provider.ts b/sdk/src/impl/model-provider.ts new file mode 100644 index 000000000..9f2f418fe --- /dev/null +++ b/sdk/src/impl/model-provider.ts @@ -0,0 +1,213 @@ +/** + * Model provider abstraction for routing requests to the appropriate LLM provider. + * + * This module handles: + * - Claude OAuth: Direct requests to Anthropic API using user's OAuth token + * - Default: Requests through Codebuff backend (which routes to OpenRouter) + */ + +import path from 'path' + +import { createAnthropic } from '@ai-sdk/anthropic' +import { BYOK_OPENROUTER_HEADER } from '@codebuff/common/constants/byok' +import { + isClaudeModel, + toAnthropicModelId, +} from '@codebuff/common/constants/claude-oauth' +import { + OpenAICompatibleChatLanguageModel, + VERSION, +} from '@codebuff/internal/openai-compatible/index' + +import { WEBSITE_URL } from '../constants' +import { getClaudeOAuthCredentials, isClaudeOAuthValid } from '../credentials' +import { getByokOpenrouterApiKeyFromEnv } from '../env' + +import type { LanguageModel } from 'ai' + +/** + * Parameters for requesting a model. + */ +export interface ModelRequestParams { + /** Codebuff API key for backend authentication */ + apiKey: string + /** Model ID (OpenRouter format, e.g., "anthropic/claude-sonnet-4") */ + model: string +} + +/** + * Result from getModelForRequest. + */ +export interface ModelResult { + /** The language model to use for requests */ + model: LanguageModel + /** Whether this model uses Claude OAuth direct (affects cost tracking) */ + isClaudeOAuth: boolean +} + +// Usage accounting type for OpenRouter/Codebuff backend responses +type OpenRouterUsageAccounting = { + cost: number | null + costDetails: { + upstreamInferenceCost: number | null + } +} + +/** + * Get the appropriate model for a request. + * + * If Claude OAuth credentials are available and the model is a Claude model, + * returns an Anthropic direct model. Otherwise, returns the Codebuff backend model. + */ +export function getModelForRequest(params: ModelRequestParams): ModelResult { + const { apiKey, model } = params + + // Check if we should use Claude OAuth direct + if (isClaudeModel(model) && isClaudeOAuthValid()) { + const claudeOAuthCredentials = getClaudeOAuthCredentials() + if (claudeOAuthCredentials) { + return { + model: createAnthropicOAuthModel(model, claudeOAuthCredentials.accessToken), + isClaudeOAuth: true, + } + } + } + + // Default: use Codebuff backend + return { + model: createCodebuffBackendModel(apiKey, model), + isClaudeOAuth: false, + } +} + +/** + * Create an Anthropic model that uses OAuth Bearer token authentication. + */ +function createAnthropicOAuthModel( + model: string, + oauthToken: string, +): LanguageModel { + // Convert OpenRouter model ID to Anthropic model ID + const anthropicModelId = toAnthropicModelId(model) + + // Create Anthropic provider with custom fetch to use Bearer token auth + // Custom fetch to handle OAuth Bearer token authentication + const customFetch = async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + const headers = new Headers(init?.headers) + + // Remove the x-api-key header that the SDK adds + headers.delete('x-api-key') + + // Add Bearer token authentication (for OAuth) + headers.set('Authorization', `Bearer ${oauthToken}`) + + // Add required beta headers for OAuth (same as opencode) + const existingBeta = headers.get('anthropic-beta') ?? '' + const betaList = existingBeta.split(',').map((b) => b.trim()).filter(Boolean) + const mergedBetas = [ + ...new Set([ + 'oauth-2025-04-20', + 'claude-code-20250219', + 'interleaved-thinking-2025-05-14', + 'fine-grained-tool-streaming-2025-05-14', + ...betaList, + ]), + ].join(',') + headers.set('anthropic-beta', mergedBetas) + + // Add Claude Code identification headers + headers.set('anthropic-dangerous-direct-browser-access', 'true') + headers.set('x-app', 'cli') + + return globalThis.fetch(input, { + ...init, + headers, + }) + } + + // Pass empty apiKey like opencode does - this prevents the SDK from adding x-api-key header + // The custom fetch will add the Bearer token instead + const anthropic = createAnthropic({ + apiKey: '', + fetch: customFetch as unknown as typeof globalThis.fetch, + }) + + // Cast to LanguageModel since the AI SDK types may be slightly different versions + // Using unknown as intermediate to handle V2 vs V3 differences + return anthropic(anthropicModelId) as unknown as LanguageModel +} + +/** + * Create a model that routes through the Codebuff backend. + * This is the existing behavior - requests go to Codebuff backend which forwards to OpenRouter. + */ +function createCodebuffBackendModel( + apiKey: string, + model: string, +): LanguageModel { + const openrouterUsage: OpenRouterUsageAccounting = { + cost: null, + costDetails: { + upstreamInferenceCost: null, + }, + } + + const openrouterApiKey = getByokOpenrouterApiKeyFromEnv() + + return new OpenAICompatibleChatLanguageModel(model, { + provider: 'codebuff', + url: ({ path: endpoint }) => + new URL(path.join('/api/v1', endpoint), WEBSITE_URL).toString(), + headers: () => ({ + Authorization: `Bearer ${apiKey}`, + 'user-agent': `ai-sdk/openai-compatible/${VERSION}/codebuff`, + ...(openrouterApiKey && { [BYOK_OPENROUTER_HEADER]: openrouterApiKey }), + }), + metadataExtractor: { + extractMetadata: async ({ parsedBody }: { parsedBody: any }) => { + if (openrouterApiKey !== undefined) { + return { codebuff: { usage: openrouterUsage } } + } + + if (typeof parsedBody?.usage?.cost === 'number') { + openrouterUsage.cost = parsedBody.usage.cost + } + if ( + typeof parsedBody?.usage?.cost_details?.upstream_inference_cost === + 'number' + ) { + openrouterUsage.costDetails.upstreamInferenceCost = + parsedBody.usage.cost_details.upstream_inference_cost + } + return { codebuff: { usage: openrouterUsage } } + }, + createStreamExtractor: () => ({ + processChunk: (parsedChunk: any) => { + if (openrouterApiKey !== undefined) { + return + } + + if (typeof parsedChunk?.usage?.cost === 'number') { + openrouterUsage.cost = parsedChunk.usage.cost + } + if ( + typeof parsedChunk?.usage?.cost_details?.upstream_inference_cost === + 'number' + ) { + openrouterUsage.costDetails.upstreamInferenceCost = + parsedChunk.usage.cost_details.upstream_inference_cost + } + }, + buildMetadata: () => { + return { codebuff: { usage: openrouterUsage } } + }, + }), + }, + fetch: undefined, + includeUsage: undefined, + supportsStructuredOutputs: true, + }) +} From 36a1940bf5f9417707413f4159b12dadf0d5711c Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 27 Dec 2025 15:43:26 -0800 Subject: [PATCH 02/20] Remvove some headers --- sdk/src/impl/model-provider.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/sdk/src/impl/model-provider.ts b/sdk/src/impl/model-provider.ts index 9f2f418fe..9bea90d91 100644 --- a/sdk/src/impl/model-provider.ts +++ b/sdk/src/impl/model-provider.ts @@ -105,6 +105,7 @@ function createAnthropicOAuthModel( headers.set('Authorization', `Bearer ${oauthToken}`) // Add required beta headers for OAuth (same as opencode) + // These beta headers are required to access Claude 4+ models with OAuth const existingBeta = headers.get('anthropic-beta') ?? '' const betaList = existingBeta.split(',').map((b) => b.trim()).filter(Boolean) const mergedBetas = [ @@ -118,9 +119,10 @@ function createAnthropicOAuthModel( ].join(',') headers.set('anthropic-beta', mergedBetas) - // Add Claude Code identification headers - headers.set('anthropic-dangerous-direct-browser-access', 'true') - headers.set('x-app', 'cli') + // Note: opencode does NOT include these headers, so we remove them: + // - anthropic-dangerous-direct-browser-access + // - x-app + // Only the oauth beta headers and authorization are needed return globalThis.fetch(input, { ...init, From 5aaeceff97efd70b1dc30f99e530720d6bd43acb Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 27 Dec 2025 16:18:30 -0800 Subject: [PATCH 03/20] Inject system prompt --- common/src/constants/claude-oauth.ts | 11 ++++- sdk/src/impl/model-provider.ts | 63 ++++++++++++++++++++++++---- 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/common/src/constants/claude-oauth.ts b/common/src/constants/claude-oauth.ts index e57a09991..39f81a2a3 100644 --- a/common/src/constants/claude-oauth.ts +++ b/common/src/constants/claude-oauth.ts @@ -19,6 +19,13 @@ export const CLAUDE_OAUTH_TOKEN_ENV_VAR = 'CODEBUFF_CLAUDE_OAUTH_TOKEN' // Required Anthropic API version header export const ANTHROPIC_API_VERSION = '2023-06-01' +/** + * System prompt prefix required by Anthropic to allow OAuth access to Claude 4+ models. + * This must be prepended to the system prompt when using Claude OAuth with Claude 4+ models. + * Without this prefix, requests will fail with "This credential is only authorized for use with Claude Code". + */ +export const CLAUDE_CODE_SYSTEM_PROMPT_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude." + /** * Model ID mapping from OpenRouter format to Anthropic format. * OpenRouter uses prefixed IDs like "anthropic/claude-sonnet-4", @@ -55,8 +62,8 @@ export const OPENROUTER_TO_ANTHROPIC_MODEL_MAP: Record = { } /** - * Models that are known to work with third-party OAuth. - * Claude 4.x models are restricted to Claude Code only. + * Models that work with third-party OAuth without requiring the Claude Code system prompt prefix. + * Claude 4.x models require the CLAUDE_CODE_SYSTEM_PROMPT_PREFIX to be prepended to the system prompt. */ export const OAUTH_COMPATIBLE_MODELS = new Set([ 'claude-3-5-haiku-20241022', diff --git a/sdk/src/impl/model-provider.ts b/sdk/src/impl/model-provider.ts index 9bea90d91..1aa7a0cc7 100644 --- a/sdk/src/impl/model-provider.ts +++ b/sdk/src/impl/model-provider.ts @@ -11,6 +11,7 @@ import path from 'path' import { createAnthropic } from '@ai-sdk/anthropic' import { BYOK_OPENROUTER_HEADER } from '@codebuff/common/constants/byok' import { + CLAUDE_CODE_SYSTEM_PROMPT_PREFIX, isClaudeModel, toAnthropicModelId, } from '@codebuff/common/constants/claude-oauth' @@ -67,7 +68,10 @@ export function getModelForRequest(params: ModelRequestParams): ModelResult { const claudeOAuthCredentials = getClaudeOAuthCredentials() if (claudeOAuthCredentials) { return { - model: createAnthropicOAuthModel(model, claudeOAuthCredentials.accessToken), + model: createAnthropicOAuthModel( + model, + claudeOAuthCredentials.accessToken, + ), isClaudeOAuth: true, } } @@ -91,7 +95,7 @@ function createAnthropicOAuthModel( const anthropicModelId = toAnthropicModelId(model) // Create Anthropic provider with custom fetch to use Bearer token auth - // Custom fetch to handle OAuth Bearer token authentication + // Custom fetch to handle OAuth Bearer token authentication and system prompt transformation const customFetch = async ( input: RequestInfo | URL, init?: RequestInit, @@ -107,7 +111,10 @@ function createAnthropicOAuthModel( // Add required beta headers for OAuth (same as opencode) // These beta headers are required to access Claude 4+ models with OAuth const existingBeta = headers.get('anthropic-beta') ?? '' - const betaList = existingBeta.split(',').map((b) => b.trim()).filter(Boolean) + const betaList = existingBeta + .split(',') + .map((b) => b.trim()) + .filter(Boolean) const mergedBetas = [ ...new Set([ 'oauth-2025-04-20', @@ -119,13 +126,53 @@ function createAnthropicOAuthModel( ].join(',') headers.set('anthropic-beta', mergedBetas) - // Note: opencode does NOT include these headers, so we remove them: - // - anthropic-dangerous-direct-browser-access - // - x-app - // Only the oauth beta headers and authorization are needed + // Transform the request body to use the correct system prompt format for Claude OAuth + // Anthropic requires the system prompt to be split into two separate blocks: + // 1. First block: Claude Code identifier (required for OAuth access) + // 2. Second block: The actual system prompt (if any) + let modifiedInit = init + if (init?.body && typeof init.body === 'string') { + try { + const body = JSON.parse(init.body) + // Always inject the Claude Code identifier for OAuth requests + // Extract existing system prompt if present + const existingSystem = body.system + ? Array.isArray(body.system) + ? body.system + .map( + (s: { text?: string; content?: string }) => + s.text ?? s.content ?? '', + ) + .join('\n\n') + : typeof body.system === 'string' + ? body.system + : '' + : '' + + // Build the system array with Claude Code identifier first + body.system = [ + { + type: 'text', + text: CLAUDE_CODE_SYSTEM_PROMPT_PREFIX, + }, + // Only add second block if there's actual content + ...(existingSystem + ? [ + { + type: 'text', + text: existingSystem, + }, + ] + : []), + ] + modifiedInit = { ...init, body: JSON.stringify(body) } + } catch { + // If parsing fails, continue with original body + } + } return globalThis.fetch(input, { - ...init, + ...modifiedInit, headers, }) } From 500cadaba87cf65bb107b76e3e4c027ef2fa51e1 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 27 Dec 2025 16:20:02 -0800 Subject: [PATCH 04/20] small cleanup --- common/src/constants/claude-oauth.ts | 28 ++-------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/common/src/constants/claude-oauth.ts b/common/src/constants/claude-oauth.ts index 39f81a2a3..77373ca0a 100644 --- a/common/src/constants/claude-oauth.ts +++ b/common/src/constants/claude-oauth.ts @@ -30,13 +30,9 @@ export const CLAUDE_CODE_SYSTEM_PROMPT_PREFIX = "You are Claude Code, Anthropic' * Model ID mapping from OpenRouter format to Anthropic format. * OpenRouter uses prefixed IDs like "anthropic/claude-sonnet-4", * while Anthropic uses versioned IDs like "claude-3-5-haiku-20241022". - * - * IMPORTANT: Claude 4.x models (Sonnet 4, Opus 4, etc.) are restricted by Anthropic - * to only work with the official Claude Code CLI. Third-party OAuth only works with - * Claude 3.x models (Haiku 3.5, etc.). */ export const OPENROUTER_TO_ANTHROPIC_MODEL_MAP: Record = { - // Claude 3.x models - WORK with third-party OAuth + // Claude 3.x models 'anthropic/claude-3.5-haiku-20241022': 'claude-3-5-haiku-20241022', 'anthropic/claude-3.5-haiku': 'claude-3-5-haiku-20241022', 'anthropic/claude-3-5-haiku': 'claude-3-5-haiku-20241022', @@ -47,8 +43,7 @@ export const OPENROUTER_TO_ANTHROPIC_MODEL_MAP: Record = { 'claude-3-haiku': 'claude-3-haiku-20240307', 'claude-3-opus': 'claude-3-opus-20240229', - // Claude 4.x models - RESTRICTED to Claude Code only (will fail with OAuth) - // Keeping these mappings for future compatibility if Anthropic lifts restrictions + // Claude 4.x models 'anthropic/claude-sonnet-4.5': 'claude-sonnet-4-5-20250929', 'anthropic/claude-sonnet-4': 'claude-sonnet-4-20250514', 'anthropic/claude-opus-4.5': 'claude-opus-4-5-20251101', @@ -61,25 +56,6 @@ export const OPENROUTER_TO_ANTHROPIC_MODEL_MAP: Record = { 'claude-opus-4': 'claude-opus-4-1-20250805', } -/** - * Models that work with third-party OAuth without requiring the Claude Code system prompt prefix. - * Claude 4.x models require the CLAUDE_CODE_SYSTEM_PROMPT_PREFIX to be prepended to the system prompt. - */ -export const OAUTH_COMPATIBLE_MODELS = new Set([ - 'claude-3-5-haiku-20241022', - 'claude-3-haiku-20240307', - 'claude-3-opus-20240229', -]) - -/** - * Check if a model is compatible with third-party OAuth. - * Returns false for Claude 4.x models which are restricted to Claude Code. - */ -export function isOAuthCompatibleModel(model: string): boolean { - const anthropicModelId = toAnthropicModelId(model) - return OAUTH_COMPATIBLE_MODELS.has(anthropicModelId) -} - /** * Check if a model is a Claude/Anthropic model that can use OAuth. */ From 9ba28ea613e9cf2893e26909142fca19ba39cf2d Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 5 Jan 2026 11:50:11 -0800 Subject: [PATCH 05/20] Fallback to codebuff when claude code subscription rate limits --- cli/src/utils/auth.ts | 9 ++ sdk/src/impl/llm.ts | 162 ++++++++++++++++++++++++--------- sdk/src/impl/model-provider.ts | 6 +- 3 files changed, 133 insertions(+), 44 deletions(-) diff --git a/cli/src/utils/auth.ts b/cli/src/utils/auth.ts index 04c98e73b..502aa7ecf 100644 --- a/cli/src/utils/auth.ts +++ b/cli/src/utils/auth.ts @@ -24,9 +24,18 @@ const userSchema = z.object({ export type User = z.infer +// Claude OAuth credentials schema (for passthrough, not strict validation here) +const claudeOAuthSchema = z.object({ + accessToken: z.string(), + refreshToken: z.string(), + expiresAt: z.number(), + connectedAt: z.number(), +}).optional() + const credentialsSchema = z .object({ default: userSchema, + claudeOAuth: claudeOAuthSchema, }) .catchall(userSchema) diff --git a/sdk/src/impl/llm.ts b/sdk/src/impl/llm.ts index a2a4324f7..5d4573ad1 100644 --- a/sdk/src/impl/llm.ts +++ b/sdk/src/impl/llm.ts @@ -21,6 +21,7 @@ import { import { getModelForRequest } from './model-provider' +import type { ModelRequestParams } from './model-provider' import type { OpenRouterProviderRoutingOptions } from '@codebuff/common/types/agent-template' import type { PromptAiSdkFn, @@ -110,8 +111,43 @@ type OpenRouterUsageAccounting = { } } +/** + * Check if an error is a Claude OAuth rate limit error that should trigger fallback. + */ +function isClaudeOAuthRateLimitError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + + // Check for APICallError from AI SDK + const err = error as { + statusCode?: number + message?: string + responseBody?: string + } + + // Check status code + if (err.statusCode === 429) return true + + // Check error message for rate limit indicators + const message = (err.message || '').toLowerCase() + const responseBody = (err.responseBody || '').toLowerCase() + + if (message.includes('rate_limit') || message.includes('rate limit')) + return true + if (message.includes('overloaded')) return true + if ( + responseBody.includes('rate_limit') || + responseBody.includes('overloaded') + ) + return true + + return false +} + export async function* promptAiSdkStream( - params: ParamsOf, + params: ParamsOf & { + skipClaudeOAuth?: boolean + onClaudeOAuthStatusChange?: (isActive: boolean) => void + }, ): ReturnType { const { logger } = params const agentChunkMetadata = @@ -128,7 +164,17 @@ export async function* promptAiSdkStream( return null } - const { model: aiSDKModel, isClaudeOAuth } = getModelForRequest(params) + const modelParams: ModelRequestParams = { + apiKey: params.apiKey, + model: params.model, + skipClaudeOAuth: params.skipClaudeOAuth, + } + const { model: aiSDKModel, isClaudeOAuth } = getModelForRequest(modelParams) + + // Notify about Claude OAuth usage + if (isClaudeOAuth && params.onClaudeOAuthStatusChange) { + params.onClaudeOAuthStatusChange(true) + } const response = streamText({ ...params, @@ -255,10 +301,14 @@ export async function* promptAiSdkStream( let content = '' const stopSequenceHandler = new StopSequenceHandler(params.stopSequences) + // Track if we've yielded any content - if so, we can't safely fall back + let hasYieldedContent = false + for await (const chunkValue of response.fullStream) { if (chunkValue.type !== 'text-delta') { const flushed = stopSequenceHandler.flush() if (flushed) { + hasYieldedContent = true content += flushed yield { type: 'text', @@ -268,8 +318,8 @@ export async function* promptAiSdkStream( } } if (chunkValue.type === 'error') { - // Error chunks from fullStream are non-network errors (tool failures, model issues, etc.) - // Network errors are thrown, not yielded as chunks. + // Error chunks from fullStream are non-network errors (tool failures, model issues, rate limits, etc.) + // Network errors which cannot be recovered from are thrown, not yielded as chunks. const errorBody = APICallError.isInstance(chunkValue.error) ? chunkValue.error.responseBody @@ -305,6 +355,28 @@ export async function* promptAiSdkStream( continue } + // Check if this is a Claude OAuth rate limit error - only fall back if no content yielded yet + if ( + isClaudeOAuth && + !params.skipClaudeOAuth && + !hasYieldedContent && + isClaudeOAuthRateLimitError(chunkValue.error) + ) { + logger.info( + { error: getErrorObject(chunkValue.error) }, + 'Claude OAuth rate limited during stream, falling back to Codebuff backend', + ) + if (params.onClaudeOAuthStatusChange) { + params.onClaudeOAuthStatusChange(false) + } + // Retry with Codebuff backend + const fallbackResult = yield* promptAiSdkStream({ + ...params, + skipClaudeOAuth: true, + }) + return fallbackResult + } + logger.error( { chunk: { ...chunkValue, error: undefined }, @@ -338,6 +410,7 @@ export async function* promptAiSdkStream( if (!params.stopSequences) { content += chunkValue.text if (chunkValue.text) { + hasYieldedContent = true yield { type: 'text', text: chunkValue.text, @@ -349,6 +422,7 @@ export async function* promptAiSdkStream( const stopSequenceResult = stopSequenceHandler.process(chunkValue.text) if (stopSequenceResult.text) { + hasYieldedContent = true content += stopSequenceResult.text yield { type: 'text', @@ -416,7 +490,12 @@ export async function promptAiSdk( return '' } - const { model: aiSDKModel, isClaudeOAuth } = getModelForRequest(params) + const modelParams: ModelRequestParams = { + apiKey: params.apiKey, + model: params.model, + skipClaudeOAuth: true, // Always use Codebuff backend for non-streaming + } + const { model: aiSDKModel } = getModelForRequest(modelParams) const response = await generateText({ ...params, @@ -430,27 +509,24 @@ export async function promptAiSdk( }) const content = response.text - // Skip cost tracking for Claude OAuth (user is on their own subscription) - if (!isClaudeOAuth) { - const providerMetadata = response.providerMetadata ?? {} - let costOverrideDollars: number | undefined - if (providerMetadata.codebuff) { - if (providerMetadata.codebuff.usage) { - const openrouterUsage = providerMetadata.codebuff - .usage as OpenRouterUsageAccounting + const providerMetadata = response.providerMetadata ?? {} + let costOverrideDollars: number | undefined + if (providerMetadata.codebuff) { + if (providerMetadata.codebuff.usage) { + const openrouterUsage = providerMetadata.codebuff + .usage as OpenRouterUsageAccounting - costOverrideDollars = - (openrouterUsage.cost ?? 0) + - (openrouterUsage.costDetails?.upstreamInferenceCost ?? 0) - } + costOverrideDollars = + (openrouterUsage.cost ?? 0) + + (openrouterUsage.costDetails?.upstreamInferenceCost ?? 0) } + } - // Call the cost callback if provided - if (params.onCostCalculated && costOverrideDollars) { - await params.onCostCalculated( - calculateUsedCredits({ costDollars: costOverrideDollars }), - ) - } + // Call the cost callback if provided + if (params.onCostCalculated && costOverrideDollars) { + await params.onCostCalculated( + calculateUsedCredits({ costDollars: costOverrideDollars }), + ) } return content @@ -471,7 +547,12 @@ export async function promptAiSdkStructured( ) return {} as T } - const { model: aiSDKModel, isClaudeOAuth } = getModelForRequest(params) + const modelParams: ModelRequestParams = { + apiKey: params.apiKey, + model: params.model, + skipClaudeOAuth: true, // Always use Codebuff backend for non-streaming + } + const { model: aiSDKModel } = getModelForRequest(modelParams) const response = await generateObject, 'object'>({ ...params, @@ -487,27 +568,24 @@ export async function promptAiSdkStructured( const content = response.object - // Skip cost tracking for Claude OAuth (user is on their own subscription) - if (!isClaudeOAuth) { - const providerMetadata = response.providerMetadata ?? {} - let costOverrideDollars: number | undefined - if (providerMetadata.codebuff) { - if (providerMetadata.codebuff.usage) { - const openrouterUsage = providerMetadata.codebuff - .usage as OpenRouterUsageAccounting + const providerMetadata = response.providerMetadata ?? {} + let costOverrideDollars: number | undefined + if (providerMetadata.codebuff) { + if (providerMetadata.codebuff.usage) { + const openrouterUsage = providerMetadata.codebuff + .usage as OpenRouterUsageAccounting - costOverrideDollars = - (openrouterUsage.cost ?? 0) + - (openrouterUsage.costDetails?.upstreamInferenceCost ?? 0) - } + costOverrideDollars = + (openrouterUsage.cost ?? 0) + + (openrouterUsage.costDetails?.upstreamInferenceCost ?? 0) } + } - // Call the cost callback if provided - if (params.onCostCalculated && costOverrideDollars) { - await params.onCostCalculated( - calculateUsedCredits({ costDollars: costOverrideDollars }), - ) - } + // Call the cost callback if provided + if (params.onCostCalculated && costOverrideDollars) { + await params.onCostCalculated( + calculateUsedCredits({ costDollars: costOverrideDollars }), + ) } return content diff --git a/sdk/src/impl/model-provider.ts b/sdk/src/impl/model-provider.ts index 1aa7a0cc7..f74e5dfb6 100644 --- a/sdk/src/impl/model-provider.ts +++ b/sdk/src/impl/model-provider.ts @@ -34,6 +34,8 @@ export interface ModelRequestParams { apiKey: string /** Model ID (OpenRouter format, e.g., "anthropic/claude-sonnet-4") */ model: string + /** If true, skip Claude OAuth and use Codebuff backend (for fallback after rate limit) */ + skipClaudeOAuth?: boolean } /** @@ -61,10 +63,10 @@ type OpenRouterUsageAccounting = { * returns an Anthropic direct model. Otherwise, returns the Codebuff backend model. */ export function getModelForRequest(params: ModelRequestParams): ModelResult { - const { apiKey, model } = params + const { apiKey, model, skipClaudeOAuth } = params // Check if we should use Claude OAuth direct - if (isClaudeModel(model) && isClaudeOAuthValid()) { + if (!skipClaudeOAuth && isClaudeModel(model) && isClaudeOAuthValid()) { const claudeOAuthCredentials = getClaudeOAuthCredentials() if (claudeOAuthCredentials) { return { From e029967f29ec2ff521bd1547f9ac94628545d903 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 5 Jan 2026 12:07:41 -0800 Subject: [PATCH 06/20] Refresh claude oauth token as needed --- cli/src/utils/claude-oauth.ts | 71 +++++------------------ sdk/src/credentials.ts | 101 +++++++++++++++++++++++++++++++++ sdk/src/impl/llm.ts | 65 ++++++++++++++++++++- sdk/src/impl/model-provider.ts | 11 ++-- 4 files changed, 183 insertions(+), 65 deletions(-) diff --git a/cli/src/utils/claude-oauth.ts b/cli/src/utils/claude-oauth.ts index 4aa9a72ca..1025511ba 100644 --- a/cli/src/utils/claude-oauth.ts +++ b/cli/src/utils/claude-oauth.ts @@ -4,11 +4,7 @@ import crypto from 'crypto' import open from 'open' -import { - CLAUDE_OAUTH_CLIENT_ID, - CLAUDE_OAUTH_AUTHORIZE_URL, - CLAUDE_OAUTH_TOKEN_URL, -} from '@codebuff/common/constants/claude-oauth' +import { CLAUDE_OAUTH_CLIENT_ID } from '@codebuff/common/constants/claude-oauth' import { saveClaudeOAuthCredentials, clearClaudeOAuthCredentials, @@ -51,7 +47,7 @@ let pendingState: string | null = null export function startOAuthFlow(): { codeVerifier: string; authUrl: string } { const codeVerifier = generateCodeVerifier() const codeChallenge = generateCodeChallenge(codeVerifier) - + // Generate a random state parameter for CSRF protection const state = crypto.randomBytes(16).toString('hex') @@ -65,8 +61,14 @@ export function startOAuthFlow(): { codeVerifier: string; authUrl: string } { authUrl.searchParams.set('code', 'true') authUrl.searchParams.set('client_id', CLAUDE_OAUTH_CLIENT_ID) authUrl.searchParams.set('response_type', 'code') - authUrl.searchParams.set('redirect_uri', 'https://console.anthropic.com/oauth/code/callback') - authUrl.searchParams.set('scope', 'org:create_api_key user:profile user:inference') + authUrl.searchParams.set( + 'redirect_uri', + 'https://console.anthropic.com/oauth/code/callback', + ) + authUrl.searchParams.set( + 'scope', + 'org:create_api_key user:profile user:inference', + ) authUrl.searchParams.set('code_challenge', codeChallenge) authUrl.searchParams.set('code_challenge_method', 'S256') authUrl.searchParams.set('state', codeVerifier) // opencode uses verifier as state @@ -92,7 +94,9 @@ export async function exchangeCodeForTokens( ): Promise { const verifier = codeVerifier ?? pendingCodeVerifier if (!verifier) { - throw new Error('No code verifier found. Please start the OAuth flow again.') + throw new Error( + 'No code verifier found. Please start the OAuth flow again.', + ) } // The authorization code from claude.ai comes in format: code#state @@ -140,55 +144,6 @@ export async function exchangeCodeForTokens( return credentials } -/** - * Refresh the access token using the refresh token. - */ -export async function refreshAccessToken(): Promise { - const credentials = getClaudeOAuthCredentials() - if (!credentials?.refreshToken) { - return null - } - - try { - // Use the v1 OAuth token endpoint (same as opencode) - const response = await fetch('https://console.anthropic.com/v1/oauth/token', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - grant_type: 'refresh_token', - refresh_token: credentials.refreshToken, - client_id: CLAUDE_OAUTH_CLIENT_ID, - }), - }) - - if (!response.ok) { - // Refresh failed, clear credentials - clearClaudeOAuthCredentials() - return null - } - - const data = await response.json() - - const newCredentials: ClaudeOAuthCredentials = { - accessToken: data.access_token, - refreshToken: data.refresh_token ?? credentials.refreshToken, - expiresAt: Date.now() + data.expires_in * 1000, - connectedAt: credentials.connectedAt, - } - - // Save updated credentials - saveClaudeOAuthCredentials(newCredentials) - - return newCredentials - } catch { - // Refresh failed, clear credentials - clearClaudeOAuthCredentials() - return null - } -} - /** * Disconnect from Claude OAuth (clear credentials). */ diff --git a/sdk/src/credentials.ts b/sdk/src/credentials.ts index 6aced7d1c..c6266e610 100644 --- a/sdk/src/credentials.ts +++ b/sdk/src/credentials.ts @@ -3,6 +3,7 @@ import path from 'node:path' import os from 'os' import { env } from '@codebuff/common/env' +import { CLAUDE_OAUTH_CLIENT_ID } from '@codebuff/common/constants/claude-oauth' import { userSchema } from '@codebuff/common/util/credentials' import { z } from 'zod/v4' @@ -211,3 +212,103 @@ export const isClaudeOAuthValid = ( const bufferMs = 5 * 60 * 1000 return credentials.expiresAt > Date.now() + bufferMs } + +// Mutex to prevent concurrent refresh attempts +let refreshPromise: Promise | null = null + +/** + * Refresh the Claude OAuth access token using the refresh token. + * Returns the new credentials if successful, null if refresh fails. + * Uses a mutex to prevent concurrent refresh attempts. + */ +export const refreshClaudeOAuthToken = async ( + clientEnv: ClientEnv = env, +): Promise => { + // If a refresh is already in progress, wait for it + if (refreshPromise) { + return refreshPromise + } + + const credentials = getClaudeOAuthCredentials(clientEnv) + if (!credentials?.refreshToken) { + return null + } + + // Start the refresh and store the promise + refreshPromise = (async () => { + try { + const response = await fetch('https://console.anthropic.com/v1/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: credentials.refreshToken, + client_id: CLAUDE_OAUTH_CLIENT_ID, + }), + }) + + if (!response.ok) { + // Refresh failed, clear credentials + clearClaudeOAuthCredentials(clientEnv) + return null + } + + const data = await response.json() + + const newCredentials: ClaudeOAuthCredentials = { + accessToken: data.access_token, + refreshToken: data.refresh_token ?? credentials.refreshToken, + expiresAt: Date.now() + data.expires_in * 1000, + connectedAt: credentials.connectedAt, + } + + // Save updated credentials + saveClaudeOAuthCredentials(newCredentials, clientEnv) + + return newCredentials + } catch { + // Refresh failed, clear credentials + clearClaudeOAuthCredentials(clientEnv) + return null + } finally { + // Clear the mutex after completion + refreshPromise = null + } + })() + + return refreshPromise +} + +/** + * Get valid Claude OAuth credentials, refreshing if necessary. + * This is the main function to use when you need credentials for an API call. + * + * - Returns credentials immediately if valid (>5 min until expiry) + * - Attempts refresh if token is expired or near-expiry + * - Returns null if no credentials or refresh fails + */ +export const getValidClaudeOAuthCredentials = async ( + clientEnv: ClientEnv = env, +): Promise => { + const credentials = getClaudeOAuthCredentials(clientEnv) + if (!credentials) { + return null + } + + // Check if token is from environment variable (synthetic credentials, no refresh needed) + if (!credentials.refreshToken) { + // Environment variable tokens are assumed valid + return credentials + } + + // Check if token is valid with 5 minute buffer + const bufferMs = 5 * 60 * 1000 + if (credentials.expiresAt > Date.now() + bufferMs) { + return credentials + } + + // Token is expired or expiring soon, try to refresh + return refreshClaudeOAuthToken(clientEnv) +} diff --git a/sdk/src/impl/llm.ts b/sdk/src/impl/llm.ts index 5d4573ad1..1cc3bdd6e 100644 --- a/sdk/src/impl/llm.ts +++ b/sdk/src/impl/llm.ts @@ -143,6 +143,43 @@ function isClaudeOAuthRateLimitError(error: unknown): boolean { return false } +/** + * Check if an error is a Claude OAuth authentication error (expired/invalid token). + * This indicates we should try refreshing the token. + */ +function isClaudeOAuthAuthError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + + const err = error as { + statusCode?: number + message?: string + responseBody?: string + } + + // 401 Unauthorized or 403 Forbidden typically indicate auth issues + if (err.statusCode === 401 || err.statusCode === 403) return true + + const message = (err.message || '').toLowerCase() + const responseBody = (err.responseBody || '').toLowerCase() + + if (message.includes('unauthorized') || message.includes('invalid_token')) + return true + if (message.includes('authentication') || message.includes('expired')) + return true + if ( + responseBody.includes('unauthorized') || + responseBody.includes('invalid_token') + ) + return true + if ( + responseBody.includes('authentication') || + responseBody.includes('expired') + ) + return true + + return false +} + export async function* promptAiSdkStream( params: ParamsOf & { skipClaudeOAuth?: boolean @@ -169,7 +206,7 @@ export async function* promptAiSdkStream( model: params.model, skipClaudeOAuth: params.skipClaudeOAuth, } - const { model: aiSDKModel, isClaudeOAuth } = getModelForRequest(modelParams) + const { model: aiSDKModel, isClaudeOAuth } = await getModelForRequest(modelParams) // Notify about Claude OAuth usage if (isClaudeOAuth && params.onClaudeOAuthStatusChange) { @@ -377,6 +414,28 @@ export async function* promptAiSdkStream( return fallbackResult } + // Check if this is a Claude OAuth authentication error (expired token) - only fall back if no content yielded yet + if ( + isClaudeOAuth && + !params.skipClaudeOAuth && + !hasYieldedContent && + isClaudeOAuthAuthError(chunkValue.error) + ) { + logger.info( + { error: getErrorObject(chunkValue.error) }, + 'Claude OAuth auth error during stream, falling back to Codebuff backend', + ) + if (params.onClaudeOAuthStatusChange) { + params.onClaudeOAuthStatusChange(false) + } + // Retry with Codebuff backend (skipClaudeOAuth will bypass the failed OAuth) + const fallbackResult = yield* promptAiSdkStream({ + ...params, + skipClaudeOAuth: true, + }) + return fallbackResult + } + logger.error( { chunk: { ...chunkValue, error: undefined }, @@ -495,7 +554,7 @@ export async function promptAiSdk( model: params.model, skipClaudeOAuth: true, // Always use Codebuff backend for non-streaming } - const { model: aiSDKModel } = getModelForRequest(modelParams) + const { model: aiSDKModel } = await getModelForRequest(modelParams) const response = await generateText({ ...params, @@ -552,7 +611,7 @@ export async function promptAiSdkStructured( model: params.model, skipClaudeOAuth: true, // Always use Codebuff backend for non-streaming } - const { model: aiSDKModel } = getModelForRequest(modelParams) + const { model: aiSDKModel } = await getModelForRequest(modelParams) const response = await generateObject, 'object'>({ ...params, diff --git a/sdk/src/impl/model-provider.ts b/sdk/src/impl/model-provider.ts index f74e5dfb6..cd5a3d100 100644 --- a/sdk/src/impl/model-provider.ts +++ b/sdk/src/impl/model-provider.ts @@ -21,7 +21,7 @@ import { } from '@codebuff/internal/openai-compatible/index' import { WEBSITE_URL } from '../constants' -import { getClaudeOAuthCredentials, isClaudeOAuthValid } from '../credentials' +import { getValidClaudeOAuthCredentials } from '../credentials' import { getByokOpenrouterApiKeyFromEnv } from '../env' import type { LanguageModel } from 'ai' @@ -61,13 +61,16 @@ type OpenRouterUsageAccounting = { * * If Claude OAuth credentials are available and the model is a Claude model, * returns an Anthropic direct model. Otherwise, returns the Codebuff backend model. + * + * This function is async because it may need to refresh the OAuth token. */ -export function getModelForRequest(params: ModelRequestParams): ModelResult { +export async function getModelForRequest(params: ModelRequestParams): Promise { const { apiKey, model, skipClaudeOAuth } = params // Check if we should use Claude OAuth direct - if (!skipClaudeOAuth && isClaudeModel(model) && isClaudeOAuthValid()) { - const claudeOAuthCredentials = getClaudeOAuthCredentials() + if (!skipClaudeOAuth && isClaudeModel(model)) { + // Get valid credentials (will refresh if needed) + const claudeOAuthCredentials = await getValidClaudeOAuthCredentials() if (claudeOAuthCredentials) { return { model: createAnthropicOAuthModel( From d1f878034e40ca13d04ba1b756c5efb401597403 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 5 Jan 2026 12:14:19 -0800 Subject: [PATCH 07/20] remove dead code --- cli/src/utils/claude-oauth.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/cli/src/utils/claude-oauth.ts b/cli/src/utils/claude-oauth.ts index 1025511ba..0c49ad1a9 100644 --- a/cli/src/utils/claude-oauth.ts +++ b/cli/src/utils/claude-oauth.ts @@ -37,7 +37,6 @@ function generateCodeChallenge(verifier: string): string { // Store the code verifier and state during the OAuth flow let pendingCodeVerifier: string | null = null -let pendingState: string | null = null /** * Start the OAuth authorization flow. @@ -48,12 +47,8 @@ export function startOAuthFlow(): { codeVerifier: string; authUrl: string } { const codeVerifier = generateCodeVerifier() const codeChallenge = generateCodeChallenge(codeVerifier) - // Generate a random state parameter for CSRF protection - const state = crypto.randomBytes(16).toString('hex') - // Store the code verifier and state for later use pendingCodeVerifier = codeVerifier - pendingState = state // Build the authorization URL // Use claude.ai for Max subscription (same as opencode) From e7936c4d00e3008b8d62eb7697248263e493cc32 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 5 Jan 2026 17:16:11 -0800 Subject: [PATCH 08/20] Fix schema parsing error --- cli/src/utils/auth.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/cli/src/utils/auth.ts b/cli/src/utils/auth.ts index 502aa7ecf..fde353a54 100644 --- a/cli/src/utils/auth.ts +++ b/cli/src/utils/auth.ts @@ -25,19 +25,21 @@ const userSchema = z.object({ export type User = z.infer // Claude OAuth credentials schema (for passthrough, not strict validation here) -const claudeOAuthSchema = z.object({ - accessToken: z.string(), - refreshToken: z.string(), - expiresAt: z.number(), - connectedAt: z.number(), -}).optional() +const claudeOAuthSchema = z + .object({ + accessToken: z.string(), + refreshToken: z.string(), + expiresAt: z.number(), + connectedAt: z.number(), + }) + .optional() const credentialsSchema = z .object({ default: userSchema, claudeOAuth: claudeOAuthSchema, }) - .catchall(userSchema) + .catchall(z.unknown()) // Get the config directory path export const getConfigDir = (): string => { @@ -67,7 +69,9 @@ const userFromJson = ( try { const allCredentials = credentialsSchema.parse(JSON.parse(json)) const profile = allCredentials[profileName] - return profile + // Validate that the profile matches the user schema + const parsed = userSchema.safeParse(profile) + return parsed.success ? parsed.data : undefined } catch (error) { logger.error( { From 60b840ef5a38637d9462affe4412c2e2e68ae41c Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 5 Jan 2026 17:38:51 -0800 Subject: [PATCH 09/20] Bottom status bar when using claude subscription --- cli/src/chat.tsx | 26 +++++ cli/src/components/bottom-status-line.tsx | 81 ++++++++++++++ cli/src/hooks/use-claude-quota-query.ts | 125 ++++++++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 cli/src/components/bottom-status-line.tsx create mode 100644 cli/src/hooks/use-claude-quota-query.ts diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 194d2a90b..14c8bb2a4 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -15,6 +15,7 @@ import { getAdsEnabled } from './commands/ads' import { routeUserPrompt, addBashMessageToHistory } from './commands/router' import { AdBanner } from './components/ad-banner' import { ChatInputBar } from './components/chat-input-bar' +import { BottomStatusLine } from './components/bottom-status-line' import { areCreditsRestored } from './components/out-of-credits-banner' import { LoadPreviousButton } from './components/load-previous-button' import { MessageWithAgents } from './components/message-with-agents' @@ -26,6 +27,7 @@ import { useAgentValidation } from './hooks/use-agent-validation' import { useAskUserBridge } from './hooks/use-ask-user-bridge' import { authQueryKeys } from './hooks/use-auth-query' import { useChatInput } from './hooks/use-chat-input' +import { useClaudeQuotaQuery } from './hooks/use-claude-quota-query' import { useChatKeyboard, type ChatKeyboardHandlers, @@ -73,6 +75,7 @@ import { getStatusIndicatorState, type AuthStatus, } from './utils/status-indicator-state' +import { getClaudeOAuthStatus } from './utils/claude-oauth' import { createPasteHandler } from './utils/strings' import { computeInputLayoutMetrics } from './utils/text-layout' import { createMarkdownPalette } from './utils/theme-system' @@ -1360,6 +1363,20 @@ export const Chat = ({ isAskUserActive: askUserState !== null, }) const hasStatusIndicatorContent = statusIndicatorState.kind !== 'idle' + + // Check if Claude OAuth is active for the current agent mode + const isClaudeOAuthActive = useMemo(() => { + const status = getClaudeOAuthStatus() + // When connected, Claude OAuth is active for Claude models + return status.connected + }, [inputMode]) // Re-check when input mode changes (e.g., after /connect:claude) + + // Fetch Claude quota when OAuth is active + const { data: claudeQuota } = useClaudeQuotaQuery({ + enabled: isClaudeOAuthActive, + refetchInterval: 60 * 1000, // Refetch every 60 seconds + }) + const inputBoxTitle = useMemo(() => { const segments: string[] = [] @@ -1380,6 +1397,9 @@ export const Chat = ({ !feedbackMode && (hasStatusIndicatorContent || shouldShowQueuePreview || !isAtBottom) + // Determine if Claude is actively streaming/waiting + const isClaudeActive = isStreaming || isWaitingForResponse + // Track mouse movement for ad activity (throttled) const lastMouseActivityRef = useRef(0) const handleMouseActivity = useCallback(() => { @@ -1541,6 +1561,12 @@ export const Chat = ({ cwd: getProjectRoot() ?? process.cwd(), })} /> + + ) diff --git a/cli/src/components/bottom-status-line.tsx b/cli/src/components/bottom-status-line.tsx new file mode 100644 index 000000000..6db1bca5b --- /dev/null +++ b/cli/src/components/bottom-status-line.tsx @@ -0,0 +1,81 @@ +import React from 'react' + +import { useTheme } from '../hooks/use-theme' + +import type { ClaudeQuotaData } from '../hooks/use-claude-quota-query' +import type { ChatTheme } from '../types/theme-system' + +interface BottomStatusLineProps { + /** Whether Claude OAuth is connected */ + isClaudeConnected: boolean + /** Whether Claude is actively being used (streaming/waiting) */ + isClaudeActive: boolean + /** Quota data from Anthropic API */ + claudeQuota?: ClaudeQuotaData | null +} + +/** + * Format remaining quota for display + */ +const formatQuota = (remaining: number): string => { + const rounded = Math.round(remaining) + return `${rounded}%` +} + +/** + * Get color for quota percentage - only highlight when approaching limit + */ +const getQuotaColor = (remaining: number, theme: ChatTheme): string => { + if (remaining <= 10) return theme.error + if (remaining <= 25) return theme.warning + return theme.muted // Use muted for normal levels - doesn't need to be salient +} + +/** + * Bottom status line component - shows below the input box + * Currently displays Claude subscription status when connected + */ +export const BottomStatusLine: React.FC = ({ + isClaudeConnected, + isClaudeActive, + claudeQuota, +}) => { + const theme = useTheme() + + // Don't render if there's nothing to show + if (!isClaudeConnected) { + return null + } + + // Use the more restrictive of the two quotas (5-hour window is usually the limiting factor) + const displayRemaining = claudeQuota + ? Math.min(claudeQuota.fiveHourRemaining, claudeQuota.sevenDayRemaining) + : null + + return ( + + + โ— + Claude subscription + {displayRemaining !== null && ( + + {' '}{formatQuota(displayRemaining)} + + )} + + + ) +} diff --git a/cli/src/hooks/use-claude-quota-query.ts b/cli/src/hooks/use-claude-quota-query.ts new file mode 100644 index 000000000..44f956403 --- /dev/null +++ b/cli/src/hooks/use-claude-quota-query.ts @@ -0,0 +1,125 @@ +import { useQuery } from '@tanstack/react-query' + +import { getClaudeOAuthCredentials, isClaudeOAuthValid } from '@codebuff/sdk' + +import { logger as defaultLogger } from '../utils/logger' + +import type { Logger } from '@codebuff/common/types/contracts/logger' + +// Query keys for type-safe cache management +export const claudeQuotaQueryKeys = { + all: ['claude-quota'] as const, + current: () => [...claudeQuotaQueryKeys.all, 'current'] as const, +} + +/** + * Response from Anthropic OAuth usage endpoint + */ +export interface ClaudeQuotaWindow { + utilization: number // Percentage used (0-100) + resets_at: string | null // ISO timestamp when quota resets +} + +export interface ClaudeQuotaResponse { + five_hour: ClaudeQuotaWindow | null + seven_day: ClaudeQuotaWindow | null + seven_day_oauth_apps: ClaudeQuotaWindow | null + seven_day_opus: ClaudeQuotaWindow | null +} + +/** + * Parsed quota data for display + */ +export interface ClaudeQuotaData { + /** Remaining percentage for the 5-hour window (0-100) */ + fiveHourRemaining: number + /** When the 5-hour quota resets */ + fiveHourResetsAt: Date | null + /** Remaining percentage for the 7-day window (0-100) */ + sevenDayRemaining: number + /** When the 7-day quota resets */ + sevenDayResetsAt: Date | null +} + +/** + * Fetches Claude OAuth usage data from Anthropic API + */ +export async function fetchClaudeQuota( + accessToken: string, + logger: Logger = defaultLogger, +): Promise { + const response = await fetch('https://api.anthropic.com/api/oauth/usage', { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + // Required beta headers for OAuth endpoints (same as model requests) + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'oauth-2025-04-20,claude-code-20250219', + }, + }) + + if (!response.ok) { + logger.debug( + { status: response.status }, + 'Failed to fetch Claude quota data', + ) + throw new Error(`Failed to fetch Claude quota: ${response.status}`) + } + + const data = (await response.json()) as ClaudeQuotaResponse + + // Parse the response into a more usable format + const fiveHour = data.five_hour + const sevenDay = data.seven_day + + return { + fiveHourRemaining: fiveHour ? Math.max(0, 100 - fiveHour.utilization) : 100, + fiveHourResetsAt: fiveHour?.resets_at ? new Date(fiveHour.resets_at) : null, + sevenDayRemaining: sevenDay ? Math.max(0, 100 - sevenDay.utilization) : 100, + sevenDayResetsAt: sevenDay?.resets_at ? new Date(sevenDay.resets_at) : null, + } +} + +export interface UseClaudeQuotaQueryDeps { + logger?: Logger + enabled?: boolean + /** Refetch interval in milliseconds (default: 60 seconds) */ + refetchInterval?: number | false +} + +/** + * Hook to fetch Claude OAuth quota data from Anthropic API + * Only fetches when Claude OAuth is connected and valid + */ +export function useClaudeQuotaQuery(deps: UseClaudeQuotaQueryDeps = {}) { + const { + logger = defaultLogger, + enabled = true, + refetchInterval = 60 * 1000, // Default: refetch every 60 seconds + } = deps + + const isConnected = isClaudeOAuthValid() + + return useQuery({ + queryKey: claudeQuotaQueryKeys.current(), + queryFn: () => { + // Get credentials inside queryFn to avoid stale closures + const credentials = getClaudeOAuthCredentials() + if (!credentials?.accessToken) { + throw new Error('No Claude OAuth credentials') + } + return fetchClaudeQuota(credentials.accessToken, logger) + }, + enabled: enabled && isConnected, + staleTime: 30 * 1000, // Consider data stale after 30 seconds + gcTime: 5 * 60 * 1000, // 5 minutes + retry: 1, // Only retry once on failure + refetchOnMount: true, + refetchOnWindowFocus: false, // CLI doesn't have window focus + refetchOnReconnect: false, + refetchInterval, + refetchIntervalInBackground: true, // Required for terminal environments + }) +} From 88440f14aeb0371f8b5a72d9063efcf8166502da Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 5 Jan 2026 17:40:36 -0800 Subject: [PATCH 10/20] Show claude subscription usage in /usage --- cli/src/components/progress-bar.tsx | 76 ++++++++++++++++++++++++++ cli/src/components/usage-banner.tsx | 84 ++++++++++++++++++++++++++--- 2 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 cli/src/components/progress-bar.tsx diff --git a/cli/src/components/progress-bar.tsx b/cli/src/components/progress-bar.tsx new file mode 100644 index 000000000..50d07bef0 --- /dev/null +++ b/cli/src/components/progress-bar.tsx @@ -0,0 +1,76 @@ +import React from 'react' + +import { useTheme } from '../hooks/use-theme' + +interface ProgressBarProps { + /** Value from 0 to 100 */ + value: number + /** Width in characters (default: 20) */ + width?: number + /** Optional label to show before the bar */ + label?: string + /** Show percentage text after the bar */ + showPercentage?: boolean +} + +/** + * Get color based on progress percentage - muted for normal, warning/error when low + */ +const getProgressColor = ( + value: number, + theme: { primary: string; muted: string; warning: string; error: string }, +): string => { + if (value <= 10) return theme.error + if (value <= 25) return theme.warning + return theme.muted // Use muted for normal levels +} + +/** + * Get color for the filled portion of the bar + */ +const getBarColor = ( + value: number, + theme: { primary: string; warning: string; error: string }, +): string => { + if (value <= 10) return theme.error + if (value <= 25) return theme.warning + return theme.primary // Use primary for the bar itself +} + +/** + * Terminal progress bar component + * Uses block characters for visual display + */ +export const ProgressBar: React.FC = ({ + value, + width = 20, + label, + showPercentage = true, +}) => { + const theme = useTheme() + const clampedValue = Math.max(0, Math.min(100, value)) + const filledWidth = Math.round((clampedValue / 100) * width) + const emptyWidth = width - filledWidth + + const filledChar = 'โ–ˆ' + const emptyChar = 'โ–‘' + + const filled = filledChar.repeat(filledWidth) + const empty = emptyChar.repeat(emptyWidth) + + const barColor = getBarColor(clampedValue, theme) + const textColor = getProgressColor(clampedValue, theme) + + return ( + + {label && ( + {label} + )} + {filled} + {empty} + {showPercentage && ( + {Math.round(clampedValue)}% + )} + + ) +} diff --git a/cli/src/components/usage-banner.tsx b/cli/src/components/usage-banner.tsx index 70a7fcbf4..7b03257c5 100644 --- a/cli/src/components/usage-banner.tsx +++ b/cli/src/components/usage-banner.tsx @@ -3,6 +3,9 @@ import React, { useEffect } from 'react' import open from 'open' import { BottomBanner } from './bottom-banner' +import { Button } from './button' +import { ProgressBar } from './progress-bar' +import { useClaudeQuotaQuery } from '../hooks/use-claude-quota-query' import { usageQueryKeys, useUsageQuery } from '../hooks/use-usage-query' import { useChatStore } from '../state/chat-store' import { @@ -12,16 +15,44 @@ import { } from '../utils/usage-banner-state' import { WEBSITE_URL } from '../login/constants' import { useTheme } from '../hooks/use-theme' -import { Button } from './button' +import { isClaudeOAuthValid } from '@codebuff/sdk' const MANUAL_SHOW_TIMEOUT = 60 * 1000 // 1 minute const USAGE_POLL_INTERVAL = 30 * 1000 // 30 seconds +/** + * Format time until reset in human-readable form + */ +const formatResetTime = (resetDate: Date | null): string => { + if (!resetDate) return '' + const now = new Date() + const diffMs = resetDate.getTime() - now.getTime() + if (diffMs <= 0) return 'now' + + const diffMins = Math.floor(diffMs / (1000 * 60)) + const diffHours = Math.floor(diffMins / 60) + const remainingMins = diffMins % 60 + + if (diffHours > 0) { + return `${diffHours}h ${remainingMins}m` + } + return `${diffMins}m` +} + export const UsageBanner = ({ showTime }: { showTime: number }) => { const queryClient = useQueryClient() const sessionCreditsUsed = useChatStore((state) => state.sessionCreditsUsed) const setInputMode = useChatStore((state) => state.setInputMode) + // Check if Claude OAuth is connected + const isClaudeConnected = isClaudeOAuthValid() + + // Fetch Claude quota data if connected + const { data: claudeQuota, isLoading: isClaudeLoading } = useClaudeQuotaQuery({ + enabled: isClaudeConnected, + refetchInterval: 30 * 1000, // Refresh every 30 seconds when banner is open + }) + const { data: apiData, isLoading, @@ -91,13 +122,50 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { borderColorKey={isLoadingData ? 'muted' : colorLevel} onClose={() => setInputMode('default')} > - + + {/* Codebuff credits section */} + + + {/* Claude subscription section - only show if connected */} + {isClaudeConnected && ( + + Claude subscription + {isClaudeLoading ? ( + Loading quota... + ) : claudeQuota ? ( + + + 5-hour: + + {claudeQuota.fiveHourResetsAt && ( + + (resets in {formatResetTime(claudeQuota.fiveHourResetsAt)}) + + )} + + {/* Only show 7-day bar if the user has a 7-day limit */} + {claudeQuota.sevenDayResetsAt && ( + + 7-day: + + + (resets in {formatResetTime(claudeQuota.sevenDayResetsAt)}) + + + )} + + ) : ( + Unable to fetch quota + )} + + )} + ) } From 4afccd184b83ebec9b7598ccce051baae0bad163 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 5 Jan 2026 18:05:52 -0800 Subject: [PATCH 11/20] Improve status bar and /usage UI --- cli/src/components/bottom-status-line.tsx | 51 ++++++------ cli/src/components/progress-bar.tsx | 13 ++-- cli/src/components/usage-banner.tsx | 77 ++++++++++++------- .../__tests__/usage-banner-state.test.ts | 50 ------------ cli/src/utils/time-format.ts | 20 +++++ cli/src/utils/usage-banner-state.ts | 61 --------------- 6 files changed, 102 insertions(+), 170 deletions(-) create mode 100644 cli/src/utils/time-format.ts diff --git a/cli/src/components/bottom-status-line.tsx b/cli/src/components/bottom-status-line.tsx index 6db1bca5b..9d29ad27f 100644 --- a/cli/src/components/bottom-status-line.tsx +++ b/cli/src/components/bottom-status-line.tsx @@ -2,8 +2,9 @@ import React from 'react' import { useTheme } from '../hooks/use-theme' +import { formatResetTime } from '../utils/time-format' + import type { ClaudeQuotaData } from '../hooks/use-claude-quota-query' -import type { ChatTheme } from '../types/theme-system' interface BottomStatusLineProps { /** Whether Claude OAuth is connected */ @@ -14,23 +15,6 @@ interface BottomStatusLineProps { claudeQuota?: ClaudeQuotaData | null } -/** - * Format remaining quota for display - */ -const formatQuota = (remaining: number): string => { - const rounded = Math.round(remaining) - return `${rounded}%` -} - -/** - * Get color for quota percentage - only highlight when approaching limit - */ -const getQuotaColor = (remaining: number, theme: ChatTheme): string => { - if (remaining <= 10) return theme.error - if (remaining <= 25) return theme.warning - return theme.muted // Use muted for normal levels - doesn't need to be salient -} - /** * Bottom status line component - shows below the input box * Currently displays Claude subscription status when connected @@ -52,6 +36,23 @@ export const BottomStatusLine: React.FC = ({ ? Math.min(claudeQuota.fiveHourRemaining, claudeQuota.sevenDayRemaining) : null + // Check if quota is exhausted (0%) + const isExhausted = displayRemaining !== null && displayRemaining <= 0 + + // Get the reset time for the limiting quota window + const resetTime = claudeQuota + ? claudeQuota.fiveHourRemaining <= claudeQuota.sevenDayRemaining + ? claudeQuota.fiveHourResetsAt + : claudeQuota.sevenDayResetsAt + : null + + // Determine dot color: red if exhausted, green if active, muted otherwise + const dotColor = isExhausted + ? theme.error + : isClaudeActive + ? theme.success + : theme.muted + return ( = ({ gap: 0, }} > - โ— - Claude subscription - {displayRemaining !== null && ( - - {' '}{formatQuota(displayRemaining)} - - )} + โ— + Claude subscription + {isExhausted && resetTime ? ( + {` ยท resets in ${formatResetTime(resetTime)}`} + ) : displayRemaining !== null ? ( + {` ${Math.round(displayRemaining)}%`} + ) : null} ) diff --git a/cli/src/components/progress-bar.tsx b/cli/src/components/progress-bar.tsx index 50d07bef0..e161772d2 100644 --- a/cli/src/components/progress-bar.tsx +++ b/cli/src/components/progress-bar.tsx @@ -18,11 +18,16 @@ interface ProgressBarProps { */ const getProgressColor = ( value: number, - theme: { primary: string; muted: string; warning: string; error: string }, + theme: { + primary: string + foreground: string + warning: string + error: string + }, ): string => { if (value <= 10) return theme.error if (value <= 25) return theme.warning - return theme.muted // Use muted for normal levels + return theme.foreground } /** @@ -63,9 +68,7 @@ export const ProgressBar: React.FC = ({ return ( - {label && ( - {label} - )} + {label && {label} } {filled} {empty} {showPercentage && ( diff --git a/cli/src/components/usage-banner.tsx b/cli/src/components/usage-banner.tsx index 7b03257c5..54742c436 100644 --- a/cli/src/components/usage-banner.tsx +++ b/cli/src/components/usage-banner.tsx @@ -10,33 +10,34 @@ import { usageQueryKeys, useUsageQuery } from '../hooks/use-usage-query' import { useChatStore } from '../state/chat-store' import { getBannerColorLevel, - generateUsageBannerText, generateLoadingBannerText, } from '../utils/usage-banner-state' import { WEBSITE_URL } from '../login/constants' import { useTheme } from '../hooks/use-theme' import { isClaudeOAuthValid } from '@codebuff/sdk' +import { formatResetTime } from '../utils/time-format' + const MANUAL_SHOW_TIMEOUT = 60 * 1000 // 1 minute const USAGE_POLL_INTERVAL = 30 * 1000 // 30 seconds /** - * Format time until reset in human-readable form + * Format the renewal date for display */ -const formatResetTime = (resetDate: Date | null): string => { - if (!resetDate) return '' - const now = new Date() - const diffMs = resetDate.getTime() - now.getTime() - if (diffMs <= 0) return 'now' - - const diffMins = Math.floor(diffMs / (1000 * 60)) - const diffHours = Math.floor(diffMins / 60) - const remainingMins = diffMins % 60 - - if (diffHours > 0) { - return `${diffHours}h ${remainingMins}m` - } - return `${diffMins}m` +const formatRenewalDate = (dateStr: string | null): string => { + if (!dateStr) return '' + const resetDate = new Date(dateStr) + const today = new Date() + const isToday = resetDate.toDateString() === today.toDateString() + return isToday + ? resetDate.toLocaleString('en-US', { + hour: 'numeric', + minute: '2-digit', + }) + : resetDate.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }) } export const UsageBanner = ({ showTime }: { showTime: number }) => { @@ -106,16 +107,8 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { } const colorLevel = getBannerColorLevel(activeData.remainingBalance) - - // Show loading indicator if refreshing data - const text = isLoadingData - ? generateLoadingBannerText(sessionCreditsUsed) - : generateUsageBannerText({ - sessionCreditsUsed, - remainingBalance: activeData.remainingBalance, - next_quota_reset: activeData.next_quota_reset, - adCredits: activeData.balanceBreakdown?.ad, - }) + const adCredits = activeData.balanceBreakdown?.ad + const renewalDate = activeData.next_quota_reset ? formatRenewalDate(activeData.next_quota_reset) : null return ( { onClose={() => setInputMode('default')} > - {/* Codebuff credits section */} + {/* Codebuff credits section - structured layout */} {/* Claude subscription section - only show if connected */} {isClaudeConnected && ( - Claude subscription + Claude subscription {isClaudeLoading ? ( Loading quota... ) : claudeQuota ? ( diff --git a/cli/src/utils/__tests__/usage-banner-state.test.ts b/cli/src/utils/__tests__/usage-banner-state.test.ts index 36f75cc53..17d9e0001 100644 --- a/cli/src/utils/__tests__/usage-banner-state.test.ts +++ b/cli/src/utils/__tests__/usage-banner-state.test.ts @@ -3,7 +3,6 @@ import { describe, test, expect } from 'bun:test' import { getBannerColorLevel, getThresholdInfo, - generateUsageBannerText, generateLoadingBannerText, shouldAutoShowBanner, } from '../usage-banner-state' @@ -111,55 +110,6 @@ describe('usage-banner-state', () => { }) }) - describe('generateUsageBannerText', () => { - test('always shows session usage', () => { - const text = generateUsageBannerText({ - sessionCreditsUsed: 250, - remainingBalance: null, - next_quota_reset: null, - }) - expect(text).toContain('250') - }) - - test('shows remaining balance when available', () => { - const text = generateUsageBannerText({ - sessionCreditsUsed: 100, - remainingBalance: 500, - next_quota_reset: null, - }) - expect(text).toContain('500') - }) - - test('omits balance when not available', () => { - const text = generateUsageBannerText({ - sessionCreditsUsed: 100, - remainingBalance: null, - next_quota_reset: null, - }) - expect(text).not.toContain('remaining') - }) - - test('shows renewal date when available', () => { - const text = generateUsageBannerText({ - sessionCreditsUsed: 100, - remainingBalance: 500, - next_quota_reset: '2025-03-15T00:00:00.000Z', - today: new Date('2025-03-01'), - }) - expect(text).toContain('Mar') - expect(text).toContain('15') - }) - - test('omits renewal date when not available', () => { - const text = generateUsageBannerText({ - sessionCreditsUsed: 100, - remainingBalance: 500, - next_quota_reset: null, - }) - expect(text.toLowerCase()).not.toContain('renew') - }) - }) - describe('shouldAutoShowBanner', () => { describe('when banner should NOT auto-show', () => { test('during active AI response chain', () => { diff --git a/cli/src/utils/time-format.ts b/cli/src/utils/time-format.ts new file mode 100644 index 000000000..af178fde8 --- /dev/null +++ b/cli/src/utils/time-format.ts @@ -0,0 +1,20 @@ +/** + * Format time until reset in human-readable form + * @param resetDate - The date when the quota/resource resets + * @returns Human-readable string like "2h 30m" or "45m" + */ +export const formatResetTime = (resetDate: Date | null): string => { + if (!resetDate) return '' + const now = new Date() + const diffMs = resetDate.getTime() - now.getTime() + if (diffMs <= 0) return 'now' + + const diffMins = Math.floor(diffMs / (1000 * 60)) + const diffHours = Math.floor(diffMins / 60) + const remainingMins = diffMins % 60 + + if (diffHours > 0) { + return `${diffHours}h ${remainingMins}m` + } + return `${diffMins}m` +} diff --git a/cli/src/utils/usage-banner-state.ts b/cli/src/utils/usage-banner-state.ts index b3bd360cd..cb9b8af20 100644 --- a/cli/src/utils/usage-banner-state.ts +++ b/cli/src/utils/usage-banner-state.ts @@ -62,16 +62,6 @@ export function getBannerColorLevel(balance: number | null): BannerColorLevel { return getThresholdInfo(balance).colorLevel } -export interface UsageBannerTextOptions { - sessionCreditsUsed: number - remainingBalance: number | null - next_quota_reset: string | null - /** Ad impression credits earned */ - adCredits?: number - /** For testing purposes, allows overriding "today" */ - today?: Date -} - /** * Generates loading text for the usage banner while data is being fetched. */ @@ -79,57 +69,6 @@ export function generateLoadingBannerText(sessionCreditsUsed: number): string { return `Session usage: ${sessionCreditsUsed.toLocaleString()}. Loading credit balance...` } -/** - * Generates the text content for the usage banner. - */ -export function generateUsageBannerText( - options: UsageBannerTextOptions, -): string { - const { - sessionCreditsUsed, - remainingBalance, - next_quota_reset, - adCredits, - today = new Date(), - } = options - - let text = `Session usage: ${sessionCreditsUsed.toLocaleString()}` - - if (remainingBalance !== null) { - text += `. Credits remaining: ${remainingBalance.toLocaleString()}` - } - - // Show ad credits earned if any - if (adCredits && adCredits > 0) { - text += ` (${adCredits.toLocaleString()} from ads)` - } - - if (next_quota_reset) { - const resetDate = new Date(next_quota_reset) - const isToday = resetDate.toDateString() === today.toDateString() - - const dateDisplay = isToday - ? resetDate.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: 'numeric', - minute: '2-digit', - }) - : resetDate.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }) - - text += `. Free credits renew ${dateDisplay}` - } - - text += `. See more` - - return text -} - /** * Gets the threshold tier for a given balance. * Returns null if balance is above all thresholds. From b9a564f97416221bbe51eeaaa7ac40d3cd4620b2 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 5 Jan 2026 18:20:46 -0800 Subject: [PATCH 12/20] Improve claude connect banner --- cli/src/components/claude-connect-banner.tsx | 77 +++++++++++++------- 1 file changed, 49 insertions(+), 28 deletions(-) diff --git a/cli/src/components/claude-connect-banner.tsx b/cli/src/components/claude-connect-banner.tsx index fdc8770a7..3283248db 100644 --- a/cli/src/components/claude-connect-banner.tsx +++ b/cli/src/components/claude-connect-banner.tsx @@ -11,7 +11,12 @@ import { } from '../utils/claude-oauth' import { useTheme } from '../hooks/use-theme' -type FlowState = 'checking' | 'not-connected' | 'waiting-for-code' | 'connected' | 'error' +type FlowState = + | 'checking' + | 'not-connected' + | 'waiting-for-code' + | 'connected' + | 'error' export const ClaudeConnectBanner = () => { const setInputMode = useChatStore((state) => state.setInputMode) @@ -19,6 +24,7 @@ export const ClaudeConnectBanner = () => { const [flowState, setFlowState] = useState('checking') const [error, setError] = useState(null) const [isDisconnectHovered, setIsDisconnectHovered] = useState(false) + const [isConnectHovered, setIsConnectHovered] = useState(false) // Check initial connection status useEffect(() => { @@ -58,19 +64,23 @@ export const ClaudeConnectBanner = () => { return ( - - - โœ“ Connected to Claude (since {connectedDate}) - - + + โœ“ Connected to Claude + + Since {connectedDate} + ยท + + ) @@ -91,12 +101,11 @@ export const ClaudeConnectBanner = () => { if (flowState === 'waiting-for-code') { return ( - - - Browser opened. Sign in with your Claude account, then paste the authorization code below. - + + Waiting for authorization - Type the code in the input box above and press Enter. + Sign in with your Claude account in the browser, then paste the code + here. @@ -106,13 +115,21 @@ export const ClaudeConnectBanner = () => { // Not connected / checking state - show connect button return ( - - - Connect your Claude Pro/Max subscription to use Claude models directly. - - + + Connect to Claude + + Use your Pro/Max subscription + ยท + + ) @@ -130,12 +147,16 @@ export async function handleClaudeAuthCode(code: string): Promise<{ await exchangeCodeForTokens(code) return { success: true, - message: 'Successfully connected to Claude! Your Claude models will now use your subscription.', + message: + 'Successfully connected your Claude subscription! Codebuff will now use it for Claude model requests.', } } catch (err) { return { success: false, - message: err instanceof Error ? err.message : 'Failed to exchange authorization code', + message: + err instanceof Error + ? err.message + : 'Failed to exchange authorization code', } } } From 27293920c12583788e7c84e93de8db7ede3ae7d6 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 5 Jan 2026 18:25:21 -0800 Subject: [PATCH 13/20] Tweak slash command wording --- cli/src/data/slash-commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts index a565976c2..73dcf4f73 100644 --- a/cli/src/data/slash-commands.ts +++ b/cli/src/data/slash-commands.ts @@ -66,7 +66,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [ { id: 'usage', label: 'usage', - description: 'View remaining or bonus credits', + description: 'View credits and subscription quota', aliases: ['credits'], }, { From 57c1c3c6a1d649d394898d7be5b347d6c7880a2b Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 5 Jan 2026 18:29:58 -0800 Subject: [PATCH 14/20] Increase interval to 120 seconds --- cli/src/hooks/use-claude-quota-query.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/hooks/use-claude-quota-query.ts b/cli/src/hooks/use-claude-quota-query.ts index 44f956403..91c1c98a9 100644 --- a/cli/src/hooks/use-claude-quota-query.ts +++ b/cli/src/hooks/use-claude-quota-query.ts @@ -97,7 +97,7 @@ export function useClaudeQuotaQuery(deps: UseClaudeQuotaQueryDeps = {}) { const { logger = defaultLogger, enabled = true, - refetchInterval = 60 * 1000, // Default: refetch every 60 seconds + refetchInterval = 120 * 1000, // Default: refetch every 120 seconds } = deps const isConnected = isClaudeOAuthValid() From ff1076ac1dcfab156886bce705529e8d104777cb Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 5 Jan 2026 18:52:53 -0800 Subject: [PATCH 15/20] Cleanup & fix model strings --- common/src/constants/claude-oauth.ts | 43 ++++++++++++++++++++++++---- sdk/src/impl/llm.ts | 2 -- sdk/src/impl/model-provider.ts | 9 ++---- 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/common/src/constants/claude-oauth.ts b/common/src/constants/claude-oauth.ts index 77373ca0a..442921fbc 100644 --- a/common/src/constants/claude-oauth.ts +++ b/common/src/constants/claude-oauth.ts @@ -19,6 +19,17 @@ export const CLAUDE_OAUTH_TOKEN_ENV_VAR = 'CODEBUFF_CLAUDE_OAUTH_TOKEN' // Required Anthropic API version header export const ANTHROPIC_API_VERSION = '2023-06-01' +/** + * Beta headers required for Claude OAuth access to Claude 4+ models. + * These must be included in the anthropic-beta header when making requests. + */ +export const CLAUDE_OAUTH_BETA_HEADERS = [ + 'oauth-2025-04-20', + 'claude-code-20250219', + 'interleaved-thinking-2025-05-14', + 'fine-grained-tool-streaming-2025-05-14', +] as const + /** * System prompt prefix required by Anthropic to allow OAuth access to Claude 4+ models. * This must be prepended to the system prompt when using Claude OAuth with Claude 4+ models. @@ -32,25 +43,47 @@ export const CLAUDE_CODE_SYSTEM_PROMPT_PREFIX = "You are Claude Code, Anthropic' * while Anthropic uses versioned IDs like "claude-3-5-haiku-20241022". */ export const OPENROUTER_TO_ANTHROPIC_MODEL_MAP: Record = { - // Claude 3.x models + // Claude 3.x Haiku models 'anthropic/claude-3.5-haiku-20241022': 'claude-3-5-haiku-20241022', 'anthropic/claude-3.5-haiku': 'claude-3-5-haiku-20241022', 'anthropic/claude-3-5-haiku': 'claude-3-5-haiku-20241022', + 'anthropic/claude-3-5-haiku-20241022': 'claude-3-5-haiku-20241022', 'anthropic/claude-3-haiku': 'claude-3-haiku-20240307', - 'anthropic/claude-3-opus': 'claude-3-opus-20240229', 'claude-3.5-haiku': 'claude-3-5-haiku-20241022', 'claude-3-5-haiku': 'claude-3-5-haiku-20241022', 'claude-3-haiku': 'claude-3-haiku-20240307', + + // Claude 3.x Sonnet models + 'anthropic/claude-3.5-sonnet': 'claude-3-5-sonnet-20241022', + 'anthropic/claude-3-5-sonnet': 'claude-3-5-sonnet-20241022', + 'anthropic/claude-3-5-sonnet-20241022': 'claude-3-5-sonnet-20241022', + 'anthropic/claude-3-5-sonnet-20240620': 'claude-3-5-sonnet-20240620', + 'anthropic/claude-3-sonnet': 'claude-3-sonnet-20240229', + 'claude-3.5-sonnet': 'claude-3-5-sonnet-20241022', + 'claude-3-5-sonnet': 'claude-3-5-sonnet-20241022', + 'claude-3-sonnet': 'claude-3-sonnet-20240229', + + // Claude 3.x Opus models + 'anthropic/claude-3-opus': 'claude-3-opus-20240229', + 'anthropic/claude-3-opus-20240229': 'claude-3-opus-20240229', 'claude-3-opus': 'claude-3-opus-20240229', - // Claude 4.x models + // Claude 4.x Haiku models + 'anthropic/claude-haiku-4.5': 'claude-haiku-4-5-20251001', + 'anthropic/claude-haiku-4': 'claude-haiku-4-20250514', + 'claude-haiku-4.5': 'claude-haiku-4-5-20251001', + 'claude-haiku-4': 'claude-haiku-4-20250514', + + // Claude 4.x Sonnet models 'anthropic/claude-sonnet-4.5': 'claude-sonnet-4-5-20250929', 'anthropic/claude-sonnet-4': 'claude-sonnet-4-20250514', + 'claude-sonnet-4.5': 'claude-sonnet-4-5-20250929', + 'claude-sonnet-4': 'claude-sonnet-4-20250514', + + // Claude 4.x Opus models 'anthropic/claude-opus-4.5': 'claude-opus-4-5-20251101', 'anthropic/claude-opus-4.1': 'claude-opus-4-1-20250805', 'anthropic/claude-opus-4': 'claude-opus-4-1-20250805', - 'claude-sonnet-4.5': 'claude-sonnet-4-5-20250929', - 'claude-sonnet-4': 'claude-sonnet-4-20250514', 'claude-opus-4.5': 'claude-opus-4-5-20251101', 'claude-opus-4.1': 'claude-opus-4-1-20250805', 'claude-opus-4': 'claude-opus-4-1-20250805', diff --git a/sdk/src/impl/llm.ts b/sdk/src/impl/llm.ts index 1cc3bdd6e..b4f894922 100644 --- a/sdk/src/impl/llm.ts +++ b/sdk/src/impl/llm.ts @@ -1,5 +1,3 @@ -import path from 'path' - import { getByokOpenrouterApiKeyFromEnv } from '../env' import { BYOK_OPENROUTER_HEADER } from '@codebuff/common/constants/byok' import { models, PROFIT_MARGIN } from '@codebuff/common/old-constants' diff --git a/sdk/src/impl/model-provider.ts b/sdk/src/impl/model-provider.ts index cd5a3d100..b5e38d1dd 100644 --- a/sdk/src/impl/model-provider.ts +++ b/sdk/src/impl/model-provider.ts @@ -12,6 +12,7 @@ import { createAnthropic } from '@ai-sdk/anthropic' import { BYOK_OPENROUTER_HEADER } from '@codebuff/common/constants/byok' import { CLAUDE_CODE_SYSTEM_PROMPT_PREFIX, + CLAUDE_OAUTH_BETA_HEADERS, isClaudeModel, toAnthropicModelId, } from '@codebuff/common/constants/claude-oauth' @@ -121,13 +122,7 @@ function createAnthropicOAuthModel( .map((b) => b.trim()) .filter(Boolean) const mergedBetas = [ - ...new Set([ - 'oauth-2025-04-20', - 'claude-code-20250219', - 'interleaved-thinking-2025-05-14', - 'fine-grained-tool-streaming-2025-05-14', - ...betaList, - ]), + ...new Set([...CLAUDE_OAUTH_BETA_HEADERS, ...betaList]), ].join(',') headers.set('anthropic-beta', mergedBetas) From 0aea8b88808493541da05ef7b684a4b5b7133cd9 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 5 Jan 2026 19:04:44 -0800 Subject: [PATCH 16/20] Don't retry when using claude subscription, for when you are out of quota --- sdk/src/impl/llm.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sdk/src/impl/llm.ts b/sdk/src/impl/llm.ts index b4f894922..c81cd31df 100644 --- a/sdk/src/impl/llm.ts +++ b/sdk/src/impl/llm.ts @@ -216,6 +216,9 @@ export async function* promptAiSdkStream( prompt: undefined, model: aiSDKModel, messages: convertCbToModelMessages(params), + // When using Claude OAuth, disable retries so we can immediately fall back to Codebuff + // backend on rate limit errors instead of retrying 4 times first + ...(isClaudeOAuth && { maxRetries: 0 }), providerOptions: getProviderOptions({ ...params, agentProviderOptions: params.agentProviderOptions, From 38092cae3e19aaf1500648c2055818567b6979f5 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 5 Jan 2026 19:31:07 -0800 Subject: [PATCH 17/20] Don't use claude subscription until cached time subscription becomes allowed --- cli/src/utils/claude-oauth.ts | 4 ++ sdk/src/impl/llm.ts | 12 +++- sdk/src/impl/model-provider.ts | 104 ++++++++++++++++++++++++++++++++- sdk/src/index.ts | 1 + 4 files changed, 117 insertions(+), 4 deletions(-) diff --git a/cli/src/utils/claude-oauth.ts b/cli/src/utils/claude-oauth.ts index 0c49ad1a9..80bea1841 100644 --- a/cli/src/utils/claude-oauth.ts +++ b/cli/src/utils/claude-oauth.ts @@ -10,6 +10,7 @@ import { clearClaudeOAuthCredentials, getClaudeOAuthCredentials, isClaudeOAuthValid, + resetClaudeOAuthRateLimit, } from '@codebuff/sdk' import type { ClaudeOAuthCredentials } from '@codebuff/sdk' @@ -136,6 +137,9 @@ export async function exchangeCodeForTokens( // Save credentials to file saveClaudeOAuthCredentials(credentials) + // Reset any cached rate limit since user just reconnected + resetClaudeOAuthRateLimit() + return credentials } diff --git a/sdk/src/impl/llm.ts b/sdk/src/impl/llm.ts index c81cd31df..fcaa4d390 100644 --- a/sdk/src/impl/llm.ts +++ b/sdk/src/impl/llm.ts @@ -1,5 +1,3 @@ -import { getByokOpenrouterApiKeyFromEnv } from '../env' -import { BYOK_OPENROUTER_HEADER } from '@codebuff/common/constants/byok' import { models, PROFIT_MARGIN } from '@codebuff/common/old-constants' import { buildArray } from '@codebuff/common/util/array' import { getErrorObject } from '@codebuff/common/util/error' @@ -17,7 +15,8 @@ import { TypeValidationError, } from 'ai' -import { getModelForRequest } from './model-provider' +import { getModelForRequest, markClaudeOAuthRateLimited, fetchClaudeOAuthResetTime } from './model-provider' +import { getValidClaudeOAuthCredentials } from '../credentials' import type { ModelRequestParams } from './model-provider' import type { OpenRouterProviderRoutingOptions } from '@codebuff/common/types/agent-template' @@ -404,6 +403,13 @@ export async function* promptAiSdkStream( { error: getErrorObject(chunkValue.error) }, 'Claude OAuth rate limited during stream, falling back to Codebuff backend', ) + // Try to get the actual reset time from the quota API, fall back to default cooldown + const credentials = await getValidClaudeOAuthCredentials() + const resetTime = credentials?.accessToken + ? await fetchClaudeOAuthResetTime(credentials.accessToken) + : null + // Mark as rate-limited so subsequent requests skip Claude OAuth + markClaudeOAuthRateLimited(resetTime ?? undefined) if (params.onClaudeOAuthStatusChange) { params.onClaudeOAuthStatusChange(false) } diff --git a/sdk/src/impl/model-provider.ts b/sdk/src/impl/model-provider.ts index b5e38d1dd..8c14dc10e 100644 --- a/sdk/src/impl/model-provider.ts +++ b/sdk/src/impl/model-provider.ts @@ -27,6 +27,107 @@ import { getByokOpenrouterApiKeyFromEnv } from '../env' import type { LanguageModel } from 'ai' +// ============================================================================ +// Claude OAuth Rate Limit Cache +// ============================================================================ + +/** Timestamp (ms) when Claude OAuth rate limit expires, or null if not rate-limited */ +let claudeOAuthRateLimitedUntil: number | null = null + +/** + * Mark Claude OAuth as rate-limited. Subsequent requests will skip Claude OAuth + * and use Codebuff backend until the reset time. + * @param resetAt - When the rate limit resets. If not provided, guesses 5 minutes from now. + */ +export function markClaudeOAuthRateLimited(resetAt?: Date): void { + const fiveMinutesFromNow = Date.now() + 5 * 60 * 1000 + claudeOAuthRateLimitedUntil = resetAt ? resetAt.getTime() : fiveMinutesFromNow +} + +/** + * Check if Claude OAuth is currently rate-limited. + * Returns true if rate-limited and reset time hasn't passed. + */ +export function isClaudeOAuthRateLimited(): boolean { + if (claudeOAuthRateLimitedUntil === null) { + return false + } + if (Date.now() >= claudeOAuthRateLimitedUntil) { + // Rate limit expired, clear the cache + claudeOAuthRateLimitedUntil = null + return false + } + return true +} + +/** + * Reset the Claude OAuth rate limit cache. + * Call this when user reconnects their Claude subscription. + */ +export function resetClaudeOAuthRateLimit(): void { + claudeOAuthRateLimitedUntil = null +} + +// ============================================================================ +// Claude OAuth Quota Fetching +// ============================================================================ + +interface ClaudeQuotaWindow { + utilization: number + resets_at: string | null +} + +interface ClaudeQuotaResponse { + five_hour: ClaudeQuotaWindow | null + seven_day: ClaudeQuotaWindow | null + seven_day_oauth_apps: ClaudeQuotaWindow | null + seven_day_opus: ClaudeQuotaWindow | null +} + +/** + * Fetch the rate limit reset time from Anthropic's quota API. + * Returns the earliest reset time (whichever limit is more restrictive). + * Returns null if fetch fails or no reset time is available. + */ +export async function fetchClaudeOAuthResetTime(accessToken: string): Promise { + try { + const response = await fetch('https://api.anthropic.com/api/oauth/usage', { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'oauth-2025-04-20,claude-code-20250219', + }, + }) + + if (!response.ok) { + return null + } + + const data = (await response.json()) as ClaudeQuotaResponse + + // Parse reset times + const fiveHour = data.five_hour + const sevenDay = data.seven_day + + const fiveHourRemaining = fiveHour ? Math.max(0, 100 - fiveHour.utilization) : 100 + const sevenDayRemaining = sevenDay ? Math.max(0, 100 - sevenDay.utilization) : 100 + + // Return the reset time for whichever limit is more restrictive (lower remaining) + if (fiveHourRemaining <= sevenDayRemaining && fiveHour?.resets_at) { + return new Date(fiveHour.resets_at) + } else if (sevenDay?.resets_at) { + return new Date(sevenDay.resets_at) + } + + return null + } catch { + return null + } +} + /** * Parameters for requesting a model. */ @@ -69,7 +170,8 @@ export async function getModelForRequest(params: ModelRequestParams): Promise Date: Mon, 5 Jan 2026 21:37:21 -0800 Subject: [PATCH 18/20] No memo for getting claude oauth status --- cli/src/chat.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 14c8bb2a4..3641515ab 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -1363,13 +1363,8 @@ export const Chat = ({ isAskUserActive: askUserState !== null, }) const hasStatusIndicatorContent = statusIndicatorState.kind !== 'idle' - - // Check if Claude OAuth is active for the current agent mode - const isClaudeOAuthActive = useMemo(() => { - const status = getClaudeOAuthStatus() - // When connected, Claude OAuth is active for Claude models - return status.connected - }, [inputMode]) // Re-check when input mode changes (e.g., after /connect:claude) + + const isClaudeOAuthActive = getClaudeOAuthStatus().connected // Fetch Claude quota when OAuth is active const { data: claudeQuota } = useClaudeQuotaQuery({ From d4a15c556e8518842bfb399796f554f1b515dcf2 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 5 Jan 2026 21:37:50 -0800 Subject: [PATCH 19/20] Clean up duplicate models in claude oauth --- common/src/constants/claude-oauth.ts | 35 ++++++++++------------------ 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/common/src/constants/claude-oauth.ts b/common/src/constants/claude-oauth.ts index 442921fbc..faa3f3155 100644 --- a/common/src/constants/claude-oauth.ts +++ b/common/src/constants/claude-oauth.ts @@ -49,9 +49,6 @@ export const OPENROUTER_TO_ANTHROPIC_MODEL_MAP: Record = { 'anthropic/claude-3-5-haiku': 'claude-3-5-haiku-20241022', 'anthropic/claude-3-5-haiku-20241022': 'claude-3-5-haiku-20241022', 'anthropic/claude-3-haiku': 'claude-3-haiku-20240307', - 'claude-3.5-haiku': 'claude-3-5-haiku-20241022', - 'claude-3-5-haiku': 'claude-3-5-haiku-20241022', - 'claude-3-haiku': 'claude-3-haiku-20240307', // Claude 3.x Sonnet models 'anthropic/claude-3.5-sonnet': 'claude-3-5-sonnet-20241022', @@ -59,34 +56,23 @@ export const OPENROUTER_TO_ANTHROPIC_MODEL_MAP: Record = { 'anthropic/claude-3-5-sonnet-20241022': 'claude-3-5-sonnet-20241022', 'anthropic/claude-3-5-sonnet-20240620': 'claude-3-5-sonnet-20240620', 'anthropic/claude-3-sonnet': 'claude-3-sonnet-20240229', - 'claude-3.5-sonnet': 'claude-3-5-sonnet-20241022', - 'claude-3-5-sonnet': 'claude-3-5-sonnet-20241022', - 'claude-3-sonnet': 'claude-3-sonnet-20240229', // Claude 3.x Opus models 'anthropic/claude-3-opus': 'claude-3-opus-20240229', 'anthropic/claude-3-opus-20240229': 'claude-3-opus-20240229', - 'claude-3-opus': 'claude-3-opus-20240229', // Claude 4.x Haiku models 'anthropic/claude-haiku-4.5': 'claude-haiku-4-5-20251001', 'anthropic/claude-haiku-4': 'claude-haiku-4-20250514', - 'claude-haiku-4.5': 'claude-haiku-4-5-20251001', - 'claude-haiku-4': 'claude-haiku-4-20250514', // Claude 4.x Sonnet models 'anthropic/claude-sonnet-4.5': 'claude-sonnet-4-5-20250929', 'anthropic/claude-sonnet-4': 'claude-sonnet-4-20250514', - 'claude-sonnet-4.5': 'claude-sonnet-4-5-20250929', - 'claude-sonnet-4': 'claude-sonnet-4-20250514', // Claude 4.x Opus models 'anthropic/claude-opus-4.5': 'claude-opus-4-5-20251101', 'anthropic/claude-opus-4.1': 'claude-opus-4-1-20250805', 'anthropic/claude-opus-4': 'claude-opus-4-1-20250805', - 'claude-opus-4.5': 'claude-opus-4-5-20251101', - 'claude-opus-4.1': 'claude-opus-4-1-20250805', - 'claude-opus-4': 'claude-opus-4-1-20250805', } /** @@ -98,24 +84,27 @@ export function isClaudeModel(model: string): boolean { /** * Convert an OpenRouter model ID to an Anthropic model ID. - * Returns the original if no mapping exists. + * Throws an error if the model has a provider prefix but is not an Anthropic model. */ export function toAnthropicModelId(openrouterModel: string): string { // If it's already an Anthropic model ID (no prefix), return as-is if (!openrouterModel.includes('/')) { return openrouterModel } - + + // Require anthropic/ prefix for OpenRouter model IDs + if (!openrouterModel.startsWith('anthropic/')) { + throw new Error( + `Cannot convert non-Anthropic model to Anthropic model ID: ${openrouterModel}`, + ) + } + // Check the mapping table const mapped = OPENROUTER_TO_ANTHROPIC_MODEL_MAP[openrouterModel] if (mapped) { return mapped } - - // Fallback: strip the "anthropic/" prefix if present - if (openrouterModel.startsWith('anthropic/')) { - return openrouterModel.replace('anthropic/', '') - } - - return openrouterModel + + // Fallback: strip the "anthropic/" prefix + return openrouterModel.replace('anthropic/', '') } From 3c505a56e9d8b4da201d441d4becddc91a41e66b Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 5 Jan 2026 21:45:00 -0800 Subject: [PATCH 20/20] Cleanup for sdk/src/credentials --- sdk/src/credentials.ts | 92 ++++++++++++++++++------------------------ 1 file changed, 39 insertions(+), 53 deletions(-) diff --git a/sdk/src/credentials.ts b/sdk/src/credentials.ts index c6266e610..c6f103f06 100644 --- a/sdk/src/credentials.ts +++ b/sdk/src/credentials.ts @@ -12,11 +12,24 @@ import { getClaudeOAuthTokenFromEnv } from './env' import type { ClientEnv } from '@codebuff/common/types/contracts/env' import type { User } from '@codebuff/common/util/credentials' -const credentialsSchema = z - .object({ - default: userSchema, - }) - .catchall(userSchema) +/** + * Schema for Claude OAuth credentials. + */ +const claudeOAuthSchema = z.object({ + accessToken: z.string(), + refreshToken: z.string(), + expiresAt: z.number(), + connectedAt: z.number(), +}) + +/** + * Unified schema for the credentials file. + * Contains both Codebuff user credentials and Claude OAuth credentials. + */ +const credentialsFileSchema = z.object({ + default: userSchema.optional(), + claudeOAuth: claudeOAuthSchema.optional(), +}) const ensureDirectoryExistsSync = (dir: string) => { if (!fs.existsSync(dir)) { @@ -24,17 +37,12 @@ const ensureDirectoryExistsSync = (dir: string) => { } } -export const userFromJson = ( - json: string, - profileName: string = 'default', -): User | undefined => { +export const userFromJson = (json: string): User | null => { try { - const allCredentials = credentialsSchema.parse(JSON.parse(json)) - const profile = allCredentials[profileName] - return profile - } catch (error) { - console.error('Error parsing user JSON:', error) - return + const credentials = credentialsFileSchema.parse(JSON.parse(json)) + return credentials.default ?? null + } catch { + return null } } @@ -58,11 +66,6 @@ export const getCredentialsPath = (clientEnv: ClientEnv = env): string => { return path.join(getConfigDir(clientEnv), 'credentials.json') } -// Legacy exports for backward compatibility - use getConfigDir() and getCredentialsPath() for testability -export const CONFIG_DIR = getConfigDir() -ensureDirectoryExistsSync(CONFIG_DIR) -export const CREDENTIALS_PATH = getCredentialsPath() - export const getUserCredentials = (clientEnv: ClientEnv = env): User | null => { const credentialsPath = getCredentialsPath(clientEnv) if (!fs.existsSync(credentialsPath)) { @@ -89,24 +92,6 @@ export interface ClaudeOAuthCredentials { connectedAt: number // Unix timestamp in milliseconds } -/** - * Schema for Claude OAuth credentials in the credentials file. - */ -const claudeOAuthSchema = z.object({ - accessToken: z.string(), - refreshToken: z.string(), - expiresAt: z.number(), - connectedAt: z.number(), -}) - -/** - * Extended credentials file schema that includes Claude OAuth. - */ -const extendedCredentialsSchema = z.object({ - default: userSchema.optional(), - claudeOAuth: claudeOAuthSchema.optional(), -}).catchall(z.unknown()) - /** * Get Claude OAuth credentials from file or environment variable. * Environment variable takes precedence. @@ -135,7 +120,7 @@ export const getClaudeOAuthCredentials = ( try { const credentialsFile = fs.readFileSync(credentialsPath, 'utf8') - const parsed = extendedCredentialsSchema.safeParse(JSON.parse(credentialsFile)) + const parsed = credentialsFileSchema.safeParse(JSON.parse(credentialsFile)) if (!parsed.success || !parsed.data.claudeOAuth) { return null } @@ -201,9 +186,7 @@ export const clearClaudeOAuthCredentials = ( * Check if Claude OAuth credentials are valid (not expired). * Returns true if credentials exist and haven't expired. */ -export const isClaudeOAuthValid = ( - clientEnv: ClientEnv = env, -): boolean => { +export const isClaudeOAuthValid = (clientEnv: ClientEnv = env): boolean => { const credentials = getClaudeOAuthCredentials(clientEnv) if (!credentials) { return false @@ -237,17 +220,20 @@ export const refreshClaudeOAuthToken = async ( // Start the refresh and store the promise refreshPromise = (async () => { try { - const response = await fetch('https://console.anthropic.com/v1/oauth/token', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', + const response = await fetch( + 'https://console.anthropic.com/v1/oauth/token', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: credentials.refreshToken, + client_id: CLAUDE_OAUTH_CLIENT_ID, + }), }, - body: JSON.stringify({ - grant_type: 'refresh_token', - refresh_token: credentials.refreshToken, - client_id: CLAUDE_OAUTH_CLIENT_ID, - }), - }) + ) if (!response.ok) { // Refresh failed, clear credentials @@ -284,7 +270,7 @@ export const refreshClaudeOAuthToken = async ( /** * Get valid Claude OAuth credentials, refreshing if necessary. * This is the main function to use when you need credentials for an API call. - * + * * - Returns credentials immediately if valid (>5 min until expiry) * - Attempts refresh if token is expired or near-expiry * - Returns null if no credentials or refresh fails