@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,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
package/src/buyer-store.ts
CHANGED
|
@@ -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
|
|