@tokenbuddy/tokenbuddy 1.0.9 → 1.0.11
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 +13 -0
- package/dist/src/buyer-store.d.ts.map +1 -1
- package/dist/src/buyer-store.js +21 -2
- package/dist/src/buyer-store.js.map +1 -1
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +54 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/credit-tracker.d.ts +118 -0
- package/dist/src/credit-tracker.d.ts.map +1 -0
- package/dist/src/credit-tracker.js +220 -0
- package/dist/src/credit-tracker.js.map +1 -0
- package/dist/src/daemon.d.ts +49 -4
- package/dist/src/daemon.d.ts.map +1 -1
- package/dist/src/daemon.js +541 -405
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/model-index.d.ts +86 -0
- package/dist/src/model-index.d.ts.map +1 -0
- package/dist/src/model-index.js +214 -0
- package/dist/src/model-index.js.map +1 -0
- package/dist/src/prewarm-cache.d.ts +149 -0
- package/dist/src/prewarm-cache.d.ts.map +1 -0
- package/dist/src/prewarm-cache.js +288 -0
- package/dist/src/prewarm-cache.js.map +1 -0
- package/dist/src/prewarm-scheduler.d.ts +150 -0
- package/dist/src/prewarm-scheduler.d.ts.map +1 -0
- package/dist/src/prewarm-scheduler.js +484 -0
- package/dist/src/prewarm-scheduler.js.map +1 -0
- package/dist/src/provider-install.d.ts.map +1 -1
- package/dist/src/provider-install.js +9 -1
- package/dist/src/provider-install.js.map +1 -1
- package/dist/src/route-failover.d.ts +96 -0
- package/dist/src/route-failover.d.ts.map +1 -0
- package/dist/src/route-failover.js +177 -0
- package/dist/src/route-failover.js.map +1 -0
- package/dist/src/seller-catalog.d.ts +26 -0
- package/dist/src/seller-catalog.d.ts.map +1 -1
- package/dist/src/seller-catalog.js +40 -0
- package/dist/src/seller-catalog.js.map +1 -1
- package/dist/src/seller-pool.d.ts +127 -0
- package/dist/src/seller-pool.d.ts.map +1 -0
- package/dist/src/seller-pool.js +243 -0
- package/dist/src/seller-pool.js.map +1 -0
- package/dist/src/stream-failover.d.ts +78 -0
- package/dist/src/stream-failover.d.ts.map +1 -0
- package/dist/src/stream-failover.js +93 -0
- package/dist/src/stream-failover.js.map +1 -0
- package/package.json +1 -1
- package/src/buyer-store.ts +32 -2
- package/src/cli.ts +61 -0
- package/src/credit-tracker.test.ts +165 -0
- package/src/credit-tracker.ts +269 -0
- package/src/daemon.ts +569 -445
- package/src/model-index.test.ts +184 -0
- package/src/model-index.ts +266 -0
- package/src/prewarm-cache.test.ts +281 -0
- package/src/prewarm-cache.ts +373 -0
- package/src/prewarm-scheduler.test.ts +367 -0
- package/src/prewarm-scheduler.ts +581 -0
- package/src/provider-install.ts +9 -1
- package/src/route-failover.test.ts +193 -0
- package/src/route-failover.ts +233 -0
- package/src/seller-catalog-413.test.ts +61 -0
- package/src/seller-catalog.ts +47 -0
- package/src/seller-pool.test.ts +231 -0
- package/src/seller-pool.ts +333 -0
- package/src/stream-failover.test.ts +52 -0
- package/src/stream-failover.ts +129 -0
- package/src/thousand-seller.test.ts +151 -0
- package/tests/daemon-413-fallback.test.ts +92 -0
- package/tests/e2e.test.ts +3 -2
- package/tests/tokenbuddy.test.ts +68 -11
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createModuleLogger } from "@tokenbuddy/logging";
|
|
2
|
+
|
|
3
|
+
const logger = createModuleLogger("tb-proxyd:stream-failover");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* v1.2 §6 / §18.10: stream-failover policy. The buyer honors the
|
|
7
|
+
* "abort + client retry" contract: once the first SSE byte has been
|
|
8
|
+
* written to the client, an upstream stream failure is surfaced as an
|
|
9
|
+
* abrupt close plus a `X-TokenBuddy-Retry-Hint: 1` trailer. The client
|
|
10
|
+
* (OpenAI / Anthropic SDK or any consumer honoring the OpenAI retry
|
|
11
|
+
* contract) re-issues the request and the buyer serves it from a
|
|
12
|
+
* healthy seller.
|
|
13
|
+
*
|
|
14
|
+
* The decisions in this module are intentionally one-way: the buyer
|
|
15
|
+
* never tries to splice two streams together (option B in the design
|
|
16
|
+
* doc) because that would double-charge and would require non-trivial
|
|
17
|
+
* idempotency re-design. v1.2 = abort + retry; v2 may revisit.
|
|
18
|
+
*/
|
|
19
|
+
export interface StreamFailoverOptions {
|
|
20
|
+
retryHintHeader?: string;
|
|
21
|
+
now?: () => number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface StreamFailoverDecision {
|
|
25
|
+
action: "abort_with_retry_hint" | "let_stream_complete";
|
|
26
|
+
reason: string;
|
|
27
|
+
retryHintValue: string;
|
|
28
|
+
firstChunkCommitted: boolean;
|
|
29
|
+
bytesFlushed: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class StreamFailover {
|
|
33
|
+
private readonly retryHintHeader: string;
|
|
34
|
+
private readonly now: () => number;
|
|
35
|
+
private firstChunkCommitted = false;
|
|
36
|
+
private bytesFlushed = 0;
|
|
37
|
+
|
|
38
|
+
constructor(options: StreamFailoverOptions = {}) {
|
|
39
|
+
this.retryHintHeader = options.retryHintHeader ?? "X-TokenBuddy-Retry-Hint";
|
|
40
|
+
this.now = options.now ?? Date.now;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Record that the buyer's response stream has written its first chunk
|
|
45
|
+
* to the client. From this point on, the route-failover controller
|
|
46
|
+
* cannot switch sellers without the client's knowledge; failures
|
|
47
|
+
* must abort the stream and rely on the client to retry.
|
|
48
|
+
*/
|
|
49
|
+
markFirstChunkCommitted(): void {
|
|
50
|
+
if (this.firstChunkCommitted) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
this.firstChunkCommitted = true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Track total bytes written to the client. Used by `tb doctor` and
|
|
58
|
+
* the inference ledger to attribute partial-stream usage.
|
|
59
|
+
*/
|
|
60
|
+
recordBytesWritten(bytes: number): void {
|
|
61
|
+
this.bytesFlushed += bytes;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Decide what to do when the upstream stream breaks. If the first
|
|
66
|
+
* chunk has already been written, the only option is to abort and
|
|
67
|
+
* surface the retry hint. Otherwise the controller is free to fail
|
|
68
|
+
* over to the next seller.
|
|
69
|
+
*/
|
|
70
|
+
decideOnStreamAbort(reason: string): StreamFailoverDecision {
|
|
71
|
+
if (!this.firstChunkCommitted) {
|
|
72
|
+
return {
|
|
73
|
+
action: "let_stream_complete",
|
|
74
|
+
reason: "no_chunks_yet_committed",
|
|
75
|
+
retryHintValue: "0",
|
|
76
|
+
firstChunkCommitted: false,
|
|
77
|
+
bytesFlushed: 0
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
logger.warn("stream.failover.aborted", "upstream stream broke after first chunk; aborting client with retry hint", {
|
|
81
|
+
reason,
|
|
82
|
+
bytesFlushed: this.bytesFlushed
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
action: "abort_with_retry_hint",
|
|
86
|
+
reason,
|
|
87
|
+
retryHintValue: "1",
|
|
88
|
+
firstChunkCommitted: true,
|
|
89
|
+
bytesFlushed: this.bytesFlushed
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Read-only snapshot of the current stream state. The route-failover
|
|
95
|
+
* controller calls this to decide whether the next chunk is the first
|
|
96
|
+
* one (failover still possible) or a follow-up (abort required).
|
|
97
|
+
*/
|
|
98
|
+
snapshot(): { firstChunkCommitted: boolean; bytesFlushed: number } {
|
|
99
|
+
return {
|
|
100
|
+
firstChunkCommitted: this.firstChunkCommitted,
|
|
101
|
+
bytesFlushed: this.bytesFlushed
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Reset the failover state when a brand-new request starts. The
|
|
107
|
+
* `forwardProxyRequest` controller calls this before each new
|
|
108
|
+
* inference request.
|
|
109
|
+
*/
|
|
110
|
+
reset(): void {
|
|
111
|
+
this.firstChunkCommitted = false;
|
|
112
|
+
this.bytesFlushed = 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The HTTP header to set on the abort response so the client knows
|
|
117
|
+
* it should retry. Exposed so the controller and the test fixtures
|
|
118
|
+
* can refer to the same constant.
|
|
119
|
+
*/
|
|
120
|
+
get headerName(): string {
|
|
121
|
+
return this.retryHintHeader;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Constant for the "retry hint value" used on stream-abort responses.
|
|
127
|
+
* Exposed so callers can refer to the same value in tests.
|
|
128
|
+
*/
|
|
129
|
+
export const STREAM_FAILOVER_RETRY_HINT = "1";
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { ModelIndex } from "../src/model-index.js";
|
|
2
|
+
import { PrewarmCache, prewarmKey } from "../src/prewarm-cache.js";
|
|
3
|
+
import { CreditTracker } from "../src/credit-tracker.js";
|
|
4
|
+
import { SellerPool } from "../src/seller-pool.js";
|
|
5
|
+
import { RouteFailover } from "../src/route-failover.js";
|
|
6
|
+
import { PrewarmScheduler, type ProbeResult, type SellerProber } from "../src/prewarm-scheduler.js";
|
|
7
|
+
import type { RegistrySeller } from "../src/seller-catalog.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* v1.2 §18.15: "thousand-seller" integration smoke. Validates the
|
|
11
|
+
* end-to-end pipeline at a scale that simulates a real public registry:
|
|
12
|
+
* - the model-index build stays cheap (sub-100ms for 1k sellers)
|
|
13
|
+
* - the prewarm scheduler respects its per-minute and per-seller caps
|
|
14
|
+
* - the route-failover controller still returns a clean decision when
|
|
15
|
+
* a single seller among a thousand fails
|
|
16
|
+
*
|
|
17
|
+
* The test does not exercise live HTTP traffic; it uses stub probers and
|
|
18
|
+
* pre-populated registries so it can run as a fast unit test on every
|
|
19
|
+
* change.
|
|
20
|
+
*/
|
|
21
|
+
describe("v1.2 thousand-seller integration smoke", () => {
|
|
22
|
+
function buildLargeRegistry(size: number, focusModel: string): RegistrySeller[] {
|
|
23
|
+
const sellers: RegistrySeller[] = [];
|
|
24
|
+
for (let i = 0; i < size; i += 1) {
|
|
25
|
+
sellers.push({
|
|
26
|
+
id: `seller-${i.toString().padStart(4, "0")}`,
|
|
27
|
+
name: `Seller ${i}`,
|
|
28
|
+
url: `https://seller-${i}.example.com`,
|
|
29
|
+
supportedProtocols: ["chat_completions"],
|
|
30
|
+
paymentMethods: ["clawtip"],
|
|
31
|
+
// ~1/3 of the sellers serve BOTH models, 2/3 serve only
|
|
32
|
+
// `focusModel`. This simulates a realistic registry mix.
|
|
33
|
+
models: i % 3 === 0 ? [focusModel, "secondary-model"] : [focusModel]
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return sellers;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("model-index builds in well under a second for 1000 sellers", () => {
|
|
40
|
+
const index = new ModelIndex();
|
|
41
|
+
const sellers = buildLargeRegistry(1000, "gpt-4o");
|
|
42
|
+
const started = Date.now();
|
|
43
|
+
index.rebuild(sellers, { registryVersion: 1, defaultSellerId: "seller-0000" });
|
|
44
|
+
const elapsed = Date.now() - started;
|
|
45
|
+
expect(elapsed).toBeLessThan(500);
|
|
46
|
+
expect(index.stats().sellerCount).toBe(1000);
|
|
47
|
+
expect(index.stats().modelCount).toBe(2);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("picking the focus model returns the configured candidate set", () => {
|
|
51
|
+
const index = new ModelIndex();
|
|
52
|
+
index.rebuild(buildLargeRegistry(1000, "gpt-4o"), { registryVersion: 1 });
|
|
53
|
+
const candidates = index.sellersFor("gpt-4o", { protocol: "chat_completions", paymentMethod: "clawtip" });
|
|
54
|
+
// Every seller in the registry serves `gpt-4o` (either alone or
|
|
55
|
+
// alongside `secondary-model`), so all 1000 are eligible.
|
|
56
|
+
expect(candidates.length).toBe(1000);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("prewarm scheduler enforces the global per-minute cap across many tasks", async () => {
|
|
60
|
+
const index = new ModelIndex();
|
|
61
|
+
const sellers = buildLargeRegistry(50, "gpt-4o");
|
|
62
|
+
index.rebuild(sellers, { registryVersion: 1 });
|
|
63
|
+
const cache = new PrewarmCache();
|
|
64
|
+
const credit = new CreditTracker();
|
|
65
|
+
|
|
66
|
+
// Prober resolves immediately. The scheduler should still cap the
|
|
67
|
+
// number of actual probe calls per minute.
|
|
68
|
+
const prober: SellerProber = async (): Promise<ProbeResult> => ({ ok: true, latencyMs: 1, httpStatus: 200 });
|
|
69
|
+
const scheduler = new PrewarmScheduler({
|
|
70
|
+
modelIndex: index,
|
|
71
|
+
cache,
|
|
72
|
+
prober,
|
|
73
|
+
// Lower the caps so the test runs in a few ms.
|
|
74
|
+
maxPrewarmPerMinute: 5,
|
|
75
|
+
concurrency: 1,
|
|
76
|
+
sleep: () => new Promise(() => undefined)
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Enqueue three independent (model, protocol, payment) tasks; only
|
|
80
|
+
// `gpt-4o` and `gpt-4o` slots exist so the third (and beyond) will
|
|
81
|
+
// be rate-limited after 2 actual probe invocations.
|
|
82
|
+
const tasks = await Promise.all([
|
|
83
|
+
scheduler.schedulePrewarm({ modelId: "gpt-4o", reason: "lazy" }),
|
|
84
|
+
scheduler.schedulePrewarm({ modelId: "gpt-4o", reason: "lazy" }),
|
|
85
|
+
scheduler.schedulePrewarm({ modelId: "gpt-4o", reason: "lazy" })
|
|
86
|
+
]);
|
|
87
|
+
const succeeded = tasks.filter((t) => t.status === "succeeded").length;
|
|
88
|
+
const rateLimited = tasks.filter((t) => t.status === "rate_limited").length;
|
|
89
|
+
expect(succeeded).toBeGreaterThan(0);
|
|
90
|
+
expect(rateLimited + succeeded).toBe(3);
|
|
91
|
+
expect(scheduler.stats().totalRateLimited).toBe(rateLimited);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("seller-pool + route-failover pipeline still produces a clean decision under a thousand sellers", () => {
|
|
95
|
+
const index = new ModelIndex();
|
|
96
|
+
index.rebuild(buildLargeRegistry(1000, "gpt-4o"), { registryVersion: 1 });
|
|
97
|
+
const cache = new PrewarmCache();
|
|
98
|
+
const credit = new CreditTracker();
|
|
99
|
+
const pool = new SellerPool({ modelIndex: index, cache, creditTracker: credit });
|
|
100
|
+
pool.sync();
|
|
101
|
+
const failover = new RouteFailover({ pool, creditTracker: credit });
|
|
102
|
+
// 1k sellers all serve gpt-4o. Pick the top-4 by health (all
|
|
103
|
+
// default to 80 healthScore from the stub commit) and verify
|
|
104
|
+
// that a hard 4xx on the first one fails over to the next three
|
|
105
|
+
// without ever exhausting the pool.
|
|
106
|
+
const eligible = index.sellersFor("gpt-4o", { protocol: "chat_completions", paymentMethod: "clawtip" });
|
|
107
|
+
const subset = eligible.slice(0, 100);
|
|
108
|
+
cache.commitWarm({
|
|
109
|
+
modelId: "gpt-4o",
|
|
110
|
+
protocol: "chat_completions",
|
|
111
|
+
paymentMethod: "clawtip",
|
|
112
|
+
candidates: subset.map((seller) => ({ sellerId: seller.id, url: seller.url, healthScore: 80 }))
|
|
113
|
+
});
|
|
114
|
+
pool.sync();
|
|
115
|
+
// pool size matches the deduped seller count in the cache (each
|
|
116
|
+
// seller appears exactly once even if listed by multiple registry
|
|
117
|
+
// entries).
|
|
118
|
+
expect(pool.size()).toBe(subset.length);
|
|
119
|
+
const first = failover.pickNext("gpt-4o", "chat_completions", "clawtip");
|
|
120
|
+
expect(first).toBeDefined();
|
|
121
|
+
credit.recordPurchase(first!.sellerId, 1_000_000, 1_000_000);
|
|
122
|
+
const decision = failover.decide(
|
|
123
|
+
{ sellerId: first!.sellerId, status: 404, errorKind: "hard_4xx", attempt: 0 },
|
|
124
|
+
100
|
|
125
|
+
);
|
|
126
|
+
expect(decision.action).toBe("failover_next");
|
|
127
|
+
expect(decision.wastedCreditMicros).toBeGreaterThan(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("prewarm-key collisions are impossible across the (model, protocol, payment) space", () => {
|
|
131
|
+
// Even with 1000 sellers, the (model, protocol, payment) key must
|
|
132
|
+
// be unique. We assert the count of unique keys equals the count of
|
|
133
|
+
// committed entries.
|
|
134
|
+
const cache = new PrewarmCache();
|
|
135
|
+
for (let i = 0; i < 1000; i += 1) {
|
|
136
|
+
const protocol = i % 2 === 0 ? "chat_completions" : "responses";
|
|
137
|
+
const payment = i % 3 === 0 ? "clawtip" : "mock";
|
|
138
|
+
cache.commitWarm({
|
|
139
|
+
modelId: `m-${i}`,
|
|
140
|
+
protocol,
|
|
141
|
+
paymentMethod: payment,
|
|
142
|
+
candidates: [{ sellerId: `s-${i}`, url: "https://x", healthScore: 80 }]
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const keys = new Set<string>();
|
|
146
|
+
for (const entry of cache.snapshot()) {
|
|
147
|
+
keys.add(prewarmKey(entry.modelId, entry.protocol, entry.paymentMethod));
|
|
148
|
+
}
|
|
149
|
+
expect(keys.size).toBe(1000);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import * as http from "http";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { AddressInfo } from "net";
|
|
5
|
+
import { TokenbuddyDaemon } from "../src/daemon.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* v1.2 §18.9: when the bootstrap returns 413 (registry over 1MB), the
|
|
9
|
+
* daemon must fall back to the last successfully fetched snapshot
|
|
10
|
+
* instead of failing every request. This is the buyer-side counterpart
|
|
11
|
+
* to the bootstrap's hard 1MB cap.
|
|
12
|
+
*/
|
|
13
|
+
describe("TokenbuddyDaemon registry 413 stale-fallback", () => {
|
|
14
|
+
const TEMP_DB = path.resolve(__dirname, "../../data-test/413-fallback-test.db");
|
|
15
|
+
let bootstrapServer: http.Server;
|
|
16
|
+
let bootstrapPort: number;
|
|
17
|
+
let daemon: TokenbuddyDaemon;
|
|
18
|
+
let daemonProxyPort: number;
|
|
19
|
+
|
|
20
|
+
function rmDb(): void {
|
|
21
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
22
|
+
const file = TEMP_DB + suffix;
|
|
23
|
+
if (fs.existsSync(file)) {
|
|
24
|
+
fs.unlinkSync(file);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
beforeAll((done) => {
|
|
30
|
+
bootstrapServer = http.createServer((_req, res) => {
|
|
31
|
+
res.setHeader("Content-Type", "application/json");
|
|
32
|
+
// Always 200 for the boot fetch. Tests that want 413 do not
|
|
33
|
+
// need to flip this server; they instead verify the catch-block
|
|
34
|
+
// in `fetchRegistry` via the dedicated unit test in
|
|
35
|
+
// `seller-catalog-413.test.ts`.
|
|
36
|
+
res.statusCode = 200;
|
|
37
|
+
res.end(JSON.stringify({
|
|
38
|
+
version: 1,
|
|
39
|
+
defaultSeller: "primary-seller",
|
|
40
|
+
sellers: [
|
|
41
|
+
{
|
|
42
|
+
id: "primary-seller",
|
|
43
|
+
url: "https://primary.example.com",
|
|
44
|
+
supportedProtocols: ["chat_completions"],
|
|
45
|
+
paymentMethods: ["mock"],
|
|
46
|
+
models: ["gpt-4o"]
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}));
|
|
50
|
+
});
|
|
51
|
+
bootstrapServer.listen(0, "127.0.0.1", () => {
|
|
52
|
+
bootstrapPort = (bootstrapServer.address() as AddressInfo).port;
|
|
53
|
+
done();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterAll((done) => {
|
|
58
|
+
bootstrapServer.close(() => done());
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
rmDb();
|
|
63
|
+
daemon = new TokenbuddyDaemon({
|
|
64
|
+
controlPort: 0,
|
|
65
|
+
proxyPort: 0,
|
|
66
|
+
dbPath: TEMP_DB,
|
|
67
|
+
sellerRegistryUrl: `http://127.0.0.1:${bootstrapPort}/registry/sellers`
|
|
68
|
+
});
|
|
69
|
+
daemon.start();
|
|
70
|
+
const proxyServer = (daemon as unknown as { proxyServer: { address(): AddressInfo } }).proxyServer;
|
|
71
|
+
daemonProxyPort = proxyServer.address().port;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(async () => {
|
|
75
|
+
daemon.stop();
|
|
76
|
+
// Drain any in-flight prewarm scheduler work to avoid jest
|
|
77
|
+
// open-handle warnings. The daemon's stop() is fire-and-forget.
|
|
78
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 50));
|
|
79
|
+
rmDb();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("daemon stays alive after a successful boot against the bootstrap", async () => {
|
|
83
|
+
// The buyer must surface this as a typed error so the daemon can
|
|
84
|
+
// fall back to the last-known snapshot. The fetch logic is covered
|
|
85
|
+
// by `seller-catalog-413.test.ts`; here we just assert the
|
|
86
|
+
// happy-path control plane is up.
|
|
87
|
+
const controlPort = (daemon as unknown as { controlServer: { address(): AddressInfo } }).controlServer.address().port;
|
|
88
|
+
const health = await (await fetch(`http://127.0.0.1:${controlPort}/health`)).json() as { status: string };
|
|
89
|
+
expect(health.status).toBe("ok");
|
|
90
|
+
expect(typeof daemonProxyPort).toBe("number");
|
|
91
|
+
});
|
|
92
|
+
});
|
package/tests/e2e.test.ts
CHANGED
|
@@ -172,8 +172,9 @@ describe("TokenBuddy Full End-to-End Integration Flow Tests", () => {
|
|
|
172
172
|
id: "seller-e2e-node",
|
|
173
173
|
name: "Seller E2E Node",
|
|
174
174
|
url: `http://127.0.0.1:${sellerPort}`,
|
|
175
|
-
supportedProtocols: ["
|
|
176
|
-
paymentMethods: ["mock"]
|
|
175
|
+
supportedProtocols: ["chat_completions"],
|
|
176
|
+
paymentMethods: ["mock"],
|
|
177
|
+
models: ["gpt-4", "gpt-4o"]
|
|
177
178
|
}
|
|
178
179
|
]
|
|
179
180
|
};
|
package/tests/tokenbuddy.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { TokenbuddyDaemon } from "../src/daemon.js";
|
|
2
2
|
import { BuyerStore, resolveBuyerStorePath, type PaymentConfig } from "../src/buyer-store.js";
|
|
3
|
+
import type { ProviderRuntimeConfig } from "../src/provider-install.js";
|
|
3
4
|
import {
|
|
4
5
|
buildCli,
|
|
5
6
|
fetchClawtipBootstrap,
|
|
@@ -165,6 +166,22 @@ describe("BuyerStore safe SQLite persistence", () => {
|
|
|
165
166
|
});
|
|
166
167
|
});
|
|
167
168
|
|
|
169
|
+
test("getToken surfaces expiresAt so the daemon can reject stale tokens", () => {
|
|
170
|
+
const futureIso = "2030-01-01T00:00:00.000Z";
|
|
171
|
+
store.saveToken("seller-exp", "raw-token-secret", "model:gpt-4", 1_000_000, futureIso);
|
|
172
|
+
expect(store.getToken("seller-exp")?.expiresAt).toBe(futureIso);
|
|
173
|
+
|
|
174
|
+
// v1.2 PR-fix: when `saveToken` is invoked, `expiresAt` is
|
|
175
|
+
// persisted; the daemon reads it via `getToken().expiresAt` to
|
|
176
|
+
// refuse cached tokens inside the safety margin. This test pins
|
|
177
|
+
// the field name so a future rename can't silently drop the
|
|
178
|
+
// buyer-side expiry check.
|
|
179
|
+
expect(store.getToken("seller-exp")).toMatchObject({
|
|
180
|
+
token: "raw-token-secret",
|
|
181
|
+
expiresAt: futureIso
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
168
185
|
test("returns stable empty state for payments, pending purchases, and ledgers", () => {
|
|
169
186
|
expect(store.listPayments()).toEqual([]);
|
|
170
187
|
expect(store.listPendingPurchases()).toEqual([]);
|
|
@@ -1583,6 +1600,32 @@ describe("Provider install planning", () => {
|
|
|
1583
1600
|
store.close();
|
|
1584
1601
|
}
|
|
1585
1602
|
});
|
|
1603
|
+
|
|
1604
|
+
test("opencode provider install uses @ai-sdk/openai-responses by default (Responses API)", () => {
|
|
1605
|
+
// 锁住不变量:v1.0.10+ tb-proxy install opencode 必须默认走 Responses API 协议,
|
|
1606
|
+
// 而不是 chat completions。原因:code.shoestravel.xin 等上游原生 SSE 事件链
|
|
1607
|
+
// 才是 Responses API 风格(response.created / response.output_text.delta / response.completed),
|
|
1608
|
+
// buyer 端 SseUsageExtractor 解析 usage 字段更稳定。改回 @ai-sdk/openai 需先
|
|
1609
|
+
// 评估 5-seller 架构是否仍能端到端 work。
|
|
1610
|
+
const config: ProviderRuntimeConfig = {
|
|
1611
|
+
selectionKind: "single-model",
|
|
1612
|
+
protocolPreference: "responses",
|
|
1613
|
+
defaultModel: "gpt-5.4",
|
|
1614
|
+
};
|
|
1615
|
+
const changes = previewProviderInstall({
|
|
1616
|
+
providers: ["opencode"],
|
|
1617
|
+
proxyUrl: "http://127.0.0.1:17821",
|
|
1618
|
+
providerSelections: { opencode: config },
|
|
1619
|
+
home: PROVIDER_HOME,
|
|
1620
|
+
});
|
|
1621
|
+
const change = changes.find((c) => c.providerId === "opencode");
|
|
1622
|
+
expect(change).toBeDefined();
|
|
1623
|
+
expect(change?.content).toBeDefined();
|
|
1624
|
+
const parsed = JSON.parse(change!.content!);
|
|
1625
|
+
expect(parsed.provider.tokenbuddy.npm).toBe("@ai-sdk/openai-responses");
|
|
1626
|
+
expect(parsed.model).toBe("tokenbuddy/gpt-5.4");
|
|
1627
|
+
expect(parsed.provider.tokenbuddy.options.baseURL).toBe("http://127.0.0.1:17821/v1");
|
|
1628
|
+
});
|
|
1586
1629
|
});
|
|
1587
1630
|
|
|
1588
1631
|
describe("TokenBuddy CLI and Daemon Integration Tests", () => {
|
|
@@ -1646,14 +1689,16 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
|
|
|
1646
1689
|
name: "Incompatible Seller",
|
|
1647
1690
|
url: `http://127.0.0.1:${mockSellerPort}/incompatible`,
|
|
1648
1691
|
supportedProtocols: ["chat_completions"],
|
|
1649
|
-
paymentMethods: ["mock"]
|
|
1692
|
+
paymentMethods: ["mock"],
|
|
1693
|
+
models: ["incompatible-only"]
|
|
1650
1694
|
},
|
|
1651
1695
|
{
|
|
1652
1696
|
id: "mock-seller",
|
|
1653
1697
|
name: "Mock Seller",
|
|
1654
1698
|
url: `http://127.0.0.1:${mockSellerPort}`,
|
|
1655
1699
|
supportedProtocols: ["chat_completions", "responses", "messages"],
|
|
1656
|
-
paymentMethods: ["mock"]
|
|
1700
|
+
paymentMethods: ["mock"],
|
|
1701
|
+
models: ["gpt-4", "gpt-4.1-mini", "claude-3-5-sonnet"]
|
|
1657
1702
|
}
|
|
1658
1703
|
]
|
|
1659
1704
|
}));
|
|
@@ -2257,7 +2302,7 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
|
|
|
2257
2302
|
expect(publicOutput).not.toContain("chatcmpl-stream");
|
|
2258
2303
|
});
|
|
2259
2304
|
|
|
2260
|
-
test("
|
|
2305
|
+
test("passes through responses SSE bytes unchanged for OpenAI Responses API clients", async () => {
|
|
2261
2306
|
const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/responses`, {
|
|
2262
2307
|
method: "POST",
|
|
2263
2308
|
headers: { "Content-Type": "application/json" },
|
|
@@ -2272,11 +2317,17 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
|
|
|
2272
2317
|
expect(response.ok).toBe(true);
|
|
2273
2318
|
expect(response.headers.get("content-type")).toContain("text/event-stream");
|
|
2274
2319
|
const body = await response.text();
|
|
2275
|
-
|
|
2276
|
-
expect(body).toContain("response.
|
|
2320
|
+
// 卖方原始 events 直转——不再注入 content_part.added / content_part.done
|
|
2321
|
+
expect(body).toContain("event: response.created");
|
|
2322
|
+
expect(body).toContain("event: response.output_item.added");
|
|
2323
|
+
expect(body).toContain("event: response.output_text.delta");
|
|
2324
|
+
expect(body).toContain("event: response.output_text.done");
|
|
2325
|
+
expect(body).toContain("event: response.output_item.done");
|
|
2326
|
+
expect(body).toContain("event: response.completed");
|
|
2277
2327
|
expect(body).toContain("\"item_id\":\"item_stream_shape\"");
|
|
2278
|
-
expect(body).toContain("\"
|
|
2279
|
-
|
|
2328
|
+
expect(body).toContain("\"delta\":\"hello\"");
|
|
2329
|
+
// 内部记账事件不泄露给客户端
|
|
2330
|
+
expect(body).not.toContain("tokenbuddy.settlement");
|
|
2280
2331
|
});
|
|
2281
2332
|
|
|
2282
2333
|
test("fails closed when no compatible seller can serve the requested model", async () => {
|
|
@@ -2326,14 +2377,16 @@ describe("TokenBuddy manual routing mode", () => {
|
|
|
2326
2377
|
name: "Primary Seller",
|
|
2327
2378
|
url: `http://127.0.0.1:${sellerPort}/primary`,
|
|
2328
2379
|
supportedProtocols: ["chat_completions"],
|
|
2329
|
-
paymentMethods: ["mock"]
|
|
2380
|
+
paymentMethods: ["mock"],
|
|
2381
|
+
models: ["gpt-manual"]
|
|
2330
2382
|
},
|
|
2331
2383
|
{
|
|
2332
2384
|
id: "backup-seller",
|
|
2333
2385
|
name: "Backup Seller",
|
|
2334
2386
|
url: `http://127.0.0.1:${sellerPort}/backup`,
|
|
2335
2387
|
supportedProtocols: ["chat_completions"],
|
|
2336
|
-
paymentMethods: ["mock"]
|
|
2388
|
+
paymentMethods: ["mock"],
|
|
2389
|
+
models: ["gpt-manual"]
|
|
2337
2390
|
}
|
|
2338
2391
|
]
|
|
2339
2392
|
}));
|
|
@@ -2471,8 +2524,10 @@ describe("TokenBuddy manual routing mode", () => {
|
|
|
2471
2524
|
expect(response.status).toBe(502);
|
|
2472
2525
|
const output = await response.json() as any;
|
|
2473
2526
|
expect(output.error.message).toContain("purchase/create failed");
|
|
2527
|
+
// v1.2: the buyer no longer fetches the seller manifest per request.
|
|
2528
|
+
// The registry's `models` field is the source of truth. Auto-purchase
|
|
2529
|
+
// is still attempted once before failing over.
|
|
2474
2530
|
expect(events).toEqual([
|
|
2475
|
-
{ seller: "primary-seller", url: "/primary/manifest" },
|
|
2476
2531
|
{ seller: "primary-seller", url: "/primary/purchase/create" }
|
|
2477
2532
|
]);
|
|
2478
2533
|
|
|
@@ -2511,8 +2566,10 @@ describe("TokenBuddy manual routing mode", () => {
|
|
|
2511
2566
|
});
|
|
2512
2567
|
|
|
2513
2568
|
expect(response.ok).toBe(true);
|
|
2569
|
+
// v1.2: the buyer no longer fetches the seller manifest per request.
|
|
2570
|
+
// The backup-seller is selected via `selectedSellerId`; the manifest
|
|
2571
|
+
// is sourced from the registry's `models` field.
|
|
2514
2572
|
expect(events).toEqual([
|
|
2515
|
-
{ seller: "backup-seller", url: "/backup/manifest" },
|
|
2516
2573
|
{ seller: "backup-seller", url: "/backup/purchase/create" },
|
|
2517
2574
|
{ seller: "backup-seller", url: "/backup/purchase/complete" },
|
|
2518
2575
|
{ seller: "backup-seller", url: "/backup/v1/chat/completions" }
|