runcap 0.1.0 → 0.2.0
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 +84 -8
- 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 +541 -86
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,38 @@ 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
|
+
export async function setBudgetCap(capUsd, { source = "manual" } = {}) {
|
|
251
|
+
await ensureStore();
|
|
252
|
+
const value = Number(capUsd);
|
|
253
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
254
|
+
throw new Error("Usage: runcap cap <usd> (a non-negative number).");
|
|
255
|
+
}
|
|
256
|
+
await writeFile(BUDGET_FILE, JSON.stringify({ capUsd: value, source, setAt: new Date().toISOString() }, null, 2));
|
|
257
|
+
const envNote = process.env.AIM_DAILY_BUDGET_USD
|
|
258
|
+
? "\nNote: AIM_DAILY_BUDGET_USD is set in your env and overrides this file."
|
|
259
|
+
: "";
|
|
260
|
+
return `Hard cap set: $${value.toFixed(2)} per ${(process.env.AIM_BUDGET_WINDOW ?? "day")}. Saved to ${BUDGET_FILE}.${envNote}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function clearBudgetCap() {
|
|
264
|
+
await ensureStore();
|
|
265
|
+
if (existsSync(BUDGET_FILE)) await writeFile(BUDGET_FILE, JSON.stringify({ capUsd: null, clearedAt: new Date().toISOString() }, null, 2));
|
|
266
|
+
return "Stored cap cleared. The gateway will only enforce AIM_DAILY_BUDGET_USD if set.";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function currentBudgetCap() {
|
|
270
|
+
const cap = readBudget();
|
|
271
|
+
if (cap === null) return "No cap set. Run `runcap cap <usd>` or `runcap plan --apply-cap`.";
|
|
272
|
+
const src = process.env.AIM_DAILY_BUDGET_USD ? "env AIM_DAILY_BUDGET_USD" : `file ${BUDGET_FILE}`;
|
|
273
|
+
return `Current hard cap: $${cap.toFixed(2)} per ${(process.env.AIM_BUDGET_WINDOW ?? "day")} (from ${src}).`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function hasStoredCap() {
|
|
277
|
+
return readStoredBudget() !== null;
|
|
278
|
+
}
|
|
279
|
+
|
|
210
280
|
export async function listPlans() {
|
|
211
281
|
await ensureStore();
|
|
212
282
|
const plans = await readPlans();
|
|
@@ -244,9 +314,15 @@ export async function setupProject() {
|
|
|
244
314
|
"OPENAI_API_KEY=",
|
|
245
315
|
"AIM_UPSTREAM_BASE_URL=https://api.openai.com/v1",
|
|
246
316
|
"",
|
|
247
|
-
"#
|
|
317
|
+
"# Hard cap (USD) per budget window. The gateway prices each call from its",
|
|
318
|
+
"# own tokens and blocks it BEFORE forwarding if it would push spend over the cap.",
|
|
319
|
+
"# You can also set this with `runcap cap <usd>` or `runcap plan --apply-cap`.",
|
|
248
320
|
"AIM_DAILY_BUDGET_USD=5",
|
|
249
321
|
"",
|
|
322
|
+
"# Budget window: day (default, rolling 24h), session (since gateway start),",
|
|
323
|
+
"# all (never resets), or a number of hours. Caps reset per window.",
|
|
324
|
+
"AIM_BUDGET_WINDOW=day",
|
|
325
|
+
"",
|
|
250
326
|
"# For demo mode without external API calls:",
|
|
251
327
|
"AIM_GATEWAY_MODE=mock"
|
|
252
328
|
].join("\n");
|
|
@@ -292,6 +368,80 @@ export async function doctor() {
|
|
|
292
368
|
].join("\n");
|
|
293
369
|
}
|
|
294
370
|
|
|
371
|
+
// Guided first-run, shown when `runcap` is invoked with no arguments. Explains
|
|
372
|
+
// in one screen what Runcap does, what it does NOT do, checks readiness, and
|
|
373
|
+
// gives exactly ONE next step based on the current state — so a newcomer reaches
|
|
374
|
+
// their first result without reading docs.
|
|
375
|
+
export async function welcome() {
|
|
376
|
+
await ensureStore();
|
|
377
|
+
const hasOpenAiKey = Boolean(process.env.AIM_UPSTREAM_API_KEY ?? process.env.OPENAI_API_KEY);
|
|
378
|
+
const hasAnthropicKey = Boolean(process.env.ANTHROPIC_API_KEY);
|
|
379
|
+
const hasAnyKey = hasOpenAiKey || hasAnthropicKey;
|
|
380
|
+
const cap = readBudget();
|
|
381
|
+
const gateway = await readGatewaySummary({ windowMs: budgetWindowMs() });
|
|
382
|
+
const window = process.env.AIM_BUDGET_WINDOW ?? "day";
|
|
383
|
+
|
|
384
|
+
const tick = (ok) => (ok ? "[x]" : "[ ]");
|
|
385
|
+
const keyLabel = hasAnyKey
|
|
386
|
+
? `API key detected (${[hasAnthropicKey && "Anthropic", hasOpenAiKey && "OpenAI"].filter(Boolean).join(" + ")})`
|
|
387
|
+
: "No API key in this shell (set ANTHROPIC_API_KEY or OPENAI_API_KEY)";
|
|
388
|
+
const capLabel = cap === null ? "No cap set yet" : `Cap set: $${cap.toFixed(2)} per ${window}`;
|
|
389
|
+
|
|
390
|
+
// One next step, chosen by what is missing.
|
|
391
|
+
let nextStep;
|
|
392
|
+
if (!hasAnyKey) {
|
|
393
|
+
nextStep = [
|
|
394
|
+
"Next: give Runcap the same provider key your agent already uses, e.g.",
|
|
395
|
+
" export ANTHROPIC_API_KEY=sk-... # or OPENAI_API_KEY=sk-...",
|
|
396
|
+
"Then run `runcap` again."
|
|
397
|
+
];
|
|
398
|
+
} else if (cap === null) {
|
|
399
|
+
nextStep = [
|
|
400
|
+
"Next: set the most you want a run to spend, then run your agent through Runcap:",
|
|
401
|
+
" runcap cap 5",
|
|
402
|
+
" runcap run -- claude \"fix the failing test\"",
|
|
403
|
+
"Runcap starts a local gateway, points your agent at it, and blocks any call",
|
|
404
|
+
"that would push spend over $5, before it reaches the paid API.",
|
|
405
|
+
"",
|
|
406
|
+
"Not sure what to cap at? Estimate first:",
|
|
407
|
+
" runcap plan --apply-cap -- \"the task you're about to run\""
|
|
408
|
+
];
|
|
409
|
+
} else {
|
|
410
|
+
nextStep = [
|
|
411
|
+
`You're ready. Cap is $${cap.toFixed(2)} per ${window}. Run any agent through Runcap:`,
|
|
412
|
+
" runcap run -- claude \"fix the failing test\"",
|
|
413
|
+
" runcap run -- codex \"...\" runcap run -- python my_agent.py",
|
|
414
|
+
"",
|
|
415
|
+
gateway.callCount > 0
|
|
416
|
+
? `Spent so far this ${window}: $${gateway.estimatedCostUsd.toFixed(4)} across ${gateway.callCount} calls. See: runcap status`
|
|
417
|
+
: "No calls recorded yet. Your first `runcap run` will show the spend."
|
|
418
|
+
];
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return [
|
|
422
|
+
"Runcap: see and cap what your AI agent spends, before it spends it.",
|
|
423
|
+
"",
|
|
424
|
+
"What it does:",
|
|
425
|
+
" - Prices each call your agent makes from its own tokens.",
|
|
426
|
+
" - Blocks any call that would exceed your cap BEFORE it hits the paid API.",
|
|
427
|
+
" - Shows you the real spend, per run and per day.",
|
|
428
|
+
"",
|
|
429
|
+
"What it does NOT do (so there are no surprises):",
|
|
430
|
+
" - It does not give you an AI model. You bring your own provider API key.",
|
|
431
|
+
" - It does not run tasks for you. You bring your own agent (Claude Code,",
|
|
432
|
+
" Codex, a script: anything that calls OpenAI/Anthropic).",
|
|
433
|
+
" - It is a local tool for that setup, not a no-account web app.",
|
|
434
|
+
"",
|
|
435
|
+
"Readiness:",
|
|
436
|
+
` ${tick(hasAnyKey)} ${keyLabel}`,
|
|
437
|
+
` ${tick(cap !== null)} ${capLabel}`,
|
|
438
|
+
"",
|
|
439
|
+
...nextStep,
|
|
440
|
+
"",
|
|
441
|
+
"Full command list: `runcap help`."
|
|
442
|
+
].join("\n");
|
|
443
|
+
}
|
|
444
|
+
|
|
295
445
|
export async function startDashboard({ port = 8791 } = {}) {
|
|
296
446
|
await ensureStore();
|
|
297
447
|
const server = http.createServer(async (request, response) => {
|
|
@@ -350,13 +500,15 @@ async function listenLocal(server, port, label) {
|
|
|
350
500
|
});
|
|
351
501
|
}
|
|
352
502
|
|
|
353
|
-
|
|
354
|
-
|
|
503
|
+
// Build (but do not start) the gateway HTTP server. Upstream targets are
|
|
504
|
+
// captured here from explicit args or env, so the auto-wrapper can pin the real
|
|
505
|
+
// upstream BEFORE it rewrites the child's base URLs to point at this gateway.
|
|
506
|
+
function createGatewayServer({ port = 8792, mock = false, upstream = {} } = {}) {
|
|
355
507
|
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";
|
|
508
|
+
const openaiKey = upstream.openaiKey ?? process.env.AIM_UPSTREAM_API_KEY ?? process.env.OPENAI_API_KEY;
|
|
509
|
+
const anthropicKey = upstream.anthropicKey ?? process.env.ANTHROPIC_API_KEY;
|
|
510
|
+
const openaiBaseUrl = upstream.openaiBaseUrl ?? process.env.AIM_UPSTREAM_BASE_URL ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1";
|
|
511
|
+
const anthropicBaseUrl = upstream.anthropicBaseUrl ?? process.env.ANTHROPIC_BASE_URL ?? "https://api.anthropic.com/v1";
|
|
360
512
|
const anthropicVersion = process.env.ANTHROPIC_VERSION ?? "2023-06-01";
|
|
361
513
|
if (gatewayMode !== "mock" && !openaiKey && !anthropicKey) {
|
|
362
514
|
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 +536,33 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
|
|
|
384
536
|
const bodyText = await readRequestBody(request);
|
|
385
537
|
const requestBody = safeJson(bodyText) ?? {};
|
|
386
538
|
const budget = readBudget();
|
|
387
|
-
const summary = await readGatewaySummary();
|
|
388
|
-
|
|
539
|
+
const summary = await readGatewaySummary({ windowMs: budgetWindowMs() });
|
|
540
|
+
// Compress the request body once (safe, lossless-by-construction). Disable with AIM_COMPRESS=off.
|
|
541
|
+
const compressionOn = (process.env.AIM_COMPRESS ?? "on").toLowerCase() !== "off";
|
|
542
|
+
let forwardBody = bodyText;
|
|
543
|
+
let compression = null;
|
|
544
|
+
if (compressionOn) {
|
|
545
|
+
const c = compressRequestBody(requestBody);
|
|
546
|
+
if (c.savedChars > 0 && c.touched > 0) {
|
|
547
|
+
forwardBody = JSON.stringify(c.body);
|
|
548
|
+
compression = {
|
|
549
|
+
savedTokens: c.savedTokens,
|
|
550
|
+
savedChars: c.savedChars,
|
|
551
|
+
beforeChars: c.before,
|
|
552
|
+
afterChars: c.after,
|
|
553
|
+
fieldsTouched: c.touched,
|
|
554
|
+
truth: "estimated"
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
// Pre-call cap: price THIS request from its own tokens and block before
|
|
559
|
+
// forwarding if (already spent in the window + this call) would exceed the
|
|
560
|
+
// cap. Catches both accumulated overspend and a single oversized call.
|
|
561
|
+
const preCall = estimateRequestCost(requestBody);
|
|
562
|
+
const callEstimate = preCall.estimatedUsd ?? 0;
|
|
563
|
+
const projectedCostUsd = Number((summary.estimatedCostUsd + callEstimate).toFixed(6));
|
|
564
|
+
if (budget !== null && projectedCostUsd > budget) {
|
|
565
|
+
const blockedByThisCall = summary.estimatedCostUsd < budget;
|
|
389
566
|
const event = {
|
|
390
567
|
at: new Date().toISOString(),
|
|
391
568
|
path: url.pathname,
|
|
@@ -395,11 +572,39 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
|
|
|
395
572
|
usage: null,
|
|
396
573
|
cost: null,
|
|
397
574
|
truth: "budget_guard",
|
|
398
|
-
|
|
575
|
+
guard: {
|
|
576
|
+
spentUsd: summary.estimatedCostUsd,
|
|
577
|
+
callEstimateUsd: callEstimate,
|
|
578
|
+
callEstimateTruth: preCall.truth,
|
|
579
|
+
projectedUsd: projectedCostUsd,
|
|
580
|
+
capUsd: budget,
|
|
581
|
+
blockedByThisCall
|
|
582
|
+
},
|
|
583
|
+
error: blockedByThisCall
|
|
584
|
+
? `Budget would be exceeded by this call: $${summary.estimatedCostUsd} spent + ~$${callEstimate} this call > cap $${budget}`
|
|
585
|
+
: `Budget exceeded: ${summary.estimatedCostUsd} >= ${budget}`,
|
|
399
586
|
requestHash: createHash("sha1").update(bodyText).digest("hex")
|
|
400
587
|
};
|
|
401
588
|
await appendGatewayEvent(event);
|
|
402
|
-
sendJson(response, { error: event.error, truth: event.truth }, 429);
|
|
589
|
+
sendJson(response, { error: event.error, truth: event.truth, guard: event.guard }, 429);
|
|
590
|
+
const breachText = blockedByThisCall
|
|
591
|
+
? `Runcap: cap protected. Blocked a ~$${callEstimate} call on ${event.model} before it ran ($${summary.estimatedCostUsd} already spent, cap $${budget}).`
|
|
592
|
+
: `Runcap: cap hit. Run blocked at $${summary.estimatedCostUsd} (cap $${budget}) on ${event.model}. The gateway stopped the call before it could spend more.`;
|
|
593
|
+
sendAlert(breachText)
|
|
594
|
+
.then((channels) => {
|
|
595
|
+
if (channels && channels.length) console.log(`Cap-breach alert sent to: ${channels.join(", ")}`);
|
|
596
|
+
})
|
|
597
|
+
.catch(() => {});
|
|
598
|
+
syncRun({
|
|
599
|
+
mission_id: null,
|
|
600
|
+
label: `gateway cap breach (${event.model})`,
|
|
601
|
+
estimate_low: budget,
|
|
602
|
+
estimate_high: projectedCostUsd,
|
|
603
|
+
cap: budget,
|
|
604
|
+
actual: summary.estimatedCostUsd,
|
|
605
|
+
capped: true,
|
|
606
|
+
status: "capped"
|
|
607
|
+
}).catch(() => {});
|
|
403
608
|
return;
|
|
404
609
|
}
|
|
405
610
|
if (gatewayMode === "mock") {
|
|
@@ -414,6 +619,7 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
|
|
|
414
619
|
durationMs: Date.now() - started,
|
|
415
620
|
usage: responseBody.usage,
|
|
416
621
|
cost: estimateApiCost(responseBody.usage, requestBody.model ?? responseBody.model),
|
|
622
|
+
compression,
|
|
417
623
|
truth: "mock_provider_usage",
|
|
418
624
|
requestHash: createHash("sha1").update(bodyText).digest("hex")
|
|
419
625
|
});
|
|
@@ -437,13 +643,16 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
|
|
|
437
643
|
"authorization": `Bearer ${upstreamKey}`,
|
|
438
644
|
"content-type": request.headers["content-type"] ?? "application/json"
|
|
439
645
|
};
|
|
440
|
-
//
|
|
441
|
-
|
|
646
|
+
// Both default upstream base URLs already include /v1, and the child calls
|
|
647
|
+
// us at /v1/*. Strip the leading /v1 from the path when the upstream base
|
|
648
|
+
// already ends in /v1, so we never produce a doubled /v1/v1 (OpenAI 404).
|
|
649
|
+
const baseHasV1 = /\/v1\/?$/.test(upstreamBase);
|
|
650
|
+
const pathForUpstream = baseHasV1 ? url.pathname.replace(/^\/v1/, "") : url.pathname;
|
|
442
651
|
const upstreamUrl = `${upstreamBase.replace(/\/$/, "")}${pathForUpstream}`;
|
|
443
652
|
const upstreamResponse = await fetch(upstreamUrl, {
|
|
444
653
|
method: "POST",
|
|
445
654
|
headers,
|
|
446
|
-
body:
|
|
655
|
+
body: forwardBody
|
|
447
656
|
});
|
|
448
657
|
const responseText = await upstreamResponse.text();
|
|
449
658
|
response.writeHead(upstreamResponse.status, {
|
|
@@ -461,9 +670,23 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
|
|
|
461
670
|
durationMs: Date.now() - started,
|
|
462
671
|
usage: responseBody.usage ?? null,
|
|
463
672
|
cost: estimateApiCost(responseBody.usage, requestBody.model ?? responseBody.model),
|
|
673
|
+
compression,
|
|
464
674
|
truth: responseBody.usage ? "provider_usage" : "unknown",
|
|
465
675
|
requestHash: createHash("sha1").update(bodyText).digest("hex")
|
|
466
676
|
});
|
|
677
|
+
if (responseBody.usage) {
|
|
678
|
+
const spent = await readGatewaySummary({ windowMs: budgetWindowMs() });
|
|
679
|
+
syncRun({
|
|
680
|
+
mission_id: null,
|
|
681
|
+
label: "gateway session (actual spend)",
|
|
682
|
+
estimate_low: spent.estimatedCostUsd,
|
|
683
|
+
estimate_high: spent.estimatedCostUsd,
|
|
684
|
+
cap: budget,
|
|
685
|
+
actual: spent.estimatedCostUsd,
|
|
686
|
+
capped: false,
|
|
687
|
+
status: "running"
|
|
688
|
+
}).catch(() => {});
|
|
689
|
+
}
|
|
467
690
|
} catch (error) {
|
|
468
691
|
await appendGatewayEvent({
|
|
469
692
|
at: new Date().toISOString(),
|
|
@@ -479,6 +702,13 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
|
|
|
479
702
|
sendJson(response, { error: error.message }, 500);
|
|
480
703
|
}
|
|
481
704
|
});
|
|
705
|
+
return { server, gatewayMode, openaiKey, anthropicKey, openaiBaseUrl, anthropicBaseUrl };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
export async function startGateway({ port = 8792, mock = false } = {}) {
|
|
709
|
+
await ensureStore();
|
|
710
|
+
const { server, gatewayMode, openaiKey, anthropicKey, openaiBaseUrl, anthropicBaseUrl } =
|
|
711
|
+
createGatewayServer({ port, mock });
|
|
482
712
|
await listenLocal(server, port, "gateway");
|
|
483
713
|
console.log(`Runcap gateway: http://127.0.0.1:${port}/v1`);
|
|
484
714
|
console.log(`Mode: ${gatewayMode}`);
|
|
@@ -491,11 +721,38 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
|
|
|
491
721
|
console.log("Press Ctrl+C to stop.");
|
|
492
722
|
}
|
|
493
723
|
|
|
724
|
+
// Start the gateway on an ephemeral free port for the duration of one wrapped
|
|
725
|
+
// run, returning a handle the wrapper uses to point the child at it and to shut
|
|
726
|
+
// it down afterward. Upstream is pinned from the CURRENT env before the child's
|
|
727
|
+
// base URLs are rewritten, so the gateway proxies to the real provider, not to
|
|
728
|
+
// itself.
|
|
729
|
+
async function startEphemeralGateway({ mock = false } = {}) {
|
|
730
|
+
await ensureStore();
|
|
731
|
+
const upstream = {
|
|
732
|
+
openaiKey: process.env.AIM_UPSTREAM_API_KEY ?? process.env.OPENAI_API_KEY,
|
|
733
|
+
anthropicKey: process.env.ANTHROPIC_API_KEY,
|
|
734
|
+
openaiBaseUrl: process.env.AIM_UPSTREAM_BASE_URL ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1",
|
|
735
|
+
anthropicBaseUrl: process.env.ANTHROPIC_BASE_URL ?? "https://api.anthropic.com/v1"
|
|
736
|
+
};
|
|
737
|
+
const { server, gatewayMode } = createGatewayServer({ port: 0, mock, upstream });
|
|
738
|
+
await new Promise((resolve, reject) => {
|
|
739
|
+
server.once("error", reject);
|
|
740
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
741
|
+
});
|
|
742
|
+
const actualPort = server.address().port;
|
|
743
|
+
return {
|
|
744
|
+
port: actualPort,
|
|
745
|
+
baseUrl: `http://127.0.0.1:${actualPort}`,
|
|
746
|
+
gatewayMode,
|
|
747
|
+
close: () => new Promise((resolve) => server.close(resolve))
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
494
751
|
export async function showStatus(options = {}) {
|
|
495
752
|
await ensureStore();
|
|
496
753
|
const fuel = await readFuel();
|
|
497
754
|
const fuelLine = fuel.currentPercent === null
|
|
498
|
-
? "Fuel: unknown. Run `
|
|
755
|
+
? "Fuel: unknown. Run `runcap fuel set <percent>` to calibrate subscription limits."
|
|
499
756
|
: `Fuel: ${fuel.currentPercent}% (${fuel.source}, confidence: ${fuel.confidence})`;
|
|
500
757
|
if (options.includeFuelOnly) return fuelLine;
|
|
501
758
|
|
|
@@ -588,13 +845,16 @@ function buildAiWorkPlan(goal, { quality = "high", fuelPercent = null, snapshot
|
|
|
588
845
|
].filter(Boolean).length;
|
|
589
846
|
const hasRepo = Boolean(snapshot.packageJson);
|
|
590
847
|
const hasVerification = hasRepo && Object.keys(snapshot.packageJson?.scripts ?? {}).some((name) => /test|build|lint|typecheck/.test(name));
|
|
591
|
-
const fuel = Number.isFinite(Number(fuelPercent))
|
|
848
|
+
const fuel = fuelPercent === null || fuelPercent === undefined || fuelPercent === "" || !Number.isFinite(Number(fuelPercent))
|
|
849
|
+
? null
|
|
850
|
+
: Number(fuelPercent);
|
|
592
851
|
const budgetRisk = bigSignals > 0 || (fuel !== null && fuel < 30) ? "High" : fuel !== null && fuel < 55 ? "Medium" : "Low";
|
|
593
852
|
const expectedWasteReduction = budgetRisk === "High" ? "40-70%" : budgetRisk === "Medium" ? "25-45%" : "10-25%";
|
|
594
853
|
const qualityRisk = quality === "cheap" && budgetRisk === "High" ? "High" : budgetRisk === "High" ? "Medium" : "Low";
|
|
595
854
|
const routing = routeTask({ taskType, budgetRisk, quality, hasVerification });
|
|
596
855
|
const proof = proofForTask({ taskType, hasVerification });
|
|
597
856
|
const missions = missionBreakdown({ taskType, budgetRisk, proof });
|
|
857
|
+
const cost = estimatePlanCost({ budgetRisk, bigSignals, words, taskType, quality });
|
|
598
858
|
return {
|
|
599
859
|
id: createPlanId(cleanGoal),
|
|
600
860
|
createdAt: new Date().toISOString(),
|
|
@@ -609,6 +869,12 @@ function buildAiWorkPlan(goal, { quality = "high", fuelPercent = null, snapshot
|
|
|
609
869
|
budget: {
|
|
610
870
|
risk: budgetRisk,
|
|
611
871
|
expectedWasteReduction,
|
|
872
|
+
costLowUsd: cost.lowUsd,
|
|
873
|
+
costHighUsd: cost.highUsd,
|
|
874
|
+
costRange: cost.range,
|
|
875
|
+
recommendedCapUsd: cost.recommendedCapUsd,
|
|
876
|
+
recommendedCap: cost.recommendedCap,
|
|
877
|
+
costPrecision: cost.precision,
|
|
612
878
|
reason: budgetRisk === "High"
|
|
613
879
|
? "The goal is broad or fuel is low. A single agent run is likely to waste context and repeat work."
|
|
614
880
|
: "The goal can be controlled with smaller missions and proof checkpoints."
|
|
@@ -629,6 +895,49 @@ function buildAiWorkPlan(goal, { quality = "high", fuelPercent = null, snapshot
|
|
|
629
895
|
};
|
|
630
896
|
}
|
|
631
897
|
|
|
898
|
+
// Estimate a USD cost RANGE for an agent run from scope signals, priced against
|
|
899
|
+
// the sourced table. Deliberately a range, not an oracle: agent runs are
|
|
900
|
+
// stochastic. The recommended cap sits above the high end so a normal run
|
|
901
|
+
// completes but a runaway loop is stopped.
|
|
902
|
+
function estimatePlanCost({ budgetRisk, bigSignals, words, taskType, quality }) {
|
|
903
|
+
// Base expected total tokens (input+output across the whole run, including
|
|
904
|
+
// the agent re-reading context on each loop). Software runs loop more.
|
|
905
|
+
let baseTokens = taskType === "software" ? 220000 : 120000;
|
|
906
|
+
baseTokens += words * 1500;
|
|
907
|
+
baseTokens += bigSignals * 350000;
|
|
908
|
+
if (budgetRisk === "High") baseTokens *= 2.4;
|
|
909
|
+
else if (budgetRisk === "Medium") baseTokens *= 1.5;
|
|
910
|
+
|
|
911
|
+
// Premium-model blended price ($/token): planning on a strong model is the
|
|
912
|
+
// expensive case, so we price the headline range against it to avoid
|
|
913
|
+
// under-promising. Opus-class: ~$5/M in, ~$25/M out, assume ~30% output.
|
|
914
|
+
const blendedPerToken = quality === "cheap"
|
|
915
|
+
? (0.75 * 0.7 + 4.5 * 0.3) / 1_000_000 // cheap tier (gpt-5.4-mini)
|
|
916
|
+
: (5 * 0.7 + 25 * 0.3) / 1_000_000; // strong tier (opus-class)
|
|
917
|
+
|
|
918
|
+
const mid = baseTokens * blendedPerToken;
|
|
919
|
+
// Range: runs vary widely, so +-45% around the midpoint.
|
|
920
|
+
const lowUsd = round2(mid * 0.55);
|
|
921
|
+
const highUsd = round2(mid * 1.45);
|
|
922
|
+
// Cap above the high end (1.5x) so a normal run finishes, a loop is killed.
|
|
923
|
+
const recommendedCapUsd = roundCap(highUsd * 1.5);
|
|
924
|
+
return {
|
|
925
|
+
lowUsd,
|
|
926
|
+
highUsd,
|
|
927
|
+
recommendedCapUsd,
|
|
928
|
+
range: `$${lowUsd.toFixed(2)}-$${highUsd.toFixed(2)}`,
|
|
929
|
+
recommendedCap: `$${recommendedCapUsd.toFixed(2)}`,
|
|
930
|
+
precision: "calculated_estimate_not_provider_bill"
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function round2(n) { return Math.round(n * 100) / 100; }
|
|
935
|
+
function roundCap(n) {
|
|
936
|
+
// Round caps to a friendly number: nearest $1 under $20, nearest $5 above.
|
|
937
|
+
if (n < 20) return Math.max(1, Math.ceil(n));
|
|
938
|
+
return Math.ceil(n / 5) * 5;
|
|
939
|
+
}
|
|
940
|
+
|
|
632
941
|
function classifyTask(lower) {
|
|
633
942
|
if (/code|bug|test|build|app|api|database|typescript|react|python|deploy|auth|repo|github/.test(lower)) return "software";
|
|
634
943
|
if (/video|script|post|content|image|marketing|copy|campaign|linkedin|youtube/.test(lower)) return "creative";
|
|
@@ -699,13 +1008,13 @@ function commandTemplatesForPlan(goal, missions) {
|
|
|
699
1008
|
}));
|
|
700
1009
|
}
|
|
701
1010
|
|
|
702
|
-
async function runChild(command, cwd) {
|
|
1011
|
+
async function runChild(command, cwd, extraEnv = {}) {
|
|
703
1012
|
const started = Date.now();
|
|
704
1013
|
const [program, ...args] = command;
|
|
705
1014
|
return await new Promise((resolve) => {
|
|
706
1015
|
const child = spawn(program, args, {
|
|
707
1016
|
cwd,
|
|
708
|
-
env: { ...process.env, AIM_WRAPPED: "1" },
|
|
1017
|
+
env: { ...process.env, AIM_WRAPPED: "1", ...extraEnv },
|
|
709
1018
|
shell: false
|
|
710
1019
|
});
|
|
711
1020
|
let stdout = "";
|
|
@@ -1049,6 +1358,7 @@ async function dashboardStatus() {
|
|
|
1049
1358
|
return {
|
|
1050
1359
|
fuel,
|
|
1051
1360
|
gateway,
|
|
1361
|
+
budget: readBudget(),
|
|
1052
1362
|
missionCount: missions.length,
|
|
1053
1363
|
latest: missions[0] ?? null,
|
|
1054
1364
|
counts: missions.reduce((acc, mission) => {
|
|
@@ -1068,26 +1378,83 @@ async function readGatewayEvents() {
|
|
|
1068
1378
|
return text.split("\n").filter(Boolean).map((line) => safeJson(line)).filter(Boolean);
|
|
1069
1379
|
}
|
|
1070
1380
|
|
|
1071
|
-
async function readGatewaySummary() {
|
|
1072
|
-
const
|
|
1381
|
+
async function readGatewaySummary({ windowMs } = {}) {
|
|
1382
|
+
const allEvents = await readGatewayEvents();
|
|
1383
|
+
// When a window is given (used by the budget guard), only count spend whose
|
|
1384
|
+
// timestamp falls inside it. The cap is then a per-window budget that resets,
|
|
1385
|
+
// not an all-time counter that locks the gateway forever.
|
|
1386
|
+
const events = windowMs
|
|
1387
|
+
? allEvents.filter((event) => {
|
|
1388
|
+
const t = Date.parse(event.at ?? "");
|
|
1389
|
+
return Number.isFinite(t) && Date.now() - t <= windowMs;
|
|
1390
|
+
})
|
|
1391
|
+
: allEvents;
|
|
1073
1392
|
const successful = events.filter((event) => event.status >= 200 && event.status < 300);
|
|
1074
|
-
const totalTokens = events.reduce((sum, event) =>
|
|
1393
|
+
const totalTokens = events.reduce((sum, event) => {
|
|
1394
|
+
const u = event.usage;
|
|
1395
|
+
if (!u) return sum;
|
|
1396
|
+
const total = Number(u.total_tokens ?? 0) ||
|
|
1397
|
+
Number(u.prompt_tokens ?? u.input_tokens ?? 0) + Number(u.completion_tokens ?? u.output_tokens ?? 0);
|
|
1398
|
+
return sum + total;
|
|
1399
|
+
}, 0);
|
|
1075
1400
|
const estimatedCost = events.reduce((sum, event) => sum + Number(event.cost?.estimatedUsd ?? 0), 0);
|
|
1401
|
+
const savedTokens = events.reduce((sum, event) => sum + Number(event.compression?.savedTokens ?? 0), 0);
|
|
1402
|
+
// Value the saved tokens at a blended input rate from the price table so we can
|
|
1403
|
+
// show one honest dollar figure. Per saved input token: use the model's input rate.
|
|
1404
|
+
const savedUsd = events.reduce((sum, event) => {
|
|
1405
|
+
const saved = Number(event.compression?.savedTokens ?? 0);
|
|
1406
|
+
if (!saved) return sum;
|
|
1407
|
+
const pricing = modelPricing(event.model);
|
|
1408
|
+
const inputRate = pricing ? pricing.inputPerMillion : 3; // fall back to a mid Sonnet-ish rate
|
|
1409
|
+
return sum + (saved * inputRate) / 1_000_000;
|
|
1410
|
+
}, 0);
|
|
1076
1411
|
return {
|
|
1077
1412
|
callCount: events.length,
|
|
1078
1413
|
successfulCallCount: successful.length,
|
|
1079
1414
|
totalTokens,
|
|
1080
1415
|
estimatedCostUsd: Number(estimatedCost.toFixed(6)),
|
|
1416
|
+
savedTokens,
|
|
1417
|
+
savedUsd: Number(savedUsd.toFixed(6)),
|
|
1418
|
+
wouldHaveSpentUsd: Number((estimatedCost + savedUsd).toFixed(6)),
|
|
1081
1419
|
truth: events.some((event) => event.truth === "provider_usage" || event.truth === "mock_provider_usage")
|
|
1082
1420
|
? "usage_plus_static_price_table"
|
|
1083
1421
|
: "unknown",
|
|
1422
|
+
windowMs: windowMs ?? null,
|
|
1084
1423
|
recent: events.slice(-20).reverse()
|
|
1085
1424
|
};
|
|
1086
1425
|
}
|
|
1087
1426
|
|
|
1427
|
+
// How wide the budget window is, in ms. AIM_BUDGET_WINDOW controls it:
|
|
1428
|
+
// "day" (default) → rolling 24h, "session" → since gateway start, "all" → no reset.
|
|
1429
|
+
const GATEWAY_STARTED_AT = Date.now();
|
|
1430
|
+
function budgetWindowMs() {
|
|
1431
|
+
const mode = (process.env.AIM_BUDGET_WINDOW ?? "day").toLowerCase();
|
|
1432
|
+
if (mode === "all") return undefined;
|
|
1433
|
+
if (mode === "session") return Date.now() - GATEWAY_STARTED_AT;
|
|
1434
|
+
const hours = Number(mode);
|
|
1435
|
+
if (Number.isFinite(hours) && hours > 0) return hours * 60 * 60 * 1000;
|
|
1436
|
+
return 24 * 60 * 60 * 1000; // "day" default
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// The cap value. Precedence: AIM_DAILY_BUDGET_USD env > persisted budget.json
|
|
1440
|
+
// (written by `runcap plan` / `runcap cap`). Null means no cap is set.
|
|
1088
1441
|
function readBudget() {
|
|
1089
1442
|
const raw = process.env.AIM_DAILY_BUDGET_USD;
|
|
1090
|
-
if (raw
|
|
1443
|
+
if (raw !== undefined && raw !== "") {
|
|
1444
|
+
const value = Number(raw);
|
|
1445
|
+
if (Number.isFinite(value) && value >= 0) return value;
|
|
1446
|
+
}
|
|
1447
|
+
const stored = readStoredBudget();
|
|
1448
|
+
return stored;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
function readStoredBudget() {
|
|
1452
|
+
if (!existsSync(BUDGET_FILE)) return null;
|
|
1453
|
+
let text = null;
|
|
1454
|
+
try { text = readFileSync(BUDGET_FILE, "utf8"); } catch { return null; }
|
|
1455
|
+
const parsed = safeJson(text);
|
|
1456
|
+
const raw = parsed?.capUsd;
|
|
1457
|
+
if (raw === null || raw === undefined || raw === "") return null;
|
|
1091
1458
|
const value = Number(raw);
|
|
1092
1459
|
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
1093
1460
|
}
|
|
@@ -1204,6 +1571,43 @@ function estimateApiCost(usage, model) {
|
|
|
1204
1571
|
};
|
|
1205
1572
|
}
|
|
1206
1573
|
|
|
1574
|
+
// Estimate the cost of a request BEFORE it is forwarded upstream, from the
|
|
1575
|
+
// request body alone. Input tokens are estimated from the serialized prompt;
|
|
1576
|
+
// output tokens from the caller's max_tokens (the worst case the provider can
|
|
1577
|
+
// bill). Returns null when the model has no verified price, so the guard can
|
|
1578
|
+
// decide whether to fail open or closed rather than guessing a number.
|
|
1579
|
+
function estimateRequestCost(requestBody) {
|
|
1580
|
+
const model = requestBody?.model ?? "";
|
|
1581
|
+
const pricing = modelPricing(model);
|
|
1582
|
+
if (!pricing) return { estimatedUsd: null, truth: "unknown_price", model };
|
|
1583
|
+
|
|
1584
|
+
const promptText = JSON.stringify(
|
|
1585
|
+
requestBody.messages ?? requestBody.system ?? requestBody.input ?? requestBody.prompt ?? ""
|
|
1586
|
+
);
|
|
1587
|
+
const inputTokens = estimateTokens(promptText);
|
|
1588
|
+
// Worst-case output the provider could bill: honor the caller's stated cap,
|
|
1589
|
+
// else assume a generous default so the guard is not fooled by an open-ended call.
|
|
1590
|
+
const maxOutput = Number(
|
|
1591
|
+
requestBody.max_tokens ??
|
|
1592
|
+
requestBody.max_completion_tokens ??
|
|
1593
|
+
requestBody.max_output_tokens ??
|
|
1594
|
+
4096
|
|
1595
|
+
);
|
|
1596
|
+
const outputTokens = Number.isFinite(maxOutput) && maxOutput > 0 ? maxOutput : 4096;
|
|
1597
|
+
|
|
1598
|
+
const estimatedUsd =
|
|
1599
|
+
(inputTokens / 1_000_000) * pricing.inputPerMillion +
|
|
1600
|
+
(outputTokens / 1_000_000) * pricing.outputPerMillion;
|
|
1601
|
+
|
|
1602
|
+
return {
|
|
1603
|
+
estimatedUsd: Number(estimatedUsd.toFixed(6)),
|
|
1604
|
+
truth: "pre_call_estimate_from_request",
|
|
1605
|
+
model,
|
|
1606
|
+
inputTokens,
|
|
1607
|
+
outputTokens
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1207
1611
|
function modelPricing(model = "") {
|
|
1208
1612
|
const name = String(model).toLowerCase();
|
|
1209
1613
|
const batch = name.includes("batch");
|
|
@@ -1249,7 +1653,7 @@ function shortSummary(mission) {
|
|
|
1249
1653
|
|
|
1250
1654
|
function formatPreflight({ command, preflight, fuel }) {
|
|
1251
1655
|
const fuelLine = fuel.currentPercent === null
|
|
1252
|
-
? "Fuel: unknown. Set it with `
|
|
1656
|
+
? "Fuel: unknown. Set it with `runcap fuel set <percent>` if using subscriptions."
|
|
1253
1657
|
: `Fuel: ${fuel.currentPercent}% (${fuel.confidence} confidence)`;
|
|
1254
1658
|
const scopeAdvice = preflight.scopeRisk === "high"
|
|
1255
1659
|
? "Do not launch as one broad mission. Split into one vertical slice with a verification command."
|
|
@@ -1273,7 +1677,7 @@ function formatPreflight({ command, preflight, fuel }) {
|
|
|
1273
1677
|
|
|
1274
1678
|
function formatReport(mission) {
|
|
1275
1679
|
const fuel = mission.fuelUsedPercent === null
|
|
1276
|
-
? `Fuel: before ${mission.fuelBefore ?? "unknown"}%, after unknown. Calibrate with \`
|
|
1680
|
+
? `Fuel: before ${mission.fuelBefore ?? "unknown"}%, after unknown. Calibrate with \`runcap fuel calibrate ${mission.id} <after-percent>\`.`
|
|
1277
1681
|
: `Fuel used: ${mission.fuelUsedPercent}% (source: manual calibration, confidence: high).`;
|
|
1278
1682
|
const errorLines = mission.errors.length
|
|
1279
1683
|
? mission.errors.map((error) => `- ${error.kind} (${error.confidence}): ${error.raw}`).join("\n")
|
|
@@ -1287,7 +1691,7 @@ function formatReport(mission) {
|
|
|
1287
1691
|
` Next action: ${rec.nextAction}`,
|
|
1288
1692
|
` Rescue prompt: ${rec.prompt}`
|
|
1289
1693
|
].join("\n")).join("\n\n");
|
|
1290
|
-
return `#
|
|
1694
|
+
return `# Runcap Mission Report
|
|
1291
1695
|
|
|
1292
1696
|
Mission: ${mission.id}
|
|
1293
1697
|
Command: \`${mission.command.join(" ")}\`
|
|
@@ -1396,7 +1800,7 @@ function formatHtmlReport(mission) {
|
|
|
1396
1800
|
<head>
|
|
1397
1801
|
<meta charset="utf-8">
|
|
1398
1802
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1399
|
-
<title>
|
|
1803
|
+
<title>Runcap Mission Report - ${escapeHtml(mission.label ?? mission.id)}</title>
|
|
1400
1804
|
<style>
|
|
1401
1805
|
:root { color-scheme: dark; --bg:#0f1115; --panel:#181c22; --soft:#202630; --line:#303946; --text:#f5f7fb; --muted:#a7b0bd; --accent:#70d6ff; --warn:#ffd166; --bad:#ff6b6b; --good:#55d78a; }
|
|
1402
1806
|
* { box-sizing:border-box; }
|
|
@@ -1478,78 +1882,96 @@ function renderDashboardHtml() {
|
|
|
1478
1882
|
<meta charset="utf-8">
|
|
1479
1883
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1480
1884
|
<title>Runcap</title>
|
|
1885
|
+
<link rel="preconnect" href="https://api.fontshare.com" crossorigin>
|
|
1886
|
+
<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">
|
|
1481
1887
|
<style>
|
|
1482
|
-
:root { color-scheme:
|
|
1888
|
+
: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); }
|
|
1483
1889
|
* { box-sizing: border-box; }
|
|
1484
|
-
body { margin:0; min-height:100vh; font-family:
|
|
1485
|
-
body:before { content:""; position:fixed; inset:0; pointer-events:none; background:radial-gradient(circle at
|
|
1890
|
+
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); }
|
|
1891
|
+
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%); }
|
|
1486
1892
|
button, textarea, select, input { font:inherit; }
|
|
1487
1893
|
.app { position:relative; display:grid; grid-template-columns: 320px minmax(0,1fr); min-height:100vh; }
|
|
1488
|
-
aside { border-right:1px solid var(--line); background:
|
|
1489
|
-
main { padding:
|
|
1490
|
-
h1 { margin:0; font-size:
|
|
1491
|
-
h2 { margin:0; font-size:
|
|
1492
|
-
h3 { margin:0; font-size:15px; }
|
|
1894
|
+
aside { border-right:1px solid var(--line); background:var(--panel); padding:22px; overflow:auto; }
|
|
1895
|
+
main { padding:32px 36px; overflow:auto; }
|
|
1896
|
+
h1 { margin:0; font-family:"Clash Display", sans-serif; font-weight:700; font-size:23px; letter-spacing:-0.01em; }
|
|
1897
|
+
h2 { margin:0; font-family:"Clash Display", sans-serif; font-weight:600; font-size:34px; line-height:1.08; letter-spacing:-0.02em; }
|
|
1898
|
+
h3 { margin:0; font-family:"Clash Display", sans-serif; font-weight:600; font-size:15px; }
|
|
1493
1899
|
p { margin:0; }
|
|
1494
1900
|
.muted { color:var(--muted); }
|
|
1495
1901
|
.brand { display:flex; align-items:center; gap:12px; margin-bottom:22px; }
|
|
1496
|
-
.mark { width:
|
|
1497
|
-
.tagline { color:var(--muted); font-size:13px; margin-top:
|
|
1902
|
+
.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); }
|
|
1903
|
+
.tagline { color:var(--muted); font-size:13px; margin-top:3px; line-height:1.35; }
|
|
1498
1904
|
.nav { display:grid; gap:8px; margin:18px 0 22px; }
|
|
1499
|
-
.nav button { text-align:left; border:1px solid var(--line); background:
|
|
1500
|
-
.nav button.active, .nav button:hover { border-color:var(--accent); background:#
|
|
1501
|
-
.nav strong { display:block; }
|
|
1905
|
+
.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; }
|
|
1906
|
+
.nav button.active, .nav button:hover { border-color:var(--accent); background:#f5f4ff; }
|
|
1907
|
+
.nav strong { display:block; font-weight:600; }
|
|
1502
1908
|
.nav span { display:block; color:var(--muted); font-size:12px; margin-top:3px; }
|
|
1503
|
-
.side-title { margin:18px 0 10px; color:var(--muted); font-size:
|
|
1909
|
+
.side-title { margin:18px 0 10px; color:var(--muted); font-size:11px; font-weight:600; letter-spacing:0.06em; text-transform:uppercase; }
|
|
1504
1910
|
.summary { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
|
|
1505
|
-
.mini, .panel, .mission, .metric, .step, .plan-card, details { border:1px solid var(--line); background:
|
|
1506
|
-
.mini { padding:
|
|
1507
|
-
.mini strong { display:block; font-size:22px; }
|
|
1911
|
+
.mini, .panel, .mission, .metric, .step, .plan-card, details { border:1px solid var(--line); background:var(--panel); border-radius:14px; }
|
|
1912
|
+
.mini { padding:13px; min-height:76px; box-shadow:var(--shadow); }
|
|
1913
|
+
.mini strong { display:block; font-family:"JetBrains Mono",monospace; font-size:22px; font-weight:500; }
|
|
1508
1914
|
.mini span { color:var(--muted); font-size:12px; }
|
|
1509
|
-
.mission { width:100%; color:inherit; text-align:left; cursor:pointer; margin:0 0 10px; padding:
|
|
1915
|
+
.mission { width:100%; color:inherit; text-align:left; cursor:pointer; margin:0 0 10px; padding:13px; transition:all .15s; }
|
|
1510
1916
|
.mission:hover, .mission.active { border-color:var(--accent); }
|
|
1511
|
-
.mission.active { background:#
|
|
1917
|
+
.mission.active { background:#f5f4ff; box-shadow: inset 3px 0 0 var(--accent); }
|
|
1512
1918
|
.mission-head { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:7px; }
|
|
1513
|
-
.mission-name { font-weight:
|
|
1919
|
+
.mission-name { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
1514
1920
|
.mission-line { color:var(--muted); font-size:13px; line-height:1.35; }
|
|
1515
|
-
.status { font-size:12px; border:1px solid var(--line); padding:4px
|
|
1516
|
-
.stuck { color:var(--bad); border-color:rgba(
|
|
1517
|
-
.at_risk { color:var(--warn); border-color:rgba(
|
|
1518
|
-
.progressing { color:var(--good); border-color:rgba(
|
|
1921
|
+
.status { font-size:12px; border:1px solid var(--line); padding:4px 9px; border-radius:999px; white-space:nowrap; font-weight:500; }
|
|
1922
|
+
.stuck { color:var(--bad); border-color:rgba(220,38,38,0.35); background:rgba(220,38,38,0.05); }
|
|
1923
|
+
.at_risk { color:var(--warn); border-color:rgba(183,121,31,0.35); background:rgba(183,121,31,0.05); }
|
|
1924
|
+
.progressing { color:var(--good); border-color:rgba(13,159,110,0.35); background:rgba(13,159,110,0.05); }
|
|
1519
1925
|
.hero { display:grid; grid-template-columns:minmax(0,1.2fr) minmax(360px,0.8fr); gap:18px; margin-bottom:18px; }
|
|
1520
|
-
.panel { padding:
|
|
1521
|
-
.hero-copy { color:var(--muted); font-size:
|
|
1926
|
+
.panel { padding:26px; box-shadow:var(--shadow); }
|
|
1927
|
+
.hero-copy { color:var(--muted); font-size:16px; line-height:1.55; margin-top:14px; max-width:880px; }
|
|
1928
|
+
/* SAVINGS HERO — the one visible number (Kirill's core fix) */
|
|
1929
|
+
.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); }
|
|
1930
|
+
.savings-label { font-size:12px; font-weight:600; letter-spacing:0.08em; text-transform:uppercase; color:var(--muted); }
|
|
1931
|
+
.savings-row { display:flex; align-items:flex-end; gap:14px; flex-wrap:wrap; margin-top:8px; }
|
|
1932
|
+
.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; }
|
|
1933
|
+
.savings-unit { font-family:"JetBrains Mono",monospace; font-size:17px; color:var(--muted); padding-bottom:8px; }
|
|
1934
|
+
.savings-sub { color:var(--muted); font-size:15px; margin-top:12px; }
|
|
1935
|
+
.savings-sub b { color:var(--text); font-family:"JetBrains Mono",monospace; font-weight:500; }
|
|
1936
|
+
.capbar { margin-top:18px; }
|
|
1937
|
+
.capbar-track { height:12px; border-radius:999px; background:var(--soft); overflow:hidden; border:1px solid var(--line); }
|
|
1938
|
+
.capbar-fill { height:100%; border-radius:999px; background:linear-gradient(90deg,var(--good),var(--accent)); transition:width .4s; }
|
|
1939
|
+
.capbar-fill.warn { background:linear-gradient(90deg,var(--warn),#e8590c); }
|
|
1940
|
+
.capbar-fill.over { background:linear-gradient(90deg,var(--bad),#991b1b); }
|
|
1941
|
+
.capbar-meta { display:flex; justify-content:space-between; font-size:12px; color:var(--muted); margin-top:7px; font-family:"JetBrains Mono",monospace; }
|
|
1522
1942
|
.badge-row { display:flex; flex-wrap:wrap; gap:8px; margin-top:18px; }
|
|
1523
|
-
.badge { display:inline-flex; align-items:center; gap:6px; border:1px solid var(--line); color:var(--muted); border-radius:999px; padding:6px
|
|
1524
|
-
.badge.good { color:var(--good); border-color:rgba(
|
|
1525
|
-
.badge.warn { color:var(--warn); border-color:rgba(
|
|
1526
|
-
.badge.bad { color:var(--bad); border-color:rgba(
|
|
1943
|
+
.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); }
|
|
1944
|
+
.badge.good { color:var(--good); border-color:rgba(13,159,110,0.35); }
|
|
1945
|
+
.badge.warn { color:var(--warn); border-color:rgba(183,121,31,0.35); }
|
|
1946
|
+
.badge.bad { color:var(--bad); border-color:rgba(220,38,38,0.35); }
|
|
1527
1947
|
.metrics { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:20px; }
|
|
1528
|
-
.metric { padding:
|
|
1529
|
-
.metric strong { display:block; font-size:
|
|
1948
|
+
.metric { padding:15px; box-shadow:var(--shadow); }
|
|
1949
|
+
.metric strong { display:block; font-family:"JetBrains Mono",monospace; font-size:23px; font-weight:500; line-height:1.1; }
|
|
1530
1950
|
.metric span { display:block; color:var(--muted); font-size:12px; margin-top:6px; }
|
|
1531
|
-
.planner textarea { width:100%; min-height:128px; resize:vertical; background
|
|
1951
|
+
.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; }
|
|
1532
1952
|
.field-row { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:10px; }
|
|
1533
|
-
select, input { width:100%; background
|
|
1534
|
-
label { display:block; color:var(--muted); font-size:12px; font-weight:
|
|
1535
|
-
.primary, .ghost { border-radius:
|
|
1536
|
-
.primary { border:1px solid
|
|
1537
|
-
.
|
|
1953
|
+
select, input { width:100%; background:var(--panel2); color:var(--text); border:1px solid var(--line); border-radius:11px; padding:11px; }
|
|
1954
|
+
label { display:block; color:var(--muted); font-size:12px; font-weight:600; margin:0 0 7px; }
|
|
1955
|
+
.primary, .ghost { border-radius:11px; padding:11px 16px; cursor:pointer; font-weight:600; transition:all .15s; }
|
|
1956
|
+
.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); }
|
|
1957
|
+
.primary:hover { filter:brightness(1.06); transform:translateY(-1px); }
|
|
1958
|
+
.ghost { border:1px solid var(--line); color:var(--text); background:var(--panel); }
|
|
1959
|
+
.ghost:hover { border-color:var(--accent); }
|
|
1538
1960
|
.actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:12px; }
|
|
1539
1961
|
.plan-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; margin:18px 0; }
|
|
1540
|
-
.plan-card { padding:
|
|
1541
|
-
.plan-card strong { display:block; margin-bottom:8px; }
|
|
1962
|
+
.plan-card { padding:18px; box-shadow:var(--shadow); }
|
|
1963
|
+
.plan-card strong { display:block; margin-bottom:8px; font-weight:600; }
|
|
1542
1964
|
.plan-card p, .step p { color:var(--muted); line-height:1.48; }
|
|
1543
1965
|
.timeline { display:grid; gap:10px; margin-top:14px; }
|
|
1544
|
-
.step { padding:
|
|
1545
|
-
.num { width:28px; height:28px; border-radius:
|
|
1546
|
-
.rescue { border-color:rgba(
|
|
1547
|
-
.decision { color:var(--
|
|
1548
|
-
pre { white-space:pre-wrap; margin:12px 0 0; background:#
|
|
1549
|
-
details { padding:14px
|
|
1550
|
-
summary { cursor:pointer; color:var(--muted); font-weight:
|
|
1966
|
+
.step { padding:16px; display:grid; grid-template-columns:34px minmax(0,1fr); gap:12px; align-items:start; box-shadow:var(--shadow); }
|
|
1967
|
+
.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; }
|
|
1968
|
+
.rescue { border-color:rgba(79,70,229,0.4); background:linear-gradient(135deg,#ffffff,#f7f6ff); }
|
|
1969
|
+
.decision { color:var(--bad); font-weight:600; font-size:18px; margin:8px 0 0; }
|
|
1970
|
+
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; }
|
|
1971
|
+
details { padding:14px 18px; margin-top:14px; box-shadow:var(--shadow); }
|
|
1972
|
+
summary { cursor:pointer; color:var(--muted); font-weight:600; }
|
|
1551
1973
|
.hidden { display:none; }
|
|
1552
|
-
.empty { padding:
|
|
1974
|
+
.empty { padding:42px; text-align:left; }
|
|
1553
1975
|
.copy { margin-top:10px; }
|
|
1554
1976
|
@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; } }
|
|
1555
1977
|
</style>
|
|
@@ -1558,10 +1980,10 @@ function renderDashboardHtml() {
|
|
|
1558
1980
|
<div class="app">
|
|
1559
1981
|
<aside>
|
|
1560
1982
|
<div class="brand">
|
|
1561
|
-
<div class="mark">
|
|
1983
|
+
<div class="mark">R</div>
|
|
1562
1984
|
<div>
|
|
1563
1985
|
<h1>Runcap</h1>
|
|
1564
|
-
<div class="tagline">
|
|
1986
|
+
<div class="tagline">Estimate cost. Cap spend. Compress tokens. Rescue stuck runs.</div>
|
|
1565
1987
|
</div>
|
|
1566
1988
|
</div>
|
|
1567
1989
|
<div class="nav">
|
|
@@ -1574,10 +1996,10 @@ function renderDashboardHtml() {
|
|
|
1574
1996
|
<div class="mission-line" id="truth">Gateway truth: loading...</div>
|
|
1575
1997
|
</div>
|
|
1576
1998
|
<div class="summary">
|
|
1577
|
-
<div class="mini"><strong id="
|
|
1578
|
-
<div class="mini"><strong id="
|
|
1999
|
+
<div class="mini"><strong id="cost">$0</strong><span>spent so far</span></div>
|
|
2000
|
+
<div class="mini"><strong id="saved" style="color:var(--good)">$0</strong><span>saved by compression</span></div>
|
|
1579
2001
|
<div class="mini"><strong id="tokens">0</strong><span>API tokens</span></div>
|
|
1580
|
-
<div class="mini"><strong id="
|
|
2002
|
+
<div class="mini"><strong id="needs">0</strong><span>need attention</span></div>
|
|
1581
2003
|
</div>
|
|
1582
2004
|
<div class="side-title">Saved plans</div>
|
|
1583
2005
|
<div id="plans"></div>
|
|
@@ -1602,13 +2024,16 @@ function renderDashboardHtml() {
|
|
|
1602
2024
|
state.plans = plans;
|
|
1603
2025
|
document.getElementById("fuel").textContent = status.fuel.currentPercent === null ? "Fuel: unknown" : "Fuel: " + status.fuel.currentPercent + "%";
|
|
1604
2026
|
document.getElementById("truth").textContent = "Gateway truth: " + status.gateway.truth;
|
|
1605
|
-
document.getElementById("total").textContent = status.missionCount;
|
|
1606
2027
|
document.getElementById("needs").textContent = (status.counts.stuck ?? 0) + (status.counts.at_risk ?? 0);
|
|
1607
|
-
document.getElementById("tokens").textContent = status.gateway.totalTokens;
|
|
1608
|
-
document.getElementById("cost").textContent = "$" + status.gateway.estimatedCostUsd;
|
|
2028
|
+
document.getElementById("tokens").textContent = Number(status.gateway.totalTokens || 0).toLocaleString();
|
|
2029
|
+
document.getElementById("cost").textContent = "$" + (status.gateway.estimatedCostUsd ?? 0);
|
|
2030
|
+
document.getElementById("saved").textContent = "$" + (status.gateway.savedUsd ?? 0);
|
|
2031
|
+
state.gateway = status.gateway;
|
|
2032
|
+
state.budget = status.budget;
|
|
1609
2033
|
renderList();
|
|
1610
2034
|
renderPlans();
|
|
1611
2035
|
if (!state.plannerRendered) renderPlanner(status);
|
|
2036
|
+
renderSavingsHero(status.gateway);
|
|
1612
2037
|
if (!state.selected && missions[0]) showMission(missions[0].id, false);
|
|
1613
2038
|
if (!missions[0]) renderEmptyMonitor();
|
|
1614
2039
|
}
|
|
@@ -1637,10 +2062,40 @@ function renderDashboardHtml() {
|
|
|
1637
2062
|
'</button>'
|
|
1638
2063
|
).join("");
|
|
1639
2064
|
}
|
|
2065
|
+
function renderSavingsHero(g) {
|
|
2066
|
+
const el = document.getElementById("savings-hero");
|
|
2067
|
+
if (!el || !g) return;
|
|
2068
|
+
const saved = Number(g.savedUsd ?? 0);
|
|
2069
|
+
const tokens = Number(g.savedTokens ?? 0);
|
|
2070
|
+
const spent = Number(g.estimatedCostUsd ?? 0);
|
|
2071
|
+
const wouldHave = Number(g.wouldHaveSpentUsd ?? spent);
|
|
2072
|
+
const fmt = (n) => "$" + (n < 0.01 && n > 0 ? n.toFixed(4) : n.toFixed(2));
|
|
2073
|
+
if (tokens === 0 && spent === 0) {
|
|
2074
|
+
el.innerHTML = '<div class="savings"><div class="savings-label">Your savings will show here</div>' +
|
|
2075
|
+
'<div class="savings-row"><div class="savings-big">$0.00</div><div class="savings-unit">saved so far</div></div>' +
|
|
2076
|
+
'<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>';
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
// cap bar
|
|
2080
|
+
let capHtml = '';
|
|
2081
|
+
if (state.budget && state.budget > 0) {
|
|
2082
|
+
const pct = Math.min(100, (spent / state.budget) * 100);
|
|
2083
|
+
const cls = pct >= 100 ? 'over' : pct >= 80 ? 'warn' : '';
|
|
2084
|
+
capHtml = '<div class="capbar"><div class="capbar-track"><div class="capbar-fill ' + cls + '" style="width:' + pct.toFixed(1) + '%"></div></div>' +
|
|
2085
|
+
'<div class="capbar-meta"><span>spent ' + fmt(spent) + '</span><span>cap ' + fmt(state.budget) + '</span></div></div>';
|
|
2086
|
+
}
|
|
2087
|
+
el.innerHTML = '<div class="savings">' +
|
|
2088
|
+
'<div class="savings-label">You saved</div>' +
|
|
2089
|
+
'<div class="savings-row"><div class="savings-big">' + fmt(saved) + '</div><div class="savings-unit">' + tokens.toLocaleString() + ' tokens compressed away</div></div>' +
|
|
2090
|
+
'<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>' +
|
|
2091
|
+
capHtml +
|
|
2092
|
+
'</div>';
|
|
2093
|
+
}
|
|
1640
2094
|
function renderPlanner(status) {
|
|
1641
2095
|
state.plannerRendered = true;
|
|
1642
2096
|
const fuel = status.fuel.currentPercent === null ? 24 : Number(status.fuel.currentPercent);
|
|
1643
2097
|
document.getElementById("plan-view").innerHTML =
|
|
2098
|
+
'<div id="savings-hero"></div>' +
|
|
1644
2099
|
'<div class="hero">' +
|
|
1645
2100
|
'<div class="panel">' +
|
|
1646
2101
|
'<h2>Turn one expensive AI request into a managed plan.</h2>' +
|