Skip to content
Open
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
8 changes: 4 additions & 4 deletions apps/server/src/provider/claudeResetCredits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import * as Schema from "effect/Schema";
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";

const API_BASE = "https://api.anthropic.com";
const PROGRAM = "cedar_ember";
const GRANT_ID = /^[a-z0-9_-]{1,40}$/;
export const PROGRAM = "cedar_ember";
export const GRANT_ID = /^[a-z0-9_-]{1,40}$/;
const REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/;
const COMPLETE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;

Expand Down Expand Up @@ -42,7 +42,7 @@ const UsageResponse = Schema.Struct({
cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)),
});
const decodeCedarEmber = Schema.decodeUnknownOption(CedarEmber);
const ClaimResponse = Schema.Struct({
export const ClaimResponse = Schema.Struct({
result: Schema.Literals([
"reset",
"already_used",
Expand Down Expand Up @@ -187,7 +187,7 @@ export const claudeAccountConfigPath = Effect.fn("claudeAccountConfigPath")(func
: path.join(yield* HostProcess.HomeDirectory, ".claude.json");
});

const CLAIM_OUTCOMES = {
export const CLAIM_OUTCOMES = {
reset: "reset",
not_limited: "nothingToReset",
already_used: "alreadyRedeemed",
Expand Down
93 changes: 78 additions & 15 deletions apps/server/src/usage/cliproxyApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,25 +312,42 @@ describe("CLIProxyAPI built-in management API", () => {
}),
);

it.effect("maps Claude scoped windows without a scheduler plugin", () =>
const claudeUsage = {
five_hour: { utilization: 10, resets_at: null },
seven_day: { utilization: 50, resets_at: "2099-01-01T00:00:00Z" },
limits: [
{
kind: "weekly_scoped",
percent: 80,
resets_at: null,
scope: { model: { display_name: "Fable" } },
},
],
cedar_ember: {
eligible: true,
grants: [
{
id: "free_reset",
resets_total: 1,
resets_left: 1,
ends_at: "2099-01-01T00:00:00Z",
paused: false,
usable_now: true,
},
],
next_grant_id: "free_reset",
},
};

it.effect("maps Claude scoped windows and banked resets without a scheduler plugin", () =>
Effect.gen(function* () {
const test = fixture({
accounts: [{ ...accounts[0]!, provider: "claude" }],
// The usage endpoint answers 429 to requests without a Claude Code user agent.
// The usage endpoint answers 429, and reports no reset grants, to requests
// without a Claude CLI user agent.
upstream: (request) => ({
status: request.header?.["User-Agent"]?.startsWith("claude-code/") ? 200 : 429,
body: {
five_hour: { utilization: 10, resets_at: null },
seven_day: { utilization: 50, resets_at: "2099-01-01T00:00:00Z" },
limits: [
{
kind: "weekly_scoped",
percent: 80,
resets_at: null,
scope: { model: { display_name: "Fable" } },
},
],
},
status: request.header?.["User-Agent"]?.startsWith("claude-cli/") ? 200 : 429,
body: claudeUsage,
}),
});
const api = yield* test.api;
Expand All @@ -342,6 +359,52 @@ describe("CLIProxyAPI built-in management API", () => {
["seven_day", 50],
["seven_day_fable", 80],
]);
expect(result[0]?.usageLimits.resetCredits).toEqual({
availableCount: 1,
nextCreditId: "free_reset",
nextExpiresAt: "2099-01-01T00:00:00.000Z",
});
expect(test.requests.find((request) => request.body?.url)?.body?.url).toContain(
"cedar_ember=1",
);
}),
);

it.effect("claims a Claude grant through the hub and clears only that account's cooldown", () =>
Effect.gen(function* () {
let result = "reset";
const test = fixture({
accounts: [{ ...accounts[0]!, provider: "claude" }],
upstream: (request) => ({
status: 200,
body: request.url?.endsWith("/api/oauth/profile")
? { organization: { uuid: "org-1" } }
: request.url?.endsWith("/reset_rate_limits")
? { result }
: claudeUsage,
}),
});
const api = yield* test.api;
expect(yield* api.consume(config, "first.json", "free_reset")).toEqual({ outcome: "reset" });
result = "not_limited";
expect(yield* api.consume(config, "first.json", "free_reset")).toEqual({
outcome: "nothingToReset",
});
const claims = test.requests.filter((request) =>
request.body?.url?.endsWith("/api/organizations/org-1/reset_rate_limits"),
);
expect(claims).toHaveLength(2);
expect(claims[0]?.body?.data).toBe(claims[1]?.body?.data);
expect(JSON.parse(claims[0]?.body?.data ?? "{}")).toMatchObject({
program: "cedar_ember",
grant_id: "free_reset",
request_id: expect.stringMatching(/^[0-9a-f-]{36}$/),
});
expect(
test.requests
.filter((request) => request.path.endsWith("/reset-quota"))
.map((request) => request.body?.auth_index),
).toEqual(["a"]);
}),
);

Expand Down
150 changes: 119 additions & 31 deletions apps/server/src/usage/cliproxyApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } fr
import { codexPlanLabel } from "../provider/CodexProvider.ts";
import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts";
import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts";
import {
CLAIM_OUTCOMES as CLAUDE_CLAIM_OUTCOMES,
ClaimResponse as ClaudeClaimResponse,
GRANT_ID as CLAUDE_GRANT_ID,
PROGRAM as CLAUDE_RESET_PROGRAM,
claudeResetCreditsToContract,
} from "../provider/claudeResetCredits.ts";
import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits";

const AuthFile = Schema.Struct({
Expand Down Expand Up @@ -71,6 +78,10 @@ const ClaudeUsage = Schema.Struct({
}),
),
),
cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)),
});
const ClaudeProfile = Schema.Struct({
organization: Schema.optional(Schema.NullOr(Schema.Struct({ uuid: Schema.String }))),
});
const CreditList = Schema.Struct({
credits: Schema.Array(
Expand All @@ -88,6 +99,8 @@ const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
const decodeApiResponse = Schema.decodeUnknownEffect(ApiResponse);
const decodeCreditList = Schema.decodeUnknownEffect(Schema.fromJsonString(CreditList));
const decodeClaudeUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaudeUsage));
const decodeClaudeProfile = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaudeProfile));
const decodeClaudeClaim = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaudeClaimResponse));
const decodeCodexUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(CodexUsage));
const isUsageLimitSourceError = Schema.is(UsageLimitSourceError);

