@tokenbuddy/tokenbuddy 1.0.8 → 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.
Files changed (71) hide show
  1. package/dist/src/buyer-store.d.ts +13 -0
  2. package/dist/src/buyer-store.d.ts.map +1 -1
  3. package/dist/src/buyer-store.js +21 -2
  4. package/dist/src/buyer-store.js.map +1 -1
  5. package/dist/src/cli.d.ts.map +1 -1
  6. package/dist/src/cli.js +54 -0
  7. package/dist/src/cli.js.map +1 -1
  8. package/dist/src/credit-tracker.d.ts +118 -0
  9. package/dist/src/credit-tracker.d.ts.map +1 -0
  10. package/dist/src/credit-tracker.js +220 -0
  11. package/dist/src/credit-tracker.js.map +1 -0
  12. package/dist/src/daemon.d.ts +49 -4
  13. package/dist/src/daemon.d.ts.map +1 -1
  14. package/dist/src/daemon.js +541 -405
  15. package/dist/src/daemon.js.map +1 -1
  16. package/dist/src/model-index.d.ts +86 -0
  17. package/dist/src/model-index.d.ts.map +1 -0
  18. package/dist/src/model-index.js +214 -0
  19. package/dist/src/model-index.js.map +1 -0
  20. package/dist/src/prewarm-cache.d.ts +149 -0
  21. package/dist/src/prewarm-cache.d.ts.map +1 -0
  22. package/dist/src/prewarm-cache.js +288 -0
  23. package/dist/src/prewarm-cache.js.map +1 -0
  24. package/dist/src/prewarm-scheduler.d.ts +150 -0
  25. package/dist/src/prewarm-scheduler.d.ts.map +1 -0
  26. package/dist/src/prewarm-scheduler.js +484 -0
  27. package/dist/src/prewarm-scheduler.js.map +1 -0
  28. package/dist/src/provider-install.d.ts.map +1 -1
  29. package/dist/src/provider-install.js +9 -1
  30. package/dist/src/provider-install.js.map +1 -1
  31. package/dist/src/route-failover.d.ts +96 -0
  32. package/dist/src/route-failover.d.ts.map +1 -0
  33. package/dist/src/route-failover.js +177 -0
  34. package/dist/src/route-failover.js.map +1 -0
  35. package/dist/src/seller-catalog.d.ts +26 -0
  36. package/dist/src/seller-catalog.d.ts.map +1 -1
  37. package/dist/src/seller-catalog.js +40 -0
  38. package/dist/src/seller-catalog.js.map +1 -1
  39. package/dist/src/seller-pool.d.ts +127 -0
  40. package/dist/src/seller-pool.d.ts.map +1 -0
  41. package/dist/src/seller-pool.js +243 -0
  42. package/dist/src/seller-pool.js.map +1 -0
  43. package/dist/src/stream-failover.d.ts +78 -0
  44. package/dist/src/stream-failover.d.ts.map +1 -0
  45. package/dist/src/stream-failover.js +93 -0
  46. package/dist/src/stream-failover.js.map +1 -0
  47. package/package.json +1 -1
  48. package/src/buyer-store.ts +32 -2
  49. package/src/cli.ts +61 -0
  50. package/src/credit-tracker.test.ts +165 -0
  51. package/src/credit-tracker.ts +269 -0
  52. package/src/daemon.ts +569 -445
  53. package/src/model-index.test.ts +184 -0
  54. package/src/model-index.ts +266 -0
  55. package/src/prewarm-cache.test.ts +281 -0
  56. package/src/prewarm-cache.ts +373 -0
  57. package/src/prewarm-scheduler.test.ts +367 -0
  58. package/src/prewarm-scheduler.ts +581 -0
  59. package/src/provider-install.ts +9 -1
  60. package/src/route-failover.test.ts +193 -0
  61. package/src/route-failover.ts +233 -0
  62. package/src/seller-catalog-413.test.ts +61 -0
  63. package/src/seller-catalog.ts +47 -0
  64. package/src/seller-pool.test.ts +231 -0
  65. package/src/seller-pool.ts +333 -0
  66. package/src/stream-failover.test.ts +52 -0
  67. package/src/stream-failover.ts +129 -0
  68. package/src/thousand-seller.test.ts +151 -0
  69. package/tests/daemon-413-fallback.test.ts +92 -0
  70. package/tests/e2e.test.ts +3 -2
  71. 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: ["openai"],
176
- paymentMethods: ["mock"]
175
+ supportedProtocols: ["chat_completions"],
176
+ paymentMethods: ["mock"],
177
+ models: ["gpt-4", "gpt-4o"]
177
178
  }
178
179
  ]
179
180
  };
@@ -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("normalizes responses SSE shape for OpenCode-compatible consumers", async () => {
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
- expect(body).toContain("response.content_part.added");
2276
- expect(body).toContain("response.content_part.done");
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("\"output_text\":\"hello\"");
2279
- expect(body).toContain("\"content\":[{\"type\":\"output_text\",\"text\":\"hello\",\"annotations\":[]}]");
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" }