debutly 0.0.0-stage → 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/LICENSE +662 -0
- package/README.md +192 -2
- package/bin/debutly.mjs +8 -0
- package/drizzle/0000_init.sql +107 -0
- package/drizzle/0001_predictions.sql +14 -0
- package/drizzle/0002_dashboard.sql +15 -0
- package/drizzle/meta/0000_snapshot.json +722 -0
- package/drizzle/meta/0001_snapshot.json +821 -0
- package/drizzle/meta/0002_snapshot.json +942 -0
- package/drizzle/meta/_journal.json +27 -0
- package/package.json +83 -4
- package/src/agent/describe.ts +29 -0
- package/src/agent/effect.ts +94 -0
- package/src/agent/give-up.ts +42 -0
- package/src/agent/label.ts +19 -0
- package/src/agent/loop.ts +302 -0
- package/src/agent/parse-reply.ts +127 -0
- package/src/agent/prompt.ts +85 -0
- package/src/agent/run-agent.ts +68 -0
- package/src/artifacts/db.ts +55 -0
- package/src/artifacts/s3.ts +36 -0
- package/src/artifacts/store.ts +26 -0
- package/src/bin.ts +140 -0
- package/src/browser/ax.ts +77 -0
- package/src/browser/execute.ts +401 -0
- package/src/browser/observe.ts +62 -0
- package/src/browser/reflow.ts +73 -0
- package/src/browser/samples.ts +119 -0
- package/src/browser/scan-script.ts +108 -0
- package/src/browser/settle.ts +32 -0
- package/src/browser/signals.ts +73 -0
- package/src/cancel.ts +20 -0
- package/src/cli.ts +281 -0
- package/src/config.ts +62 -0
- package/src/cost/estimate.ts +32 -0
- package/src/cost/prices.ts +32 -0
- package/src/cost/tiers.ts +27 -0
- package/src/costs.ts +96 -0
- package/src/crew/run-unit.ts +62 -0
- package/src/detect/findings.ts +649 -0
- package/src/detect/wcag.ts +38 -0
- package/src/doctor.ts +145 -0
- package/src/hear-cli.ts +137 -0
- package/src/hear.ts +309 -0
- package/src/import.ts +108 -0
- package/src/inbox/mailpit.ts +46 -0
- package/src/inbox/types.ts +10 -0
- package/src/init.ts +184 -0
- package/src/llm/anthropic.ts +41 -0
- package/src/llm/credits.ts +35 -0
- package/src/llm/factory.ts +65 -0
- package/src/llm/meter.ts +39 -0
- package/src/llm/openai-compatible.ts +90 -0
- package/src/llm/openrouter.ts +9 -0
- package/src/llm/scripted.ts +18 -0
- package/src/llm/types.ts +36 -0
- package/src/perceive.ts +150 -0
- package/src/perception/aggregate.ts +111 -0
- package/src/perception/compare.ts +77 -0
- package/src/perception/evaluate.ts +83 -0
- package/src/perception/exhibits.ts +146 -0
- package/src/perception/market-context.ts +50 -0
- package/src/perception/run-perception.ts +178 -0
- package/src/persona/generate.ts +92 -0
- package/src/persona/inclusion.ts +53 -0
- package/src/persona/language.ts +59 -0
- package/src/persona/people.ts +32 -0
- package/src/predictions.ts +33 -0
- package/src/queue/dispatch.ts +18 -0
- package/src/queue/jobs.ts +28 -0
- package/src/queue/processor.ts +42 -0
- package/src/report/fix-first.ts +121 -0
- package/src/report/launch-readiness.ts +106 -0
- package/src/report/markdown.ts +202 -0
- package/src/run.ts +141 -0
- package/src/sandbox/outbox.ts +65 -0
- package/src/store/db.ts +40 -0
- package/src/store/import-run.ts +116 -0
- package/src/store/pg-run-store.ts +163 -0
- package/src/store/predictions.ts +70 -0
- package/src/store/run-store.ts +78 -0
- package/src/store/schema.ts +193 -0
- package/src/store/url.ts +15 -0
- package/src/types.ts +201 -0
- package/src/util/chromium.ts +20 -0
- package/src/util/json.ts +7 -0
- package/src/util/key-defaults.ts +34 -0
- package/src/util/legacy-env.ts +14 -0
- package/src/util/load-env.ts +28 -0
- package/src/util/pool.ts +12 -0
- package/src/util/run-origin.ts +27 -0
- package/src/worker.ts +53 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Runs in the page. Passed as a string, not a function, because tsx/esbuild can inject
|
|
2
|
+
// helpers (e.g. __name) into function bodies that don't exist inside the browser.
|
|
3
|
+
// Includes ARIA widgets (custom selects, menus, tabs, toggles) that apps build from divs:
|
|
4
|
+
// without them, agents can open a dropdown but never see its options.
|
|
5
|
+
export const SCAN_SCRIPT = `(() => {
|
|
6
|
+
const selector = [
|
|
7
|
+
'a[href]', 'button', 'input', 'select', 'textarea', 'summary',
|
|
8
|
+
'[role="button"]', '[role="link"]', '[role="option"]', '[role="menuitem"]', '[role="menuitemcheckbox"]',
|
|
9
|
+
'[role="menuitemradio"]', '[role="tab"]', '[role="checkbox"]', '[role="radio"]', '[role="switch"]', '[role="combobox"]',
|
|
10
|
+
].join(', ');
|
|
11
|
+
const visible = (el) => typeof el.checkVisibility === 'function'
|
|
12
|
+
? el.checkVisibility({ visibilityProperty: true })
|
|
13
|
+
: (getComputedStyle(el).display !== 'none' && getComputedStyle(el).visibility !== 'hidden');
|
|
14
|
+
const textOf = (el) => (el && (el.innerText || el.textContent)) || '';
|
|
15
|
+
// What a sighted person sees on a control with no text: its icon. Icon sets name their icons in
|
|
16
|
+
// class names (lucide-menu, tabler-icon-dots, fa-bars, i-ph-gear), data attributes, sprite
|
|
17
|
+
// references (<use href="#menu">) or an image's file name. Without it, agents met
|
|
18
|
+
// a row of buttons named "" and guessed.
|
|
19
|
+
const ICON_CLASS = /^(?:i-[a-z0-9]+-|lucide-|tabler-icon-|icon-tabler-|fa-|bi-|ti-|mdi-|ph-|ri-|feather-|heroicons?-|bx-|bxs-|octicon-|codicon-|icon-)([a-z][a-z0-9-]*)$/;
|
|
20
|
+
const NOT_ICON = /^(?:solid|regular|light|thin|duotone|brands|sharp|fw|xs|sm|md|lg|xl|spin|pulse|button|btn|wrapper|container|only|small|large|size-.*)$/;
|
|
21
|
+
const iconWord = (s) => {
|
|
22
|
+
const word = s.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase().replace(/[-_]+/g, ' ')
|
|
23
|
+
.replace(/^icon /, '').replace(/( (?:icon|outline|outlined|filled|fill|line|solid|bold|regular|light|thin|duotone|round|rounded|sharp|[0-9]+))+$/, '').trim();
|
|
24
|
+
return word || s.toLowerCase();
|
|
25
|
+
};
|
|
26
|
+
const iconOf = (el) => {
|
|
27
|
+
for (const n of [el, ...el.querySelectorAll('svg, i, span, img, use')].slice(0, 15)) {
|
|
28
|
+
const tag = n.tagName.toLowerCase();
|
|
29
|
+
const href = tag === 'use' ? n.getAttribute('href') || n.getAttribute('xlink:href') || '' : '';
|
|
30
|
+
const testId = (n.getAttribute('data-testid') || '').match(/^(.+)Icon$/);
|
|
31
|
+
const named = n.getAttribute('data-icon') || n.getAttribute('data-lucide') || (href.includes('#') && href.split('#').pop()) || (testId && testId[1]);
|
|
32
|
+
if (named) return iconWord(named);
|
|
33
|
+
for (const token of n.classList || []) {
|
|
34
|
+
const m = token.match(ICON_CLASS);
|
|
35
|
+
if (m && !NOT_ICON.test(m[1])) return iconWord(m[1]);
|
|
36
|
+
}
|
|
37
|
+
if (tag === 'img') {
|
|
38
|
+
const file = (n.getAttribute('src') || '').split(/[?#]/)[0].split('/').pop().replace(/[.][a-z0-9]+$/i, '');
|
|
39
|
+
if (/^[a-z][a-z_-]{1,30}$/i.test(file)) return iconWord(file);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return '';
|
|
43
|
+
};
|
|
44
|
+
const out = [];
|
|
45
|
+
let next = 0;
|
|
46
|
+
// Ids from the last look stay on elements that have since gone out of sight; the same number
|
|
47
|
+
// given to another element then matches two ("strict mode violation"). Start clean each time.
|
|
48
|
+
for (const el of document.querySelectorAll('[data-debutly-id]')) el.removeAttribute('data-debutly-id');
|
|
49
|
+
for (const el of document.querySelectorAll(selector)) {
|
|
50
|
+
// Apps hide the real file field behind a styled button or drop zone; files can still be
|
|
51
|
+
// attached to it, so list it.
|
|
52
|
+
const fileField = el.tagName === 'INPUT' && el.type === 'file';
|
|
53
|
+
if (el.type === 'hidden' || (!visible(el) && !fileField)) continue;
|
|
54
|
+
const id = next++;
|
|
55
|
+
el.setAttribute('data-debutly-id', String(id));
|
|
56
|
+
const tag = el.tagName.toLowerCase();
|
|
57
|
+
const role = el.getAttribute('role') || (tag === 'a' ? 'link' : tag === 'input' ? 'input:' + (el.type || 'text') : tag);
|
|
58
|
+
const labelledBy = (el.getAttribute('aria-labelledby') || '').split(/\\s+/).filter(Boolean)
|
|
59
|
+
.map((ref) => textOf(document.getElementById(ref))).join(' ');
|
|
60
|
+
// A field's label points at it (for=) or wraps it; a wrapping label's name is its own text,
|
|
61
|
+
// without the field's (a select's options, a textarea's content).
|
|
62
|
+
const field = tag === 'input' || tag === 'select' || tag === 'textarea';
|
|
63
|
+
const labelEl = (field && el.labels && el.labels[0]) || (el.id ? document.querySelector('label[for="' + CSS.escape(el.id) + '"]') : null);
|
|
64
|
+
let label = null;
|
|
65
|
+
if (labelEl) {
|
|
66
|
+
label = labelEl.cloneNode(true);
|
|
67
|
+
label.querySelectorAll('input, select, textarea, button').forEach((n) => n.remove());
|
|
68
|
+
}
|
|
69
|
+
// title is the accessible name of last resort, as browsers and screen readers treat it.
|
|
70
|
+
// A native select's text is all its options run together: name it by its label, and list them apart.
|
|
71
|
+
const isSelect = tag === 'select';
|
|
72
|
+
const fileHint = fileField ? 'file upload' + (el.getAttribute('accept') ? ' (' + el.getAttribute('accept') + ')' : '') : '';
|
|
73
|
+
const labelled = el.getAttribute('aria-label') || labelledBy || (label && label.textContent.trim());
|
|
74
|
+
const placeholder = el.getAttribute('placeholder');
|
|
75
|
+
const raw = labelled || placeholder || (isSelect ? '' : textOf(el).trim()) || el.getAttribute('title') || fileHint;
|
|
76
|
+
const item = { id, role, name: raw.replace(/\\s+/g, ' ').trim().slice(0, 80), tag };
|
|
77
|
+
// A placeholder is an example, not a label: screen readers read it as the field's name (a
|
|
78
|
+
// password's dots as "13 bullet"), and it's gone once someone types.
|
|
79
|
+
if (!labelled && placeholder) item.nameFrom = 'placeholder';
|
|
80
|
+
if (!item.name) {
|
|
81
|
+
const icon = iconOf(el);
|
|
82
|
+
if (icon) item.icon = icon;
|
|
83
|
+
}
|
|
84
|
+
// What a keyboard user can reach: a disabled control, or one out of the tab order, isn't.
|
|
85
|
+
if (el.disabled === true || el.getAttribute('aria-disabled') === 'true') item.disabled = true;
|
|
86
|
+
else if (el.tabIndex < 0 || el.closest('[inert]')) item.tabbable = false;
|
|
87
|
+
if (isSelect) {
|
|
88
|
+
const chosen = el.selectedOptions[0];
|
|
89
|
+
item.value = chosen ? chosen.label.slice(0, 80) : '';
|
|
90
|
+
item.options = Array.from(el.options).slice(0, 30).map((o) => o.label.slice(0, 60));
|
|
91
|
+
}
|
|
92
|
+
// What a person sees of a control's state: what a field holds (a password only as filled),
|
|
93
|
+
// whether it can be edited, whether a box is ticked and whether a menu is open.
|
|
94
|
+
const textField = tag === 'textarea' || (tag === 'input' && !['checkbox', 'radio', 'file', 'submit', 'button', 'reset', 'image', 'range', 'color'].includes(el.type));
|
|
95
|
+
if (textField && el.value) {
|
|
96
|
+
if (el.type === 'password') item.filled = true;
|
|
97
|
+
else item.value = el.value.slice(0, 60);
|
|
98
|
+
}
|
|
99
|
+
if (textField && el.readOnly) item.readOnly = true;
|
|
100
|
+
const ariaChecked = el.getAttribute('aria-checked');
|
|
101
|
+
if (el.type === 'checkbox' || el.type === 'radio') item.checked = el.checked;
|
|
102
|
+
else if (ariaChecked === 'true' || ariaChecked === 'false') item.checked = ariaChecked === 'true';
|
|
103
|
+
const ariaExpanded = el.getAttribute('aria-expanded');
|
|
104
|
+
if (ariaExpanded === 'true' || ariaExpanded === 'false') item.expanded = ariaExpanded === 'true';
|
|
105
|
+
out.push(item);
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
})()`;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
|
|
3
|
+
export interface SettleOptions {
|
|
4
|
+
/** Longest wait for the network to go quiet (ms). */
|
|
5
|
+
idleMs?: number;
|
|
6
|
+
/** Longest extra wait while the page still says it is loading (ms). */
|
|
7
|
+
busyMs?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Waits, as a person would, until the page has finished loading before it is read: the network
|
|
12
|
+
* goes quiet, and no "Loading…" placeholder or aria-busy region is left. Client-rendered apps show
|
|
13
|
+
* a spinner or an empty shell at DOMContentLoaded; on a slow connection agents otherwise judge (and
|
|
14
|
+
* navigate away from) pages that were only still loading. Bounded, so a page that never settles
|
|
15
|
+
* (polling, a stuck spinner) is read as it is.
|
|
16
|
+
*/
|
|
17
|
+
export async function settle(page: Page, o: SettleOptions = {}): Promise<void> {
|
|
18
|
+
const { idleMs = 5_000, busyMs = 10_000 } = o;
|
|
19
|
+
await page.waitForLoadState("networkidle", { timeout: idleMs }).catch(() => {});
|
|
20
|
+
const deadline = Date.now() + busyMs;
|
|
21
|
+
while (Date.now() < deadline) {
|
|
22
|
+
const busy = await page
|
|
23
|
+
.evaluate(() => {
|
|
24
|
+
if (document.querySelector('[aria-busy="true"]')) return true;
|
|
25
|
+
const text = (document.body?.innerText ?? "").trim();
|
|
26
|
+
return text.length < 300 && /\b(loading|please wait)\b/i.test(text);
|
|
27
|
+
})
|
|
28
|
+
.catch(() => false);
|
|
29
|
+
if (!busy) return;
|
|
30
|
+
await page.waitForTimeout(500);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
import type { Signal } from "../types";
|
|
3
|
+
|
|
4
|
+
const BODY_CHARS = 500;
|
|
5
|
+
/** How long a step waits for server-error bodies still being read before it is stored without them. */
|
|
6
|
+
const BODY_WAIT_MS = 2000;
|
|
7
|
+
|
|
8
|
+
export class SignalCollector {
|
|
9
|
+
private buffer: Signal[] = [];
|
|
10
|
+
private reading = new Set<Promise<void>>();
|
|
11
|
+
|
|
12
|
+
constructor(page: Page) {
|
|
13
|
+
page.on("console", (msg) => {
|
|
14
|
+
if (msg.type() === "error") this.buffer.push({ kind: "console_error", message: msg.text(), url: page.url() });
|
|
15
|
+
});
|
|
16
|
+
page.on("pageerror", (err) => {
|
|
17
|
+
this.buffer.push({ kind: "page_error", message: err.message, url: page.url() });
|
|
18
|
+
});
|
|
19
|
+
page.on("response", (res) => {
|
|
20
|
+
if (res.status() < 400) return;
|
|
21
|
+
// A failed navigation belongs to the page that was requested, not the one being left.
|
|
22
|
+
const url = res.request().isNavigationRequest() ? res.url() : page.url();
|
|
23
|
+
const startedAt = res.request().timing().startTime;
|
|
24
|
+
const signal: Signal = { kind: "http_error", status: res.status(), requestUrl: res.url(), url, startedAt: startedAt > 0 ? startedAt : undefined, at: Date.now() };
|
|
25
|
+
this.buffer.push(signal);
|
|
26
|
+
// A server error often says why (e.g. "No API key found"). Read the start of it; it lands on
|
|
27
|
+
// the same signal object, which findings read once the run is over.
|
|
28
|
+
if (res.status() >= 500) {
|
|
29
|
+
const read = res
|
|
30
|
+
.text()
|
|
31
|
+
.then((text) => {
|
|
32
|
+
if (signal.kind === "http_error") signal.body = text.slice(0, BODY_CHARS);
|
|
33
|
+
})
|
|
34
|
+
.catch(() => {})
|
|
35
|
+
.finally(() => this.reading.delete(read));
|
|
36
|
+
this.reading.add(read);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
// Generating a report often only downloads a file; without this the agent saw "nothing
|
|
40
|
+
// visibly changed" and concluded the button was broken.
|
|
41
|
+
page.on("download", (download) => {
|
|
42
|
+
this.buffer.push({ kind: "download", filename: download.suggestedFilename(), url: page.url() });
|
|
43
|
+
});
|
|
44
|
+
// Playwright dismisses dialogs by default, so every confirm() would read as Cancel. Answer OK,
|
|
45
|
+
// as a person going along with the app would, and let the agent know what it asked.
|
|
46
|
+
page.on("dialog", (dialog) => {
|
|
47
|
+
this.buffer.push({ kind: "dialog", dialogType: dialog.type(), message: dialog.message(), url: page.url() });
|
|
48
|
+
dialog.accept().catch(() => {});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A signal the agent loop observed itself, such as a slow page. */
|
|
53
|
+
note(signal: Signal): void {
|
|
54
|
+
this.buffer.push(signal);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Waits, briefly, for the server-error bodies still being read. A step is stored as soon as it
|
|
59
|
+
* ends; a fast 502 was stored without the body that said why it failed (2026-10-17).
|
|
60
|
+
*/
|
|
61
|
+
async settle(): Promise<void> {
|
|
62
|
+
if (!this.reading.size) return;
|
|
63
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
64
|
+
await Promise.race([Promise.allSettled([...this.reading]), new Promise((r) => (timer = setTimeout(r, BODY_WAIT_MS)))]);
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
drain(): Signal[] {
|
|
69
|
+
const out = this.buffer;
|
|
70
|
+
this.buffer = [];
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/cancel.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The run kill switch from anywhere: npm run debutly:cancel -- <runId> --store postgres://…
|
|
2
|
+
// Agents stop at their next step; the run still writes its (partial) report.
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { openDb } from "./store/db";
|
|
5
|
+
import { PgRunStore } from "./store/pg-run-store";
|
|
6
|
+
|
|
7
|
+
const { values, positionals } = parseArgs({ allowPositionals: true, options: { store: { type: "string" } } });
|
|
8
|
+
const [runId] = positionals;
|
|
9
|
+
const url = values.store ?? process.env.DATABASE_URL;
|
|
10
|
+
if (!runId || !url) {
|
|
11
|
+
console.error("Usage: npm run debutly:cancel -- <runId> --store postgres://… (or set DATABASE_URL)");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
const { db, close } = await openDb(url);
|
|
15
|
+
const store = new PgRunStore(db);
|
|
16
|
+
await store.requestCancel(runId);
|
|
17
|
+
const run = await store.getRun(runId);
|
|
18
|
+
await close();
|
|
19
|
+
console.log(run ? `Run ${runId}: ${run.status}` : `No run ${runId}`);
|
|
20
|
+
process.exit(run ? 0 : 1);
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import "./util/legacy-env";
|
|
2
|
+
import "./util/key-defaults";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { Queue, QueueEvents } from "bullmq";
|
|
5
|
+
import { writeFile } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { estimatePerceptionCost, estimateRunCost } from "./cost/estimate";
|
|
8
|
+
import { fetchModelPrice } from "./cost/prices";
|
|
9
|
+
import { agentCount, resolveRunSize } from "./cost/tiers";
|
|
10
|
+
import { DbArtifactStore, TeeArtifactStore } from "./artifacts/db";
|
|
11
|
+
import { S3ArtifactStore } from "./artifacts/s3";
|
|
12
|
+
import { LocalArtifactStore, type ArtifactStore } from "./artifacts/store";
|
|
13
|
+
import { MailpitInbox } from "./inbox/mailpit";
|
|
14
|
+
import { creditCheck, keyCreditsLeft } from "./llm/credits";
|
|
15
|
+
import { createLlms } from "./llm/factory";
|
|
16
|
+
import { OUT_OF_CREDITS } from "./llm/types";
|
|
17
|
+
import { generateUnits } from "./persona/generate";
|
|
18
|
+
import { withInclusion } from "./persona/inclusion";
|
|
19
|
+
import { languageName, parseLanguages, withLanguages } from "./persona/language";
|
|
20
|
+
import { peopleCount, readPeople } from "./persona/people";
|
|
21
|
+
import { exhibitsFromSessions } from "./perception/exhibits";
|
|
22
|
+
import { runPerception } from "./perception/run-perception";
|
|
23
|
+
import { renderLaunchReadiness } from "./report/launch-readiness";
|
|
24
|
+
import { queueDispatch } from "./queue/dispatch";
|
|
25
|
+
import { AGENT_QUEUE, redisClient } from "./queue/jobs";
|
|
26
|
+
import { HttpSandboxOutbox } from "./sandbox/outbox";
|
|
27
|
+
import { runSimulation } from "./run";
|
|
28
|
+
import { openDb } from "./store/db";
|
|
29
|
+
import { PgRunStore } from "./store/pg-run-store";
|
|
30
|
+
import type { CustomerUnit, Persona } from "./types";
|
|
31
|
+
import { runOrigin } from "./util/run-origin";
|
|
32
|
+
|
|
33
|
+
const MAX_EMPLOYEES = 4;
|
|
34
|
+
|
|
35
|
+
const { values } = parseArgs({
|
|
36
|
+
options: {
|
|
37
|
+
url: { type: "string" },
|
|
38
|
+
goal: { type: "string" },
|
|
39
|
+
icp: { type: "string" },
|
|
40
|
+
// Customer units: "individual" (B2C, one person each) or "business" (owner + employees).
|
|
41
|
+
unit: { type: "string", default: "individual" },
|
|
42
|
+
// Run size: quick (3 units, owners only), standard (10, 2 employees each) or deep (30, 3 each).
|
|
43
|
+
tier: { type: "string", default: "quick" },
|
|
44
|
+
// How many units (individuals or business crews); overrides the tier.
|
|
45
|
+
agents: { type: "string" },
|
|
46
|
+
// Employees per business crew (0 = owner only); overrides the tier.
|
|
47
|
+
employees: { type: "string" },
|
|
48
|
+
// Refuse to start if the run could cost more than this many USD.
|
|
49
|
+
"max-cost": { type: "string" },
|
|
50
|
+
// Screen-reader, low-vision, slow-connection and non-native-English agents, rotating nightly.
|
|
51
|
+
inclusion: { type: "string", default: "on" },
|
|
52
|
+
// GLM-5.3 Flash, the default agent model, takes about 40% more steps than Gemini.
|
|
53
|
+
"max-steps": { type: "string", default: "30" },
|
|
54
|
+
concurrency: { type: "string", default: "3" },
|
|
55
|
+
out: { type: "string" },
|
|
56
|
+
// Local mail catcher (e.g. Mailpit from `supabase start`) that gives agents real inboxes.
|
|
57
|
+
mailpit: { type: "string" },
|
|
58
|
+
// Keeps each agent's signed-in browser for `debutly hear`; holds session cookies, so keep it out of the report.
|
|
59
|
+
"browser-state": { type: "string" },
|
|
60
|
+
"email-domain": { type: "string", default: "agents.test" },
|
|
61
|
+
// The app's sandbox outbox (token in DEBUTLY_SANDBOX_TOKEN), to separate failures the test environment caused.
|
|
62
|
+
"sandbox-outbox": { type: "string" },
|
|
63
|
+
// Record the run as it happens: postgres://… (or memory: for a throwaway in-process database).
|
|
64
|
+
store: { type: "string" },
|
|
65
|
+
// For the dashboard: which app this is, what started the run, and its CI page. In GitHub
|
|
66
|
+
// Actions all three default from the workflow (repository name, event, run URL).
|
|
67
|
+
app: { type: "string" },
|
|
68
|
+
trigger: { type: "string" },
|
|
69
|
+
"ci-url": { type: "string" },
|
|
70
|
+
// Days to keep screenshots stored in the database (with --store and no S3), default 7.
|
|
71
|
+
"screenshot-days": { type: "string", default: "7" },
|
|
72
|
+
// Run agents on queue workers (npm run debutly:worker) instead of here: redis://…; needs a postgres --store.
|
|
73
|
+
queue: { type: "string" },
|
|
74
|
+
// Per-agent caps on top of --max-steps.
|
|
75
|
+
"agent-token-cap": { type: "string" },
|
|
76
|
+
"agent-minutes": { type: "string" },
|
|
77
|
+
// After the depth run, this many breadth evaluators judge the pages the agents saw (Launch Readiness report).
|
|
78
|
+
evaluators: { type: "string" },
|
|
79
|
+
// Customers' first languages, as lang codes (ar,en): agents take them in turn.
|
|
80
|
+
languages: { type: "string" },
|
|
81
|
+
// An earlier run's people.json: the same agents again, e.g. to see whether a fix worked.
|
|
82
|
+
people: { type: "string" },
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const unitKind = values.unit;
|
|
87
|
+
if (!values.url || !values.goal || !values.icp || (unitKind !== "individual" && unitKind !== "business")) {
|
|
88
|
+
console.error(
|
|
89
|
+
'Usage: npx debutly (settings in debutly.json; npx debutly init writes one) or npx debutly --url <url> --goal "<goal>" --icp "<ideal customer profile>" [--unit individual|business] [--tier quick|standard|deep] [--agents N] [--employees N] [--max-cost USD] [--inclusion on|off] [--max-steps 30] [--concurrency 3] [--out runs/x] [--mailpit http://127.0.0.1:54324] [--email-domain agents.test] [--sandbox-outbox http://localhost:3000/api/sandbox/outbox] [--store postgres://…] [--app name] [--trigger nightly|pr|manual] [--ci-url url] [--screenshot-days 7] [--browser-state dir] [--queue redis://…] [--agent-token-cap N] [--agent-minutes N] [--evaluators N] [--people runs/x/people.json]',
|
|
90
|
+
);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
// Checked before anything is spent: a wrong code stops the run here.
|
|
94
|
+
let languages: string[];
|
|
95
|
+
try {
|
|
96
|
+
languages = parseLanguages(values.languages);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error(err instanceof Error ? err.message : err);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
const size = resolveRunSize({
|
|
102
|
+
tier: values.tier,
|
|
103
|
+
units: values.agents !== undefined ? Number(values.agents) : undefined,
|
|
104
|
+
employees: values.employees !== undefined ? Number(values.employees) : undefined,
|
|
105
|
+
});
|
|
106
|
+
const employees = Math.min(Math.max(size.employees || 0, 0), MAX_EMPLOYEES);
|
|
107
|
+
const count = size.units;
|
|
108
|
+
const maxSteps = Number(values["max-steps"]);
|
|
109
|
+
let reused: CustomerUnit[] | undefined;
|
|
110
|
+
try {
|
|
111
|
+
reused = values.people ? await readPeople(values.people) : undefined;
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.error(err instanceof Error ? err.message : err);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
if (unitKind === "business" && employees > 0 && !values.mailpit) {
|
|
117
|
+
console.warn("Business crews need --mailpit for invites; employees will be recorded as not invited.");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Before anything is spent: what could this run cost?
|
|
121
|
+
const navModel = process.env.DEBUTLY_NAV_MODEL ?? (process.env.DEBUTLY_PROVIDER === "openai-compatible" ? "" : "claude-haiku-4-5");
|
|
122
|
+
const agents = reused ? peopleCount(reused) : agentCount(unitKind, { ...size, employees });
|
|
123
|
+
const price = await fetchModelPrice(navModel);
|
|
124
|
+
const evaluatorCount = values.evaluators !== undefined ? Number(values.evaluators) : 0;
|
|
125
|
+
const depthEstimate = estimateRunCost({ agents, maxSteps, price });
|
|
126
|
+
const breadthEstimate = evaluatorCount ? estimatePerceptionCost({ evaluators: evaluatorCount, experiment: false, price }) : { low: 0, high: 0 };
|
|
127
|
+
const estimate = depthEstimate && breadthEstimate ? { low: depthEstimate.low + breadthEstimate.low, high: depthEstimate.high + breadthEstimate.high } : null;
|
|
128
|
+
console.log(
|
|
129
|
+
estimate
|
|
130
|
+
? `Estimated cost: $${estimate.low.toFixed(2)}–$${estimate.high.toFixed(2)} (${agents} agents × up to ${maxSteps} steps${evaluatorCount ? ` + ${evaluatorCount} evaluators` : ""} on ${navModel})`
|
|
131
|
+
: `Estimated cost: unknown (no price found for ${navModel})`,
|
|
132
|
+
);
|
|
133
|
+
if (values["max-cost"] !== undefined) {
|
|
134
|
+
const ceiling = Number(values["max-cost"]);
|
|
135
|
+
if (!estimate) {
|
|
136
|
+
console.error(`Refusing to start: --max-cost is set but the price of ${navModel} is unknown.`);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
if (estimate.high > ceiling) {
|
|
140
|
+
console.error(`Refusing to start: this run could cost up to $${estimate.high.toFixed(2)}, above --max-cost $${ceiling.toFixed(2)}.`);
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A key with a spending limit says what it has left: don't start a run it can't finish.
|
|
146
|
+
const credits = creditCheck(await keyCreditsLeft(process.env.DEBUTLY_BASE_URL ?? "", process.env.DEBUTLY_API_KEY ?? ""), estimate);
|
|
147
|
+
if (credits.refuse) {
|
|
148
|
+
console.error(`Refusing to start: ${credits.refuse}`);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
if (credits.warn) console.warn(`Warning: ${credits.warn}`);
|
|
152
|
+
|
|
153
|
+
const { nav, judge } = createLlms(process.env);
|
|
154
|
+
let units: CustomerUnit[];
|
|
155
|
+
if (reused) {
|
|
156
|
+
console.log(`The same ${agents} agents as before, from ${values.people}:`);
|
|
157
|
+
units = reused;
|
|
158
|
+
} else {
|
|
159
|
+
console.log(`Generating ${count} ${unitKind === "business" ? `business crew(s) with ${employees} employees each` : "individual(s)"}...`);
|
|
160
|
+
units = await generateUnits(judge, values.icp, { kind: unitKind, count, employees, languages });
|
|
161
|
+
if (values.inclusion !== "off") {
|
|
162
|
+
const dayOfYear = Math.floor((Date.now() - Date.UTC(new Date().getUTCFullYear(), 0, 1)) / 86_400_000);
|
|
163
|
+
units = withInclusion(units, dayOfYear);
|
|
164
|
+
}
|
|
165
|
+
units = withLanguages(units, languages);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const describe = (p: Persona) =>
|
|
169
|
+
`${p.id} ${p.name}${p.role ? ` (${p.role})` : ""}${p.inclusion ? ` [${p.inclusion.replace(/_/g, " ")}]` : ""}${p.language && p.language !== "en" ? ` [${languageName(p.language)}]` : ""}: ${p.background}`;
|
|
170
|
+
for (const u of units) {
|
|
171
|
+
if (u.kind === "individual") console.log(` ${describe(u.person)}`);
|
|
172
|
+
else {
|
|
173
|
+
console.log(` ${u.id} ${u.business.name}, ${u.business.archetype} of ~${u.business.size}, $${u.business.monthlyBudget}/mo`);
|
|
174
|
+
for (const p of [u.owner, ...u.employees]) console.log(` ${describe(p)}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (values.queue && (!values.store || values.store === "memory:")) {
|
|
179
|
+
console.error("--queue needs --store postgres://…: workers record the agents there.");
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
const outDir = values.out ?? `runs/${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
183
|
+
const opened = values.store ? await openDb(values.store) : undefined;
|
|
184
|
+
const store = opened ? new PgRunStore(opened.db) : undefined;
|
|
185
|
+
const origin = runOrigin({ app: values.app, trigger: values.trigger, ciUrl: values["ci-url"] }, process.env);
|
|
186
|
+
const runId = store
|
|
187
|
+
? await store.createRun({ ...origin, targetUrl: values.url, goal: values.goal, icp: values.icp, tier: size.tier, unitKind, model: navModel, estimate })
|
|
188
|
+
: undefined;
|
|
189
|
+
// Screenshots: S3 when it is configured; otherwise, with a store, the database (compact JPEGs,
|
|
190
|
+
// old ones pruned) as well as the run folder, so the dashboard has them with nothing but Postgres.
|
|
191
|
+
const s3 = runId ? S3ArtifactStore.fromEnv(process.env, runId) : undefined;
|
|
192
|
+
let artifacts: ArtifactStore | undefined = s3;
|
|
193
|
+
if (!s3 && opened && runId && values.store !== "memory:") {
|
|
194
|
+
const pruned = await DbArtifactStore.prune(opened.db, Number(values["screenshot-days"]));
|
|
195
|
+
if (pruned) console.log(`Pruned ${pruned} screenshots older than ${values["screenshot-days"]} days from the store.`);
|
|
196
|
+
artifacts = new TeeArtifactStore([new LocalArtifactStore(join(outDir, "screenshots")), new DbArtifactStore(opened.db, runId)]);
|
|
197
|
+
}
|
|
198
|
+
if (runId) console.log(`Run ${runId} (cancel from anywhere with: npm run debutly:cancel -- ${runId} --store <url>)`);
|
|
199
|
+
|
|
200
|
+
// Kill switch: the first Ctrl+C lets agents stop at their next step and still writes
|
|
201
|
+
// the report; a second one exits at once.
|
|
202
|
+
let cancelled = false;
|
|
203
|
+
process.on("SIGINT", () => {
|
|
204
|
+
if (cancelled) process.exit(130);
|
|
205
|
+
cancelled = true;
|
|
206
|
+
console.warn("\nCancelling: agents stop at their next step (Ctrl+C again to quit now)...");
|
|
207
|
+
if (store && runId) void store.requestCancel(runId);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const queue = values.queue ? new Queue(AGENT_QUEUE, { connection: redisClient(values.queue) }) : undefined;
|
|
211
|
+
const queueEvents = values.queue ? new QueueEvents(AGENT_QUEUE, { connection: redisClient(values.queue) }) : undefined;
|
|
212
|
+
const caps = {
|
|
213
|
+
maxTokens: values["agent-token-cap"] !== undefined ? Number(values["agent-token-cap"]) : undefined,
|
|
214
|
+
maxMs: values["agent-minutes"] !== undefined ? Number(values["agent-minutes"]) * 60_000 : undefined,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
console.log(`Running against ${values.url}${values.mailpit ? " (with inboxes)" : ""}...`);
|
|
218
|
+
const result = await runSimulation({
|
|
219
|
+
url: values.url,
|
|
220
|
+
goal: values.goal,
|
|
221
|
+
units,
|
|
222
|
+
llm: nav,
|
|
223
|
+
maxSteps,
|
|
224
|
+
concurrency: Number(values.concurrency),
|
|
225
|
+
outDir,
|
|
226
|
+
inbox: values.mailpit ? new MailpitInbox(values.mailpit) : undefined,
|
|
227
|
+
emailDomain: values["email-domain"],
|
|
228
|
+
sandboxOutbox: values["sandbox-outbox"] ? new HttpSandboxOutbox(values["sandbox-outbox"], process.env.DEBUTLY_SANDBOX_TOKEN ?? "") : undefined,
|
|
229
|
+
store,
|
|
230
|
+
runId,
|
|
231
|
+
price,
|
|
232
|
+
caps,
|
|
233
|
+
shouldStop: () => cancelled,
|
|
234
|
+
artifacts,
|
|
235
|
+
browserStateDir: values["browser-state"],
|
|
236
|
+
dispatch:
|
|
237
|
+
queue && queueEvents && runId
|
|
238
|
+
? ({ runTag }) =>
|
|
239
|
+
queueDispatch(queue, queueEvents, {
|
|
240
|
+
runId,
|
|
241
|
+
targetUrl: values.url!,
|
|
242
|
+
maxSteps,
|
|
243
|
+
caps,
|
|
244
|
+
price,
|
|
245
|
+
mailpitUrl: values.mailpit,
|
|
246
|
+
emailDomain: values["email-domain"] ?? "agents.test",
|
|
247
|
+
runTag,
|
|
248
|
+
})
|
|
249
|
+
: undefined,
|
|
250
|
+
});
|
|
251
|
+
await queueEvents?.close();
|
|
252
|
+
await queue?.close();
|
|
253
|
+
await opened?.close();
|
|
254
|
+
|
|
255
|
+
if (evaluatorCount > 0) {
|
|
256
|
+
console.log(`${evaluatorCount} evaluators judging the pages the agents saw...`);
|
|
257
|
+
const perception = await runPerception({
|
|
258
|
+
llm: nav,
|
|
259
|
+
judge,
|
|
260
|
+
icp: values.icp,
|
|
261
|
+
product: values.url,
|
|
262
|
+
exhibitsByVariant: { main: exhibitsFromSessions(result.sessions) },
|
|
263
|
+
evaluators: evaluatorCount,
|
|
264
|
+
concurrency: Math.max(Number(values.concurrency), 8),
|
|
265
|
+
price,
|
|
266
|
+
judgePrice: await fetchModelPrice(process.env.DEBUTLY_JUDGE_MODEL ?? ""),
|
|
267
|
+
functional: result.findings,
|
|
268
|
+
});
|
|
269
|
+
const readiness = join(outDir, "launch-readiness.md");
|
|
270
|
+
await writeFile(readiness, renderLaunchReadiness({ target: values.url, icp: values.icp, result: perception, functional: result.findings }));
|
|
271
|
+
console.log(`Launch Readiness: ${readiness}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const noCredit = result.sessions.filter((s) => s.outcome === "error" && s.outcomeNote.startsWith(OUT_OF_CREDITS)).length;
|
|
275
|
+
if (noCredit) {
|
|
276
|
+
const message = `The AI provider account ran out of credits: ${noCredit} of ${result.sessions.length} agents stopped early. Top up and run again.`;
|
|
277
|
+
// On GitHub Actions, an annotation puts it on the run's summary page.
|
|
278
|
+
console.error(process.env.GITHUB_ACTIONS ? `::error title=Debutly run cut short::${message}` : message);
|
|
279
|
+
}
|
|
280
|
+
const cost = result.usage.costUsd !== undefined ? `, cost $${result.usage.costUsd.toFixed(4)}` : "";
|
|
281
|
+
console.log(`Done: ${result.findings.length} findings${cost}. Report: ${result.reportPath}${runId ? ` (run ${runId})` : ""}`);
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Settings for `debutly run`, kept in the app's repo so a run needs no flags. */
|
|
5
|
+
export const CONFIG_FILE = "debutly.json";
|
|
6
|
+
|
|
7
|
+
// What debutly.json may set: the run's flags, camelCased. The store's address is a secret, so it
|
|
8
|
+
// comes from DEBUTLY_STORE_URL instead; where reports go and what started a run are per run.
|
|
9
|
+
const SETTINGS = [
|
|
10
|
+
"url",
|
|
11
|
+
"goal",
|
|
12
|
+
"icp",
|
|
13
|
+
"unit",
|
|
14
|
+
"tier",
|
|
15
|
+
"agents",
|
|
16
|
+
"employees",
|
|
17
|
+
"maxCost",
|
|
18
|
+
"inclusion",
|
|
19
|
+
"maxSteps",
|
|
20
|
+
"concurrency",
|
|
21
|
+
"mailpit",
|
|
22
|
+
"emailDomain",
|
|
23
|
+
"sandboxOutbox",
|
|
24
|
+
"app",
|
|
25
|
+
"screenshotDays",
|
|
26
|
+
"agentTokenCap",
|
|
27
|
+
"agentMinutes",
|
|
28
|
+
"evaluators",
|
|
29
|
+
"languages",
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
export type Setting = (typeof SETTINGS)[number];
|
|
33
|
+
export type DebutlyConfig = Partial<Record<Setting, string | number | boolean>> & { $schema?: string };
|
|
34
|
+
|
|
35
|
+
const flagOf = (setting: string) => `--${setting.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
|
|
36
|
+
|
|
37
|
+
/** The config file in `dir`, or undefined when there is none. */
|
|
38
|
+
export function readConfig(dir: string = process.cwd()): DebutlyConfig | undefined {
|
|
39
|
+
const path = join(dir, CONFIG_FILE);
|
|
40
|
+
if (!existsSync(path)) return undefined;
|
|
41
|
+
try {
|
|
42
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
43
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("it should hold one JSON object");
|
|
44
|
+
return parsed as DebutlyConfig;
|
|
45
|
+
} catch (err) {
|
|
46
|
+
throw new Error(`${CONFIG_FILE} can't be read: ${err instanceof Error ? err.message : err}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The config as command-line flags. Flags given on the command line come after, so they win. */
|
|
51
|
+
export function configArgs(config: DebutlyConfig): string[] {
|
|
52
|
+
const args: string[] = [];
|
|
53
|
+
for (const [key, value] of Object.entries(config)) {
|
|
54
|
+
if (key === "$schema" || value === undefined || value === null) continue;
|
|
55
|
+
if (!(SETTINGS as readonly string[]).includes(key)) {
|
|
56
|
+
throw new Error(`${CONFIG_FILE}: "${key}" is not a setting. Settings: ${SETTINGS.join(", ")}.`);
|
|
57
|
+
}
|
|
58
|
+
if (typeof value === "object") throw new Error(`${CONFIG_FILE}: "${key}" should be text or a number.`);
|
|
59
|
+
args.push(flagOf(key), key === "inclusion" && typeof value === "boolean" ? (value ? "on" : "off") : String(value));
|
|
60
|
+
}
|
|
61
|
+
return args;
|
|
62
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ModelPrice } from "../llm/meter";
|
|
2
|
+
|
|
3
|
+
export interface CostEstimate {
|
|
4
|
+
low: number;
|
|
5
|
+
high: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Typical navigation step on the 2026-09-27/28 runs: ~2,500 prompt tokens (page + history, with
|
|
9
|
+
// provider-side caching) and ~150 reply tokens. That brackets the observed ~$0.08 per 3-agent run.
|
|
10
|
+
// Replace with averages from stored runs once enough are recorded.
|
|
11
|
+
const TOKENS_PER_STEP = { input: 2500, output: 150 };
|
|
12
|
+
|
|
13
|
+
/** Cost range for a run: agents using 60% (low) to 100% (high) of their step budget. */
|
|
14
|
+
export function estimateRunCost(o: { agents: number; maxSteps: number; price: ModelPrice | null; tokensPerStep?: { input: number; output: number } }): CostEstimate | null {
|
|
15
|
+
if (!o.price) return null;
|
|
16
|
+
const t = o.tokensPerStep ?? TOKENS_PER_STEP;
|
|
17
|
+
const perStep = (t.input * o.price.inputPerMTok + t.output * o.price.outputPerMTok) / 1_000_000;
|
|
18
|
+
const high = o.agents * o.maxSteps * perStep;
|
|
19
|
+
return { low: high * 0.6, high };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// One evaluator call: the pages (~3,000 tokens; two variants ~5,000) plus rubric and persona in,
|
|
23
|
+
// a JSON verdict (~450 tokens) out. Persona generation and the summary add little.
|
|
24
|
+
const EVALUATOR_TOKENS = { single: { input: 3500, output: 450 }, experiment: { input: 5500, output: 500 } };
|
|
25
|
+
|
|
26
|
+
/** Cost range for a perception run: ±20% around the per-call estimate. */
|
|
27
|
+
export function estimatePerceptionCost(o: { evaluators: number; experiment: boolean; price: ModelPrice | null }): CostEstimate | null {
|
|
28
|
+
if (!o.price) return null;
|
|
29
|
+
const t = o.experiment ? EVALUATOR_TOKENS.experiment : EVALUATOR_TOKENS.single;
|
|
30
|
+
const mid = (o.evaluators * (t.input * o.price.inputPerMTok + t.output * o.price.outputPerMTok)) / 1_000_000;
|
|
31
|
+
return { low: mid * 0.8, high: mid * 1.2 };
|
|
32
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ModelPrice } from "../llm/meter";
|
|
2
|
+
|
|
3
|
+
// USD per million tokens, from OpenRouter's model list on 2026-09-28. Used when it can't be reached.
|
|
4
|
+
const KNOWN: Record<string, ModelPrice> = {
|
|
5
|
+
"google/gemini-3.8-flash": { inputPerMTok: 0.75, outputPerMTok: 3.75 },
|
|
6
|
+
"z-ai/glm-5.3-flash": { inputPerMTok: 0.15, outputPerMTok: 0.5 },
|
|
7
|
+
"deepseek/deepseek-v4-flash": { inputPerMTok: 0.079, outputPerMTok: 0.159 },
|
|
8
|
+
"anthropic/claude-haiku-4.5": { inputPerMTok: 1, outputPerMTok: 5 },
|
|
9
|
+
"claude-haiku-4-5": { inputPerMTok: 1, outputPerMTok: 5 },
|
|
10
|
+
"anthropic/claude-sonnet-5": { inputPerMTok: 2, outputPerMTok: 10 },
|
|
11
|
+
"anthropic/claude-opus-5": { inputPerMTok: 5, outputPerMTok: 25 },
|
|
12
|
+
"claude-opus-5": { inputPerMTok: 5, outputPerMTok: 25 },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** A model's price per million tokens: OpenRouter's live list, then the table above, else null (unknown). */
|
|
16
|
+
export async function fetchModelPrice(model: string, fetchFn: typeof fetch = fetch): Promise<ModelPrice | null> {
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetchFn("https://openrouter.ai/api/v1/models");
|
|
19
|
+
if (res.ok) {
|
|
20
|
+
const { data = [] } = (await res.json()) as { data?: { id: string; pricing?: { prompt?: string; completion?: string } }[] };
|
|
21
|
+
const pricing = data.find((m) => m.id === model)?.pricing;
|
|
22
|
+
const input = Number(pricing?.prompt);
|
|
23
|
+
const output = Number(pricing?.completion);
|
|
24
|
+
if (Number.isFinite(input) && Number.isFinite(output) && pricing) {
|
|
25
|
+
return { inputPerMTok: Math.round(input * 1e9) / 1e3, outputPerMTok: Math.round(output * 1e9) / 1e3 };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
// Offline or blocked: fall through to the table.
|
|
30
|
+
}
|
|
31
|
+
return KNOWN[model] ?? null;
|
|
32
|
+
}
|