usage-ledger.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  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("backfill copies non-warmup message_request rows when ledger rows are missing", {
  248. timeout: 60_000,
  249. }, async () => {
  250. const userId = nextUserId();
  251. const providerId = nextProviderId();
  252. const keepA = await insertMessageRequestRow({
  253. key: nextKey("backfill-a"),
  254. userId,
  255. providerId,
  256. costUsd: "1.100000000000000",
  257. });
  258. const keepB = await insertMessageRequestRow({
  259. key: nextKey("backfill-b"),
  260. userId,
  261. providerId,
  262. costUsd: "2.200000000000000",
  263. });
  264. const warmup = await insertMessageRequestRow({
  265. key: nextKey("backfill-warmup"),
  266. userId,
  267. providerId,
  268. blockedBy: "warmup",
  269. });
  270. await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup]));
  271. const summary = await backfillUsageLedger();
  272. expect(summary.totalProcessed).toBeGreaterThanOrEqual(2);
  273. const rows = await db
  274. .select({ requestId: usageLedger.requestId })
  275. .from(usageLedger)
  276. .where(inArray(usageLedger.requestId, [keepA, keepB, warmup]));
  277. const requestIds = rows.map((row) => row.requestId);
  278. expect(requestIds).toContain(keepA);
  279. expect(requestIds).toContain(keepB);
  280. expect(requestIds).not.toContain(warmup);
  281. });
  282. test("backfill is idempotent when running twice", { timeout: 60_000 }, async () => {
  283. const requestId = await insertMessageRequestRow({
  284. key: nextKey("backfill-idempotent"),
  285. userId: nextUserId(),
  286. providerId: nextProviderId(),
  287. costUsd: "6.600000000000000",
  288. });
  289. await db.delete(usageLedger).where(eq(usageLedger.requestId, requestId));
  290. await backfillUsageLedger();
  291. const countAfterFirst = await db
  292. .select({ count: sql<number>`count(*)::int` })
  293. .from(usageLedger)
  294. .where(eq(usageLedger.requestId, requestId));
  295. await backfillUsageLedger();
  296. const countAfterSecond = await db
  297. .select({ count: sql<number>`count(*)::int` })
  298. .from(usageLedger)
  299. .where(eq(usageLedger.requestId, requestId));
  300. expect(countAfterFirst[0]?.count ?? 0).toBe(1);
  301. expect(countAfterSecond[0]?.count ?? 0).toBe(1);
  302. });
  303. });
  304. describe("read path consistency", () => {
  305. test("sumUserTotalCost matches expected cost from trigger-written ledger data", async () => {
  306. const userId = nextUserId();
  307. const providerId = nextProviderId();
  308. await insertMessageRequestRow({
  309. key: nextKey("read-match-a"),
  310. userId,
  311. providerId,
  312. costUsd: "1.110000000000000",
  313. });
  314. await insertMessageRequestRow({
  315. key: nextKey("read-match-b"),
  316. userId,
  317. providerId,
  318. costUsd: "2.220000000000000",
  319. });
  320. const total = await sumUserTotalCost(userId, Number.POSITIVE_INFINITY);
  321. expect(total).toBeCloseTo(3.33, 10);
  322. });
  323. test("ledger totals remain stable after deleting message_request rows", async () => {
  324. const userId = nextUserId();
  325. const providerId = nextProviderId();
  326. const requestA = await insertMessageRequestRow({
  327. key: nextKey("read-delete-a"),
  328. userId,
  329. providerId,
  330. costUsd: "4.440000000000000",
  331. });
  332. const requestB = await insertMessageRequestRow({
  333. key: nextKey("read-delete-b"),
  334. userId,
  335. providerId,
  336. costUsd: "5.550000000000000",
  337. });
  338. const beforeUserCost = await sumUserTotalCost(userId, Number.POSITIVE_INFINITY);
  339. const beforeProviderCost = await sumProviderTotalCost(providerId);
  340. await db
  341. .delete(messageRequest)
  342. .where(
  343. and(eq(messageRequest.userId, userId), inArray(messageRequest.id, [requestA, requestB]))
  344. );
  345. const afterUserCost = await sumUserTotalCost(userId, Number.POSITIVE_INFINITY);
  346. const afterProviderCost = await sumProviderTotalCost(providerId);
  347. expect(afterUserCost).toBeCloseTo(beforeUserCost, 10);
  348. expect(afterProviderCost).toBeCloseTo(beforeProviderCost, 10);
  349. });
  350. });
  351. describe("ledger-only mode", () => {
  352. test("isLedgerOnlyMode returns boolean", async () => {
  353. const result = await isLedgerOnlyMode();
  354. expect(typeof result).toBe("boolean");
  355. });
  356. test("log listing has ledger fallback path", async () => {
  357. const key = nextKey("ledger-only-logs");
  358. const userId = nextUserId();
  359. const providerId = nextProviderId();
  360. const requestId = await insertMessageRequestRow({
  361. key,
  362. userId,
  363. providerId,
  364. costUsd: "7.770000000000000",
  365. });
  366. await db.delete(messageRequest).where(eq(messageRequest.id, requestId));
  367. const [remaining] = await db
  368. .select({ count: sql<number>`count(*)::int` })
  369. .from(messageRequest);
  370. if ((remaining?.count ?? 0) > 0) {
  371. const source = await readFile(resolve(process.cwd(), "src/repository/message.ts"), "utf8");
  372. expect(source).toContain("if (!(await isLedgerOnlyMode()))");
  373. expect(source).toContain(".from(usageLedger)");
  374. return;
  375. }
  376. vi.resetModules();
  377. const { findUsageLogs: findUsageLogsFresh } = await import("@/repository/message");
  378. const result = await findUsageLogsFresh({ userId, page: 1, pageSize: 20 });
  379. expect(result.logs.some((row) => row.id === requestId)).toBe(true);
  380. expect(result.total).toBeGreaterThanOrEqual(1);
  381. });
  382. });
  383. test("findUsageLogs remains callable for compatibility", async () => {
  384. const key = nextKey("compat-call");
  385. const userId = nextUserId();
  386. const providerId = nextProviderId();
  387. await insertMessageRequestRow({
  388. key,
  389. userId,
  390. providerId,
  391. costUsd: "0.010000000000000",
  392. });
  393. const result = await findUsageLogs({ userId, page: 1, pageSize: 5 });
  394. expect(Array.isArray(result.logs)).toBe(true);
  395. });
  396. });