@tokenbuddy/tokenbuddy 1.0.35 → 1.0.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/dist/src/buyer-store.d.ts +6 -1
  2. package/dist/src/buyer-store.js +43 -4
  3. package/dist/src/cli.js +2 -2
  4. package/dist/src/daemon.d.ts +12 -0
  5. package/dist/src/daemon.js +791 -61
  6. package/dist/src/doctor-diagnostics.js +1 -6
  7. package/dist/src/provider-install.d.ts +2 -2
  8. package/dist/src/provider-install.js +248 -2
  9. package/dist/src/seller-catalog.d.ts +21 -0
  10. package/dist/src/seller-catalog.js +17 -0
  11. package/dist/src/seller-route-planner.d.ts +4 -1
  12. package/dist/src/seller-route-planner.js +3 -0
  13. package/dist/src/seller-routing-strategy.d.ts +3 -0
  14. package/dist/src/terminal-detect.d.ts +1 -1
  15. package/dist/src/terminal-detect.js +3 -2
  16. package/package.json +15 -2
  17. package/static/ui/assets/index-Djfl9tw5.js +271 -0
  18. package/static/ui/assets/index-DkfztCkn.css +1 -0
  19. package/static/ui/index.html +2 -2
  20. package/dist/src/buyer-store.d.ts.map +0 -1
  21. package/dist/src/buyer-store.js.map +0 -1
  22. package/dist/src/clawtip-bootstrap.d.ts.map +0 -1
  23. package/dist/src/clawtip-bootstrap.js.map +0 -1
  24. package/dist/src/cli.d.ts.map +0 -1
  25. package/dist/src/cli.js.map +0 -1
  26. package/dist/src/credit-tracker.d.ts.map +0 -1
  27. package/dist/src/credit-tracker.js.map +0 -1
  28. package/dist/src/daemon.d.ts.map +0 -1
  29. package/dist/src/daemon.js.map +0 -1
  30. package/dist/src/doctor-clawtip-wallet.d.ts.map +0 -1
  31. package/dist/src/doctor-clawtip-wallet.js.map +0 -1
  32. package/dist/src/doctor-diagnostics.d.ts.map +0 -1
  33. package/dist/src/doctor-diagnostics.js.map +0 -1
  34. package/dist/src/index.d.ts.map +0 -1
  35. package/dist/src/index.js.map +0 -1
  36. package/dist/src/init-clawtip-activation.d.ts.map +0 -1
  37. package/dist/src/init-clawtip-activation.js.map +0 -1
  38. package/dist/src/init-payment-options.d.ts.map +0 -1
  39. package/dist/src/init-payment-options.js.map +0 -1
  40. package/dist/src/init-setup.d.ts.map +0 -1
  41. package/dist/src/init-setup.js.map +0 -1
  42. package/dist/src/model-index.d.ts.map +0 -1
  43. package/dist/src/model-index.js.map +0 -1
  44. package/dist/src/package-update.d.ts.map +0 -1
  45. package/dist/src/package-update.js.map +0 -1
  46. package/dist/src/prewarm-cache.d.ts.map +0 -1
  47. package/dist/src/prewarm-cache.js.map +0 -1
  48. package/dist/src/prewarm-scheduler.d.ts.map +0 -1
  49. package/dist/src/prewarm-scheduler.js.map +0 -1
  50. package/dist/src/provider-install.d.ts.map +0 -1
  51. package/dist/src/provider-install.js.map +0 -1
  52. package/dist/src/provider-routing-config.d.ts.map +0 -1
  53. package/dist/src/provider-routing-config.js.map +0 -1
  54. package/dist/src/registry-trust.d.ts.map +0 -1
  55. package/dist/src/registry-trust.js.map +0 -1
  56. package/dist/src/route-failover.d.ts.map +0 -1
  57. package/dist/src/route-failover.js.map +0 -1
  58. package/dist/src/seller-catalog.d.ts.map +0 -1
  59. package/dist/src/seller-catalog.js.map +0 -1
  60. package/dist/src/seller-concurrency-limiter.d.ts.map +0 -1
  61. package/dist/src/seller-concurrency-limiter.js.map +0 -1
  62. package/dist/src/seller-metadata-cache.d.ts.map +0 -1
  63. package/dist/src/seller-metadata-cache.js.map +0 -1
  64. package/dist/src/seller-pool.d.ts.map +0 -1
  65. package/dist/src/seller-pool.js.map +0 -1
  66. package/dist/src/seller-route-planner.d.ts.map +0 -1
  67. package/dist/src/seller-route-planner.js.map +0 -1
  68. package/dist/src/seller-routing-config.d.ts.map +0 -1
  69. package/dist/src/seller-routing-config.js.map +0 -1
  70. package/dist/src/seller-routing-strategy.d.ts.map +0 -1
  71. package/dist/src/seller-routing-strategy.js.map +0 -1
  72. package/dist/src/stream-failover.d.ts.map +0 -1
  73. package/dist/src/stream-failover.js.map +0 -1
  74. package/dist/src/tb-clawtip-proof.d.ts.map +0 -1
  75. package/dist/src/tb-clawtip-proof.js.map +0 -1
  76. package/dist/src/tb-proxyd.d.ts.map +0 -1
  77. package/dist/src/tb-proxyd.js.map +0 -1
  78. package/dist/src/terminal-detect.d.ts.map +0 -1
  79. package/dist/src/terminal-detect.js.map +0 -1
  80. package/dist/src/terminal-image.d.ts.map +0 -1
  81. package/dist/src/terminal-image.js.map +0 -1
  82. package/src/buyer-store.ts +0 -1090
  83. package/src/clawtip-bootstrap.ts +0 -65
  84. package/src/cli.ts +0 -2243
  85. package/src/credit-tracker.ts +0 -295
  86. package/src/daemon.ts +0 -5475
  87. package/src/doctor-clawtip-wallet.ts +0 -95
  88. package/src/doctor-diagnostics.ts +0 -1026
  89. package/src/index.ts +0 -16
  90. package/src/init-clawtip-activation.ts +0 -695
  91. package/src/init-payment-options.ts +0 -373
  92. package/src/init-setup.ts +0 -165
  93. package/src/model-index.ts +0 -278
  94. package/src/package-update.ts +0 -311
  95. package/src/prewarm-cache.ts +0 -485
  96. package/src/prewarm-scheduler.ts +0 -675
  97. package/src/provider-install.ts +0 -1006
  98. package/src/provider-routing-config.ts +0 -410
  99. package/src/registry-trust.ts +0 -51
  100. package/src/route-failover.ts +0 -304
  101. package/src/seller-catalog.ts +0 -505
  102. package/src/seller-concurrency-limiter.ts +0 -161
  103. package/src/seller-metadata-cache.ts +0 -91
  104. package/src/seller-pool.ts +0 -557
  105. package/src/seller-route-planner.ts +0 -513
  106. package/src/seller-routing-config.ts +0 -211
  107. package/src/seller-routing-strategy.ts +0 -362
  108. package/src/stream-failover.ts +0 -152
  109. package/src/tb-clawtip-proof.ts +0 -28
  110. package/src/tb-proxyd.ts +0 -101
  111. package/src/terminal-detect.ts +0 -333
  112. package/src/terminal-image.ts +0 -228
  113. package/static/ui/assets/index-0MVXD7bH.css +0 -1
  114. package/static/ui/assets/index-BVbeDEwq.js +0 -271
  115. package/static/ui/assets/index-BVbeDEwq.js.map +0 -1
  116. package/tests/cli-routing.test.ts +0 -363
  117. package/tests/control-plane-ui-endpoints.test.ts +0 -1630
  118. package/tests/credit-tracker.test.ts +0 -165
  119. package/tests/daemon-413-fallback.test.ts +0 -92
  120. package/tests/daemon-classify.test.ts +0 -452
  121. package/tests/daemon-roles.test.ts +0 -92
  122. package/tests/daemon-trusted-registry-cache.test.ts +0 -132
  123. package/tests/e2e.test.ts +0 -366
  124. package/tests/image-generation-e2e.test.ts +0 -230
  125. package/tests/model-index.test.ts +0 -198
  126. package/tests/package-update.test.ts +0 -147
  127. package/tests/prewarm-cache.test.ts +0 -296
  128. package/tests/prewarm-scheduler.test.ts +0 -367
  129. package/tests/provider-routing-config.test.ts +0 -150
  130. package/tests/registry-trust.test.ts +0 -28
  131. package/tests/route-failover.test.ts +0 -222
  132. package/tests/seller-catalog-413.test.ts +0 -120
  133. package/tests/seller-catalog-utilities.test.ts +0 -124
  134. package/tests/seller-concurrency-limiter.test.ts +0 -83
  135. package/tests/seller-metadata-cache.test.ts +0 -89
  136. package/tests/seller-pool.test.ts +0 -365
  137. package/tests/seller-route-planner.test.ts +0 -312
  138. package/tests/seller-routing-config.test.ts +0 -124
  139. package/tests/seller-routing-strategy.test.ts +0 -167
  140. package/tests/stream-failover.test.ts +0 -52
  141. package/tests/thousand-seller.test.ts +0 -151
  142. package/tests/tokenbuddy.test.ts +0 -4043
  143. package/tsconfig.json +0 -8
