@tokenbuddy/tokenbuddy 1.0.9 → 1.0.12
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 +10 -0
- 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 +10 -0
- 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 +70 -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,34 @@ describe("Provider install planning", () => {
|
|
|
1583
1600
|
store.close();
|
|
1584
1601
|
}
|
|
1585
1602
|
});
|
|
1603
|
+
|
|
1604
|
+
test("opencode provider install uses @ai-sdk/openai (chat completions) by default", () => {
|
|
1605
|
+
// 锁住不变量:v1.0.12+ tb-proxy install opencode 必须默认用 @ai-sdk/openai。
|
|
1606
|
+
// v1.0.11 改成 @ai-sdk/openai-responses 在 opencode 1.14.28 desktop 上
|
|
1607
|
+
// 报 ProviderModelNotFoundError(SDK 是 dynamic model,opencode 1.14.x 期望静态
|
|
1608
|
+
// languageModel 映射,schema mismatch),对 opencode 用户实际跑不通。
|
|
1609
|
+
// 切回 @ai-sdk/openai 后走 chat completions 协议,opencode 用户能 work
|
|
1610
|
+
// (走 tbs-719577/tbs-825edb 上游)。buyer 端 /v1/responses 协议支持仍完整,
|
|
1611
|
+
// 等 opencode 1.15+ 支持 openai-responses custom provider 再切默认。
|
|
1612
|
+
const config: ProviderRuntimeConfig = {
|
|
1613
|
+
selectionKind: "single-model",
|
|
1614
|
+
protocolPreference: "chat_completions",
|
|
1615
|
+
defaultModel: "gpt-5.4",
|
|
1616
|
+
};
|
|
1617
|
+
const changes = previewProviderInstall({
|
|
1618
|
+
providers: ["opencode"],
|
|
1619
|
+
proxyUrl: "http://127.0.0.1:17821",
|
|
1620
|
+
providerSelections: { opencode: config },
|
|
1621
|
+
home: PROVIDER_HOME,
|
|
1622
|
+
});
|
|
1623
|
+
const change = changes.find((c) => c.providerId === "opencode");
|
|
1624
|
+
expect(change).toBeDefined();
|
|
1625
|
+
expect(change?.content).toBeDefined();
|
|
1626
|
+
const parsed = JSON.parse(change!.content!);
|
|
1627
|
+
expect(parsed.provider.tokenbuddy.npm).toBe("@ai-sdk/openai");
|
|
1628
|
+
expect(parsed.model).toBe("tokenbuddy/gpt-5.4");
|
|
1629
|
+
expect(parsed.provider.tokenbuddy.options.baseURL).toBe("http://127.0.0.1:17821/v1");
|
|
1630
|
+
});
|
|
1586
1631
|
});
|
|
1587
1632
|
|
|
1588
1633
|
describe("TokenBuddy CLI and Daemon Integration Tests", () => {
|
|
@@ -1646,14 +1691,16 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
|
|
|
1646
1691
|
name: "Incompatible Seller",
|
|
1647
1692
|
url: `http://127.0.0.1:${mockSellerPort}/incompatible`,
|
|
1648
1693
|
supportedProtocols: ["chat_completions"],
|
|
1649
|
-
paymentMethods: ["mock"]
|
|
1694
|
+
paymentMethods: ["mock"],
|
|
1695
|
+
models: ["incompatible-only"]
|
|
1650
1696
|
},
|
|
1651
1697
|
{
|
|
1652
1698
|
id: "mock-seller",
|
|
1653
1699
|
name: "Mock Seller",
|
|
1654
1700
|
url: `http://127.0.0.1:${mockSellerPort}`,
|
|
1655
1701
|
supportedProtocols: ["chat_completions", "responses", "messages"],
|
|
1656
|
-
paymentMethods: ["mock"]
|
|
1702
|
+
paymentMethods: ["mock"],
|
|
1703
|
+
models: ["gpt-4", "gpt-4.1-mini", "claude-3-5-sonnet"]
|
|
1657
1704
|
}
|
|
1658
1705
|
]
|
|
1659
1706
|
}));
|
|
@@ -2257,7 +2304,7 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
|
|
|
2257
2304
|
expect(publicOutput).not.toContain("chatcmpl-stream");
|
|
2258
2305
|
});
|
|
2259
2306
|
|
|
2260
|
-
test("
|
|
2307
|
+
test("passes through responses SSE bytes unchanged for OpenAI Responses API clients", async () => {
|
|
2261
2308
|
const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/responses`, {
|
|
2262
2309
|
method: "POST",
|
|
2263
2310
|
headers: { "Content-Type": "application/json" },
|
|
@@ -2272,11 +2319,17 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
|
|
|
2272
2319
|
expect(response.ok).toBe(true);
|
|
2273
2320
|
expect(response.headers.get("content-type")).toContain("text/event-stream");
|
|
2274
2321
|
const body = await response.text();
|
|
2275
|
-
|
|
2276
|
-
expect(body).toContain("response.
|
|
2322
|
+
// 卖方原始 events 直转——不再注入 content_part.added / content_part.done
|
|
2323
|
+
expect(body).toContain("event: response.created");
|
|
2324
|
+
expect(body).toContain("event: response.output_item.added");
|
|
2325
|
+
expect(body).toContain("event: response.output_text.delta");
|
|
2326
|
+
expect(body).toContain("event: response.output_text.done");
|
|
2327
|
+
expect(body).toContain("event: response.output_item.done");
|
|
2328
|
+
expect(body).toContain("event: response.completed");
|
|
2277
2329
|
expect(body).toContain("\"item_id\":\"item_stream_shape\"");
|
|
2278
|
-
expect(body).toContain("\"
|
|
2279
|
-
|
|
2330
|
+
expect(body).toContain("\"delta\":\"hello\"");
|
|
2331
|
+
// 内部记账事件不泄露给客户端
|
|
2332
|
+
expect(body).not.toContain("tokenbuddy.settlement");
|
|
2280
2333
|
});
|
|
2281
2334
|
|
|
2282
2335
|
test("fails closed when no compatible seller can serve the requested model", async () => {
|
|
@@ -2326,14 +2379,16 @@ describe("TokenBuddy manual routing mode", () => {
|
|
|
2326
2379
|
name: "Primary Seller",
|
|
2327
2380
|
url: `http://127.0.0.1:${sellerPort}/primary`,
|
|
2328
2381
|
supportedProtocols: ["chat_completions"],
|
|
2329
|
-
paymentMethods: ["mock"]
|
|
2382
|
+
paymentMethods: ["mock"],
|
|
2383
|
+
models: ["gpt-manual"]
|
|
2330
2384
|
},
|
|
2331
2385
|
{
|
|
2332
2386
|
id: "backup-seller",
|
|
2333
2387
|
name: "Backup Seller",
|
|
2334
2388
|
url: `http://127.0.0.1:${sellerPort}/backup`,
|
|
2335
2389
|
supportedProtocols: ["chat_completions"],
|
|
2336
|
-
paymentMethods: ["mock"]
|
|
2390
|
+
paymentMethods: ["mock"],
|
|
2391
|
+
models: ["gpt-manual"]
|
|
2337
2392
|
}
|
|
2338
2393
|
]
|
|
2339
2394
|
}));
|
|
@@ -2471,8 +2526,10 @@ describe("TokenBuddy manual routing mode", () => {
|
|
|
2471
2526
|
expect(response.status).toBe(502);
|
|
2472
2527
|
const output = await response.json() as any;
|
|
2473
2528
|
expect(output.error.message).toContain("purchase/create failed");
|
|
2529
|
+
// v1.2: the buyer no longer fetches the seller manifest per request.
|
|
2530
|
+
// The registry's `models` field is the source of truth. Auto-purchase
|
|
2531
|
+
// is still attempted once before failing over.
|
|
2474
2532
|
expect(events).toEqual([
|
|
2475
|
-
{ seller: "primary-seller", url: "/primary/manifest" },
|
|
2476
2533
|
{ seller: "primary-seller", url: "/primary/purchase/create" }
|
|
2477
2534
|
]);
|
|
2478
2535
|
|
|
@@ -2511,8 +2568,10 @@ describe("TokenBuddy manual routing mode", () => {
|
|
|
2511
2568
|
});
|
|
2512
2569
|
|
|
2513
2570
|
expect(response.ok).toBe(true);
|
|
2571
|
+
// v1.2: the buyer no longer fetches the seller manifest per request.
|
|
2572
|
+
// The backup-seller is selected via `selectedSellerId`; the manifest
|
|
2573
|
+
// is sourced from the registry's `models` field.
|
|
2514
2574
|
expect(events).toEqual([
|
|
2515
|
-
{ seller: "backup-seller", url: "/backup/manifest" },
|
|
2516
2575
|
{ seller: "backup-seller", url: "/backup/purchase/create" },
|
|
2517
2576
|
{ seller: "backup-seller", url: "/backup/purchase/complete" },
|
|
2518
2577
|
{ seller: "backup-seller", url: "/backup/v1/chat/completions" }
|