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

const API_BASE = "https://api.anthropic.com";
const PROGRAM = "cedar_ember";
export const CLAUDE_RESET_PROGRAM = "cedar_ember";
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 ClaudeResetClaimResponse = Schema.Struct({
result: Schema.Literals([
"reset",
"already_used",
Expand Down Expand Up @@ -143,11 +143,14 @@ const readAccessToken = Effect.fn("claudeResetCredits.readAccessToken")(function
return credentials.claudeAiOauth?.accessToken?.trim() || undefined;
});

/** Claude only reveals reset credits to a current Claude Code CLI's user agent. */
export const claudeCliUserAgent = (version: string) => `claude-cli/${version} (external, cli)`;

const withClaudeHeaders = (token: string, version: string) =>
HttpClientRequest.setHeaders({
authorization: `Bearer ${token}`,
"anthropic-beta": "oauth-2025-04-20",
"user-agent": `claude-cli/${version} (external, cli)`,
"user-agent": claudeCliUserAgent(version),
});

export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")(
Expand Down Expand Up @@ -187,7 +190,7 @@ export const claudeAccountConfigPath = Effect.fn("claudeAccountConfigPath")(func
: path.join(yield* HostProcess.HomeDirectory, ".claude.json");
});

const CLAIM_OUTCOMES = {
export const CLAUDE_RESET_CLAIM_OUTCOMES = {
reset: "reset",
not_limited: "nothingToReset",
already_used: "alreadyRedeemed",
Expand Down Expand Up @@ -226,7 +229,7 @@ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(fu
).pipe(
withClaudeHeaders(token, input.version),
HttpClientRequest.bodyJsonUnsafe({
program: PROGRAM,
program: CLAUDE_RESET_PROGRAM,
grant_id: input.grantId,
request_id: input.requestId,
}),
Expand All @@ -243,7 +246,7 @@ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(fu
return yield* new ClaudeResetCreditError({ reason: "signedOut" });
}
const body = yield* HttpClientResponse.filterStatusOk(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(ClaimResponse)),
Effect.flatMap(HttpClientResponse.schemaBodyJson(ClaudeResetClaimResponse)),
Effect.timeout("25 seconds"),
Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })),
);
Expand All @@ -254,5 +257,5 @@ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(fu
if (body.result === "unavailable") {
return yield* new ClaudeResetCreditError({ reason: "unconfirmed" });
}
return CLAIM_OUTCOMES[body.result];
return CLAUDE_RESET_CLAIM_OUTCOMES[body.result];
});
3 changes: 3 additions & 0 deletions apps/server/src/usage/UsageLimitSources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as Stream from "effect/Stream";
import { HttpClient, HttpClientResponse } from "effect/http";

import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as UsageLimitSources from "./UsageLimitSources.ts";

Expand Down Expand Up @@ -78,6 +79,7 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () =>
},
},
}),
Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
Layer.mock(BackgroundPolicy.BackgroundPolicy)({
shouldRunScopeWork: () => Effect.succeed(false),
}),
Expand Down Expand Up @@ -130,6 +132,7 @@ it.effect("reads a management key from its secret provider", () => {
},
},
}),
Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
Layer.mock(BackgroundPolicy.BackgroundPolicy)({
shouldRunScopeWork: () => Effect.succeed(false),
}),
Expand Down
26 changes: 22 additions & 4 deletions apps/server/src/usage/UsageLimitSources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type UsageLimitSourceSnapshot,
} from "@t3tools/contracts";
import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
import { compareSemverVersions } from "@t3tools/shared/semver";
import * as Context from "effect/Context";
import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
Expand All @@ -22,6 +23,7 @@ import * as Semaphore from "effect/Semaphore";
import * as Stream from "effect/Stream";

