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.
Files changed (93) hide show
  1. package/LICENSE +662 -0
  2. package/README.md +192 -2
  3. package/bin/debutly.mjs +8 -0
  4. package/drizzle/0000_init.sql +107 -0
  5. package/drizzle/0001_predictions.sql +14 -0
  6. package/drizzle/0002_dashboard.sql +15 -0
  7. package/drizzle/meta/0000_snapshot.json +722 -0
  8. package/drizzle/meta/0001_snapshot.json +821 -0
  9. package/drizzle/meta/0002_snapshot.json +942 -0
  10. package/drizzle/meta/_journal.json +27 -0
  11. package/package.json +83 -4
  12. package/src/agent/describe.ts +29 -0
  13. package/src/agent/effect.ts +94 -0
  14. package/src/agent/give-up.ts +42 -0
  15. package/src/agent/label.ts +19 -0
  16. package/src/agent/loop.ts +302 -0
  17. package/src/agent/parse-reply.ts +127 -0
  18. package/src/agent/prompt.ts +85 -0
  19. package/src/agent/run-agent.ts +68 -0
  20. package/src/artifacts/db.ts +55 -0
  21. package/src/artifacts/s3.ts +36 -0
  22. package/src/artifacts/store.ts +26 -0
  23. package/src/bin.ts +140 -0
  24. package/src/browser/ax.ts +77 -0
  25. package/src/browser/execute.ts +401 -0
  26. package/src/browser/observe.ts +62 -0
  27. package/src/browser/reflow.ts +73 -0
  28. package/src/browser/samples.ts +119 -0
  29. package/src/browser/scan-script.ts +108 -0
  30. package/src/browser/settle.ts +32 -0
  31. package/src/browser/signals.ts +73 -0
  32. package/src/cancel.ts +20 -0
  33. package/src/cli.ts +281 -0
  34. package/src/config.ts +62 -0
  35. package/src/cost/estimate.ts +32 -0
  36. package/src/cost/prices.ts +32 -0
  37. package/src/cost/tiers.ts +27 -0
  38. package/src/costs.ts +96 -0
  39. package/src/crew/run-unit.ts +62 -0
  40. package/src/detect/findings.ts +649 -0
  41. package/src/detect/wcag.ts +38 -0
  42. package/src/doctor.ts +145 -0
  43. package/src/hear-cli.ts +137 -0
  44. package/src/hear.ts +309 -0
  45. package/src/import.ts +108 -0
  46. package/src/inbox/mailpit.ts +46 -0
  47. package/src/inbox/types.ts +10 -0
  48. package/src/init.ts +184 -0
  49. package/src/llm/anthropic.ts +41 -0
  50. package/src/llm/credits.ts +35 -0
  51. package/src/llm/factory.ts +65 -0
  52. package/src/llm/meter.ts +39 -0
  53. package/src/llm/openai-compatible.ts +90 -0
  54. package/src/llm/openrouter.ts +9 -0
  55. package/src/llm/scripted.ts +18 -0
  56. package/src/llm/types.ts +36 -0
  57. package/src/perceive.ts +150 -0
  58. package/src/perception/aggregate.ts +111 -0
  59. package/src/perception/compare.ts +77 -0
  60. package/src/perception/evaluate.ts +83 -0
  61. package/src/perception/exhibits.ts +146 -0
  62. package/src/perception/market-context.ts +50 -0
  63. package/src/perception/run-perception.ts +178 -0
  64. package/src/persona/generate.ts +92 -0
  65. package/src/persona/inclusion.ts +53 -0
  66. package/src/persona/language.ts +59 -0
  67. package/src/persona/people.ts +32 -0
  68. package/src/predictions.ts +33 -0
  69. package/src/queue/dispatch.ts +18 -0
  70. package/src/queue/jobs.ts +28 -0
  71. package/src/queue/processor.ts +42 -0
  72. package/src/report/fix-first.ts +121 -0
  73. package/src/report/launch-readiness.ts +106 -0
  74. package/src/report/markdown.ts +202 -0
  75. package/src/run.ts +141 -0
  76. package/src/sandbox/outbox.ts +65 -0
  77. package/src/store/db.ts +40 -0
  78. package/src/store/import-run.ts +116 -0
  79. package/src/store/pg-run-store.ts +163 -0
  80. package/src/store/predictions.ts +70 -0
  81. package/src/store/run-store.ts +78 -0
  82. package/src/store/schema.ts +193 -0
  83. package/src/store/url.ts +15 -0
  84. package/src/types.ts +201 -0
  85. package/src/util/chromium.ts +20 -0
  86. package/src/util/json.ts +7 -0
  87. package/src/util/key-defaults.ts +34 -0
  88. package/src/util/legacy-env.ts +14 -0
  89. package/src/util/load-env.ts +28 -0
  90. package/src/util/pool.ts +12 -0
  91. package/src/util/run-origin.ts +27 -0
  92. package/src/worker.ts +53 -0
  93. package/tsconfig.json +13 -0
