takomi 2.1.31 → 2.1.33
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/.pi/extensions/oauth-router/README.md +2 -1
- package/.pi/extensions/oauth-router/config.ts +14 -5
- package/.pi/extensions/oauth-router/index.ts +105 -16
- package/.pi/extensions/oauth-router/provider.ts +144 -3
- package/.pi/extensions/oauth-router/state.ts +372 -372
- package/.pi/extensions/oauth-router/types.ts +19 -1
- package/.pi/extensions/takomi-runtime/model-routing-defaults.ts +296 -296
- package/assets/.agent/skills/photo-book-builder/SKILL.md +96 -0
- package/assets/.agent/skills/photo-book-builder/references/layout_templates.md +72 -0
- package/assets/.agent/skills/photo-book-builder/scripts/create_full_bleed_layouts.py +212 -0
- package/assets/.agent/skills/photo-book-builder/scripts/organize_photos.py +99 -0
- package/assets/.agent/skills/photo-book-builder/scripts/revert_organization.py +61 -0
- package/assets/.agent/skills/photo-book-builder/scripts/upscale_covers.py +47 -0
- package/assets/.agent/skills/youtube-pipeline/SKILL.md +73 -62
- package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Application.md +1 -0
- package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Phase 1.md +86 -0
- package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Phase 2.md +106 -0
- package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Phase 3.md +112 -0
- package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Phase 4.md +90 -0
- package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Phased Outline.md +58 -0
- package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Repurposing.md +1 -0
- package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Research.md +438 -0
- package/assets/.agent/skills/youtube-pipeline/resources/prompts/Shorts Bridge Protocol.md +159 -0
- package/assets/.agent/skills/youtube-pipeline/resources/prompts/Title Thumbnail Picker Prompt.md +144 -0
- package/assets/.agent/skills/youtube-pipeline/resources/prompts/Transcription Extraction Prompt v2.md +190 -0
- package/assets/.agent/skills/youtube-pipeline/resources/prompts/Transcription Extraction Prompt.md +156 -0
- package/assets/.agent/skills/youtube-pipeline/resources/prompts/Video QA Prompt.md +133 -0
- package/assets/.agent/skills/youtube-pipeline/resources/youtube-phase1-strategy.md +28 -18
- package/assets/.agent/skills/youtube-pipeline/resources/youtube-phase2-packaging.md +2 -2
- package/assets/.agent/skills/youtube-pipeline/resources/youtube-phase3-scripting.md +2 -2
- package/assets/.agent/skills/youtube-pipeline/resources/youtube-phase3.5-shorts.md +2 -2
- package/assets/.agent/skills/youtube-pipeline/resources/youtube-phase4-production.md +2 -2
- package/assets/.agent/skills/youtube-pipeline/resources/youtube-pipeline.md +15 -15
- package/assets/.agent/skills/youtube-pipeline/scripts/google-trends/package.json +17 -0
- package/assets/.agent/skills/youtube-pipeline/scripts/google-trends/pnpm-lock.yaml +31 -0
- package/assets/.agent/skills/youtube-pipeline/scripts/google-trends/search.js +168 -0
- package/package.json +1 -1
- package/src/cli.js +5 -0
- package/src/pi-harness.js +34 -8
- package/src/pi-optional-features.js +190 -195
- package/src/postinstall.js +27 -27
- package/src/skills-catalog.js +245 -245
- package/src/skills-installer.js +244 -244
- package/src/skills-selection-tui.js +200 -200
- package/src/store.js +418 -418
- package/src/takomi-stats.d.ts +3 -3
- package/src/takomi-stats.js +442 -35
|
@@ -1,372 +1,372 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { STATE_PATH, withJsonFileLock, writeJsonFile } from "./config.ts";
|
|
3
|
-
import type {
|
|
4
|
-
RouterAccountState,
|
|
5
|
-
RouterProviderUsageSnapshot,
|
|
6
|
-
RouterRuntimeState,
|
|
7
|
-
RouterUsageSample,
|
|
8
|
-
RouterUsageSummary,
|
|
9
|
-
RouterUsageWindowSummary,
|
|
10
|
-
RoutingPolicyName,
|
|
11
|
-
} from "./types.ts";
|
|
12
|
-
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
13
|
-
|
|
14
|
-
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
|
15
|
-
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
16
|
-
const USAGE_RETENTION_MS = WEEK_MS + 24 * 60 * 60 * 1000;
|
|
17
|
-
|
|
18
|
-
const DEFAULT_STATE: RouterRuntimeState = {
|
|
19
|
-
version: 1,
|
|
20
|
-
policy: "round-robin",
|
|
21
|
-
rrCursor: 0,
|
|
22
|
-
weightedCursor: 0,
|
|
23
|
-
accounts: {},
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
function finiteNumber(value: unknown, fallback = 0): number {
|
|
27
|
-
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function optionalFiniteNumber(value: unknown): number | undefined {
|
|
31
|
-
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function normalizeUsageSample(input: Partial<RouterUsageSample>): RouterUsageSample | undefined {
|
|
35
|
-
const at = finiteNumber(input.at, 0);
|
|
36
|
-
if (!at) return undefined;
|
|
37
|
-
return {
|
|
38
|
-
at,
|
|
39
|
-
model: typeof input.model === "string" && input.model ? input.model : "unknown",
|
|
40
|
-
status: optionalFiniteNumber(input.status),
|
|
41
|
-
input: finiteNumber(input.input),
|
|
42
|
-
output: finiteNumber(input.output),
|
|
43
|
-
cacheRead: finiteNumber(input.cacheRead),
|
|
44
|
-
cacheWrite: finiteNumber(input.cacheWrite),
|
|
45
|
-
totalTokens: finiteNumber(input.totalTokens),
|
|
46
|
-
costTotal: finiteNumber(input.costTotal),
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function normalizeUsageSamples(input: unknown): RouterUsageSample[] {
|
|
51
|
-
if (!Array.isArray(input)) return [];
|
|
52
|
-
const cutoff = Date.now() - USAGE_RETENTION_MS;
|
|
53
|
-
return input
|
|
54
|
-
.map((sample) => normalizeUsageSample(sample as Partial<RouterUsageSample>))
|
|
55
|
-
.filter((sample): sample is RouterUsageSample => Boolean(sample))
|
|
56
|
-
.filter((sample) => sample.at >= cutoff)
|
|
57
|
-
.sort((a, b) => a.at - b.at);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function stripQuotaRaw(window: RouterProviderUsageSnapshot["fiveHour"]): RouterProviderUsageSnapshot["fiveHour"] {
|
|
61
|
-
if (!window) return undefined;
|
|
62
|
-
const { raw: _raw, ...safeWindow } = window;
|
|
63
|
-
return safeWindow;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function normalizeProviderUsage(input: unknown): RouterProviderUsageSnapshot | undefined {
|
|
67
|
-
if (!input || typeof input !== "object") return undefined;
|
|
68
|
-
const snapshot = input as Partial<RouterProviderUsageSnapshot>;
|
|
69
|
-
const fetchedAt = optionalFiniteNumber(snapshot.fetchedAt);
|
|
70
|
-
if (!fetchedAt) return undefined;
|
|
71
|
-
const source =
|
|
72
|
-
snapshot.source === "token-claims" || snapshot.source === "provider" || snapshot.source === "local" || snapshot.source === "unavailable"
|
|
73
|
-
? snapshot.source
|
|
74
|
-
: "unavailable";
|
|
75
|
-
const { raw: _raw, ...safeSnapshot } = snapshot;
|
|
76
|
-
return {
|
|
77
|
-
...safeSnapshot,
|
|
78
|
-
fetchedAt,
|
|
79
|
-
source,
|
|
80
|
-
fiveHour: stripQuotaRaw(snapshot.fiveHour),
|
|
81
|
-
weekly: stripQuotaRaw(snapshot.weekly),
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function normalizeAccountState(accountId: string, input?: Partial<RouterAccountState>): RouterAccountState {
|
|
86
|
-
return {
|
|
87
|
-
accountId,
|
|
88
|
-
authHealth: input?.authHealth === "invalid" ? "invalid" : "ok",
|
|
89
|
-
cooldownUntil: optionalFiniteNumber(input?.cooldownUntil),
|
|
90
|
-
penaltyUntil: optionalFiniteNumber(input?.penaltyUntil),
|
|
91
|
-
lastUsedAt: optionalFiniteNumber(input?.lastUsedAt),
|
|
92
|
-
lastTriedAt: optionalFiniteNumber(input?.lastTriedAt),
|
|
93
|
-
lastModel: typeof input?.lastModel === "string" ? input.lastModel : undefined,
|
|
94
|
-
lastStatus: optionalFiniteNumber(input?.lastStatus),
|
|
95
|
-
lastError: typeof input?.lastError === "string" ? input.lastError : undefined,
|
|
96
|
-
failures: finiteNumber(input?.failures),
|
|
97
|
-
rateLimitCount: finiteNumber(input?.rateLimitCount),
|
|
98
|
-
authFailureCount: finiteNumber(input?.authFailureCount),
|
|
99
|
-
successCount: finiteNumber(input?.successCount),
|
|
100
|
-
usageSamples: normalizeUsageSamples(input?.usageSamples),
|
|
101
|
-
providerUsage: normalizeProviderUsage(input?.providerUsage),
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function isAbortLikeError(message?: string): boolean {
|
|
106
|
-
const lower = (message ?? "").toLowerCase();
|
|
107
|
-
return lower.includes("abort") || lower.includes("cancelled") || lower.includes("canceled");
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function usageFromMessage(usage: AssistantMessage["usage"]): Omit<RouterUsageSample, "at" | "model" | "status"> {
|
|
111
|
-
return {
|
|
112
|
-
input: finiteNumber(usage?.input),
|
|
113
|
-
output: finiteNumber(usage?.output),
|
|
114
|
-
cacheRead: finiteNumber(usage?.cacheRead),
|
|
115
|
-
cacheWrite: finiteNumber(usage?.cacheWrite),
|
|
116
|
-
totalTokens: finiteNumber(usage?.totalTokens),
|
|
117
|
-
costTotal: finiteNumber(usage?.cost?.total),
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function summarizeWindow(label: RouterUsageWindowSummary["label"], samples: RouterUsageSample[], windowMs: number, until = Date.now()): RouterUsageWindowSummary {
|
|
122
|
-
const since = until - windowMs;
|
|
123
|
-
const selected = samples.filter((sample) => sample.at >= since && sample.at <= until);
|
|
124
|
-
return selected.reduce<RouterUsageWindowSummary>(
|
|
125
|
-
(summary, sample) => ({
|
|
126
|
-
...summary,
|
|
127
|
-
requests: summary.requests + 1,
|
|
128
|
-
input: summary.input + sample.input,
|
|
129
|
-
output: summary.output + sample.output,
|
|
130
|
-
cacheRead: summary.cacheRead + sample.cacheRead,
|
|
131
|
-
cacheWrite: summary.cacheWrite + sample.cacheWrite,
|
|
132
|
-
totalTokens: summary.totalTokens + sample.totalTokens,
|
|
133
|
-
costTotal: summary.costTotal + sample.costTotal,
|
|
134
|
-
}),
|
|
135
|
-
{ label, since, until, requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, costTotal: 0 },
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export class RouterStateStore {
|
|
140
|
-
private data: RouterRuntimeState;
|
|
141
|
-
|
|
142
|
-
constructor(initialPolicy: RoutingPolicyName) {
|
|
143
|
-
this.data = this.load(initialPolicy);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
private load(initialPolicy: RoutingPolicyName): RouterRuntimeState {
|
|
147
|
-
if (!existsSync(STATE_PATH)) {
|
|
148
|
-
const initial = { ...DEFAULT_STATE, policy: initialPolicy } satisfies RouterRuntimeState;
|
|
149
|
-
writeJsonFile(STATE_PATH, initial, true);
|
|
150
|
-
return initial;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
try {
|
|
154
|
-
const parsed = JSON.parse(readFileSync(STATE_PATH, "utf8")) as Partial<RouterRuntimeState>;
|
|
155
|
-
const accounts = Object.fromEntries(
|
|
156
|
-
Object.entries(parsed.accounts ?? {}).map(([accountId, state]) => [accountId, normalizeAccountState(accountId, state)]),
|
|
157
|
-
);
|
|
158
|
-
return {
|
|
159
|
-
version: 1,
|
|
160
|
-
policy:
|
|
161
|
-
parsed.policy === "weighted-round-robin" || parsed.policy === "round-robin" ? parsed.policy : initialPolicy,
|
|
162
|
-
rrCursor: typeof parsed.rrCursor === "number" && Number.isFinite(parsed.rrCursor) ? parsed.rrCursor : 0,
|
|
163
|
-
weightedCursor: typeof parsed.weightedCursor === "number" && Number.isFinite(parsed.weightedCursor) ? parsed.weightedCursor : 0,
|
|
164
|
-
accounts,
|
|
165
|
-
};
|
|
166
|
-
} catch {
|
|
167
|
-
const initial = { ...DEFAULT_STATE, policy: initialPolicy } satisfies RouterRuntimeState;
|
|
168
|
-
writeJsonFile(STATE_PATH, initial, true);
|
|
169
|
-
return initial;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
private save() {
|
|
174
|
-
writeJsonFile(STATE_PATH, this.data, true);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
private mutate<T>(fn: () => T): T {
|
|
178
|
-
return withJsonFileLock(STATE_PATH, () => {
|
|
179
|
-
this.data = this.load(this.data.policy);
|
|
180
|
-
const result = fn();
|
|
181
|
-
this.save();
|
|
182
|
-
return result;
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
private ensureAccountInMemory(accountId: string): RouterAccountState {
|
|
187
|
-
if (!this.data.accounts[accountId]) {
|
|
188
|
-
this.data.accounts[accountId] = normalizeAccountState(accountId);
|
|
189
|
-
}
|
|
190
|
-
return this.data.accounts[accountId]!;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
snapshot(): RouterRuntimeState {
|
|
194
|
-
return JSON.parse(JSON.stringify(this.data)) as RouterRuntimeState;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
getPolicy(defaultPolicy: RoutingPolicyName): RoutingPolicyName {
|
|
198
|
-
if (this.data.policy !== "round-robin" && this.data.policy !== "weighted-round-robin") {
|
|
199
|
-
return this.mutate(() => {
|
|
200
|
-
this.data.policy = defaultPolicy;
|
|
201
|
-
return this.data.policy;
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
return this.data.policy;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
setPolicy(policy: RoutingPolicyName) {
|
|
208
|
-
this.mutate(() => {
|
|
209
|
-
this.data.policy = policy;
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
getCursor(policy: RoutingPolicyName): number {
|
|
214
|
-
return policy === "weighted-round-robin" ? this.data.weightedCursor : this.data.rrCursor;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
advanceCursor(policy: RoutingPolicyName, next: number) {
|
|
218
|
-
this.mutate(() => {
|
|
219
|
-
if (policy === "weighted-round-robin") {
|
|
220
|
-
this.data.weightedCursor = next;
|
|
221
|
-
} else {
|
|
222
|
-
this.data.rrCursor = next;
|
|
223
|
-
}
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
ensureAccount(accountId: string): RouterAccountState {
|
|
228
|
-
return this.mutate(() => this.ensureAccountInMemory(accountId));
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
pruneAccountIds(validIds: string[]) {
|
|
232
|
-
this.mutate(() => {
|
|
233
|
-
const valid = new Set(validIds);
|
|
234
|
-
for (const id of Object.keys(this.data.accounts)) {
|
|
235
|
-
if (!valid.has(id)) delete this.data.accounts[id];
|
|
236
|
-
}
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
markAttempt(accountId: string, modelId: string) {
|
|
241
|
-
this.mutate(() => {
|
|
242
|
-
const account = this.ensureAccountInMemory(accountId);
|
|
243
|
-
account.lastTriedAt = Date.now();
|
|
244
|
-
account.lastModel = modelId;
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
markSuccess(accountId: string, status?: number) {
|
|
249
|
-
this.mutate(() => {
|
|
250
|
-
const account = this.ensureAccountInMemory(accountId);
|
|
251
|
-
account.authHealth = "ok";
|
|
252
|
-
account.lastUsedAt = Date.now();
|
|
253
|
-
account.lastStatus = status;
|
|
254
|
-
account.lastError = undefined;
|
|
255
|
-
account.penaltyUntil = undefined;
|
|
256
|
-
account.failures = 0;
|
|
257
|
-
account.successCount += 1;
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
recordUsage(accountId: string, modelId: string, usage: AssistantMessage["usage"], status?: number) {
|
|
262
|
-
this.mutate(() => {
|
|
263
|
-
const account = this.ensureAccountInMemory(accountId);
|
|
264
|
-
const cutoff = Date.now() - USAGE_RETENTION_MS;
|
|
265
|
-
account.usageSamples = [
|
|
266
|
-
...account.usageSamples.filter((sample) => sample.at >= cutoff),
|
|
267
|
-
{
|
|
268
|
-
at: Date.now(),
|
|
269
|
-
model: modelId,
|
|
270
|
-
status,
|
|
271
|
-
...usageFromMessage(usage),
|
|
272
|
-
},
|
|
273
|
-
];
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
getUsageSummary(accountId: string): RouterUsageSummary {
|
|
278
|
-
const account = this.ensureAccount(accountId);
|
|
279
|
-
const until = Date.now();
|
|
280
|
-
return {
|
|
281
|
-
accountId,
|
|
282
|
-
fiveHour: summarizeWindow("5h", account.usageSamples, FIVE_HOURS_MS, until),
|
|
283
|
-
weekly: summarizeWindow("weekly", account.usageSamples, WEEK_MS, until),
|
|
284
|
-
provider: account.providerUsage,
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
setProviderUsage(accountId: string, snapshot: RouterProviderUsageSnapshot) {
|
|
289
|
-
this.mutate(() => {
|
|
290
|
-
const account = this.ensureAccountInMemory(accountId);
|
|
291
|
-
account.providerUsage = snapshot;
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
resetUsage(accountId: string) {
|
|
296
|
-
this.mutate(() => {
|
|
297
|
-
const account = this.ensureAccountInMemory(accountId);
|
|
298
|
-
account.usageSamples = [];
|
|
299
|
-
account.providerUsage = undefined;
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
markRateLimit(accountId: string, retryAfterMs: number, status = 429, message = "Rate limited") {
|
|
304
|
-
this.mutate(() => {
|
|
305
|
-
const account = this.ensureAccountInMemory(accountId);
|
|
306
|
-
const now = Date.now();
|
|
307
|
-
account.lastStatus = status;
|
|
308
|
-
account.lastError = message;
|
|
309
|
-
account.cooldownUntil = now + Math.max(1_000, retryAfterMs);
|
|
310
|
-
account.failures += 1;
|
|
311
|
-
account.rateLimitCount += 1;
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
markAuthFailure(accountId: string, status = 401, message = "Authentication failed") {
|
|
316
|
-
this.mutate(() => {
|
|
317
|
-
const account = this.ensureAccountInMemory(accountId);
|
|
318
|
-
account.authHealth = "invalid";
|
|
319
|
-
account.lastStatus = status;
|
|
320
|
-
account.lastError = message;
|
|
321
|
-
account.failures += 1;
|
|
322
|
-
account.authFailureCount += 1;
|
|
323
|
-
account.cooldownUntil = undefined;
|
|
324
|
-
account.penaltyUntil = undefined;
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
markTransientFailure(accountId: string, penaltyMs: number, status = 500, message = "Transient upstream failure") {
|
|
329
|
-
this.mutate(() => {
|
|
330
|
-
const account = this.ensureAccountInMemory(accountId);
|
|
331
|
-
account.lastStatus = status;
|
|
332
|
-
account.lastError = message;
|
|
333
|
-
account.penaltyUntil = Date.now() + Math.max(1_000, penaltyMs);
|
|
334
|
-
account.failures += 1;
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
recordClientNetworkFailure(accountId: string, status: number | undefined, message = "Client/network transport failure") {
|
|
339
|
-
this.mutate(() => {
|
|
340
|
-
const account = this.ensureAccountInMemory(accountId);
|
|
341
|
-
account.lastStatus = status;
|
|
342
|
-
account.lastError = message;
|
|
343
|
-
account.failures += 1;
|
|
344
|
-
account.penaltyUntil = undefined;
|
|
345
|
-
account.cooldownUntil = undefined;
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
clearHealth(accountId: string) {
|
|
350
|
-
this.mutate(() => {
|
|
351
|
-
const account = this.ensureAccountInMemory(accountId);
|
|
352
|
-
account.authHealth = "ok";
|
|
353
|
-
account.cooldownUntil = undefined;
|
|
354
|
-
account.penaltyUntil = undefined;
|
|
355
|
-
account.lastError = undefined;
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
clearAbortHealth(accountIds: string[]) {
|
|
360
|
-
this.mutate(() => {
|
|
361
|
-
for (const accountId of accountIds) {
|
|
362
|
-
const account = this.ensureAccountInMemory(accountId);
|
|
363
|
-
if (!isAbortLikeError(account.lastError)) continue;
|
|
364
|
-
account.authHealth = "ok";
|
|
365
|
-
account.cooldownUntil = undefined;
|
|
366
|
-
account.penaltyUntil = undefined;
|
|
367
|
-
account.lastError = undefined;
|
|
368
|
-
account.lastStatus = undefined;
|
|
369
|
-
}
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
}
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { STATE_PATH, withJsonFileLock, writeJsonFile } from "./config.ts";
|
|
3
|
+
import type {
|
|
4
|
+
RouterAccountState,
|
|
5
|
+
RouterProviderUsageSnapshot,
|
|
6
|
+
RouterRuntimeState,
|
|
7
|
+
RouterUsageSample,
|
|
8
|
+
RouterUsageSummary,
|
|
9
|
+
RouterUsageWindowSummary,
|
|
10
|
+
RoutingPolicyName,
|
|
11
|
+
} from "./types.ts";
|
|
12
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
13
|
+
|
|
14
|
+
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
|
15
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
16
|
+
const USAGE_RETENTION_MS = WEEK_MS + 24 * 60 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
const DEFAULT_STATE: RouterRuntimeState = {
|
|
19
|
+
version: 1,
|
|
20
|
+
policy: "round-robin",
|
|
21
|
+
rrCursor: 0,
|
|
22
|
+
weightedCursor: 0,
|
|
23
|
+
accounts: {},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function finiteNumber(value: unknown, fallback = 0): number {
|
|
27
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function optionalFiniteNumber(value: unknown): number | undefined {
|
|
31
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeUsageSample(input: Partial<RouterUsageSample>): RouterUsageSample | undefined {
|
|
35
|
+
const at = finiteNumber(input.at, 0);
|
|
36
|
+
if (!at) return undefined;
|
|
37
|
+
return {
|
|
38
|
+
at,
|
|
39
|
+
model: typeof input.model === "string" && input.model ? input.model : "unknown",
|
|
40
|
+
status: optionalFiniteNumber(input.status),
|
|
41
|
+
input: finiteNumber(input.input),
|
|
42
|
+
output: finiteNumber(input.output),
|
|
43
|
+
cacheRead: finiteNumber(input.cacheRead),
|
|
44
|
+
cacheWrite: finiteNumber(input.cacheWrite),
|
|
45
|
+
totalTokens: finiteNumber(input.totalTokens),
|
|
46
|
+
costTotal: finiteNumber(input.costTotal),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeUsageSamples(input: unknown): RouterUsageSample[] {
|
|
51
|
+
if (!Array.isArray(input)) return [];
|
|
52
|
+
const cutoff = Date.now() - USAGE_RETENTION_MS;
|
|
53
|
+
return input
|
|
54
|
+
.map((sample) => normalizeUsageSample(sample as Partial<RouterUsageSample>))
|
|
55
|
+
.filter((sample): sample is RouterUsageSample => Boolean(sample))
|
|
56
|
+
.filter((sample) => sample.at >= cutoff)
|
|
57
|
+
.sort((a, b) => a.at - b.at);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function stripQuotaRaw(window: RouterProviderUsageSnapshot["fiveHour"]): RouterProviderUsageSnapshot["fiveHour"] {
|
|
61
|
+
if (!window) return undefined;
|
|
62
|
+
const { raw: _raw, ...safeWindow } = window;
|
|
63
|
+
return safeWindow;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeProviderUsage(input: unknown): RouterProviderUsageSnapshot | undefined {
|
|
67
|
+
if (!input || typeof input !== "object") return undefined;
|
|
68
|
+
const snapshot = input as Partial<RouterProviderUsageSnapshot>;
|
|
69
|
+
const fetchedAt = optionalFiniteNumber(snapshot.fetchedAt);
|
|
70
|
+
if (!fetchedAt) return undefined;
|
|
71
|
+
const source =
|
|
72
|
+
snapshot.source === "token-claims" || snapshot.source === "provider" || snapshot.source === "local" || snapshot.source === "unavailable"
|
|
73
|
+
? snapshot.source
|
|
74
|
+
: "unavailable";
|
|
75
|
+
const { raw: _raw, ...safeSnapshot } = snapshot;
|
|
76
|
+
return {
|
|
77
|
+
...safeSnapshot,
|
|
78
|
+
fetchedAt,
|
|
79
|
+
source,
|
|
80
|
+
fiveHour: stripQuotaRaw(snapshot.fiveHour),
|
|
81
|
+
weekly: stripQuotaRaw(snapshot.weekly),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeAccountState(accountId: string, input?: Partial<RouterAccountState>): RouterAccountState {
|
|
86
|
+
return {
|
|
87
|
+
accountId,
|
|
88
|
+
authHealth: input?.authHealth === "invalid" ? "invalid" : "ok",
|
|
89
|
+
cooldownUntil: optionalFiniteNumber(input?.cooldownUntil),
|
|
90
|
+
penaltyUntil: optionalFiniteNumber(input?.penaltyUntil),
|
|
91
|
+
lastUsedAt: optionalFiniteNumber(input?.lastUsedAt),
|
|
92
|
+
lastTriedAt: optionalFiniteNumber(input?.lastTriedAt),
|
|
93
|
+
lastModel: typeof input?.lastModel === "string" ? input.lastModel : undefined,
|
|
94
|
+
lastStatus: optionalFiniteNumber(input?.lastStatus),
|
|
95
|
+
lastError: typeof input?.lastError === "string" ? input.lastError : undefined,
|
|
96
|
+
failures: finiteNumber(input?.failures),
|
|
97
|
+
rateLimitCount: finiteNumber(input?.rateLimitCount),
|
|
98
|
+
authFailureCount: finiteNumber(input?.authFailureCount),
|
|
99
|
+
successCount: finiteNumber(input?.successCount),
|
|
100
|
+
usageSamples: normalizeUsageSamples(input?.usageSamples),
|
|
101
|
+
providerUsage: normalizeProviderUsage(input?.providerUsage),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isAbortLikeError(message?: string): boolean {
|
|
106
|
+
const lower = (message ?? "").toLowerCase();
|
|
107
|
+
return lower.includes("abort") || lower.includes("cancelled") || lower.includes("canceled");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function usageFromMessage(usage: AssistantMessage["usage"]): Omit<RouterUsageSample, "at" | "model" | "status"> {
|
|
111
|
+
return {
|
|
112
|
+
input: finiteNumber(usage?.input),
|
|
113
|
+
output: finiteNumber(usage?.output),
|
|
114
|
+
cacheRead: finiteNumber(usage?.cacheRead),
|
|
115
|
+
cacheWrite: finiteNumber(usage?.cacheWrite),
|
|
116
|
+
totalTokens: finiteNumber(usage?.totalTokens),
|
|
117
|
+
costTotal: finiteNumber(usage?.cost?.total),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function summarizeWindow(label: RouterUsageWindowSummary["label"], samples: RouterUsageSample[], windowMs: number, until = Date.now()): RouterUsageWindowSummary {
|
|
122
|
+
const since = until - windowMs;
|
|
123
|
+
const selected = samples.filter((sample) => sample.at >= since && sample.at <= until);
|
|
124
|
+
return selected.reduce<RouterUsageWindowSummary>(
|
|
125
|
+
(summary, sample) => ({
|
|
126
|
+
...summary,
|
|
127
|
+
requests: summary.requests + 1,
|
|
128
|
+
input: summary.input + sample.input,
|
|
129
|
+
output: summary.output + sample.output,
|
|
130
|
+
cacheRead: summary.cacheRead + sample.cacheRead,
|
|
131
|
+
cacheWrite: summary.cacheWrite + sample.cacheWrite,
|
|
132
|
+
totalTokens: summary.totalTokens + sample.totalTokens,
|
|
133
|
+
costTotal: summary.costTotal + sample.costTotal,
|
|
134
|
+
}),
|
|
135
|
+
{ label, since, until, requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, costTotal: 0 },
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export class RouterStateStore {
|
|
140
|
+
private data: RouterRuntimeState;
|
|
141
|
+
|
|
142
|
+
constructor(initialPolicy: RoutingPolicyName) {
|
|
143
|
+
this.data = this.load(initialPolicy);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private load(initialPolicy: RoutingPolicyName): RouterRuntimeState {
|
|
147
|
+
if (!existsSync(STATE_PATH)) {
|
|
148
|
+
const initial = { ...DEFAULT_STATE, policy: initialPolicy } satisfies RouterRuntimeState;
|
|
149
|
+
writeJsonFile(STATE_PATH, initial, true);
|
|
150
|
+
return initial;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const parsed = JSON.parse(readFileSync(STATE_PATH, "utf8")) as Partial<RouterRuntimeState>;
|
|
155
|
+
const accounts = Object.fromEntries(
|
|
156
|
+
Object.entries(parsed.accounts ?? {}).map(([accountId, state]) => [accountId, normalizeAccountState(accountId, state)]),
|
|
157
|
+
);
|
|
158
|
+
return {
|
|
159
|
+
version: 1,
|
|
160
|
+
policy:
|
|
161
|
+
parsed.policy === "weighted-round-robin" || parsed.policy === "round-robin" ? parsed.policy : initialPolicy,
|
|
162
|
+
rrCursor: typeof parsed.rrCursor === "number" && Number.isFinite(parsed.rrCursor) ? parsed.rrCursor : 0,
|
|
163
|
+
weightedCursor: typeof parsed.weightedCursor === "number" && Number.isFinite(parsed.weightedCursor) ? parsed.weightedCursor : 0,
|
|
164
|
+
accounts,
|
|
165
|
+
};
|
|
166
|
+
} catch {
|
|
167
|
+
const initial = { ...DEFAULT_STATE, policy: initialPolicy } satisfies RouterRuntimeState;
|
|
168
|
+
writeJsonFile(STATE_PATH, initial, true);
|
|
169
|
+
return initial;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private save() {
|
|
174
|
+
writeJsonFile(STATE_PATH, this.data, true);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private mutate<T>(fn: () => T): T {
|
|
178
|
+
return withJsonFileLock(STATE_PATH, () => {
|
|
179
|
+
this.data = this.load(this.data.policy);
|
|
180
|
+
const result = fn();
|
|
181
|
+
this.save();
|
|
182
|
+
return result;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private ensureAccountInMemory(accountId: string): RouterAccountState {
|
|
187
|
+
if (!this.data.accounts[accountId]) {
|
|
188
|
+
this.data.accounts[accountId] = normalizeAccountState(accountId);
|
|
189
|
+
}
|
|
190
|
+
return this.data.accounts[accountId]!;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
snapshot(): RouterRuntimeState {
|
|
194
|
+
return JSON.parse(JSON.stringify(this.data)) as RouterRuntimeState;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
getPolicy(defaultPolicy: RoutingPolicyName): RoutingPolicyName {
|
|
198
|
+
if (this.data.policy !== "round-robin" && this.data.policy !== "weighted-round-robin") {
|
|
199
|
+
return this.mutate(() => {
|
|
200
|
+
this.data.policy = defaultPolicy;
|
|
201
|
+
return this.data.policy;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return this.data.policy;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
setPolicy(policy: RoutingPolicyName) {
|
|
208
|
+
this.mutate(() => {
|
|
209
|
+
this.data.policy = policy;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
getCursor(policy: RoutingPolicyName): number {
|
|
214
|
+
return policy === "weighted-round-robin" ? this.data.weightedCursor : this.data.rrCursor;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
advanceCursor(policy: RoutingPolicyName, next: number) {
|
|
218
|
+
this.mutate(() => {
|
|
219
|
+
if (policy === "weighted-round-robin") {
|
|
220
|
+
this.data.weightedCursor = next;
|
|
221
|
+
} else {
|
|
222
|
+
this.data.rrCursor = next;
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
ensureAccount(accountId: string): RouterAccountState {
|
|
228
|
+
return this.mutate(() => this.ensureAccountInMemory(accountId));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
pruneAccountIds(validIds: string[]) {
|
|
232
|
+
this.mutate(() => {
|
|
233
|
+
const valid = new Set(validIds);
|
|
234
|
+
for (const id of Object.keys(this.data.accounts)) {
|
|
235
|
+
if (!valid.has(id)) delete this.data.accounts[id];
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
markAttempt(accountId: string, modelId: string) {
|
|
241
|
+
this.mutate(() => {
|
|
242
|
+
const account = this.ensureAccountInMemory(accountId);
|
|
243
|
+
account.lastTriedAt = Date.now();
|
|
244
|
+
account.lastModel = modelId;
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
markSuccess(accountId: string, status?: number) {
|
|
249
|
+
this.mutate(() => {
|
|
250
|
+
const account = this.ensureAccountInMemory(accountId);
|
|
251
|
+
account.authHealth = "ok";
|
|
252
|
+
account.lastUsedAt = Date.now();
|
|
253
|
+
account.lastStatus = status;
|
|
254
|
+
account.lastError = undefined;
|
|
255
|
+
account.penaltyUntil = undefined;
|
|
256
|
+
account.failures = 0;
|
|
257
|
+
account.successCount += 1;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
recordUsage(accountId: string, modelId: string, usage: AssistantMessage["usage"], status?: number) {
|
|
262
|
+
this.mutate(() => {
|
|
263
|
+
const account = this.ensureAccountInMemory(accountId);
|
|
264
|
+
const cutoff = Date.now() - USAGE_RETENTION_MS;
|
|
265
|
+
account.usageSamples = [
|
|
266
|
+
...account.usageSamples.filter((sample) => sample.at >= cutoff),
|
|
267
|
+
{
|
|
268
|
+
at: Date.now(),
|
|
269
|
+
model: modelId,
|
|
270
|
+
status,
|
|
271
|
+
...usageFromMessage(usage),
|
|
272
|
+
},
|
|
273
|
+
];
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
getUsageSummary(accountId: string): RouterUsageSummary {
|
|
278
|
+
const account = this.ensureAccount(accountId);
|
|
279
|
+
const until = Date.now();
|
|
280
|
+
return {
|
|
281
|
+
accountId,
|
|
282
|
+
fiveHour: summarizeWindow("5h", account.usageSamples, FIVE_HOURS_MS, until),
|
|
283
|
+
weekly: summarizeWindow("weekly", account.usageSamples, WEEK_MS, until),
|
|
284
|
+
provider: account.providerUsage,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
setProviderUsage(accountId: string, snapshot: RouterProviderUsageSnapshot) {
|
|
289
|
+
this.mutate(() => {
|
|
290
|
+
const account = this.ensureAccountInMemory(accountId);
|
|
291
|
+
account.providerUsage = snapshot;
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
resetUsage(accountId: string) {
|
|
296
|
+
this.mutate(() => {
|
|
297
|
+
const account = this.ensureAccountInMemory(accountId);
|
|
298
|
+
account.usageSamples = [];
|
|
299
|
+
account.providerUsage = undefined;
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
markRateLimit(accountId: string, retryAfterMs: number, status = 429, message = "Rate limited") {
|
|
304
|
+
this.mutate(() => {
|
|
305
|
+
const account = this.ensureAccountInMemory(accountId);
|
|
306
|
+
const now = Date.now();
|
|
307
|
+
account.lastStatus = status;
|
|
308
|
+
account.lastError = message;
|
|
309
|
+
account.cooldownUntil = now + Math.max(1_000, retryAfterMs);
|
|
310
|
+
account.failures += 1;
|
|
311
|
+
account.rateLimitCount += 1;
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
markAuthFailure(accountId: string, status = 401, message = "Authentication failed") {
|
|
316
|
+
this.mutate(() => {
|
|
317
|
+
const account = this.ensureAccountInMemory(accountId);
|
|
318
|
+
account.authHealth = "invalid";
|
|
319
|
+
account.lastStatus = status;
|
|
320
|
+
account.lastError = message;
|
|
321
|
+
account.failures += 1;
|
|
322
|
+
account.authFailureCount += 1;
|
|
323
|
+
account.cooldownUntil = undefined;
|
|
324
|
+
account.penaltyUntil = undefined;
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
markTransientFailure(accountId: string, penaltyMs: number, status = 500, message = "Transient upstream failure") {
|
|
329
|
+
this.mutate(() => {
|
|
330
|
+
const account = this.ensureAccountInMemory(accountId);
|
|
331
|
+
account.lastStatus = status;
|
|
332
|
+
account.lastError = message;
|
|
333
|
+
account.penaltyUntil = Date.now() + Math.max(1_000, penaltyMs);
|
|
334
|
+
account.failures += 1;
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
recordClientNetworkFailure(accountId: string, status: number | undefined, message = "Client/network transport failure") {
|
|
339
|
+
this.mutate(() => {
|
|
340
|
+
const account = this.ensureAccountInMemory(accountId);
|
|
341
|
+
account.lastStatus = status;
|
|
342
|
+
account.lastError = message;
|
|
343
|
+
account.failures += 1;
|
|
344
|
+
account.penaltyUntil = undefined;
|
|
345
|
+
account.cooldownUntil = undefined;
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
clearHealth(accountId: string) {
|
|
350
|
+
this.mutate(() => {
|
|
351
|
+
const account = this.ensureAccountInMemory(accountId);
|
|
352
|
+
account.authHealth = "ok";
|
|
353
|
+
account.cooldownUntil = undefined;
|
|
354
|
+
account.penaltyUntil = undefined;
|
|
355
|
+
account.lastError = undefined;
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
clearAbortHealth(accountIds: string[]) {
|
|
360
|
+
this.mutate(() => {
|
|
361
|
+
for (const accountId of accountIds) {
|
|
362
|
+
const account = this.ensureAccountInMemory(accountId);
|
|
363
|
+
if (!isAbortLikeError(account.lastError)) continue;
|
|
364
|
+
account.authHealth = "ok";
|
|
365
|
+
account.cooldownUntil = undefined;
|
|
366
|
+
account.penaltyUntil = undefined;
|
|
367
|
+
account.lastError = undefined;
|
|
368
|
+
account.lastStatus = undefined;
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|