import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver";
import * as SecretResolverLayer from "../secrets/SecretResolver.ts";
import { resolveSettingsSecrets } from "../secrets/settingsSecretValue.ts";
Expand Down Expand Up @@ -62,6 +64,24 @@ export const make = Effect.gen(function* () {
const settingsService = yield* Settings.ServerSettingsService;
const secrets = yield* SecretResolver.SecretResolver;
const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
const claudeCliVersion = providerRegistry.getProviders.pipe(
Effect.map((providers) =>
providers
.flatMap((provider) => {
const version = provider.installedVersion ?? provider.version;
return provider.driver === "claudeAgent" && provider.enabled && version ? [version] : [];
})
.toSorted(compareSemverVersions)
.at(-1),
),
);
const hub = Effect.fn("UsageLimitSources.hub")(function* (
config: UsageLimitSourceConfig,
managementKey: string,
) {
return { url: config.url, managementKey, claudeCliVersion: yield* claudeCliVersion };
});
const readManagementKey = Effect.fn("UsageLimitSources.readManagementKey")(function* (
config: UsageLimitSourceConfig,
) {
Expand All @@ -84,9 +104,7 @@ export const make = Effect.gen(function* () {
if (managementKey.length === 0) {
return { ...base, accounts: [], error: missingManagementKey(config) };
}
const accounts = yield* api
.readAccounts({ url: config.url, managementKey })
.pipe(Effect.result);
const accounts = yield* api.readAccounts(yield* hub(config, managementKey)).pipe(Effect.result);
if (accounts._tag === "Failure") {
yield* Effect.logDebug("usage limit source read failed", { id, cause: accounts.failure });
return { ...base, accounts: [], error: accounts.failure.detail };
Expand Down Expand Up @@ -138,7 +156,7 @@ export const make = Effect.gen(function* () {
return yield* new UsageLimitSourceError({ detail: missingManagementKey(config) });
}
const result = yield* api.consume(
{ url: config.url, managementKey },
yield* hub(config, managementKey),
input.accountId,
input.creditId,
);
Expand Down
103 changes: 103 additions & 0 deletions apps/server/src/usage/cliproxyApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,109 @@ describe("CLIProxyAPI built-in management API", () => {
}),
);

it.effect(
"shows Claude reset credits only when the hub knows the current Claude Code version",
() =>
Effect.gen(function* () {
const grant = (id: string, ends_at: string) => ({
id,
resets_left: 1,
ends_at,
paused: false,
usable_now: true,
});
const test = fixture({
accounts: [{ ...accounts[0]!, provider: "claude" }],
// Claude hides grants from any user agent other than a current Claude Code CLI.
upstream: (request) => ({
status: 200,
body: {
seven_day: { utilization: 100, resets_at: "2099-01-01T00:00:00Z" },
cedar_ember: request.header?.["User-Agent"]?.startsWith("claude-cli/2.1.319 ")
? {
eligible: true,
next_grant_id: "full-reset-2611",
grants: [
grant("full-reset-2611", "2099-12-10T22:00:00+00:00"),
grant("launch-2610-fable", "2099-03-09T07:59:00+00:00"),
],
}
: { eligible: false, ineligible_reason: "surface", grants: [] },
},
}),
});
const api = yield* test.api;
const current = yield* api.readAccounts({ ...config, claudeCliVersion: "2.1.319" });
expect(current[0]?.usageLimits.resetCredits).toEqual({
availableCount: 2,
nextCreditId: "full-reset-2611",
nextExpiresAt: "2099-12-10T22:00:00.000Z",
});
const unknown = yield* api.readAccounts(config);
expect(unknown[0]?.usageLimits.resetCredits).toBeUndefined();
expect(unknown[0]?.usageLimits.windows.map((window) => window.id)).toEqual(["seven_day"]);
}),
);

it.effect("redeems a Claude reset in the account's organization and clears its cooldown", () =>
Effect.gen(function* () {
const test = fixture({
accounts: [{ ...accounts[0]!, provider: "claude" }],
upstream: (request) => ({
status: 200,
body: request.url?.endsWith("/api/oauth/profile")
? { organization: { uuid: "org-1" } }
: { result: "reset" },
}),
});
const api = yield* test.api;
const hub = { ...config, claudeCliVersion: "2.1.319" };
expect(yield* api.consume(hub, "first.json", "full-reset-2611")).toEqual({
outcome: "reset",
});
expect(yield* api.consume(hub, "first.json", "full-reset-2611")).toEqual({
outcome: "reset",
});
const claims = test.requests.filter((request) =>
request.body?.url?.endsWith("/reset_rate_limits"),
);
expect(claims.map((request) => request.body?.url)).toEqual([
"https://api.anthropic.com/api/organizations/org-1/reset_rate_limits",
"https://api.anthropic.com/api/organizations/org-1/reset_rate_limits",
]);
// A retried click reuses the request id, so Claude spends the grant at most once.
expect(claims[0]?.body?.data).toBe(claims[1]?.body?.data);
expect(JSON.parse(claims[0]!.body!.data!)).toMatchObject({
program: "cedar_ember",
grant_id: "full-reset-2611",
});
expect(
test.requests
.filter((request) => request.path.endsWith("/reset-quota"))
.map((request) => request.body?.auth_index),
).toEqual(["a", "a"]);
}),
);

it.effect("reports a Claude reset cooldown without clearing the hub cooldown", () =>
Effect.gen(function* () {
const test = fixture({
accounts: [{ ...accounts[0]!, provider: "claude" }],
upstream: (request) => ({
status: 200,
body: request.url?.endsWith("/api/oauth/profile")
? { organization: { uuid: "org-1" } }
: { result: "cooldown" },
}),
});
const result = yield* (yield* test.api)
.consume({ ...config, claudeCliVersion: "2.1.319" }, "first.json", "full-reset-2611")
.pipe(Effect.flip);
expect(result.detail).toContain("cooling down");
expect(test.requests.some((request) => request.path.endsWith("/reset-quota"))).toBe(false);
}),
);

it.effect("pins redemption to the displayed credit and clears only that account's cooldown", () =>
Effect.gen(function* () {
const test = fixture();
Expand Down
Loading
Loading