feat(costs): add billing, quota, and budget control plane
This commit is contained in:
@@ -22,6 +22,9 @@ vi.mock("../services/index.js", () => ({
|
||||
canUser: vi.fn(),
|
||||
ensureMembership: vi.fn(),
|
||||
}),
|
||||
budgetService: () => ({
|
||||
upsertPolicy: vi.fn(),
|
||||
}),
|
||||
logActivity: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { costRoutes } from "../routes/costs.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseDateRange — tested via the route handler since it's a private function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Minimal db stub — just enough for costService() not to throw at construction
|
||||
function makeDb(overrides: Record<string, unknown> = {}) {
|
||||
const selectChain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
@@ -17,9 +12,10 @@ function makeDb(overrides: Record<string, unknown> = {}) {
|
||||
innerJoin: vi.fn().mockReturnThis(),
|
||||
groupBy: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
// Make it thenable so Drizzle query chains resolve to []
|
||||
|
||||
const thenableChain = Object.assign(Promise.resolve([]), selectChain);
|
||||
|
||||
return {
|
||||
@@ -43,17 +39,40 @@ const mockAgentService = vi.hoisted(() => ({
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockFetchAllQuotaWindows = vi.hoisted(() => vi.fn());
|
||||
const mockCostService = vi.hoisted(() => ({
|
||||
createEvent: vi.fn(),
|
||||
summary: vi.fn().mockResolvedValue({ spendCents: 0 }),
|
||||
byAgent: vi.fn().mockResolvedValue([]),
|
||||
byAgentModel: vi.fn().mockResolvedValue([]),
|
||||
byProvider: vi.fn().mockResolvedValue([]),
|
||||
byBiller: vi.fn().mockResolvedValue([]),
|
||||
windowSpend: vi.fn().mockResolvedValue([]),
|
||||
byProject: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
const mockFinanceService = vi.hoisted(() => ({
|
||||
createEvent: vi.fn(),
|
||||
summary: vi.fn().mockResolvedValue({ debitCents: 0, creditCents: 0, netCents: 0, estimatedDebitCents: 0, eventCount: 0 }),
|
||||
byBiller: vi.fn().mockResolvedValue([]),
|
||||
byKind: vi.fn().mockResolvedValue([]),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
const mockBudgetService = vi.hoisted(() => ({
|
||||
overview: vi.fn().mockResolvedValue({
|
||||
companyId: "company-1",
|
||||
policies: [],
|
||||
activeIncidents: [],
|
||||
pausedAgentCount: 0,
|
||||
pausedProjectCount: 0,
|
||||
pendingApprovalCount: 0,
|
||||
}),
|
||||
upsertPolicy: vi.fn(),
|
||||
resolveIncident: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
costService: () => ({
|
||||
createEvent: vi.fn(),
|
||||
summary: vi.fn().mockResolvedValue({ spendCents: 0 }),
|
||||
byAgent: vi.fn().mockResolvedValue([]),
|
||||
byAgentModel: vi.fn().mockResolvedValue([]),
|
||||
byProvider: vi.fn().mockResolvedValue([]),
|
||||
windowSpend: vi.fn().mockResolvedValue([]),
|
||||
byProject: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
budgetService: () => mockBudgetService,
|
||||
costService: () => mockCostService,
|
||||
financeService: () => mockFinanceService,
|
||||
companyService: () => mockCompanyService,
|
||||
agentService: () => mockAgentService,
|
||||
logActivity: mockLogActivity,
|
||||
@@ -75,8 +94,12 @@ function createApp() {
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("parseDateRange — date validation via route", () => {
|
||||
it("accepts valid ISO date strings and passes them to the service", async () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("cost routes", () => {
|
||||
it("accepts valid ISO date strings and passes them to cost summary routes", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/summary")
|
||||
@@ -102,138 +125,30 @@ describe("parseDateRange — date validation via route", () => {
|
||||
expect(res.body.error).toMatch(/invalid 'to' date/i);
|
||||
});
|
||||
|
||||
it("treats missing 'from' and 'to' as no range (passes undefined to service)", async () => {
|
||||
it("returns finance summary rows for valid requests", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app).get("/api/companies/company-1/costs/summary");
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/finance-summary")
|
||||
.query({ from: "2026-02-01T00:00:00.000Z", to: "2026-02-28T23:59:59.999Z" });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// byProvider pro-rata subscription split — pure math, no DB needed
|
||||
// ---------------------------------------------------------------------------
|
||||
// The split logic operates on arrays returned by DB queries.
|
||||
// We test it by calling the actual costService with a mock DB that yields
|
||||
// controlled query results and verifying the output proportions.
|
||||
|
||||
import { costService } from "../services/index.js";
|
||||
|
||||
describe("byProvider — pro-rata subscription attribution", () => {
|
||||
it("splits subscription counts proportionally by token share", async () => {
|
||||
// Two models: modelA has 75% of tokens, modelB has 25%.
|
||||
// Total subscription runs = 100, sub input tokens = 1000, sub output tokens = 400.
|
||||
// Expected: modelA gets 75% of each, modelB gets 25%.
|
||||
|
||||
// We bypass the DB by directly exercising the accumulator math.
|
||||
// Inline the accumulation logic from costs.ts to verify the arithmetic is correct.
|
||||
const costRows = [
|
||||
{ provider: "anthropic", model: "claude-sonnet", costCents: 300, inputTokens: 600, outputTokens: 150 },
|
||||
{ provider: "anthropic", model: "claude-haiku", costCents: 100, inputTokens: 200, outputTokens: 50 },
|
||||
];
|
||||
const subscriptionTotals = {
|
||||
apiRunCount: 20,
|
||||
subscriptionRunCount: 100,
|
||||
subscriptionInputTokens: 1000,
|
||||
subscriptionOutputTokens: 400,
|
||||
};
|
||||
|
||||
const totalTokens = costRows.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0);
|
||||
// totalTokens = (600+150) + (200+50) = 750 + 250 = 1000
|
||||
|
||||
const result = costRows.map((row) => {
|
||||
const rowTokens = row.inputTokens + row.outputTokens;
|
||||
const share = totalTokens > 0 ? rowTokens / totalTokens : 0;
|
||||
return {
|
||||
...row,
|
||||
apiRunCount: Math.round(subscriptionTotals.apiRunCount * share),
|
||||
subscriptionRunCount: Math.round(subscriptionTotals.subscriptionRunCount * share),
|
||||
subscriptionInputTokens: Math.round(subscriptionTotals.subscriptionInputTokens * share),
|
||||
subscriptionOutputTokens: Math.round(subscriptionTotals.subscriptionOutputTokens * share),
|
||||
};
|
||||
});
|
||||
|
||||
// modelA: 750/1000 = 75%
|
||||
expect(result[0]!.subscriptionRunCount).toBe(75); // 100 * 0.75
|
||||
expect(result[0]!.subscriptionInputTokens).toBe(750); // 1000 * 0.75
|
||||
expect(result[0]!.subscriptionOutputTokens).toBe(300); // 400 * 0.75
|
||||
expect(result[0]!.apiRunCount).toBe(15); // 20 * 0.75
|
||||
|
||||
// modelB: 250/1000 = 25%
|
||||
expect(result[1]!.subscriptionRunCount).toBe(25); // 100 * 0.25
|
||||
expect(result[1]!.subscriptionInputTokens).toBe(250); // 1000 * 0.25
|
||||
expect(result[1]!.subscriptionOutputTokens).toBe(100); // 400 * 0.25
|
||||
expect(result[1]!.apiRunCount).toBe(5); // 20 * 0.25
|
||||
});
|
||||
|
||||
it("assigns share=0 to all rows when totalTokens is zero (avoids divide-by-zero)", () => {
|
||||
const costRows = [
|
||||
{ provider: "anthropic", model: "claude-sonnet", costCents: 0, inputTokens: 0, outputTokens: 0 },
|
||||
{ provider: "openai", model: "gpt-5", costCents: 0, inputTokens: 0, outputTokens: 0 },
|
||||
];
|
||||
const subscriptionTotals = { apiRunCount: 10, subscriptionRunCount: 5, subscriptionInputTokens: 100, subscriptionOutputTokens: 50 };
|
||||
const totalTokens = 0;
|
||||
|
||||
const result = costRows.map((row) => {
|
||||
const rowTokens = row.inputTokens + row.outputTokens;
|
||||
const share = totalTokens > 0 ? rowTokens / totalTokens : 0;
|
||||
return {
|
||||
subscriptionRunCount: Math.round(subscriptionTotals.subscriptionRunCount * share),
|
||||
subscriptionInputTokens: Math.round(subscriptionTotals.subscriptionInputTokens * share),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result[0]!.subscriptionRunCount).toBe(0);
|
||||
expect(result[0]!.subscriptionInputTokens).toBe(0);
|
||||
expect(result[1]!.subscriptionRunCount).toBe(0);
|
||||
expect(result[1]!.subscriptionInputTokens).toBe(0);
|
||||
});
|
||||
|
||||
it("attribution rounds to nearest integer (no fractional run counts)", () => {
|
||||
// 3 models, 10 runs to split — rounding may not sum to exactly 10, that's expected
|
||||
const costRows = [
|
||||
{ inputTokens: 1, outputTokens: 0 }, // 1/3
|
||||
{ inputTokens: 1, outputTokens: 0 }, // 1/3
|
||||
{ inputTokens: 1, outputTokens: 0 }, // 1/3
|
||||
];
|
||||
const totalTokens = 3;
|
||||
const subscriptionRunCount = 10;
|
||||
|
||||
const result = costRows.map((row) => {
|
||||
const share = row.inputTokens / totalTokens;
|
||||
return Math.round(subscriptionRunCount * share);
|
||||
});
|
||||
|
||||
// Each should be Math.round(10/3) = Math.round(3.33) = 3
|
||||
expect(result).toEqual([3, 3, 3]);
|
||||
for (const count of result) {
|
||||
expect(Number.isInteger(count)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// windowSpend — verify shape of rolling window results
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("windowSpend — rolling window labels and hours", () => {
|
||||
it("returns results for the three standard windows (5h, 24h, 7d)", async () => {
|
||||
// The windowSpend method computes three rolling windows internally.
|
||||
// We verify the expected window labels exist in a real call by checking
|
||||
// the service contract shape. Since we're not connecting to a DB here,
|
||||
// we verify the window definitions directly from service source by
|
||||
// exercising the label computation inline.
|
||||
|
||||
const windows = [
|
||||
{ label: "5h", hours: 5 },
|
||||
{ label: "24h", hours: 24 },
|
||||
{ label: "7d", hours: 168 },
|
||||
] as const;
|
||||
|
||||
// All three standard windows must be present
|
||||
expect(windows.map((w) => w.label)).toEqual(["5h", "24h", "7d"]);
|
||||
// Hours must match expected durations
|
||||
expect(windows[0]!.hours).toBe(5);
|
||||
expect(windows[1]!.hours).toBe(24);
|
||||
expect(windows[2]!.hours).toBe(168); // 7 * 24
|
||||
expect(mockFinanceService.summary).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for invalid finance event list limits", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/finance-events")
|
||||
.query({ limit: "0" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid 'limit'/i);
|
||||
});
|
||||
|
||||
it("accepts valid finance event list limits", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/companies/company-1/costs/finance-events")
|
||||
.query({ limit: "25" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockFinanceService.list).toHaveBeenCalledWith("company-1", undefined, 25);
|
||||
});
|
||||
});
|
||||
|
||||
56
server/src/__tests__/quota-windows-service.test.ts
Normal file
56
server/src/__tests__/quota-windows-service.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("../adapters/registry.js", () => ({
|
||||
listServerAdapters: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listServerAdapters } from "../adapters/registry.js";
|
||||
import { fetchAllQuotaWindows } from "../services/quota-windows.js";
|
||||
|
||||
describe("fetchAllQuotaWindows", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns adapter results without waiting for a slower provider to finish forever", async () => {
|
||||
vi.mocked(listServerAdapters).mockReturnValue([
|
||||
{
|
||||
type: "codex_local",
|
||||
getQuotaWindows: vi.fn().mockResolvedValue({
|
||||
provider: "openai",
|
||||
source: "codex-rpc",
|
||||
ok: true,
|
||||
windows: [{ label: "5h limit", usedPercent: 2, resetsAt: null, valueLabel: null, detail: null }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "claude_local",
|
||||
getQuotaWindows: vi.fn(() => new Promise(() => {})),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const promise = fetchAllQuotaWindows();
|
||||
await vi.advanceTimersByTimeAsync(20_001);
|
||||
const results = await promise;
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
provider: "openai",
|
||||
source: "codex-rpc",
|
||||
ok: true,
|
||||
windows: [{ label: "5h limit", usedPercent: 2, resetsAt: null, valueLabel: null, detail: null }],
|
||||
},
|
||||
{
|
||||
provider: "anthropic",
|
||||
ok: false,
|
||||
error: "quota polling timed out after 20s",
|
||||
windows: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -8,14 +8,17 @@ import {
|
||||
toPercent,
|
||||
fetchWithTimeout,
|
||||
fetchClaudeQuota,
|
||||
parseClaudeCliUsageText,
|
||||
readClaudeToken,
|
||||
claudeConfigDir,
|
||||
} from "@paperclipai/adapter-claude-local/server";
|
||||
|
||||
import {
|
||||
secondsToWindowLabel,
|
||||
readCodexAuthInfo,
|
||||
readCodexToken,
|
||||
fetchCodexQuota,
|
||||
mapCodexRpcQuota,
|
||||
codexHomeDir,
|
||||
} from "@paperclipai/adapter-codex-local/server";
|
||||
|
||||
@@ -271,13 +274,86 @@ describe("readClaudeToken", () => {
|
||||
expect(token).toBe("my-test-token");
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
|
||||
it("reads the token from .credentials.json when that is the available Claude auth file", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-claude-${Date.now()}`);
|
||||
const creds = { claudeAiOauth: { accessToken: "dotfile-token" } };
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, ".credentials.json"), JSON.stringify(creds)),
|
||||
),
|
||||
);
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir;
|
||||
const token = await readClaudeToken();
|
||||
expect(token).toBe("dotfile-token");
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseClaudeCliUsageText", () => {
|
||||
it("parses the Claude usage panel layout into quota windows", () => {
|
||||
const raw = `
|
||||
Settings: Status Config Usage
|
||||
Current session
|
||||
2% used
|
||||
Resets 5pm (America/Chicago)
|
||||
|
||||
Current week (all models)
|
||||
47% used
|
||||
Resets Mar 18 at 7:59am (America/Chicago)
|
||||
|
||||
Current week (Sonnet only)
|
||||
0% used
|
||||
Resets Mar 18 at 8:59am (America/Chicago)
|
||||
|
||||
Extra usage
|
||||
Extra usage not enabled • /extra-usage to enable
|
||||
`;
|
||||
|
||||
expect(parseClaudeCliUsageText(raw)).toEqual([
|
||||
{
|
||||
label: "Current session",
|
||||
usedPercent: 2,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: "Resets 5pm (America/Chicago)",
|
||||
},
|
||||
{
|
||||
label: "Current week (all models)",
|
||||
usedPercent: 47,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: "Resets Mar 18 at 7:59am (America/Chicago)",
|
||||
},
|
||||
{
|
||||
label: "Current week (Sonnet only)",
|
||||
usedPercent: 0,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: "Resets Mar 18 at 8:59am (America/Chicago)",
|
||||
},
|
||||
{
|
||||
label: "Extra usage",
|
||||
usedPercent: null,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: "Extra usage not enabled • /extra-usage to enable",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws a useful error when the Claude CLI panel reports a usage load failure", () => {
|
||||
expect(() => parseClaudeCliUsageText("Failed to load usage data")).toThrow(
|
||||
"Claude CLI could not load usage data. Open the CLI and retry `/usage`.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// readCodexToken — filesystem paths
|
||||
// readCodexAuthInfo / readCodexToken — filesystem paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("readCodexToken", () => {
|
||||
describe("readCodexAuthInfo", () => {
|
||||
const savedEnv = process.env.CODEX_HOME;
|
||||
|
||||
afterEach(() => {
|
||||
@@ -290,7 +366,7 @@ describe("readCodexToken", () => {
|
||||
|
||||
it("returns null when auth.json does not exist", async () => {
|
||||
process.env.CODEX_HOME = "/tmp/__no_such_paperclip_codex_dir__";
|
||||
const result = await readCodexToken();
|
||||
const result = await readCodexAuthInfo();
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
@@ -302,7 +378,7 @@ describe("readCodexToken", () => {
|
||||
),
|
||||
);
|
||||
process.env.CODEX_HOME = tmpDir;
|
||||
const result = await readCodexToken();
|
||||
const result = await readCodexAuthInfo();
|
||||
expect(result).toBe(null);
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
@@ -315,12 +391,12 @@ describe("readCodexToken", () => {
|
||||
),
|
||||
);
|
||||
process.env.CODEX_HOME = tmpDir;
|
||||
const result = await readCodexToken();
|
||||
const result = await readCodexAuthInfo();
|
||||
expect(result).toBe(null);
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
|
||||
it("returns token and accountId when both are present", async () => {
|
||||
it("reads the legacy flat auth shape", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
|
||||
const auth = { accessToken: "codex-token", accountId: "acc-123" };
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
@@ -329,21 +405,81 @@ describe("readCodexToken", () => {
|
||||
),
|
||||
);
|
||||
process.env.CODEX_HOME = tmpDir;
|
||||
const result = await readCodexToken();
|
||||
expect(result).toEqual({ token: "codex-token", accountId: "acc-123" });
|
||||
const result = await readCodexAuthInfo();
|
||||
expect(result).toMatchObject({
|
||||
accessToken: "codex-token",
|
||||
accountId: "acc-123",
|
||||
email: null,
|
||||
planType: null,
|
||||
});
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
|
||||
it("returns token with null accountId when accountId is absent", async () => {
|
||||
it("reads the modern nested auth shape", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
|
||||
const jwtPayload = Buffer.from(
|
||||
JSON.stringify({
|
||||
email: "codex@example.com",
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_plan_type: "pro",
|
||||
chatgpt_user_email: "codex@example.com",
|
||||
},
|
||||
}),
|
||||
).toString("base64url");
|
||||
const auth = {
|
||||
tokens: {
|
||||
access_token: `header.${jwtPayload}.sig`,
|
||||
account_id: "acc-modern",
|
||||
refresh_token: "refresh-me",
|
||||
id_token: `header.${jwtPayload}.sig`,
|
||||
},
|
||||
last_refresh: "2026-03-14T12:00:00Z",
|
||||
};
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, "auth.json"), JSON.stringify(auth)),
|
||||
),
|
||||
);
|
||||
process.env.CODEX_HOME = tmpDir;
|
||||
const result = await readCodexAuthInfo();
|
||||
expect(result).toMatchObject({
|
||||
accessToken: `header.${jwtPayload}.sig`,
|
||||
accountId: "acc-modern",
|
||||
refreshToken: "refresh-me",
|
||||
email: "codex@example.com",
|
||||
planType: "pro",
|
||||
lastRefresh: "2026-03-14T12:00:00Z",
|
||||
});
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("readCodexToken", () => {
|
||||
const savedEnv = process.env.CODEX_HOME;
|
||||
|
||||
afterEach(() => {
|
||||
if (savedEnv === undefined) {
|
||||
delete process.env.CODEX_HOME;
|
||||
} else {
|
||||
process.env.CODEX_HOME = savedEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns token and accountId from the nested auth shape", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
|
||||
await import("node:fs/promises").then((fs) =>
|
||||
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
||||
fs.writeFile(path.join(tmpDir, "auth.json"), JSON.stringify({ accessToken: "tok" })),
|
||||
fs.writeFile(path.join(tmpDir, "auth.json"), JSON.stringify({
|
||||
tokens: {
|
||||
access_token: "nested-token",
|
||||
account_id: "acc-nested",
|
||||
},
|
||||
})),
|
||||
),
|
||||
);
|
||||
process.env.CODEX_HOME = tmpDir;
|
||||
const result = await readCodexToken();
|
||||
expect(result).toEqual({ token: "tok", accountId: null });
|
||||
expect(result).toEqual({ token: "nested-token", accountId: "acc-nested" });
|
||||
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
||||
});
|
||||
});
|
||||
@@ -384,14 +520,22 @@ describe("fetchClaudeQuota", () => {
|
||||
mockFetch({ five_hour: { utilization: 0.4, resets_at: "2026-01-01T00:00:00Z" } });
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0]).toMatchObject({ label: "5h", usedPercent: 40, resetsAt: "2026-01-01T00:00:00Z" });
|
||||
expect(windows[0]).toMatchObject({
|
||||
label: "Current session",
|
||||
usedPercent: 40,
|
||||
resetsAt: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses seven_day window", async () => {
|
||||
mockFetch({ seven_day: { utilization: 0.75, resets_at: null } });
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0]).toMatchObject({ label: "7d", usedPercent: 75, resetsAt: null });
|
||||
expect(windows[0]).toMatchObject({
|
||||
label: "Current week (all models)",
|
||||
usedPercent: 75,
|
||||
resetsAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses seven_day_sonnet and seven_day_opus windows", async () => {
|
||||
@@ -401,8 +545,8 @@ describe("fetchClaudeQuota", () => {
|
||||
});
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toHaveLength(2);
|
||||
expect(windows[0]!.label).toBe("Sonnet 7d");
|
||||
expect(windows[1]!.label).toBe("Opus 7d");
|
||||
expect(windows[0]!.label).toBe("Current week (Sonnet only)");
|
||||
expect(windows[1]!.label).toBe("Current week (Opus only)");
|
||||
});
|
||||
|
||||
it("sets usedPercent to null when utilization is absent", async () => {
|
||||
@@ -421,7 +565,31 @@ describe("fetchClaudeQuota", () => {
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toHaveLength(4);
|
||||
const labels = windows.map((w: QuotaWindow) => w.label);
|
||||
expect(labels).toEqual(["5h", "7d", "Sonnet 7d", "Opus 7d"]);
|
||||
expect(labels).toEqual([
|
||||
"Current session",
|
||||
"Current week (all models)",
|
||||
"Current week (Sonnet only)",
|
||||
"Current week (Opus only)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses extra usage when the OAuth response includes it", async () => {
|
||||
mockFetch({
|
||||
extra_usage: {
|
||||
is_enabled: false,
|
||||
utilization: null,
|
||||
},
|
||||
});
|
||||
const windows = await fetchClaudeQuota("token");
|
||||
expect(windows).toEqual([
|
||||
{
|
||||
label: "Extra usage",
|
||||
usedPercent: null,
|
||||
resetsAt: null,
|
||||
valueLabel: "Not enabled",
|
||||
detail: "Extra usage not enabled",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -471,15 +639,15 @@ describe("fetchCodexQuota", () => {
|
||||
expect(windows).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses primary_window with 24h label", async () => {
|
||||
it("normalizes numeric reset timestamps from WHAM", async () => {
|
||||
mockFetch({
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 30, limit_window_seconds: 86400, reset_at: "2026-01-02T00:00:00Z" },
|
||||
primary_window: { used_percent: 30, limit_window_seconds: 86400, reset_at: 1_767_312_000 },
|
||||
},
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0]).toMatchObject({ label: "24h", usedPercent: 30, resetsAt: "2026-01-02T00:00:00Z" });
|
||||
expect(windows[0]).toMatchObject({ label: "5h limit", usedPercent: 30, resetsAt: "2026-01-02T00:00:00.000Z" });
|
||||
});
|
||||
|
||||
it("parses secondary_window alongside primary_window", async () => {
|
||||
@@ -491,8 +659,8 @@ describe("fetchCodexQuota", () => {
|
||||
});
|
||||
const windows = await fetchCodexQuota("token", null);
|
||||
expect(windows).toHaveLength(2);
|
||||
expect(windows[0]!.label).toBe("5h");
|
||||
expect(windows[1]!.label).toBe("7d");
|
||||
expect(windows[0]!.label).toBe("5h limit");
|
||||
expect(windows[1]!.label).toBe("Weekly limit");
|
||||
});
|
||||
|
||||
it("includes Credits window when credits present and not unlimited", async () => {
|
||||
@@ -521,6 +689,90 @@ describe("fetchCodexQuota", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapCodexRpcQuota", () => {
|
||||
it("maps account and model-specific Codex limits into quota windows", () => {
|
||||
const snapshot = mapCodexRpcQuota(
|
||||
{
|
||||
rateLimits: {
|
||||
limitId: "codex",
|
||||
primary: { usedPercent: 1, windowDurationMins: 300, resetsAt: 1_763_500_000 },
|
||||
secondary: { usedPercent: 27, windowDurationMins: 10_080 },
|
||||
planType: "pro",
|
||||
},
|
||||
rateLimitsByLimitId: {
|
||||
codex_bengalfox: {
|
||||
limitId: "codex_bengalfox",
|
||||
limitName: "GPT-5.3-Codex-Spark",
|
||||
primary: { usedPercent: 8, windowDurationMins: 300 },
|
||||
secondary: { usedPercent: 20, windowDurationMins: 10_080 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
account: {
|
||||
email: "codex@example.com",
|
||||
planType: "pro",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(snapshot.email).toBe("codex@example.com");
|
||||
expect(snapshot.planType).toBe("pro");
|
||||
expect(snapshot.windows).toEqual([
|
||||
{
|
||||
label: "5h limit",
|
||||
usedPercent: 1,
|
||||
resetsAt: "2025-11-18T21:06:40.000Z",
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
},
|
||||
{
|
||||
label: "Weekly limit",
|
||||
usedPercent: 27,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
},
|
||||
{
|
||||
label: "GPT-5.3-Codex-Spark · 5h limit",
|
||||
usedPercent: 8,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
},
|
||||
{
|
||||
label: "GPT-5.3-Codex-Spark · Weekly limit",
|
||||
usedPercent: 20,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes a credits row when the root Codex limit reports finite credits", () => {
|
||||
const snapshot = mapCodexRpcQuota({
|
||||
rateLimits: {
|
||||
limitId: "codex",
|
||||
credits: {
|
||||
unlimited: false,
|
||||
balance: "12.34",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.windows).toEqual([
|
||||
{
|
||||
label: "Credits",
|
||||
usedPercent: null,
|
||||
resetsAt: null,
|
||||
valueLabel: "$12.34 remaining",
|
||||
detail: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetchWithTimeout — abort on timeout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user