Skip to content

Commit 38092ca

Browse files
committed
Don't use claude subscription until cached time subscription becomes allowed
1 parent 0aea8b8 commit 38092ca

File tree

4 files changed

+117
-4
lines changed

4 files changed

+117
-4
lines changed

cli/src/utils/claude-oauth.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
clearClaudeOAuthCredentials,
1111
getClaudeOAuthCredentials,
1212
isClaudeOAuthValid,
13+
resetClaudeOAuthRateLimit,
1314
} from '@codebuff/sdk'
1415

1516
import type { ClaudeOAuthCredentials } from '@codebuff/sdk'
@@ -136,6 +137,9 @@ export async function exchangeCodeForTokens(
136137
// Save credentials to file
137138
saveClaudeOAuthCredentials(credentials)
138139

140+
// Reset any cached rate limit since user just reconnected
141+
resetClaudeOAuthRateLimit()
142+
139143
return credentials
140144
}
141145

sdk/src/impl/llm.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { getByokOpenrouterApiKeyFromEnv } from '../env'
2-
import { BYOK_OPENROUTER_HEADER } from '@codebuff/common/constants/byok'
31
import { models, PROFIT_MARGIN } from '@codebuff/common/old-constants'
42
import { buildArray } from '@codebuff/common/util/array'
53
import { getErrorObject } from '@codebuff/common/util/error'
@@ -17,7 +15,8 @@ import {
1715
TypeValidationError,
1816
} from 'ai'
1917

20-
import { getModelForRequest } from './model-provider'
18+
import { getModelForRequest, markClaudeOAuthRateLimited, fetchClaudeOAuthResetTime } from './model-provider'
19+
import { getValidClaudeOAuthCredentials } from '../credentials'
2120

2221
import type { ModelRequestParams } from './model-provider'
2322
import type { OpenRouterProviderRoutingOptions } from '@codebuff/common/types/agent-template'
@@ -404,6 +403,13 @@ export async function* promptAiSdkStream(
404403
{ error: getErrorObject(chunkValue.error) },
405404
'Claude OAuth rate limited during stream, falling back to Codebuff backend',
406405
)
406+
// Try to get the actual reset time from the quota API, fall back to default cooldown
407+
const credentials = await getValidClaudeOAuthCredentials()
408+
const resetTime = credentials?.accessToken
409+
? await fetchClaudeOAuthResetTime(credentials.accessToken)
410+
: null
411+
// Mark as rate-limited so subsequent requests skip Claude OAuth
412+
markClaudeOAuthRateLimited(resetTime ?? undefined)
407413
if (params.onClaudeOAuthStatusChange) {
408414
params.onClaudeOAuthStatusChange(false)
409415
}

sdk/src/impl/model-provider.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,107 @@ import { getByokOpenrouterApiKeyFromEnv } from '../env'
2727

2828
import type { LanguageModel } from 'ai'
2929

