quotacap 0.0.34 → 0.0.35
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/README.md +1 -1
- package/dist/advisory/engine.d.ts +8 -3
- package/dist/advisory/engine.js +80 -48
- package/dist/advisory/snapshot.js +37 -21
- package/dist/advisory/types.d.ts +28 -2
- package/dist/advisory/types.js +3 -0
- package/dist/catalog/agy.d.ts +4 -0
- package/dist/catalog/agy.js +59 -0
- package/dist/catalog/claude.d.ts +3 -0
- package/dist/catalog/claude.js +70 -0
- package/dist/catalog/codex.d.ts +3 -0
- package/dist/catalog/codex.js +55 -0
- package/dist/catalog/grok.d.ts +3 -0
- package/dist/catalog/grok.js +36 -0
- package/dist/catalog/index.d.ts +17 -0
- package/dist/catalog/index.js +152 -0
- package/dist/catalog/kimi.d.ts +3 -0
- package/dist/catalog/kimi.js +51 -0
- package/dist/catalog/muse.d.ts +4 -0
- package/dist/catalog/muse.js +67 -0
- package/dist/catalog/types.d.ts +21 -0
- package/dist/catalog/types.js +3 -0
- package/dist/cli/advise.js +20 -0
- package/dist/cli/clients.d.ts +3 -0
- package/dist/cli/clients.js +2 -0
- package/dist/cli/models.d.ts +3 -0
- package/dist/cli/models.js +134 -0
- package/dist/cli/snapshot-source.js +1 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +2 -0
- package/dist/format/markdown.d.ts +1 -0
- package/dist/format/markdown.js +14 -0
- package/dist/format/rows.d.ts +1 -1
- package/dist/format/rows.js +24 -4
- package/dist/format/terminal.js +18 -1
- package/dist/http/server.d.ts +2 -0
- package/dist/http/server.js +100 -0
- package/dist/mcp/server.d.ts +13 -0
- package/dist/mcp/server.js +31 -3
- package/dist/runtime/poll.d.ts +13 -0
- package/dist/runtime/poll.js +34 -2
- package/dist/runtime/service.js +9 -0
- package/dist/store/catalogs.d.ts +10 -0
- package/dist/store/catalogs.js +64 -0
- package/dist/store/db.js +27 -1
- package/dist/store/quotas.d.ts +22 -0
- package/dist/store/quotas.js +93 -8
- package/dist/version.js +1 -1
- package/dist/web/src/components/ClosedWeeks.d.ts +16 -0
- package/dist/web/src/components/ClosedWeeks.js +102 -0
- package/dist/web/src/components/PaceBar.d.ts +17 -5
- package/dist/web/src/components/PaceBar.js +51 -8
- package/dist/web/src/components/PacingLegend.js +1 -0
- package/dist/web/src/components/ProviderCard.js +3 -1
- package/dist/web/src/components/ProviderDrawer.js +7 -1
- package/dist/web/src/components/ProviderRow.js +3 -1
- package/dist/web/src/components/Recommendation.js +12 -3
- package/dist/web/src/components/ResetRail.js +2 -0
- package/dist/web/src/state.d.ts +20 -1
- package/dist/web/src/state.js +1 -0
- package/dist/webAssets.js +1 -1
- package/dist/webHtml.js +1 -1
- package/package.json +1 -1
- package/web/dist/assets/index-Cm_QtC0G.js +12 -0
- package/web/dist/assets/index-DiOyUuQc.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CjNVp2Yx.css +0 -1
- package/web/dist/assets/index-irnos0UG.js +0 -12
package/dist/format/rows.js
CHANGED
|
@@ -61,7 +61,9 @@ export function railCells(ps, now) {
|
|
|
61
61
|
return cells;
|
|
62
62
|
}
|
|
63
63
|
// Shared STATE vocabulary (locked badge mapping): any exclusion reads
|
|
64
|
-
// Not reporting; at-risk status beats urgency
|
|
64
|
+
// Not reporting; at-risk status beats urgency, then watch, then unknown;
|
|
65
|
+
// on-track rows ahead of cumulative elapsed time revive Ahead of pace;
|
|
66
|
+
// urgency maps to the remaining pace words.
|
|
65
67
|
export function stateWord(ps) {
|
|
66
68
|
if (ps.exclusionReason !== null)
|
|
67
69
|
return "Not reporting";
|
|
@@ -70,8 +72,12 @@ export function stateWord(ps) {
|
|
|
70
72
|
return "Not reporting";
|
|
71
73
|
if (adv.status === "at risk")
|
|
72
74
|
return "Cap risk";
|
|
75
|
+
if (adv.status === "watch")
|
|
76
|
+
return "Watch";
|
|
73
77
|
if (adv.status === "unknown")
|
|
74
78
|
return "Measuring";
|
|
79
|
+
if (adv.aheadOfElapsed === true)
|
|
80
|
+
return "Ahead of pace";
|
|
75
81
|
switch (adv.urgency) {
|
|
76
82
|
case "burn now":
|
|
77
83
|
case "use soon":
|
|
@@ -124,6 +130,12 @@ export function forecastText(ps, now) {
|
|
|
124
130
|
else if (adv.paceSource === "unknown" || adv.burnRate === null) {
|
|
125
131
|
text = `Measuring pace; ${Math.round(adv.remaining)}% remains with ${adv.daysLeft.toFixed(1)}d until reset`;
|
|
126
132
|
}
|
|
133
|
+
else if (adv.status === "watch") {
|
|
134
|
+
text =
|
|
135
|
+
adv.daysToExhaust !== null && Number.isFinite(adv.daysToExhaust)
|
|
136
|
+
? `Exhausts in ${adv.daysToExhaust.toFixed(1)}d — watch`
|
|
137
|
+
: "Near the cap — watch";
|
|
138
|
+
}
|
|
127
139
|
else if (adv.wastePct !== null && adv.wastePct > 0) {
|
|
128
140
|
text = `${Math.round(adv.wastePct)}% waste in ${adv.daysLeft.toFixed(1)}d`;
|
|
129
141
|
}
|
|
@@ -141,8 +153,8 @@ export function forecastText(ps, now) {
|
|
|
141
153
|
}
|
|
142
154
|
return text;
|
|
143
155
|
}
|
|
144
|
-
// Compact legend: none = On track; ! = Cap risk; ~ = Behind/Ahead
|
|
145
|
-
// estimated reset; ? = unknown pace or any excluded row.
|
|
156
|
+
// Compact legend: none = On track; ! = Cap risk; ~ = Watch or Behind/Ahead
|
|
157
|
+
// pace or estimated reset; ? = unknown pace or any excluded row.
|
|
146
158
|
export function compactSuffix(ps) {
|
|
147
159
|
if (ps.exclusionReason !== null)
|
|
148
160
|
return "?";
|
|
@@ -151,6 +163,8 @@ export function compactSuffix(ps) {
|
|
|
151
163
|
return "?";
|
|
152
164
|
if (adv.status === "at risk")
|
|
153
165
|
return "!";
|
|
166
|
+
if (adv.status === "watch")
|
|
167
|
+
return "~";
|
|
154
168
|
if (adv.urgency !== "on track" || ps.quota?.resetsAtEstimated)
|
|
155
169
|
return "~";
|
|
156
170
|
return "";
|
|
@@ -243,7 +257,13 @@ export function paceText(ps) {
|
|
|
243
257
|
return "—";
|
|
244
258
|
// avgPace is absent on advisories from a daemon older than the split.
|
|
245
259
|
const avg = adv.avgPace ?? null;
|
|
246
|
-
|
|
260
|
+
// The 24h figure is the measured recentRate, never the blended forecast
|
|
261
|
+
// input; an older daemon omits the field and burnRate was the raw rate then.
|
|
262
|
+
const recent = adv.recentRate !== undefined
|
|
263
|
+
? adv.recentRate
|
|
264
|
+
: adv.paceSource === "recent"
|
|
265
|
+
? adv.burnRate
|
|
266
|
+
: null;
|
|
247
267
|
if (avg !== null && recent !== null && avg.toFixed(1) !== recent.toFixed(1)) {
|
|
248
268
|
return `${avg.toFixed(1)} avg · ${recent.toFixed(1)} 24h`;
|
|
249
269
|
}
|
package/dist/format/terminal.js
CHANGED
|
@@ -28,6 +28,7 @@ const STATE_COLORS = {
|
|
|
28
28
|
"On track": "\x1b[32m",
|
|
29
29
|
"Behind pace": "\x1b[33m",
|
|
30
30
|
"Ahead of pace": "\x1b[93m",
|
|
31
|
+
"Watch": "\x1b[38;5;214m",
|
|
31
32
|
"Cap risk": "\x1b[31m",
|
|
32
33
|
"Measuring": "\x1b[90m",
|
|
33
34
|
"Not reporting": "\x1b[90m",
|
|
@@ -76,6 +77,14 @@ export function renderWide(snapshot, opts) {
|
|
|
76
77
|
const rows = sorted.map((p) => ({ p, row: buildRow(p, opts.now) }));
|
|
77
78
|
const paceWidth = Math.max("PACE (%/DAY)".length, ...rows.map(({ row }) => row.pace.length));
|
|
78
79
|
const paint = (s, code) => (color ? `${code}${s}${RESET}` : s);
|
|
80
|
+
const resetsIndent = " ".repeat(width + GAP.length + 6 + GAP.length + 8 + GAP.length + paceWidth + GAP.length + 20 + GAP.length);
|
|
81
|
+
const leftoverLine = (p) => {
|
|
82
|
+
const close = p.lastCloses?.[0];
|
|
83
|
+
if (!close)
|
|
84
|
+
return null;
|
|
85
|
+
const tail = `last week ${close.leftoverPct}% leftover${close.resetsAtEstimated ? " (est.)" : ""}`;
|
|
86
|
+
return `${resetsIndent}${paint(tail, DIM)}`;
|
|
87
|
+
};
|
|
79
88
|
const header = [
|
|
80
89
|
"PROVIDER".padEnd(width),
|
|
81
90
|
"USED".padStart(6),
|
|
@@ -96,7 +105,7 @@ export function renderWide(snapshot, opts) {
|
|
|
96
105
|
const countdown = (opts.ascii ? asciiText(row.countdown) : row.countdown).padEnd(9);
|
|
97
106
|
const state = row.state.padEnd(15);
|
|
98
107
|
const forecast = opts.ascii ? asciiText(row.forecast) : row.forecast;
|
|
99
|
-
|
|
108
|
+
const main = [
|
|
100
109
|
color ? colorProviderField(name, recommended, row.state) : name,
|
|
101
110
|
used,
|
|
102
111
|
elapsed,
|
|
@@ -106,6 +115,8 @@ export function renderWide(snapshot, opts) {
|
|
|
106
115
|
paint(state, STATE_COLORS[row.state]),
|
|
107
116
|
forecast,
|
|
108
117
|
].join(GAP);
|
|
118
|
+
const leftover = leftoverLine(p);
|
|
119
|
+
return leftover ? `${main}\n${leftover}` : main;
|
|
109
120
|
});
|
|
110
121
|
return [paint(header, DIM), ...lines].join("\n");
|
|
111
122
|
}
|
|
@@ -129,6 +140,12 @@ export function renderNarrow(snapshot, opts) {
|
|
|
129
140
|
const paceBits = row.pace === "—" ? pace : `${pace} %/day`;
|
|
130
141
|
const state = color ? `${STATE_COLORS[row.state]}${row.state}${RESET}` : row.state;
|
|
131
142
|
lines.push(`${color ? colorProviderField(name, recommended, row.state) : name} ${used} used ${g.sep} ${elapsed} elapsed ${g.sep} ${paceBits}`, `${rail} resets ${countdown} ${g.sep} ${state} ${g.sep} ${forecast}`);
|
|
143
|
+
const close = p.lastCloses?.[0];
|
|
144
|
+
if (close) {
|
|
145
|
+
const indent = " ".repeat(row.railCells.length + " resets ".length);
|
|
146
|
+
const tail = `last week ${close.leftoverPct}% leftover${close.resetsAtEstimated ? " (est.)" : ""}`;
|
|
147
|
+
lines.push(`${indent}${color ? `${DIM}${tail}${RESET}` : tail}`);
|
|
148
|
+
}
|
|
132
149
|
}
|
|
133
150
|
return lines.join("\n");
|
|
134
151
|
}
|
package/dist/http/server.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface RuntimeContext {
|
|
|
30
30
|
exec?: string;
|
|
31
31
|
}) => void;
|
|
32
32
|
configPath?: string;
|
|
33
|
+
catalogTtlHours?: number;
|
|
34
|
+
catalogFetchers?: Record<string, any>;
|
|
33
35
|
}
|
|
34
36
|
export declare function testCtx(db: any, overrides?: Partial<RuntimeContext>): RuntimeContext;
|
|
35
37
|
export declare function buildApp(ctx: RuntimeContext): FastifyInstance;
|
package/dist/http/server.js
CHANGED
|
@@ -16,6 +16,8 @@ import { classifyFailure } from "../diagnostics/failure.js";
|
|
|
16
16
|
import { validateDisplayName } from "../advisory/validation.js";
|
|
17
17
|
import { providerIdentity } from "../advisory/provider-names.js";
|
|
18
18
|
import { setProviderNameOverride } from "../config.js";
|
|
19
|
+
import { ensureCatalogs, catalogProviders, CATALOG_FAIL_COOLDOWN_MS } from "../catalog/index.js";
|
|
20
|
+
import { getAllLatest } from "../store/quotas.js";
|
|
19
21
|
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
|
|
20
22
|
export function hostnameFromHostHeader(hostHeader) {
|
|
21
23
|
if (typeof hostHeader !== "string" || !hostHeader.trim())
|
|
@@ -208,6 +210,104 @@ export function buildApp(ctx) {
|
|
|
208
210
|
};
|
|
209
211
|
}
|
|
210
212
|
});
|
|
213
|
+
// --- Model catalog ---
|
|
214
|
+
const catalogTtl = ctx.catalogTtlHours ?? 6;
|
|
215
|
+
function projectModels(catalogs, quotaMap) {
|
|
216
|
+
const out = [];
|
|
217
|
+
for (const [id, catalog] of catalogs) {
|
|
218
|
+
const identity = providerIdentity(id, ctx.providerNames);
|
|
219
|
+
const quota = quotaMap.get(id);
|
|
220
|
+
const leftoverPct = quota?.usedPct != null ? Math.round((100 - quota.usedPct) * 10) / 10 : null;
|
|
221
|
+
out.push({
|
|
222
|
+
id,
|
|
223
|
+
displayName: identity.displayName,
|
|
224
|
+
harness: identity.harness,
|
|
225
|
+
vendor: identity.vendor,
|
|
226
|
+
leftoverPct,
|
|
227
|
+
resetsAt: quota?.resetsAt ?? null,
|
|
228
|
+
catalog,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
app.get("/api/models", async (req, reply) => {
|
|
234
|
+
const providerParam = req.query?.provider;
|
|
235
|
+
const allProviders = catalogProviders(ctx.enabledProviders);
|
|
236
|
+
const providers = providerParam
|
|
237
|
+
? [String(providerParam)]
|
|
238
|
+
: allProviders;
|
|
239
|
+
// Unknown provider: 400 before the (expensive) waiting ensure.
|
|
240
|
+
if (providerParam) {
|
|
241
|
+
const known = new Set(allProviders);
|
|
242
|
+
if (!known.has(providerParam)) {
|
|
243
|
+
return reply.status(400).send({ error: `unknown provider: ${providerParam}` });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const catalogs = await ensureCatalogs({
|
|
247
|
+
db: ctx.db,
|
|
248
|
+
wait: true,
|
|
249
|
+
providers,
|
|
250
|
+
ttlHours: catalogTtl,
|
|
251
|
+
now: ctx.now,
|
|
252
|
+
fetchers: ctx.catalogFetchers,
|
|
253
|
+
});
|
|
254
|
+
const quotas = getAllLatest(ctx.db);
|
|
255
|
+
const quotaMap = new Map(quotas.map((q) => [q.provider, q]));
|
|
256
|
+
return projectModels(catalogs, quotaMap);
|
|
257
|
+
});
|
|
258
|
+
let lastModelsRefreshAt = 0;
|
|
259
|
+
app.post("/api/models/refresh", async (req, reply) => {
|
|
260
|
+
const headerToken = req.headers["x-quotacap-token"];
|
|
261
|
+
if (!isValidToken(headerToken, ctx.token)) {
|
|
262
|
+
return reply.status(401).send({ error: "unauthorized: missing or invalid X-QuotaCap-Token header" });
|
|
263
|
+
}
|
|
264
|
+
const providerParam = req.query?.provider ?? req.body?.provider;
|
|
265
|
+
const allProviders = catalogProviders(ctx.enabledProviders);
|
|
266
|
+
if (providerParam) {
|
|
267
|
+
const known = new Set(allProviders);
|
|
268
|
+
if (!known.has(providerParam)) {
|
|
269
|
+
return reply.status(400).send({ error: `unknown provider: ${providerParam}` });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const providers = providerParam ? [String(providerParam)] : allProviders;
|
|
273
|
+
const nowMs = (ctx.now?.() ?? new Date()).getTime();
|
|
274
|
+
if (lastModelsRefreshAt > 0 && nowMs - lastModelsRefreshAt < CATALOG_FAIL_COOLDOWN_MS) {
|
|
275
|
+
const catalogs = await ensureCatalogs({
|
|
276
|
+
db: ctx.db,
|
|
277
|
+
wait: false,
|
|
278
|
+
providers,
|
|
279
|
+
ttlHours: catalogTtl,
|
|
280
|
+
now: ctx.now,
|
|
281
|
+
fetchers: ctx.catalogFetchers,
|
|
282
|
+
});
|
|
283
|
+
const quotas = getAllLatest(ctx.db);
|
|
284
|
+
const quotaMap = new Map(quotas.map((q) => [q.provider, q]));
|
|
285
|
+
const models = projectModels(catalogs, quotaMap);
|
|
286
|
+
return {
|
|
287
|
+
cooldown: true,
|
|
288
|
+
models,
|
|
289
|
+
results: models,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const catalogs = await ensureCatalogs({
|
|
293
|
+
db: ctx.db,
|
|
294
|
+
wait: true,
|
|
295
|
+
refresh: true,
|
|
296
|
+
providers,
|
|
297
|
+
ttlHours: catalogTtl,
|
|
298
|
+
now: ctx.now,
|
|
299
|
+
fetchers: ctx.catalogFetchers,
|
|
300
|
+
});
|
|
301
|
+
const quotas = getAllLatest(ctx.db);
|
|
302
|
+
const quotaMap = new Map(quotas.map((q) => [q.provider, q]));
|
|
303
|
+
const models = projectModels(catalogs, quotaMap);
|
|
304
|
+
lastModelsRefreshAt = nowMs;
|
|
305
|
+
return {
|
|
306
|
+
cooldown: false,
|
|
307
|
+
models,
|
|
308
|
+
results: models,
|
|
309
|
+
};
|
|
310
|
+
});
|
|
211
311
|
app.patch("/api/providers/:id", async (req, reply) => {
|
|
212
312
|
const headerToken = req.headers["x-quotacap-token"];
|
|
213
313
|
if (!isValidToken(headerToken, ctx.token)) {
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -23,6 +23,19 @@ export declare const tools: ({
|
|
|
23
23
|
};
|
|
24
24
|
required?: undefined;
|
|
25
25
|
};
|
|
26
|
+
} | {
|
|
27
|
+
name: string;
|
|
28
|
+
description: string;
|
|
29
|
+
inputSchema: {
|
|
30
|
+
type: string;
|
|
31
|
+
properties: {
|
|
32
|
+
provider: {
|
|
33
|
+
type: string;
|
|
34
|
+
};
|
|
35
|
+
task?: undefined;
|
|
36
|
+
};
|
|
37
|
+
required?: undefined;
|
|
38
|
+
};
|
|
26
39
|
} | {
|
|
27
40
|
name: string;
|
|
28
41
|
description: string;
|
package/dist/mcp/server.js
CHANGED
|
@@ -3,13 +3,15 @@ import { projectQuotasResponse, projectRecommendationResponse, } from "../adviso
|
|
|
3
3
|
import { validateForecastProvider, validateTask } from "../advisory/validate.js";
|
|
4
4
|
import { getDbPath, readConfig } from "../config.js";
|
|
5
5
|
import { forecastText, stateWord } from "../format/rows.js";
|
|
6
|
-
import { renderMarkdownTable, renderRecommendationSummary } from "../format/markdown.js";
|
|
7
|
-
import { createServiceClientForBase } from "../runtime/client.js";
|
|
6
|
+
import { renderMarkdownTable, renderRecommendationSummary, renderModelsMarkdownTable } from "../format/markdown.js";
|
|
7
|
+
import { createServiceClientForBase, ServiceUnavailable } from "../runtime/client.js";
|
|
8
|
+
import { CATALOG_CLIENT_TIMEOUT_MS } from "../catalog/index.js";
|
|
8
9
|
import { VERSION } from "../version.js";
|
|
9
10
|
import { ClientError, OFFLINE_LABEL, resolveSnapshot, } from "../cli/snapshot-source.js";
|
|
10
11
|
export const tools = [
|
|
11
12
|
{ name: "get_quotas", description: "All quotas with resets and health", inputSchema: { type: "object", properties: {}, required: [] } },
|
|
12
13
|
{ name: "get_recommendation", description: "Which provider to use next (same advice for every task)", inputSchema: { type: "object", properties: { task: { type: "string", enum: ["any", "heavy", "light"] } } } },
|
|
14
|
+
{ name: "get_models", description: "Currently listed models per quota bucket (inventory, not a task ranking)", inputSchema: { type: "object", properties: { provider: { type: "string" } } } },
|
|
13
15
|
{ name: "forecast", description: "Burn (24h + window avg) vs ideal + waste for a provider", inputSchema: { type: "object", properties: { provider: { type: "string" } }, required: ["provider"] } },
|
|
14
16
|
];
|
|
15
17
|
// QUOTACAP_URL is used as configured: scheme, authority, effective port and
|
|
@@ -72,6 +74,28 @@ export async function handleTool(name, args) {
|
|
|
72
74
|
],
|
|
73
75
|
};
|
|
74
76
|
}
|
|
77
|
+
if (name === "get_models") {
|
|
78
|
+
const provider = args?.provider;
|
|
79
|
+
const base = process.env.QUOTACAP_URL ?? "http://localhost:8787";
|
|
80
|
+
const client = createServiceClientForBase(base, { timeoutMs: CATALOG_CLIENT_TIMEOUT_MS });
|
|
81
|
+
const url = provider ? `/api/models?provider=${encodeURIComponent(provider)}` : "/api/models";
|
|
82
|
+
try {
|
|
83
|
+
const body = await client.get(url);
|
|
84
|
+
const list = Array.isArray(body) ? body : (body?.models ?? []);
|
|
85
|
+
return {
|
|
86
|
+
content: [
|
|
87
|
+
{ type: "text", text: renderModelsMarkdownTable(list) },
|
|
88
|
+
{ type: "text", text: JSON.stringify(body, null, 2) },
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
if (err instanceof ServiceUnavailable) {
|
|
94
|
+
throw new Error("model catalog unavailable (daemon unreachable)");
|
|
95
|
+
}
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
75
99
|
if (name === "forecast") {
|
|
76
100
|
rejectMissingProvider(args);
|
|
77
101
|
const { snapshot, source } = await resolveState();
|
|
@@ -91,6 +115,7 @@ export async function handleTool(name, args) {
|
|
|
91
115
|
state: stateWord(ps),
|
|
92
116
|
forecast: forecastText(ps, new Date()),
|
|
93
117
|
lastAttempt: ps.lastAttempt,
|
|
118
|
+
lastCloses: ps.lastCloses,
|
|
94
119
|
};
|
|
95
120
|
return { content: [{ type: "text", text: JSON.stringify(body, null, 2) }] };
|
|
96
121
|
}
|
|
@@ -137,8 +162,11 @@ export async function runMcpServer() {
|
|
|
137
162
|
try {
|
|
138
163
|
const result = await handleTool(toolName, toolArgs);
|
|
139
164
|
const content = Array.isArray(result?.content) ? result.content : [{ type: "text", text: JSON.stringify(result, null, 2) }];
|
|
165
|
+
const res = { content };
|
|
166
|
+
if (result?.isError)
|
|
167
|
+
res.isError = true;
|
|
140
168
|
if (!isNotification)
|
|
141
|
-
respond(id,
|
|
169
|
+
respond(id, res);
|
|
142
170
|
}
|
|
143
171
|
catch (e) {
|
|
144
172
|
const content = [{ type: "text", text: e?.message ?? String(e) }];
|
package/dist/runtime/poll.d.ts
CHANGED
|
@@ -54,5 +54,18 @@ export interface Coordinator {
|
|
|
54
54
|
setClosing(): void;
|
|
55
55
|
isClosing(): boolean;
|
|
56
56
|
}
|
|
57
|
+
export declare const PRE_RESET_LEAD_MS: number;
|
|
58
|
+
export declare const PRE_RESET_MIN_REMAINING_MS: number;
|
|
59
|
+
export declare function nextPollDelayMs(opts: {
|
|
60
|
+
nowMs: number;
|
|
61
|
+
intervalMs: number;
|
|
62
|
+
latest: Array<{
|
|
63
|
+
provider: string;
|
|
64
|
+
resetsAt?: string | null;
|
|
65
|
+
resetsAtEstimated?: boolean;
|
|
66
|
+
}>;
|
|
67
|
+
lastCompletedPollAtMs: number | null;
|
|
68
|
+
enabledProviders: string[];
|
|
69
|
+
}): number;
|
|
57
70
|
export declare function mapFailureCategory(reason: unknown): Exclude<FailureCategory, null>;
|
|
58
71
|
export declare function createCoordinator(opts: CoordinatorOptions): Coordinator;
|
package/dist/runtime/poll.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Poll coordinator: scheduled and manual refresh share one in-flight poll
|
|
2
2
|
// with a completion-measured cooldown (decisions D3/D4).
|
|
3
3
|
import { pollAll } from "../adapters/index.js";
|
|
4
|
-
import { upsertQuota } from "../store/quotas.js";
|
|
4
|
+
import { getAllLatest, upsertQuota } from "../store/quotas.js";
|
|
5
5
|
import { recordAttempt, getAttempt, } from "../store/attempts.js";
|
|
6
6
|
import { classifyFailure, } from "../diagnostics/failure.js";
|
|
7
7
|
export class ServiceClosing extends Error {
|
|
@@ -13,6 +13,32 @@ export class ServiceClosing extends Error {
|
|
|
13
13
|
function defaultPollFn(enabled) {
|
|
14
14
|
return pollAll(enabled);
|
|
15
15
|
}
|
|
16
|
+
export const PRE_RESET_LEAD_MS = 5 * 60 * 1000;
|
|
17
|
+
export const PRE_RESET_MIN_REMAINING_MS = 25 * 1000;
|
|
18
|
+
export function nextPollDelayMs(opts) {
|
|
19
|
+
const { nowMs, intervalMs, latest, lastCompletedPollAtMs, enabledProviders } = opts;
|
|
20
|
+
let best = intervalMs;
|
|
21
|
+
for (const row of latest) {
|
|
22
|
+
const enabled = enabledProviders.includes(row.provider) ||
|
|
23
|
+
(row.provider.startsWith("agy:") && enabledProviders.includes("agy"));
|
|
24
|
+
if (!enabled || row.resetsAtEstimated || !row.resetsAt)
|
|
25
|
+
continue;
|
|
26
|
+
const resetsMs = new Date(row.resetsAt).getTime();
|
|
27
|
+
if (Number.isNaN(resetsMs) || resetsMs <= nowMs || resetsMs - nowMs < PRE_RESET_MIN_REMAINING_MS) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const target = resetsMs - PRE_RESET_LEAD_MS;
|
|
31
|
+
if (lastCompletedPollAtMs !== null &&
|
|
32
|
+
lastCompletedPollAtMs >= target &&
|
|
33
|
+
lastCompletedPollAtMs < resetsMs) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const raw = Math.max(0, target - nowMs);
|
|
37
|
+
const delay = raw === 0 ? 1000 : raw;
|
|
38
|
+
best = Math.min(best, Math.min(intervalMs, delay));
|
|
39
|
+
}
|
|
40
|
+
return best;
|
|
41
|
+
}
|
|
16
42
|
export function mapFailureCategory(reason) {
|
|
17
43
|
return classifyFailure("Provider", reason).category;
|
|
18
44
|
}
|
|
@@ -42,7 +68,13 @@ export function createCoordinator(opts) {
|
|
|
42
68
|
const detail = classifyFailure("all", e).errorDetail;
|
|
43
69
|
console.warn("[quotacap] scheduled poll failed", detail);
|
|
44
70
|
});
|
|
45
|
-
},
|
|
71
|
+
}, nextPollDelayMs({
|
|
72
|
+
nowMs: now(),
|
|
73
|
+
intervalMs,
|
|
74
|
+
latest: getAllLatest(db),
|
|
75
|
+
lastCompletedPollAtMs: lastCompletedPollAt ? completedAtMs : null,
|
|
76
|
+
enabledProviders,
|
|
77
|
+
}));
|
|
46
78
|
}
|
|
47
79
|
async function runGeneration() {
|
|
48
80
|
const attemptedAt = new Date(now()).toISOString();
|
package/dist/runtime/service.js
CHANGED
|
@@ -17,6 +17,7 @@ import { detectChannel, refreshUpdateCache } from "./updates.js";
|
|
|
17
17
|
import { VERSION } from "../version.js";
|
|
18
18
|
import { claudeAdapter } from "../adapters/claude.js";
|
|
19
19
|
import { classifyFailure } from "../diagnostics/failure.js";
|
|
20
|
+
import { ensureCatalogs, catalogProviders } from "../catalog/index.js";
|
|
20
21
|
export function resolveClaudeExecPath() {
|
|
21
22
|
try {
|
|
22
23
|
const resolved = execFileSync("which", ["claude"], {
|
|
@@ -198,6 +199,7 @@ export async function startService(opts) {
|
|
|
198
199
|
coordinator,
|
|
199
200
|
enabledProviders: config.enabledProviders,
|
|
200
201
|
providerNames: config.providerNames,
|
|
202
|
+
catalogTtlHours: config.catalogTtlHours,
|
|
201
203
|
version: VERSION,
|
|
202
204
|
exec: process.execPath,
|
|
203
205
|
canWrite: () => claim.verify(),
|
|
@@ -231,6 +233,13 @@ export async function startService(opts) {
|
|
|
231
233
|
console.warn("[quotacap] initial poll failed", detail);
|
|
232
234
|
});
|
|
233
235
|
coordinator.start(config.pollMinutes * 60 * 1000);
|
|
236
|
+
// Background catalog warm: wait: true after listen, never blocks /health ready.
|
|
237
|
+
void ensureCatalogs({
|
|
238
|
+
db,
|
|
239
|
+
wait: true,
|
|
240
|
+
providers: catalogProviders(config.enabledProviders),
|
|
241
|
+
ttlHours: config.catalogTtlHours,
|
|
242
|
+
}).catch(() => { });
|
|
234
243
|
let stopping = false;
|
|
235
244
|
let stopped = false;
|
|
236
245
|
async function stop() {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type CatalogView, type ListedModel } from "../catalog/types.js";
|
|
2
|
+
export declare function getCatalog(db: any, provider: string): CatalogView;
|
|
3
|
+
export declare function getCatalogs(db: any): Map<string, CatalogView>;
|
|
4
|
+
export declare function upsertCatalog(db: any, provider: string, listed: ListedModel[], fetchedAt: string): void;
|
|
5
|
+
export declare function markCatalogFailure(db: any, provider: string, diag: {
|
|
6
|
+
diagnosticCode: string;
|
|
7
|
+
summary: string;
|
|
8
|
+
action: string;
|
|
9
|
+
errorDetail: string;
|
|
10
|
+
}): void;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { emptyCatalog } from "../catalog/types.js";
|
|
2
|
+
function isMissingTable(e) {
|
|
3
|
+
return /no such table/i.test(e?.message ?? "");
|
|
4
|
+
}
|
|
5
|
+
function mapCatalog(r) {
|
|
6
|
+
return {
|
|
7
|
+
status: r.status,
|
|
8
|
+
fetchedAt: r.fetched_at ?? null,
|
|
9
|
+
listed: JSON.parse(r.models_json ?? "[]"),
|
|
10
|
+
diagnosticCode: r.diagnostic_code ?? null,
|
|
11
|
+
summary: r.summary ?? null,
|
|
12
|
+
action: r.action ?? null,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function getCatalog(db, provider) {
|
|
16
|
+
let r;
|
|
17
|
+
try {
|
|
18
|
+
r = db.prepare(`SELECT * FROM model_catalogs WHERE provider=?`).get(provider);
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
// Pre-catalog DB or an offline file the daemon has not migrated: read as
|
|
22
|
+
// unfetched. Never migrate or throw from a read helper.
|
|
23
|
+
if (isMissingTable(e))
|
|
24
|
+
return emptyCatalog();
|
|
25
|
+
throw e;
|
|
26
|
+
}
|
|
27
|
+
if (!r)
|
|
28
|
+
return emptyCatalog();
|
|
29
|
+
return mapCatalog(r);
|
|
30
|
+
}
|
|
31
|
+
export function getCatalogs(db) {
|
|
32
|
+
let rows;
|
|
33
|
+
try {
|
|
34
|
+
rows = db.prepare(`SELECT * FROM model_catalogs`).all();
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
if (isMissingTable(e))
|
|
38
|
+
return new Map();
|
|
39
|
+
throw e;
|
|
40
|
+
}
|
|
41
|
+
return new Map(rows.map((r) => [r.provider, mapCatalog(r)]));
|
|
42
|
+
}
|
|
43
|
+
export function upsertCatalog(db, provider, listed, fetchedAt) {
|
|
44
|
+
db.prepare(`INSERT INTO model_catalogs(provider, status, models_json, fetched_at, diagnostic_code, summary, action, error_detail)
|
|
45
|
+
VALUES(?,?,?,?,NULL,NULL,NULL,NULL)
|
|
46
|
+
ON CONFLICT(provider) DO UPDATE SET status=excluded.status, models_json=excluded.models_json, fetched_at=excluded.fetched_at,
|
|
47
|
+
diagnostic_code=NULL, summary=NULL, action=NULL, error_detail=NULL`)
|
|
48
|
+
.run(provider, "ok", JSON.stringify(listed), fetchedAt);
|
|
49
|
+
}
|
|
50
|
+
export function markCatalogFailure(db, provider, diag) {
|
|
51
|
+
const row = db.prepare(`SELECT status FROM model_catalogs WHERE provider=?`).get(provider);
|
|
52
|
+
if (row) {
|
|
53
|
+
// A prior success degrades to stale and keeps models_json/fetched_at; a
|
|
54
|
+
// repeated failure on an error row stays error.
|
|
55
|
+
const status = row.status === "ok" ? "stale" : row.status;
|
|
56
|
+
db.prepare(`UPDATE model_catalogs SET status=?, diagnostic_code=?, summary=?, action=?, error_detail=? WHERE provider=?`)
|
|
57
|
+
.run(status, diag.diagnosticCode, diag.summary, diag.action, diag.errorDetail, provider);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
db.prepare(`INSERT INTO model_catalogs(provider, status, models_json, fetched_at, diagnostic_code, summary, action, error_detail)
|
|
61
|
+
VALUES(?, 'error', '[]', NULL, ?,?,?,?)`)
|
|
62
|
+
.run(provider, diag.diagnosticCode, diag.summary, diag.action, diag.errorDetail);
|
|
63
|
+
}
|
|
64
|
+
}
|
package/dist/store/db.js
CHANGED
|
@@ -39,7 +39,33 @@ export function migrate(db) {
|
|
|
39
39
|
db.exec(`CREATE TABLE IF NOT EXISTS quotas(id INTEGER PRIMARY KEY, provider TEXT, plan TEXT, used_pct REAL, resets_at TEXT, period_start TEXT, source TEXT, fetched_at TEXT, credits_usd REAL, resets_at_estimated INTEGER, session_pct REAL);
|
|
40
40
|
CREATE TABLE IF NOT EXISTS snapshots(day TEXT, provider TEXT, used_pct REAL, burn_rate REAL, ideal_rate REAL, PRIMARY KEY(day, provider));
|
|
41
41
|
CREATE TABLE IF NOT EXISTS adapter_attempts(provider TEXT PRIMARY KEY, attempted_at TEXT NOT NULL, completed_at TEXT, succeeded_at TEXT, success INTEGER NOT NULL, failure_category TEXT, diagnostic_code TEXT, summary TEXT, action TEXT, error_detail TEXT);
|
|
42
|
-
CREATE INDEX IF NOT EXISTS idx_quotas_provider ON quotas(provider)
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_quotas_provider ON quotas(provider);
|
|
43
|
+
CREATE TABLE IF NOT EXISTS model_catalogs(
|
|
44
|
+
provider TEXT PRIMARY KEY,
|
|
45
|
+
fetched_at TEXT,
|
|
46
|
+
status TEXT NOT NULL,
|
|
47
|
+
models_json TEXT NOT NULL,
|
|
48
|
+
diagnostic_code TEXT,
|
|
49
|
+
summary TEXT,
|
|
50
|
+
action TEXT,
|
|
51
|
+
error_detail TEXT
|
|
52
|
+
);
|
|
53
|
+
CREATE TABLE IF NOT EXISTS window_closes(
|
|
54
|
+
id INTEGER PRIMARY KEY,
|
|
55
|
+
provider TEXT NOT NULL,
|
|
56
|
+
plan TEXT,
|
|
57
|
+
used_pct REAL NOT NULL,
|
|
58
|
+
leftover_pct REAL NOT NULL,
|
|
59
|
+
sampled_at TEXT NOT NULL,
|
|
60
|
+
sampled_quota_id INTEGER NOT NULL,
|
|
61
|
+
period_start TEXT,
|
|
62
|
+
resets_at TEXT,
|
|
63
|
+
resets_at_estimated INTEGER,
|
|
64
|
+
detected_at TEXT NOT NULL,
|
|
65
|
+
reason TEXT NOT NULL,
|
|
66
|
+
UNIQUE(provider, sampled_quota_id)
|
|
67
|
+
);
|
|
68
|
+
CREATE INDEX IF NOT EXISTS idx_window_closes_provider_detected ON window_closes(provider, detected_at DESC);`);
|
|
43
69
|
try {
|
|
44
70
|
const cols = db.prepare(`PRAGMA table_info(quotas)`).all();
|
|
45
71
|
const names = new Set(cols.map((c) => c.name));
|
package/dist/store/quotas.d.ts
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
|
+
export declare function isWeeklyCycleBreak(prev: {
|
|
2
|
+
usedPct: number;
|
|
3
|
+
t: number;
|
|
4
|
+
resetsAt: string | null;
|
|
5
|
+
}, cur: {
|
|
6
|
+
usedPct: number;
|
|
7
|
+
t: number;
|
|
8
|
+
resetsAt: string | null;
|
|
9
|
+
}): boolean;
|
|
1
10
|
export declare function upsertQuota(db: any, q: any): void;
|
|
11
|
+
export declare function getWindowCloses(db: any, provider: string, limit?: number): Array<{
|
|
12
|
+
provider: string;
|
|
13
|
+
plan: string | null;
|
|
14
|
+
usedPct: number;
|
|
15
|
+
leftoverPct: number;
|
|
16
|
+
sampledAt: string;
|
|
17
|
+
periodStart: string | null;
|
|
18
|
+
resetsAt: string | null;
|
|
19
|
+
resetsAtEstimated: boolean;
|
|
20
|
+
detectedAt: string;
|
|
21
|
+
reason: "usage-drop" | "resets-at-rolled" | "both";
|
|
22
|
+
}>;
|
|
2
23
|
export declare function getLatestByProvider(db: any, provider: string): any;
|
|
3
24
|
export declare function getAllLatest(db: any): any;
|
|
4
25
|
export declare const getQuotas: typeof getAllLatest;
|
|
5
26
|
export declare function getSnapshots(db: any): any;
|
|
6
27
|
export declare function getBurnRates(db: any, now?: number): Map<string, number>;
|
|
28
|
+
export declare function getHistoryBaseline(db: any, limit?: number): Map<string, number>;
|