diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 431af7b5971ff..1ad7eb9345a34 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -373,6 +373,44 @@ describe("CLIProxyAPI built-in management API", () => { }), ); + const claudeHub = () => { + const state = { usageStatus: 200 }; + const test = fixture({ + accounts: [{ ...accounts[0]!, provider: "claude" }], + upstream: () => ({ + status: state.usageStatus, + body: { five_hour: { utilization: 10, resets_at: null } }, + }), + }); + const usageProbes = () => + test.requests.filter((request) => request.body?.url?.endsWith("/api/oauth/usage")).length; + return { state, api: test.api, usageProbes }; + }; + + it.effect("reuses a Claude reading taken seconds ago instead of probing again", () => + Effect.gen(function* () { + const hub = claudeHub(); + const api = yield* hub.api; + yield* api.readAccounts(config); + yield* TestClock.adjust("5 seconds"); + yield* api.readAccounts(config); + expect(hub.usageProbes()).toBe(1); + }), + ); + + it.effect("keeps the last Claude reading when a later probe answers 429", () => + Effect.gen(function* () { + const hub = claudeHub(); + const api = yield* hub.api; + yield* api.readAccounts(config); + hub.state.usageStatus = 429; + yield* TestClock.adjust("2 minutes"); + const result = yield* api.readAccounts(config); + expect(result[0]?.usageLimits.unavailable).toBeUndefined(); + expect(result[0]?.usageLimits.windows[0]?.usedPercent).toBe(10); + }), + ); + it.effect.each([ { name: "spend budget", diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 030983f50e33d..a0ab412e5e463 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -7,6 +7,7 @@ import { } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -168,6 +169,10 @@ const decodeConsumeResponse = Schema.decodeUnknownEffect( ), ); +// Claude's OAuth usage endpoint answers bursts of reads with 429, so readings are reused this long. +const USAGE_READ_INTERVAL_MS = 60_000; +const RATE_LIMITED_DETAIL = "The provider is rate limiting the hub's usage requests."; + const CODEX_BASE = "https://chatgpt.com/backend-api/wham"; const CREDIT_URL = `${CODEX_BASE}/rate-limit-reset-credits`; @@ -453,6 +458,11 @@ export const makeCliproxyApi = Effect.gen(function* () { ...(data === undefined ? {} : { data: yield* encodeJson(data) }), }); const response = yield* decodeApiResponse(raw); + if (response.status_code === 429) { + return yield* new UsageLimitSourceError({ + detail: RATE_LIMITED_DETAIL, + }); + } if (response.status_code < 200 || response.status_code >= 300) { return yield* new UsageLimitSourceError({ detail: `The provider refused the hub request (HTTP ${response.status_code}).`, @@ -461,26 +471,57 @@ export const makeCliproxyApi = Effect.gen(function* () { return response.body; }); + // Per hub account: when it was last probed and what to show until the next probe. + const usageReads = new Map< + string, + { readonly probedAt: number; readonly reading: UsageLimitSourceAccount; readonly good: boolean } + >(); + const usageReadKey = (hub: CliproxyHub, accountId: string) => + JSON.stringify([hub.url, accountId]); + const readAccount = Effect.fn("CliproxyApi.readAccount")(function* ( hub: CliproxyHub, account: AuthFile, provider: HubProvider, ) { + const key = usageReadKey(hub, account.id); + const now = yield* Clock.currentTimeMillis; + const previous = usageReads.get(key); + if (previous && now - previous.probedAt < USAGE_READ_INTERVAL_MS) return previous.reading; const checkedAt = DateTime.formatIso(yield* DateTime.now); const call: HubCall = (url, data) => apiCall(hub, account, provider.header(account, platform, architecture), url, data); - const reading = yield* provider.read({ account, call, checkedAt }).pipe( - Effect.provideService(Digest.Digest, digest), - Effect.orElseSucceed(() => ({ - ...listedIdentity(account), - usageLimits: makeUnavailableUsageLimits({ - checkedAt, - reason: "probeFailed", - message: "The hub could not read this account's usage.", - }), - })), - ); - return { id: account.id, driver: provider.driver, ...reading }; + const result = yield* provider + .read({ account, call, checkedAt }) + .pipe(Effect.provideService(Digest.Digest, digest), Effect.result); + const entry = + result._tag === "Success" + ? { + reading: { id: account.id, driver: provider.driver, ...result.success }, + good: true, + } + : previous?.good + ? // Keep the last known bars; their checkedAt still says how old they are. + previous + : { + reading: { + id: account.id, + driver: provider.driver, + ...listedIdentity(account), + usageLimits: makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + message: + isUsageLimitSourceError(result.failure) && + result.failure.detail === RATE_LIMITED_DETAIL + ? RATE_LIMITED_DETAIL + : "The hub could not read this account's usage.", + }), + }, + good: false, + }; + usageReads.set(key, { probedAt: now, reading: entry.reading, good: entry.good }); + return entry.reading; }); const readAccounts = Effect.fn("CliproxyApi.readAccounts")(function* ( @@ -552,6 +593,7 @@ export const makeCliproxyApi = Effect.gen(function* () { } as const; }); return yield* operation.pipe( + Effect.ensuring(Effect.sync(() => usageReads.delete(usageReadKey(hub, accountId)))), Effect.mapError((error) => isUsageLimitSourceError(error) ? error