runcap 0.1.1 → 0.2.1
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 +34 -15
- package/bin/runcap.mjs +90 -6
- package/package.json +3 -3
- package/src/alerts.mjs +145 -0
- package/src/cloud.mjs +90 -0
- package/src/compressor.mjs +169 -0
- package/src/mission-control.mjs +496 -81
package/src/mission-control.mjs
CHANGED
|
@@ -2,15 +2,19 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import http from "node:http";
|
|
4
4
|
import { appendFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
5
|
-
import { existsSync } from "node:fs";
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import process from "node:process";
|
|
8
|
+
import { syncRun } from "./cloud.mjs";
|
|
9
|
+
import { sendAlert } from "./alerts.mjs";
|
|
10
|
+
import { compressRequestBody, estimateTokens } from "./compressor.mjs";
|
|
8
11
|
|
|
9
12
|
const STORE_DIR = ".runcap";
|
|
10
13
|
const MISSIONS_DIR = path.join(STORE_DIR, "missions");
|
|
11
14
|
const PLANS_DIR = path.join(STORE_DIR, "plans");
|
|
12
15
|
const FUEL_FILE = path.join(STORE_DIR, "fuel.json");
|
|
13
16
|
const GATEWAY_EVENTS_FILE = path.join(STORE_DIR, "gateway-events.jsonl");
|
|
17
|
+
const BUDGET_FILE = path.join(STORE_DIR, "budget.json");
|
|
14
18
|
const ENV_EXAMPLE_FILE = ".env.example";
|
|
15
19
|
|
|
16
20
|
const ERROR_PATTERNS = [
|
|
@@ -51,7 +55,7 @@ const ERROR_PATTERNS = [
|
|
|
51
55
|
}
|
|
52
56
|
];
|
|
53
57
|
|
|
54
|
-
export async function runMission({ command, label, fuelBefore }) {
|
|
58
|
+
export async function runMission({ command, label, fuelBefore, autoGateway = false, mock = false }) {
|
|
55
59
|
await ensureStore();
|
|
56
60
|
const id = createMissionId(label);
|
|
57
61
|
const missionDir = path.join(MISSIONS_DIR, id);
|
|
@@ -61,7 +65,29 @@ export async function runMission({ command, label, fuelBefore }) {
|
|
|
61
65
|
const cwd = process.cwd();
|
|
62
66
|
const before = await collectSnapshot(cwd);
|
|
63
67
|
const preflight = buildPreflight(command.join(" "), before);
|
|
64
|
-
|
|
68
|
+
|
|
69
|
+
// Zero-config: bring up a gateway for just this run and point the child's
|
|
70
|
+
// provider base URLs at it, so the cap is enforced without the user manually
|
|
71
|
+
// starting a gateway or exporting any base URL.
|
|
72
|
+
let gateway = null;
|
|
73
|
+
let childEnv = {};
|
|
74
|
+
const budgetBefore = readBudget();
|
|
75
|
+
const spentBefore = autoGateway ? (await readGatewaySummary({ windowMs: budgetWindowMs() })).estimatedCostUsd : 0;
|
|
76
|
+
if (autoGateway) {
|
|
77
|
+
gateway = await startEphemeralGateway({ mock });
|
|
78
|
+
childEnv = {
|
|
79
|
+
ANTHROPIC_BASE_URL: `${gateway.baseUrl}/v1`,
|
|
80
|
+
OPENAI_BASE_URL: `${gateway.baseUrl}/v1`,
|
|
81
|
+
OPENAI_API_BASE: `${gateway.baseUrl}/v1`
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let output;
|
|
86
|
+
try {
|
|
87
|
+
output = await runChild(command, cwd, childEnv);
|
|
88
|
+
} finally {
|
|
89
|
+
if (gateway) await gateway.close().catch(() => {});
|
|
90
|
+
}
|
|
65
91
|
const after = await collectSnapshot(cwd);
|
|
66
92
|
const terminal = `${output.stdout}\n${output.stderr}`;
|
|
67
93
|
const errors = parseErrors(terminal);
|
|
@@ -103,9 +129,21 @@ export async function runMission({ command, label, fuelBefore }) {
|
|
|
103
129
|
await writeFile(path.join(missionDir, "report.html"), formatHtmlReport(mission));
|
|
104
130
|
await writeFile(path.join(STORE_DIR, "latest"), id);
|
|
105
131
|
|
|
132
|
+
let capSummary = null;
|
|
133
|
+
if (autoGateway) {
|
|
134
|
+
const spentAfter = (await readGatewaySummary({ windowMs: budgetWindowMs() })).estimatedCostUsd;
|
|
135
|
+
capSummary = {
|
|
136
|
+
capUsd: budgetBefore,
|
|
137
|
+
spentThisRunUsd: Number((spentAfter - spentBefore).toFixed(6)),
|
|
138
|
+
spentWindowUsd: spentAfter,
|
|
139
|
+
mode: gateway?.gatewayMode ?? "proxy"
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
106
143
|
return {
|
|
107
144
|
id,
|
|
108
|
-
summary: shortSummary(mission)
|
|
145
|
+
summary: shortSummary(mission),
|
|
146
|
+
capSummary
|
|
109
147
|
};
|
|
110
148
|
}
|
|
111
149
|
|
|
@@ -207,6 +245,48 @@ export async function planMission(goal, options = {}) {
|
|
|
207
245
|
return plan;
|
|
208
246
|
}
|
|
209
247
|
|
|
248
|
+
// Persist a hard cap to .runcap/budget.json so the gateway enforces it without
|
|
249
|
+
// the user manually exporting AIM_DAILY_BUDGET_USD. env still wins if set.
|
|
250
|
+
// Show a meaningful figure for sub-cent spend; a real call can cost a fraction
|
|
251
|
+
// of a cent, and rounding it to $0.00 reads as "nothing was recorded".
|
|
252
|
+
function fmtUsd(n) {
|
|
253
|
+
const v = Number(n);
|
|
254
|
+
if (!(v > 0)) return "$0.00";
|
|
255
|
+
if (v >= 0.01) return `$${v.toFixed(2)}`;
|
|
256
|
+
if (v >= 0.0001) return `$${v.toFixed(4)}`;
|
|
257
|
+
return `$${v.toPrecision(2)}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function setBudgetCap(capUsd, { source = "manual" } = {}) {
|
|
261
|
+
await ensureStore();
|
|
262
|
+
const value = Number(capUsd);
|
|
263
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
264
|
+
throw new Error("Usage: runcap cap <usd> (a non-negative number).");
|
|
265
|
+
}
|
|
266
|
+
await writeFile(BUDGET_FILE, JSON.stringify({ capUsd: value, source, setAt: new Date().toISOString() }, null, 2));
|
|
267
|
+
const envNote = process.env.AIM_DAILY_BUDGET_USD
|
|
268
|
+
? "\nNote: AIM_DAILY_BUDGET_USD is set in your env and overrides this file."
|
|
269
|
+
: "";
|
|
270
|
+
return `Hard cap set: $${value.toFixed(2)} per ${(process.env.AIM_BUDGET_WINDOW ?? "day")}. Saved to ${BUDGET_FILE}.${envNote}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function clearBudgetCap() {
|
|
274
|
+
await ensureStore();
|
|
275
|
+
if (existsSync(BUDGET_FILE)) await writeFile(BUDGET_FILE, JSON.stringify({ capUsd: null, clearedAt: new Date().toISOString() }, null, 2));
|
|
276
|
+
return "Stored cap cleared. The gateway will only enforce AIM_DAILY_BUDGET_USD if set.";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function currentBudgetCap() {
|
|
280
|
+
const cap = readBudget();
|
|
281
|
+
if (cap === null) return "No cap set. Run `runcap cap <usd>` or `runcap plan --apply-cap`.";
|
|
282
|
+
const src = process.env.AIM_DAILY_BUDGET_USD ? "env AIM_DAILY_BUDGET_USD" : `file ${BUDGET_FILE}`;
|
|
283
|
+
return `Current hard cap: $${cap.toFixed(2)} per ${(process.env.AIM_BUDGET_WINDOW ?? "day")} (from ${src}).`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function hasStoredCap() {
|
|
287
|
+
return readStoredBudget() !== null;
|
|
288
|
+
}
|
|
289
|
+
|
|
210
290
|
export async function listPlans() {
|
|
211
291
|
await ensureStore();
|
|
212
292
|
const plans = await readPlans();
|
|
@@ -244,9 +324,15 @@ export async function setupProject() {
|
|
|
244
324
|
"OPENAI_API_KEY=",
|
|
245
325
|
"AIM_UPSTREAM_BASE_URL=https://api.openai.com/v1",
|
|
246
326
|
"",
|
|
247
|
-
"#
|
|
327
|
+
"# Hard cap (USD) per budget window. The gateway prices each call from its",
|
|
328
|
+
"# own tokens and blocks it BEFORE forwarding if it would push spend over the cap.",
|
|
329
|
+
"# You can also set this with `runcap cap <usd>` or `runcap plan --apply-cap`.",
|
|
248
330
|
"AIM_DAILY_BUDGET_USD=5",
|
|
249
331
|
"",
|
|
332
|
+
"# Budget window: day (default, rolling 24h), session (since gateway start),",
|
|
333
|
+
"# all (never resets), or a number of hours. Caps reset per window.",
|
|
334
|
+
"AIM_BUDGET_WINDOW=day",
|
|
335
|
+
"",
|
|
250
336
|
"# For demo mode without external API calls:",
|
|
251
337
|
"AIM_GATEWAY_MODE=mock"
|
|
252
338
|
].join("\n");
|
|
@@ -292,6 +378,80 @@ export async function doctor() {
|
|
|
292
378
|
].join("\n");
|
|
293
379
|
}
|
|
294
380
|
|
|
381
|
+
// Guided first-run, shown when `runcap` is invoked with no arguments. Explains
|
|
382
|
+
// in one screen what Runcap does, what it does NOT do, checks readiness, and
|
|
383
|
+
// gives exactly ONE next step based on the current state — so a newcomer reaches
|
|
384
|
+
// their first result without reading docs.
|
|
385
|
+
export async function welcome() {
|
|
386
|
+
await ensureStore();
|
|
387
|
+
const hasOpenAiKey = Boolean(process.env.AIM_UPSTREAM_API_KEY ?? process.env.OPENAI_API_KEY);
|
|
388
|
+
const hasAnthropicKey = Boolean(process.env.ANTHROPIC_API_KEY);
|
|
389
|
+
const hasAnyKey = hasOpenAiKey || hasAnthropicKey;
|
|
390
|
+
const cap = readBudget();
|
|
391
|
+
const gateway = await readGatewaySummary({ windowMs: budgetWindowMs() });
|
|
392
|
+
const window = process.env.AIM_BUDGET_WINDOW ?? "day";
|
|
393
|
+
|
|
394
|
+
const tick = (ok) => (ok ? "[x]" : "[ ]");
|
|
395
|
+
const keyLabel = hasAnyKey
|
|
396
|
+
? `API key detected (${[hasAnthropicKey && "Anthropic", hasOpenAiKey && "OpenAI"].filter(Boolean).join(" + ")})`
|
|
397
|
+
: "No API key in this shell (set ANTHROPIC_API_KEY or OPENAI_API_KEY)";
|
|
398
|
+
const capLabel = cap === null ? "No cap set yet" : `Cap set: $${cap.toFixed(2)} per ${window}`;
|
|
399
|
+
|
|
400
|
+
// One next step, chosen by what is missing.
|
|
401
|
+
let nextStep;
|
|
402
|
+
if (!hasAnyKey) {
|
|
403
|
+
nextStep = [
|
|
404
|
+
"Next: give Runcap the same provider key your agent already uses, e.g.",
|
|
405
|
+
" export ANTHROPIC_API_KEY=sk-... # or OPENAI_API_KEY=sk-...",
|
|
406
|
+
"Then run `runcap` again."
|
|
407
|
+
];
|
|
408
|
+
} else if (cap === null) {
|
|
409
|
+
nextStep = [
|
|
410
|
+
"Next: set the most you want a run to spend, then run your agent through Runcap:",
|
|
411
|
+
" runcap cap 5",
|
|
412
|
+
" runcap run -- claude \"fix the failing test\"",
|
|
413
|
+
"Runcap starts a local gateway, points your agent at it, and blocks any call",
|
|
414
|
+
"that would push spend over $5, before it reaches the paid API.",
|
|
415
|
+
"",
|
|
416
|
+
"Not sure what to cap at? Estimate first:",
|
|
417
|
+
" runcap plan --apply-cap -- \"the task you're about to run\""
|
|
418
|
+
];
|
|
419
|
+
} else {
|
|
420
|
+
nextStep = [
|
|
421
|
+
`You're ready. Cap is $${cap.toFixed(2)} per ${window}. Run any agent through Runcap:`,
|
|
422
|
+
" runcap run -- claude \"fix the failing test\"",
|
|
423
|
+
" runcap run -- codex \"...\" runcap run -- python my_agent.py",
|
|
424
|
+
"",
|
|
425
|
+
gateway.callCount > 0
|
|
426
|
+
? `Spent so far this ${window}: ${fmtUsd(gateway.estimatedCostUsd)} across ${gateway.callCount} calls. See: runcap status`
|
|
427
|
+
: "No calls recorded yet. Your first `runcap run` will show the spend."
|
|
428
|
+
];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return [
|
|
432
|
+
"Runcap: see and cap what your AI agent spends, before it spends it.",
|
|
433
|
+
"",
|
|
434
|
+
"What it does:",
|
|
435
|
+
" - Prices each call your agent makes from its own tokens.",
|
|
436
|
+
" - Blocks any call that would exceed your cap BEFORE it hits the paid API.",
|
|
437
|
+
" - Shows you the real spend, per run and per day.",
|
|
438
|
+
"",
|
|
439
|
+
"What it does NOT do (so there are no surprises):",
|
|
440
|
+
" - It does not give you an AI model. You bring your own provider API key.",
|
|
441
|
+
" - It does not run tasks for you. You bring your own agent (Claude Code,",
|
|
442
|
+
" Codex, a script: anything that calls OpenAI/Anthropic).",
|
|
443
|
+
" - It is a local tool for that setup, not a no-account web app.",
|
|
444
|
+
"",
|
|
445
|
+
"Readiness:",
|
|
446
|
+
` ${tick(hasAnyKey)} ${keyLabel}`,
|
|
447
|
+
` ${tick(cap !== null)} ${capLabel}`,
|
|
448
|
+
"",
|
|
449
|
+
...nextStep,
|
|
450
|
+
"",
|
|
451
|
+
"Full command list: `runcap help`."
|
|
452
|
+
].join("\n");
|
|
453
|
+
}
|
|
454
|
+
|
|
295
455
|
export async function startDashboard({ port = 8791 } = {}) {
|
|
296
456
|
await ensureStore();
|
|
297
457
|
const server = http.createServer(async (request, response) => {
|
|
@@ -350,13 +510,15 @@ async function listenLocal(server, port, label) {
|
|
|
350
510
|
});
|
|
351
511
|
}
|
|
352
512
|
|
|
353
|
-
|
|
354
|
-
|
|
513
|
+
// Build (but do not start) the gateway HTTP server. Upstream targets are
|
|
514
|
+
// captured here from explicit args or env, so the auto-wrapper can pin the real
|
|
515
|
+
// upstream BEFORE it rewrites the child's base URLs to point at this gateway.
|
|
516
|
+
function createGatewayServer({ port = 8792, mock = false, upstream = {} } = {}) {
|
|
355
517
|
const gatewayMode = mock || process.env.AIM_GATEWAY_MODE === "mock" ? "mock" : "proxy";
|
|
356
|
-
const openaiKey = process.env.AIM_UPSTREAM_API_KEY ?? process.env.OPENAI_API_KEY;
|
|
357
|
-
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
|
358
|
-
const openaiBaseUrl = process.env.AIM_UPSTREAM_BASE_URL ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1";
|
|
359
|
-
const anthropicBaseUrl = process.env.ANTHROPIC_BASE_URL ?? "https://api.anthropic.com/v1";
|
|
518
|
+
const openaiKey = upstream.openaiKey ?? process.env.AIM_UPSTREAM_API_KEY ?? process.env.OPENAI_API_KEY;
|
|
519
|
+
const anthropicKey = upstream.anthropicKey ?? process.env.ANTHROPIC_API_KEY;
|
|
520
|
+
const openaiBaseUrl = upstream.openaiBaseUrl ?? process.env.AIM_UPSTREAM_BASE_URL ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1";
|
|
521
|
+
const anthropicBaseUrl = upstream.anthropicBaseUrl ?? process.env.ANTHROPIC_BASE_URL ?? "https://api.anthropic.com/v1";
|
|
360
522
|
const anthropicVersion = process.env.ANTHROPIC_VERSION ?? "2023-06-01";
|
|
361
523
|
if (gatewayMode !== "mock" && !openaiKey && !anthropicKey) {
|
|
362
524
|
throw new Error("Missing upstream key. Set OPENAI_API_KEY (for /v1/chat/completions) and/or ANTHROPIC_API_KEY (for /v1/messages). The gateway cannot proxy without at least one.");
|
|
@@ -384,8 +546,33 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
|
|
|
384
546
|
const bodyText = await readRequestBody(request);
|
|
385
547
|
const requestBody = safeJson(bodyText) ?? {};
|
|
386
548
|
const budget = readBudget();
|
|
387
|
-
const summary = await readGatewaySummary();
|
|
388
|
-
|
|
549
|
+
const summary = await readGatewaySummary({ windowMs: budgetWindowMs() });
|
|
550
|
+
// Compress the request body once (safe, lossless-by-construction). Disable with AIM_COMPRESS=off.
|
|
551
|
+
const compressionOn = (process.env.AIM_COMPRESS ?? "on").toLowerCase() !== "off";
|
|
552
|
+
let forwardBody = bodyText;
|
|
553
|
+
let compression = null;
|
|
554
|
+
if (compressionOn) {
|
|
555
|
+
const c = compressRequestBody(requestBody);
|
|
556
|
+
if (c.savedChars > 0 && c.touched > 0) {
|
|
557
|
+
forwardBody = JSON.stringify(c.body);
|
|
558
|
+
compression = {
|
|
559
|
+
savedTokens: c.savedTokens,
|
|
560
|
+
savedChars: c.savedChars,
|
|
561
|
+
beforeChars: c.before,
|
|
562
|
+
afterChars: c.after,
|
|
563
|
+
fieldsTouched: c.touched,
|
|
564
|
+
truth: "estimated"
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// Pre-call cap: price THIS request from its own tokens and block before
|
|
569
|
+
// forwarding if (already spent in the window + this call) would exceed the
|
|
570
|
+
// cap. Catches both accumulated overspend and a single oversized call.
|
|
571
|
+
const preCall = estimateRequestCost(requestBody);
|
|
572
|
+
const callEstimate = preCall.estimatedUsd ?? 0;
|
|
573
|
+
const projectedCostUsd = Number((summary.estimatedCostUsd + callEstimate).toFixed(6));
|
|
574
|
+
if (budget !== null && projectedCostUsd > budget) {
|
|
575
|
+
const blockedByThisCall = summary.estimatedCostUsd < budget;
|
|
389
576
|
const event = {
|
|
390
577
|
at: new Date().toISOString(),
|
|
391
578
|
path: url.pathname,
|
|
@@ -395,11 +582,39 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
|
|
|
395
582
|
usage: null,
|
|
396
583
|
cost: null,
|
|
397
584
|
truth: "budget_guard",
|
|
398
|
-
|
|
585
|
+
guard: {
|
|
586
|
+
spentUsd: summary.estimatedCostUsd,
|
|
587
|
+
callEstimateUsd: callEstimate,
|
|
588
|
+
callEstimateTruth: preCall.truth,
|
|
589
|
+
projectedUsd: projectedCostUsd,
|
|
590
|
+
capUsd: budget,
|
|
591
|
+
blockedByThisCall
|
|
592
|
+
},
|
|
593
|
+
error: blockedByThisCall
|
|
594
|
+
? `Budget would be exceeded by this call: $${summary.estimatedCostUsd} spent + ~$${callEstimate} this call > cap $${budget}`
|
|
595
|
+
: `Budget exceeded: ${summary.estimatedCostUsd} >= ${budget}`,
|
|
399
596
|
requestHash: createHash("sha1").update(bodyText).digest("hex")
|
|
400
597
|
};
|
|
401
598
|
await appendGatewayEvent(event);
|
|
402
|
-
sendJson(response, { error: event.error, truth: event.truth }, 429);
|
|
599
|
+
sendJson(response, { error: event.error, truth: event.truth, guard: event.guard }, 429);
|
|
600
|
+
const breachText = blockedByThisCall
|
|
601
|
+
? `Runcap: cap protected. Blocked a ~$${callEstimate} call on ${event.model} before it ran ($${summary.estimatedCostUsd} already spent, cap $${budget}).`
|
|
602
|
+
: `Runcap: cap hit. Run blocked at $${summary.estimatedCostUsd} (cap $${budget}) on ${event.model}. The gateway stopped the call before it could spend more.`;
|
|
603
|
+
sendAlert(breachText)
|
|
604
|
+
.then((channels) => {
|
|
605
|
+
if (channels && channels.length) console.log(`Cap-breach alert sent to: ${channels.join(", ")}`);
|
|
606
|
+
})
|
|
607
|
+
.catch(() => {});
|
|
608
|
+
syncRun({
|
|
609
|
+
mission_id: null,
|
|
610
|
+
label: `gateway cap breach (${event.model})`,
|
|
611
|
+
estimate_low: budget,
|
|
612
|
+
estimate_high: projectedCostUsd,
|
|
613
|
+
cap: budget,
|
|
614
|
+
actual: summary.estimatedCostUsd,
|
|
615
|
+
capped: true,
|
|
616
|
+
status: "capped"
|
|
617
|
+
}).catch(() => {});
|
|
403
618
|
return;
|
|
404
619
|
}
|
|
405
620
|
if (gatewayMode === "mock") {
|
|
@@ -414,6 +629,7 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
|
|
|
414
629
|
durationMs: Date.now() - started,
|
|
415
630
|
usage: responseBody.usage,
|
|
416
631
|
cost: estimateApiCost(responseBody.usage, requestBody.model ?? responseBody.model),
|
|
632
|
+
compression,
|
|
417
633
|
truth: "mock_provider_usage",
|
|
418
634
|
requestHash: createHash("sha1").update(bodyText).digest("hex")
|
|
419
635
|
});
|
|
@@ -437,13 +653,16 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
|
|
|
437
653
|
"authorization": `Bearer ${upstreamKey}`,
|
|
438
654
|
"content-type": request.headers["content-type"] ?? "application/json"
|
|
439
655
|
};
|
|
440
|
-
//
|
|
441
|
-
|
|
656
|
+
// Both default upstream base URLs already include /v1, and the child calls
|
|
657
|
+
// us at /v1/*. Strip the leading /v1 from the path when the upstream base
|
|
658
|
+
// already ends in /v1, so we never produce a doubled /v1/v1 (OpenAI 404).
|
|
659
|
+
const baseHasV1 = /\/v1\/?$/.test(upstreamBase);
|
|
660
|
+
const pathForUpstream = baseHasV1 ? url.pathname.replace(/^\/v1/, "") : url.pathname;
|
|
442
661
|
const upstreamUrl = `${upstreamBase.replace(/\/$/, "")}${pathForUpstream}`;
|
|
443
662
|
const upstreamResponse = await fetch(upstreamUrl, {
|
|
444
663
|
method: "POST",
|
|
445
664
|
headers,
|
|
446
|
-
body:
|
|
665
|
+
body: forwardBody
|
|
447
666
|
});
|
|
448
667
|
const responseText = await upstreamResponse.text();
|
|
449
668
|
response.writeHead(upstreamResponse.status, {
|
|
@@ -461,9 +680,23 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
|
|
|
461
680
|
durationMs: Date.now() - started,
|
|
462
681
|
usage: responseBody.usage ?? null,
|
|
463
682
|
cost: estimateApiCost(responseBody.usage, requestBody.model ?? responseBody.model),
|
|
683
|
+
compression,
|
|
464
684
|
truth: responseBody.usage ? "provider_usage" : "unknown",
|
|
465
685
|
requestHash: createHash("sha1").update(bodyText).digest("hex")
|
|
466
686
|
});
|
|
687
|
+
if (responseBody.usage) {
|
|
688
|
+
const spent = await readGatewaySummary({ windowMs: budgetWindowMs() });
|
|
689
|
+
syncRun({
|
|
690
|
+
mission_id: null,
|
|
691
|
+
label: "gateway session (actual spend)",
|
|
692
|
+
estimate_low: spent.estimatedCostUsd,
|
|
693
|
+
estimate_high: spent.estimatedCostUsd,
|
|
694
|
+
cap: budget,
|
|
695
|
+
actual: spent.estimatedCostUsd,
|
|
696
|
+
capped: false,
|
|
697
|
+
status: "running"
|
|
698
|
+
}).catch(() => {});
|
|
699
|
+
}
|
|
467
700
|
} catch (error) {
|
|
468
701
|
await appendGatewayEvent({
|
|
469
702
|
at: new Date().toISOString(),
|
|
@@ -479,6 +712,13 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
|
|
|
479
712
|
sendJson(response, { error: error.message }, 500);
|
|
480
713
|
}
|
|
481
714
|
});
|
|
715
|
+
return { server, gatewayMode, openaiKey, anthropicKey, openaiBaseUrl, anthropicBaseUrl };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export async function startGateway({ port = 8792, mock = false } = {}) {
|
|
719
|
+
await ensureStore();
|
|
720
|
+
const { server, gatewayMode, openaiKey, anthropicKey, openaiBaseUrl, anthropicBaseUrl } =
|
|
721
|
+
createGatewayServer({ port, mock });
|
|
482
722
|
await listenLocal(server, port, "gateway");
|
|
483
723
|
console.log(`Runcap gateway: http://127.0.0.1:${port}/v1`);
|
|
484
724
|
console.log(`Mode: ${gatewayMode}`);
|
|
@@ -491,6 +731,33 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
|
|
|
491
731
|
console.log("Press Ctrl+C to stop.");
|
|
492
732
|
}
|
|
493
733
|
|
|
734
|
+
// Start the gateway on an ephemeral free port for the duration of one wrapped
|
|
735
|
+
// run, returning a handle the wrapper uses to point the child at it and to shut
|
|
736
|
+
// it down afterward. Upstream is pinned from the CURRENT env before the child's
|
|
737
|
+
// base URLs are rewritten, so the gateway proxies to the real provider, not to
|
|
738
|
+
// itself.
|
|
739
|
+
async function startEphemeralGateway({ mock = false } = {}) {
|
|
740
|
+
await ensureStore();
|
|
741
|
+
const upstream = {
|
|
742
|
+
openaiKey: process.env.AIM_UPSTREAM_API_KEY ?? process.env.OPENAI_API_KEY,
|
|
743
|
+
anthropicKey: process.env.ANTHROPIC_API_KEY,
|
|
744
|
+
openaiBaseUrl: process.env.AIM_UPSTREAM_BASE_URL ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1",
|
|
745
|
+
anthropicBaseUrl: process.env.ANTHROPIC_BASE_URL ?? "https://api.anthropic.com/v1"
|
|
746
|
+
};
|
|
747
|
+
const { server, gatewayMode } = createGatewayServer({ port: 0, mock, upstream });
|
|
748
|
+
await new Promise((resolve, reject) => {
|
|
749
|
+
server.once("error", reject);
|
|
750
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
751
|
+
});
|
|
752
|
+
const actualPort = server.address().port;
|
|
753
|
+
return {
|
|
754
|
+
port: actualPort,
|
|
755
|
+
baseUrl: `http://127.0.0.1:${actualPort}`,
|
|
756
|
+
gatewayMode,
|
|
757
|
+
close: () => new Promise((resolve) => server.close(resolve))
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
494
761
|
export async function showStatus(options = {}) {
|
|
495
762
|
await ensureStore();
|
|
496
763
|
const fuel = await readFuel();
|
|
@@ -588,7 +855,9 @@ function buildAiWorkPlan(goal, { quality = "high", fuelPercent = null, snapshot
|
|
|
588
855
|
].filter(Boolean).length;
|
|
589
856
|
const hasRepo = Boolean(snapshot.packageJson);
|
|
590
857
|
const hasVerification = hasRepo && Object.keys(snapshot.packageJson?.scripts ?? {}).some((name) => /test|build|lint|typecheck/.test(name));
|
|
591
|
-
const fuel = Number.isFinite(Number(fuelPercent))
|
|
858
|
+
const fuel = fuelPercent === null || fuelPercent === undefined || fuelPercent === "" || !Number.isFinite(Number(fuelPercent))
|
|
859
|
+
? null
|
|
860
|
+
: Number(fuelPercent);
|
|
592
861
|
const budgetRisk = bigSignals > 0 || (fuel !== null && fuel < 30) ? "High" : fuel !== null && fuel < 55 ? "Medium" : "Low";
|
|
593
862
|
const expectedWasteReduction = budgetRisk === "High" ? "40-70%" : budgetRisk === "Medium" ? "25-45%" : "10-25%";
|
|
594
863
|
const qualityRisk = quality === "cheap" && budgetRisk === "High" ? "High" : budgetRisk === "High" ? "Medium" : "Low";
|
|
@@ -749,13 +1018,13 @@ function commandTemplatesForPlan(goal, missions) {
|
|
|
749
1018
|
}));
|
|
750
1019
|
}
|
|
751
1020
|
|
|
752
|
-
async function runChild(command, cwd) {
|
|
1021
|
+
async function runChild(command, cwd, extraEnv = {}) {
|
|
753
1022
|
const started = Date.now();
|
|
754
1023
|
const [program, ...args] = command;
|
|
755
1024
|
return await new Promise((resolve) => {
|
|
756
1025
|
const child = spawn(program, args, {
|
|
757
1026
|
cwd,
|
|
758
|
-
env: { ...process.env, AIM_WRAPPED: "1" },
|
|
1027
|
+
env: { ...process.env, AIM_WRAPPED: "1", ...extraEnv },
|
|
759
1028
|
shell: false
|
|
760
1029
|
});
|
|
761
1030
|
let stdout = "";
|
|
@@ -1099,6 +1368,7 @@ async function dashboardStatus() {
|
|
|
1099
1368
|
return {
|
|
1100
1369
|
fuel,
|
|
1101
1370
|
gateway,
|
|
1371
|
+
budget: readBudget(),
|
|
1102
1372
|
missionCount: missions.length,
|
|
1103
1373
|
latest: missions[0] ?? null,
|
|
1104
1374
|
counts: missions.reduce((acc, mission) => {
|
|
@@ -1118,26 +1388,83 @@ async function readGatewayEvents() {
|
|
|
1118
1388
|
return text.split("\n").filter(Boolean).map((line) => safeJson(line)).filter(Boolean);
|
|
1119
1389
|
}
|
|
1120
1390
|
|
|
1121
|
-
async function readGatewaySummary() {
|
|
1122
|
-
const
|
|
1391
|
+
async function readGatewaySummary({ windowMs } = {}) {
|
|
1392
|
+
const allEvents = await readGatewayEvents();
|
|
1393
|
+
// When a window is given (used by the budget guard), only count spend whose
|
|
1394
|
+
// timestamp falls inside it. The cap is then a per-window budget that resets,
|
|
1395
|
+
// not an all-time counter that locks the gateway forever.
|
|
1396
|
+
const events = windowMs
|
|
1397
|
+
? allEvents.filter((event) => {
|
|
1398
|
+
const t = Date.parse(event.at ?? "");
|
|
1399
|
+
return Number.isFinite(t) && Date.now() - t <= windowMs;
|
|
1400
|
+
})
|
|
1401
|
+
: allEvents;
|
|
1123
1402
|
const successful = events.filter((event) => event.status >= 200 && event.status < 300);
|
|
1124
|
-
const totalTokens = events.reduce((sum, event) =>
|
|
1403
|
+
const totalTokens = events.reduce((sum, event) => {
|
|
1404
|
+
const u = event.usage;
|
|
1405
|
+
if (!u) return sum;
|
|
1406
|
+
const total = Number(u.total_tokens ?? 0) ||
|
|
1407
|
+
Number(u.prompt_tokens ?? u.input_tokens ?? 0) + Number(u.completion_tokens ?? u.output_tokens ?? 0);
|
|
1408
|
+
return sum + total;
|
|
1409
|
+
}, 0);
|
|
1125
1410
|
const estimatedCost = events.reduce((sum, event) => sum + Number(event.cost?.estimatedUsd ?? 0), 0);
|
|
1411
|
+
const savedTokens = events.reduce((sum, event) => sum + Number(event.compression?.savedTokens ?? 0), 0);
|
|
1412
|
+
// Value the saved tokens at a blended input rate from the price table so we can
|
|
1413
|
+
// show one honest dollar figure. Per saved input token: use the model's input rate.
|
|
1414
|
+
const savedUsd = events.reduce((sum, event) => {
|
|
1415
|
+
const saved = Number(event.compression?.savedTokens ?? 0);
|
|
1416
|
+
if (!saved) return sum;
|
|
1417
|
+
const pricing = modelPricing(event.model);
|
|
1418
|
+
const inputRate = pricing ? pricing.inputPerMillion : 3; // fall back to a mid Sonnet-ish rate
|
|
1419
|
+
return sum + (saved * inputRate) / 1_000_000;
|
|
1420
|
+
}, 0);
|
|
1126
1421
|
return {
|
|
1127
1422
|
callCount: events.length,
|
|
1128
1423
|
successfulCallCount: successful.length,
|
|
1129
1424
|
totalTokens,
|
|
1130
1425
|
estimatedCostUsd: Number(estimatedCost.toFixed(6)),
|
|
1426
|
+
savedTokens,
|
|
1427
|
+
savedUsd: Number(savedUsd.toFixed(6)),
|
|
1428
|
+
wouldHaveSpentUsd: Number((estimatedCost + savedUsd).toFixed(6)),
|
|
1131
1429
|
truth: events.some((event) => event.truth === "provider_usage" || event.truth === "mock_provider_usage")
|
|
1132
1430
|
? "usage_plus_static_price_table"
|
|
1133
1431
|
: "unknown",
|
|
1432
|
+
windowMs: windowMs ?? null,
|
|
1134
1433
|
recent: events.slice(-20).reverse()
|
|
1135
1434
|
};
|
|
1136
1435
|
}
|
|
1137
1436
|
|
|
1437
|
+
// How wide the budget window is, in ms. AIM_BUDGET_WINDOW controls it:
|
|
1438
|
+
// "day" (default) → rolling 24h, "session" → since gateway start, "all" → no reset.
|
|
1439
|
+
const GATEWAY_STARTED_AT = Date.now();
|
|
1440
|
+
function budgetWindowMs() {
|
|
1441
|
+
const mode = (process.env.AIM_BUDGET_WINDOW ?? "day").toLowerCase();
|
|
1442
|
+
if (mode === "all") return undefined;
|
|
1443
|
+
if (mode === "session") return Date.now() - GATEWAY_STARTED_AT;
|
|
1444
|
+
const hours = Number(mode);
|
|
1445
|
+
if (Number.isFinite(hours) && hours > 0) return hours * 60 * 60 * 1000;
|
|
1446
|
+
return 24 * 60 * 60 * 1000; // "day" default
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// The cap value. Precedence: AIM_DAILY_BUDGET_USD env > persisted budget.json
|
|
1450
|
+
// (written by `runcap plan` / `runcap cap`). Null means no cap is set.
|
|
1138
1451
|
function readBudget() {
|
|
1139
1452
|
const raw = process.env.AIM_DAILY_BUDGET_USD;
|
|
1140
|
-
if (raw
|
|
1453
|
+
if (raw !== undefined && raw !== "") {
|
|
1454
|
+
const value = Number(raw);
|
|
1455
|
+
if (Number.isFinite(value) && value >= 0) return value;
|
|
1456
|
+
}
|
|
1457
|
+
const stored = readStoredBudget();
|
|
1458
|
+
return stored;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function readStoredBudget() {
|
|
1462
|
+
if (!existsSync(BUDGET_FILE)) return null;
|
|
1463
|
+
let text = null;
|
|
1464
|
+
try { text = readFileSync(BUDGET_FILE, "utf8"); } catch { return null; }
|
|
1465
|
+
const parsed = safeJson(text);
|
|
1466
|
+
const raw = parsed?.capUsd;
|
|
1467
|
+
if (raw === null || raw === undefined || raw === "") return null;
|
|
1141
1468
|
const value = Number(raw);
|
|
1142
1469
|
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
1143
1470
|
}
|
|
@@ -1254,6 +1581,43 @@ function estimateApiCost(usage, model) {
|
|
|
1254
1581
|
};
|
|
1255
1582
|
}
|
|
1256
1583
|
|
|
1584
|
+
// Estimate the cost of a request BEFORE it is forwarded upstream, from the
|
|
1585
|
+
// request body alone. Input tokens are estimated from the serialized prompt;
|
|
1586
|
+
// output tokens from the caller's max_tokens (the worst case the provider can
|
|
1587
|
+
// bill). Returns null when the model has no verified price, so the guard can
|
|
1588
|
+
// decide whether to fail open or closed rather than guessing a number.
|
|
1589
|
+
function estimateRequestCost(requestBody) {
|
|
1590
|
+
const model = requestBody?.model ?? "";
|
|
1591
|
+
const pricing = modelPricing(model);
|
|
1592
|
+
if (!pricing) return { estimatedUsd: null, truth: "unknown_price", model };
|
|
1593
|
+
|
|
1594
|
+
const promptText = JSON.stringify(
|
|
1595
|
+
requestBody.messages ?? requestBody.system ?? requestBody.input ?? requestBody.prompt ?? ""
|
|
1596
|
+
);
|
|
1597
|
+
const inputTokens = estimateTokens(promptText);
|
|
1598
|
+
// Worst-case output the provider could bill: honor the caller's stated cap,
|
|
1599
|
+
// else assume a generous default so the guard is not fooled by an open-ended call.
|
|
1600
|
+
const maxOutput = Number(
|
|
1601
|
+
requestBody.max_tokens ??
|
|
1602
|
+
requestBody.max_completion_tokens ??
|
|
1603
|
+
requestBody.max_output_tokens ??
|
|
1604
|
+
4096
|
|
1605
|
+
);
|
|
1606
|
+
const outputTokens = Number.isFinite(maxOutput) && maxOutput > 0 ? maxOutput : 4096;
|
|
1607
|
+
|
|
1608
|
+
const estimatedUsd =
|
|
1609
|
+
(inputTokens / 1_000_000) * pricing.inputPerMillion +
|
|
1610
|
+
(outputTokens / 1_000_000) * pricing.outputPerMillion;
|
|
1611
|
+
|
|
1612
|
+
return {
|
|
1613
|
+
estimatedUsd: Number(estimatedUsd.toFixed(6)),
|
|
1614
|
+
truth: "pre_call_estimate_from_request",
|
|
1615
|
+
model,
|
|
1616
|
+
inputTokens,
|
|
1617
|
+
outputTokens
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1257
1621
|
function modelPricing(model = "") {
|
|
1258
1622
|
const name = String(model).toLowerCase();
|
|
1259
1623
|
const batch = name.includes("batch");
|
|
@@ -1528,78 +1892,96 @@ function renderDashboardHtml() {
|
|
|
1528
1892
|
<meta charset="utf-8">
|
|
1529
1893
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1530
1894
|
<title>Runcap</title>
|
|
1895
|
+
<link rel="preconnect" href="https://api.fontshare.com" crossorigin>
|
|
1896
|
+
<link href="https://api.fontshare.com/v2/css?f[]=clash-display@600,700&f[]=general-sans@400,500,600,700&f[]=jetbrains-mono@400,500&display=swap" rel="stylesheet">
|
|
1531
1897
|
<style>
|
|
1532
|
-
:root { color-scheme:
|
|
1898
|
+
:root { color-scheme: light; --bg:#f6f7f9; --panel:#ffffff; --panel2:#fbfbfc; --soft:#f0f2f5; --line:#e6e8ec; --text:#0b0d12; --muted:#6b7280; --good:#0d9f6e; --warn:#b7791f; --bad:#dc2626; --accent:#4f46e5; --violet:#7c3aed; --shadow:0 1px 2px rgba(16,24,40,0.04), 0 8px 24px rgba(16,24,40,0.06); }
|
|
1533
1899
|
* { box-sizing: border-box; }
|
|
1534
|
-
body { margin:0; min-height:100vh; font-family:
|
|
1535
|
-
body:before { content:""; position:fixed; inset:0; pointer-events:none; background:radial-gradient(circle at
|
|
1900
|
+
body { margin:0; min-height:100vh; font-family: "General Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); }
|
|
1901
|
+
body:before { content:""; position:fixed; inset:0; pointer-events:none; background:radial-gradient(circle at 18% -4%, rgba(79,70,229,0.06), transparent 36%), radial-gradient(circle at 92% 4%, rgba(124,58,237,0.05), transparent 38%); }
|
|
1536
1902
|
button, textarea, select, input { font:inherit; }
|
|
1537
1903
|
.app { position:relative; display:grid; grid-template-columns: 320px minmax(0,1fr); min-height:100vh; }
|
|
1538
|
-
aside { border-right:1px solid var(--line); background:
|
|
1539
|
-
main { padding:
|
|
1540
|
-
h1 { margin:0; font-size:
|
|
1541
|
-
h2 { margin:0; font-size:
|
|
1542
|
-
h3 { margin:0; font-size:15px; }
|
|
1904
|
+
aside { border-right:1px solid var(--line); background:var(--panel); padding:22px; overflow:auto; }
|
|
1905
|
+
main { padding:32px 36px; overflow:auto; }
|
|
1906
|
+
h1 { margin:0; font-family:"Clash Display", sans-serif; font-weight:700; font-size:23px; letter-spacing:-0.01em; }
|
|
1907
|
+
h2 { margin:0; font-family:"Clash Display", sans-serif; font-weight:600; font-size:34px; line-height:1.08; letter-spacing:-0.02em; }
|
|
1908
|
+
h3 { margin:0; font-family:"Clash Display", sans-serif; font-weight:600; font-size:15px; }
|
|
1543
1909
|
p { margin:0; }
|
|
1544
1910
|
.muted { color:var(--muted); }
|
|
1545
1911
|
.brand { display:flex; align-items:center; gap:12px; margin-bottom:22px; }
|
|
1546
|
-
.mark { width:
|
|
1547
|
-
.tagline { color:var(--muted); font-size:13px; margin-top:
|
|
1912
|
+
.mark { width:40px; height:40px; border-radius:11px; display:grid; place-items:center; color:#fff; font-family:"Clash Display",sans-serif; font-weight:700; background:linear-gradient(135deg, var(--accent), var(--violet)); box-shadow:var(--shadow); }
|
|
1913
|
+
.tagline { color:var(--muted); font-size:13px; margin-top:3px; line-height:1.35; }
|
|
1548
1914
|
.nav { display:grid; gap:8px; margin:18px 0 22px; }
|
|
1549
|
-
.nav button { text-align:left; border:1px solid var(--line); background:
|
|
1550
|
-
.nav button.active, .nav button:hover { border-color:var(--accent); background:#
|
|
1551
|
-
.nav strong { display:block; }
|
|
1915
|
+
.nav button { text-align:left; border:1px solid var(--line); background:var(--panel); color:var(--text); border-radius:11px; padding:12px 14px; cursor:pointer; transition:all .15s; }
|
|
1916
|
+
.nav button.active, .nav button:hover { border-color:var(--accent); background:#f5f4ff; }
|
|
1917
|
+
.nav strong { display:block; font-weight:600; }
|
|
1552
1918
|
.nav span { display:block; color:var(--muted); font-size:12px; margin-top:3px; }
|
|
1553
|
-
.side-title { margin:18px 0 10px; color:var(--muted); font-size:
|
|
1919
|
+
.side-title { margin:18px 0 10px; color:var(--muted); font-size:11px; font-weight:600; letter-spacing:0.06em; text-transform:uppercase; }
|
|
1554
1920
|
.summary { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
|
|
1555
|
-
.mini, .panel, .mission, .metric, .step, .plan-card, details { border:1px solid var(--line); background:
|
|
1556
|
-
.mini { padding:
|
|
1557
|
-
.mini strong { display:block; font-size:22px; }
|
|
1921
|
+
.mini, .panel, .mission, .metric, .step, .plan-card, details { border:1px solid var(--line); background:var(--panel); border-radius:14px; }
|
|
1922
|
+
.mini { padding:13px; min-height:76px; box-shadow:var(--shadow); }
|
|
1923
|
+
.mini strong { display:block; font-family:"JetBrains Mono",monospace; font-size:22px; font-weight:500; }
|
|
1558
1924
|
.mini span { color:var(--muted); font-size:12px; }
|
|
1559
|
-
.mission { width:100%; color:inherit; text-align:left; cursor:pointer; margin:0 0 10px; padding:
|
|
1925
|
+
.mission { width:100%; color:inherit; text-align:left; cursor:pointer; margin:0 0 10px; padding:13px; transition:all .15s; }
|
|
1560
1926
|
.mission:hover, .mission.active { border-color:var(--accent); }
|
|
1561
|
-
.mission.active { background:#
|
|
1927
|
+
.mission.active { background:#f5f4ff; box-shadow: inset 3px 0 0 var(--accent); }
|
|
1562
1928
|
.mission-head { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:7px; }
|
|
1563
|
-
.mission-name { font-weight:
|
|
1929
|
+
.mission-name { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
1564
1930
|
.mission-line { color:var(--muted); font-size:13px; line-height:1.35; }
|
|
1565
|
-
.status { font-size:12px; border:1px solid var(--line); padding:4px
|
|
1566
|
-
.stuck { color:var(--bad); border-color:rgba(
|
|
1567
|
-
.at_risk { color:var(--warn); border-color:rgba(
|
|
1568
|
-
.progressing { color:var(--good); border-color:rgba(
|
|
1931
|
+
.status { font-size:12px; border:1px solid var(--line); padding:4px 9px; border-radius:999px; white-space:nowrap; font-weight:500; }
|
|
1932
|
+
.stuck { color:var(--bad); border-color:rgba(220,38,38,0.35); background:rgba(220,38,38,0.05); }
|
|
1933
|
+
.at_risk { color:var(--warn); border-color:rgba(183,121,31,0.35); background:rgba(183,121,31,0.05); }
|
|
1934
|
+
.progressing { color:var(--good); border-color:rgba(13,159,110,0.35); background:rgba(13,159,110,0.05); }
|
|
1569
1935
|
.hero { display:grid; grid-template-columns:minmax(0,1.2fr) minmax(360px,0.8fr); gap:18px; margin-bottom:18px; }
|
|
1570
|
-
.panel { padding:
|
|
1571
|
-
.hero-copy { color:var(--muted); font-size:
|
|
1936
|
+
.panel { padding:26px; box-shadow:var(--shadow); }
|
|
1937
|
+
.hero-copy { color:var(--muted); font-size:16px; line-height:1.55; margin-top:14px; max-width:880px; }
|
|
1938
|
+
/* SAVINGS HERO — the one visible number (Kirill's core fix) */
|
|
1939
|
+
.savings { grid-column:1 / -1; border:1px solid var(--line); border-radius:18px; padding:28px 30px; margin-bottom:18px; background:linear-gradient(135deg,#ffffff, #f7f6ff); box-shadow:var(--shadow); }
|
|
1940
|
+
.savings-label { font-size:12px; font-weight:600; letter-spacing:0.08em; text-transform:uppercase; color:var(--muted); }
|
|
1941
|
+
.savings-row { display:flex; align-items:flex-end; gap:14px; flex-wrap:wrap; margin-top:8px; }
|
|
1942
|
+
.savings-big { font-family:"Clash Display",sans-serif; font-weight:700; font-size:clamp(40px,6vw,68px); line-height:1; letter-spacing:-0.03em; background:linear-gradient(135deg,var(--accent),var(--violet)); -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; }
|
|
1943
|
+
.savings-unit { font-family:"JetBrains Mono",monospace; font-size:17px; color:var(--muted); padding-bottom:8px; }
|
|
1944
|
+
.savings-sub { color:var(--muted); font-size:15px; margin-top:12px; }
|
|
1945
|
+
.savings-sub b { color:var(--text); font-family:"JetBrains Mono",monospace; font-weight:500; }
|
|
1946
|
+
.capbar { margin-top:18px; }
|
|
1947
|
+
.capbar-track { height:12px; border-radius:999px; background:var(--soft); overflow:hidden; border:1px solid var(--line); }
|
|
1948
|
+
.capbar-fill { height:100%; border-radius:999px; background:linear-gradient(90deg,var(--good),var(--accent)); transition:width .4s; }
|
|
1949
|
+
.capbar-fill.warn { background:linear-gradient(90deg,var(--warn),#e8590c); }
|
|
1950
|
+
.capbar-fill.over { background:linear-gradient(90deg,var(--bad),#991b1b); }
|
|
1951
|
+
.capbar-meta { display:flex; justify-content:space-between; font-size:12px; color:var(--muted); margin-top:7px; font-family:"JetBrains Mono",monospace; }
|
|
1572
1952
|
.badge-row { display:flex; flex-wrap:wrap; gap:8px; margin-top:18px; }
|
|
1573
|
-
.badge { display:inline-flex; align-items:center; gap:6px; border:1px solid var(--line); color:var(--muted); border-radius:999px; padding:6px
|
|
1574
|
-
.badge.good { color:var(--good); border-color:rgba(
|
|
1575
|
-
.badge.warn { color:var(--warn); border-color:rgba(
|
|
1576
|
-
.badge.bad { color:var(--bad); border-color:rgba(
|
|
1953
|
+
.badge { display:inline-flex; align-items:center; gap:6px; border:1px solid var(--line); color:var(--muted); border-radius:999px; padding:6px 11px; font-size:12px; background:var(--panel2); }
|
|
1954
|
+
.badge.good { color:var(--good); border-color:rgba(13,159,110,0.35); }
|
|
1955
|
+
.badge.warn { color:var(--warn); border-color:rgba(183,121,31,0.35); }
|
|
1956
|
+
.badge.bad { color:var(--bad); border-color:rgba(220,38,38,0.35); }
|
|
1577
1957
|
.metrics { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:20px; }
|
|
1578
|
-
.metric { padding:
|
|
1579
|
-
.metric strong { display:block; font-size:
|
|
1958
|
+
.metric { padding:15px; box-shadow:var(--shadow); }
|
|
1959
|
+
.metric strong { display:block; font-family:"JetBrains Mono",monospace; font-size:23px; font-weight:500; line-height:1.1; }
|
|
1580
1960
|
.metric span { display:block; color:var(--muted); font-size:12px; margin-top:6px; }
|
|
1581
|
-
.planner textarea { width:100%; min-height:128px; resize:vertical; background
|
|
1961
|
+
.planner textarea { width:100%; min-height:128px; resize:vertical; background:var(--panel2); color:var(--text); border:1px solid var(--line); border-radius:11px; padding:13px; line-height:1.45; }
|
|
1582
1962
|
.field-row { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:10px; }
|
|
1583
|
-
select, input { width:100%; background
|
|
1584
|
-
label { display:block; color:var(--muted); font-size:12px; font-weight:
|
|
1585
|
-
.primary, .ghost { border-radius:
|
|
1586
|
-
.primary { border:1px solid
|
|
1587
|
-
.
|
|
1963
|
+
select, input { width:100%; background:var(--panel2); color:var(--text); border:1px solid var(--line); border-radius:11px; padding:11px; }
|
|
1964
|
+
label { display:block; color:var(--muted); font-size:12px; font-weight:600; margin:0 0 7px; }
|
|
1965
|
+
.primary, .ghost { border-radius:11px; padding:11px 16px; cursor:pointer; font-weight:600; transition:all .15s; }
|
|
1966
|
+
.primary { border:1px solid transparent; color:#fff; background:linear-gradient(135deg, var(--accent), var(--violet)); box-shadow:0 6px 16px rgba(79,70,229,0.25); }
|
|
1967
|
+
.primary:hover { filter:brightness(1.06); transform:translateY(-1px); }
|
|
1968
|
+
.ghost { border:1px solid var(--line); color:var(--text); background:var(--panel); }
|
|
1969
|
+
.ghost:hover { border-color:var(--accent); }
|
|
1588
1970
|
.actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:12px; }
|
|
1589
1971
|
.plan-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; margin:18px 0; }
|
|
1590
|
-
.plan-card { padding:
|
|
1591
|
-
.plan-card strong { display:block; margin-bottom:8px; }
|
|
1972
|
+
.plan-card { padding:18px; box-shadow:var(--shadow); }
|
|
1973
|
+
.plan-card strong { display:block; margin-bottom:8px; font-weight:600; }
|
|
1592
1974
|
.plan-card p, .step p { color:var(--muted); line-height:1.48; }
|
|
1593
1975
|
.timeline { display:grid; gap:10px; margin-top:14px; }
|
|
1594
|
-
.step { padding:
|
|
1595
|
-
.num { width:28px; height:28px; border-radius:
|
|
1596
|
-
.rescue { border-color:rgba(
|
|
1597
|
-
.decision { color:var(--
|
|
1598
|
-
pre { white-space:pre-wrap; margin:12px 0 0; background:#
|
|
1599
|
-
details { padding:14px
|
|
1600
|
-
summary { cursor:pointer; color:var(--muted); font-weight:
|
|
1976
|
+
.step { padding:16px; display:grid; grid-template-columns:34px minmax(0,1fr); gap:12px; align-items:start; box-shadow:var(--shadow); }
|
|
1977
|
+
.num { width:28px; height:28px; border-radius:9px; display:grid; place-items:center; background:#f5f4ff; border:1px solid var(--line); color:var(--accent); font-family:"JetBrains Mono",monospace; font-weight:500; }
|
|
1978
|
+
.rescue { border-color:rgba(79,70,229,0.4); background:linear-gradient(135deg,#ffffff,#f7f6ff); }
|
|
1979
|
+
.decision { color:var(--bad); font-weight:600; font-size:18px; margin:8px 0 0; }
|
|
1980
|
+
pre { white-space:pre-wrap; margin:12px 0 0; background:#0b0d12; color:#e6e8ec; border:1px solid var(--line); border-radius:11px; padding:14px; line-height:1.5; overflow:auto; font-family:"JetBrains Mono",monospace; font-size:13px; }
|
|
1981
|
+
details { padding:14px 18px; margin-top:14px; box-shadow:var(--shadow); }
|
|
1982
|
+
summary { cursor:pointer; color:var(--muted); font-weight:600; }
|
|
1601
1983
|
.hidden { display:none; }
|
|
1602
|
-
.empty { padding:
|
|
1984
|
+
.empty { padding:42px; text-align:left; }
|
|
1603
1985
|
.copy { margin-top:10px; }
|
|
1604
1986
|
@media (max-width: 1180px) { .app { grid-template-columns:1fr; } aside { border-right:0; border-bottom:1px solid var(--line); } .hero, .plan-grid, .metrics { grid-template-columns:1fr; } .field-row { grid-template-columns:1fr; } }
|
|
1605
1987
|
</style>
|
|
@@ -1608,10 +1990,10 @@ function renderDashboardHtml() {
|
|
|
1608
1990
|
<div class="app">
|
|
1609
1991
|
<aside>
|
|
1610
1992
|
<div class="brand">
|
|
1611
|
-
<div class="mark">
|
|
1993
|
+
<div class="mark">R</div>
|
|
1612
1994
|
<div>
|
|
1613
1995
|
<h1>Runcap</h1>
|
|
1614
|
-
<div class="tagline">
|
|
1996
|
+
<div class="tagline">Estimate cost. Cap spend. Compress tokens. Rescue stuck runs.</div>
|
|
1615
1997
|
</div>
|
|
1616
1998
|
</div>
|
|
1617
1999
|
<div class="nav">
|
|
@@ -1624,10 +2006,10 @@ function renderDashboardHtml() {
|
|
|
1624
2006
|
<div class="mission-line" id="truth">Gateway truth: loading...</div>
|
|
1625
2007
|
</div>
|
|
1626
2008
|
<div class="summary">
|
|
1627
|
-
<div class="mini"><strong id="
|
|
1628
|
-
<div class="mini"><strong id="
|
|
2009
|
+
<div class="mini"><strong id="cost">$0</strong><span>spent so far</span></div>
|
|
2010
|
+
<div class="mini"><strong id="saved" style="color:var(--good)">$0</strong><span>saved by compression</span></div>
|
|
1629
2011
|
<div class="mini"><strong id="tokens">0</strong><span>API tokens</span></div>
|
|
1630
|
-
<div class="mini"><strong id="
|
|
2012
|
+
<div class="mini"><strong id="needs">0</strong><span>need attention</span></div>
|
|
1631
2013
|
</div>
|
|
1632
2014
|
<div class="side-title">Saved plans</div>
|
|
1633
2015
|
<div id="plans"></div>
|
|
@@ -1652,13 +2034,16 @@ function renderDashboardHtml() {
|
|
|
1652
2034
|
state.plans = plans;
|
|
1653
2035
|
document.getElementById("fuel").textContent = status.fuel.currentPercent === null ? "Fuel: unknown" : "Fuel: " + status.fuel.currentPercent + "%";
|
|
1654
2036
|
document.getElementById("truth").textContent = "Gateway truth: " + status.gateway.truth;
|
|
1655
|
-
document.getElementById("total").textContent = status.missionCount;
|
|
1656
2037
|
document.getElementById("needs").textContent = (status.counts.stuck ?? 0) + (status.counts.at_risk ?? 0);
|
|
1657
|
-
document.getElementById("tokens").textContent = status.gateway.totalTokens;
|
|
1658
|
-
document.getElementById("cost").textContent = "$" + status.gateway.estimatedCostUsd;
|
|
2038
|
+
document.getElementById("tokens").textContent = Number(status.gateway.totalTokens || 0).toLocaleString();
|
|
2039
|
+
document.getElementById("cost").textContent = "$" + (status.gateway.estimatedCostUsd ?? 0);
|
|
2040
|
+
document.getElementById("saved").textContent = "$" + (status.gateway.savedUsd ?? 0);
|
|
2041
|
+
state.gateway = status.gateway;
|
|
2042
|
+
state.budget = status.budget;
|
|
1659
2043
|
renderList();
|
|
1660
2044
|
renderPlans();
|
|
1661
2045
|
if (!state.plannerRendered) renderPlanner(status);
|
|
2046
|
+
renderSavingsHero(status.gateway);
|
|
1662
2047
|
if (!state.selected && missions[0]) showMission(missions[0].id, false);
|
|
1663
2048
|
if (!missions[0]) renderEmptyMonitor();
|
|
1664
2049
|
}
|
|
@@ -1687,10 +2072,40 @@ function renderDashboardHtml() {
|
|
|
1687
2072
|
'</button>'
|
|
1688
2073
|
).join("");
|
|
1689
2074
|
}
|
|
2075
|
+
function renderSavingsHero(g) {
|
|
2076
|
+
const el = document.getElementById("savings-hero");
|
|
2077
|
+
if (!el || !g) return;
|
|
2078
|
+
const saved = Number(g.savedUsd ?? 0);
|
|
2079
|
+
const tokens = Number(g.savedTokens ?? 0);
|
|
2080
|
+
const spent = Number(g.estimatedCostUsd ?? 0);
|
|
2081
|
+
const wouldHave = Number(g.wouldHaveSpentUsd ?? spent);
|
|
2082
|
+
const fmt = (n) => "$" + (n < 0.01 && n > 0 ? n.toFixed(4) : n.toFixed(2));
|
|
2083
|
+
if (tokens === 0 && spent === 0) {
|
|
2084
|
+
el.innerHTML = '<div class="savings"><div class="savings-label">Your savings will show here</div>' +
|
|
2085
|
+
'<div class="savings-row"><div class="savings-big">$0.00</div><div class="savings-unit">saved so far</div></div>' +
|
|
2086
|
+
'<div class="savings-sub">Point your agent at the Runcap gateway and every call is compressed and capped. This number grows on its own.</div></div>';
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
// cap bar
|
|
2090
|
+
let capHtml = '';
|
|
2091
|
+
if (state.budget && state.budget > 0) {
|
|
2092
|
+
const pct = Math.min(100, (spent / state.budget) * 100);
|
|
2093
|
+
const cls = pct >= 100 ? 'over' : pct >= 80 ? 'warn' : '';
|
|
2094
|
+
capHtml = '<div class="capbar"><div class="capbar-track"><div class="capbar-fill ' + cls + '" style="width:' + pct.toFixed(1) + '%"></div></div>' +
|
|
2095
|
+
'<div class="capbar-meta"><span>spent ' + fmt(spent) + '</span><span>cap ' + fmt(state.budget) + '</span></div></div>';
|
|
2096
|
+
}
|
|
2097
|
+
el.innerHTML = '<div class="savings">' +
|
|
2098
|
+
'<div class="savings-label">You saved</div>' +
|
|
2099
|
+
'<div class="savings-row"><div class="savings-big">' + fmt(saved) + '</div><div class="savings-unit">' + tokens.toLocaleString() + ' tokens compressed away</div></div>' +
|
|
2100
|
+
'<div class="savings-sub">You would have spent <b>' + fmt(wouldHave) + '</b>, Runcap compressed it down to <b>' + fmt(spent) + '</b>. Same answers, fewer tokens.</div>' +
|
|
2101
|
+
capHtml +
|
|
2102
|
+
'</div>';
|
|
2103
|
+
}
|
|
1690
2104
|
function renderPlanner(status) {
|
|
1691
2105
|
state.plannerRendered = true;
|
|
1692
2106
|
const fuel = status.fuel.currentPercent === null ? 24 : Number(status.fuel.currentPercent);
|
|
1693
2107
|
document.getElementById("plan-view").innerHTML =
|
|
2108
|
+
'<div id="savings-hero"></div>' +
|
|
1694
2109
|
'<div class="hero">' +
|
|
1695
2110
|
'<div class="panel">' +
|
|
1696
2111
|
'<h2>Turn one expensive AI request into a managed plan.</h2>' +
|