@@ -1,198 +0,0 @@
1
- import { ModelIndex } from "../src/model-index.js";
2
- import type { RegistrySeller } from "../src/seller-catalog.js";
3
-
4
- function makeSeller(overrides: Partial<RegistrySeller> & { id: string; models?: string[] }): RegistrySeller {
5
- return {
6
- id: overrides.id,
7
- name: overrides.name ?? overrides.id,
8
- status: overrides.status,
9
- url: overrides.url ?? `https://${overrides.id}.example.com`,
10
- supportedProtocols: overrides.supportedProtocols ?? ["chat_completions"],
11
- paymentMethods: overrides.paymentMethods ?? ["clawtip"],
12
- models: overrides.models
13
- };
14
- }
15
-
16
- describe("ModelIndex", () => {
17
- test("rebuilds an empty snapshot without throwing", () => {
18
- const index = new ModelIndex();
19
- index.rebuild([], { registryVersion: 1 });
20
-
21
- expect(index.sellersFor("anything")).toEqual([]);
22
- expect(index.knownModelIds()).toEqual([]);
23
- const stats = index.stats();
24
- expect(stats.sellerCount).toBe(0);
25
- expect(stats.modelCount).toBe(0);
26
- expect(stats.missingModelsCount).toBe(0);
27
- expect(stats.registryVersion).toBe(1);
28
- });
29
-
30
- test("rebuild populates byModel and bySeller from a registry snapshot", () => {
31
- const index = new ModelIndex();
32
- const sellers: RegistrySeller[] = [
33
- makeSeller({ id: "s1", models: ["claude-sonnet-4-5", "gpt-4o"] }),
34
- makeSeller({ id: "s2", models: ["gpt-4o", "deepseek-v4-pro"] })
35
- ];
36
-
37
- index.rebuild(sellers, { registryVersion: 4, defaultSellerId: "s1" });
38
-
39
- expect(index.sellersFor("claude-sonnet-4-5").map((s) => s.id)).toEqual(["s1"]);
40
- expect(index.sellersFor("gpt-4o").map((s) => s.id)).toEqual(["s1", "s2"]);
41
- expect(index.sellersFor("deepseek-v4-pro").map((s) => s.id)).toEqual(["s2"]);
42
- expect(index.getSeller("s1")?.url).toBe("https://s1.example.com");
43
- expect(index.getSeller("missing")).toBeUndefined();
44
- expect(index.stats().defaultSellerId).toBe("s1");
45
- expect(index.stats().registryVersion).toBe(4);
46
- });
47
-
48
- test("model id matching is case-insensitive and trims whitespace", () => {
49
- const index = new ModelIndex();
50
- index.rebuild([makeSeller({ id: "s1", models: ["claude-sonnet-4-5"] })]);
51
-
52
- expect(index.sellersFor("Claude-Sonnet-4-5").map((s) => s.id)).toEqual(["s1"]);
53
- expect(index.sellersFor(" claude-sonnet-4-5 ").map((s) => s.id)).toEqual(["s1"]);
54
- expect(index.sellersFor("CLAUDE-SONNET-4-5").map((s) => s.id)).toEqual(["s1"]);
55
- });
56
-
57
- test("filters by protocol and payment method", () => {
58
- const index = new ModelIndex();
59
- const sellers: RegistrySeller[] = [
60
- makeSeller({
61
- id: "s-chat",
62
- models: ["gpt-4o"],
63
- supportedProtocols: ["chat_completions"],
64
- paymentMethods: ["clawtip"]
65
- }),
66
- makeSeller({
67
- id: "s-msg",
68
- models: ["gpt-4o"],
69
- supportedProtocols: ["messages"],
70
- paymentMethods: ["mock"]
71
- })
72
- ];
73
- index.rebuild(sellers);
74
-
75
- const chatClawtip = index.sellersFor("gpt-4o", { protocol: "chat_completions", paymentMethod: "clawtip" });
76
- expect(chatClawtip.map((s) => s.id)).toEqual(["s-chat"]);
77
-
78
- const messages = index.sellersFor("gpt-4o", { protocol: "messages" });
79
- expect(messages.map((s) => s.id)).toEqual(["s-msg"]);
80
-
81
- const messagesClawtip = index.sellersFor("gpt-4o", { protocol: "messages", paymentMethod: "clawtip" });
82
- expect(messagesClawtip).toEqual([]);
83
-
84
- const noFilter = index.sellersFor("gpt-4o");
85
- expect(noFilter.map((s) => s.id).sort()).toEqual(["s-chat", "s-msg"]);
86
- });
87
-
88
- test("indexes only active buyer-visible registry sellers", () => {
89
- const index = new ModelIndex();
90
- index.rebuild([
91
- makeSeller({ id: "legacy-no-status", models: ["gpt-4o"] }),
92
- makeSeller({ id: "active", status: "active", models: ["gpt-4o"] }),
93
- makeSeller({ id: "pending", status: "pending", models: ["gpt-4o"] }),
94
- makeSeller({ id: "offline", status: "offline", models: ["gpt-4o"] })
95
- ]);
96
-
97
- expect(index.sellersFor("gpt-4o").map((seller) => seller.id)).toEqual(["legacy-no-status", "active"]);
98
- expect(index.getSeller("pending")).toBeUndefined();
99
- });
100
-
101
- test("sellers missing models field are excluded from lookups but still addressable by id", () => {
102
- const index = new ModelIndex();
103
- const sellers: RegistrySeller[] = [
104
- makeSeller({ id: "ok", models: ["gpt-4o"] }),
105
- makeSeller({ id: "broken" }) // no models
106
- ];
107
- index.rebuild(sellers);
108
-
109
- expect(index.sellersFor("gpt-4o").map((s) => s.id)).toEqual(["ok"]);
110
- expect(index.getSeller("broken")?.id).toBe("broken");
111
- expect(index.stats().missingModelsCount).toBe(1);
112
- expect(index.stats().sellerCount).toBe(2);
113
- });
114
-
115
- test("resolve returns matched flag and snapshot", () => {
116
- const index = new ModelIndex();
117
- index.rebuild([makeSeller({ id: "s1", models: ["gpt-4o"] })]);
118
-
119
- const hit = index.resolve("gpt-4o");
120
- expect(hit.matched).toBe(true);
121
- expect(hit.sellers).toHaveLength(1);
122
-
123
- const miss = index.resolve("unknown-model");
124
- expect(miss.matched).toBe(false);
125
- expect(miss.sellers).toEqual([]);
126
- });
127
-
128
- test("rebuild replaces the previous snapshot atomically", () => {
129
- const index = new ModelIndex();
130
- index.rebuild([makeSeller({ id: "s1", models: ["m1"] })], { registryVersion: 1 });
131
- expect(index.sellersFor("m1")).toHaveLength(1);
132
-
133
- index.rebuild([makeSeller({ id: "s2", models: ["m2"] })], { registryVersion: 2 });
134
- expect(index.sellersFor("m1")).toEqual([]);
135
- expect(index.sellersFor("m2")).toHaveLength(1);
136
- expect(index.stats().registryVersion).toBe(2);
137
- });
138
-
139
- test("prune drops sellers that have not been seen within staleAfterMs", () => {
140
- const index = new ModelIndex();
141
- index.rebuild([
142
- makeSeller({ id: "s1", models: ["gpt-4o", "m1"] }),
143
- makeSeller({ id: "s2", models: ["m2"] })
144
- ]);
145
-
146
- const now = 1_700_000_000_000;
147
- const lastSeenAt = new Map<string, number>([
148
- ["s1", now - 1000], // fresh
149
- ["s2", now - 10 * 24 * 60 * 60 * 1000] // 10 days old
150
- ]);
151
-
152
- const removed = index.prune(lastSeenAt, 7 * 24 * 60 * 60 * 1000, now);
153
- expect(removed).toBe(1);
154
- expect(index.getSeller("s1")).toBeDefined();
155
- expect(index.getSeller("s2")).toBeUndefined();
156
- // Models that belonged only to the pruned seller must be removed.
157
- expect(index.sellersFor("m2")).toEqual([]);
158
- // Models shared with the remaining seller must still resolve.
159
- expect(index.sellersFor("gpt-4o")).toHaveLength(1);
160
- });
161
-
162
- test("prune is a no-op when staleAfterMs is non-positive", () => {
163
- const index = new ModelIndex();
164
- index.rebuild([makeSeller({ id: "s1", models: ["m1"] })]);
165
- expect(index.prune(new Map([["s1", 0]]), 0)).toBe(0);
166
- expect(index.sellersFor("m1")).toHaveLength(1);
167
- });
168
-
169
- test("ignores malformed model entries when populating the index", () => {
170
- const index = new ModelIndex();
171
- const sellers: RegistrySeller[] = [
172
- {
173
- id: "s1",
174
- name: "s1",
175
- url: "https://s1.example.com",
176
- supportedProtocols: ["chat_completions"],
177
- paymentMethods: ["clawtip"],
178
- // Mixed valid / invalid entries; the index must skip the bad ones
179
- // without throwing.
180
- models: ["m1", "", " ", "m2"] as string[]
181
- }
182
- ];
183
- index.rebuild(sellers);
184
-
185
- expect(index.sellersFor("m1")).toHaveLength(1);
186
- expect(index.sellersFor("m2")).toHaveLength(1);
187
- expect(index.knownModelIds().sort()).toEqual(["m1", "m2"]);
188
- });
189
-
190
- test("knownModelIds returns a snapshot that does not mutate internal state", () => {
191
- const index = new ModelIndex();
192
- index.rebuild([makeSeller({ id: "s1", models: ["m1"] })]);
193
-
194
- const ids = index.knownModelIds();
195
- ids.push("hacked");
196
- expect(index.knownModelIds()).toEqual(["m1"]);
197
- });
198
- });
@@ -1,147 +0,0 @@
1
- import * as fs from "fs";
2
- import * as os from "os";
3
- import * as path from "path";
4
- import {
5
- checkPackageUpdate,
6
- runPackageUpdate,
7
- scheduleLaunchAgentRestart,
8
- } from "../src/package-update.js";
9
-
10
- function makeFetch(latestVersion: string, ok = true): typeof fetch {
11
- return (async () => ({
12
- ok,
13
- status: ok ? 200 : 404,
14
- json: async () => ({ "dist-tags": { latest: latestVersion } }),
15
- })) as unknown as typeof fetch;
16
- }
17
-
18
- function writePackageJson(root: string, version: string, name = "@tokenbuddy/tokenbuddy"): string {
19
- const binDir = path.join(root, "bin");
20
- fs.mkdirSync(binDir, { recursive: true });
21
- const binPath = path.join(binDir, "tb.js");
22
- fs.writeFileSync(binPath, "", "utf8");
23
- fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name, version }), "utf8");
24
- return binPath;
25
- }
26
-
27
- describe("TokenBuddy package update", () => {
28
- let tempRoot: string;
29
-
30
- beforeEach(() => {
31
- tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "tokenbuddy-package-update-"));
32
- });
33
-
34
- afterEach(() => {
35
- fs.rmSync(tempRoot, { recursive: true, force: true });
36
- });
37
-
38
- test("checks npm latest for the scoped tokenbuddy package", async () => {
39
- const binPath = writePackageJson(tempRoot, "1.0.0");
40
-
41
- const check = await checkPackageUpdate({
42
- fetch: makeFetch("1.2.0"),
43
- argv: ["node", binPath],
44
- cwd: tempRoot,
45
- env: {},
46
- });
47
-
48
- expect(check).toMatchObject({
49
- packageName: "@tokenbuddy/tokenbuddy",
50
- currentVersion: "1.0.0",
51
- latestVersion: "1.2.0",
52
- updateAvailable: true,
53
- registryUrl: "https://registry.npmjs.org/%40tokenbuddy%2Ftokenbuddy",
54
- installCommand: "npm install -g @tokenbuddy/tokenbuddy@1.2.0",
55
- });
56
- });
57
-
58
- test("uses the unscoped package name only for legacy unscoped installs", async () => {
59
- const binPath = writePackageJson(tempRoot, "1.0.0", "tokenbuddy");
60
-
61
- const check = await checkPackageUpdate({
62
- fetch: makeFetch("1.2.0"),
63
- argv: ["node", binPath],
64
- cwd: tempRoot,
65
- env: {},
66
- });
67
-
68
- expect(check.packageName).toBe("tokenbuddy");
69
- expect(check.installCommand).toBe("npm install -g tokenbuddy@1.2.0");
70
- });
71
-
72
- test("installs the latest package and restarts tb-proxyd when an update is available", async () => {
73
- const binPath = writePackageJson(tempRoot, "1.0.0");
74
- const installCalls: Array<{ command: string; args: string[] }> = [];
75
- const restartCalls: number[] = [];
76
-
77
- const result = await runPackageUpdate(
78
- { apply: true, controlPort: 17820 },
79
- {
80
- fetch: makeFetch("1.1.0"),
81
- argv: ["node", binPath],
82
- cwd: tempRoot,
83
- env: {},
84
- runNpmInstall: (command, args) => {
85
- installCalls.push({ command, args });
86
- },
87
- restartProxyd: async (controlPort) => {
88
- restartCalls.push(controlPort);
89
- return { attempted: true, restarted: true, method: "launchd" };
90
- },
91
- },
92
- );
93
-
94
- expect(result.install).toMatchObject({ attempted: true, succeeded: true });
95
- expect(result.restart).toMatchObject({ attempted: true, restarted: true });
96
- expect(installCalls).toEqual([{ command: "npm", args: ["install", "-g", "@tokenbuddy/tokenbuddy@1.1.0"] }]);
97
- expect(restartCalls).toEqual([17820]);
98
- });
99
-
100
- test("does not install or restart when the current version is already latest", async () => {
101
- const binPath = writePackageJson(tempRoot, "1.0.0");
102
- const result = await runPackageUpdate(
103
- { apply: true, controlPort: 17820 },
104
- {
105
- fetch: makeFetch("1.0.0"),
106
- argv: ["node", binPath],
107
- cwd: tempRoot,
108
- env: {},
109
- runNpmInstall: () => {
110
- throw new Error("install should not run");
111
- },
112
- restartProxyd: async () => {
113
- throw new Error("restart should not run");
114
- },
115
- },
116
- );
117
-
118
- expect(result.install.attempted).toBe(false);
119
- expect(result.restart.attempted).toBe(false);
120
- });
121
-
122
- test("reports launchd restart scheduling without blocking on the current daemon process", () => {
123
- const homeDir = path.join(tempRoot, "home");
124
- const plistPath = path.join(homeDir, "Library", "LaunchAgents", "com.tokenbuddy.proxyd.plist");
125
- fs.mkdirSync(path.dirname(plistPath), { recursive: true });
126
- fs.writeFileSync(plistPath, "", "utf8");
127
- const children: string[][] = [];
128
-
129
- const result = scheduleLaunchAgentRestart({
130
- platform: "darwin",
131
- homeDir,
132
- spawn: ((command: string, args: string[]) => {
133
- children.push([command, ...args]);
134
- return { unref: () => undefined };
135
- }) as unknown as typeof import("child_process").spawn,
136
- });
137
-
138
- expect(result).toMatchObject({
139
- attempted: true,
140
- scheduled: true,
141
- method: "launchd",
142
- plistPath,
143
- });
144
- expect(children[0]).toEqual(expect.arrayContaining(["sh", "-c"]));
145
- expect(children[0][2]).toContain("launchctl kickstart -k");
146
- });
147
- });
@@ -1,296 +0,0 @@
1
- import {
2
- DEFAULT_PREWARM_TTL_MS,
3
- PrewarmCache,
4
- prewarmKey
5
- } from "../src/prewarm-cache.js";
6
-
7
- describe("PrewarmCache", () => {
8
- function makeCandidate(overrides: { sellerId: string; healthScore?: number; url?: string }) {
9
- return {
10
- sellerId: overrides.sellerId,
11
- url: overrides.url ?? `https://${overrides.sellerId}.example.com`,
12
- healthScore: overrides.healthScore ?? 80
13
- };
14
- }
15
-
16
- test("key encoding is collision-free and case-insensitive", () => {
17
- const a = prewarmKey("gpt-4o", "chat_completions", "clawtip");
18
- const b = prewarmKey("GPT-4O", "Chat_Completions", "ClawTip");
19
- expect(a).toBe(b);
20
- });
21
-
22
- test("get returns undefined for unknown keys; freshness reports empty", () => {
23
- const cache = new PrewarmCache();
24
- expect(cache.get("m1", "chat_completions", "clawtip")).toBeUndefined();
25
- const f = cache.freshness("m1", "chat_completions", "clawtip");
26
- expect(f.present).toBe(false);
27
- expect(f.expired).toBe(true);
28
- expect(f.expiringSoon).toBe(true);
29
- expect(f.state).toBe("empty");
30
- });
31
-
32
- test("beginWarming creates a new warming entry without mutating any prior candidates", () => {
33
- const cache = new PrewarmCache();
34
- cache.commitWarm({
35
- modelId: "gpt-4o",
36
- protocol: "chat_completions",
37
- paymentMethod: "clawtip",
38
- candidates: [makeCandidate({ sellerId: "s1" })]
39
- });
40
-
41
- const begin = cache.beginWarming("gpt-4o", "chat_completions", "clawtip");
42
- expect(begin.hadPrevious).toBe(true);
43
- expect(begin.entry.state).toBe("warming");
44
- // Prior warm candidates are preserved while a new probe is in flight;
45
- // a re-probe that finds nothing must not silently wipe the cache.
46
- expect(begin.entry.candidates).toHaveLength(1);
47
- expect(begin.entry.candidates[0].sellerId).toBe("s1");
48
- });
49
-
50
- test("commitWarm resets warmedAt and replaces candidates", () => {
51
- const fakeNow = (() => {
52
- let t = 1_000_000;
53
- return () => t;
54
- })();
55
- const cache = new PrewarmCache({ now: fakeNow });
56
- cache.commitWarm({
57
- modelId: "gpt-4o",
58
- protocol: "chat_completions",
59
- paymentMethod: "clawtip",
60
- candidates: [makeCandidate({ sellerId: "s1" })]
61
- });
62
- expect(cache.get("gpt-4o", "chat_completions", "clawtip")?.warmedAt).toBe(1_000_000);
63
-
64
- fakeNow();
65
- fakeNow();
66
- const advanced = 1_000_000 + 9 * 60 * 1000; // 9 minutes later, still warm
67
- const secondNow = (() => {
68
- let t = advanced;
69
- return () => t;
70
- })();
71
- const cache2 = new PrewarmCache({ now: secondNow });
72
- cache2.commitWarm({
73
- modelId: "gpt-4o",
74
- protocol: "chat_completions",
75
- paymentMethod: "clawtip",
76
- candidates: [makeCandidate({ sellerId: "s1" })]
77
- });
78
- cache2.commitWarm({
79
- modelId: "gpt-4o",
80
- protocol: "chat_completions",
81
- paymentMethod: "clawtip",
82
- candidates: [makeCandidate({ sellerId: "s2" }), makeCandidate({ sellerId: "s3" })]
83
- });
84
- const entry = cache2.get("gpt-4o", "chat_completions", "clawtip");
85
- expect(entry?.warmedAt).toBe(advanced);
86
- expect(entry?.state).toBe("warm");
87
- expect(entry?.candidates.map((c) => c.sellerId).sort()).toEqual(["s2", "s3"]);
88
- });
89
-
90
- test("commitWarm with zero candidates marks the entry as empty", () => {
91
- const cache = new PrewarmCache();
92
- const result = cache.commitWarm({
93
- modelId: "gpt-4o",
94
- protocol: "chat_completions",
95
- paymentMethod: "clawtip",
96
- candidates: []
97
- });
98
- expect(result.entry.state).toBe("empty");
99
- expect(result.entry.candidates).toEqual([]);
100
- });
101
-
102
- test("commitWarm treats zero tokens-per-second as unknown", () => {
103
- const cache = new PrewarmCache();
104
- cache.commitWarm({
105
- modelId: "gpt-4o",
106
- protocol: "chat_completions",
107
- paymentMethod: "clawtip",
108
- candidates: [{
109
- ...makeCandidate({ sellerId: "s1" }),
110
- avgTokensPerSecond: 0
111
- }]
112
- });
113
-
114
- expect(cache.get("gpt-4o", "chat_completions", "clawtip")?.candidates[0].avgTokensPerSecond).toBeUndefined();
115
- });
116
-
117
- test("freshness reports expiringSoon when within the last 10% of TTL", () => {
118
- // Use a controllable `now` so we can advance time deterministically.
119
- let now = 0;
120
- const cache = new PrewarmCache({ now: () => now, defaultTtlMs: 1000 });
121
- cache.commitWarm({
122
- modelId: "gpt-4o",
123
- protocol: "chat_completions",
124
- paymentMethod: "clawtip",
125
- candidates: [makeCandidate({ sellerId: "s1" })]
126
- });
127
-
128
- // At t=0: warmedAt=0, age=0, far from expiry.
129
- const fresh = cache.freshness("gpt-4o", "chat_completions", "clawtip");
130
- expect(fresh.expired).toBe(false);
131
- expect(fresh.expiringSoon).toBe(false);
132
- expect(fresh.remainingMs).toBe(1000);
133
-
134
- // At t=850: 150ms left, not in the last 10%.
135
- now = 850;
136
- const midLife = cache.freshness("gpt-4o", "chat_completions", "clawtip");
137
- expect(midLife.expired).toBe(false);
138
- expect(midLife.expiringSoon).toBe(false);
139
- expect(midLife.remainingMs).toBe(150);
140
-
141
- // At t=950: 50ms left, in the last 10%.
142
- now = 950;
143
- const nearEnd = cache.freshness("gpt-4o", "chat_completions", "clawtip");
144
- expect(nearEnd.expired).toBe(false);
145
- expect(nearEnd.expiringSoon).toBe(true);
146
- expect(nearEnd.remainingMs).toBe(50);
147
-
148
- // At t=1100: past TTL, expired.
149
- now = 1100;
150
- const past = cache.freshness("gpt-4o", "chat_completions", "clawtip");
151
- expect(past.expired).toBe(true);
152
- expect(past.state).toBe("stale");
153
- });
154
-
155
- test("recordFailure increments consecutive failures and marks entry stale", () => {
156
- const cache = new PrewarmCache();
157
- cache.commitWarm({
158
- modelId: "gpt-4o",
159
- protocol: "chat_completions",
160
- paymentMethod: "clawtip",
161
- candidates: [makeCandidate({ sellerId: "s1" })]
162
- });
163
- const first = cache.recordFailure("gpt-4o", "chat_completions", "clawtip", "503");
164
- expect(first?.consecutiveWarmingFailures).toBe(1);
165
- expect(first?.state).toBe("stale");
166
- const second = cache.recordFailure("gpt-4o", "chat_completions", "clawtip");
167
- expect(second?.consecutiveWarmingFailures).toBe(2);
168
- });
169
-
170
- test("recordFailure is a no-op when the key is unknown", () => {
171
- const cache = new PrewarmCache();
172
- expect(cache.recordFailure("missing", "chat_completions", "clawtip")).toBeUndefined();
173
- });
174
-
175
- test("invalidateSeller drops the seller from every entry", () => {
176
- const cache = new PrewarmCache();
177
- cache.commitWarm({
178
- modelId: "gpt-4o",
179
- protocol: "chat_completions",
180
- paymentMethod: "clawtip",
181
- candidates: [makeCandidate({ sellerId: "s1" }), makeCandidate({ sellerId: "s2" })]
182
- });
183
- cache.commitWarm({
184
- modelId: "claude-sonnet-4-5",
185
- protocol: "chat_completions",
186
- paymentMethod: "clawtip",
187
- candidates: [makeCandidate({ sellerId: "s2" })]
188
- });
189
-
190
- const affected = cache.invalidateSeller("s2");
191
- expect(affected).toBe(2);
192
- const gpt = cache.get("gpt-4o", "chat_completions", "clawtip");
193
- expect(gpt?.candidates.map((c) => c.sellerId)).toEqual(["s1"]);
194
- const claude = cache.get("claude-sonnet-4-5", "chat_completions", "clawtip");
195
- expect(claude?.state).toBe("empty");
196
- expect(claude?.candidates).toEqual([]);
197
- });
198
-
199
- test("invalidateKey removes a single entry entirely", () => {
200
- const cache = new PrewarmCache();
201
- cache.commitWarm({
202
- modelId: "gpt-4o",
203
- protocol: "chat_completions",
204
- paymentMethod: "clawtip",
205
- candidates: [makeCandidate({ sellerId: "s1" })]
206
- });
207
- expect(cache.invalidateKey("gpt-4o", "chat_completions", "clawtip")).toBe(true);
208
- expect(cache.invalidateKey("gpt-4o", "chat_completions", "clawtip")).toBe(false);
209
- expect(cache.size()).toBe(0);
210
- });
211
-
212
- test("evictExpired removes only entries past their TTL", () => {
213
- // Use a single controllable clock so commit and evict see consistent time.
214
- let now = 0;
215
- const cache = new PrewarmCache({ now: () => now, defaultTtlMs: 1000 });
216
- cache.commitWarm({
217
- modelId: "gpt-4o",
218
- protocol: "chat_completions",
219
- paymentMethod: "clawtip",
220
- candidates: [makeCandidate({ sellerId: "s1" })]
221
- });
222
- cache.commitWarm({
223
- modelId: "claude-sonnet-4-5",
224
- protocol: "chat_completions",
225
- paymentMethod: "clawtip",
226
- candidates: [makeCandidate({ sellerId: "s2" })]
227
- });
228
- // Clock at 0; both fresh.
229
- expect(cache.evictExpired()).toBe(0);
230
-
231
- // Advance to t=500; still fresh.
232
- now = 500;
233
- expect(cache.evictExpired()).toBe(0);
234
-
235
- // Advance to t=1500; both entries now older than TTL=1000.
236
- now = 1500;
237
- expect(cache.evictExpired()).toBe(2);
238
- expect(cache.size()).toBe(0);
239
- });
240
-
241
- test("isExpiringSoon returns true only inside the expiry window", () => {
242
- const cache = new PrewarmCache({ now: () => 0, defaultTtlMs: 1000 });
243
- cache.commitWarm({
244
- modelId: "gpt-4o",
245
- protocol: "chat_completions",
246
- paymentMethod: "clawtip",
247
- candidates: [makeCandidate({ sellerId: "s1" })]
248
- });
249
- // At t=0: warmedAt==0, age==0, not expiring.
250
- expect(cache.isExpiringSoon("gpt-4o", "chat_completions", "clawtip", 100, 200)).toBe(false);
251
- // At t=850 (150ms left, within 100ms window? no).
252
- expect(cache.isExpiringSoon("gpt-4o", "chat_completions", "clawtip", 100, 850)).toBe(false);
253
- // At t=950 (50ms left, within 100ms window).
254
- expect(cache.isExpiringSoon("gpt-4o", "chat_completions", "clawtip", 100, 950)).toBe(true);
255
- // At t=1000 (expired; not in the "soon" window anymore).
256
- expect(cache.isExpiringSoon("gpt-4o", "chat_completions", "clawtip", 100, 1000)).toBe(false);
257
- });
258
-
259
- test("snapshot returns deep copies that do not mutate cache state", () => {
260
- const cache = new PrewarmCache();
261
- cache.commitWarm({
262
- modelId: "gpt-4o",
263
- protocol: "chat_completions",
264
- paymentMethod: "clawtip",
265
- candidates: [makeCandidate({ sellerId: "s1" })]
266
- });
267
- const snap = cache.snapshot();
268
- snap[0].candidates[0].healthScore = -1;
269
- expect(cache.get("gpt-4o", "chat_completions", "clawtip")?.candidates[0].healthScore).not.toBe(-1);
270
- });
271
-
272
- test("keys() decodes the cache key back into the model/protocol/payment triple", () => {
273
- const cache = new PrewarmCache();
274
- cache.commitWarm({
275
- modelId: "gpt-4o",
276
- protocol: "chat_completions",
277
- paymentMethod: "clawtip",
278
- candidates: [makeCandidate({ sellerId: "s1" })]
279
- });
280
- cache.commitWarm({
281
- modelId: "claude-sonnet-4-5",
282
- protocol: "messages",
283
- paymentMethod: "clawtip",
284
- candidates: [makeCandidate({ sellerId: "s2" })]
285
- });
286
- const keys = cache.keys().sort((a, b) => a.modelId.localeCompare(b.modelId));
287
- expect(keys).toEqual([
288
- { modelId: "claude-sonnet-4-5", protocol: "messages", paymentMethod: "clawtip" },
289
- { modelId: "gpt-4o", protocol: "chat_completions", paymentMethod: "clawtip" }
290
- ]);
291
- });
292
-
293
- test("default TTL is 10 minutes", () => {
294
- expect(DEFAULT_PREWARM_TTL_MS).toBe(600_000);
295
- });
296
- });