@tokenbuddy/tokenbuddy 1.0.13 → 1.0.15
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.
- package/dist/src/buyer-store.d.ts +23 -0
- package/dist/src/buyer-store.d.ts.map +1 -1
- package/dist/src/buyer-store.js +31 -6
- package/dist/src/buyer-store.js.map +1 -1
- package/dist/src/clawtip-bootstrap.d.ts +23 -0
- package/dist/src/clawtip-bootstrap.d.ts.map +1 -0
- package/dist/src/clawtip-bootstrap.js +47 -0
- package/dist/src/clawtip-bootstrap.js.map +1 -0
- package/dist/src/cli.d.ts +24 -33
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +157 -58
- package/dist/src/cli.js.map +1 -1
- package/dist/src/daemon.d.ts +79 -1
- package/dist/src/daemon.d.ts.map +1 -1
- package/dist/src/daemon.js +984 -23
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/model-index.d.ts +1 -1
- package/dist/src/model-index.d.ts.map +1 -1
- package/dist/src/model-index.js +4 -0
- package/dist/src/model-index.js.map +1 -1
- package/dist/src/prewarm-cache.d.ts +4 -0
- package/dist/src/prewarm-cache.d.ts.map +1 -1
- package/dist/src/prewarm-cache.js +2 -1
- package/dist/src/prewarm-cache.js.map +1 -1
- package/dist/src/prewarm-scheduler.d.ts +2 -0
- package/dist/src/prewarm-scheduler.d.ts.map +1 -1
- package/dist/src/prewarm-scheduler.js +4 -2
- package/dist/src/prewarm-scheduler.js.map +1 -1
- package/dist/src/route-failover.d.ts.map +1 -1
- package/dist/src/route-failover.js +10 -0
- package/dist/src/route-failover.js.map +1 -1
- package/dist/src/seller-catalog.d.ts +17 -0
- package/dist/src/seller-catalog.d.ts.map +1 -1
- package/dist/src/seller-catalog.js +15 -1
- package/dist/src/seller-catalog.js.map +1 -1
- package/dist/src/seller-pool.d.ts +12 -1
- package/dist/src/seller-pool.d.ts.map +1 -1
- package/dist/src/seller-pool.js +61 -7
- package/dist/src/seller-pool.js.map +1 -1
- package/dist/src/seller-route-planner.d.ts +11 -1
- package/dist/src/seller-route-planner.d.ts.map +1 -1
- package/dist/src/seller-route-planner.js +21 -9
- package/dist/src/seller-route-planner.js.map +1 -1
- package/dist/src/seller-routing-config.d.ts +2 -0
- package/dist/src/seller-routing-config.d.ts.map +1 -1
- package/dist/src/seller-routing-config.js +11 -1
- package/dist/src/seller-routing-config.js.map +1 -1
- package/package.json +1 -1
- package/src/buyer-store.ts +70 -7
- package/src/clawtip-bootstrap.ts +64 -0
- package/src/cli.ts +201 -76
- package/src/daemon.ts +1132 -25
- package/src/model-index.ts +4 -1
- package/src/prewarm-cache.ts +6 -1
- package/src/prewarm-scheduler.ts +6 -2
- package/src/route-failover.ts +11 -0
- package/src/seller-catalog.ts +24 -1
- package/src/seller-pool.ts +69 -7
- package/src/seller-route-planner.ts +33 -11
- package/src/seller-routing-config.ts +14 -1
- package/static/clawtip/recharge.png +0 -0
- package/tests/control-plane-ui-endpoints.test.ts +559 -0
- package/tests/daemon-classify.test.ts +9 -0
- package/tests/model-index.test.ts +14 -0
- package/tests/route-failover.test.ts +16 -0
- package/tests/seller-catalog-utilities.test.ts +54 -0
- package/tests/seller-pool.test.ts +56 -0
- package/tests/seller-route-planner.test.ts +40 -0
- package/tests/seller-routing-config.test.ts +13 -0
- package/tests/tokenbuddy.test.ts +200 -7
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
dedupeCatalogEntries,
|
|
3
|
+
discoverSellerBackedModels,
|
|
3
4
|
filterCatalogByProtocol,
|
|
4
5
|
filterCatalogBySeller,
|
|
5
6
|
manifestModelIds,
|
|
@@ -67,4 +68,57 @@ describe("seller catalog utilities", () => {
|
|
|
67
68
|
|
|
68
69
|
expect(manifestModelIds(manifest)).toEqual(["gpt-5.4", "claude"]);
|
|
69
70
|
});
|
|
71
|
+
|
|
72
|
+
test("discovers models from active buyer-visible registry sellers only", async () => {
|
|
73
|
+
const fetchMock = jest.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
|
|
74
|
+
const href = String(url);
|
|
75
|
+
if (href.endsWith("/registry/sellers")) {
|
|
76
|
+
return jsonResponse({
|
|
77
|
+
version: 8,
|
|
78
|
+
sellers: [
|
|
79
|
+
{
|
|
80
|
+
id: "active",
|
|
81
|
+
status: "active",
|
|
82
|
+
url: "https://active.example.com",
|
|
83
|
+
supportedProtocols: ["chat_completions"],
|
|
84
|
+
paymentMethods: ["clawtip"]
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: "pending",
|
|
88
|
+
status: "pending",
|
|
89
|
+
url: "https://pending.example.com",
|
|
90
|
+
supportedProtocols: ["chat_completions"],
|
|
91
|
+
paymentMethods: ["clawtip"]
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (href === "https://active.example.com/manifest") {
|
|
97
|
+
return jsonResponse({
|
|
98
|
+
sellerId: "active",
|
|
99
|
+
supportedProtocols: ["chat_completions"],
|
|
100
|
+
paymentMethods: ["clawtip"],
|
|
101
|
+
models: [{ id: "gpt-5.4" }]
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`unexpected fetch ${href}`);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const catalog = await discoverSellerBackedModels("https://bootstrap.example.com/registry/sellers");
|
|
109
|
+
|
|
110
|
+
expect(catalog.sellers.map((seller) => seller.id)).toEqual(["active"]);
|
|
111
|
+
expect(catalog.models.map((model) => model.sellerId)).toEqual(["active"]);
|
|
112
|
+
expect(fetchMock).not.toHaveBeenCalledWith("https://pending.example.com/manifest");
|
|
113
|
+
} finally {
|
|
114
|
+
fetchMock.mockRestore();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
70
117
|
});
|
|
118
|
+
|
|
119
|
+
function jsonResponse(body: unknown): Response {
|
|
120
|
+
return new Response(JSON.stringify(body), {
|
|
121
|
+
status: 200,
|
|
122
|
+
headers: { "Content-Type": "application/json" }
|
|
123
|
+
});
|
|
124
|
+
}
|
|
@@ -132,6 +132,62 @@ describe("SellerPool", () => {
|
|
|
132
132
|
expect(pool.snapshot()[0].circuit).toBe("open");
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
+
test("busy_capacity temporarily blocks selection without opening the circuit", () => {
|
|
136
|
+
const clock = makeClock();
|
|
137
|
+
const ctx = build([{ id: "s1", healthScore: 90 }, { id: "s2", healthScore: 50 }]);
|
|
138
|
+
const pool = new SellerPool({
|
|
139
|
+
modelIndex: ctx.index,
|
|
140
|
+
cache: ctx.cache,
|
|
141
|
+
creditTracker: ctx.credit,
|
|
142
|
+
failureThreshold: 1,
|
|
143
|
+
capacityBlockMs: 1000,
|
|
144
|
+
now: () => clock.now
|
|
145
|
+
});
|
|
146
|
+
pool.sync();
|
|
147
|
+
|
|
148
|
+
const blocked = pool.recordFailure("s1", "busy_capacity");
|
|
149
|
+
expect(blocked?.circuit).toBe("closed");
|
|
150
|
+
expect(blocked?.consecutiveFailures).toBe(0);
|
|
151
|
+
expect(blocked?.recentFailures).toEqual([]);
|
|
152
|
+
expect(blocked?.capacityBlockedUntil).toBe(clock.now + 1000);
|
|
153
|
+
|
|
154
|
+
let result = pool.pick({ modelId: "gpt-4o", protocol: "chat_completions", paymentMethod: "clawtip" });
|
|
155
|
+
expect(result.candidates.map((c) => c.entry.sellerId)).toEqual(["s2"]);
|
|
156
|
+
|
|
157
|
+
clock.advance(1001);
|
|
158
|
+
result = pool.pick({ modelId: "gpt-4o", protocol: "chat_completions", paymentMethod: "clawtip" });
|
|
159
|
+
expect(result.candidates.map((c) => c.entry.sellerId)).toEqual(["s1", "s2"]);
|
|
160
|
+
|
|
161
|
+
pool.recordFailure("s1", "busy_capacity");
|
|
162
|
+
pool.recordSuccess("s1", 250_000);
|
|
163
|
+
expect(pool.snapshot().find((entry) => entry.sellerId === "s1")?.capacityBlockedUntil).toBeUndefined();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("sync preserves registry fallback runtime state for sellers still in the registry", () => {
|
|
167
|
+
const clock = makeClock();
|
|
168
|
+
const index = new ModelIndex();
|
|
169
|
+
const sellers = [makeSeller({ id: "s1", models: ["gpt-4o"] })];
|
|
170
|
+
index.rebuild(sellers, { registryVersion: 1 });
|
|
171
|
+
const cache = new PrewarmCache();
|
|
172
|
+
const credit = new CreditTracker();
|
|
173
|
+
const pool = new SellerPool({
|
|
174
|
+
modelIndex: index,
|
|
175
|
+
cache,
|
|
176
|
+
creditTracker: credit,
|
|
177
|
+
capacityBlockMs: 1000,
|
|
178
|
+
now: () => clock.now
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
pool.ensureRegistrySellers(sellers);
|
|
182
|
+
pool.recordFailure("s1", "busy_capacity");
|
|
183
|
+
pool.sync();
|
|
184
|
+
|
|
185
|
+
expect(pool.snapshot()[0].capacityBlockedUntil).toBe(clock.now + 1000);
|
|
186
|
+
index.rebuild([], { registryVersion: 2 });
|
|
187
|
+
pool.sync();
|
|
188
|
+
expect(pool.snapshot()).toEqual([]);
|
|
189
|
+
});
|
|
190
|
+
|
|
135
191
|
test("recordSuccess closes the circuit and reports to the credit tracker", () => {
|
|
136
192
|
const clock = makeClock();
|
|
137
193
|
const ctx = build([{ id: "s1" }]);
|
|
@@ -5,6 +5,7 @@ function seller(overrides: Partial<RegistrySeller> & { id: string; models?: stri
|
|
|
5
5
|
return {
|
|
6
6
|
id: overrides.id,
|
|
7
7
|
name: overrides.name ?? overrides.id,
|
|
8
|
+
status: overrides.status,
|
|
8
9
|
url: overrides.url ?? `https://${overrides.id}.example.com`,
|
|
9
10
|
supportedProtocols: overrides.supportedProtocols ?? ["chat_completions"],
|
|
10
11
|
paymentMethods: overrides.paymentMethods ?? ["clawtip"],
|
|
@@ -80,6 +81,20 @@ describe("seller route planner", () => {
|
|
|
80
81
|
expect(result.reason).toBe("fullAuto:balanced:routes_1");
|
|
81
82
|
});
|
|
82
83
|
|
|
84
|
+
test("only active registry sellers are visible to buyer routing", () => {
|
|
85
|
+
const result = plan({
|
|
86
|
+
registrySellers: [
|
|
87
|
+
seller({ id: "legacy-no-status" }),
|
|
88
|
+
seller({ id: "active", status: "active" }),
|
|
89
|
+
seller({ id: "pending", status: "pending" }),
|
|
90
|
+
seller({ id: "draining", status: "draining" }),
|
|
91
|
+
seller({ id: "offline", status: "offline" })
|
|
92
|
+
]
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(result.routes.map((route) => route.seller.id)).toEqual(["legacy-no-status", "active"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
83
98
|
test("fixed mode fails closed when selected seller is outside compatibility", () => {
|
|
84
99
|
const result = plan({
|
|
85
100
|
routing: { mode: "fixed", sellerId: "s3", scorer: "speed" }
|
|
@@ -147,4 +162,29 @@ describe("seller route planner", () => {
|
|
|
147
162
|
|
|
148
163
|
expect(result.routes.map((route) => route.seller.id)).toEqual(["s2"]);
|
|
149
164
|
});
|
|
165
|
+
|
|
166
|
+
test("active capacity blocks are excluded before strategy selection", () => {
|
|
167
|
+
const now = 10_000;
|
|
168
|
+
const blocked = plan({
|
|
169
|
+
now,
|
|
170
|
+
routing: { mode: "fullAuto", scorer: "speed" },
|
|
171
|
+
sellerMetrics: [
|
|
172
|
+
{ sellerId: "s1", healthScore: 100, avgLatencyMs: 10, capacityBlockedUntil: now + 1000 },
|
|
173
|
+
{ sellerId: "s2", healthScore: 20, avgLatencyMs: 100 }
|
|
174
|
+
]
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(blocked.routes.map((route) => route.seller.id)).toEqual(["s2"]);
|
|
178
|
+
|
|
179
|
+
const expired = plan({
|
|
180
|
+
now: now + 1001,
|
|
181
|
+
routing: { mode: "fullAuto", scorer: "speed" },
|
|
182
|
+
sellerMetrics: [
|
|
183
|
+
{ sellerId: "s1", healthScore: 100, avgLatencyMs: 10, capacityBlockedUntil: now + 1000 },
|
|
184
|
+
{ sellerId: "s2", healthScore: 20, avgLatencyMs: 100 }
|
|
185
|
+
]
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(expired.routes.map((route) => route.seller.id)).toEqual(["s1", "s2"]);
|
|
189
|
+
});
|
|
150
190
|
});
|
|
@@ -19,10 +19,18 @@ describe("seller routing config", () => {
|
|
|
19
19
|
expect(normalizeSellerRoutingConfig({
|
|
20
20
|
mode: "fixed",
|
|
21
21
|
sellerId: " tbs-1 ",
|
|
22
|
+
fixedByModel: {
|
|
23
|
+
" gpt-4o ": " tbs-2 ",
|
|
24
|
+
empty: "",
|
|
25
|
+
ignored: 42
|
|
26
|
+
},
|
|
22
27
|
scorer: "speed"
|
|
23
28
|
})).toEqual({
|
|
24
29
|
mode: "fixed",
|
|
25
30
|
sellerId: "tbs-1",
|
|
31
|
+
fixedByModel: {
|
|
32
|
+
"gpt-4o": "tbs-2"
|
|
33
|
+
},
|
|
26
34
|
scorer: "speed"
|
|
27
35
|
});
|
|
28
36
|
|
|
@@ -102,6 +110,11 @@ describe("seller routing config", () => {
|
|
|
102
110
|
|
|
103
111
|
test("validates required fixed strategy parameters", () => {
|
|
104
112
|
expect(() => assertSellerRoutingConfig({ mode: "fixed", scorer: "balanced" })).toThrow("--seller");
|
|
113
|
+
expect(() => assertSellerRoutingConfig({
|
|
114
|
+
mode: "fixed",
|
|
115
|
+
scorer: "balanced",
|
|
116
|
+
fixedByModel: { "gpt-4o": "tbs-1" }
|
|
117
|
+
})).not.toThrow();
|
|
105
118
|
expect(() => assertSellerRoutingConfig({ mode: "fixedSet", sellerIds: [], scorer: "balanced" })).toThrow("--seller-set");
|
|
106
119
|
});
|
|
107
120
|
|
package/tests/tokenbuddy.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
buildCli,
|
|
7
7
|
fetchClawtipBootstrap,
|
|
8
8
|
normalizeClawtipBootstrapResourceUrl,
|
|
9
|
+
restartLaunchAgent,
|
|
9
10
|
} from "../src/cli.js";
|
|
10
11
|
import {
|
|
11
12
|
checkOpenClawRuntime,
|
|
@@ -71,7 +72,15 @@ describe("TokenBuddy CLI command surface", () => {
|
|
|
71
72
|
.filter(command => command !== "help")
|
|
72
73
|
.sort();
|
|
73
74
|
|
|
74
|
-
expect(commandNames).toEqual(["doctor", "init", "models", "payment", "routing"]);
|
|
75
|
+
expect(commandNames).toEqual(["daemon", "doctor", "init", "models", "payment", "routing", "ui"]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("tb daemon help exposes restart", () => {
|
|
79
|
+
const program = buildCli();
|
|
80
|
+
const daemon = program.commands.find(command => command.name() === "daemon");
|
|
81
|
+
|
|
82
|
+
expect(daemon).toBeDefined();
|
|
83
|
+
expect(daemon!.commands.map(command => command.name()).sort()).toEqual(["restart"]);
|
|
75
84
|
});
|
|
76
85
|
|
|
77
86
|
test("tb payment help only exposes list, add, and remove", () => {
|
|
@@ -151,6 +160,64 @@ describe("TokenBuddy CLI command surface", () => {
|
|
|
151
160
|
expect(plist).not.toContain("payCredential");
|
|
152
161
|
expect(plist).not.toContain("PAYMENT_PROOF");
|
|
153
162
|
});
|
|
163
|
+
|
|
164
|
+
test("restartLaunchAgent kickstarts the installed LaunchAgent and waits for readiness", async () => {
|
|
165
|
+
const launchctlCalls: string[][] = [];
|
|
166
|
+
const result = await restartLaunchAgent(17820, {
|
|
167
|
+
platform: "darwin",
|
|
168
|
+
plistPath: "/Users/example/Library/LaunchAgents/com.tokenbuddy.proxyd.plist",
|
|
169
|
+
existsSync: () => true,
|
|
170
|
+
runLaunchctl: (args) => {
|
|
171
|
+
launchctlCalls.push(args);
|
|
172
|
+
},
|
|
173
|
+
probeDaemonStatus: async () => ({
|
|
174
|
+
running: true,
|
|
175
|
+
status: { pid: 100, controlPort: 17820, proxyPort: 17821 }
|
|
176
|
+
}),
|
|
177
|
+
waitForDaemonStatus: async () => ({
|
|
178
|
+
running: true,
|
|
179
|
+
status: { pid: 200, controlPort: 17820, proxyPort: 17821 }
|
|
180
|
+
})
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(result).toMatchObject({
|
|
184
|
+
attempted: true,
|
|
185
|
+
restarted: true,
|
|
186
|
+
method: "launchd",
|
|
187
|
+
plistPath: "/Users/example/Library/LaunchAgents/com.tokenbuddy.proxyd.plist",
|
|
188
|
+
after: {
|
|
189
|
+
running: true,
|
|
190
|
+
status: { pid: 200 }
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
expect(launchctlCalls).toEqual([[
|
|
194
|
+
"kickstart",
|
|
195
|
+
"-k",
|
|
196
|
+
expect.stringMatching(/^gui\/\d+\/com\.tokenbuddy\.proxyd$/)
|
|
197
|
+
]]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("restartLaunchAgent reports missing LaunchAgent plist without calling launchctl", async () => {
|
|
201
|
+
const launchctlCalls: string[][] = [];
|
|
202
|
+
const result = await restartLaunchAgent(17820, {
|
|
203
|
+
platform: "darwin",
|
|
204
|
+
plistPath: "/Users/example/Library/LaunchAgents/com.tokenbuddy.proxyd.plist",
|
|
205
|
+
existsSync: () => false,
|
|
206
|
+
runLaunchctl: (args) => {
|
|
207
|
+
launchctlCalls.push(args);
|
|
208
|
+
},
|
|
209
|
+
probeDaemonStatus: async () => ({ running: false, error: "offline" }),
|
|
210
|
+
waitForDaemonStatus: async () => ({ running: false, error: "not called" })
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(result).toMatchObject({
|
|
214
|
+
attempted: false,
|
|
215
|
+
restarted: false,
|
|
216
|
+
method: "launchd",
|
|
217
|
+
error: expect.stringContaining("tb init")
|
|
218
|
+
});
|
|
219
|
+
expect(launchctlCalls).toEqual([]);
|
|
220
|
+
});
|
|
154
221
|
});
|
|
155
222
|
|
|
156
223
|
describe("BuyerStore safe SQLite persistence", () => {
|
|
@@ -2518,7 +2585,10 @@ describe("TokenBuddy seller routing strategies", () => {
|
|
|
2518
2585
|
const events: Array<{ seller: string; url?: string }> = [];
|
|
2519
2586
|
let primaryPurchaseSucceeds = false;
|
|
2520
2587
|
let primaryInferenceFails = false;
|
|
2588
|
+
let primaryInferenceBusy = false;
|
|
2521
2589
|
const dbPath = path.resolve(__dirname, "../../data-test/manual-routing-test.db");
|
|
2590
|
+
const routeEvents = (): Array<{ seller: string; url?: string }> => events
|
|
2591
|
+
.filter((event) => event.url !== "/primary/health" && event.url !== "/backup/health");
|
|
2522
2592
|
|
|
2523
2593
|
const readJsonBody = (req: http.IncomingMessage): Promise<any> => new Promise((resolve) => {
|
|
2524
2594
|
let body = "";
|
|
@@ -2615,6 +2685,11 @@ describe("TokenBuddy seller routing strategies", () => {
|
|
|
2615
2685
|
|
|
2616
2686
|
if (req.url === "/primary/v1/chat/completions") {
|
|
2617
2687
|
events.push({ seller: "primary-seller", url: req.url });
|
|
2688
|
+
if (primaryInferenceBusy) {
|
|
2689
|
+
res.statusCode = 429;
|
|
2690
|
+
res.end(JSON.stringify({ error: { code: "busy_capacity", message: "primary seller capacity is full" } }));
|
|
2691
|
+
return;
|
|
2692
|
+
}
|
|
2618
2693
|
if (primaryInferenceFails) {
|
|
2619
2694
|
res.statusCode = 500;
|
|
2620
2695
|
res.end(JSON.stringify({ error: { code: "upstream_failed", message: "primary seller failed" } }));
|
|
@@ -2686,6 +2761,7 @@ describe("TokenBuddy seller routing strategies", () => {
|
|
|
2686
2761
|
events.length = 0;
|
|
2687
2762
|
primaryPurchaseSucceeds = false;
|
|
2688
2763
|
primaryInferenceFails = false;
|
|
2764
|
+
primaryInferenceBusy = false;
|
|
2689
2765
|
rmSqliteFiles(dbPath);
|
|
2690
2766
|
const store = new BuyerStore({ dbPath });
|
|
2691
2767
|
store.savePayment({
|
|
@@ -2738,7 +2814,7 @@ describe("TokenBuddy seller routing strategies", () => {
|
|
|
2738
2814
|
// v1.2: the buyer no longer fetches the seller manifest per request.
|
|
2739
2815
|
// The registry's `models` field is the source of truth. Auto-purchase
|
|
2740
2816
|
// is still attempted once before failing over.
|
|
2741
|
-
expect(
|
|
2817
|
+
expect(routeEvents()).toEqual([
|
|
2742
2818
|
{ seller: "primary-seller", url: "/primary/purchase/create" }
|
|
2743
2819
|
]);
|
|
2744
2820
|
|
|
@@ -2784,7 +2860,7 @@ describe("TokenBuddy seller routing strategies", () => {
|
|
|
2784
2860
|
// v1.2: the buyer no longer fetches the seller manifest per request.
|
|
2785
2861
|
// The backup-seller is selected via the fixed seller routing config; the manifest
|
|
2786
2862
|
// is sourced from the registry's `models` field.
|
|
2787
|
-
expect(
|
|
2863
|
+
expect(routeEvents()).toEqual([
|
|
2788
2864
|
{ seller: "backup-seller", url: "/backup/purchase/create" },
|
|
2789
2865
|
{ seller: "backup-seller", url: "/backup/purchase/complete" },
|
|
2790
2866
|
{ seller: "backup-seller", url: "/backup/v1/chat/completions" }
|
|
@@ -2826,11 +2902,72 @@ describe("TokenBuddy seller routing strategies", () => {
|
|
|
2826
2902
|
});
|
|
2827
2903
|
|
|
2828
2904
|
expect(response.ok).toBe(true);
|
|
2829
|
-
expect(
|
|
2905
|
+
expect(routeEvents()).toEqual([
|
|
2906
|
+
{ seller: "backup-seller", url: "/backup/purchase/create" },
|
|
2907
|
+
{ seller: "backup-seller", url: "/backup/purchase/complete" },
|
|
2908
|
+
{ seller: "backup-seller", url: "/backup/v1/chat/completions" }
|
|
2909
|
+
]);
|
|
2910
|
+
});
|
|
2911
|
+
|
|
2912
|
+
test("daemon applies tb routing set fullAuto without restart", async () => {
|
|
2913
|
+
daemon.stop();
|
|
2914
|
+
events.length = 0;
|
|
2915
|
+
const store = new BuyerStore({ dbPath });
|
|
2916
|
+
store.saveDaemonRuntimeConfig("routing", {
|
|
2917
|
+
mode: "fixed",
|
|
2918
|
+
sellerId: "primary-seller",
|
|
2919
|
+
scorer: "discount"
|
|
2920
|
+
});
|
|
2921
|
+
store.close();
|
|
2922
|
+
|
|
2923
|
+
daemon = new TokenbuddyDaemon({
|
|
2924
|
+
controlPort: 0,
|
|
2925
|
+
proxyPort: 0,
|
|
2926
|
+
dbPath,
|
|
2927
|
+
sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`
|
|
2928
|
+
});
|
|
2929
|
+
daemon.start();
|
|
2930
|
+
daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
|
|
2931
|
+
daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
|
|
2932
|
+
|
|
2933
|
+
const initialStatus = await (await fetch(`http://127.0.0.1:${daemonControlPort}/status`)).json() as any;
|
|
2934
|
+
expect(initialStatus.sellerRoutingMode).toBe("fixed");
|
|
2935
|
+
expect(initialStatus.sellerRoutingScorer).toBe("discount");
|
|
2936
|
+
expect(initialStatus.selectedSellerId).toBe("primary-seller");
|
|
2937
|
+
|
|
2938
|
+
const refreshedStore = new BuyerStore({ dbPath });
|
|
2939
|
+
refreshedStore.saveDaemonRuntimeConfig("routing", {
|
|
2940
|
+
mode: "fullAuto",
|
|
2941
|
+
scorer: "balanced"
|
|
2942
|
+
});
|
|
2943
|
+
refreshedStore.close();
|
|
2944
|
+
|
|
2945
|
+
const reloadedStatus = await (await fetch(`http://127.0.0.1:${daemonControlPort}/status`)).json() as any;
|
|
2946
|
+
expect(reloadedStatus.sellerRoutingMode).toBe("fullAuto");
|
|
2947
|
+
expect(reloadedStatus.sellerRoutingScorer).toBe("balanced");
|
|
2948
|
+
expect(reloadedStatus.selectedSellerId).toBeUndefined();
|
|
2949
|
+
const prewarmBeforeRequest = await (await fetch(`http://127.0.0.1:${daemonControlPort}/v1.2/prewarm`)).json() as any;
|
|
2950
|
+
const scheduledBeforeRequest = prewarmBeforeRequest.scheduler.totalScheduled;
|
|
2951
|
+
|
|
2952
|
+
const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
|
|
2953
|
+
method: "POST",
|
|
2954
|
+
headers: { "Content-Type": "application/json" },
|
|
2955
|
+
body: JSON.stringify({
|
|
2956
|
+
model: "gpt-manual",
|
|
2957
|
+
messages: [{ role: "user", content: "fullAuto should reload without restart" }]
|
|
2958
|
+
})
|
|
2959
|
+
});
|
|
2960
|
+
|
|
2961
|
+
expect(response.ok).toBe(true);
|
|
2962
|
+
expect((await response.json() as any).id).toBe("backup-chat");
|
|
2963
|
+
expect(routeEvents()).toEqual([
|
|
2964
|
+
{ seller: "primary-seller", url: "/primary/purchase/create" },
|
|
2830
2965
|
{ seller: "backup-seller", url: "/backup/purchase/create" },
|
|
2831
2966
|
{ seller: "backup-seller", url: "/backup/purchase/complete" },
|
|
2832
2967
|
{ seller: "backup-seller", url: "/backup/v1/chat/completions" }
|
|
2833
2968
|
]);
|
|
2969
|
+
const prewarmAfterRequest = await (await fetch(`http://127.0.0.1:${daemonControlPort}/v1.2/prewarm`)).json() as any;
|
|
2970
|
+
expect(prewarmAfterRequest.scheduler.totalScheduled).toBeGreaterThan(scheduledBeforeRequest);
|
|
2834
2971
|
});
|
|
2835
2972
|
|
|
2836
2973
|
test("fixedSet routing only uses sellers in the configured pool", async () => {
|
|
@@ -2866,7 +3003,7 @@ describe("TokenBuddy seller routing strategies", () => {
|
|
|
2866
3003
|
});
|
|
2867
3004
|
|
|
2868
3005
|
expect(response.ok).toBe(true);
|
|
2869
|
-
expect(
|
|
3006
|
+
expect(routeEvents()).toEqual([
|
|
2870
3007
|
{ seller: "backup-seller", url: "/backup/purchase/create" },
|
|
2871
3008
|
{ seller: "backup-seller", url: "/backup/purchase/complete" },
|
|
2872
3009
|
{ seller: "backup-seller", url: "/backup/v1/chat/completions" }
|
|
@@ -2911,7 +3048,7 @@ describe("TokenBuddy seller routing strategies", () => {
|
|
|
2911
3048
|
|
|
2912
3049
|
expect(response.ok).toBe(true);
|
|
2913
3050
|
expect((await response.json() as any).id).toBe("backup-chat");
|
|
2914
|
-
expect(
|
|
3051
|
+
expect(routeEvents()).toEqual([
|
|
2915
3052
|
{ seller: "primary-seller", url: "/primary/purchase/create" },
|
|
2916
3053
|
{ seller: "primary-seller", url: "/primary/purchase/complete" },
|
|
2917
3054
|
{ seller: "primary-seller", url: "/primary/v1/chat/completions" },
|
|
@@ -2939,6 +3076,62 @@ describe("TokenBuddy seller routing strategies", () => {
|
|
|
2939
3076
|
expect(logs).not.toContain(rawPrompt);
|
|
2940
3077
|
});
|
|
2941
3078
|
|
|
3079
|
+
test("fullAuto routing treats busy_capacity as a capacity block and starts the next request on backup", async () => {
|
|
3080
|
+
daemon.stop();
|
|
3081
|
+
events.length = 0;
|
|
3082
|
+
primaryPurchaseSucceeds = true;
|
|
3083
|
+
primaryInferenceBusy = true;
|
|
3084
|
+
daemon = new TokenbuddyDaemon({
|
|
3085
|
+
controlPort: 0,
|
|
3086
|
+
proxyPort: 0,
|
|
3087
|
+
dbPath,
|
|
3088
|
+
sellerRegistryUrl: `http://127.0.0.1:${sellerPort}/registry/sellers`,
|
|
3089
|
+
sellerRouting: {
|
|
3090
|
+
mode: "fullAuto",
|
|
3091
|
+
scorer: "balanced"
|
|
3092
|
+
}
|
|
3093
|
+
});
|
|
3094
|
+
daemon.start();
|
|
3095
|
+
daemonControlPort = ((daemon as any).controlServer.address() as AddressInfo).port;
|
|
3096
|
+
daemonProxyPort = ((daemon as any).proxyServer.address() as AddressInfo).port;
|
|
3097
|
+
|
|
3098
|
+
const first = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
|
|
3099
|
+
method: "POST",
|
|
3100
|
+
headers: { "Content-Type": "application/json" },
|
|
3101
|
+
body: JSON.stringify({
|
|
3102
|
+
model: "gpt-manual",
|
|
3103
|
+
messages: [{ role: "user", content: "primary is at capacity" }]
|
|
3104
|
+
})
|
|
3105
|
+
});
|
|
3106
|
+
|
|
3107
|
+
expect(first.ok).toBe(true);
|
|
3108
|
+
expect((await first.json() as any).id).toBe("backup-chat");
|
|
3109
|
+
expect(routeEvents()).toEqual([
|
|
3110
|
+
{ seller: "primary-seller", url: "/primary/purchase/create" },
|
|
3111
|
+
{ seller: "primary-seller", url: "/primary/purchase/complete" },
|
|
3112
|
+
{ seller: "primary-seller", url: "/primary/v1/chat/completions" },
|
|
3113
|
+
{ seller: "backup-seller", url: "/backup/purchase/create" },
|
|
3114
|
+
{ seller: "backup-seller", url: "/backup/purchase/complete" },
|
|
3115
|
+
{ seller: "backup-seller", url: "/backup/v1/chat/completions" }
|
|
3116
|
+
]);
|
|
3117
|
+
|
|
3118
|
+
events.length = 0;
|
|
3119
|
+
const second = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
|
|
3120
|
+
method: "POST",
|
|
3121
|
+
headers: { "Content-Type": "application/json" },
|
|
3122
|
+
body: JSON.stringify({
|
|
3123
|
+
model: "gpt-manual",
|
|
3124
|
+
messages: [{ role: "user", content: "capacity block should still be active" }]
|
|
3125
|
+
})
|
|
3126
|
+
});
|
|
3127
|
+
|
|
3128
|
+
expect(second.ok).toBe(true);
|
|
3129
|
+
expect((await second.json() as any).id).toBe("backup-chat");
|
|
3130
|
+
expect(routeEvents()).toEqual([
|
|
3131
|
+
{ seller: "backup-seller", url: "/backup/v1/chat/completions" }
|
|
3132
|
+
]);
|
|
3133
|
+
});
|
|
3134
|
+
|
|
2942
3135
|
test("fullAuto routing logs purchase failure failover before trying the backup seller", async () => {
|
|
2943
3136
|
daemon.stop();
|
|
2944
3137
|
events.length = 0;
|
|
@@ -2970,7 +3163,7 @@ describe("TokenBuddy seller routing strategies", () => {
|
|
|
2970
3163
|
|
|
2971
3164
|
expect(response.ok).toBe(true);
|
|
2972
3165
|
expect((await response.json() as any).id).toBe("backup-chat");
|
|
2973
|
-
expect(
|
|
3166
|
+
expect(routeEvents()).toEqual([
|
|
2974
3167
|
{ seller: "primary-seller", url: "/primary/purchase/create" },
|
|
2975
3168
|
{ seller: "backup-seller", url: "/backup/purchase/create" },
|
|
2976
3169
|
{ seller: "backup-seller", url: "/backup/purchase/complete" },
|