Expand Down Expand Up @@ -133,6 +146,7 @@ const decodeConsumeResponse = Schema.decodeUnknownEffect(
),
);

const CLAUDE_BASE = "https://api.anthropic.com";
const CODEX_BASE = "https://chatgpt.com/backend-api/wham";
const CREDIT_URL = `${CODEX_BASE}/rate-limit-reset-credits`;

Expand Down Expand Up @@ -214,9 +228,11 @@ export const makeCliproxyApi = Effect.gen(function* () {
}
: {
Authorization: "Bearer $TOKEN$",
"Content-Type": "application/json",
"anthropic-beta": "oauth-2025-04-20",
// The OAuth usage endpoint can return 429 without a Claude Code user agent.
"User-Agent": "claude-code/2.1.273",
// The OAuth usage endpoint answers 429 without a Claude CLI user agent and
// reports banked reset grants only to CLI versions from 2.1.280 on.
"User-Agent": "claude-cli/2.1.312 (external, cli)",
};
const raw = yield* management(config, "api-call", {
auth_index: account.auth_index,
Expand Down Expand Up @@ -266,8 +282,16 @@ export const makeCliproxyApi = Effect.gen(function* () {
};
const read = Effect.gen(function* () {
if (account.provider === "claude") {
const body = yield* apiCall(config, account, "https://api.anthropic.com/api/oauth/usage");
const body = yield* apiCall(
config,
account,
`${CLAUDE_BASE}/api/oauth/usage?cedar_ember=1&skip_spend=1`,
);
const usage = yield* decodeClaudeUsage(body);
const resetCredits = claudeResetCreditsToContract(
usage.cedar_ember,
DateTime.toEpochMillis(yield* DateTime.now),
);
const model_scoped = (usage.limits ?? []).flatMap((limit) =>
limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number"
? [
Expand All @@ -282,17 +306,20 @@ export const makeCliproxyApi = Effect.gen(function* () {
return {
...base,
plan: "Claude Subscription",
usageLimits: claudeUsageResponseToLimits({
checkedAt,
response: {
rate_limits_available: true,
rate_limits: {
five_hour: usage.five_hour ?? null,
seven_day: usage.seven_day ?? null,
model_scoped,
usageLimits: {
...claudeUsageResponseToLimits({
checkedAt,
response: {
rate_limits_available: true,
rate_limits: {
five_hour: usage.five_hour ?? null,
seven_day: usage.seven_day ?? null,
model_scoped,
},
},
},
}).limits,
}).limits,
...(resetCredits ? { resetCredits } : {}),
},
};
}
const body = yield* apiCall(config, account, `${CODEX_BASE}/usage`);
Expand Down Expand Up @@ -371,34 +398,95 @@ export const makeCliproxyApi = Effect.gen(function* () {
);
});

const redeemCodexCredit = Effect.fn("CliproxyApi.redeemCodexCredit")(function* (
config: UsageLimitSourceConfig,
account: typeof AuthFile.Type,
creditId: string,
) {
const body = yield* apiCall(config, account, `${CREDIT_URL}/consume`, {
redeem_request_id: yield* creditRedeemRequestId(
account.id_token?.chatgpt_account_id ?? account.id,
creditId,
).pipe(Effect.provideService(Crypto.Crypto, crypto)),
credit_id: creditId,
});
const response = yield* decodeConsumeResponse(body);
return (
{
reset: "reset",
nothing_to_reset: "nothingToReset",
no_credit: "noCredit",
already_redeemed: "alreadyRedeemed",
} as const
)[response.code];
});

// Same claim the Claude CLI sends; request_id is the idempotency key, so a retry
// addresses the claim already made.
const claimClaudeGrant = Effect.fn("CliproxyApi.claimClaudeGrant")(function* (
config: UsageLimitSourceConfig,
account: typeof AuthFile.Type,
grantId: string,
) {
if (!CLAUDE_GRANT_ID.test(grantId)) {
return yield* new UsageLimitSourceError({ detail: "The Claude reset grant is not valid." });
}
const profile = yield* decodeClaudeProfile(
yield* apiCall(config, account, `${CLAUDE_BASE}/api/oauth/profile`),
);
const organization = profile.organization?.uuid.trim();
if (!organization) {
return yield* new UsageLimitSourceError({
detail: "The hub could not read the Claude account's organization.",
});
}
const body = yield* apiCall(
config,
account,
`${CLAUDE_BASE}/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`,
{
program: CLAUDE_RESET_PROGRAM,
grant_id: grantId,
request_id: yield* creditRedeemRequestId(account.id, grantId).pipe(
Effect.provideService(Crypto.Crypto, crypto),
),
},
);
const claim = yield* decodeClaudeClaim(body);
if (claim.result === "cooldown") {
return yield* new UsageLimitSourceError({
detail: "Claude resets are cooling down. Try again later.",
});
}
if (claim.result === "unavailable") {
return yield* new UsageLimitSourceError({
detail:
"Claude could not confirm the reset. If you are still limited in a moment, try again.",
});
}
return CLAUDE_CLAIM_OUTCOMES[claim.result];
});

const consume = Effect.fn("CliproxyApi.consume")(function* (
config: UsageLimitSourceConfig,
accountId: string,
creditId: string,
): Effect.fn.Return<ProviderConsumeResetCreditResult, UsageLimitSourceError> {
const operation = Effect.gen(function* () {
const account = (yield* authFiles(config)).find((account) => account.id === accountId);
if (!account || account.disabled || account.provider !== "codex") {
if (
!account ||
account.disabled ||
(account.provider !== "codex" && account.provider !== "claude")
) {
return yield* new UsageLimitSourceError({
detail: "The Codex hub account is missing or disabled.",
detail: "The hub account is missing or disabled.",
});
}
const body = yield* apiCall(config, account, `${CREDIT_URL}/consume`, {
redeem_request_id: yield* creditRedeemRequestId(
account.id_token?.chatgpt_account_id ?? account.id,
creditId,
).pipe(Effect.provideService(Crypto.Crypto, crypto)),
credit_id: creditId,
});
const response = yield* decodeConsumeResponse(body);
const outcome = (
{
reset: "reset",
nothing_to_reset: "nothingToReset",
no_credit: "noCredit",
already_redeemed: "alreadyRedeemed",
} as const
)[response.code];
const outcome =
account.provider === "claude"
? yield* claimClaudeGrant(config, account, creditId)
: yield* redeemCodexCredit(config, account, creditId);
if (outcome !== "reset" && outcome !== "alreadyRedeemed") return { outcome };
const cleared = yield* management(config, "reset-quota", {
auth_index: account.auth_index,
Expand Down
6 changes: 3 additions & 3 deletions docs/user/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ part of a segment is what that reset restores. Tap a segment or account row for
where it is signed in, and its reset time. On web, you can hover too. Codex and Claude accounts
with banked reset credits show a ticket count and the **Use reset** action in the account details.
Claude resets are not available when the server runs on macOS, where Claude keeps its login in the
Keychain. On narrow screens, numbered rows below
Keychain; a hub reports them on any platform. On narrow screens, numbered rows below
the bar show each account's quota, countdown, and credits. Tap a row to open its details.

The same account signed in on more than one environment, or reported by a hub as well, counts once.
Expand Down Expand Up @@ -139,8 +139,8 @@ using a proxy through `ANTHROPIC_AUTH_TOKEN`.
To see pooled accounts, open **Settings → Providers → Usage providers → Add hub**. Choose the
environment that will connect to the hub and enter its URL and management key.

The accounts appear under **Usage → Limits**. Codex accounts show banked reset credits; select an
account and choose **Use reset** to redeem one. No hub plugin is required.
The accounts appear under **Usage → Limits**. Codex and Claude accounts show banked reset credits;
select an account and choose **Use reset** to redeem one. No hub plugin is required.

This connection supplies usage information; configure
the provider separately to send agent requests through the hub. Remove the hub from the same
Expand Down
Loading