diff --git a/bun.lock b/bun.lock index 386eabb69..35ac9c991 100644 --- a/bun.lock +++ b/bun.lock @@ -85,7 +85,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", @@ -215,9 +215,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", @@ -340,7 +341,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=="], @@ -1394,8 +1397,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=="], @@ -1430,7 +1431,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=="], @@ -3570,6 +3571,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=="], @@ -3762,6 +3767,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=="], @@ -4120,6 +4127,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=="], @@ -4292,6 +4303,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/chat.tsx b/cli/src/chat.tsx index 194d2a90b..3641515ab 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,15 @@ export const Chat = ({ isAskUserActive: askUserState !== null, }) const hasStatusIndicatorContent = statusIndicatorState.kind !== 'idle' + + const isClaudeOAuthActive = getClaudeOAuthStatus().connected + + // 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 +1392,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 +1556,12 @@ export const Chat = ({ cwd: getProjectRoot() ?? process.cwd(), })} /> + + ) diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index 03401fa04..b06242eb7 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -442,6 +442,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 5f3faa251..75d3ddc2c 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 { @@ -284,6 +285,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/bottom-status-line.tsx b/cli/src/components/bottom-status-line.tsx new file mode 100644 index 000000000..9d29ad27f --- /dev/null +++ b/cli/src/components/bottom-status-line.tsx @@ -0,0 +1,82 @@ +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' + +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 +} + +/** + * 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 + + // 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 ( + + + + Claude subscription + {isExhausted && resetTime ? ( + {` · resets in ${formatResetTime(resetTime)}`} + ) : displayRemaining !== null ? ( + {` ${Math.round(displayRemaining)}%`} + ) : null} + + + ) +} diff --git a/cli/src/components/claude-connect-banner.tsx b/cli/src/components/claude-connect-banner.tsx new file mode 100644 index 000000000..3283248db --- /dev/null +++ b/cli/src/components/claude-connect-banner.tsx @@ -0,0 +1,162 @@ +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) + const [isConnectHovered, setIsConnectHovered] = 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 ( + + + Waiting for authorization + + Sign in with your Claude account in the browser, then paste the code + here. + + + + ) + } + + // Not connected / checking state - show connect button + return ( + + + Connect to Claude + + Use your Pro/Max subscription + · + + + + + ) +} + +/** + * 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 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', + } + } +} 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/components/progress-bar.tsx b/cli/src/components/progress-bar.tsx new file mode 100644 index 000000000..e161772d2 --- /dev/null +++ b/cli/src/components/progress-bar.tsx @@ -0,0 +1,79 @@ +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 + foreground: string + warning: string + error: string + }, +): string => { + if (value <= 10) return theme.error + if (value <= 25) return theme.warning + return theme.foreground +} + +/** + * 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..54742c436 100644 --- a/cli/src/components/usage-banner.tsx +++ b/cli/src/components/usage-banner.tsx @@ -3,25 +3,57 @@ 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 { getBannerColorLevel, - generateUsageBannerText, generateLoadingBannerText, } 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' + +import { formatResetTime } from '../utils/time-format' const MANUAL_SHOW_TIMEOUT = 60 * 1000 // 1 minute const USAGE_POLL_INTERVAL = 30 * 1000 // 30 seconds +/** + * Format the renewal date for display + */ +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 }) => { 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, @@ -75,29 +107,84 @@ 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 ( setInputMode('default')} > - + + {/* Codebuff credits section - structured layout */} + + + {/* 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 + )} + + )} + ) } diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts index c8ecbdb61..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'], }, { @@ -103,5 +103,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/hooks/use-claude-quota-query.ts b/cli/src/hooks/use-claude-quota-query.ts new file mode 100644 index 000000000..91c1c98a9 --- /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 = 120 * 1000, // Default: refetch every 120 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 + }) +} 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/auth.ts b/cli/src/utils/auth.ts index 04c98e73b..fde353a54 100644 --- a/cli/src/utils/auth.ts +++ b/cli/src/utils/auth.ts @@ -24,11 +24,22 @@ 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) + .catchall(z.unknown()) // Get the config directory path export const getConfigDir = (): string => { @@ -58,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( { diff --git a/cli/src/utils/claude-oauth.ts b/cli/src/utils/claude-oauth.ts new file mode 100644 index 000000000..80bea1841 --- /dev/null +++ b/cli/src/utils/claude-oauth.ts @@ -0,0 +1,175 @@ +/** + * 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 } from '@codebuff/common/constants/claude-oauth' +import { + saveClaudeOAuthCredentials, + clearClaudeOAuthCredentials, + getClaudeOAuthCredentials, + isClaudeOAuthValid, + resetClaudeOAuthRateLimit, +} 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 + +/** + * 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) + + // Store the code verifier and state for later use + pendingCodeVerifier = codeVerifier + + // 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) + + // Reset any cached rate limit since user just reconnected + resetClaudeOAuthRateLimit() + + return credentials +} + +/** + * 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 8d166a584..be2196223 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' | 'outOfCredits' // Theme color keys that are valid color values (must match ChatTheme keys) @@ -97,6 +98,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, + }, outOfCredits: { icon: null, color: 'warning', 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. diff --git a/common/src/constants/claude-oauth.ts b/common/src/constants/claude-oauth.ts new file mode 100644 index 000000000..faa3f3155 --- /dev/null +++ b/common/src/constants/claude-oauth.ts @@ -0,0 +1,110 @@ +/** + * 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' + +/** + * 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. + * 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", + * while Anthropic uses versioned IDs like "claude-3-5-haiku-20241022". + */ +export const OPENROUTER_TO_ANTHROPIC_MODEL_MAP: Record = { + // 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', + + // 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.x Opus models + 'anthropic/claude-3-opus': 'claude-3-opus-20240229', + 'anthropic/claude-3-opus-20240229': '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 4.x Sonnet models + 'anthropic/claude-sonnet-4.5': 'claude-sonnet-4-5-20250929', + 'anthropic/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', +} + +/** + * 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. + * 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 + return openrouterModel.replace('anthropic/', '') +} 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..c6f103f06 100644 --- a/sdk/src/credentials.ts +++ b/sdk/src/credentials.ts @@ -3,17 +3,33 @@ 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' +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)) { @@ -21,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 } } @@ -55,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)) { @@ -75,3 +81,220 @@ 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 +} + +/** + * 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 = credentialsFileSchema.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 +} + +// 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/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 3f7cc62ce..fcaa4d390 100644 --- a/sdk/src/impl/llm.ts +++ b/sdk/src/impl/llm.ts @@ -1,17 +1,9 @@ -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' 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, @@ -23,8 +15,10 @@ import { TypeValidationError, } from 'ai' -import { WEBSITE_URL } from '../constants' -import type { LanguageModelV2 } from '@ai-sdk/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' import type { PromptAiSdkFn, @@ -37,14 +31,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]: [ @@ -114,78 +100,88 @@ function getProviderOptions(params: { } } -function getAiSdkModel(params: { - apiKey: string - model: string -}): LanguageModelV2 { - const { apiKey, model } = params +// Usage accounting type for OpenRouter/Codebuff backend responses +type OpenRouterUsageAccounting = { + cost: number | null + costDetails: { + upstreamInferenceCost: number | null + } +} - const openrouterUsage: OpenRouterUsageAccounting = { - cost: null, - costDetails: { - upstreamInferenceCost: null, - }, +/** + * 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 } - 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 } } - } + // Check status code + if (err.statusCode === 429) return true - 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 - } + // Check error message for rate limit indicators + const message = (err.message || '').toLowerCase() + const responseBody = (err.responseBody || '').toLowerCase() - 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 + 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 +} + +/** + * 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, + params: ParamsOf & { + skipClaudeOAuth?: boolean + onClaudeOAuthStatusChange?: (isActive: boolean) => void + }, ): ReturnType { const { logger } = params const agentChunkMetadata = @@ -202,13 +198,26 @@ export async function* promptAiSdkStream( return null } - let aiSDKModel = getAiSdkModel(params) + const modelParams: ModelRequestParams = { + apiKey: params.apiKey, + model: params.model, + skipClaudeOAuth: params.skipClaudeOAuth, + } + const { model: aiSDKModel, isClaudeOAuth } = await getModelForRequest(modelParams) + + // Notify about Claude OAuth usage + if (isClaudeOAuth && params.onClaudeOAuthStatusChange) { + params.onClaudeOAuthStatusChange(true) + } const response = streamText({ ...params, 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, @@ -329,10 +338,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', @@ -342,8 +355,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 @@ -379,6 +392,57 @@ 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', + ) + // 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) + } + // Retry with Codebuff backend + const fallbackResult = yield* promptAiSdkStream({ + ...params, + skipClaudeOAuth: true, + }) + 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 }, @@ -412,6 +476,7 @@ export async function* promptAiSdkStream( if (!params.stopSequences) { content += chunkValue.text if (chunkValue.text) { + hasYieldedContent = true yield { type: 'text', text: chunkValue.text, @@ -423,6 +488,7 @@ export async function* promptAiSdkStream( const stopSequenceResult = stopSequenceHandler.process(chunkValue.text) if (stopSequenceResult.text) { + hasYieldedContent = true content += stopSequenceResult.text yield { type: 'text', @@ -445,27 +511,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 @@ -487,7 +556,12 @@ export async function promptAiSdk( return '' } - let aiSDKModel = getAiSdkModel(params) + const modelParams: ModelRequestParams = { + apiKey: params.apiKey, + model: params.model, + skipClaudeOAuth: true, // Always use Codebuff backend for non-streaming + } + const { model: aiSDKModel } = await getModelForRequest(modelParams) const response = await generateText({ ...params, @@ -539,7 +613,12 @@ export async function promptAiSdkStructured( ) return {} as T } - let aiSDKModel = getAiSdkModel(params) + const modelParams: ModelRequestParams = { + apiKey: params.apiKey, + model: params.model, + skipClaudeOAuth: true, // Always use Codebuff backend for non-streaming + } + 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 new file mode 100644 index 000000000..8c14dc10e --- /dev/null +++ b/sdk/src/impl/model-provider.ts @@ -0,0 +1,364 @@ +/** + * 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 { + CLAUDE_CODE_SYSTEM_PROMPT_PREFIX, + CLAUDE_OAUTH_BETA_HEADERS, + isClaudeModel, + toAnthropicModelId, +} from '@codebuff/common/constants/claude-oauth' +import { + OpenAICompatibleChatLanguageModel, + VERSION, +} from '@codebuff/internal/openai-compatible/index' + +import { WEBSITE_URL } from '../constants' +import { getValidClaudeOAuthCredentials } from '../credentials' +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. + */ +export interface ModelRequestParams { + /** Codebuff API key for backend authentication */ + 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 +} + +/** + * 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. + * + * This function is async because it may need to refresh the OAuth token. + */ +export async function getModelForRequest(params: ModelRequestParams): Promise { + const { apiKey, model, skipClaudeOAuth } = params + + // Check if we should use Claude OAuth direct + // Skip if explicitly requested, if rate-limited, or if not a Claude model + if (!skipClaudeOAuth && !isClaudeOAuthRateLimited() && isClaudeModel(model)) { + // Get valid credentials (will refresh if needed) + const claudeOAuthCredentials = await getValidClaudeOAuthCredentials() + 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 and system prompt transformation + 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) + // 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 = [ + ...new Set([...CLAUDE_OAUTH_BETA_HEADERS, ...betaList]), + ].join(',') + headers.set('anthropic-beta', mergedBetas) + + // 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, { + ...modifiedInit, + 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, + }) +} diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 6c487a7fa..10a5b04c7 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -79,3 +79,4 @@ export { promptAiSdkStream, promptAiSdkStructured, } from './impl/llm' +export { resetClaudeOAuthRateLimit } from './impl/model-provider'