@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,243 @@
1
+ import { createModuleLogger } from "@tokenbuddy/logging";
2
+ const logger = createModuleLogger("tb-proxyd:seller-pool");
3
+ const DEFAULTS = {
4
+ failureThreshold: 3,
5
+ windowMs: 60_000,
6
+ windowFailureRate: 0.5,
7
+ openStateMs: 30_000
8
+ };
9
+ /**
10
+ * v2 SellerPool: combines `ModelIndex` (registry index), `PrewarmCache`
11
+ * (probe results), and `CreditTracker` (balance protection) into a single
12
+ * source of truth used by the route-failover controller. The pool is
13
+ * process-local and rebuilds its entry list from the cache whenever the
14
+ * cache mutates; entries not yet present in the cache are not in the pool.
15
+ */
16
+ export class SellerPool {
17
+ modelIndex;
18
+ cache;
19
+ creditTracker;
20
+ failureThreshold;
21
+ windowMs;
22
+ windowFailureRate;
23
+ openStateMs;
24
+ now;
25
+ entries = new Map();
26
+ constructor(options) {
27
+ this.modelIndex = options.modelIndex;
28
+ this.cache = options.cache;
29
+ this.creditTracker = options.creditTracker;
30
+ this.failureThreshold = options.failureThreshold ?? DEFAULTS.failureThreshold;
31
+ this.windowMs = options.windowMs ?? DEFAULTS.windowMs;
32
+ this.windowFailureRate = options.windowFailureRate ?? DEFAULTS.windowFailureRate;
33
+ this.openStateMs = options.openStateMs ?? DEFAULTS.openStateMs;
34
+ this.now = options.now ?? Date.now;
35
+ }
36
+ /**
37
+ * Rebuild entries from the current prewarm cache. Called by
38
+ * `route-failover` whenever the cache is mutated (commit, invalidate,
39
+ * etc.) so the pool always reflects the latest probe results.
40
+ */
41
+ sync() {
42
+ const fresh = new Map();
43
+ for (const entry of this.cache.snapshot()) {
44
+ for (const candidate of entry.candidates) {
45
+ const registry = this.modelIndex.getSeller(candidate.sellerId);
46
+ if (!registry) {
47
+ // Seller disappeared from the registry since the probe; skip.
48
+ continue;
49
+ }
50
+ const previous = this.entries.get(candidate.sellerId);
51
+ fresh.set(candidate.sellerId, {
52
+ sellerId: candidate.sellerId,
53
+ url: candidate.url,
54
+ registrySeller: registry,
55
+ circuit: previous?.circuit ?? "closed",
56
+ consecutiveFailures: previous?.consecutiveFailures ?? 0,
57
+ recentFailures: previous?.recentFailures ?? [],
58
+ lastSuccessAt: candidate.lastSuccessAt || previous?.lastSuccessAt || 0,
59
+ lastFailAt: candidate.lastFailAt || previous?.lastFailAt || 0,
60
+ lastProbeAt: entry.warmedAt,
61
+ healthScore: candidate.healthScore,
62
+ avgLatencyMs: candidate.avgLatencyMs
63
+ });
64
+ }
65
+ }
66
+ this.entries = fresh;
67
+ return this.entries.size;
68
+ }
69
+ /**
70
+ * Pick up to `limit` candidates for a (model, protocol, payment) triple.
71
+ * Sellers in the `open` circuit are skipped unless their open state has
72
+ * expired (they are flipped to `half_open` and included). Candidates are
73
+ * sorted by health score (descending) so the strongest seller goes first.
74
+ */
75
+ pick(options) {
76
+ const now = options.now ?? this.now();
77
+ const limit = options.limit ?? 4;
78
+ const freshness = this.cache.freshness(options.modelId, options.protocol, options.paymentMethod);
79
+ const resolved = this.modelIndex.resolve(options.modelId, {
80
+ protocol: options.protocol,
81
+ paymentMethod: options.paymentMethod
82
+ });
83
+ if (freshness.entry && freshness.entry.candidates.length === 0) {
84
+ return {
85
+ candidates: [],
86
+ reason: "prewarm_cache_empty",
87
+ resolved: asResolution(resolved)
88
+ };
89
+ }
90
+ const candidates = (freshness.entry?.candidates ?? [])
91
+ .map((candidate) => {
92
+ const entry = this.entries.get(candidate.sellerId);
93
+ if (!entry) {
94
+ return null;
95
+ }
96
+ return { entry, registrySeller: entry.registrySeller, candidate };
97
+ })
98
+ .filter((row) => row !== null)
99
+ .map((row) => {
100
+ const entry = this.maybeRecycleFromOpen(row.entry, now);
101
+ return { entry, registrySeller: row.registrySeller };
102
+ })
103
+ .filter((row) => row.entry.circuit !== "open")
104
+ .sort((a, b) => b.entry.healthScore - a.entry.healthScore)
105
+ .slice(0, limit);
106
+ return {
107
+ candidates,
108
+ reason: candidates.length > 0 ? "prewarm_cache" : "no_prewarm_candidates",
109
+ resolved: asResolution(resolved)
110
+ };
111
+ }
112
+ /**
113
+ * Record a successful inference against `sellerId`. The circuit closes
114
+ * (if it was half-open) and the credit tracker observes the latest
115
+ * balance via `recordSpend`.
116
+ */
117
+ recordSuccess(sellerId, balanceMicros, now = this.now()) {
118
+ const entry = this.entries.get(sellerId);
119
+ if (!entry) {
120
+ return undefined;
121
+ }
122
+ const next = {
123
+ ...entry,
124
+ circuit: "closed",
125
+ consecutiveFailures: 0,
126
+ recentFailures: [],
127
+ lastSuccessAt: now,
128
+ healthScore: Math.min(100, Math.max(entry.healthScore, 60))
129
+ };
130
+ this.entries.set(sellerId, next);
131
+ this.creditTracker.recordSpend(sellerId, balanceMicros);
132
+ logger.info("pool.success.recorded", "seller pool entry marked successful", {
133
+ sellerId,
134
+ balanceMicros,
135
+ healthScore: next.healthScore
136
+ });
137
+ return next;
138
+ }
139
+ /**
140
+ * Record a failure against `sellerId`. Returns the new PoolEntry. The
141
+ * caller (route-failover) uses the returned `entry.circuit` and the
142
+ * entry's `lastFailAt` to decide whether to fail over, retry, or stop.
143
+ * On a non-recoverable failure (`hard_4xx`, `auth_invalid`,
144
+ * `insufficient_funds`) the credit is also transferred to the wasted
145
+ * bucket so the wasted-micros counter stays accurate.
146
+ */
147
+ recordFailure(sellerId, kind, options = {}) {
148
+ const entry = this.entries.get(sellerId);
149
+ if (!entry) {
150
+ return undefined;
151
+ }
152
+ const now = options.now ?? this.now();
153
+ const recentFailures = [...entry.recentFailures, now].filter((ts) => ts >= now - this.windowMs);
154
+ const consecutiveFailures = entry.consecutiveFailures + 1;
155
+ const failureRate = recentFailures.length / Math.max(1, this.windowMs / 1000);
156
+ const overThreshold = consecutiveFailures >= this.failureThreshold;
157
+ const overRate = failureRate >= this.windowFailureRate;
158
+ const isHard = kind === "hard_4xx" || kind === "auth_invalid" || kind === "no_compatible";
159
+ const circuit = isHard || overThreshold || overRate ? "open" : entry.circuit;
160
+ const next = {
161
+ ...entry,
162
+ circuit,
163
+ consecutiveFailures,
164
+ recentFailures,
165
+ lastFailAt: now
166
+ };
167
+ this.entries.set(sellerId, next);
168
+ if (options.transferLeftover || isHard) {
169
+ this.creditTracker.transferLeftoverToWasted(sellerId, options.reason ?? kind);
170
+ }
171
+ if (circuit === "open") {
172
+ logger.warn("pool.circuit_opened", "seller pool entry transitioned to circuit_open", {
173
+ sellerId,
174
+ kind,
175
+ consecutiveFailures,
176
+ recentFailureRate: failureRate,
177
+ threshold: this.failureThreshold
178
+ });
179
+ }
180
+ return next;
181
+ }
182
+ /**
183
+ * Expose a per-seller credit / circuit snapshot to the route-failover.
184
+ * Used to decide whether a soft failure should retry on the same seller
185
+ * (刚买窗口保护) or fail over immediately.
186
+ */
187
+ inspect(sellerId) {
188
+ const entry = this.entries.get(sellerId);
189
+ const freshPurchase = this.creditTracker.isInFreshPurchaseWindow(sellerId, this.now());
190
+ const autoPurchaseAvailable = this.creditTracker.canAutoPurchase(this.now());
191
+ return { entry, freshPurchase, autoPurchaseAvailable };
192
+ }
193
+ /**
194
+ * Manually mark an entry as `open`. Used by the registry loop when a
195
+ * seller is removed from the registry: the entry lingers for a grace
196
+ * period but is unreachable, so opening the circuit prevents any
197
+ * further selection.
198
+ */
199
+ markOpen(sellerId, reason, now = this.now()) {
200
+ const entry = this.entries.get(sellerId);
201
+ if (!entry) {
202
+ return;
203
+ }
204
+ this.entries.set(sellerId, { ...entry, circuit: "open", lastFailAt: now });
205
+ logger.warn("pool.circuit_force_opened", "seller pool entry forced to circuit_open", {
206
+ sellerId,
207
+ reason
208
+ });
209
+ }
210
+ /**
211
+ * List all known pool entries. Used by `tb doctor` and tests.
212
+ */
213
+ snapshot() {
214
+ return Array.from(this.entries.values()).map((entry) => ({ ...entry, recentFailures: [...entry.recentFailures] }));
215
+ }
216
+ size() {
217
+ return this.entries.size;
218
+ }
219
+ maybeRecycleFromOpen(entry, now) {
220
+ if (entry.circuit !== "open") {
221
+ return entry;
222
+ }
223
+ if (now - entry.lastFailAt < this.openStateMs) {
224
+ return entry;
225
+ }
226
+ const recycled = { ...entry, circuit: "half_open" };
227
+ this.entries.set(entry.sellerId, recycled);
228
+ logger.info("pool.circuit_half_opened", "seller pool entry recycled to half_open", {
229
+ sellerId: entry.sellerId,
230
+ openStateMs: this.openStateMs
231
+ });
232
+ return recycled;
233
+ }
234
+ }
235
+ function asResolution(resolved) {
236
+ return {
237
+ modelId: resolved.modelId,
238
+ matched: resolved.matched,
239
+ candidates: resolved.sellers,
240
+ missingModelsFlag: resolved.missingModelsFlag
241
+ };
242
+ }
243
+ //# sourceMappingURL=seller-pool.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seller-pool.js","sourceRoot":"","sources":["../../src/seller-pool.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,78 @@
1
+ /**
2
+ * v1.2 §6 / §18.10: stream-failover policy. The buyer honors the
3
+ * "abort + client retry" contract: once the first SSE byte has been
4
+ * written to the client, an upstream stream failure is surfaced as an
5
+ * abrupt close plus a `X-TokenBuddy-Retry-Hint: 1` trailer. The client
6
+ * (OpenAI / Anthropic SDK or any consumer honoring the OpenAI retry
7
+ * contract) re-issues the request and the buyer serves it from a
8
+ * healthy seller.
9
+ *
10
+ * The decisions in this module are intentionally one-way: the buyer
11
+ * never tries to splice two streams together (option B in the design
12
+ * doc) because that would double-charge and would require non-trivial
13
+ * idempotency re-design. v1.2 = abort + retry; v2 may revisit.
14
+ */
15
+ export interface StreamFailoverOptions {
16
+ retryHintHeader?: string;
17
+ now?: () => number;
18
+ }
19
+ export interface StreamFailoverDecision {
20
+ action: "abort_with_retry_hint" | "let_stream_complete";
21
+ reason: string;
22
+ retryHintValue: string;
23
+ firstChunkCommitted: boolean;
24
+ bytesFlushed: number;
25
+ }
26
+ export declare class StreamFailover {
27
+ private readonly retryHintHeader;
28
+ private readonly now;
29
+ private firstChunkCommitted;
30
+ private bytesFlushed;
31
+ constructor(options?: StreamFailoverOptions);
32
+ /**
33
+ * Record that the buyer's response stream has written its first chunk
34
+ * to the client. From this point on, the route-failover controller
35
+ * cannot switch sellers without the client's knowledge; failures
36
+ * must abort the stream and rely on the client to retry.
37
+ */
38
+ markFirstChunkCommitted(): void;
39
+ /**
40
+ * Track total bytes written to the client. Used by `tb doctor` and
41
+ * the inference ledger to attribute partial-stream usage.
42
+ */
43
+ recordBytesWritten(bytes: number): void;
44
+ /**
45
+ * Decide what to do when the upstream stream breaks. If the first
46
+ * chunk has already been written, the only option is to abort and
47
+ * surface the retry hint. Otherwise the controller is free to fail
48
+ * over to the next seller.
49
+ */
50
+ decideOnStreamAbort(reason: string): StreamFailoverDecision;
51
+ /**
52
+ * Read-only snapshot of the current stream state. The route-failover
53
+ * controller calls this to decide whether the next chunk is the first
54
+ * one (failover still possible) or a follow-up (abort required).
55
+ */
56
+ snapshot(): {
57
+ firstChunkCommitted: boolean;
58
+ bytesFlushed: number;
59
+ };
60
+ /**
61
+ * Reset the failover state when a brand-new request starts. The
62
+ * `forwardProxyRequest` controller calls this before each new
63
+ * inference request.
64
+ */
65
+ reset(): void;
66
+ /**
67
+ * The HTTP header to set on the abort response so the client knows
68
+ * it should retry. Exposed so the controller and the test fixtures
69
+ * can refer to the same constant.
70
+ */
71
+ get headerName(): string;
72
+ }
73
+ /**
74
+ * Constant for the "retry hint value" used on stream-abort responses.
75
+ * Exposed so callers can refer to the same value in tests.
76
+ */
77
+ export declare const STREAM_FAILOVER_RETRY_HINT = "1";
78
+ //# sourceMappingURL=stream-failover.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stream-failover.d.ts","sourceRoot":"","sources":["../../src/stream-failover.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,qBAAqB;IACpC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,uBAAuB,GAAG,qBAAqB,CAAC;IACxD,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;IACvB,mBAAmB,EAAE,OAAO,CAAC;IAC7B,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;IACzC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,mBAAmB,CAAS;IACpC,OAAO,CAAC,YAAY,CAAK;gBAEb,OAAO,GAAE,qBAA0B;IAK/C;;;;;OAKG;IACH,uBAAuB,IAAI,IAAI;IAO/B;;;OAGG;IACH,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAIvC;;;;;OAKG;IACH,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,sBAAsB;IAuB3D;;;;OAIG;IACH,QAAQ,IAAI;QAAE,mBAAmB,EAAE,OAAO,CAAC;QAAC,YAAY,EAAE,MAAM,CAAA;KAAE;IAOlE;;;;OAIG;IACH,KAAK,IAAI,IAAI;IAKb;;;;OAIG;IACH,IAAI,UAAU,IAAI,MAAM,CAEvB;CACF;AAED;;;GAGG;AACH,eAAO,MAAM,0BAA0B,MAAM,CAAC"}
@@ -0,0 +1,93 @@
1
+ import { createModuleLogger } from "@tokenbuddy/logging";
2
+ const logger = createModuleLogger("tb-proxyd:stream-failover");
3
+ export class StreamFailover {
4
+ retryHintHeader;
5
+ now;
6
+ firstChunkCommitted = false;
7
+ bytesFlushed = 0;
8
+ constructor(options = {}) {
9
+ this.retryHintHeader = options.retryHintHeader ?? "X-TokenBuddy-Retry-Hint";
10
+ this.now = options.now ?? Date.now;
11
+ }
12
+ /**
13
+ * Record that the buyer's response stream has written its first chunk
14
+ * to the client. From this point on, the route-failover controller
15
+ * cannot switch sellers without the client's knowledge; failures
16
+ * must abort the stream and rely on the client to retry.
17
+ */
18
+ markFirstChunkCommitted() {
19
+ if (this.firstChunkCommitted) {
20
+ return;
21
+ }
22
+ this.firstChunkCommitted = true;
23
+ }
24
+ /**
25
+ * Track total bytes written to the client. Used by `tb doctor` and
26
+ * the inference ledger to attribute partial-stream usage.
27
+ */
28
+ recordBytesWritten(bytes) {
29
+ this.bytesFlushed += bytes;
30
+ }
31
+ /**
32
+ * Decide what to do when the upstream stream breaks. If the first
33
+ * chunk has already been written, the only option is to abort and
34
+ * surface the retry hint. Otherwise the controller is free to fail
35
+ * over to the next seller.
36
+ */
37
+ decideOnStreamAbort(reason) {
38
+ if (!this.firstChunkCommitted) {
39
+ return {
40
+ action: "let_stream_complete",
41
+ reason: "no_chunks_yet_committed",
42
+ retryHintValue: "0",
43
+ firstChunkCommitted: false,
44
+ bytesFlushed: 0
45
+ };
46
+ }
47
+ logger.warn("stream.failover.aborted", "upstream stream broke after first chunk; aborting client with retry hint", {
48
+ reason,
49
+ bytesFlushed: this.bytesFlushed
50
+ });
51
+ return {
52
+ action: "abort_with_retry_hint",
53
+ reason,
54
+ retryHintValue: "1",
55
+ firstChunkCommitted: true,
56
+ bytesFlushed: this.bytesFlushed
57
+ };
58
+ }
59
+ /**
60
+ * Read-only snapshot of the current stream state. The route-failover
61
+ * controller calls this to decide whether the next chunk is the first
62
+ * one (failover still possible) or a follow-up (abort required).
63
+ */
64
+ snapshot() {
65
+ return {
66
+ firstChunkCommitted: this.firstChunkCommitted,
67
+ bytesFlushed: this.bytesFlushed
68
+ };
69
+ }
70
+ /**
71
+ * Reset the failover state when a brand-new request starts. The
72
+ * `forwardProxyRequest` controller calls this before each new
73
+ * inference request.
74
+ */
75
+ reset() {
76
+ this.firstChunkCommitted = false;
77
+ this.bytesFlushed = 0;
78
+ }
79
+ /**
80
+ * The HTTP header to set on the abort response so the client knows
81
+ * it should retry. Exposed so the controller and the test fixtures
82
+ * can refer to the same constant.
83
+ */
84
+ get headerName() {
85
+ return this.retryHintHeader;
86
+ }
87
+ }
88
+ /**
89
+ * Constant for the "retry hint value" used on stream-abort responses.
90
+ * Exposed so callers can refer to the same value in tests.
91
+ */
92
+ export const STREAM_FAILOVER_RETRY_HINT = "1";
93
+ //# sourceMappingURL=stream-failover.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stream-failover.js","sourceRoot":"","sources":["../../src/stream-failover.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAEzD,MAAM,MAAM,GAAG,kBAAkB,CAAC,2BAA2B,CAAC,CAAC;AA6B/D,MAAM,OAAO,cAAc;IACR,eAAe,CAAS;IACxB,GAAG,CAAe;IAC3B,mBAAmB,GAAG,KAAK,CAAC;IAC5B,YAAY,GAAG,CAAC,CAAC;IAEzB,YAAY,UAAiC,EAAE;QAC7C,IAAI,CAAC,eAAe,GAAG,OAAO,CAAC,eAAe,IAAI,yBAAyB,CAAC;QAC5E,IAAI,CAAC,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,CAAC;IACrC,CAAC;IAED;;;;;OAKG;IACH,uBAAuB;QACrB,IAAI,IAAI,CAAC,mBAAmB,EAAE,CAAC;YAC7B,OAAO;QACT,CAAC;QACD,IAAI,CAAC,mBAAmB,GAAG,IAAI,CAAC;IAClC,CAAC;IAED;;;OAGG;IACH,kBAAkB,CAAC,KAAa;QAC9B,IAAI,CAAC,YAAY,IAAI,KAAK,CAAC;IAC7B,CAAC;IAED;;;;;OAKG;IACH,mBAAmB,CAAC,MAAc;QAChC,IAAI,CAAC,IAAI,CAAC,mBAAmB,EAAE,CAAC;YAC9B,OAAO;gBACL,MAAM,EAAE,qBAAqB;gBAC7B,MAAM,EAAE,yBAAyB;gBACjC,cAAc,EAAE,GAAG;gBACnB,mBAAmB,EAAE,KAAK;gBAC1B,YAAY,EAAE,CAAC;aAChB,CAAC;QACJ,CAAC;QACD,MAAM,CAAC,IAAI,CAAC,yBAAyB,EAAE,0EAA0E,EAAE;YACjH,MAAM;YACN,YAAY,EAAE,IAAI,CAAC,YAAY;SAChC,CAAC,CAAC;QACH,OAAO;YACL,MAAM,EAAE,uBAAuB;YAC/B,MAAM;YACN,cAAc,EAAE,GAAG;YACnB,mBAAmB,EAAE,IAAI;YACzB,YAAY,EAAE,IAAI,CAAC,YAAY;SAChC,CAAC;IACJ,CAAC;IAED;;;;OAIG;IACH,QAAQ;QACN,OAAO;YACL,mBAAmB,EAAE,IAAI,CAAC,mBAAmB;YAC7C,YAAY,EAAE,IAAI,CAAC,YAAY;SAChC,CAAC;IACJ,CAAC;IAED;;;;OAIG;IACH,KAAK;QACH,IAAI,CAAC,mBAAmB,GAAG,KAAK,CAAC;QACjC,IAAI,CAAC,YAAY,GAAG,CAAC,CAAC;IACxB,CAAC;IAED;;;;OAIG;IACH,IAAI,UAAU;QACZ,OAAO,IAAI,CAAC,eAAe,CAAC;IAC9B,CAAC;CACF;AAED;;;GAGG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAG,GAAG,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenbuddy/tokenbuddy",
3
- "version": "1.0.8",
3
+ "version": "1.0.11",
4
4
  "description": "TokenBuddy Client CLI and Daemon",
