Skip to content
Source
Sign inSign up

cliproxyApi.ts

565 lines20.4 KB

1import {
2 ProviderDriverKind,
3 UsageLimitSourceError,
4 type ProviderConsumeResetCreditResult,
5 type UsageLimitSourceAccount,
6 type UsageLimitSourceConfig,
7} from "@t3tools/contracts";
8import * as HostProcess from "@t3tools/shared/HostProcess";
9import * as Cause from "effect/Cause";
10import * as Digest from "effect/crypto/Digest";
11import * as DateTime from "effect/DateTime";
12import * as Effect from "effect/Effect";
13import * as Hex from "effect/encoding/Hex";
14import * as Option from "effect/Option";
15import * as Schema from "effect/Schema";
16import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";
17​
18import {
19 GROK_USAGE_URL,
20 GrokUsageResponse,
21 grokUsageResponseToLimits,
22} from "@t3tools/provider-grok/server";
23import { codexPlanLabel } from "../provider/CodexProvider.ts";
24import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts";
25import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts";
26import {
27 antigravityAccountFingerprint,
28 antigravitySummaryWindows,
29 antigravityUserAgent,
30} from "../provider/antigravityUsageLimits.ts";
31import {
32 makeUnavailableUsageLimits,
33 makeUsageLimits,
34} from "@t3tools/provider-core/server/usageLimits";
35​
36/** A hub with its management key already read from wherever the setting keeps it. */
37export interface CliproxyHub {
38 readonly url: UsageLimitSourceConfig["url"];
39 readonly managementKey: string;
41​
42const AuthFile = Schema.Struct({
43 id: Schema.String,
44 auth_index: Schema.String,
45 provider: Schema.String,
46 email: Schema.optional(Schema.String),
47 disabled: Schema.optional(Schema.Boolean),
48 project_id: Schema.optional(Schema.String),
49 id_token: Schema.optional(
50 Schema.Struct({
51 chatgpt_account_id: Schema.optional(Schema.String),
52 chatgpt_plan_type: Schema.optional(Schema.String),
53 plan_type: Schema.optional(Schema.String),
54 }),
55 ),
56});
57const AuthFiles = Schema.Struct({ files: Schema.Array(AuthFile) });
58const ApiResponse = Schema.Struct({ status_code: Schema.Number, body: Schema.String });
59const CodexWindow = Schema.Struct({
60 used_percent: Schema.Number,
61 reset_at: Schema.optional(Schema.NullOr(Schema.Number)),
62 limit_window_seconds: Schema.optional(Schema.Number),
63});
64const CodexUsage = Schema.Struct({
65 plan_type: Schema.optional(Schema.String),
66 rate_limit: Schema.NullOr(
67 Schema.Struct({
68 primary_window: Schema.optional(Schema.NullOr(CodexWindow)),
69 secondary_window: Schema.optional(Schema.NullOr(CodexWindow)),
70 }),
71 ),
72});
73const ClaudeWindow = Schema.Struct({
74 utilization: Schema.Number,
75 resets_at: Schema.NullOr(Schema.String),
76});
77const ClaudeUsage = Schema.Struct({
78 five_hour: Schema.optional(Schema.NullOr(ClaudeWindow)),
79 seven_day: Schema.optional(Schema.NullOr(ClaudeWindow)),
80 spend: Schema.optional(Schema.Unknown),
81 extra_usage: Schema.optional(
82 Schema.NullOr(
83 Schema.Struct({
84 is_enabled: Schema.Boolean,
85 monthly_limit: Schema.NullOr(Schema.Number),
86 used_credits: Schema.NullOr(Schema.Number),
87 utilization: Schema.NullOr(Schema.Number),
88 }),
89 ),
90 ),
91 limits: Schema.optional(
92 Schema.Array(
93 Schema.Struct({
94 kind: Schema.String,
95 percent: Schema.optional(Schema.NullOr(Schema.Number)),
96 resets_at: Schema.optional(Schema.NullOr(Schema.String)),
97 scope: Schema.optional(
98 Schema.NullOr(
99 Schema.Struct({
100 model: Schema.optional(Schema.NullOr(Schema.Struct({ display_name: Schema.String }))),
101 }),
102 ),
103 ),
104 }),
105 ),
106 ),
107});
108const CreditList = Schema.Struct({
109 credits: Schema.Array(
110 Schema.Struct({
111 id: Schema.String,
112 status: Schema.String,
113 reset_type: Schema.String,
114 expires_at: Schema.String,
115 }),
116 ),
117});
118​
119const decodeAuthFiles = Schema.decodeUnknownEffect(AuthFiles);
120const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
121const decodeApiResponse = Schema.decodeUnknownEffect(ApiResponse);
122const decodeCreditList = Schema.decodeUnknownEffect(Schema.fromJsonString(CreditList));
123const decodeClaudeUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaudeUsage));
124const decodeCodexUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(CodexUsage));
125const decodeGrokUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(GrokUsageResponse));
126const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
127const isUsageLimitSourceError = Schema.is(UsageLimitSourceError);
128​
129// Extra CAs can fix untrusted chains; expiry/hostname failures report codes only.
130const UNTRUSTED_CERTIFICATE_CODES = new Set([
131 "DEPTH_ZERO_SELF_SIGNED_CERT",
132 "SELF_SIGNED_CERT_IN_CHAIN",
133 "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
134 "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
135]);
136​
137// fetch wraps Node error codes in generic TypeErrors.
138const nodeErrorCode = (cause: unknown) => {
139 for (let error = cause, depth = 0; error instanceof Error && depth < 3; depth += 1) {
140 const code = (error as { readonly code?: unknown }).code;
141 if (typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code)) return code;
142 error = error.cause;
143 }
144 return undefined;
145};
146​
147// Hub responses can contain credentials; expose only failure kind, HTTP status or Node code.
148const describeManagementFailure = (error: unknown) => {
149 if (Cause.isTimeoutError(error)) return "The hub did not answer within 15 seconds.";
150 if (HttpClientError.isHttpClientError(error)) {
151 const reason = error.reason;
152 if (reason._tag === "StatusCodeError") {
153 return `The hub answered with HTTP ${reason.response.status}.`;
154 }
155 const code = reason._tag === "TransportError" ? nodeErrorCode(reason.cause) : undefined;
156 if (code !== undefined && UNTRUSTED_CERTIFICATE_CODES.has(code)) {
157 return `The hub's TLS certificate is not trusted (${code}). If it comes from a private CA, add that CA to NODE_EXTRA_CA_CERTS.`;
158 }
159 if (code !== undefined) return `The hub request failed (${code}).`;
160 }
161 return "The hub management request failed.";
162};
163const decodeConsumeResponse = Schema.decodeUnknownEffect(
164 Schema.fromJsonString(
165 Schema.Struct({
166 code: Schema.Literals(["reset", "nothing_to_reset", "no_credit", "already_redeemed"]),
167 }),
168 ),
169);
170​
171const CODEX_BASE = "https://chatgpt.com/backend-api/wham";
172const CREDIT_URL = `${CODEX_BASE}/rate-limit-reset-credits`;
173​
174const CREDIT_REDEEM_NAMESPACE = new Uint8Array([
175 0x6f, 0x1c, 0x2a, 0x9e, 0x2d, 0x4b, 0x4c, 0x1e, 0x9a, 0x7f, 0x3b, 0x8d, 0x5e, 0x0c, 0x1a, 0x42,
176]);
177​
178// UUIDv5 per account and credit also deduplicates retries across T3 environments.
179const creditRedeemRequestId = Effect.fn("CliproxyApi.creditRedeemRequestId")(function* (
180 accountId: string,
181 creditId: string,
182) {
183 const digest = yield* Digest.Digest;
184 const name = new TextEncoder().encode(`${accountId}:${creditId}`);
185 const input = new Uint8Array(CREDIT_REDEEM_NAMESPACE.length + name.length);
186 input.set(CREDIT_REDEEM_NAMESPACE);
187 input.set(name, CREDIT_REDEEM_NAMESPACE.length);
188 const bytes = (yield* digest.digest(Digest.sha1, input).pipe(Effect.orDie)).slice(0, 16);
189 bytes[6] = (bytes[6]! & 0x0f) | 0x50;
190 bytes[8] = (bytes[8]! & 0x3f) | 0x80;
191 const hex = Hex.encode(bytes);
192 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
193});
194​
195type AuthFile = typeof AuthFile.Type;
196/** Sends one upstream request with the account's token, as GET or as a JSON POST of `data`. */
197type HubCall = (
198 url: string,
199 data?: unknown,
200) => Effect.Effect<string, UsageLimitSourceError | Schema.SchemaError>;
201type HubReading = Omit<UsageLimitSourceAccount, "id" | "driver">;
202interface HubProvider {
203 readonly driver: ProviderDriverKind;
204 readonly header: (
205 account: AuthFile,
206 platform: NodeJS.Platform,
207 architecture: NodeJS.Architecture,
208 ) => Record<string, string>;
209 readonly read: (input: {
210 readonly account: AuthFile;
211 readonly call: HubCall;
212 readonly checkedAt: string;
213 }) => Effect.Effect<HubReading, UsageLimitSourceError | Schema.SchemaError, Digest.Digest>;
215​
216const listedIdentity = (account: AuthFile) => ({
217 ...(account.email ? { email: account.email } : {}),
218 ...(account.id_token?.chatgpt_account_id?.trim()
219 ? { workspaceId: account.id_token.chatgpt_account_id.trim() }
220 : {}),
221});
222​
223const credits = Effect.fn("CliproxyApi.credits")(function* (call: HubCall) {
224 const response = yield* decodeCreditList(yield* call(CREDIT_URL));
225 const now = DateTime.toEpochMillis(yield* DateTime.now);
226 return response.credits
227 .filter(
228 (credit) =>
229 credit.reset_type === "codex_rate_limits" &&
230 credit.status === "available" &&
231 Date.parse(credit.expires_at) > now,
232 )
233 .toSorted((a, b) => Date.parse(a.expires_at) - Date.parse(b.expires_at));
234});
235​
236const CODEX_HUB: HubProvider = {
237 driver: ProviderDriverKind.make("codex"),
238 header: (account) => ({
239 Authorization: "Bearer $TOKEN$",
240 "Content-Type": "application/json",
241 "OpenAI-Beta": "codex-1",
242 Originator: "Codex Desktop",
243 ...(account.id_token?.chatgpt_account_id
244 ? { "Chatgpt-Account-Id": account.id_token.chatgpt_account_id }
245 : {}),
246 }),
247 read: Effect.fnUntraced(function* ({ account, call, checkedAt }) {
248 const usage = yield* decodeCodexUsage(yield* call(`${CODEX_BASE}/usage`));
249 const toWindow = (window: typeof CodexWindow.Type | null | undefined) =>
250 window
251 ? {
252 usedPercent: window.used_percent,
253 resetsAt: window.reset_at ?? null,
254 ...(window.limit_window_seconds === undefined
255 ? {}
256 : { windowDurationMins: window.limit_window_seconds / 60 }),
257 }
258 : null;
259 const available = yield* credits(call).pipe(Effect.orElseSucceed(() => undefined));
260 const next = available?.[0];
261 const planType =
262 usage.plan_type ?? account.id_token?.plan_type ?? account.id_token?.chatgpt_plan_type;
263 return {
264 ...listedIdentity(account),
265 plan: codexPlanLabel(planType),
266 usageLimits: {
267 ...codexRateLimitsToLimits({
268 checkedAt,
269 snapshot: {
270 planType: planType ?? null,
271 primary: toWindow(usage.rate_limit?.primary_window),
272 secondary: toWindow(usage.rate_limit?.secondary_window),
273 },
274 }),
275 ...(available
276 ? {
277 resetCredits: {
278 availableCount: available.length,
279 ...(next
280 ? {
281 nextCreditId: next.id,
282 nextExpiresAt: DateTime.formatIso(DateTime.makeUnsafe(next.expires_at)),
283 }
284 : {}),
285 },
286 }
287 : {}),
288 },
289 };
290 }),
291};
292​
293const GROK_CLI_VERSION = "1.0.77";
294const ANTIGRAVITY_SUMMARY_URL =
295 "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary";
296​
297// Keyed by the auth-files `provider`; accounts of any other provider are skipped.
298const HUB_PROVIDERS = new Map<string, HubProvider>([
299 ["codex", CODEX_HUB],
300 [
301 "claude",
302 {
303 driver: ProviderDriverKind.make("claudeAgent"),
304 header: () => ({
305 Authorization: "Bearer $TOKEN$",
306 "anthropic-beta": "oauth-2025-04-20",
307 // The OAuth usage endpoint can return 429 without a Claude Code user agent.
308 "User-Agent": "claude-code/2.1.273",
309 }),
310 read: Effect.fnUntraced(function* ({ account, call, checkedAt }) {
311 const usage = yield* decodeClaudeUsage(
312 yield* call("https://api.anthropic.com/api/oauth/usage"),
313 );
314 const model_scoped = (usage.limits ?? []).flatMap((limit) =>
315 limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number"
316 ? [
317 {
318 display_name: limit.scope.model.display_name,
319 utilization: limit.percent,
320 resets_at: limit.resets_at ?? null,
321 },
322 ]
323 : [],
324 );
325 const rateLimits = {
326 five_hour: usage.five_hour ?? null,
327 seven_day: usage.seven_day ?? null,
328 model_scoped,
329 spend: usage.spend,
330 extra_usage: usage.extra_usage ?? null,
331 };
332 return {
333 ...listedIdentity(account),
334 plan: "Claude Subscription",
335 usageLimits: claudeUsageResponseToLimits({
336 checkedAt,
337 response: { rate_limits_available: true, rate_limits: rateLimits },
338 }).limits,
339 };
340 }),
341 },
342 ],
343 [
344 "xai",
345 {
346 driver: ProviderDriverKind.make("grok"),
347 header: () => ({
348 Authorization: "Bearer $TOKEN$",
349 "x-xai-token-auth": "xai-grok-cli",
350 "x-grok-client-version": GROK_CLI_VERSION,
351 "User-Agent": `grok-cli/${GROK_CLI_VERSION}`,
352 }),
353 read: Effect.fnUntraced(function* ({ account, call, checkedAt }) {
354 const usage = yield* decodeGrokUsage(yield* call(GROK_USAGE_URL));
355 return {
356 ...listedIdentity(account),
357 usageLimits: grokUsageResponseToLimits(usage, checkedAt),
358 };
359 }),
360 },
361 ],
362 [
363 "antigravity",
364 {
365 driver: ProviderDriverKind.make("antigravity"),
366 header: (_account, platform, architecture) => ({
367 Authorization: "Bearer $TOKEN$",
368 "Content-Type": "application/json",
369 "User-Agent": antigravityUserAgent(platform, architecture),
370 }),
371 read: Effect.fnUntraced(function* ({ account, call, checkedAt }) {
372 const project = account.project_id?.trim();
373 if (!project) {
374 return yield* new UsageLimitSourceError({ detail: "No Antigravity project reported." });
375 }
376 const requestedAt = DateTime.toEpochMillis(yield* DateTime.now);
377 const summary = yield* call(ANTIGRAVITY_SUMMARY_URL, { project });
378 const windows = yield* antigravitySummaryWindows(yield* decodeJson(summary), requestedAt);
379 if (windows.length === 0) {
380 return yield* new UsageLimitSourceError({ detail: "No Antigravity quota reported." });
381 }
382 // Local Antigravity reports no e-mail, so the Google account fingerprint is what pools the two.
383 const fingerprint = yield* call("https://www.googleapis.com/oauth2/v2/userinfo").pipe(
384 Effect.flatMap(decodeJson),
385 Effect.flatMap(antigravityAccountFingerprint),
386 Effect.option,
387 );
388 const usageLimits = makeUsageLimits({ checkedAt, windows });
389 return Option.match(fingerprint, {
390 onNone: () => ({ ...listedIdentity(account), usageLimits }),
391 onSome: (credentialFingerprint) => ({
392 usageLimits: { ...usageLimits, credentialFingerprint },
393 }),
394 });
395 }),
396 },
397 ],
398]);
399​
400export const makeCliproxyApi = Effect.gen(function* () {
401 const client = yield* HttpClient.HttpClient;
402 const digest = yield* Digest.Digest;
403 const platform = yield* HostProcess.Platform;
404 const architecture = yield* HostProcess.Architecture;
405​
406 const management = Effect.fn("CliproxyApi.management")(function* (
407 hub: CliproxyHub,
408 path: string,
409 body?: unknown,
410 ) {
411 const url = yield* Effect.try({
412 try: () => {
413 const base = new URL(hub.url);
414 // Users often paste the hub's web panel URL, which ends in management.html.
415 base.pathname = base.pathname.replace(/(\/management\.html)?\/?$/i, "/");
416 return new URL(`v0/management/${path}`, base).toString();
417 },
418 catch: () => new UsageLimitSourceError({ detail: "The hub URL is not valid." }),
419 });
420 const request = (
421 body === undefined ? HttpClientRequest.get(url) : HttpClientRequest.post(url)
422 ).pipe(HttpClientRequest.setHeader("Authorization", `Bearer ${hub.managementKey}`));
423 const response = yield* client
424 .execute(body === undefined ? request : request.pipe(HttpClientRequest.bodyJsonUnsafe(body)))
425 .pipe(
426 Effect.flatMap(HttpClientResponse.filterStatusOk),
427 Effect.flatMap((response) => response.json),
428 Effect.timeout("15 seconds"),
429 Effect.mapError(
430 (error) => new UsageLimitSourceError({ detail: describeManagementFailure(error) }),
431 ),
432 );
433 return response;
434 });
435​
436 const authFiles = Effect.fn("CliproxyApi.authFiles")(function* (hub: CliproxyHub) {
437 const response = yield* management(hub, "auth-files");
438 return (yield* decodeAuthFiles(response)).files;
439 });
440​
441 const apiCall = Effect.fn("CliproxyApi.apiCall")(function* (
442 hub: CliproxyHub,
443 account: AuthFile,
444 header: Record<string, string>,
445 url: string,
446 data?: unknown,
447 ) {
448 const raw = yield* management(hub, "api-call", {
449 auth_index: account.auth_index,
450 method: data === undefined ? "GET" : "POST",
451 url,
452 header,
453 ...(data === undefined ? {} : { data: yield* encodeJson(data) }),
454 });
455 const response = yield* decodeApiResponse(raw);
456 if (response.status_code < 200 || response.status_code >= 300) {
457 return yield* new UsageLimitSourceError({
458 detail: `The provider refused the hub request (HTTP ${response.status_code}).`,
459 });
460 }
461 return response.body;
462 });
463​
464 const readAccount = Effect.fn("CliproxyApi.readAccount")(function* (
465 hub: CliproxyHub,
466 account: AuthFile,
467 provider: HubProvider,
468 ) {
469 const checkedAt = DateTime.formatIso(yield* DateTime.now);
470 const call: HubCall = (url, data) =>
471 apiCall(hub, account, provider.header(account, platform, architecture), url, data);
472 const reading = yield* provider.read({ account, call, checkedAt }).pipe(
473 Effect.provideService(Digest.Digest, digest),
474 Effect.orElseSucceed(() => ({
475 ...listedIdentity(account),
476 usageLimits: makeUnavailableUsageLimits({
477 checkedAt,
478 reason: "probeFailed",
479 message: "The hub could not read this account's usage.",
480 }),
481 })),
482 );
483 return { id: account.id, driver: provider.driver, ...reading };
484 });
485​
486 const readAccounts = Effect.fn("CliproxyApi.readAccounts")(function* (
487 hub: CliproxyHub,
488 ): Effect.fn.Return<ReadonlyArray<UsageLimitSourceAccount>, UsageLimitSourceError> {
489 const accounts = yield* authFiles(hub).pipe(
490 Effect.mapError((error) =>
491 isUsageLimitSourceError(error)
492 ? error
493 : new UsageLimitSourceError({ detail: "The hub could not list accounts." }),
494 ),
495 );
496 return yield* Effect.forEach(
497 accounts.flatMap((account) => {
498 const provider = HUB_PROVIDERS.get(account.provider);
499 return provider && !account.disabled ? [{ account, provider }] : [];
500 }),
501 ({ account, provider }) => readAccount(hub, account, provider),
502 { concurrency: 4 },
503 );
504 });
505​
506 const consume = Effect.fn("CliproxyApi.consume")(function* (
507 hub: CliproxyHub,
508 accountId: string,
509 creditId: string,
510 ): Effect.fn.Return<ProviderConsumeResetCreditResult, UsageLimitSourceError> {
511 const operation = Effect.gen(function* () {
512 const account = (yield* authFiles(hub)).find((account) => account.id === accountId);
513 if (!account || account.disabled || account.provider !== "codex") {
514 return yield* new UsageLimitSourceError({
515 detail: "The Codex hub account is missing or disabled.",
516 });
517 }
518 const body = yield* apiCall(
519 hub,
520 account,
521 CODEX_HUB.header(account, platform, architecture),
522 `${CREDIT_URL}/consume`,
523 {
524 redeem_request_id: yield* creditRedeemRequestId(
525 account.id_token?.chatgpt_account_id ?? account.id,
526 creditId,
527 ).pipe(Effect.provideService(Digest.Digest, digest)),
528 credit_id: creditId,
529 },
530 );
531 const response = yield* decodeConsumeResponse(body);
532 const outcome = (
533 {
534 reset: "reset",
535 nothing_to_reset: "nothingToReset",
536 no_credit: "noCredit",
537 already_redeemed: "alreadyRedeemed",
538 } as const
539 )[response.code];
540 if (outcome !== "reset" && outcome !== "alreadyRedeemed") return { outcome };
541 const cleared = yield* management(hub, "reset-quota", {
542 auth_index: account.auth_index,
543 }).pipe(Effect.result);
544 return {
545 outcome,
546 ...(cleared._tag === "Failure"
547 ? {
548 warning:
549 "Credit redeemed, but the hub cooldown could not be cleared. Routing may resume after its cooldown expires.",
550 }
551 : {}),
552 } as const;
553 });
554 return yield* operation.pipe(
555 Effect.mapError((error) =>
556 isUsageLimitSourceError(error)
557 ? error
558 : new UsageLimitSourceError({
559 detail: "The hub returned an unexpected reset-credit response.",
560 }),
561 ),
562 );
563 });
564 return { readAccounts, consume };
565});