usage-ledger.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. import { readFile } from "node:fs/promises";
  2. import { resolve } from "node:path";
  3. import { and, eq, inArray, sql } from "drizzle-orm";
  4. import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
  5. import { db } from "@/drizzle/db";
  6. import { messageRequest, usageLedger } from "@/drizzle/schema";
  7. import { backfillUsageLedger } from "@/lib/ledger-backfill";
  8. import { isLedgerOnlyMode } from "@/lib/ledger-fallback";
  9. import { findUsageLogs } from "@/repository/message";
  10. import { sumProviderTotalCost, sumUserTotalCost } from "@/repository/statistics";
  11. if (!process.env.DSN && process.env.DATABASE_URL) {
  12. process.env.DSN = process.env.DATABASE_URL;
  13. }
  14. const HAS_DB = Boolean(process.env.DSN);
  15. const run = describe.skipIf(!HAS_DB);
  16. const KEY_PREFIX = `it-usage-ledger-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  17. const ID_SEED = Math.floor(Date.now() / 1000) % 1_000_000;
  18. let idCursor = 0;
  19. let keyCursor = 0;
  20. function nextUserId() {
  21. idCursor += 1;
  22. return 700_000_000 + ID_SEED * 10 + idCursor;
  23. }
  24. function nextProviderId() {
  25. idCursor += 1;
  26. return 800_000_000 + ID_SEED * 10 + idCursor;
  27. }
  28. function nextKey(tag: string) {
  29. keyCursor += 1;
  30. return `${KEY_PREFIX}-${tag}-${keyCursor}`;
  31. }
  32. function toNumber(value: unknown) {
  33. const parsed = Number(value ?? 0);
  34. return Number.isFinite(parsed) ? parsed : 0;
  35. }
  36. type InsertRequestInput = {
  37. key: string;
  38. userId: number;
  39. providerId: number;
  40. model?: string | null;
  41. originalModel?: string | null;
  42. endpoint?: string | null;
  43. apiType?: string | null;
  44. statusCode?: number | null;
  45. blockedBy?: string | null;
  46. errorMessage?: string | null;
  47. costUsd?: string | null;
  48. costMultiplier?: string | null;
  49. inputTokens?: number | null;
  50. outputTokens?: number | null;
  51. providerChain?: Array<{ id: number; name: string }> | null;
  52. createdAt?: Date;
  53. };
  54. async function insertMessageRequestRow(input: InsertRequestInput) {
  55. const [row] = await db
  56. .insert(messageRequest)
  57. .values({
  58. key: input.key,
  59. userId: input.userId,
  60. providerId: input.providerId,
  61. model: input.model,
  62. originalModel: input.originalModel,
  63. endpoint: input.endpoint,
  64. apiType: input.apiType,
  65. statusCode: input.statusCode,
  66. blockedBy: input.blockedBy,
  67. errorMessage: input.errorMessage,
  68. costUsd: input.costUsd,
  69. costMultiplier: input.costMultiplier,
  70. inputTokens: input.inputTokens,
  71. outputTokens: input.outputTokens,
  72. providerChain: input.providerChain,
  73. createdAt: input.createdAt,
  74. })
  75. .returning({ id: messageRequest.id });
  76. if (!row) {
  77. throw new Error("failed to insert message_request test row");
  78. }
  79. return row.id;
  80. }
  81. async function selectLedgerRowByRequestId(requestId: number) {
  82. const [row] = await db
  83. .select({
  84. id: usageLedger.id,
  85. requestId: usageLedger.requestId,
  86. userId: usageLedger.userId,
  87. key: usageLedger.key,
  88. providerId: usageLedger.providerId,
  89. finalProviderId: usageLedger.finalProviderId,
  90. model: usageLedger.model,
  91. originalModel: usageLedger.originalModel,
  92. endpoint: usageLedger.endpoint,
  93. apiType: usageLedger.apiType,
  94. statusCode: usageLedger.statusCode,
  95. isSuccess: usageLedger.isSuccess,
  96. blockedBy: usageLedger.blockedBy,
  97. costUsd: usageLedger.costUsd,
  98. costMultiplier: usageLedger.costMultiplier,
  99. inputTokens: usageLedger.inputTokens,
  100. outputTokens: usageLedger.outputTokens,
  101. createdAt: usageLedger.createdAt,
  102. })
  103. .from(usageLedger)
  104. .where(eq(usageLedger.requestId, requestId))
  105. .limit(1);
  106. return row ?? null;
  107. }
  108. async function cleanupTestRows() {
  109. const keyLike = `${KEY_PREFIX}%`;
  110. await db.delete(messageRequest).where(sql`${messageRequest.key} LIKE ${keyLike}`);
  111. await db.delete(usageLedger).where(sql`${usageLedger.key} LIKE ${keyLike}`);
  112. }
  113. run("usage ledger integration", () => {
  114. beforeAll(async () => {
  115. await cleanupTestRows();
  116. });
  117. afterAll(async () => {
  118. await cleanupTestRows();
  119. });
  120. describe("trigger", () => {
  121. test("inserts usage_ledger row after inserting message_request", async () => {
  122. const key = nextKey("trigger-insert");
  123. const userId = nextUserId();
  124. const providerId = nextProviderId();
  125. const createdAt = new Date("2026-02-19T03:00:00.000Z");
  126. const requestId = await insertMessageRequestRow({
  127. key,
  128. userId,
  129. providerId,
  130. model: "model-a",
  131. originalModel: "model-a-original",
  132. endpoint: "/v1/messages",
  133. apiType: "response",
  134. statusCode: 200,
  135. costUsd: "1.250000000000000",
  136. costMultiplier: "1.1000",
  137. inputTokens: 12,
  138. outputTokens: 34,
  139. createdAt,
  140. });
  141. const ledgerRow = await selectLedgerRowByRequestId(requestId);
  142. expect(ledgerRow).not.toBeNull();
  143. expect(ledgerRow?.requestId).toBe(requestId);
  144. expect(ledgerRow?.key).toBe(key);
  145. expect(ledgerRow?.userId).toBe(userId);
  146. expect(ledgerRow?.providerId).toBe(providerId);
  147. expect(ledgerRow?.finalProviderId).toBe(providerId);
  148. expect(ledgerRow?.model).toBe("model-a");
  149. expect(ledgerRow?.originalModel).toBe("model-a-original");
  150. expect(ledgerRow?.endpoint).toBe("/v1/messages");
  151. expect(ledgerRow?.apiType).toBe("response");
  152. expect(ledgerRow?.statusCode).toBe(200);
  153. expect(ledgerRow?.isSuccess).toBe(true);
  154. expect(toNumber(ledgerRow?.costUsd)).toBeCloseTo(1.25, 10);
  155. expect(ledgerRow?.inputTokens).toBe(12);
  156. expect(ledgerRow?.outputTokens).toBe(34);
  157. expect(ledgerRow?.createdAt).toEqual(createdAt);
  158. });
  159. test("updates usage_ledger row on message_request update (UPSERT)", async () => {
  160. const key = nextKey("trigger-update");
  161. const userId = nextUserId();
  162. const providerId = nextProviderId();
  163. const requestId = await insertMessageRequestRow({
  164. key,
  165. userId,
  166. providerId,
  167. model: "model-before",
  168. costUsd: "0",
  169. });
  170. await db
  171. .update(messageRequest)
  172. .set({
  173. model: "model-after",
  174. costUsd: "3.500000000000000",
  175. inputTokens: 101,
  176. outputTokens: 202,
  177. statusCode: 201,
  178. })
  179. .where(eq(messageRequest.id, requestId));
  180. const rows = await db
  181. .select({
  182. id: usageLedger.id,
  183. model: usageLedger.model,
  184. costUsd: usageLedger.costUsd,
  185. inputTokens: usageLedger.inputTokens,
  186. outputTokens: usageLedger.outputTokens,
  187. statusCode: usageLedger.statusCode,
  188. })
  189. .from(usageLedger)
  190. .where(eq(usageLedger.requestId, requestId));
  191. expect(rows).toHaveLength(1);
  192. expect(rows[0]?.model).toBe("model-after");
  193. expect(toNumber(rows[0]?.costUsd)).toBeCloseTo(3.5, 10);
  194. expect(rows[0]?.inputTokens).toBe(101);
  195. expect(rows[0]?.outputTokens).toBe(202);
  196. expect(rows[0]?.statusCode).toBe(201);
  197. });
  198. test("does not insert usage_ledger row for warmup requests", async () => {
  199. const requestId = await insertMessageRequestRow({
  200. key: nextKey("trigger-warmup"),
  201. userId: nextUserId(),
  202. providerId: nextProviderId(),
  203. blockedBy: "warmup",
  204. costUsd: "8.900000000000000",
  205. });
  206. const ledgerRow = await selectLedgerRowByRequestId(requestId);
  207. expect(ledgerRow).toBeNull();
  208. });
  209. test("extracts final_provider_id from provider_chain", async () => {
  210. const providerId = nextProviderId();
  211. const requestId = await insertMessageRequestRow({
  212. key: nextKey("trigger-provider-chain"),
  213. userId: nextUserId(),
  214. providerId,
  215. providerChain: [
  216. { id: providerId, name: "origin" },
  217. { id: providerId + 777, name: "final" },
  218. ],
  219. });
  220. const ledgerRow = await selectLedgerRowByRequestId(requestId);
  221. expect(ledgerRow).not.toBeNull();
  222. expect(ledgerRow?.finalProviderId).toBe(providerId + 777);
  223. });
  224. test("sets is_success=false when error_message exists", async () => {
  225. const requestId = await insertMessageRequestRow({
  226. key: nextKey("trigger-error"),
  227. userId: nextUserId(),
  228. providerId: nextProviderId(),
  229. errorMessage: "upstream failed",
  230. statusCode: 500,
  231. });
  232. const ledgerRow = await selectLedgerRowByRequestId(requestId);
  233. expect(ledgerRow?.isSuccess).toBe(false);
  234. });
  235. test("sets is_success=true when error_message is absent", async () => {
  236. const requestId = await insertMessageRequestRow({
  237. key: nextKey("trigger-success"),
  238. userId: nextUserId(),
  239. providerId: nextProviderId(),
  240. statusCode: 200,
  241. });
  242. const ledgerRow = await selectLedgerRowByRequestId(requestId);
  243. expect(ledgerRow?.isSuccess).toBe(true);
  244. });
  245. });
  246. describe("backfill", () => {
  247. test(
  248. "backfill copies non-warmup message_request rows when ledger rows are missing",
  249. { timeout: 60_000 },
  250. async () => {
  251. const userId = nextUserId();
  252. const providerId = nextProviderId();
  253. const keepA = await insertMessageRequestRow({
  254. key: nextKey("backfill-a"),
  255. userId,
  256. providerId,
  257. costUsd: "1.100000000000000",
  258. });
  259. const keepB = await insertMessageRequestRow({
  260. key: nextKey("backfill-b"),
  261. userId,
  262. providerId,
  263. costUsd: "2.200000000000000",
  264. });
  265. const warmup = await insertMessageRequestRow({
  266. key: nextKey("backfill-warmup"),
  267. userId,
  268. providerId,
  269. blockedBy: "warmup",
  270. });
  271. await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup]));
  272. const summary = await backfillUsageLedger();
  273. expect(summary.totalProcessed).toBeGreaterThanOrEqual(2);
  274. const rows = await db
  275. .select({ requestId: usageLedger.requestId })
  276. .from(usageLedger)
  277. .where(inArray(usageLedger.requestId, [keepA, keepB, warmup]));
  278. const requestIds = rows.map((row) => row.requestId);
  279. expect(requestIds).toContain(keepA);
  280. expect(requestIds).toContain(keepB);
  281. expect(requestIds).not.toContain(warmup);
  282. }
  283. );
  284. test("backfill is idempotent when running twice", { timeout: 60_000 }, async () => {
  285. const requestId = await insertMessageRequestRow({
  286. key: nextKey("backfill-idempotent"),
  287. userId: nextUserId(),
  288. providerId: nextProviderId(),
  289. costUsd: "6.600000000000000",
  290. });
  291. await db.delete(usageLedger).where(eq(usageLedger.requestId, requestId));
  292. await backfillUsageLedger();
  293. const countAfterFirst = await db
  294. .select({ count: sql<number>`count(*)::int` })
  295. .from(usageLedger)
  296. .where(eq(usageLedger.requestId, requestId));
  297. await backfillUsageLedger();
  298. const countAfterSecond = await db
  299. .select({ count: sql<number>`count(*)::int` })
  300. .from(usageLedger)
  301. .where(eq(usageLedger.requestId, requestId));
  302. expect(countAfterFirst[0]?.count ?? 0).toBe(1);
  303. expect(countAfterSecond[0]?.count ?? 0).toBe(1);
  304. });
  305. });
  306. describe("read path consistency", () => {
  307. test("sumUserTotalCost matches expected cost from trigger-written ledger data", async () => {
  308. const userId = nextUserId();
  309. const providerId = nextProviderId();
  310. await insertMessageRequestRow({
  311. key: nextKey("read-match-a"),
  312. userId,
  313. providerId,
  314. costUsd: "1.110000000000000",
  315. });
  316. await insertMessageRequestRow({
  317. key: nextKey("read-match-b"),
  318. userId,
  319. providerId,
  320. costUsd: "2.220000000000000",
  321. });
  322. const total = await sumUserTotalCost(userId, Number.POSITIVE_INFINITY);
  323. expect(total).toBeCloseTo(3.33, 10);
  324. });
  325. test("ledger totals remain stable after deleting message_request rows", async () => {
  326. const userId = nextUserId();
  327. const providerId = nextProviderId();
  328. const requestA = await insertMessageRequestRow({
  329. key: nextKey("read-delete-a"),
  330. userId,
  331. providerId,
  332. costUsd: "4.440000000000000",
  333. });
  334. const requestB = await insertMessageRequestRow({
  335. key: nextKey("read-delete-b"),
  336. userId,
  337. providerId,
  338. costUsd: "5.550000000000000",
  339. });
  340. const beforeUserCost = await sumUserTotalCost(userId, Number.POSITIVE_INFINITY);
  341. const beforeProviderCost = await sumProviderTotalCost(providerId);
  342. await db
  343. .delete(messageRequest)
  344. .where(
  345. and(eq(messageRequest.userId, userId), inArray(messageRequest.id, [requestA, requestB]))
  346. );
  347. const afterUserCost = await sumUserTotalCost(userId, Number.POSITIVE_INFINITY);
  348. const afterProviderCost = await sumProviderTotalCost(providerId);
  349. expect(afterUserCost).toBeCloseTo(beforeUserCost, 10);
  350. expect(afterProviderCost).toBeCloseTo(beforeProviderCost, 10);
  351. });
  352. });
  353. describe("ledger-only mode", () => {
  354. test("isLedgerOnlyMode returns boolean", async () => {
  355. const result = await isLedgerOnlyMode();
  356. expect(typeof result).toBe("boolean");
  357. });
  358. test("log listing has ledger fallback path", async () => {
  359. const key = nextKey("ledger-only-logs");
  360. const userId = nextUserId();
  361. const providerId = nextProviderId();
  362. const requestId = await insertMessageRequestRow({
  363. key,
  364. userId,
  365. providerId,
  366. costUsd: "7.770000000000000",
  367. });
  368. await db.delete(messageRequest).where(eq(messageRequest.id, requestId));
  369. const [remaining] = await db
  370. .select({ count: sql<number>`count(*)::int` })
  371. .from(messageRequest);
  372. if ((remaining?.count ?? 0) > 0) {
  373. const source = await readFile(resolve(process.cwd(), "src/repository/message.ts"), "utf8");
  374. expect(source).toContain("if (!(await isLedgerOnlyMode()))");
  375. expect(source).toContain(".from(usageLedger)");
  376. return;
  377. }
  378. vi.resetModules();
  379. const { findUsageLogs: findUsageLogsFresh } = await import("@/repository/message");
  380. const result = await findUsageLogsFresh({ userId, page: 1, pageSize: 20 });
  381. expect(result.logs.some((row) => row.id === requestId)).toBe(true);
  382. expect(result.total).toBeGreaterThanOrEqual(1);
  383. });
  384. });
  385. test("findUsageLogs remains callable for compatibility", async () => {
  386. const key = nextKey("compat-call");
  387. const userId = nextUserId();
  388. const providerId = nextProviderId();
  389. await insertMessageRequestRow({
  390. key,
  391. userId,
  392. providerId,
  393. costUsd: "0.010000000000000",
  394. });
  395. const result = await findUsageLogs({ userId, page: 1, pageSize: 5 });
  396. expect(Array.isArray(result.logs)).toBe(true);
  397. });
  398. });