5
5
  "main": "dist/src/index.js",
6
6
  "types": "dist/src/index.d.ts",
@@ -14,6 +14,13 @@ export interface CachedToken {
14
14
  reservedMicros: number;
15
15
  spentMicros: number;
16
16
  balanceSource?: string;
17
+ /**
18
+ * ISO-8601 expiry timestamp sourced from the seller's
19
+ * `/purchase/complete` response. v1.2 PR-fix: the buyer now
20
+ * checks this on every cached-token lookup so we never serve a
21
+ * stale access token to the upstream.
22
+ */
23
+ expiresAt?: string;
17
24
  }
18
25
 
19
26
  export interface PaymentConfig {
@@ -322,7 +329,7 @@ export class BuyerStore {
322
329
 
323
330
  public getToken(sellerKey: string): CachedToken | undefined {
324
331
  const stmt = this.db.prepare(
325
- "SELECT token, balance_micros, reserved_micros, spent_micros, balance_source FROM token_cache WHERE seller_key = ?"
332
+ "SELECT token, balance_micros, reserved_micros, spent_micros, balance_source, expires_at FROM token_cache WHERE seller_key = ?"
326
333
  );
327
334
  const row = stmt.get(sellerKey) as {
328
335
  token: string;
@@ -330,6 +337,7 @@ export class BuyerStore {
330
337
  reserved_micros: number;
331
338
  spent_micros: number;
332
339
  balance_source: string | null;
340
+ expires_at: string | null;
333
341
  } | undefined;
334
342
  if (!row) {
335
343
  return undefined;
@@ -339,7 +347,8 @@ export class BuyerStore {
339
347
  balanceMicros: row.balance_micros,
340
348
  reservedMicros: row.reserved_micros,
341
349
  spentMicros: row.spent_micros,
342
- balanceSource: row.balance_source || undefined
350
+ balanceSource: row.balance_source || undefined,
351
+ expiresAt: row.expires_at || undefined
343
352
  };
344
353
  }
345
354
 
@@ -768,4 +777,25 @@ export class BuyerStore {
768
777
  const row = this.db.prepare(`SELECT COUNT(*) AS count FROM ${tableName}`).get() as { count: number };
769
778
  return row.count;
770
779
  }
780
+
781
+ /**
782
+ * v1.2 §18.4: aggregate inference-ledger rows from the last `days`
783
+ * window and return the top `limit` most-used model ids. The focus-set
784
+ * builder uses this when no explicit warmup configuration is provided.
785
+ */
786
+ public recentModels(days: number, limit: number): string[] {
787
+ if (days <= 0 || limit <= 0) {
788
+ return [];
789
+ }
790
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
791
+ const rows = this.db.prepare(
792
+ `SELECT model_id, COUNT(*) AS uses
793
+ FROM inference_ledger
794
+ WHERE created_at >= ? AND model_id IS NOT NULL AND model_id != ''
795
+ GROUP BY model_id
796
+ ORDER BY uses DESC, model_id ASC
797
+ LIMIT ?`
798
+ ).all(cutoff, limit) as Array<{ model_id: string; uses: number }>;
799
+ return rows.map((row) => row.model_id);
800
+ }
771
801
  }
package/src/cli.ts CHANGED
@@ -714,6 +714,9 @@ export function buildCli(): Command {
714
714
  providers,
715
715
  sellerRegistryUrl: daemonStatus?.sellerRegistryUrl,
716
716
  });
717
+ const v12Snapshot = daemonRunning
718
+ ? await fetchV12Snapshot(controlUrl)
719
+ : null;
717
720
  console.log(JSON.stringify({
718
721
  daemon: {
719
722
  running: daemonRunning,
@@ -733,6 +736,7 @@ export function buildCli(): Command {
733
736
  plistPath,
734
737
  plistExists: plistPath ? fs.existsSync(plistPath) : false
735
738
  },
739
+ v12: v12Snapshot,
736
740
  ...diagnostics,
737
741
  }, null, 2));
738
742
  return;
@@ -791,8 +795,65 @@ export function buildCli(): Command {
791
795
  sellerRegistryUrl: daemonStatus?.sellerRegistryUrl,
792
796
  providers,
793
797
  });
798
+
799
+ // v1.2 §18.11: append the prewarm cache / pool / credit summary
800
+ // block so users can see at a glance how much of their budget is
801
+ // being burned by failover and which sellers are in circuit_open.
802
+ if (daemonRunning) {
803
+ await renderDoctorV12Section(controlUrl);
804
+ }
794
805
  });