package/src/import.ts ADDED
@@ -0,0 +1,108 @@
1
+ // Load finished runs into the store for the dashboard.
2
+ //
3
+ // npm run debutly:import -- --store <url> --dir runs/2026-10-02 --app acme [--at 2026-10-02T10:02Z]
4
+ // npm run debutly:import -- --store <url> --github acme/app --app acme [--since 2026-09-28]
5
+ //
6
+ // --github reads that repo's Debutly workflow runs through the `gh` CLI, downloads each
7
+ // one's report artifact and imports it; runs already in the store (same CI page) are skipped.
8
+ import { execFile } from "node:child_process";
9
+ import { mkdtemp, rm } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { parseArgs, promisify } from "node:util";
13
+ import { eq } from "drizzle-orm";
14
+ import { chromium, type Browser } from "playwright";
15
+ import { openDb } from "./store/db";
16
+ import { importRunFolder } from "./store/import-run";
17
+ import { runs } from "./store/schema";
18
+
19
+ const run = promisify(execFile);
20
+ const TRIGGERS: Record<string, string> = { schedule: "nightly", pull_request: "pr", workflow_dispatch: "manual" };
21
+ // The report artifact's name; runs from before 2026-10-03, when Debutly was Wind Tunnel, use the second.
22
+ const REPORT_ARTIFACTS = ["debutly-report", "wind-tunnel-report"];
23
+
24
+ /** Downloads a CI run's report into dir; false when the run has none. */
25
+ async function downloadReport(repo: string, runId: number, dir: string): Promise<boolean> {
26
+ for (const name of REPORT_ARTIFACTS) {
27
+ try {
28
+ await run("gh", ["run", "download", String(runId), "--repo", repo, "-n", name, "-D", dir], { maxBuffer: 1 << 24 });
29
+ return true;
30
+ } catch {
31
+ // No artifact by this name; try the next.
32
+ }
33
+ }
34
+ return false;
35
+ }
36
+
37
+ const { values } = parseArgs({
38
+ options: {
39
+ store: { type: "string" },
40
+ dir: { type: "string" },
41
+ github: { type: "string" },
42
+ workflow: { type: "string", default: "throwaway-env.yml" },
43
+ app: { type: "string" },
44
+ at: { type: "string" },
45
+ "ci-url": { type: "string" },
46
+ trigger: { type: "string" },
47
+ since: { type: "string" },
48
+ limit: { type: "string", default: "30" },
49
+ },
50
+ });
51
+
52
+ if (!values.store || (!values.dir && !values.github)) {
53
+ console.error("Usage: npm run debutly:import -- --store <url> (--dir <run folder> | --github owner/repo) [--app name] [--since YYYY-MM-DD]");
54
+ process.exit(1);
55
+ }
56
+
57
+ // Screenshots go in as compact JPEGs, converted by drawing each PNG in a browser.
58
+ async function jpegConverter(browser: Browser) {
59
+ const page = await browser.newPage({ viewport: { width: 1600, height: 1200 } });
60
+ return async (png: Buffer) => {
61
+ await page.setContent(`<style>body{margin:0}</style><img src="data:image/png;base64,${png.toString("base64")}">`);
62
+ const img = page.locator("img");
63
+ await img.evaluate((el: HTMLImageElement) => el.decode());
64
+ return img.screenshot({ type: "jpeg", quality: 50 });
65
+ };
66
+ }
67
+
68
+ const { db, close } = await openDb(values.store);
69
+ const browser = await chromium.launch();
70
+ try {
71
+ const toJpeg = await jpegConverter(browser);
72
+ if (values.dir) {
73
+ const id = await importRunFolder(db, values.dir, {
74
+ app: values.app,
75
+ trigger: values.trigger,
76
+ ciUrl: values["ci-url"],
77
+ at: values.at ? new Date(values.at) : undefined,
78
+ toJpeg,
79
+ });
80
+ console.log(`Imported ${values.dir} as run ${id}`);
81
+ } else {
82
+ const repo = values.github!;
83
+ const app = values.app ?? repo.split("/").pop()!;
84
+ const { stdout } = await run("gh", ["run", "list", "--repo", repo, "--workflow", values.workflow!, "--status", "success", "--limit", values.limit!, "--json", "databaseId,event,createdAt,url"], { maxBuffer: 1 << 24 });
85
+ const listed = (JSON.parse(stdout) as { databaseId: number; event: string; createdAt: string; url: string }[])
86
+ .filter((r) => !values.since || r.createdAt >= values.since)
87
+ .reverse();
88
+ for (const r of listed) {
89
+ const [seen] = await db.select({ id: runs.id }).from(runs).where(eq(runs.ciUrl, r.url));
90
+ if (seen) continue;
91
+ const dir = await mkdtemp(join(tmpdir(), "debutly-import-"));
92
+ // Runs that skipped the agents (e.g. pull requests without the run-agents label) have no report.
93
+ if (!(await downloadReport(repo, r.databaseId, dir))) {
94
+ await rm(dir, { recursive: true, force: true });
95
+ continue;
96
+ }
97
+ try {
98
+ const id = await importRunFolder(db, dir, { app, trigger: TRIGGERS[r.event] ?? r.event, ciUrl: r.url, at: new Date(r.createdAt), toJpeg });
99
+ console.log(`Imported ${repo} ${r.createdAt} (${r.event}) as run ${id}`);
100
+ } finally {
101
+ await rm(dir, { recursive: true, force: true });
102
+ }
103
+ }
104
+ }
105
+ } finally {
106
+ await browser.close();
107
+ await close();
108
+ }
@@ -0,0 +1,46 @@
1
+ import type { EmailMessage, Inbox } from "./types";
2
+
3
+ const MAX_MESSAGES = 3;
4
+
5
+ const decodeEntities = (s: string) => s.replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'");
6
+
7
+ function extractLinks(text: string, html: string): string[] {
8
+ const fromText = text.match(/https?:\/\/[^\s<>"')]+/g) ?? [];
9
+ const fromHtml = [...html.matchAll(/href\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1]);
10
+ return [...new Set([...fromText, ...fromHtml].map(decodeEntities).filter((l) => /^https?:\/\//.test(l)))];
11
+ }
12
+
13
+ /** Reads the local Mailpit mail catcher that `supabase start` runs, used as the agents' inboxes. */
14
+ export class MailpitInbox implements Inbox {
15
+ private readonly base: string;
16
+ private readonly headers: Record<string, string> = {};
17
+
18
+ /** A mailbox with a login is given as https://user:password@host; fetch won't take that, so it becomes a header. */
19
+ constructor(
20
+ baseUrl: string,
21
+ private readonly fetchFn: typeof fetch = fetch,
22
+ ) {
23
+ const url = new URL(baseUrl);
24
+ if (url.username) {
25
+ const login = `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`;
26
+ this.headers.authorization = `Basic ${Buffer.from(login).toString("base64")}`;
27
+ url.username = "";
28
+ url.password = "";
29
+ }
30
+ this.base = url.href.replace(/\/+$/, "");
31
+ }
32
+
33
+ async latest(address: string): Promise<EmailMessage[]> {
34
+ const search = await this.fetchFn(`${this.base}/api/v1/search?query=${encodeURIComponent(`to:"${address}"`)}`, { headers: this.headers });
35
+ if (!search.ok) throw new Error(`mailbox search failed: HTTP ${search.status}`);
36
+ const { messages = [] } = (await search.json()) as { messages?: { ID: string }[] };
37
+ const out: EmailMessage[] = [];
38
+ for (const { ID } of messages.slice(0, MAX_MESSAGES)) {
39
+ const res = await this.fetchFn(`${this.base}/api/v1/message/${ID}`, { headers: this.headers });
40
+ if (!res.ok) continue;
41
+ const m = (await res.json()) as { Subject?: string; Text?: string; HTML?: string };
42
+ out.push({ subject: m.Subject ?? "", text: (m.Text ?? "").slice(0, 1500), links: extractLinks(m.Text ?? "", m.HTML ?? "") });
43
+ }
44
+ return out;
45
+ }
46
+ }
@@ -0,0 +1,10 @@
1
+ export interface EmailMessage {
2
+ subject: string;
3
+ text: string;
4
+ links: string[];
5
+ }
6
+
7
+ export interface Inbox {
8
+ /** Most recent messages for an address, newest first. */
9
+ latest(address: string): Promise<EmailMessage[]>;
10
+ }
package/src/init.ts ADDED
@@ -0,0 +1,184 @@
1
+ // `debutly init`: a few questions, then a debutly.json (and, if wanted, a weekly GitHub workflow)
2
+ // so that `npx debutly` alone runs the agents.
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { CONFIG_FILE, type DebutlyConfig } from "./config";
7
+ import { looksLikeProduction } from "./doctor";
8
+
9
+ export const GOALS = [
10
+ "Sign up and get to the main screen",
11
+ "Sign up, choose a plan and pay (test mode)",
12
+ "Sign up and invite a teammate",
13
+ ] as const;
14
+
15
+ /** Customers who invite teammates are teams: an owner signs up, teammates accept by email. */
16
+ export const teamGoal = (goal: string) => /\b(invite|teammate|team|colleague|workspace|seat)s?\b/i.test(goal);
17
+
18
+ export interface Answers {
19
+ url: string;
20
+ goal: string;
21
+ icp: string;
22
+ team: boolean;
23
+ mailpit?: string;
24
+ }
25
+
26
+ export function buildConfig(a: Answers): DebutlyConfig {
27
+ return {
28
+ $schema: "https://debutly.dev/schema.json",
29
+ url: a.url,
30
+ goal: a.goal,
31
+ icp: a.icp,
32
+ ...(a.team ? { unit: "business", employees: 2 } : {}),
33
+ tier: "quick",
34
+ maxCost: 1,
35
+ ...(a.mailpit ? { mailpit: a.mailpit } : {}),
36
+ };
37
+ }
38
+
39
+ /** How the workflow starts the app, guessed from its package.json scripts. */
40
+ export function startCommand(scripts: Record<string, string> | undefined, url: string): string[] {
41
+ const port = new URL(url).port || "3000";
42
+ if (scripts?.build && scripts?.start) return ["npm ci", "npm run build", `PORT=${port} npm start &`];
43
+ if (scripts?.dev) return ["npm ci", `PORT=${port} npm run dev &`];
44
+ return ["# Start a test copy of your app here"];
45
+ }
46
+
47
+ export function workflowYaml(start: string[]): string {
48
+ return [
49
+ "# Debutly's AI agents use a test copy of your app like new customers, every Monday.",
50
+ "# Settings are in debutly.json. Add your model key as the repository secret DEBUTLY_API_KEY.",
51
+ "name: debutly",
52
+ "on:",
53
+ ' schedule: [{ cron: "0 4 * * 1" }]',
54
+ " workflow_dispatch:",
55
+ "jobs:",
56
+ " agents:",
57
+ " runs-on: ubuntu-latest",
58
+ " timeout-minutes: 45",
59
+ " steps:",
60
+ " - uses: actions/checkout@v4",
61
+ " # Starts your app for the agents. Adjust it if the app needs a database or test keys.",
62
+ " - run: |",
63
+ ...start.map((line) => ` ${line}`),
64
+ " - uses: sroay/debutly@v0",
65
+ " with:",
66
+ " api-key: ${{ secrets.DEBUTLY_API_KEY }}",
67
+ "",
68
+ ].join("\n");
69
+ }
70
+
71
+ /** Adds the lines .gitignore lacks: the key file and Debutly's local reports. */
72
+ export function ignoreLines(current: string): string[] {
73
+ const have = new Set(current.split(/\r?\n/).map((l) => l.trim()));
74
+ return [".env", ".debutly/"].filter((l) => !have.has(l) && !have.has(`/${l}`));
75
+ }
76
+
77
+ const visibleText = (html: string) =>
78
+ html
79
+ .replace(/<(script|style|noscript)[\s\S]*?<\/\1>/gi, " ")
80
+ .replace(/<[^>]+>/g, " ")
81
+ .replace(/&[a-z#0-9]+;/gi, " ")
82
+ .replace(/\s+/g, " ")
83
+ .trim()
84
+ .slice(0, 4000);
85
+
86
+ /** One sentence on who the app is for, read off its home page by the judge model. */
87
+ async function guessCustomers(url: string): Promise<string | undefined> {
88
+ if (!process.env.DEBUTLY_API_KEY) return undefined;
89
+ try {
90
+ const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
91
+ const text = visibleText(await res.text());
92
+ if (text.length < 80) return undefined;
93
+ const { createLlms } = await import("./llm/factory");
94
+ const { judge } = createLlms(process.env);
95
+ const reply = await judge.complete({
96
+ system: "You describe a product's ideal customers in one plain sentence, under 20 words, like: Owners of small marketing agencies, 2 to 15 people.",
97
+ messages: [{ role: "user", content: `Who are the ideal customers of this product? Its home page says:\n\n${text}` }],
98
+ maxTokens: 200,
99
+ });
100
+ return reply.replace(/\s+/g, " ").trim().replace(/^["']|["']$/g, "") || undefined;
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ }
105
+
106
+ export async function init(cwd: string = process.cwd(), input: NodeJS.ReadableStream & { isTTY?: boolean } = process.stdin): Promise<number> {
107
+ const rl = createInterface({ input, output: process.stdout });
108
+ // Lines are buffered, so answers piped in by a script aren't lost between questions; at the end
109
+ // of the input every question takes its default.
110
+ const lines = rl[Symbol.asyncIterator]();
111
+ const ask = async (question: string, fallback = "") => {
112
+ process.stdout.write(fallback ? `${question} [${fallback}] ` : `${question} `);
113
+ const { value, done } = await lines.next();
114
+ if (done || !input.isTTY) process.stdout.write("\n");
115
+ return (done ? "" : String(value).trim()) || fallback;
116
+ };
117
+ try {
118
+ console.log("Debutly sends AI agents through a test copy of your app, like your first customers.\n");
119
+ if (existsSync(join(cwd, CONFIG_FILE)) && !/^y/i.test(await ask(`${CONFIG_FILE} exists. Replace it? (y/N)`, "N"))) return 0;
120
+
121
+ const url = await ask("Where is the test copy of your app running?", "http://localhost:3000");
122
+ if (looksLikeProduction(url)) {
123
+ console.log(" Careful: agents create accounts, submit forms and pay in test mode. Use a local, staging or throwaway copy, never production.");
124
+ }
125
+
126
+ console.log("\nWhat should the agents try to do?");
127
+ GOALS.forEach((g, i) => console.log(` ${i + 1}. ${g}`));
128
+ console.log(" 4. Something else");
129
+ // A goal typed here instead of a number is the goal.
130
+ const pick = await ask("Choose 1-4, or type the goal:", "1");
131
+ const goal = GOALS[Number(pick) - 1] ?? (/^\d*$/.test(pick) ? await ask("Describe it in a sentence:") : pick);
132
+
133
+ const guess = await guessCustomers(url);
134
+ const icp = await ask("\nWho are your customers? One sentence, e.g. who they are and how big.", guess);
135
+ if (!icp) {
136
+ console.log("Debutly needs to know who the customers are; run init again when you know.");
137
+ return 1;
138
+ }
139
+ const team = /^y/i.test(await ask("Do they use it as a team, inviting teammates? (y/N)", teamGoal(goal) ? "y" : "N"));
140
+ const mailpit =
141
+ (await ask(
142
+ "If sign-up sends a confirmation email: the address of a local mail catcher (Mailpit, e.g. from `supabase start`), or Enter to skip.",
143
+ team ? "http://127.0.0.1:54324" : "",
144
+ )) || undefined;
145
+
146
+ const config = buildConfig({ url, goal, icp, team, mailpit });
147
+ writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`);
148
+ console.log(`\nWrote ${CONFIG_FILE}.`);
149
+
150
+ const gitignore = join(cwd, ".gitignore");
151
+ if (existsSync(join(cwd, ".git")) || existsSync(gitignore)) {
152
+ const add = ignoreLines(existsSync(gitignore) ? readFileSync(gitignore, "utf8") : "");
153
+ if (add.length) {
154
+ appendFileSync(gitignore, `\n# Debutly: your model key, and local reports\n${add.join("\n")}\n`);
155
+ console.log(`Added ${add.join(" and ")} to .gitignore.`);
156
+ }
157
+ }
158
+
159
+ if (/^y/i.test(await ask("\nAdd a GitHub Actions workflow that runs the agents every Monday? (y/N)", "N"))) {
160
+ const path = join(cwd, ".github", "workflows", "debutly.yml");
161
+ if (existsSync(path)) console.log(".github/workflows/debutly.yml exists; left as it is.");
162
+ else {
163
+ let scripts: Record<string, string> | undefined;
164
+ try {
165
+ scripts = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")).scripts;
166
+ } catch {
167
+ // not a Node app, or no package.json: the workflow says where to start the app
168
+ }
169
+ mkdirSync(join(cwd, ".github", "workflows"), { recursive: true });
170
+ writeFileSync(path, workflowYaml(startCommand(scripts, url)));
171
+ console.log("Wrote .github/workflows/debutly.yml. Add the repository secret DEBUTLY_API_KEY (Settings → Secrets and variables → Actions).");
172
+ }
173
+ }
174
+
175
+ console.log(
176
+ process.env.DEBUTLY_API_KEY
177
+ ? "\nNext: npx debutly doctor, then npx debutly"
178
+ : "\nNext: create a key at https://openrouter.ai/keys, put DEBUTLY_API_KEY=… in .env here, then run npx debutly doctor and npx debutly",
179
+ );
180
+ return 0;
181
+ } finally {
182
+ rl.close();
183
+ }
184
+ }
@@ -0,0 +1,41 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import { OutOfCreditsError, type LlmClient, type LlmRequest } from "./types";
3
+
4
+ export type MessagesClient = Pick<Anthropic, "messages">;
5
+
6
+ const LOW_BALANCE = /credit balance is too low/i;
7
+
8
+ export class AnthropicLlm implements LlmClient {
9
+ private outOfCredits?: OutOfCreditsError;
10
+
11
+ constructor(
12
+ private readonly client: MessagesClient,
13
+ readonly model: string,
14
+ ) {}
15
+
16
+ static fromApiKey(apiKey: string, model: string): AnthropicLlm {
17
+ return new AnthropicLlm(new Anthropic({ apiKey }), model);
18
+ }
19
+
20
+ async complete(req: LlmRequest): Promise<string> {
21
+ if (this.outOfCredits) throw this.outOfCredits;
22
+ let response: Anthropic.Message;
23
+ try {
24
+ response = await this.client.messages.create({
25
+ model: this.model,
26
+ max_tokens: req.maxTokens ?? 16000,
27
+ system: req.system,
28
+ messages: req.messages,
29
+ });
30
+ } catch (err) {
31
+ const message = err instanceof Error ? err.message : String(err);
32
+ if (LOW_BALANCE.test(message)) throw (this.outOfCredits = new OutOfCreditsError(message));
33
+ throw err;
34
+ }
35
+ if (response.usage) req.onUsage?.({ inputTokens: response.usage.input_tokens, outputTokens: response.usage.output_tokens });
36
+ if (response.stop_reason === "refusal") {
37
+ throw new Error("LLM refused the request");
38
+ }
39
+ return response.content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("");
40
+ }
41
+ }
@@ -0,0 +1,35 @@
1
+ import type { CostEstimate } from "../cost/estimate";
2
+ import { isOpenRouter } from "./openrouter";
3
+
4
+ /**
5
+ * What an OpenRouter key may still spend, in USD, from GET /key. Null when the key has no limit,
6
+ * the provider isn't OpenRouter, or the lookup fails: the account's own balance needs a
7
+ * management key, so a key limit is the only balance an ordinary key can see.
8
+ */
9
+ export async function keyCreditsLeft(baseUrl: string, apiKey: string, fetchFn: typeof fetch = fetch): Promise<number | null> {
10
+ if (!isOpenRouter(baseUrl)) return null;
11
+ try {
12
+ const res = await fetchFn(`${baseUrl.replace(/\/+$/, "")}/key`, { headers: { authorization: `Bearer ${apiKey}` } });
13
+ if (!res.ok) return null;
14
+ const { data } = (await res.json()) as { data?: { limit_remaining?: number | null } };
15
+ return typeof data?.limit_remaining === "number" ? data.limit_remaining : null;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Before a run: refuse one the key can't pay for even at the low estimate (agents would stop
23
+ * part-way, 2026-10-21), and warn when the high estimate is more than is left.
24
+ */
25
+ export function creditCheck(left: number | null, estimate: CostEstimate | null): { refuse?: string; warn?: string } {
26
+ if (left === null || !estimate) return {};
27
+ const usd = (n: number) => `$${n.toFixed(2)}`;
28
+ if (left < estimate.low) {
29
+ return { refuse: `the API key has ${usd(left)} left and this run needs at least ${usd(estimate.low)}. Top up, or raise the key's limit, then run again.` };
30
+ }
31
+ if (left < estimate.high) {
32
+ return { warn: `the API key has ${usd(left)} left and this run could cost up to ${usd(estimate.high)}; agents may stop part-way.` };
33
+ }
34
+ return {};
35
+ }
@@ -0,0 +1,65 @@
1
+ import { AnthropicLlm } from "./anthropic";
2
+ import { OpenAiCompatibleLlm } from "./openai-compatible";
3
+ import { isOpenRouter } from "./openrouter";
4
+ import type { LlmClient } from "./types";
5
+
6
+ export interface LlmPair {
7
+ nav: LlmClient;
8
+ judge: LlmClient;
9
+ }
10
+
11
+ /** DEBUTLY_NAV_ROUTING / DEBUTLY_JUDGE_ROUTING: OpenRouter provider preferences as JSON, e.g. {"order":["google-ai-studio/flex"]}. */
12
+ export function parseRouting(json: string | undefined, name = "DEBUTLY_NAV_ROUTING"): Record<string, unknown> | undefined {
13
+ if (!json?.trim()) return undefined;
14
+ let value: unknown;
15
+ try {
16
+ value = JSON.parse(json);
17
+ } catch {
18
+ throw new Error(`${name} is not valid JSON: ${json}`);
19
+ }
20
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${name} must be a JSON object`);
21
+ return value as Record<string, unknown>;
22
+ }
23
+
24
+ // Agents send the pages they read to the model provider. On OpenRouter, Debutly asks only for
25
+ // providers that neither keep prompts (zero data retention) nor use them for training. The
26
+ // recommended models are served that way at their usual price (checked 2026-10-23).
27
+ // DEBUTLY_ZERO_RETENTION=off allows any provider.
28
+ const NO_RETENTION = { zdr: true, data_collection: "deny" } as const;
29
+
30
+ export function routing(env: Record<string, string | undefined>, baseUrl: string, own: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
31
+ const zeroRetention = isOpenRouter(baseUrl) && !/^(off|false|0|no)$/i.test(env.DEBUTLY_ZERO_RETENTION ?? "");
32
+ if (!zeroRetention) return own;
33
+ return { ...NO_RETENTION, ...own };
34
+ }
35
+
36
+ export function createLlms(env: Record<string, string | undefined>): LlmPair {
37
+ const apiKey = env.DEBUTLY_API_KEY;
38
+ if (!apiKey) throw new Error("DEBUTLY_API_KEY is required (bring your own key)");
39
+ const provider = env.DEBUTLY_PROVIDER ?? "anthropic";
40
+
41
+ if (provider === "anthropic") {
42
+ return {
43
+ nav: AnthropicLlm.fromApiKey(apiKey, env.DEBUTLY_NAV_MODEL ?? "claude-haiku-4-5"),
44
+ judge: AnthropicLlm.fromApiKey(apiKey, env.DEBUTLY_JUDGE_MODEL ?? "claude-opus-5"),
45
+ };
46
+ }
47
+
48
+ if (provider === "openai-compatible") {
49
+ const baseUrl = env.DEBUTLY_BASE_URL;
50
+ const navModel = env.DEBUTLY_NAV_MODEL;
51
+ const judgeModel = env.DEBUTLY_JUDGE_MODEL;
52
+ if (!baseUrl || !navModel || !judgeModel) {
53
+ throw new Error("openai-compatible requires DEBUTLY_BASE_URL, DEBUTLY_NAV_MODEL and DEBUTLY_JUDGE_MODEL");
54
+ }
55
+ const reasoningEffort = env.DEBUTLY_REASONING_EFFORT || undefined;
56
+ const navProvider = routing(env, baseUrl, parseRouting(env.DEBUTLY_NAV_ROUTING));
57
+ const judgeProvider = routing(env, baseUrl, parseRouting(env.DEBUTLY_JUDGE_ROUTING, "DEBUTLY_JUDGE_ROUTING"));
58
+ return {
59
+ nav: new OpenAiCompatibleLlm({ baseUrl, apiKey, model: navModel, reasoningEffort, provider: navProvider }),
60
+ judge: new OpenAiCompatibleLlm({ baseUrl, apiKey, model: judgeModel, provider: judgeProvider }),
61
+ };
62
+ }
63
+
64
+ throw new Error(`Unknown DEBUTLY_PROVIDER "${provider}"`);
65
+ }
@@ -0,0 +1,39 @@
1
+ import type { LlmUsage } from "./types";
2
+
3
+ /** USD per million tokens. */
4
+ export interface ModelPrice {
5
+ inputPerMTok: number;
6
+ outputPerMTok: number;
7
+ }
8
+
9
+ /** Running token count for a session or a run. */
10
+ export class Meter {
11
+ private inputTokens = 0;
12
+ private outputTokens = 0;
13
+ private requests = 0;
14
+ private charged = 0;
15
+ private unchargedRequests = 0;
16
+
17
+ add = (u: LlmUsage): void => {
18
+ this.inputTokens += u.inputTokens;
19
+ this.outputTokens += u.outputTokens;
20
+ this.requests++;
21
+ if (u.costUsd === undefined) this.unchargedRequests++;
22
+ else this.charged += u.costUsd;
23
+ };
24
+
25
+ get total(): LlmUsage {
26
+ return { inputTokens: this.inputTokens, outputTokens: this.outputTokens };
27
+ }
28
+
29
+ /**
30
+ * What the provider charged when it reported every request's charge: that reflects caching,
31
+ * the provider tier (e.g. Gemini flex) and routing. Otherwise an estimate from the list price;
32
+ * undefined when that is unknown too.
33
+ */
34
+ costUsd(price: ModelPrice | null | undefined): number | undefined {
35
+ if (this.requests > 0 && this.unchargedRequests === 0) return this.charged;
36
+ if (!price) return undefined;
37
+ return (this.inputTokens * price.inputPerMTok + this.outputTokens * price.outputPerMTok) / 1_000_000;
38
+ }
39
+ }
@@ -0,0 +1,90 @@
1
+ import { isOpenRouter } from "./openrouter";
2
+ import { OutOfCreditsError, type LlmClient, type LlmRequest } from "./types";
3
+
4
+ export interface OpenAiCompatibleOptions {
5
+ baseUrl: string;
6
+ apiKey: string;
7
+ model: string;
8
+ fetchFn?: typeof fetch;
9
+ sleep?: (ms: number) => Promise<void>;
10
+ /** OpenRouter's unified reasoning control. Clicking through pages rarely needs deep thinking. */
11
+ reasoningEffort?: string;
12
+ /**
13
+ * OpenRouter provider routing, passed through: e.g. {"order":["google-ai-studio/flex"]} for
14
+ * Gemini's half-price tier, or {"sort":"throughput"} for the fastest host of an open model.
15
+ */
16
+ provider?: Record<string, unknown>;
17
+ }
18
+
19
+ const MAX_ATTEMPTS = 4;
20
+ const MAX_WAIT_MS = 30_000;
21
+ const isRetryable = (status: number) => status === 429 || status >= 500;
22
+ // OpenRouter holds back credit for a reply of max_tokens. On a low balance it refuses with 402 and
23
+ // says what it can still cover ("You requested up to 16000 tokens, but can only afford 7001"); a
24
+ // reply that size is still plenty for most requests (an agent's is ~150 tokens), so ask again.
25
+ const AFFORDABLE = /can only afford (\d+)/;
26
+ const MIN_AFFORDABLE_TOKENS = 1024;
27
+
28
+ export class OpenAiCompatibleLlm implements LlmClient {
29
+ private outOfCredits?: OutOfCreditsError;
30
+
31
+ constructor(private readonly opts: OpenAiCompatibleOptions) {}
32
+
33
+ get reasoningEffort(): string | undefined {
34
+ return this.opts.reasoningEffort;
35
+ }
36
+
37
+ async complete(req: LlmRequest): Promise<string> {
38
+ if (this.outOfCredits) throw this.outOfCredits;
39
+ const fetchFn = this.opts.fetchFn ?? fetch;
40
+ const openRouter = isOpenRouter(this.opts.baseUrl);
41
+ const sleep = this.opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
42
+ let maxTokens = req.maxTokens ?? 4096;
43
+ const body = () =>
44
+ JSON.stringify({
45
+ model: this.opts.model,
46
+ max_tokens: maxTokens,
47
+ messages: [{ role: "system", content: req.system }, ...req.messages],
48
+ ...(this.opts.reasoningEffort ? { reasoning: { effort: this.opts.reasoningEffort } } : {}),
49
+ ...(this.opts.provider ? { provider: this.opts.provider } : {}),
50
+ // OpenRouter then reports what each request actually cost.
51
+ ...(openRouter ? { usage: { include: true } } : {}),
52
+ });
53
+
54
+ for (let attempt = 1; ; attempt++) {
55
+ const res = await fetchFn(`${this.opts.baseUrl.replace(/\/+$/, "")}/chat/completions`, {
56
+ method: "POST",
57
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.opts.apiKey}` },
58
+ body: body(),
59
+ });
60
+ if (res.ok) {
61
+ const data = (await res.json()) as {
62
+ choices?: { message?: { content?: string | null } }[];
63
+ usage?: { prompt_tokens?: number; completion_tokens?: number; cost?: number };
64
+ };
65
+ if (data.usage) {
66
+ const { prompt_tokens = 0, completion_tokens = 0, cost } = data.usage;
67
+ req.onUsage?.({ inputTokens: prompt_tokens, outputTokens: completion_tokens, ...(typeof cost === "number" ? { costUsd: cost } : {}) });
68
+ }
69
+ return data.choices?.[0]?.message?.content ?? "";
70
+ }
71
+ const text = await res.text();
72
+ // OpenRouter: the account (or the key's limit) can't cover this request.
73
+ if (res.status === 402) {
74
+ const affordable = Number(text.match(AFFORDABLE)?.[1]);
75
+ if (affordable >= MIN_AFFORDABLE_TOKENS && affordable < maxTokens) {
76
+ maxTokens = affordable;
77
+ continue;
78
+ }
79
+ this.outOfCredits = new OutOfCreditsError(text);
80
+ throw this.outOfCredits;
81
+ }
82
+ if (!isRetryable(res.status) || attempt >= MAX_ATTEMPTS) {
83
+ throw new Error(`LLM HTTP ${res.status}: ${text}`);
84
+ }
85
+ const retryAfter = Number(res.headers.get("retry-after"));
86
+ const waitMs = retryAfter > 0 ? retryAfter * 1000 : 1000 * 2 ** attempt;
87
+ await sleep(Math.min(waitMs, MAX_WAIT_MS));
88
+ }
89
+ }
90
+ }
@@ -0,0 +1,9 @@
1
+ /** Is this OpenRouter's API? By host, so https://evil.test/openrouter.ai/ is not. */
2
+ export function isOpenRouter(url: string | undefined): boolean {
3
+ try {
4
+ const host = new URL(url ?? "").hostname.toLowerCase();
5
+ return host === "openrouter.ai" || host.endsWith(".openrouter.ai");
6
+ } catch {
7
+ return false;
8
+ }
9
+ }
@@ -0,0 +1,18 @@
1
+ import type { LlmClient, LlmRequest } from "./types";
2
+
3
+ const estimateTokens = (text: string) => Math.ceil(text.length / 4);
4
+
5
+ export class ScriptedLlm implements LlmClient {
6
+ readonly calls: LlmRequest[] = [];
7
+
8
+ constructor(private readonly respond: (req: LlmRequest, callIndex: number) => string) {}
9
+
10
+ async complete(req: LlmRequest): Promise<string> {
11
+ const index = this.calls.length;
12
+ this.calls.push(req);
13
+ const reply = this.respond(req, index);
14
+ // No provider to ask, so estimate: lets tests exercise metering and caps.
15
+ req.onUsage?.({ inputTokens: estimateTokens(req.system + req.messages.map((m) => m.content).join("")), outputTokens: estimateTokens(reply) });
16
+ return reply;
17
+ }
18
+ }