codecartographer-pi 0.20.0 → 0.22.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/.codecarto/GUIDE.md +1 -1
- package/.codecarto/broadside/SKILL.md +15 -4
- package/.codecarto/broadside/config.yaml +26 -10
- package/.codecarto/findings/contracts/SKILL.md +4 -1
- package/.codecarto/findings/defect-scan/SKILL.md +10 -0
- package/.codecarto/findings/defect-scan-mechanical/SKILL.md +6 -0
- package/.codecarto/findings/defect-scan-semantic/SKILL.md +8 -1
- package/.codecarto/findings/porting/SKILL.md +4 -0
- package/.codecarto/findings/protocols/SKILL.md +4 -0
- package/.codecarto/templates/mechanical-defects.md +15 -0
- package/.codecarto/templates/reimplementation-spec.md +5 -3
- package/.codecarto/templates/reverse-engineering-bundle.md +10 -1
- package/.codecarto/templates/semantic-defects.md +15 -0
- package/.codecarto/workflow/VALIDATE.md +1 -1
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +5 -5
- package/agent-skill/codecartographer/references/broadside.md +10 -0
- package/dist/core/amendment.js +9 -4
- package/dist/core/broadside.d.ts +98 -0
- package/dist/core/broadside.js +330 -63
- package/dist/core/completion.js +7 -1
- package/dist/core/library.js +5 -3
- package/dist/core/pipeline.d.ts +39 -3
- package/dist/core/pipeline.js +61 -7
- package/dist/core/prompts.js +10 -3
- package/dist/core/status.d.ts +8 -0
- package/dist/core/status.js +39 -17
- package/dist/core/utils.d.ts +7 -0
- package/dist/core/utils.js +7 -0
- package/dist/core/workspace.js +2 -2
- package/dist/core/yaml.js +8 -1
- package/dist/extensions/codecarto/auto-runner.d.ts +1 -1
- package/dist/extensions/codecarto/auto-runner.js +18 -3
- package/dist/extensions/codecarto/broadside-flags.d.ts +7 -1
- package/dist/extensions/codecarto/broadside-flags.js +51 -0
- package/dist/extensions/codecarto/index.js +103 -35
- package/dist/extensions/codecarto/phase-compaction.js +5 -1
- package/dist/mcp-server/server.d.ts +5 -1
- package/dist/mcp-server/server.js +140 -32
- package/package.json +1 -1
package/dist/core/broadside.js
CHANGED
|
@@ -31,11 +31,12 @@
|
|
|
31
31
|
// executable surfaces (Pi and MCP), not the pure template. What the template does
|
|
32
32
|
// carry is the reading guide for its output — `.codecarto/broadside/SKILL.md`,
|
|
33
33
|
// served by codecarto_skill under the name `broadside` (see readBroadsideSkill).
|
|
34
|
+
import { createHash } from "node:crypto";
|
|
34
35
|
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
35
36
|
import { execFile } from "node:child_process";
|
|
36
37
|
import { promisify } from "node:util";
|
|
37
38
|
import { join, relative } from "node:path";
|
|
38
|
-
import { atomicWriteFile, pathExists, sleep } from "./utils.js";
|
|
39
|
+
import { atomicWriteFile, GIT_TIMEOUT_MS, pathExists, sleep } from "./utils.js";
|
|
39
40
|
import { describeRedactions, isSecretFile, redactSecrets } from "./secrets.js";
|
|
40
41
|
import { acquireLock } from "./status.js";
|
|
41
42
|
import { loadYamlFile } from "./yaml.js";
|
|
@@ -64,6 +65,13 @@ export const BROADSIDE_OUTPUT_PRICE_PER_M = 1.875;
|
|
|
64
65
|
export const BROADSIDE_MODELS_URL = "https://openrouter.ai/api/v1/models";
|
|
65
66
|
export const BROADSIDE_BENCHMARKS_URL = "https://openrouter.ai/api/v1/benchmarks";
|
|
66
67
|
export const BROADSIDE_CATALOG_CACHE_FILE = "model-catalog.json";
|
|
68
|
+
/**
|
|
69
|
+
* What this repository's own submits learned about batch endpoints: which
|
|
70
|
+
* `:batch` ids OpenRouter accepted a job for and which it refused with
|
|
71
|
+
* "does not have a :batch endpoint". The catalog cannot tell the two apart
|
|
72
|
+
* (#141), so the `models` action annotates its rows from this file.
|
|
73
|
+
*/
|
|
74
|
+
export const BROADSIDE_ENDPOINTS_FILE = "batch-endpoints.json";
|
|
67
75
|
export const BROADSIDE_CATALOG_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
68
76
|
export const BROADSIDE_LENS_IDS = [
|
|
69
77
|
"architecture",
|
|
@@ -75,6 +83,16 @@ export const BROADSIDE_LENS_IDS = [
|
|
|
75
83
|
];
|
|
76
84
|
export const BROADSIDE_POLL_INTERVAL_MS = 15_000;
|
|
77
85
|
export const BROADSIDE_DEFAULT_POLL_BUDGET_MS = 25 * 60 * 1000;
|
|
86
|
+
/**
|
|
87
|
+
* The run expense limit in USD a repository gets before it configures one.
|
|
88
|
+
* Pi asks a human before submitting over the estimate; the MCP surface cannot,
|
|
89
|
+
* and shipped with no limit at all, so a host calling submit with the stock
|
|
90
|
+
* config spent whatever the estimate came to (#231). One dollar covers a
|
|
91
|
+
* six-lens run of a repository this size with room to spare; a larger one
|
|
92
|
+
* raises `max_cost` in config.yaml, passes `max_cost` on the call, or sets it
|
|
93
|
+
* to 0 for no limit.
|
|
94
|
+
*/
|
|
95
|
+
export const BROADSIDE_DEFAULT_MAX_COST = 1;
|
|
78
96
|
/**
|
|
79
97
|
* The share of a lens's output budget reasoning may spend.
|
|
80
98
|
*
|
|
@@ -120,6 +138,37 @@ export class BroadsideAuthError extends Error {
|
|
|
120
138
|
this.detail = detail;
|
|
121
139
|
}
|
|
122
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* `broadside/config.yaml` exists but cannot be used. A file that failed to
|
|
143
|
+
* parse used to be treated exactly like an absent one — defaults, including
|
|
144
|
+
* no spend cap and no lens routing, with no message — so a typo removed the
|
|
145
|
+
* user's own guard (#232). Only an absent file yields defaults now.
|
|
146
|
+
*/
|
|
147
|
+
export class BroadsideConfigError extends Error {
|
|
148
|
+
path;
|
|
149
|
+
constructor(path, detail) {
|
|
150
|
+
super(`Broad-Side config ${path} ${detail}. Fix or remove the file; nothing runs on defaults while it is unreadable.`);
|
|
151
|
+
this.name = "BroadsideConfigError";
|
|
152
|
+
this.path = path;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* `broadside/state.json` exists but cannot be read. It used to be read as
|
|
157
|
+
* empty and the next checkpoint wrote that empty state over it, losing the
|
|
158
|
+
* batch ids of every in-flight, already-paid run (#233). The corrupt file is
|
|
159
|
+
* preserved beside itself and nothing writes over it until someone looks.
|
|
160
|
+
*/
|
|
161
|
+
export class BroadsideStateError extends Error {
|
|
162
|
+
path;
|
|
163
|
+
backupPath;
|
|
164
|
+
constructor(path, backupPath, detail) {
|
|
165
|
+
super(`Broad-Side state ${path} ${detail}. A copy is preserved at ${backupPath}; the file is not overwritten. ` +
|
|
166
|
+
"Repair state.json from the copy (each run's batch ids are what collect needs), or move it aside to start fresh.");
|
|
167
|
+
this.name = "BroadsideStateError";
|
|
168
|
+
this.path = path;
|
|
169
|
+
this.backupPath = backupPath;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
123
172
|
/** Thrown when a confirm hook declines a run. Nothing was submitted. */
|
|
124
173
|
export class BroadsideCancelledError extends Error {
|
|
125
174
|
constructor(message = "Broad-Side submission cancelled. Nothing was submitted.") {
|
|
@@ -851,9 +900,10 @@ const SOURCE_SPECS = {
|
|
|
851
900
|
*/
|
|
852
901
|
async function listRepoFiles(targetDir) {
|
|
853
902
|
try {
|
|
854
|
-
const listed = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { maxBuffer: 64 * 1024 * 1024 });
|
|
903
|
+
const listed = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { maxBuffer: 64 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
|
|
855
904
|
const deleted = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--deleted"], {
|
|
856
905
|
maxBuffer: 64 * 1024 * 1024,
|
|
906
|
+
timeout: GIT_TIMEOUT_MS,
|
|
857
907
|
});
|
|
858
908
|
const gone = new Set(deleted.stdout.split("\0").filter(Boolean));
|
|
859
909
|
const files = listed.stdout.split("\0").filter((path) => path && !gone.has(path));
|
|
@@ -865,7 +915,7 @@ async function listRepoFiles(targetDir) {
|
|
|
865
915
|
}
|
|
866
916
|
async function gitHead(targetDir) {
|
|
867
917
|
try {
|
|
868
|
-
const { stdout } = await execFileAsync("git", ["-C", targetDir, "rev-parse", "HEAD"], { maxBuffer: 1024 * 1024 });
|
|
918
|
+
const { stdout } = await execFileAsync("git", ["-C", targetDir, "rev-parse", "HEAD"], { maxBuffer: 1024 * 1024, timeout: GIT_TIMEOUT_MS });
|
|
869
919
|
return stdout.trim() || null;
|
|
870
920
|
}
|
|
871
921
|
catch {
|
|
@@ -874,7 +924,7 @@ async function gitHead(targetDir) {
|
|
|
874
924
|
}
|
|
875
925
|
async function gitDirty(targetDir) {
|
|
876
926
|
try {
|
|
877
|
-
const { stdout } = await execFileAsync("git", ["-C", targetDir, "status", "--porcelain"], { maxBuffer: 1024 * 1024 });
|
|
927
|
+
const { stdout } = await execFileAsync("git", ["-C", targetDir, "status", "--porcelain"], { maxBuffer: 1024 * 1024, timeout: GIT_TIMEOUT_MS });
|
|
878
928
|
return stdout.trim().length > 0;
|
|
879
929
|
}
|
|
880
930
|
catch {
|
|
@@ -890,7 +940,7 @@ async function changedFilesSince(targetDir, baseHead) {
|
|
|
890
940
|
if (!baseHead)
|
|
891
941
|
return null;
|
|
892
942
|
try {
|
|
893
|
-
const { stdout } = await execFileAsync("git", ["-C", targetDir, "diff", "--name-only", baseHead, "HEAD"], { maxBuffer: 64 * 1024 * 1024 });
|
|
943
|
+
const { stdout } = await execFileAsync("git", ["-C", targetDir, "diff", "--name-only", baseHead, "HEAD"], { maxBuffer: 64 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
|
|
894
944
|
return new Set(stdout.split("\n").filter(Boolean));
|
|
895
945
|
}
|
|
896
946
|
catch {
|
|
@@ -909,7 +959,7 @@ async function walkFiles(rootDir, dir, depth, remaining) {
|
|
|
909
959
|
return out;
|
|
910
960
|
}
|
|
911
961
|
for (const entry of entries) {
|
|
912
|
-
if (entry.name.startsWith(".")
|
|
962
|
+
if (entry.name.startsWith("."))
|
|
913
963
|
continue;
|
|
914
964
|
if (entry.isDirectory()) {
|
|
915
965
|
if (SKIP_DIR_NAMES.has(entry.name))
|
|
@@ -1371,15 +1421,30 @@ export async function loadBroadsideState(broadsideDir) {
|
|
|
1371
1421
|
const statePath = join(broadsideDir, BROADSIDE_STATE_FILE);
|
|
1372
1422
|
if (!(await pathExists(statePath)))
|
|
1373
1423
|
return defaultBroadsideState();
|
|
1424
|
+
const text = await readFile(statePath, "utf8");
|
|
1425
|
+
let raw;
|
|
1374
1426
|
try {
|
|
1375
|
-
|
|
1376
|
-
if (!raw || typeof raw !== "object" || !Array.isArray(raw.runs))
|
|
1377
|
-
return defaultBroadsideState();
|
|
1378
|
-
return raw;
|
|
1427
|
+
raw = JSON.parse(text);
|
|
1379
1428
|
}
|
|
1380
|
-
catch {
|
|
1381
|
-
|
|
1429
|
+
catch (error) {
|
|
1430
|
+
throw new BroadsideStateError(statePath, await preserveCorruptState(statePath, text), `could not be parsed (${error instanceof Error ? error.message : String(error)})`);
|
|
1431
|
+
}
|
|
1432
|
+
if (!raw || typeof raw !== "object" || !Array.isArray(raw.runs)) {
|
|
1433
|
+
throw new BroadsideStateError(statePath, await preserveCorruptState(statePath, text), "is not a state file (expected an object with a runs array)");
|
|
1382
1434
|
}
|
|
1435
|
+
return raw;
|
|
1436
|
+
}
|
|
1437
|
+
/**
|
|
1438
|
+
* Copy an unreadable state file to `state.json.corrupt-<hash>` beside it,
|
|
1439
|
+
* named by content so repeated loads do not multiply copies. Returns the
|
|
1440
|
+
* copy's path (the existing one, when the same content was preserved before).
|
|
1441
|
+
*/
|
|
1442
|
+
async function preserveCorruptState(statePath, text) {
|
|
1443
|
+
const digest = createHash("sha1").update(text).digest("hex").slice(0, 8);
|
|
1444
|
+
const backupPath = `${statePath}.corrupt-${digest}`;
|
|
1445
|
+
if (!(await pathExists(backupPath)))
|
|
1446
|
+
await writeFile(backupPath, text, "utf8");
|
|
1447
|
+
return backupPath;
|
|
1383
1448
|
}
|
|
1384
1449
|
/**
|
|
1385
1450
|
* Overwrite `state.json` wholesale with `state`.
|
|
@@ -1472,13 +1537,26 @@ export async function loadBroadsideConfig(broadsideDir) {
|
|
|
1472
1537
|
const configPath = join(broadsideDir, BROADSIDE_CONFIG_FILE);
|
|
1473
1538
|
let raw = {};
|
|
1474
1539
|
if (await pathExists(configPath)) {
|
|
1540
|
+
let parsed;
|
|
1475
1541
|
try {
|
|
1476
|
-
|
|
1542
|
+
parsed = await loadYamlFile(configPath);
|
|
1477
1543
|
}
|
|
1478
|
-
catch {
|
|
1479
|
-
|
|
1544
|
+
catch (error) {
|
|
1545
|
+
throw new BroadsideConfigError(configPath, `could not be parsed (${error instanceof Error ? error.message : String(error)})`);
|
|
1546
|
+
}
|
|
1547
|
+
if (parsed !== null && parsed !== undefined) {
|
|
1548
|
+
if (typeof parsed !== "object" || Array.isArray(parsed))
|
|
1549
|
+
throw new BroadsideConfigError(configPath, "is not a YAML mapping");
|
|
1550
|
+
raw = parsed;
|
|
1480
1551
|
}
|
|
1481
1552
|
}
|
|
1553
|
+
return buildBroadsideConfig(raw);
|
|
1554
|
+
}
|
|
1555
|
+
/** The shipped defaults: what an absent config.yaml means. */
|
|
1556
|
+
export function defaultBroadsideConfig() {
|
|
1557
|
+
return buildBroadsideConfig({});
|
|
1558
|
+
}
|
|
1559
|
+
function buildBroadsideConfig(raw) {
|
|
1482
1560
|
const lenses = Array.isArray(raw.default_lenses)
|
|
1483
1561
|
? (raw.default_lenses.filter((l) => BROADSIDE_LENS_IDS.includes(l)))
|
|
1484
1562
|
: [];
|
|
@@ -1503,7 +1581,9 @@ export async function loadBroadsideConfig(broadsideDir) {
|
|
|
1503
1581
|
model: typeof raw.model === "string" && raw.model.trim() ? raw.model.trim() : BROADSIDE_MODEL,
|
|
1504
1582
|
apiKey: typeof raw.api_key === "string" ? raw.api_key.trim() : "",
|
|
1505
1583
|
defaultLenses: lenses.length > 0 ? lenses : [...BROADSIDE_LENS_IDS],
|
|
1506
|
-
|
|
1584
|
+
// Absent: the shipped default. An explicit 0 is "no limit", spelled out
|
|
1585
|
+
// on purpose; a negative or non-numeric value is not a limit at all.
|
|
1586
|
+
maxCost: typeof raw.max_cost === "number" && raw.max_cost >= 0 ? raw.max_cost : BROADSIDE_DEFAULT_MAX_COST,
|
|
1507
1587
|
pricing: inputOverride !== undefined && outputOverride !== undefined
|
|
1508
1588
|
? { inputPerM: inputOverride, outputPerM: outputOverride }
|
|
1509
1589
|
: null,
|
|
@@ -1520,13 +1600,18 @@ export async function loadBroadsideConfig(broadsideDir) {
|
|
|
1520
1600
|
redactSecrets: flag("redact_secrets", true),
|
|
1521
1601
|
};
|
|
1522
1602
|
}
|
|
1603
|
+
// ---------- model catalog, pricing, benchmarks ----------
|
|
1604
|
+
/** The catalog cache schema this build writes; a file from another is not read. */
|
|
1605
|
+
export const BROADSIDE_CATALOG_CACHE_SCHEMA = 3;
|
|
1523
1606
|
async function readCatalogCache(broadsideDir) {
|
|
1524
1607
|
const cachePath = join(broadsideDir, BROADSIDE_CATALOG_CACHE_FILE);
|
|
1525
1608
|
if (!(await pathExists(cachePath)))
|
|
1526
1609
|
return null;
|
|
1527
1610
|
try {
|
|
1528
1611
|
const parsed = JSON.parse(await readFile(cachePath, "utf8"));
|
|
1529
|
-
if (!parsed || typeof parsed !== "object" || typeof parsed.models !== "object")
|
|
1612
|
+
if (!parsed || typeof parsed !== "object" || !parsed.models || typeof parsed.models !== "object")
|
|
1613
|
+
return null;
|
|
1614
|
+
if (parsed.schema_version !== BROADSIDE_CATALOG_CACHE_SCHEMA && parsed.schema_version !== 2)
|
|
1530
1615
|
return null;
|
|
1531
1616
|
return parsed;
|
|
1532
1617
|
}
|
|
@@ -1534,10 +1619,76 @@ async function readCatalogCache(broadsideDir) {
|
|
|
1534
1619
|
return null;
|
|
1535
1620
|
}
|
|
1536
1621
|
}
|
|
1622
|
+
/** When a cached entry was fetched: its own stamp, or the file's for a schema-2 cache. */
|
|
1623
|
+
function catalogEntryFetchedAt(cache, model) {
|
|
1624
|
+
const stamp = cache.models[model]?.fetched_at ?? cache.fetched_at;
|
|
1625
|
+
return new Date(stamp).getTime();
|
|
1626
|
+
}
|
|
1537
1627
|
async function writeCatalogCache(broadsideDir, cache) {
|
|
1538
1628
|
await mkdir(broadsideDir, { recursive: true });
|
|
1539
1629
|
await writeFile(join(broadsideDir, BROADSIDE_CATALOG_CACHE_FILE), `${JSON.stringify(cache, null, "\t")}\n`, "utf8");
|
|
1540
1630
|
}
|
|
1631
|
+
const BROADSIDE_ENDPOINTS_SCHEMA = 1;
|
|
1632
|
+
export async function readBatchEndpoints(broadsideDir) {
|
|
1633
|
+
const path = join(broadsideDir, BROADSIDE_ENDPOINTS_FILE);
|
|
1634
|
+
if (!(await pathExists(path)))
|
|
1635
|
+
return {};
|
|
1636
|
+
try {
|
|
1637
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
1638
|
+
if (!parsed || typeof parsed !== "object" || parsed.schema_version !== BROADSIDE_ENDPOINTS_SCHEMA)
|
|
1639
|
+
return {};
|
|
1640
|
+
if (!parsed.models || typeof parsed.models !== "object")
|
|
1641
|
+
return {};
|
|
1642
|
+
const out = {};
|
|
1643
|
+
for (const [model, record] of Object.entries(parsed.models)) {
|
|
1644
|
+
if (!record || typeof record !== "object")
|
|
1645
|
+
continue;
|
|
1646
|
+
if (record.status !== "accepted" && record.status !== "rejected")
|
|
1647
|
+
continue;
|
|
1648
|
+
if (typeof record.at !== "string")
|
|
1649
|
+
continue;
|
|
1650
|
+
out[model] = { status: record.status, at: record.at, ...(typeof record.error === "string" && { error: record.error }) };
|
|
1651
|
+
}
|
|
1652
|
+
return out;
|
|
1653
|
+
}
|
|
1654
|
+
catch {
|
|
1655
|
+
// An unreadable memory is an empty one: it only annotates a listing.
|
|
1656
|
+
return {};
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* The refusal OpenRouter returns for a catalog id that has no batch endpoint
|
|
1661
|
+
* behind it. Matched loosely: the message is the only signal there is.
|
|
1662
|
+
*/
|
|
1663
|
+
const NO_BATCH_ENDPOINT_RE = /does not have a :batch endpoint/i;
|
|
1664
|
+
/** The refusal for a full per-account concurrent batch-job quota. */
|
|
1665
|
+
const BATCH_QUOTA_RE = /job-submission-count/i;
|
|
1666
|
+
/**
|
|
1667
|
+
* Remember what a submit learned about each model it posted to. An accepted
|
|
1668
|
+
* job proves the endpoint exists; a "does not have a :batch endpoint"
|
|
1669
|
+
* refusal proves it does not. Any other rejection (quota, malformed request,
|
|
1670
|
+
* auth) says nothing about the endpoint and leaves the record alone.
|
|
1671
|
+
*/
|
|
1672
|
+
export async function recordBatchEndpoints(broadsideDir, outcomes) {
|
|
1673
|
+
const at = new Date().toISOString();
|
|
1674
|
+
const updates = {};
|
|
1675
|
+
for (const { model, batchId, error } of outcomes) {
|
|
1676
|
+
if (batchId) {
|
|
1677
|
+
updates[model] = { status: "accepted", at };
|
|
1678
|
+
continue;
|
|
1679
|
+
}
|
|
1680
|
+
const message = describeBatchError(error);
|
|
1681
|
+
if (message && NO_BATCH_ENDPOINT_RE.test(message)) {
|
|
1682
|
+
updates[model] = { status: "rejected", at, error: message };
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
if (Object.keys(updates).length === 0)
|
|
1686
|
+
return;
|
|
1687
|
+
const models = { ...(await readBatchEndpoints(broadsideDir)), ...updates };
|
|
1688
|
+
await mkdir(broadsideDir, { recursive: true });
|
|
1689
|
+
const file = { schema_version: BROADSIDE_ENDPOINTS_SCHEMA, models };
|
|
1690
|
+
await atomicWriteFile(join(broadsideDir, BROADSIDE_ENDPOINTS_FILE), `${JSON.stringify(file, null, "\t")}\n`);
|
|
1691
|
+
}
|
|
1541
1692
|
function parseCatalogEntry(raw) {
|
|
1542
1693
|
const id = String(raw.id ?? "");
|
|
1543
1694
|
if (!id)
|
|
@@ -1613,7 +1764,7 @@ export async function resolveCatalogEntry(broadsideDir, config, model, apiKey, f
|
|
|
1613
1764
|
// constants below are what we fall back to when the network is unavailable.
|
|
1614
1765
|
const cache = await readCatalogCache(broadsideDir);
|
|
1615
1766
|
const cached = cache?.models[model];
|
|
1616
|
-
if (cached && Date.now() -
|
|
1767
|
+
if (cache && cached && Date.now() - catalogEntryFetchedAt(cache, model) < BROADSIDE_CATALOG_CACHE_TTL_MS) {
|
|
1617
1768
|
return { model, source: "cache", entry: cached };
|
|
1618
1769
|
}
|
|
1619
1770
|
// What went wrong when the live lookup produced nothing, for the error
|
|
@@ -1649,12 +1800,15 @@ export async function resolveCatalogEntry(broadsideDir, config, model, apiKey, f
|
|
|
1649
1800
|
catalogFailure = `the model catalog could not be fetched (${error instanceof Error ? error.message : String(error)})`;
|
|
1650
1801
|
}
|
|
1651
1802
|
if (live) {
|
|
1803
|
+
const now = new Date().toISOString();
|
|
1652
1804
|
const updated = {
|
|
1653
|
-
schema_version:
|
|
1654
|
-
fetched_at:
|
|
1655
|
-
|
|
1805
|
+
schema_version: BROADSIDE_CATALOG_CACHE_SCHEMA,
|
|
1806
|
+
fetched_at: now,
|
|
1807
|
+
// Other entries keep their own stamps (a schema-2 file's entries
|
|
1808
|
+
// inherit the file's, once, on this upgrade); only this model is fresh.
|
|
1809
|
+
models: Object.fromEntries(Object.entries(cache?.models ?? {}).map(([id, entry]) => [id, { ...entry, fetched_at: entry.fetched_at ?? cache.fetched_at }])),
|
|
1656
1810
|
};
|
|
1657
|
-
updated.models[model] = live;
|
|
1811
|
+
updated.models[model] = { ...live, fetched_at: now };
|
|
1658
1812
|
await writeCatalogCache(broadsideDir, updated);
|
|
1659
1813
|
return { model, source: "live", entry: live };
|
|
1660
1814
|
}
|
|
@@ -1751,12 +1905,14 @@ export async function listBatchModels(broadsideDir, config, apiKey, opts = {}) {
|
|
|
1751
1905
|
}
|
|
1752
1906
|
entries.sort((a, b) => a.inputPerM + a.outputPerM - (b.inputPerM + b.outputPerM));
|
|
1753
1907
|
// Persist the catalog so the next submit's pricing resolution hits cache.
|
|
1754
|
-
const
|
|
1908
|
+
const fetchedAt = new Date().toISOString();
|
|
1909
|
+
const cache = { schema_version: BROADSIDE_CATALOG_CACHE_SCHEMA, fetched_at: fetchedAt, models: {} };
|
|
1755
1910
|
for (const entry of entries)
|
|
1756
|
-
cache.models[entry.id] = entry;
|
|
1911
|
+
cache.models[entry.id] = { ...entry, fetched_at: fetchedAt };
|
|
1757
1912
|
await writeCatalogCache(broadsideDir, cache);
|
|
1758
1913
|
const benchmarks = opts.includeBenchmarks ? await fetchCodingBenchmarks(apiKey, fetcher) : null;
|
|
1759
|
-
|
|
1914
|
+
const endpoints = await readBatchEndpoints(broadsideDir);
|
|
1915
|
+
return { entries, source: "live", benchmarks, defaultModel: config.model, endpoints };
|
|
1760
1916
|
}
|
|
1761
1917
|
export async function submitBatch(batchRequests, apiKey, fetcher = fetch, model = BROADSIDE_MODEL) {
|
|
1762
1918
|
// The OpenRouter batch endpoint stream-parses the body and requires
|
|
@@ -1811,6 +1967,14 @@ export async function fetchBatch(batchId, apiKey, fetcher = fetch) {
|
|
|
1811
1967
|
* charged, so callers must come back for it rather than retire it.
|
|
1812
1968
|
*/
|
|
1813
1969
|
export const BROADSIDE_DEAD_BATCH_STATUSES = ["failed", "expired", "cancelled", "auth-failed"];
|
|
1970
|
+
/**
|
|
1971
|
+
* Batch entry statuses collect never polls again: the dead ones above, plus
|
|
1972
|
+
* `completed`, plus the two a submit assigns without a batch (`skipped`: no
|
|
1973
|
+
* matching files; `rejected`: the provider refused it). The 0.19.1 changelog
|
|
1974
|
+
* called the dead set "a named constant rather than two hand-maintained
|
|
1975
|
+
* lists"; this set was still three literal copies (self-audit sem 5.8).
|
|
1976
|
+
*/
|
|
1977
|
+
export const BROADSIDE_TERMINAL_ENTRY_STATUSES = ["completed", ...BROADSIDE_DEAD_BATCH_STATUSES, "skipped", "rejected"];
|
|
1814
1978
|
export async function pollBatchUntilTerminal(batchId, apiKey, opts = {}) {
|
|
1815
1979
|
const deadline = Date.now() + (opts.deadlineMs ?? BROADSIDE_DEFAULT_POLL_BUDGET_MS);
|
|
1816
1980
|
const intervalMs = opts.pollIntervalMs ?? BROADSIDE_POLL_INTERVAL_MS;
|
|
@@ -1906,7 +2070,8 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
1906
2070
|
throw new Error(`Broad-Side found no ${info.language} source files to scan (detected from ${info.manifest?.path ?? "the file counts"}; ` +
|
|
1907
2071
|
`the lenses look for ${info.sourceExts.join(", ")}). Nothing was submitted.`);
|
|
1908
2072
|
}
|
|
1909
|
-
const
|
|
2073
|
+
const lensModels = { ...config.lensModels, ...opts.lensModels };
|
|
2074
|
+
const modelForLens = (lensId) => lensModels[lensId] ?? model;
|
|
1910
2075
|
const resolved = new Map();
|
|
1911
2076
|
for (const candidate of new Set([model, ...lensIds.map(modelForLens)])) {
|
|
1912
2077
|
const catalog = await resolveCatalogEntry(broadsideDir, config, candidate, apiKey, opts.fetcher);
|
|
@@ -2042,7 +2207,9 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
2042
2207
|
`$${limit.toFixed(2)}. Nothing was submitted.\nBreakdown:\n${breakdown}\n` +
|
|
2043
2208
|
`Pass force: true to submit anyway, or raise max_cost in .codecarto/broadside/config.yaml.`);
|
|
2044
2209
|
}
|
|
2045
|
-
|
|
2210
|
+
// Read before anything is posted: a state.json that cannot be read refuses
|
|
2211
|
+
// the run here (#233), while persistBroadsideRun below merges by run id.
|
|
2212
|
+
await loadBroadsideState(broadsideDir);
|
|
2046
2213
|
const runId = new Date().toISOString().replace(/[:.]/g, "-");
|
|
2047
2214
|
const run = {
|
|
2048
2215
|
id: runId,
|
|
@@ -2069,7 +2236,6 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
2069
2236
|
skippedFiles: info.secretFilesSkipped.length,
|
|
2070
2237
|
},
|
|
2071
2238
|
};
|
|
2072
|
-
state.runs.push(run);
|
|
2073
2239
|
await persistBroadsideRun(broadsideDir, run);
|
|
2074
2240
|
const requestsByCustomId = {};
|
|
2075
2241
|
const submissions = [];
|
|
@@ -2119,6 +2285,12 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
2119
2285
|
}
|
|
2120
2286
|
await Promise.allSettled(submissions);
|
|
2121
2287
|
await persistBroadsideRun(broadsideDir, run);
|
|
2288
|
+
// What the provider just said about each model's batch endpoint outlives
|
|
2289
|
+
// the run: the `models` action reads it back (#141).
|
|
2290
|
+
await recordBatchEndpoints(broadsideDir, lensIds
|
|
2291
|
+
.map((lensId) => run.batches[lensId])
|
|
2292
|
+
.filter((entry) => Boolean(entry) && entry.status !== "skipped")
|
|
2293
|
+
.map((entry) => ({ model: entry.model ?? model, batchId: entry.batchId, error: entry.error })));
|
|
2122
2294
|
// Persist the exact request bodies so collect can re-submit a truncated
|
|
2123
2295
|
// slice (bumped output cap) without re-walking the repo (#133). The run
|
|
2124
2296
|
// dir is created here rather than waiting for collect so a crash between
|
|
@@ -2364,8 +2536,13 @@ function parseTriageItems(content) {
|
|
|
2364
2536
|
export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
2365
2537
|
const broadsideDir = broadsideDirFor(cwd);
|
|
2366
2538
|
const state = await loadBroadsideState(broadsideDir);
|
|
2367
|
-
const run = state.runs[state.runs.length - 1];
|
|
2539
|
+
const run = opts.runId ? state.runs.find((candidate) => candidate.id === opts.runId) : state.runs[state.runs.length - 1];
|
|
2368
2540
|
if (!run) {
|
|
2541
|
+
if (opts.runId) {
|
|
2542
|
+
const known = state.runs.map((candidate) => candidate.id);
|
|
2543
|
+
throw new Error(`No Broad-Side run with id ${opts.runId}. ` +
|
|
2544
|
+
(known.length > 0 ? `Recorded runs: ${known.join(", ")}.` : "No runs are recorded; call codecarto_broadside with action 'submit' first."));
|
|
2545
|
+
}
|
|
2369
2546
|
throw new Error("No Broad-Side run recorded. Call codecarto_broadside with action 'submit' first.");
|
|
2370
2547
|
}
|
|
2371
2548
|
const runDir = join(broadsideDir, run.outputDir);
|
|
@@ -2386,7 +2563,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2386
2563
|
lensOutcomes[lensId] = { status: entry?.status ?? "failed", resultCount: 0 };
|
|
2387
2564
|
continue;
|
|
2388
2565
|
}
|
|
2389
|
-
if (
|
|
2566
|
+
if (BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status)) {
|
|
2390
2567
|
totalCost += entry.cost ?? 0;
|
|
2391
2568
|
resultCount += entry.resultCount ?? 0;
|
|
2392
2569
|
lensOutcomes[lensId] = { status: entry.status, cost: entry.cost, resultCount: entry.resultCount };
|
|
@@ -2419,7 +2596,19 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2419
2596
|
truncatedCount += truncated;
|
|
2420
2597
|
totalCost += cost ?? 0;
|
|
2421
2598
|
await writeFile(join(runDir, `raw-${lensId}.json`), `${JSON.stringify(batch, null, "\t")}\n`, "utf8");
|
|
2422
|
-
|
|
2599
|
+
// A batch can complete with every request failed — the account's
|
|
2600
|
+
// concurrent-job quota filling after acceptance does exactly this.
|
|
2601
|
+
// The per-request errors are on disk as `<id>.error.json`, but a
|
|
2602
|
+
// lens reporting "completed, 0 result(s)" with the reason buried
|
|
2603
|
+
// there read as an empty repository rather than a refused run.
|
|
2604
|
+
const results = Array.isArray(batch.results) ? batch.results : [];
|
|
2605
|
+
const failed = results.filter((r) => r.error && extractContent(r) === null);
|
|
2606
|
+
const allFailed = stored.length === 0 && failed.length > 0
|
|
2607
|
+
? `all ${failed.length} request(s) failed: ${explainBatchError(failed[0].error)}`
|
|
2608
|
+
: null;
|
|
2609
|
+
if (allFailed)
|
|
2610
|
+
entry.error = allFailed;
|
|
2611
|
+
lensOutcomes[lensId] = { status, cost: entry.cost, resultCount: entry.resultCount, truncated, ...(allFailed && { error: allFailed }) };
|
|
2423
2612
|
}
|
|
2424
2613
|
else {
|
|
2425
2614
|
// Every non-completed outcome still has to reach the report.
|
|
@@ -2431,7 +2620,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2431
2620
|
// indistinguishable in the output from one that was never requested.
|
|
2432
2621
|
if (batch.error)
|
|
2433
2622
|
entry.error = batch.error;
|
|
2434
|
-
const error =
|
|
2623
|
+
const error = explainBatchError(batch.error);
|
|
2435
2624
|
lensOutcomes[lensId] = { status, cost: entry.cost, resultCount: entry.resultCount, ...(error && { error }) };
|
|
2436
2625
|
}
|
|
2437
2626
|
await persistBroadsideRun(broadsideDir, run);
|
|
@@ -2439,9 +2628,19 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2439
2628
|
// #133: re-submit truncated slices once with a bumped output cap. Batch
|
|
2440
2629
|
// requests are pure, so re-running is always safe; the aim is to recover
|
|
2441
2630
|
// coverage the first pass lost to a max_tokens cutoff, not to loop forever.
|
|
2631
|
+
//
|
|
2632
|
+
// All bumped requests for one model go out as ONE batch, and the batches
|
|
2633
|
+
// (one per model, since a batch carries a single model) are polled
|
|
2634
|
+
// together against the shared deadline. Each truncated slice used to be
|
|
2635
|
+
// submitted and polled to terminal before the next was submitted, so a
|
|
2636
|
+
// model that truncated 11 of 13 slices turned a five-minute collect into
|
|
2637
|
+
// eleven sequential round trips — the serialization #136 removed from the
|
|
2638
|
+
// lens pass, still present here (#206). Grouping also keeps the retry to
|
|
2639
|
+
// one job per model against OpenRouter's 16-concurrent-job quota.
|
|
2442
2640
|
let retriedCount = 0;
|
|
2443
2641
|
if (opts.retryTruncated !== false && truncatedCount > 0) {
|
|
2444
2642
|
const requestsByCustomId = await loadStoredRequests(runDir);
|
|
2643
|
+
const byModel = new Map();
|
|
2445
2644
|
for (const stored of allLensResults) {
|
|
2446
2645
|
if (!stored.truncated)
|
|
2447
2646
|
continue;
|
|
@@ -2458,42 +2657,57 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2458
2657
|
const bumpedMax = lensCap ? Math.min(previousMax * 2, lensCap) : previousMax * 2;
|
|
2459
2658
|
if (bumpedMax <= previousMax)
|
|
2460
2659
|
continue; // already at the ceiling
|
|
2461
|
-
const
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2660
|
+
const group = byModel.get(lensModel) ?? { requests: [], slices: new Map() };
|
|
2661
|
+
group.requests.push({ ...original, body: { ...original.body, max_tokens: bumpedMax } });
|
|
2662
|
+
group.slices.set(stored.customId, stored);
|
|
2663
|
+
byModel.set(lensModel, group);
|
|
2664
|
+
}
|
|
2665
|
+
// Submit every group, then poll whatever was accepted, together.
|
|
2666
|
+
const submitted = [];
|
|
2667
|
+
for (const [model, group] of byModel) {
|
|
2465
2668
|
try {
|
|
2466
|
-
const { batchId, error } = await submitBatch(
|
|
2467
|
-
if (error)
|
|
2669
|
+
const { batchId, error } = await submitBatch(group.requests, apiKey, opts.fetcher, model);
|
|
2670
|
+
if (!error && batchId)
|
|
2671
|
+
submitted.push({ model, batchId });
|
|
2672
|
+
}
|
|
2673
|
+
catch {
|
|
2674
|
+
// A retry batch that fails to submit leaves its slices' original
|
|
2675
|
+
// truncated results in place — nothing is lost.
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
const polled = await pollBatchesConcurrently(submitted.map(({ model, batchId }) => ({ lensId: `retry:${model}`, batchId })), apiKey, {
|
|
2679
|
+
// Share the caller's deadline. Each of these polls used to start a
|
|
2680
|
+
// fresh 25-minute budget, so `wait_seconds` bounded only the lens
|
|
2681
|
+
// poll and a collect could run for the caller's budget plus fifty
|
|
2682
|
+
// minutes.
|
|
2683
|
+
deadlineMs: Math.max(0, deadline - Date.now()),
|
|
2684
|
+
fetcher: opts.fetcher,
|
|
2685
|
+
onStatus: opts.onStatus,
|
|
2686
|
+
});
|
|
2687
|
+
for (const { model, batchId } of submitted) {
|
|
2688
|
+
const batch = polled.get(batchId);
|
|
2689
|
+
if (!batch || batch.status !== "completed")
|
|
2690
|
+
continue;
|
|
2691
|
+
const group = byModel.get(model);
|
|
2692
|
+
const usage = (batch.usage ?? {});
|
|
2693
|
+
totalCost += typeof usage.cost === "number" ? usage.cost : 0;
|
|
2694
|
+
const results = Array.isArray(batch.results) ? batch.results : [];
|
|
2695
|
+
for (const result of results) {
|
|
2696
|
+
const stored = group.slices.get(String(result.custom_id ?? ""));
|
|
2697
|
+
if (!stored)
|
|
2468
2698
|
continue;
|
|
2469
|
-
const
|
|
2470
|
-
|
|
2471
|
-
// start a fresh 25-minute budget, so `wait_seconds` bounded
|
|
2472
|
-
// only the lens poll and a collect could run for the caller's
|
|
2473
|
-
// budget plus fifty minutes.
|
|
2474
|
-
deadlineMs: Math.max(0, deadline - Date.now()),
|
|
2475
|
-
onStatus: (status, counts) => opts.onStatus?.(`${stored.lensId}:retry`, status, counts),
|
|
2476
|
-
fetcher: opts.fetcher,
|
|
2477
|
-
});
|
|
2478
|
-
if (batch.status !== "completed")
|
|
2699
|
+
const content = extractContent(result);
|
|
2700
|
+
if (content === null)
|
|
2479
2701
|
continue;
|
|
2480
|
-
const results = Array.isArray(batch.results) ? batch.results : [];
|
|
2481
|
-
const content = results.length > 0 ? extractContent(results[0]) : null;
|
|
2482
|
-
if (content === null || parseLensJson(content) === null)
|
|
2483
|
-
continue; // still no good
|
|
2484
|
-
const usage = (batch.usage ?? {});
|
|
2485
|
-
totalCost += typeof usage.cost === "number" ? usage.cost : 0;
|
|
2486
2702
|
const parsed = parseLensJson(content);
|
|
2703
|
+
if (parsed === null)
|
|
2704
|
+
continue; // still no good
|
|
2487
2705
|
await writeFile(join(runDir, `${sanitizeId(stored.customId)}.json`), `${JSON.stringify(parsed, null, "\t")}\n`, "utf8");
|
|
2488
2706
|
await writeFile(join(runDir, `${sanitizeId(stored.customId)}.md`), renderFindingsMarkdown(content), "utf8");
|
|
2489
2707
|
stored.content = content;
|
|
2490
2708
|
stored.truncated = false;
|
|
2491
2709
|
retriedCount += 1;
|
|
2492
2710
|
}
|
|
2493
|
-
catch {
|
|
2494
|
-
// A retry that fails to submit/poll leaves the original
|
|
2495
|
-
// truncated result in place — nothing is lost.
|
|
2496
|
-
}
|
|
2497
2711
|
}
|
|
2498
2712
|
truncatedCount = allLensResults.filter((s) => s.truncated).length;
|
|
2499
2713
|
for (const [lensId, outcome] of Object.entries(lensOutcomes)) {
|
|
@@ -2526,7 +2740,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2526
2740
|
if ((wantSynthesis || wantTriage) && allLensResults.length > 0) {
|
|
2527
2741
|
const allTerminal = run.lenses.every((lensId) => {
|
|
2528
2742
|
const entry = run.batches[lensId];
|
|
2529
|
-
return entry &&
|
|
2743
|
+
return entry && BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status);
|
|
2530
2744
|
});
|
|
2531
2745
|
if (allTerminal && (postPassUnfinished(run.synthesis) || postPassUnfinished(run.triage))) {
|
|
2532
2746
|
const findingsText = allLensResults
|
|
@@ -2638,7 +2852,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2638
2852
|
}
|
|
2639
2853
|
const terminal = run.lenses.every((lensId) => {
|
|
2640
2854
|
const entry = run.batches[lensId];
|
|
2641
|
-
return entry &&
|
|
2855
|
+
return entry && BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status);
|
|
2642
2856
|
});
|
|
2643
2857
|
run.status = terminal ? (resultCount > 0 ? "completed" : "failed") : "partial";
|
|
2644
2858
|
run.totalCost = totalCost;
|
|
@@ -2777,7 +2991,11 @@ export function estimateSubmitText(result, lenses) {
|
|
|
2777
2991
|
continue;
|
|
2778
2992
|
const status = entry.batchId ? `batch ${entry.batchId}` : entry.status;
|
|
2779
2993
|
const override = entry.model ? ` on ${entry.model}` : "";
|
|
2780
|
-
|
|
2994
|
+
// A rejected lens says why: the message is the only way to tell a
|
|
2995
|
+
// catalog id with no batch endpoint from a full job quota, and both
|
|
2996
|
+
// used to read as a bare "rejected".
|
|
2997
|
+
const reason = !entry.batchId && entry.error ? ` — ${explainBatchError(entry.error)}` : "";
|
|
2998
|
+
lines.push(` ${lens.name}: ${status} (${entry.requests} request(s), ~$${entry.estimatedCost.toFixed(4)})${override}${reason}`);
|
|
2781
2999
|
}
|
|
2782
3000
|
if (result.repo) {
|
|
2783
3001
|
const head = result.repo.sourceHead ? ` at ${result.repo.sourceHead.slice(0, 8)}${result.repo.sourceDirty ? " (dirty)" : ""}` : "";
|
|
@@ -2814,8 +3032,15 @@ export function estimateSubmitText(result, lenses) {
|
|
|
2814
3032
|
return lines.join("\n");
|
|
2815
3033
|
}
|
|
2816
3034
|
export function modelsText(entries, opts) {
|
|
3035
|
+
const endpoints = opts.endpoints ?? {};
|
|
2817
3036
|
const lines = [
|
|
2818
3037
|
`Batch models on OpenRouter (${entries.length}, cheapest first).`,
|
|
3038
|
+
// The catalog over-reports: it returns a `:batch` id for models whose
|
|
3039
|
+
// Batch API refuses the job, with nothing in the entry to tell them
|
|
3040
|
+
// apart (#141). Say so before the table, not after it.
|
|
3041
|
+
"Advisory: this is the catalog's list of :batch ids, not a list of working batch endpoints. Some ids are refused at submit " +
|
|
3042
|
+
"(\"does not have a :batch endpoint\"), at no cost. Rows tagged [no batch endpoint …] or [batch OK …] carry what this " +
|
|
3043
|
+
"repository's own submits found; an untagged row has not been tried here.",
|
|
2819
3044
|
"",
|
|
2820
3045
|
"id | $/M in | $/M out | ctx | max out | structured | coding idx",
|
|
2821
3046
|
];
|
|
@@ -2835,12 +3060,21 @@ export function modelsText(entries, opts) {
|
|
|
2835
3060
|
const out = entry.maxCompletionTokens ? `${(entry.maxCompletionTokens / 1024).toFixed(0)}k` : "?";
|
|
2836
3061
|
const tag = entry.id === opts.defaultModel ? " (default)" : "";
|
|
2837
3062
|
const exp = entry.expirationDate ? " [deprecated]" : "";
|
|
2838
|
-
|
|
3063
|
+
const record = endpoints[entry.id];
|
|
3064
|
+
const seen = record
|
|
3065
|
+
? record.status === "rejected"
|
|
3066
|
+
? ` [no batch endpoint, refused ${record.at.slice(0, 10)}]`
|
|
3067
|
+
: ` [batch OK ${record.at.slice(0, 10)}]`
|
|
3068
|
+
: "";
|
|
3069
|
+
lines.push(`${entry.id}${tag}${exp}${seen} | ${entry.inputPerM.toFixed(3)} | ${entry.outputPerM.toFixed(3)} | ${ctx} | ${out} | ${structured} | ${coding}`);
|
|
2839
3070
|
}
|
|
2840
3071
|
if (opts.benchmarks?.meta.as_of) {
|
|
2841
3072
|
lines.push("", `Benchmarks: Artificial Analysis coding index (as of ${String(opts.benchmarks.meta.as_of)}).`);
|
|
2842
3073
|
}
|
|
2843
|
-
lines.push("", "
|
|
3074
|
+
lines.push("", "Choose with the model parameter (--model= on Pi) for one run, lens_models (--lens-model=LENS:ID) per lens, or the model key in " +
|
|
3075
|
+
".codecarto/broadside/config.yaml for the repository. Higher coding index ≠ better scout: precision, context, structured-output " +
|
|
3076
|
+
"support, and whether the model spends its output budget reasoning (see reasoning: in config.yaml) matter most here. " +
|
|
3077
|
+
"A refused submit costs nothing, so probe an untried model on one lens first.");
|
|
2844
3078
|
return lines.join("\n");
|
|
2845
3079
|
}
|
|
2846
3080
|
/** One line of a batch's error field, whatever shape the provider gave it. */
|
|
@@ -2853,6 +3087,15 @@ function describeBatchError(error) {
|
|
|
2853
3087
|
const message = error.message;
|
|
2854
3088
|
if (typeof message === "string" && message)
|
|
2855
3089
|
return message.slice(0, 300);
|
|
3090
|
+
// OpenRouter wraps a submit refusal as `{ error: { message } }`.
|
|
3091
|
+
const nested = error.error;
|
|
3092
|
+
if (nested && typeof nested === "object") {
|
|
3093
|
+
const inner = nested.message;
|
|
3094
|
+
if (typeof inner === "string" && inner)
|
|
3095
|
+
return inner.slice(0, 300);
|
|
3096
|
+
}
|
|
3097
|
+
if (typeof nested === "string" && nested)
|
|
3098
|
+
return nested.slice(0, 300);
|
|
2856
3099
|
try {
|
|
2857
3100
|
return JSON.stringify(error).slice(0, 300);
|
|
2858
3101
|
}
|
|
@@ -2862,6 +3105,30 @@ function describeBatchError(error) {
|
|
|
2862
3105
|
}
|
|
2863
3106
|
return String(error);
|
|
2864
3107
|
}
|
|
3108
|
+
/**
|
|
3109
|
+
* A provider refusal plus what to do about it, for the two refusals a batch
|
|
3110
|
+
* run meets in practice and cannot fix by itself (#141):
|
|
3111
|
+
*
|
|
3112
|
+
* - `Model '<id>' does not have a :batch endpoint.` — the catalog advertises a
|
|
3113
|
+
* `:batch` id that OpenRouter runs no batch endpoint for. Nothing in the
|
|
3114
|
+
* catalog distinguishes these; the `models` action marks ids this
|
|
3115
|
+
* repository has seen refused.
|
|
3116
|
+
* - `job-submission-count … in use: 16, quota: 16` — the per-account limit
|
|
3117
|
+
* on concurrent batch jobs. Broad-Side submits one job per lens, so a few
|
|
3118
|
+
* runs in flight on the same key fill it; the refusal costs nothing.
|
|
3119
|
+
*/
|
|
3120
|
+
export function explainBatchError(error) {
|
|
3121
|
+
const message = describeBatchError(error);
|
|
3122
|
+
if (!message)
|
|
3123
|
+
return null;
|
|
3124
|
+
if (NO_BATCH_ENDPOINT_RE.test(message)) {
|
|
3125
|
+
return `${message} — the catalog lists this id, but OpenRouter runs no batch endpoint for it. Nothing was charged; pick another model (the models action marks ids this repository has seen refused).`;
|
|
3126
|
+
}
|
|
3127
|
+
if (BATCH_QUOTA_RE.test(message)) {
|
|
3128
|
+
return `${message} — OpenRouter's per-account limit on concurrent batch jobs is full. Broad-Side submits one job per lens, so a few runs in flight on this key (in any repository) fill it. Nothing was charged; collect or wait out the runs in flight, then re-submit.`;
|
|
3129
|
+
}
|
|
3130
|
+
return message;
|
|
3131
|
+
}
|
|
2865
3132
|
export function collectResultText(result) {
|
|
2866
3133
|
const lines = [
|
|
2867
3134
|
`Broad-Side run ${result.runId}: ${result.status}`,
|