795
806
 
807
+ // v1.2 §18.11 helpers for `tb doctor` / `tb doctor --json`.
808
+ async function fetchV12Snapshot(controlUrl: string): Promise<unknown | null> {
809
+ try {
810
+ const res = await fetch(`${controlUrl}/v1.2/prewarm`);
811
+ if (!res.ok) {
812
+ return null;
813
+ }
814
+ return await res.json();
815
+ } catch {
816
+ return null;
817
+ }
818
+ }
819
+
820
+ async function renderDoctorV12Section(controlUrl: string): Promise<void> {
821
+ try {
822
+ const res = await fetch(`${controlUrl}/v1.2/prewarm`);
823
+ if (!res.ok) {
824
+ return;
825
+ }
826
+ const snapshot = (await res.json()) as {
827
+ prewarm: { entries: Array<{ modelId: string; state: string; candidateCount: number; warmedAt: number; ttlMs: number; consecutiveWarmingFailures: number }>; size: number };
828
+ pool: { size: number; entries: Array<{ sellerId: string; circuit: string; healthScore: number }> };
829
+ credit: { totalWastedMicros: number; wastedSinceLastDoctorRun: number; purchasesInLastMinute: number; purchaseBudgetPerMinute: number; perSeller: Array<{ sellerId: string; currentBalanceMicros: number; leftoverCreditMicros: number }> };
830
+ focusSet: string[];
831
+ scheduler: { inFlight: number; queueDepth: number; totalSucceeded: number; totalFailed: number };
832
+ };
833
+ console.log("");
834
+ console.log("=== v1.2 Fallback Pipeline ===");
835
+ console.log(`Focus Set: ${snapshot.focusSet.length === 0 ? "(empty; using lazy prewarms)" : snapshot.focusSet.join(", ")}`);
836
+ console.log(`Prewarm Cache: ${snapshot.prewarm.size} entries`);
837
+ for (const entry of snapshot.prewarm.entries.slice(0, 10)) {
838
+ console.log(` - ${entry.modelId} [${entry.state}] ${entry.candidateCount} candidates, age ${Math.max(0, Date.now() - entry.warmedAt)}ms / ttl ${entry.ttlMs}ms`);
839
+ }
840
+ const open = snapshot.pool.entries.filter((e) => e.circuit !== "closed");
841
+ console.log(`Seller Pool: ${snapshot.pool.size} entries, ${open.length} non-closed`);
842
+ for (const entry of open.slice(0, 5)) {
843
+ console.log(` - ${entry.sellerId} [${entry.circuit}] healthScore=${entry.healthScore}`);
844
+ }
845
+ console.log(`Credit: totalWasted=${snapshot.credit.totalWastedMicros}μ, sinceLastDoctor=${snapshot.credit.wastedSinceLastDoctorRun}μ, purchasesInLastMinute=${snapshot.credit.purchasesInLastMinute}/${snapshot.credit.purchaseBudgetPerMinute}`);
846
+ for (const seller of snapshot.credit.perSeller.slice(0, 5)) {
847
+ console.log(` - ${seller.sellerId} balance=${seller.currentBalanceMicros}μ, leftover=${seller.leftoverCreditMicros}μ`);
848
+ }
849
+ console.log(`Scheduler: inFlight=${snapshot.scheduler.inFlight}, queueDepth=${snapshot.scheduler.queueDepth}, succeeded=${snapshot.scheduler.totalSucceeded}, failed=${snapshot.scheduler.totalFailed}`);
850
+ } catch (err) {
851
+ // Doctor must not fail because of an optional section.
852
+ const message = err instanceof Error ? err.message : String(err);
853
+ console.log(`\n(v1.2 snapshot unavailable: ${message})`);
854
+ }
855
+ }
856
+
796
857
  // 2. tb payment
797
858
  const payment = program.command("payment").description("Manage payment methods");
798
859