@wrangled/cli 0.3.23 → 0.3.24
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/dist/guide.js +5 -0
- package/dist/main.js +76 -1
- package/dist/playbooks.js +3 -3
- package/dist/runner/claim.js +8 -1
- package/dist/runner/run.js +31 -2
- package/package.json +1 -1
package/dist/guide.js
CHANGED
|
@@ -20,6 +20,10 @@ Run \`${cli} guide\` first. It says, in plain words, what this workspace has. It
|
|
|
20
20
|
|
|
21
21
|
Before writing new ads or a campaign plan, run \`${cli} brief\`. In one read it gives the offer (from the confirmed offers and funnels map, else the brand notes and landing pages, or it says the offer is unknown), the brand voice, the best ads by spend and by results with their words and a piece of their video transcript, the ads to avoid repeating, the pages those ads send people to, the funnel and top campaigns, who the top Facebook campaigns were aimed at, and what is missing. \`--from\` and \`--to\` pick the dates (default: the last 30 days), \`--channel\` or \`--campaign <id>\` narrow it, and \`--top <n>\` sets how many of each list it shows (default 5, at most 20). It prints plain Markdown to paste into a prompt; \`--format json\` gives the same brief as data. It says what it cut to stay short.
|
|
22
22
|
|
|
23
|
+
## Which Facebook ads won: winners and flops
|
|
24
|
+
|
|
25
|
+
\`${cli} winners\` says which Facebook ads won and which flopped, for one Monday-to-Sunday week and for the 30 days ending that Sunday, judged on real sales: each sale goes to the Facebook ad that first brought the buyer in, and payments typed into GoHighLevel by hand never count toward an ad. An ad with no sales that has spent less than twice the account's cost per sale is too new to judge on sales, so it is judged on cost per sign-up and listed apart. Each ad says in a sentence why it is on its list. \`--week <YYYY-MM-DD>\` picks any day of the week (default: the latest complete week), \`--top <n>\` how many ads each list shows (default 5, at most 20), and \`--format json\` gives the report as data.
|
|
26
|
+
|
|
23
27
|
## Content: search before SQL
|
|
24
28
|
|
|
25
29
|
To find content, such as "the 10 emails with the best click rate", "our 5 best videos by return on ad spend" or "every landing page that mentions the guarantee", use \`${cli} content search\` before writing SQL on the \`content_\` tables.
|
|
@@ -243,6 +247,7 @@ ${cli} whoami show the current workspace and library acces
|
|
|
243
247
|
${cli} knowledge search "<question>" [--limit <n>] [--kind <kind>] short, cited snippets from the Creative Cartel know-how library (keys with library access)
|
|
244
248
|
${cli} guide what this workspace's data holds, how fresh it is, and what is missing: read it first
|
|
245
249
|
${cli} brief [--from <date>] [--to <date>] [--channel <channel>] [--campaign <id>] [--top <n>] the offer, voice, best and worst ads, pages, results and audience: start here before writing ads
|
|
250
|
+
${cli} winners [--week <date>] [--top <n>] which Facebook ads won and flopped this week and over 30 days, judged on first-touch sales
|
|
246
251
|
${cli} warehouse schema [--summary] [--table <name> ...] [--search <words>] list tables and columns; --summary for one row per table
|
|
247
252
|
${cli} warehouse query "<sql>" run one read-only query (or --file <path>, or stdin); --out <file.csv | file.ndjson | file.json> saves the rows; add --all for every row
|
|
248
253
|
${cli} funnel [--from <date>] [--to <date>] [--grain day|week|month] spend, leads, calls and sales per period, with totals
|
package/dist/main.js
CHANGED
|
@@ -10,6 +10,7 @@ import { firstSymlink, inRunFolder, writeFileAtomic } from "./files.js";
|
|
|
10
10
|
import { guide, skill } from "./guide.js";
|
|
11
11
|
import { PULL_STATE_FILE, pullContent } from "./pull.js";
|
|
12
12
|
import { AGENTS_MD_MARKER, metaAdsAgentsMd, PLAYBOOK_CHOICES, playbooks } from "./playbooks.js";
|
|
13
|
+
import { OPENROUTER_KEY_ENV } from "./runner/codex.js";
|
|
13
14
|
import { AGENT_ENV, HARNESS_COMMAND_ENV, RUNNER_TOKEN_ENV, runLoop, runOnce } from "./runner/run.js";
|
|
14
15
|
import { SKILL_NAME_PATTERN } from "./skill-zip.js";
|
|
15
16
|
import { installSkillFolder, operatorConnection, packSkill, parseSkillRef, readSkillFolder, unpackSkill, } from "./skills.js";
|
|
@@ -609,6 +610,21 @@ const commands = {
|
|
|
609
610
|
process.stdout.write(brief.text);
|
|
610
611
|
},
|
|
611
612
|
},
|
|
613
|
+
winners: {
|
|
614
|
+
usage: "winners [--week <YYYY-MM-DD>] [--top <n>] (which Facebook ads won and flopped, judged on real first-touch sales)",
|
|
615
|
+
async run(args, options) {
|
|
616
|
+
if (args.length > 0)
|
|
617
|
+
throw new CliError("Usage: winners [--week <date>] [--top <n>]", exitCodes.usage);
|
|
618
|
+
const query = new URLSearchParams();
|
|
619
|
+
for (const [name, value] of Object.entries({ week: options.week, top: options.top }))
|
|
620
|
+
if (value !== undefined)
|
|
621
|
+
query.set(name, value);
|
|
622
|
+
const report = await api(await resolveConnection(options.apiUrl), `/api/v1/winners${query.size ? `?${query}` : ""}`);
|
|
623
|
+
if (options.format === "json")
|
|
624
|
+
return printJson(report);
|
|
625
|
+
process.stdout.write(winnersText(report));
|
|
626
|
+
},
|
|
627
|
+
},
|
|
612
628
|
examples: {
|
|
613
629
|
usage: "examples (plain questions with SQL that answers them, for the tables this workspace has)",
|
|
614
630
|
async run(args, options) {
|
|
@@ -1209,6 +1225,8 @@ const commands = {
|
|
|
1209
1225
|
console.log(`Failed for ${failure.adIds.join(", ")}: ${failure.error}`);
|
|
1210
1226
|
if (result.stopped === "tool_cap")
|
|
1211
1227
|
console.log("Stopped early: the run's tool spending cap was reached.");
|
|
1228
|
+
if (result.nothingToRead > 0)
|
|
1229
|
+
console.log(`Left out ${plural(result.nothingToRead)} with nothing to read: no copy, headline, transcript or picture description.`);
|
|
1212
1230
|
},
|
|
1213
1231
|
},
|
|
1214
1232
|
"content tags": {
|
|
@@ -1693,6 +1711,7 @@ async function withRunner(options, work) {
|
|
|
1693
1711
|
apiUrl: resolveApiUrl(options.apiUrl, null),
|
|
1694
1712
|
token,
|
|
1695
1713
|
harnessCommand: process.env[HARNESS_COMMAND_ENV] || undefined,
|
|
1714
|
+
openRouterKey: process.env[OPENROUTER_KEY_ENV] || undefined,
|
|
1696
1715
|
maxSeconds: options.maxSeconds,
|
|
1697
1716
|
signal: stop.signal,
|
|
1698
1717
|
say: (line) => console.log(line),
|
|
@@ -1769,6 +1788,59 @@ const CAMPAIGN_NUMBERS = [
|
|
|
1769
1788
|
"roas_first_touch",
|
|
1770
1789
|
"roas_last_touch",
|
|
1771
1790
|
];
|
|
1791
|
+
function money(value, currency) {
|
|
1792
|
+
try {
|
|
1793
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(value);
|
|
1794
|
+
}
|
|
1795
|
+
catch {
|
|
1796
|
+
return `${value.toFixed(2)} ${currency}`;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
const counted = (n, one) => `${n} ${n === 1 ? one : `${one}s`}`;
|
|
1800
|
+
/** An ad's line: name, SKU and campaign, its numbers, its tags, then why it is on the list. */
|
|
1801
|
+
function winnersAdLines(ad, currency) {
|
|
1802
|
+
const n = ad.numbers;
|
|
1803
|
+
const onSales = ad.judgedOn === "sales";
|
|
1804
|
+
const cost = onSales
|
|
1805
|
+
? n.costPerSale === null ? "no sales" : `${money(n.costPerSale, currency)} per sale`
|
|
1806
|
+
: n.costPerSignup === null ? "no sign-ups" : `${money(n.costPerSignup, currency)} per sign-up`;
|
|
1807
|
+
const name = [ad.name ?? `Ad ${ad.adId}`, ad.sku && `[${ad.sku}]`, ad.campaignName && `(${ad.campaignName})`].filter(Boolean).join(" ");
|
|
1808
|
+
const tags = Object.entries(ad.tags).map(([field, value]) => `${field}: ${value}`).join(", ");
|
|
1809
|
+
const count = onSales ? n.sales !== null && counted(n.sales, "sale") : n.signups !== null && counted(n.signups, "sign-up");
|
|
1810
|
+
return `- ${name}: spent ${money(n.spend, currency)}, ${count ? `${count}, ` : ""}${cost}${tags ? `. Tags: ${tags}` : ""}\n ${ad.pickedBecause}\n`;
|
|
1811
|
+
}
|
|
1812
|
+
function winnersWindowText(title, window) {
|
|
1813
|
+
const head = `\n## ${title}, ${window.from} to ${window.to}\n`;
|
|
1814
|
+
if (window.sections.length === 0)
|
|
1815
|
+
return `${head}\nNo Facebook ad spent in these dates.\n`;
|
|
1816
|
+
return (head +
|
|
1817
|
+
window.sections
|
|
1818
|
+
.map((s) => {
|
|
1819
|
+
const list = (heading, ads) => `\n${heading}\n${ads.length === 0 ? "- none\n" : ads.map((ad) => winnersAdLines(ad, s.currency)).join("")}`;
|
|
1820
|
+
const account = s.salesSource === "not_connected"
|
|
1821
|
+
? "sales are not connected"
|
|
1822
|
+
: s.accountCostPerSale === null
|
|
1823
|
+
? "no sale credited to a Facebook ad in these dates"
|
|
1824
|
+
: `the account paid ${money(s.accountCostPerSale, s.currency)} per sale`;
|
|
1825
|
+
const tests = s.tests.map((t) => `- Test ${t.testId}${t.hypothesisId ? ` (idea ${t.hypothesisId})` : ""}: ${counted(t.ads.length, "ad")}, spent ${money(t.spend, s.currency)}, ${counted(t.sales, "sale")}` +
|
|
1826
|
+
`${t.costPerSale === null ? "" : `, ${money(t.costPerSale, s.currency)} per sale`}\n`);
|
|
1827
|
+
return (`\nIn ${s.currency}: ${account}. ${s.adsJudgedOnSales} judged on sales, ${s.adsJudgedOnSignups} judged on sign-ups.\n` +
|
|
1828
|
+
s.notes.map((note) => `${note}\n`).join("") +
|
|
1829
|
+
list("Winners", s.winners) +
|
|
1830
|
+
list("Flops", s.flops) +
|
|
1831
|
+
list(`${s.onSignups.title}: best`, s.onSignups.best) +
|
|
1832
|
+
list(`${s.onSignups.title}: worst`, s.onSignups.worst) +
|
|
1833
|
+
(tests.length > 0 ? `\nAdzara tests\n${tests.join("")}` : ""));
|
|
1834
|
+
})
|
|
1835
|
+
.join(""));
|
|
1836
|
+
}
|
|
1837
|
+
/** The winners report as plain Markdown: what a sale means, then this week and the last 30 days, each currency apart. */
|
|
1838
|
+
function winnersText(report) {
|
|
1839
|
+
return (`# Winners and flops for ${report.workspace}\n\n` +
|
|
1840
|
+
`A sale means ${report.saleMeans}. Each sale goes to the Facebook ad that first brought the buyer in; payments typed into GoHighLevel by hand never count toward an ad.\n` +
|
|
1841
|
+
winnersWindowText("This week", report.week) +
|
|
1842
|
+
winnersWindowText("Last 30 days", report.last30Days));
|
|
1843
|
+
}
|
|
1772
1844
|
function reportQuery(options, extra) {
|
|
1773
1845
|
const query = new URLSearchParams();
|
|
1774
1846
|
for (const [name, value] of Object.entries({ from: options.from, to: options.to, ...extra }))
|
|
@@ -1915,7 +1987,8 @@ Options:
|
|
|
1915
1987
|
--channel <channel> only this channel's campaigns (and, with brief, its ads): meta, google, affiliate, a utm_source, unknown or recorded_in_crm;
|
|
1916
1988
|
for content track-link, where the link goes: facebook, email, sms or web (default)
|
|
1917
1989
|
--campaign <id> the campaign content track-link puts in utm_campaign; with brief, the one campaign it covers
|
|
1918
|
-
--top <n> how many best ads, worst ads, pages and campaigns brief shows (default: 5, at most 20)
|
|
1990
|
+
--top <n> how many best ads, worst ads, pages and campaigns brief shows, or ads in each winners list (default: 5, at most 20)
|
|
1991
|
+
--week <YYYY-MM-DD> any day of the week winners covers, Monday to Sunday (default: the latest complete week)
|
|
1919
1992
|
--body, --html <text | @file> the words, or the full HTML, content create, capture and edit save
|
|
1920
1993
|
--file <path> an image, video or audio file, up to 50 MB, content create, capture and edit save; repeat for several
|
|
1921
1994
|
--transcript <text | @file> what is said in the first video or audio file content capture saves, so none is paid for
|
|
@@ -2007,6 +2080,7 @@ function parseOptions(argv) {
|
|
|
2007
2080
|
channel: { type: "string" },
|
|
2008
2081
|
campaign: { type: "string" },
|
|
2009
2082
|
top: { type: "string" },
|
|
2083
|
+
week: { type: "string" },
|
|
2010
2084
|
name: { type: "string" },
|
|
2011
2085
|
subject: { type: "string" },
|
|
2012
2086
|
preview: { type: "string" },
|
|
@@ -2103,6 +2177,7 @@ function parseOptions(argv) {
|
|
|
2103
2177
|
channel: values.channel,
|
|
2104
2178
|
campaign: values.campaign,
|
|
2105
2179
|
top: values.top,
|
|
2180
|
+
week: values.week,
|
|
2106
2181
|
name: values.name,
|
|
2107
2182
|
subject: values.subject,
|
|
2108
2183
|
preview: values.preview,
|
package/dist/playbooks.js
CHANGED
|
@@ -864,11 +864,11 @@ ${cli} ads autotag --limit ${BACK_TAG_BATCH} --json > batch.json
|
|
|
864
864
|
|
|
865
865
|
Each call takes the ${BACK_TAG_BATCH} biggest-spending ads with no tags yet, so the next call moves on to the next ones. Open \`batch.json\` after each one. An ad Jev says is not marketing (\`not_marketing\`), or can't tell about (\`gate_unclear\`), is listed under \`skipped\` but was saved with only that answer, so it counts as progress. Run it again until one of these happens:
|
|
866
866
|
|
|
867
|
-
- A batch saved nothing new: \`proposed\` is empty, and every ad under \`skipped\` is \`no_content\` (nothing to read: no copy, transcript or picture description) or \`not_found\`, with the rest under \`failed\`.
|
|
867
|
+
- A batch saved nothing new: \`proposed\` is empty, and every ad under \`skipped\` is \`no_content\` (nothing to read: no copy, transcript or picture description) or \`not_found\`, with the rest under \`failed\`. Asking again won't help.
|
|
868
868
|
- You have run ${BACK_TAG_MAX_BATCHES} batches, or the person's spending limit would be passed.
|
|
869
869
|
- \`stopped\` is \`tool_cap\`: the run's tool spending cap was reached.
|
|
870
870
|
|
|
871
|
-
Keep a running count of ads proposed, ads skipped (and why), failures and \`costUsd\` from each batch.
|
|
871
|
+
Keep a running count of ads proposed, ads skipped (and why), failures and \`costUsd\` from each batch. \`nothingToRead\` counts the untagged ads the tagger left out because they have nothing to read; keep the last batch's number.
|
|
872
872
|
|
|
873
873
|
## 3. Report
|
|
874
874
|
|
|
@@ -876,7 +876,7 @@ Run the coverage query from step 1 again, then finish with a short report in pla
|
|
|
876
876
|
|
|
877
877
|
- How many ads you tagged this run and what it cost.
|
|
878
878
|
- How many ads that spent anything now have tags, and what share of the spend that is (\`pct_spend_tagged\`); what share of spend a person has confirmed (\`pct_spend_confirmed\`). Give each currency's shares on their own; never add them together.
|
|
879
|
-
- How many ads are still untagged and why, if the batches said
|
|
879
|
+
- How many ads are still untagged and why, if the batches said: \`nothingToRead\` ads have nothing to read (no copy, headline, transcript or picture description), plus any failures.
|
|
880
880
|
- What to do next: open the Ad tags page in the ${brand.name} web app, press Make the list (or Make a new list) under Check these first, and confirm, fix or skip the ads that carry the top 20% of spend. Everything else stays as the tagger's best guess, marked not yet confirmed.
|
|
881
881
|
|
|
882
882
|
## Commands
|
package/dist/runner/claim.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CliError, exitCodes } from "../api.js";
|
|
2
2
|
import { SKILL_NAME_PATTERN } from "../skill-zip.js";
|
|
3
3
|
export const harnessNames = ["claude", "codex"];
|
|
4
|
+
const aiBillings = ["run_key", "shared_key", "subscription"];
|
|
4
5
|
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
6
|
/** The claim response as the runner uses it. Never echoes the body: it holds the run key. */
|
|
6
7
|
export function parseClaim(body) {
|
|
@@ -28,6 +29,12 @@ export function parseClaim(body) {
|
|
|
28
29
|
throw bad("deadline");
|
|
29
30
|
if (body.openRouterKey !== undefined && (typeof body.openRouterKey !== "string" || body.openRouterKey.length === 0))
|
|
30
31
|
throw bad("openRouterKey");
|
|
32
|
+
// A server from before `aiBilling` says run_key only by sending a key.
|
|
33
|
+
const aiBilling = body.aiBilling ?? (body.openRouterKey === undefined ? "subscription" : "run_key");
|
|
34
|
+
if (!aiBillings.includes(aiBilling))
|
|
35
|
+
throw bad("aiBilling");
|
|
36
|
+
if ((aiBilling === "run_key") !== (typeof body.openRouterKey === "string"))
|
|
37
|
+
throw bad("openRouterKey");
|
|
31
38
|
return {
|
|
32
39
|
runId: run.id,
|
|
33
40
|
runKey: body.runKey,
|
|
@@ -37,6 +44,6 @@ export function parseClaim(body) {
|
|
|
37
44
|
prompt: body.prompt,
|
|
38
45
|
aiCapUsd: caps.aiCents / 100,
|
|
39
46
|
deadline,
|
|
40
|
-
billing: typeof body.openRouterKey === "string" ? { kind: "openrouter", key: body.openRouterKey } : { kind:
|
|
47
|
+
billing: typeof body.openRouterKey === "string" ? { kind: "openrouter", key: body.openRouterKey } : { kind: aiBilling },
|
|
41
48
|
};
|
|
42
49
|
}
|
package/dist/runner/run.js
CHANGED
|
@@ -8,6 +8,7 @@ import { brand, envPrefix } from "../brand.js";
|
|
|
8
8
|
import { RUN_ROOT_ENV } from "../files.js";
|
|
9
9
|
import { installSkillFolder, unpackSkill } from "../skills.js";
|
|
10
10
|
import { parseClaim } from "./claim.js";
|
|
11
|
+
import { OPENROUTER_KEY_ENV } from "./codex.js";
|
|
11
12
|
import { MAX_TURNS } from "./harness.js";
|
|
12
13
|
import { commandHarness, harnesses } from "./harnesses.js";
|
|
13
14
|
import { LogTail, redact, RUN_ERROR_MAX_BYTES, tailBytes } from "./redact.js";
|
|
@@ -68,7 +69,7 @@ export async function runOnce(options) {
|
|
|
68
69
|
if (answer === "")
|
|
69
70
|
return { kind: "idle" };
|
|
70
71
|
const claim = parseClaim(JSON.parse(answer));
|
|
71
|
-
const secrets = [claim.runKey, options.token, ...(claim.billing.kind === "openrouter" ? [claim.billing.key] : [])];
|
|
72
|
+
const secrets = [claim.runKey, options.token, ...(claim.billing.kind === "openrouter" ? [claim.billing.key] : []), ...(options.openRouterKey ? [options.openRouterKey] : [])];
|
|
72
73
|
const say = (line) => options.say(redact(line, secrets));
|
|
73
74
|
say(`Claimed run ${claim.runId}: ${claim.skill.name} version ${claim.skill.version} with ${claim.harness}.`);
|
|
74
75
|
const stop = new AbortController();
|
|
@@ -90,6 +91,9 @@ export async function runOnce(options) {
|
|
|
90
91
|
dir = await mkdtemp(join(tmpdir(), `${brand.slug}-run-`));
|
|
91
92
|
const result = await execute(claim, dir, options, stop.signal, (text) => log.append(text));
|
|
92
93
|
report = finishReport(result, stop.signal.reason);
|
|
94
|
+
const aiCents = reportedAiCents(claim.billing, result.tokens);
|
|
95
|
+
if (aiCents !== undefined)
|
|
96
|
+
report.aiCents = aiCents;
|
|
93
97
|
}
|
|
94
98
|
catch (error) {
|
|
95
99
|
report = { status: "failed", error: stop.signal.aborted ? stopMessages[stop.signal.reason] : `The runner failed: ${error.message}` };
|
|
@@ -143,6 +147,7 @@ function finishReport(result, stopped) {
|
|
|
143
147
|
}
|
|
144
148
|
/** Puts the skill, the run key's login and a `wr` in `dir`, then runs the harness there. */
|
|
145
149
|
async function execute(claim, dir, options, signal, log) {
|
|
150
|
+
const billing = harnessBilling(claim, options.openRouterKey);
|
|
146
151
|
const skillsDir = join(dir, claim.harness === "claude" ? ".claude" : ".codex", "skills");
|
|
147
152
|
await installSkillFolder(skillsDir, claim.skill.name, unpackSkill(claim.skill, await getSigned(claim.skill.url)), []);
|
|
148
153
|
const configHome = join(dir, ".config");
|
|
@@ -158,7 +163,7 @@ async function execute(claim, dir, options, signal, log) {
|
|
|
158
163
|
skill: claim.skill.name,
|
|
159
164
|
prompt: runPrompt(claim.prompt),
|
|
160
165
|
model: claim.model,
|
|
161
|
-
billing
|
|
166
|
+
billing,
|
|
162
167
|
aiCapUsd: claim.aiCapUsd,
|
|
163
168
|
maxTurns: MAX_TURNS,
|
|
164
169
|
env: harnessEnv(process.env, { dir, binDir, configHome, apiUrl: options.apiUrl }),
|
|
@@ -166,6 +171,30 @@ async function execute(claim, dir, options, signal, log) {
|
|
|
166
171
|
log,
|
|
167
172
|
});
|
|
168
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* How the harness pays. A shared_key run pays OpenRouter with the runner's own key, and only Claude Code, whose budget
|
|
176
|
+
* stops it at the AI cap, may run that way: nothing would hold a Codex run to its cap on a key shared by every run.
|
|
177
|
+
*/
|
|
178
|
+
export function harnessBilling(claim, openRouterKey) {
|
|
179
|
+
if (claim.billing.kind !== "shared_key")
|
|
180
|
+
return claim.billing;
|
|
181
|
+
if (!openRouterKey) {
|
|
182
|
+
throw new Error(`This run pays for AI with the runner's own OpenRouter key, but ${OPENROUTER_KEY_ENV} is not set on this runner. Set it, or set OPENROUTER_PROVISIONING_KEY on the server to an OpenRouter management key so each run gets its own key.`);
|
|
183
|
+
}
|
|
184
|
+
if (claim.harness === "codex") {
|
|
185
|
+
throw new Error(`A Codex run cannot keep to its AI cap on the runner's shared OpenRouter key. Set OPENROUTER_PROVISIONING_KEY on the server to an OpenRouter management key so each run gets its own capped key.`);
|
|
186
|
+
}
|
|
187
|
+
return { kind: "openrouter", key: openRouterKey };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The AI spend a finish report carries: only a shared_key run's, whose spend the server cannot read from OpenRouter
|
|
191
|
+
* itself, from the harness's own estimate. A zero estimate may mean the harness could not price the model, so it is not
|
|
192
|
+
* sent; a run without a report counts at its full cap.
|
|
193
|
+
*/
|
|
194
|
+
export function reportedAiCents(billing, tokens) {
|
|
195
|
+
const costUsd = tokens?.costUsd;
|
|
196
|
+
return billing.kind === "shared_key" && costUsd !== undefined && costUsd > 0 ? Math.ceil(costUsd * 100) : undefined;
|
|
197
|
+
}
|
|
169
198
|
/** Beats now (moving the run to running) and every minute; a cancel, or a server that no longer knows the run, stops it. */
|
|
170
199
|
function startHeartbeat(connection, runId, cancel, say) {
|
|
171
200
|
const beat = async () => {
|