30+
// ============================================================================
31+
// Claude OAuth Rate Limit Cache
32+
// ============================================================================
33+
34+
/** Timestamp (ms) when Claude OAuth rate limit expires, or null if not rate-limited */
35+
let claudeOAuthRateLimitedUntil: number | null = null
36+
37+
/**
38+
* Mark Claude OAuth as rate-limited. Subsequent requests will skip Claude OAuth
39+
* and use Codebuff backend until the reset time.
40+
* @param resetAt - When the rate limit resets. If not provided, guesses 5 minutes from now.
41+
*/
42+
export function markClaudeOAuthRateLimited(resetAt?: Date): void {
43+
const fiveMinutesFromNow = Date.now() + 5 * 60 * 1000
44+
claudeOAuthRateLimitedUntil = resetAt ? resetAt.getTime() : fiveMinutesFromNow
45+
}
46+
47+
/**
48+
* Check if Claude OAuth is currently rate-limited.
49+
* Returns true if rate-limited and reset time hasn't passed.
50+
*/
51+
export function isClaudeOAuthRateLimited(): boolean {
52+
if (claudeOAuthRateLimitedUntil === null) {
53+
return false
54+
}
55+
if (Date.now() >= claudeOAuthRateLimitedUntil) {
56+
// Rate limit expired, clear the cache
57+
claudeOAuthRateLimitedUntil = null
58+
return false
59+
}
60+
return true
61+
}
62+
63+
/**
64+
* Reset the Claude OAuth rate limit cache.
65+
* Call this when user reconnects their Claude subscription.
66+
*/
67+
export function resetClaudeOAuthRateLimit(): void {
68+
claudeOAuthRateLimitedUntil = null
69+
}
70+
71+
// ============================================================================
72+
// Claude OAuth Quota Fetching
73+
// ============================================================================
74+
75+
interface ClaudeQuotaWindow {
76+
utilization: number
77+
resets_at: string | null
78+
}
79+
80+
interface ClaudeQuotaResponse {
81+
five_hour: ClaudeQuotaWindow | null
82+
seven_day: ClaudeQuotaWindow | null
83+
seven_day_oauth_apps: ClaudeQuotaWindow | null
84+
seven_day_opus: ClaudeQuotaWindow | null
85+
}
86+
87+
/**
88+
* Fetch the rate limit reset time from Anthropic's quota API.
89+
* Returns the earliest reset time (whichever limit is more restrictive).
90+
* Returns null if fetch fails or no reset time is available.
91+
*/
92+
export async function fetchClaudeOAuthResetTime(accessToken: string): Promise<Date | null> {
93+
try {
94+
const response = await fetch('https://api.anthropic.com/api/oauth/usage', {
95+
method: 'GET',
96+
headers: {
97+
Authorization: `Bearer ${accessToken}`,
98+
Accept: 'application/json',
99+
'Content-Type': 'application/json',
100+
'anthropic-version': '2023-06-01',
101+
'anthropic-beta': 'oauth-2025-04-20,claude-code-20250219',
102+
},
103+
})
104+
105+
if (!response.ok) {
106+
return null
107+
}
108+
109+
const data = (await response.json()) as ClaudeQuotaResponse
110+
111+
// Parse reset times
112+
const fiveHour = data.five_hour
113+
const sevenDay = data.seven_day
114+
115+
const fiveHourRemaining = fiveHour ? Math.max(0, 100 - fiveHour.utilization) : 100
116+
const sevenDayRemaining = sevenDay ? Math.max(0, 100 - sevenDay.utilization) : 100
117+
118+
// Return the reset time for whichever limit is more restrictive (lower remaining)
119+
if (fiveHourRemaining <= sevenDayRemaining && fiveHour?.resets_at) {
120+
return new Date(fiveHour.resets_at)
121+
} else if (sevenDay?.resets_at) {
122+
return new Date(sevenDay.resets_at)
123+
}
124+
125+
return null
126+
} catch {
127+
return null
128+
}
129+
}
130+
30131
/**
31132
* Parameters for requesting a model.
32133
*/
@@ -69,7 +170,8 @@ export async function getModelForRequest(params: ModelRequestParams): Promise<Mo
69170
const { apiKey, model, skipClaudeOAuth } = params
70171

71172
// Check if we should use Claude OAuth direct
72-
if (!skipClaudeOAuth && isClaudeModel(model)) {
173+
// Skip if explicitly requested, if rate-limited, or if not a Claude model
174+
if (!skipClaudeOAuth && !isClaudeOAuthRateLimited() && isClaudeModel(model)) {
73175
// Get valid credentials (will refresh if needed)
74176
const claudeOAuthCredentials = await getValidClaudeOAuthCredentials()
75177
if (claudeOAuthCredentials) {

sdk/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,4 @@ export {
7979
promptAiSdkStream,
8080
promptAiSdkStructured,
8181
} from './impl/llm'
82+
export { resetClaudeOAuthRateLimit } from './impl/model-provider'

0 commit comments

Comments
 (0)