1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Schema from "effect/Schema";
5import * as TestClock from "effect/testing/TestClock";
6import { HttpClient, HttpClientResponse } from "effect/http";
7
8import { makeCliproxyApi } from "./cliproxyApi.ts";
9
10const config = {
11 kind: "cliproxy",
12 url: "http://hub.test:8317",
13 managementKey: "management-secret",
14 enabled: true,
15} as const;
16const accounts = [
17 {
18 id: "first.json",
19 auth_index: "a",
20 provider: "codex",
21 email: "first@example.com",
22 id_token: { chatgpt_account_id: "account-a" },
23 },
24 {
25 id: "second.json",
26 auth_index: "b",
27 provider: "codex",
28 email: "second@example.com",
29 id_token: { chatgpt_account_id: "account-b" },
30 },
31];
32const credit = (id: string, expires_at = "2099-01-01T00:00:00Z") => ({
33 id,
34 expires_at,
35 status: "available",
36 reset_type: "codex_rate_limits",
37});
38const RequestBody = Schema.Struct({
39 auth_index: Schema.String,
40 method: Schema.optional(Schema.String),
41 url: Schema.optional(Schema.String),
42 header: Schema.optional(Schema.Record(Schema.String, Schema.String)),
43 data: Schema.optional(Schema.String),
44});
45type RequestBody = typeof RequestBody.Type;
46const decodeRequest = Schema.decodeUnknownSync(Schema.fromJsonString(RequestBody));
47const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
48
49function fixture(
50 options: {
51 accounts?: Array<(typeof accounts)[number] & { disabled?: boolean }>;
52 upstream?: (request: RequestBody) => { status: number; body: unknown };
53 cooldownStatus?: number;
54 } = {},
55) {
56 const requests: Array<{ path: string; body?: RequestBody }> = [];
57 const http = HttpClient.make((request) =>
58 Effect.sync(() => {
59 expect(request.headers.authorization).toBe("Bearer management-secret");
60 const path = new URL(request.url).pathname;
61 const body =
62 request.body._tag === "Uint8Array"
63 ? decodeRequest(new TextDecoder().decode(request.body.body))
64 : undefined;
65 requests.push({ path, ...(body ? { body } : {}) });
66 if (path.endsWith("/auth-files"))
67 return HttpClientResponse.fromWeb(
68 request,
69 Response.json({ files: options.accounts ?? accounts }),
70 );
71 if (path.endsWith("/reset-quota"))
72 return HttpClientResponse.fromWeb(
73 request,
74 Response.json({}, { status: options.cooldownStatus ?? 200 }),
75 );
76 expect(path).toBe("/v0/management/api-call");
77 expect(body?.header?.Authorization).toBe("Bearer $TOKEN$");
78 const upstream = options.upstream?.(body!) ?? {
79 status: 200,
80 body: body?.url?.endsWith("/consume")
81 ? { code: "reset" }
82 : body?.url?.endsWith("/rate-limit-reset-credits")
83 ? {
84 credits: [
85 credit("later", "2099-02-01T00:00:00Z"),
86 credit("first"),
87 credit("expired", "2000-01-01T00:00:00Z"),
88 { ...credit("used"), status: "redeemed" },
89 ],
90 }
91 : {
92 plan_type: "pro",
93 rate_limit: {
94 secondary_window: {
95 used_percent: 78,
96 reset_at: 4070908800,
97 limit_window_seconds: 604800,
98 },
99 },
100 },
101 };
102 return HttpClientResponse.fromWeb(
103 request,
104 Response.json({ status_code: upstream.status, body: encodeJson(upstream.body) }),
105 );
106 }),
107 );
108 return {
109 requests,
110 api: makeCliproxyApi.pipe(
111 Effect.provideService(HttpClient.HttpClient, http),
112 Effect.provide(NodeCrypto.layer),
113 ),
114 };
115}
116
117describe("CLIProxyAPI built-in management API", () => {
118 it.effect(
119 "reads both accounts and their earliest unexpired credits without plugin endpoints",
120 () =>
121 Effect.gen(function* () {
122 yield* TestClock.setTime(1788710400000);
123 const test = fixture();
124 const api = yield* test.api;
125 const result = yield* api.readAccounts(config);
126 expect(result.map((account) => account.usageLimits.resetCredits)).toEqual([
127 { availableCount: 2, nextCreditId: "first", nextExpiresAt: "2099-01-01T00:00:00.000Z" },
128 { availableCount: 2, nextCreditId: "first", nextExpiresAt: "2099-01-01T00:00:00.000Z" },
129 ]);
130 expect(result[0]?.usageLimits.windows).toMatchObject([
131 { id: "secondary", usedPercent: 78, kind: "weekly" },
132 ]);
133 const calls = test.requests.filter((request) => request.body?.url);
134 expect(calls.map((request) => request.body?.auth_index).sort()).toEqual([
135 "a",
136 "a",
137 "b",
138 "b",
139 ]);
140 expect(
141 calls.find((request) => request.body?.auth_index === "b")?.body?.header?.[
142 "Chatgpt-Account-Id"
143 ],
144 ).toBe("account-b");
145 }),
146 );
147
148 it.effect("keeps usage when the credits endpoint fails", () =>
149 Effect.gen(function* () {
150 const test = fixture({
151 upstream: (request) =>
152 request.url?.endsWith("rate-limit-reset-credits")
153 ? { status: 503, body: { token: "do-not-publish" } }
154 : { status: 200, body: { rate_limit: { primary_window: { used_percent: 12 } } } },
155 });
156 const api = yield* test.api;
157 const result = yield* api.readAccounts(config);
158 expect(result[0]?.usageLimits.windows[0]?.usedPercent).toBe(12);
159 expect(result[0]?.usageLimits.resetCredits).toBeUndefined();
160 }),
161 );
162
163 it.effect("isolates a failed account and never publishes upstream error bodies", () =>
164 Effect.gen(function* () {
165 const test = fixture({
166 upstream: (request) =>
167 request.auth_index === "a"
168 ? { status: 401, body: { token: "do-not-publish" } }
169 : {
170 status: 200,
171 body: request.url?.endsWith("rate-limit-reset-credits")
172 ? { credits: [] }
173 : { rate_limit: { primary_window: { used_percent: 12 } } },
174 },
175 });
176 const api = yield* test.api;
177 const result = yield* api.readAccounts(config);
178 expect(result[0]?.usageLimits.unavailable?.reason).toBe("probeFailed");
179 expect(result[1]?.usageLimits.windows[0]?.usedPercent).toBe(12);
180 expect(encodeJson(result)).not.toContain("do-not-publish");
181 }),
182 );
183
184 it.effect("maps Claude scoped windows without a scheduler plugin", () =>
185 Effect.gen(function* () {
186 const test = fixture({
187 accounts: [{ ...accounts[0]!, provider: "claude" }],
188 upstream: () => ({
189 status: 200,
190 body: {
191 five_hour: { utilization: 10, resets_at: null },
192 seven_day: { utilization: 50, resets_at: "2099-01-01T00:00:00Z" },
193 limits: [
194 {
195 kind: "weekly_scoped",
196 percent: 80,
197 resets_at: null,
198 scope: { model: { display_name: "Fable" } },
199 },
200 ],
201 },
202 }),
203 });
204 const api = yield* test.api;
205 const result = yield* api.readAccounts(config);
206 expect(
207 result[0]?.usageLimits.windows.map((window) => [window.id, window.usedPercent]),
208 ).toEqual([
209 ["five_hour", 10],
210 ["seven_day", 50],
211 ["seven_day_fable", 80],
212 ]);
213 }),
214 );
215
216 it.effect("pins redemption to the displayed credit and clears only that account's cooldown", () =>
217 Effect.gen(function* () {
218 const test = fixture();
219 const api = yield* test.api;
220 expect(yield* api.consume(config, "second.json", "credit-b")).toEqual({ outcome: "reset" });
221 expect(yield* api.consume(config, "second.json", "credit-b")).toEqual({ outcome: "reset" });
222 const redemptions = test.requests.filter((request) =>
223 request.body?.url?.endsWith("/consume"),
224 );
225 expect(redemptions).toHaveLength(2);
226 expect(redemptions[0]?.body?.data).toBe(redemptions[1]?.body?.data);
227 expect(redemptions[0]?.body?.data).toBe(
228 encodeJson({
229 // UUIDv5 of "account-b:credit-b"; must stay stable so retries deduplicate.
230 redeem_request_id: "519d5243-011a-5b7b-91f3-44d85f095705",
231 credit_id: "credit-b",
232 }),
233 );
234 expect(
235 test.requests
236 .filter((request) => request.path.endsWith("/reset-quota"))
237 .map((request) => request.body?.auth_index),
238 ).toEqual(["b", "b"]);
239 }),
240 );
241
242 it.effect.each([
243 ["nothing_to_reset", "nothingToReset"],
244 ["no_credit", "noCredit"],
245 ["already_redeemed", "alreadyRedeemed"],
246 ] as const)("reports %s accurately", ([code, outcome]) =>
247 Effect.gen(function* () {
248 const test = fixture({ upstream: () => ({ status: 200, body: { code } }) });
249 const api = yield* test.api;
250 expect(yield* api.consume(config, "first.json", "credit")).toEqual({ outcome });
251 expect(test.requests.some((request) => request.path.endsWith("/reset-quota"))).toBe(
252 code === "already_redeemed",
253 );
254 }),
255 );
256
257 it.effect("reports redemption success even if cooldown clearing fails", () =>
258 Effect.gen(function* () {
259 const api = yield* fixture({ cooldownStatus: 404 }).api;
260 const result = yield* api.consume(config, "first.json", "credit");
261 expect(result.outcome).toBe("reset");
262 expect(result.warning).toContain("cooldown");
263 }),
264 );
265
266 it.effect("skips disabled accounts and rejects redemption on them", () =>
267 Effect.gen(function* () {
268 const test = fixture({ accounts: [{ ...accounts[0]!, disabled: true }] });
269 const api = yield* test.api;
270 expect(yield* api.readAccounts(config)).toEqual([]);
271 expect((yield* api.consume(config, "first.json", "credit").pipe(Effect.result))._tag).toBe(
272 "Failure",
273 );
274 expect(test.requests.every((request) => request.path.endsWith("/auth-files"))).toBe(true);
275 }),
276 );
277
278 it.effect("keeps the same redemption id after an uncertain upstream failure", () =>
279 Effect.gen(function* () {
280 let attempts = 0;
281 const test = fixture({
282 upstream: () =>
283 ++attempts === 1
284 ? { status: 503, body: {} }
285 : { status: 200, body: { code: "already_redeemed" } },
286 });
287 const api = yield* test.api;
288 expect((yield* api.consume(config, "first.json", "credit").pipe(Effect.result))._tag).toBe(
289 "Failure",
290 );
291 expect(yield* api.consume(config, "first.json", "credit")).toEqual({
292 outcome: "alreadyRedeemed",
293 });
294 const data = test.requests
295 .filter((request) => request.body?.url?.endsWith("/consume"))
296 .map((request) => request.body?.data);
297 expect(data[0]).toBe(data[1]);
298 expect(test.requests.filter((request) => request.path.endsWith("/reset-quota"))).toHaveLength(
299 1,
300 );
301 }),
302 );
303
304 it.effect("rejects unknown accounts without forwarding a redemption", () =>
305 Effect.gen(function* () {
306 const test = fixture();
307 const api = yield* test.api;
308 const result = yield* api.consume(config, "missing.json", "credit").pipe(Effect.result);
309 expect(result._tag).toBe("Failure");
310 expect(test.requests).toHaveLength(1);
311 }),
312 );
313});