Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions apps/server/src/usage/cliproxyApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
66 changes: 54 additions & 12 deletions apps/server/src/usage/cliproxyApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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`;

Expand Down Expand Up @@ -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}).`,
Expand All @@ -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* (
Expand Down Expand Up @@ -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
Expand Down
Loading