@bongos/core 1.19.1037 → 1.19.1038
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/.bongos-core.json +33 -18
- package/docs/adr/0089-modular-multi-model-adversarial-grader.md +2 -0
- package/docs/module-api-changelog.md +2 -0
- package/modules/government/protected-surfaces.json +2 -0
- package/modules/grading/grader-model.js +50 -5
- package/modules/grading/grader-providers.neutral.json +4 -0
- package/modules/grading/grader-rubric.json +5 -0
- package/modules/grading/grader-subagent.js +17 -1
- package/modules/grading/model-provider.js +257 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/module-api.js +1 -1
- package/tests/government_protected_surfaces.mjs +14 -0
- package/tests/grade_model_provider.mjs +217 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.1038",
|
|
6
|
+
"core_contract": "1.19.1038",
|
|
7
|
+
"source_commit": "da6abbe0d3be205fbf1468053e22d062f0f88c53",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-26T08:02:16.753Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 542,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2493,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 3062,
|
|
20
|
+
"tree_sha256": "809d2cff15d7aea1e31dcb2a5d161eae233d7b7b4edaae142688cd13b33ef9a4",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -907,7 +907,7 @@
|
|
|
907
907
|
{
|
|
908
908
|
"path": "docs/adr/0089-modular-multi-model-adversarial-grader.md",
|
|
909
909
|
"mode": "0000644",
|
|
910
|
-
"sha256": "
|
|
910
|
+
"sha256": "4ec01143da7055676994ec4351c71a1321d41224076f0c4639c451fba28e62b9"
|
|
911
911
|
},
|
|
912
912
|
{
|
|
913
913
|
"path": "docs/adr/0090-metic-task-authoring.md",
|
|
@@ -2767,7 +2767,7 @@
|
|
|
2767
2767
|
{
|
|
2768
2768
|
"path": "docs/module-api-changelog.md",
|
|
2769
2769
|
"mode": "0000644",
|
|
2770
|
-
"sha256": "
|
|
2770
|
+
"sha256": "f4e772d2b38f923f36c45a1e2fc9254a2843370b4703d291ab82b0e5b126e998"
|
|
2771
2771
|
},
|
|
2772
2772
|
{
|
|
2773
2773
|
"path": "docs/modules-contract.md",
|
|
@@ -4597,7 +4597,7 @@
|
|
|
4597
4597
|
{
|
|
4598
4598
|
"path": "modules/government/protected-surfaces.json",
|
|
4599
4599
|
"mode": "0000644",
|
|
4600
|
-
"sha256": "
|
|
4600
|
+
"sha256": "e703a0c652d40d8d8f5578fbcb4b08dde9afd4f97ecef0b99db5e3a181b05aae"
|
|
4601
4601
|
},
|
|
4602
4602
|
{
|
|
4603
4603
|
"path": "modules/government/ranks.js",
|
|
@@ -4662,7 +4662,7 @@
|
|
|
4662
4662
|
{
|
|
4663
4663
|
"path": "modules/grading/grader-model.js",
|
|
4664
4664
|
"mode": "0000644",
|
|
4665
|
-
"sha256": "
|
|
4665
|
+
"sha256": "4141bc9e7ff90ab474af51bc78c0805af982b610c91ec90a8441e2b89a7596ce"
|
|
4666
4666
|
},
|
|
4667
4667
|
{
|
|
4668
4668
|
"path": "modules/grading/grader-no-artifact.js",
|
|
@@ -4674,10 +4674,15 @@
|
|
|
4674
4674
|
"mode": "0000644",
|
|
4675
4675
|
"sha256": "d845b8785b39c89777e9cb9cdf5e869b51e8eae11c76288d9be4790239fd5e13"
|
|
4676
4676
|
},
|
|
4677
|
+
{
|
|
4678
|
+
"path": "modules/grading/grader-providers.neutral.json",
|
|
4679
|
+
"mode": "0000644",
|
|
4680
|
+
"sha256": "1e1313c9bf157ce4b380c89e59832da4ee5573a18e1b70aa730c3826923b67eb"
|
|
4681
|
+
},
|
|
4677
4682
|
{
|
|
4678
4683
|
"path": "modules/grading/grader-rubric.json",
|
|
4679
4684
|
"mode": "0000644",
|
|
4680
|
-
"sha256": "
|
|
4685
|
+
"sha256": "58c445d96348ea0e9ee7d0603b88aa684fd7ad478a9de46c6668696ce8cd13f0"
|
|
4681
4686
|
},
|
|
4682
4687
|
{
|
|
4683
4688
|
"path": "modules/grading/grader-score.js",
|
|
@@ -4687,7 +4692,7 @@
|
|
|
4687
4692
|
{
|
|
4688
4693
|
"path": "modules/grading/grader-subagent.js",
|
|
4689
4694
|
"mode": "0000644",
|
|
4690
|
-
"sha256": "
|
|
4695
|
+
"sha256": "a7e7c0cd702ff1a6b83d12ce0310de72ed601c6a4b8e886f1a873eeaeaaecadd"
|
|
4691
4696
|
},
|
|
4692
4697
|
{
|
|
4693
4698
|
"path": "modules/grading/grader-workers/efficiency.js",
|
|
@@ -4749,6 +4754,11 @@
|
|
|
4749
4754
|
"mode": "0000644",
|
|
4750
4755
|
"sha256": "e223155c25f1fe4a66d7e3dd03b10632b76d4a7b851182df4afd05f5ae9d40bd"
|
|
4751
4756
|
},
|
|
4757
|
+
{
|
|
4758
|
+
"path": "modules/grading/model-provider.js",
|
|
4759
|
+
"mode": "0000644",
|
|
4760
|
+
"sha256": "608f90bb52ed2fb9341021c02c6ae0f44ed41192c0050ce3052861e9216bd2ad"
|
|
4761
|
+
},
|
|
4752
4762
|
{
|
|
4753
4763
|
"path": "modules/grading/module.json",
|
|
4754
4764
|
"mode": "0000644",
|
|
@@ -8442,12 +8452,12 @@
|
|
|
8442
8452
|
{
|
|
8443
8453
|
"path": "package-lock.json",
|
|
8444
8454
|
"mode": "0000644",
|
|
8445
|
-
"sha256": "
|
|
8455
|
+
"sha256": "b884a2821059a8a69b9757b7eb9cb8432983a1563b5a5461044aed5129119a69"
|
|
8446
8456
|
},
|
|
8447
8457
|
{
|
|
8448
8458
|
"path": "package.json",
|
|
8449
8459
|
"mode": "0000644",
|
|
8450
|
-
"sha256": "
|
|
8460
|
+
"sha256": "735b76d59c3bbe7444b3354e6bba1a1e72bcf639236fb4dd7925561290c71e96"
|
|
8451
8461
|
},
|
|
8452
8462
|
{
|
|
8453
8463
|
"path": "public-docs/index.html",
|
|
@@ -8467,7 +8477,7 @@
|
|
|
8467
8477
|
{
|
|
8468
8478
|
"path": "release-notes.json",
|
|
8469
8479
|
"mode": "0000644",
|
|
8470
|
-
"sha256": "
|
|
8480
|
+
"sha256": "bd9f61be14683fe846ffb42627b8cd3b24e1a4a732c793248d4eb4d845dae5cd"
|
|
8471
8481
|
},
|
|
8472
8482
|
{
|
|
8473
8483
|
"path": "scripts/bongos-mcp.js",
|
|
@@ -10522,7 +10532,7 @@
|
|
|
10522
10532
|
{
|
|
10523
10533
|
"path": "src/module-api.js",
|
|
10524
10534
|
"mode": "0000644",
|
|
10525
|
-
"sha256": "
|
|
10535
|
+
"sha256": "b1619ae083207e98797c5198861f067a405ee89c80ccc5a0b83d1fc1d381a465"
|
|
10526
10536
|
},
|
|
10527
10537
|
{
|
|
10528
10538
|
"path": "src/module-loader/catalog.js",
|
|
@@ -12352,7 +12362,7 @@
|
|
|
12352
12362
|
{
|
|
12353
12363
|
"path": "tests/government_protected_surfaces.mjs",
|
|
12354
12364
|
"mode": "0000644",
|
|
12355
|
-
"sha256": "
|
|
12365
|
+
"sha256": "3b022d77647dd871c43cdcfd8855932cc5d2c8a40fb061babcbae0d86e225097"
|
|
12356
12366
|
},
|
|
12357
12367
|
{
|
|
12358
12368
|
"path": "tests/government_rank_sync.mjs",
|
|
@@ -12439,6 +12449,11 @@
|
|
|
12439
12449
|
"mode": "0000644",
|
|
12440
12450
|
"sha256": "b638a9fc43d9a1b8ca92ee20b9d0b6cc92104581d4f5d3af7bfd4bf9b310e508"
|
|
12441
12451
|
},
|
|
12452
|
+
{
|
|
12453
|
+
"path": "tests/grade_model_provider.mjs",
|
|
12454
|
+
"mode": "0000644",
|
|
12455
|
+
"sha256": "11cfa2deade2db1f88f643d9aa310d34bdd0f37423944db4a68fa0ea2b2eba77"
|
|
12456
|
+
},
|
|
12442
12457
|
{
|
|
12443
12458
|
"path": "tests/grade_server_authoritative.mjs",
|
|
12444
12459
|
"mode": "0000644",
|
|
@@ -187,6 +187,8 @@ Roll out behind a flag (the [ADR 0079](0079-value-gated-autonomous-engagement.md
|
|
|
187
187
|
|
|
188
188
|
**Progress (2026-09-25).** Step 3's engine and registry landed first, ahead of steps 1–2, because both build on it: `modules/grading/grade-engine.js` names the pipeline (`select → pre_pass → evidence → fan_out → aggregate → gate → persist`), owns both deterministic pre-passes and a deterministic veto no work-type's aggregate can override, and resolves work-types from its own registry or a module's `grader.workType.<key>` seam provider. The code panel is registered as work-type `code`, the default. Proven a no-op: a before/after run of 27 grading scenarios came out byte-identical, and a replay of 376 recorded production grades reproduced every stored decision except 22 from June 2026, which an older grader produced before two deliberate gate changes (the task 943 "grader unavailable" outcome and the task 574 missing-security-lens block). Task 1002839; the replay tool is `scripts/gds/grade-replay.js`.
|
|
189
189
|
|
|
190
|
+
**Progress (2026-09-26).** Step 1 landed (task 1002840): `modules/grading/model-provider.js` is the `ModelProvider` port at `runSubagent`, with the Claude spawn as the default `anthropic-cli` provider (a before/after run of 29 grading scenarios came out byte-identical) and an `openai-compatible` adapter tested against a local stub. `selectGraderModel`'s if/else is now the rubric's `judge_by_builder_family` table, refused at load if a judge would grade its own family or leave Claude. Providers are instance config (`config/grader-providers.json`); the core declares none, so no open model is called and nothing is spent until the owner adds one. The error-correlation audit (step 5) also landed early (task 1002843, `scripts/gds/grade-correlation-audit.js`).
|
|
191
|
+
|
|
190
192
|
---
|
|
191
193
|
|
|
192
194
|
## Sources
|
|
@@ -2553,5 +2553,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2553
2553
|
landed since 1.19.1035 with no explicit bump. run 36226955285. (task 1002620)
|
|
2554
2554
|
1.19.1037 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2555
2555
|
landed since 1.19.1036 with no explicit bump. run 36227523642. (task 1002620)
|
|
2556
|
+
1.19.1038 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2557
|
+
landed since 1.19.1037 with no explicit bump. run 36228544921. (task 1002620)
|
|
2556
2558
|
---------------------------------------------------------------------------
|
|
2557
2559
|
```
|
|
@@ -60,6 +60,8 @@
|
|
|
60
60
|
{ "glob": "modules/grading/grader-score.js", "group": "pipeline", "why": "task 1004024. Carved out of grader.js: the deterministic permission-path pre-pass, the server-side score guards a model cannot talk past, the subagent spawn and the prompt. grader.js alone protected the facade and none of the gate." },
|
|
61
61
|
{ "glob": "modules/grading/grader-subagent.js", "group": "pipeline", "why": "task 1004024. Carved out of grader.js: the deterministic permission-path pre-pass, the server-side score guards a model cannot talk past, the subagent spawn and the prompt. grader.js alone protected the facade and none of the gate." },
|
|
62
62
|
{ "glob": "modules/grading/grade-engine.js", "group": "pipeline", "why": "task 1002839. The central grading engine every work-type is graded through: it runs the deterministic route-rank and permission-path pre-passes and applies the deterministic veto, so editing it can switch off the protected-path gate for every grade at once." },
|
|
63
|
+
{ "glob": "modules/grading/model-provider.js", "group": "pipeline", "why": "task 1002840. The ModelProvider port every grader model call goes through: it decides which vendor receives the diff under review and with which key, so editing it can re-route or blind the whole panel." },
|
|
64
|
+
{ "glob": "modules/grading/grader-providers.neutral.json", "group": "pipeline", "why": "task 1002840. The core's neutral provider set (none): an entry here would send every instance's diffs to a vendor by default." },
|
|
63
65
|
{ "glob": "modules/grading/grader-workers/", "group": "pipeline", "why": "the adversarial panel (Narc/Hacker/Quality/Efficiency) — blunting these blunts the gate" },
|
|
64
66
|
{ "glob": "modules/grading/grader-rubric.json", "group": "pipeline", "why": "the grader's scoring rubric (ADR 0058)" },
|
|
65
67
|
{ "glob": "scripts/gds/ship.js", "group": "pipeline", "why": "the ship lifecycle driver" },
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
'use strict';
|
|
10
10
|
|
|
11
|
-
const { ASSUMED_BUILDER_MODEL, DEFAULT_GRADER_MODEL, WORKER_MODEL_TIERS, api } = require('./grader-config.js');
|
|
11
|
+
const { ASSUMED_BUILDER_MODEL, DEFAULT_GRADER_MODEL, V4, WORKER_MODEL_TIERS, api } = require('./grader-config.js');
|
|
12
12
|
|
|
13
13
|
function r1(n) {
|
|
14
14
|
return Math.round(Number(n) * 10) / 10;
|
|
@@ -93,12 +93,14 @@ function isTrivialDiff({ changedFiles, diffStats } = {}) {
|
|
|
93
93
|
// misconfiguration it guarded. Unknown ids now warn loudly and grade on
|
|
94
94
|
// DEFAULT_GRADER_MODEL; the id still reaches signals.builder_model_assumed,
|
|
95
95
|
// so /grader-health can spot the drift.
|
|
96
|
+
//
|
|
97
|
+
// task 1002840 (ADR 0089 §3): the Claude-only if/else is now DATA — the judge
|
|
98
|
+
// table below, first matching row wins, in the old branch order.
|
|
96
99
|
function selectGraderModel({ builderModel } = {}) {
|
|
97
100
|
const bm = (builderModel || ASSUMED_BUILDER_MODEL).toLowerCase();
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (bm.includes('haiku')) return 'claude-opus-5';
|
|
101
|
+
for (const row of JUDGE_TABLE) {
|
|
102
|
+
if (row.builder_families.some((f) => bm.includes(f))) return row.judge_model;
|
|
103
|
+
}
|
|
102
104
|
console.error(
|
|
103
105
|
`selectGraderModel: unrecognized builder model ${JSON.stringify(builderModel)} — ` +
|
|
104
106
|
`no 'fable'/'mythos'/'opus'/'sonnet'/'haiku' token; grading on the default ` +
|
|
@@ -112,6 +114,46 @@ function selectGraderModel({ builderModel } = {}) {
|
|
|
112
114
|
// back to graderModel) when it lands on the BUILDER's own family: tiering is
|
|
113
115
|
// a cost lever, never a hole in the ADR 0024 cross-model guarantee.
|
|
114
116
|
const MODEL_FAMILY_TOKENS = ['fable', 'mythos', 'opus', 'sonnet', 'haiku'];
|
|
117
|
+
|
|
118
|
+
// task 1002840: the judge table — which grader model each builder family gets.
|
|
119
|
+
// Read from the rubric (v4.judge_by_builder_family) and VALIDATED at load. A
|
|
120
|
+
// row is refused when its judge shares a family with a builder it serves (the
|
|
121
|
+
// ADR 0024 cross-model guarantee: no resolution may land the judge in the
|
|
122
|
+
// builder's own family) or is not a plain Claude model id — the GATING panel
|
|
123
|
+
// stays on Claude, the quality floor ADR 0089 keeps; an open model joins only as
|
|
124
|
+
// an advisory judge (task 1003858). A table that fails is replaced WHOLE by the
|
|
125
|
+
// built-in one below, which is the mapping the old if/else hard-coded, so a bad
|
|
126
|
+
// rubric edit degrades to today's behaviour instead of to a throw at grade time.
|
|
127
|
+
const BUILTIN_JUDGE_TABLE = Object.freeze([
|
|
128
|
+
Object.freeze({ builder_families: Object.freeze(['fable', 'mythos', 'opus']), judge_model: 'claude-sonnet-5' }),
|
|
129
|
+
Object.freeze({ builder_families: Object.freeze(['sonnet', 'haiku']), judge_model: 'claude-opus-5' }),
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
function judgeTableProblems(table) {
|
|
133
|
+
if (!Array.isArray(table) || table.length === 0) return ['judge_by_builder_family must be a non-empty array'];
|
|
134
|
+
const problems = [];
|
|
135
|
+
table.forEach((row, i) => {
|
|
136
|
+
const fams = row && Array.isArray(row.builder_families) ? row.builder_families : null;
|
|
137
|
+
const judge = row && typeof row.judge_model === 'string' ? row.judge_model.toLowerCase() : null;
|
|
138
|
+
if (!fams || fams.length === 0 || !fams.every((f) => MODEL_FAMILY_TOKENS.includes(f))) {
|
|
139
|
+
problems.push(`row ${i}: builder_families must list family tokens (${MODEL_FAMILY_TOKENS.join('/')})`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const judgeFamily = judge && !judge.includes(':') ? MODEL_FAMILY_TOKENS.find((t) => judge.includes(t)) : null;
|
|
143
|
+
if (!judgeFamily) problems.push(`row ${i}: judge_model must be a plain Claude model id (the gating panel stays on Claude)`);
|
|
144
|
+
else if (fams.includes(judgeFamily)) problems.push(`row ${i}: judge '${row.judge_model}' is in the '${judgeFamily}' family it would grade (ADR 0024)`);
|
|
145
|
+
});
|
|
146
|
+
return problems;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function resolveJudgeTable(table) {
|
|
150
|
+
const problems = judgeTableProblems(table);
|
|
151
|
+
if (problems.length === 0) return table;
|
|
152
|
+
console.error(`grader: rubric v4.judge_by_builder_family refused (${problems.join('; ')}) — grading on the built-in judge table`);
|
|
153
|
+
return BUILTIN_JUDGE_TABLE;
|
|
154
|
+
}
|
|
155
|
+
const JUDGE_TABLE = resolveJudgeTable(V4.judge_by_builder_family);
|
|
156
|
+
|
|
115
157
|
function workerModelFor(kind, graderModel, builderModel) {
|
|
116
158
|
const tier = WORKER_MODEL_TIERS[kind];
|
|
117
159
|
if (!tier) return graderModel;
|
|
@@ -137,12 +179,15 @@ const LLM_PROJECT = (() => {
|
|
|
137
179
|
|
|
138
180
|
|
|
139
181
|
module.exports = {
|
|
182
|
+
BUILTIN_JUDGE_TABLE,
|
|
183
|
+
JUDGE_TABLE,
|
|
140
184
|
LLM_PROJECT,
|
|
141
185
|
MODEL_FAMILY_TOKENS,
|
|
142
186
|
TRIVIAL_MAX_FILES,
|
|
143
187
|
TRIVIAL_MAX_LINES,
|
|
144
188
|
clamp,
|
|
145
189
|
isTrivialDiff,
|
|
190
|
+
judgeTableProblems,
|
|
146
191
|
median,
|
|
147
192
|
r1,
|
|
148
193
|
selectGraderModel,
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "The core's neutral starter for grader model providers (task 1002840, ADR 0089 §3): NONE, so a vanilla instance grades on Claude alone. An instance declares its own in config/grader-providers.json at the instance root. Each entry is { kind: 'openai-compatible', base_url: https://…, api_key_env: <ENV VAR NAME, never the key>, price_per_mtok: { <model>: { input, output } } }. Declaring one is an owner decision: it sends the diff under review to that vendor and costs money per call.",
|
|
3
|
+
"providers": {}
|
|
4
|
+
}
|
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
"active_panel": ["narc", "quality", "hacker", "efficiency"],
|
|
15
15
|
"worker_model_tiers": { "quality": "claude-sonnet-5", "reader": "claude-sonnet-5" },
|
|
16
16
|
"worker_model_tiers_note": "task 1002660: per-worker cost tiering. Quality was 41% of panel spend with 75% nit findings — its cq/economy lens ran on the haiku tier. task 1003318: moved to claude-sonnet-5 — haiku 4.5's 200k window overflows on any diff over ~150 KB (`Prompt is too long`, deterministic; tasks 1003297 and 1003298 each lost three ~$5 panel runs to it) and a Quality worker that never starts is missing signal on a GATING lens. For fable/opus builders the panel model is already claude-sonnet-5, so the tier coincides with default_model and acts as a floor — a future default_model bump must not pin Quality back onto a smaller window; it stays a live cost lever only for haiku builders (whose panel runs on opus-5). workerModelFor refuses an override that lands on the builder's own model family (the ADR 0024 cross-model guarantee outranks the cost lever); gate semantics unchanged — Quality's verdict still gates identically. task 1003977: the READER is pinned to claude-sonnet-5 for the same task-1003318 reason — it is Read-heavy by construction (the voice guide, then the render paths that decide whether a string is user-facing), and a small window fails deterministically with PROMPT_TOO_LONG. For a NON-GATING worker that means silently reading no copy at all on exactly the biggest copy diffs, with nothing blocking to reveal it.",
|
|
17
|
+
"judge_by_builder_family": [
|
|
18
|
+
{ "builder_families": ["fable", "mythos", "opus"], "judge_model": "claude-sonnet-5" },
|
|
19
|
+
{ "builder_families": ["sonnet", "haiku"], "judge_model": "claude-opus-5" }
|
|
20
|
+
],
|
|
21
|
+
"judge_by_builder_family_note": "task 1002840 (ADR 0089 §3): the cross-family judge choice selectGraderModel used to hard-code as an if/else — the first row whose family token appears in the builder's model id wins. Validated at load (grader-model.js judgeTableProblems): a judge in the family it grades (ADR 0024) or anything but a plain Claude id is refused and the whole table falls back to the built-in copy of exactly these two rows. An unmatched builder still grades on default_model with a warning (task 1002659).",
|
|
17
22
|
"fallthrough_confidence": 0.3,
|
|
18
23
|
"low_confidence_threshold": 0.4,
|
|
19
24
|
"rationale": "GDS-V3 task #410 (MAS Phase 2.0), 2026-05-29. Supersedes v3 (single-prompt 3-shot generalist). Phase 1 calibration showed minimal between-shot disagreement on the same prompt — the strict gate fired correctly but the diversity signal was weak. v4 trades sampling-variance for lens-specialization: replace 3 identical-prompt shots with N workers (each a distinct prompt focused on one axis). Phase 2.0 ships 2 of the 3 planned workers (narc for completion, quality for cq + advisory economy) — keeps the gate at the same strictness while the framework calibrates. Phase 2.1 adds hacker (security; wraps /builder-redteam as a CI gate). Phase 2.2 adds efficiency_monger.",
|
|
@@ -12,8 +12,13 @@ const path = require('node:path');
|
|
|
12
12
|
const fs = require('node:fs');
|
|
13
13
|
const { spawn } = require('node:child_process');
|
|
14
14
|
const { DEFAULT_GRADER_MODEL, api } = require('./grader-config.js');
|
|
15
|
+
// task 1002840: the ModelProvider port (ADR 0089 §3) — runSubagent below routes
|
|
16
|
+
// through it; the Claude spawn is its default provider.
|
|
17
|
+
const modelProvider = require('./model-provider.js');
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
// The `claude -p` spawn — the anthropic-cli ModelProvider. Body unchanged by
|
|
20
|
+
// task 1002840; only its name moved, so runSubagent could become the port.
|
|
21
|
+
function runClaudeCli({ prompt, opts = {} }) {
|
|
17
22
|
return new Promise((resolve, reject) => {
|
|
18
23
|
const timeoutMs = opts.timeoutMs ?? 240_000;
|
|
19
24
|
const cwd = opts.cwd ?? process.cwd();
|
|
@@ -119,6 +124,17 @@ function runSubagent({ prompt, opts = {} }) {
|
|
|
119
124
|
child.stdin.end();
|
|
120
125
|
});
|
|
121
126
|
}
|
|
127
|
+
modelProvider.registerModelProvider({ name: modelProvider.DEFAULT_PROVIDER, kind: 'anthropic-cli', run: runClaudeCli });
|
|
128
|
+
|
|
129
|
+
// runSubagent — the ModelProvider port every platform LLM call goes through
|
|
130
|
+
// (the panel workers, the no-artifact shots, architect-audit, the agents module
|
|
131
|
+
// via the grade port). A model with no registered provider prefix — every id in
|
|
132
|
+
// use today — runs on the Claude spawn exactly as before; `<provider>:<model>`
|
|
133
|
+
// runs on that provider, which answers in the same envelope + error codes.
|
|
134
|
+
function runSubagent({ prompt, opts = {} }) {
|
|
135
|
+
const ref = modelProvider.parseModelRef(opts.model ?? DEFAULT_GRADER_MODEL);
|
|
136
|
+
return modelProvider.resolveModelProvider(ref.provider).run({ prompt, opts: { ...opts, model: ref.model } });
|
|
137
|
+
}
|
|
122
138
|
|
|
123
139
|
// ---------- task 1002660: shared context bundle ----------
|
|
124
140
|
//
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// modules/grading/model-provider.js — the ModelProvider port (ADR 0089 §3,
|
|
2
|
+
// task 1002840): how a judge's model is called, as an adapter behind one seam
|
|
3
|
+
// instead of a hard-coded `claude` spawn.
|
|
4
|
+
//
|
|
5
|
+
// WHY. Every platform LLM call funnels through runSubagent (grader-subagent.js):
|
|
6
|
+
// the panel workers, the no-artifact shots, architect-audit, and the agents
|
|
7
|
+
// module through the grade port. Until this file that function WAS the Claude
|
|
8
|
+
// CLI spawn, so a judge from another model family — the property ADR 0089's
|
|
9
|
+
// ensemble exists for — had no way in. Now a call names a provider and the
|
|
10
|
+
// provider makes it. Every provider answers in the SAME shape the Claude spawn
|
|
11
|
+
// always has: a `claude -p --output-format json`-style envelope on stdout and the
|
|
12
|
+
// same error codes (SUBAGENT_SPAWN_FAILED / SUBAGENT_TIMEOUT /
|
|
13
|
+
// SUBAGENT_NONZERO_EXIT). So nothing downstream changes — runWorker's retry, the
|
|
14
|
+
// outage detection, extractAssistantResult / extractCostUsd, the LLM cache.
|
|
15
|
+
//
|
|
16
|
+
// THE MODEL REFERENCE. A model is still ONE STRING everywhere it travels
|
|
17
|
+
// (signals.workers[].model, grader_model, the cache key). A string with no
|
|
18
|
+
// provider prefix is an anthropic-cli model — every id this project uses today —
|
|
19
|
+
// so today's calls are byte-identical. `<provider>:<model>` routes to a
|
|
20
|
+
// REGISTERED provider (`deepinfra:deepseek-ai/DeepSeek-V3.1`,
|
|
21
|
+
// `ollama:qwen2.5-coder:32b`). Only a prefix that names a registered provider is
|
|
22
|
+
// split off; anything else stays a Claude model id, so a colon inside an id can
|
|
23
|
+
// never re-route a call by accident.
|
|
24
|
+
//
|
|
25
|
+
// PROVIDERS.
|
|
26
|
+
// anthropic-cli the `claude -p` spawn (grader-subagent.js registers it).
|
|
27
|
+
// The default, and the only provider shipped config uses.
|
|
28
|
+
// openai-compatible one adapter for DeepInfra / OpenRouter / Together /
|
|
29
|
+
// Fireworks / Groq / vLLM / Ollama — POST
|
|
30
|
+
// <base_url>/chat/completions with a BYOK key read from the
|
|
31
|
+
// env var the instance names. PROMPT-ONLY: it has no Read
|
|
32
|
+
// tool, so such a judge sees exactly what its prompt carries.
|
|
33
|
+
//
|
|
34
|
+
// INSTANCE CONFIG (the ADR 0062 host-owned pattern). Providers are declared in
|
|
35
|
+
// config/grader-providers.json at the instance root; the core ships
|
|
36
|
+
// grader-providers.neutral.json beside this file with NO providers, so a vanilla
|
|
37
|
+
// instance is Claude-only. A key never lives in a config file — only the NAME
|
|
38
|
+
// of the env var holding it. Declaring a provider is an owner decision: it sends
|
|
39
|
+
// the diff under review to that vendor and it costs money per call (ADR 0089 §3).
|
|
40
|
+
|
|
41
|
+
'use strict';
|
|
42
|
+
|
|
43
|
+
const fs = require('node:fs');
|
|
44
|
+
const path = require('node:path');
|
|
45
|
+
const api = require('../../src/module-api');
|
|
46
|
+
|
|
47
|
+
const DEFAULT_PROVIDER = 'anthropic-cli';
|
|
48
|
+
const PROVIDER_NAME_RE = /^[a-z][a-z0-9-]{1,39}$/;
|
|
49
|
+
const API_KEY_ENV_RE = /^[A-Z][A-Z0-9_]{1,63}$/;
|
|
50
|
+
const KINDS = new Set(['openai-compatible']);
|
|
51
|
+
const DEFAULT_TIMEOUT_MS = 240_000;
|
|
52
|
+
const ERROR_BODY_MAX = 2000;
|
|
53
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
|
54
|
+
|
|
55
|
+
const providers = new Map(); // name -> { name, kind, run }
|
|
56
|
+
|
|
57
|
+
// One place a refusal is reported, so a malformed instance config is loud
|
|
58
|
+
// without scattering console calls (the fitness console ratchet counts them).
|
|
59
|
+
function refuse(msg) {
|
|
60
|
+
console.error(`grade model-provider: ${msg}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function registerModelProvider(provider) {
|
|
64
|
+
if (!provider || typeof provider.run !== 'function' || typeof provider.name !== 'string' || !provider.name) {
|
|
65
|
+
throw new TypeError('registerModelProvider: a provider needs a name and a run({ prompt, opts }) function');
|
|
66
|
+
}
|
|
67
|
+
const held = providers.get(provider.name);
|
|
68
|
+
if (held && held !== provider) throw new Error(`model provider '${provider.name}' is already registered`);
|
|
69
|
+
providers.set(provider.name, provider);
|
|
70
|
+
return provider;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resolveModelProvider(name = DEFAULT_PROVIDER) {
|
|
74
|
+
const p = providers.get(name);
|
|
75
|
+
if (!p) throw new Error(`model provider '${name}' is not registered`);
|
|
76
|
+
return p;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function listModelProviders() {
|
|
80
|
+
return [...providers.keys()].sort();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// `<provider>:<model>` → { provider, model }, split ONLY when the prefix names a
|
|
84
|
+
// registered provider other than the default; everything else is one Claude id.
|
|
85
|
+
function parseModelRef(ref) {
|
|
86
|
+
const s = String(ref == null ? '' : ref);
|
|
87
|
+
const i = s.indexOf(':');
|
|
88
|
+
if (i > 0) {
|
|
89
|
+
const name = s.slice(0, i);
|
|
90
|
+
if (name !== DEFAULT_PROVIDER && providers.has(name)) return { provider: name, model: s.slice(i + 1) };
|
|
91
|
+
}
|
|
92
|
+
return { provider: DEFAULT_PROVIDER, model: s };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function codeError(code, message, extra = {}) {
|
|
96
|
+
return Object.assign(new Error(message), { code, ...extra });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// base_url must be https, or plain http to THIS machine only (a local vLLM /
|
|
100
|
+
// Ollama): a key must never cross a network in cleartext.
|
|
101
|
+
function checkBaseUrl(raw) {
|
|
102
|
+
let u;
|
|
103
|
+
try { u = new URL(String(raw)); } catch { return null; }
|
|
104
|
+
const loopback = LOOPBACK_HOSTS.has(u.hostname);
|
|
105
|
+
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && loopback)) return null;
|
|
106
|
+
if (u.username || u.password || u.search || u.hash) return null;
|
|
107
|
+
return { url: u.toString().replace(/\/+$/, ''), loopback };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Validate one declared provider: { def } normalised, or { error } saying why
|
|
111
|
+
// not — never throws, so one bad entry cannot take the grader down.
|
|
112
|
+
function validateProviderDef(name, def) {
|
|
113
|
+
if (!PROVIDER_NAME_RE.test(name) || name === DEFAULT_PROVIDER) return { error: `provider name '${name}' must match ${PROVIDER_NAME_RE} and not be '${DEFAULT_PROVIDER}'` };
|
|
114
|
+
if (!def || typeof def !== 'object') return { error: `provider '${name}' must be an object` };
|
|
115
|
+
if (!KINDS.has(def.kind)) return { error: `provider '${name}': kind must be one of ${[...KINDS].join(', ')}` };
|
|
116
|
+
const base = checkBaseUrl(def.base_url);
|
|
117
|
+
if (!base) return { error: `provider '${name}': base_url must be https:// (http:// only to localhost), with no credentials or query` };
|
|
118
|
+
const keyEnv = def.api_key_env == null ? null : String(def.api_key_env);
|
|
119
|
+
if (keyEnv != null && !API_KEY_ENV_RE.test(keyEnv)) return { error: `provider '${name}': api_key_env must be an env var NAME (${API_KEY_ENV_RE}) — never the key itself` };
|
|
120
|
+
if (keyEnv == null && !base.loopback) return { error: `provider '${name}': a remote provider needs api_key_env` };
|
|
121
|
+
const prices = {};
|
|
122
|
+
for (const [model, p] of Object.entries(def.price_per_mtok || {})) {
|
|
123
|
+
const input = Number(p && p.input);
|
|
124
|
+
const output = Number(p && p.output);
|
|
125
|
+
if (!(Number.isFinite(input) && input >= 0 && Number.isFinite(output) && output >= 0)) {
|
|
126
|
+
return { error: `provider '${name}': price_per_mtok['${model}'] needs non-negative input and output` };
|
|
127
|
+
}
|
|
128
|
+
prices[model] = { input, output };
|
|
129
|
+
}
|
|
130
|
+
return { def: { name, kind: def.kind, baseUrl: base.url, apiKeyEnv: keyEnv, prices } };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// USD for one call from the reply's token usage, or null when the model has no
|
|
134
|
+
// declared price — a cost signal never fabricates a number it does not have.
|
|
135
|
+
function priceCall(prices, model, usage) {
|
|
136
|
+
const p = prices[model];
|
|
137
|
+
if (!p || !usage) return null;
|
|
138
|
+
const inTok = Number(usage.prompt_tokens);
|
|
139
|
+
const outTok = Number(usage.completion_tokens);
|
|
140
|
+
if (!Number.isFinite(inTok) || !Number.isFinite(outTok)) return null;
|
|
141
|
+
return (inTok * p.input + outTok * p.output) / 1_000_000;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The openai-compatible adapter. `fetchImpl` and `env` are injectable so the
|
|
145
|
+
// whole adapter is testable against a stub with no key and no network.
|
|
146
|
+
function createOpenAICompatibleProvider(def, { fetchImpl = globalThis.fetch, env = process.env } = {}) {
|
|
147
|
+
const run = async ({ prompt, opts = {} }) => {
|
|
148
|
+
const started = Date.now();
|
|
149
|
+
const model = String(opts.model || '');
|
|
150
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
151
|
+
const key = def.apiKeyEnv ? env[def.apiKeyEnv] : null;
|
|
152
|
+
// Refused BEFORE any request: no key means nothing is sent anywhere.
|
|
153
|
+
if (def.apiKeyEnv && !key) {
|
|
154
|
+
throw codeError('SUBAGENT_SPAWN_FAILED', `provider ${def.name}: no API key — set ${def.apiKeyEnv} (bring-your-own key)`, { run_ms: 0, stdout: '', stderr: '' });
|
|
155
|
+
}
|
|
156
|
+
const scrub = (t) => (key ? String(t).split(key).join('[redacted]') : String(t));
|
|
157
|
+
const controller = new AbortController();
|
|
158
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
159
|
+
let res;
|
|
160
|
+
let text;
|
|
161
|
+
try {
|
|
162
|
+
res = await fetchImpl(`${def.baseUrl}/chat/completions`, {
|
|
163
|
+
method: 'POST',
|
|
164
|
+
headers: { 'content-type': 'application/json', ...(key ? { authorization: `Bearer ${key}` } : {}) },
|
|
165
|
+
body: JSON.stringify({ model, messages: [{ role: 'user', content: String(prompt) }] }),
|
|
166
|
+
signal: controller.signal,
|
|
167
|
+
});
|
|
168
|
+
text = await res.text();
|
|
169
|
+
} catch (err) {
|
|
170
|
+
const run_ms = Date.now() - started;
|
|
171
|
+
if (controller.signal.aborted) {
|
|
172
|
+
throw codeError('SUBAGENT_TIMEOUT', `provider ${def.name}: timed out after ${timeoutMs}ms`, { run_ms, stdout: '', stderr: '' });
|
|
173
|
+
}
|
|
174
|
+
throw codeError('SUBAGENT_SPAWN_FAILED', `provider ${def.name}: request failed: ${scrub(err && err.message)}`, { run_ms, stdout: '', stderr: scrub(err && err.message) });
|
|
175
|
+
} finally {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
}
|
|
178
|
+
const run_ms = Date.now() - started;
|
|
179
|
+
if (!res.ok) {
|
|
180
|
+
throw codeError('SUBAGENT_NONZERO_EXIT', `provider ${def.name}: HTTP ${res.status}`, {
|
|
181
|
+
exit_code: res.status, run_ms, stdout: '', stderr: scrub(text).slice(0, ERROR_BODY_MAX),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
let body = null;
|
|
185
|
+
try { body = JSON.parse(text); } catch { /* an unparseable 200 → empty result → the worker's parse_error path */ }
|
|
186
|
+
const content = body && body.choices && body.choices[0] && body.choices[0].message
|
|
187
|
+
? body.choices[0].message.content : null;
|
|
188
|
+
const usage = body && body.usage ? body.usage : null;
|
|
189
|
+
const cost = priceCall(def.prices, model, usage);
|
|
190
|
+
const envelope = {
|
|
191
|
+
type: 'result',
|
|
192
|
+
result: typeof content === 'string' ? content : '',
|
|
193
|
+
...(cost != null ? { total_cost_usd: cost } : {}),
|
|
194
|
+
provider: def.name,
|
|
195
|
+
model,
|
|
196
|
+
usage,
|
|
197
|
+
};
|
|
198
|
+
return { stdout: JSON.stringify(envelope), stderr: '', run_ms, exit_code: 0, cost_usd: cost };
|
|
199
|
+
};
|
|
200
|
+
return { name: def.name, kind: def.kind, run };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------- instance config ----------
|
|
204
|
+
//
|
|
205
|
+
// The host's config/grader-providers.json, else the neutral starter beside this
|
|
206
|
+
// file (which declares none). A missing instance file is the ordinary case; one
|
|
207
|
+
// that does not parse falls back to the neutral starter — fail to vanilla, never
|
|
208
|
+
// to dead — and each invalid entry is refused by name while the rest load.
|
|
209
|
+
const NEUTRAL_PATH = path.join(__dirname, 'grader-providers.neutral.json');
|
|
210
|
+
const INSTANCE_PATH = path.join(api.resolveInstanceRoot(), 'config', 'grader-providers.json');
|
|
211
|
+
|
|
212
|
+
function readPack(file) {
|
|
213
|
+
let raw;
|
|
214
|
+
try { raw = fs.readFileSync(file, 'utf8'); } catch { return null; }
|
|
215
|
+
try {
|
|
216
|
+
const parsed = JSON.parse(raw);
|
|
217
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
218
|
+
} catch { return null; }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function loadProviderConfig({ instancePath = INSTANCE_PATH, neutralPath = NEUTRAL_PATH } = {}) {
|
|
222
|
+
const instance = readPack(instancePath);
|
|
223
|
+
const pack = instance || readPack(neutralPath) || {};
|
|
224
|
+
const defs = [];
|
|
225
|
+
const refused = [];
|
|
226
|
+
const declared = pack.providers && typeof pack.providers === 'object' && !Array.isArray(pack.providers) ? pack.providers : {};
|
|
227
|
+
for (const [name, def] of Object.entries(declared)) {
|
|
228
|
+
const v = validateProviderDef(name, def);
|
|
229
|
+
if (v.error) refused.push(v.error); else defs.push(v.def);
|
|
230
|
+
}
|
|
231
|
+
return { source: instance ? instancePath : neutralPath, defs, refused };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Register every provider the instance declares. Idempotent: an already
|
|
235
|
+
// registered name is left alone. Returns the provider names now resolvable.
|
|
236
|
+
function registerConfiguredProviders(opts = {}) {
|
|
237
|
+
const { defs, refused } = loadProviderConfig(opts);
|
|
238
|
+
for (const reason of refused) refuse(`${reason} — refused, grading continues without it`);
|
|
239
|
+
for (const def of defs) {
|
|
240
|
+
if (!providers.has(def.name)) registerModelProvider(createOpenAICompatibleProvider(def, opts));
|
|
241
|
+
}
|
|
242
|
+
return listModelProviders();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
registerConfiguredProviders();
|
|
246
|
+
|
|
247
|
+
module.exports = {
|
|
248
|
+
DEFAULT_PROVIDER,
|
|
249
|
+
createOpenAICompatibleProvider,
|
|
250
|
+
listModelProviders,
|
|
251
|
+
loadProviderConfig,
|
|
252
|
+
parseModelRef,
|
|
253
|
+
registerConfiguredProviders,
|
|
254
|
+
registerModelProvider,
|
|
255
|
+
resolveModelProvider,
|
|
256
|
+
validateProviderDef,
|
|
257
|
+
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1038",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.1038",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1038",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -7507,5 +7507,11 @@
|
|
|
7507
7507
|
"id": "1004280",
|
|
7508
7508
|
"text": "The project's own notes no longer claim a past decision banned all new checks; each one now gives its real reason, and a test stops the wrong claim coming back."
|
|
7509
7509
|
}
|
|
7510
|
+
],
|
|
7511
|
+
"1.19.1038": [
|
|
7512
|
+
{
|
|
7513
|
+
"id": "1002840",
|
|
7514
|
+
"text": "The grader can now call AI models from other companies through one plug-in point, ready for a second-opinion reviewer; nothing changes today — it still uses only Claude and spends nothing new."
|
|
7515
|
+
}
|
|
7510
7516
|
]
|
|
7511
7517
|
}
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1038'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -305,6 +305,20 @@ const ADDED_SINCE_R101 = [
|
|
|
305
305
|
+ 'deterministic veto, so an edit here can switch off the protected-path gate '
|
|
306
306
|
+ 'for every grade at once.',
|
|
307
307
|
},
|
|
308
|
+
// task 1002840 (ADR 0089 §3): the ModelProvider port and its neutral config.
|
|
309
|
+
{
|
|
310
|
+
after: 'modules/grading/grade-engine.js',
|
|
311
|
+
glob: 'modules/grading/model-provider.js',
|
|
312
|
+
why: 'the ModelProvider port every grader model call goes through. It decides which '
|
|
313
|
+
+ 'vendor receives the diff under review and with which key, so an edit here can '
|
|
314
|
+
+ 're-route or blind the whole panel.',
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
after: 'modules/grading/model-provider.js',
|
|
318
|
+
glob: 'modules/grading/grader-providers.neutral.json',
|
|
319
|
+
why: 'the core neutral provider set, which declares none. An entry added here would '
|
|
320
|
+
+ 'send every instance\'s diffs to a vendor by default.',
|
|
321
|
+
},
|
|
308
322
|
];
|
|
309
323
|
|
|
310
324
|
// Surfaces RENAMED since R101 — still protected, under a new path. Distinct from
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// tests/grade_model_provider.mjs — the ModelProvider port + the openai-compatible
|
|
2
|
+
// adapter (task 1002840, ADR 0089 §3).
|
|
3
|
+
//
|
|
4
|
+
// Pins: (1) every model id in use today still runs on the Claude spawn, with the
|
|
5
|
+
// identical argv; (2) the openai-compatible adapter against a LOCAL STUB server —
|
|
6
|
+
// request shape, envelope, cost, and every failure mapped to the error codes the
|
|
7
|
+
// worker runner already understands — with no real key and no network; (3) a key
|
|
8
|
+
// that is missing sends nothing, and a key never leaks into an error; (4) the
|
|
9
|
+
// config loader fails to vanilla; (5) the judge table reproduces the old if/else
|
|
10
|
+
// and refuses a same-family or non-Claude judge (ADR 0024 / the Claude floor).
|
|
11
|
+
//
|
|
12
|
+
// Run: node --test tests/grade_model_provider.mjs
|
|
13
|
+
|
|
14
|
+
import { test } from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import http from 'node:http';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const subagent = require('../modules/grading/grader-subagent.js');
|
|
24
|
+
const mp = require('../modules/grading/model-provider.js');
|
|
25
|
+
const model = require('../modules/grading/grader-model.js');
|
|
26
|
+
const workers = require('../modules/grading/grader-workers');
|
|
27
|
+
|
|
28
|
+
// A local OpenAI-shaped server. `reply(req, body)` returns { status, json|text, delayMs }.
|
|
29
|
+
async function stubServer(reply) {
|
|
30
|
+
const seen = [];
|
|
31
|
+
const server = http.createServer((req, res) => {
|
|
32
|
+
let raw = '';
|
|
33
|
+
req.on('data', (d) => { raw += d; });
|
|
34
|
+
req.on('end', async () => {
|
|
35
|
+
const body = raw ? JSON.parse(raw) : null;
|
|
36
|
+
seen.push({ method: req.method, url: req.url, headers: req.headers, body });
|
|
37
|
+
const r = reply(req, body) || {};
|
|
38
|
+
if (r.delayMs) await new Promise((ok) => setTimeout(ok, r.delayMs));
|
|
39
|
+
res.writeHead(r.status || 200, { 'content-type': 'application/json' });
|
|
40
|
+
res.end(r.text != null ? r.text : JSON.stringify(r.json));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
|
|
44
|
+
const { port } = server.address();
|
|
45
|
+
return { seen, base: `http://127.0.0.1:${port}/v1`, close: () => new Promise((ok) => server.close(ok)) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const KEY = 'test-key-not-a-real-secret-0123456789';
|
|
49
|
+
function provider(base, extra = {}, env = { STUB_LLM_KEY: KEY }) {
|
|
50
|
+
const v = mp.validateProviderDef('stub', { kind: 'openai-compatible', base_url: base, api_key_env: 'STUB_LLM_KEY', ...extra });
|
|
51
|
+
assert.ok(v.def, v.error);
|
|
52
|
+
return mp.createOpenAICompatibleProvider(v.def, { env });
|
|
53
|
+
}
|
|
54
|
+
const completion = (content, usage = { prompt_tokens: 1000, completion_tokens: 200 }) => ({ json: { choices: [{ message: { role: 'assistant', content } }], usage } });
|
|
55
|
+
|
|
56
|
+
test('parseModelRef: every id in use today is a Claude model; only a REGISTERED prefix routes elsewhere', () => {
|
|
57
|
+
for (const id of ['claude-sonnet-5', 'claude-opus-5', 'claude-fable-5', '', 'x:y']) {
|
|
58
|
+
assert.deepEqual(mp.parseModelRef(id), { provider: 'anthropic-cli', model: id });
|
|
59
|
+
}
|
|
60
|
+
mp.registerModelProvider({ name: 'test-ollama', kind: 'openai-compatible', run: async () => ({}) });
|
|
61
|
+
assert.deepEqual(mp.parseModelRef('test-ollama:qwen2.5-coder:32b'), { provider: 'test-ollama', model: 'qwen2.5-coder:32b' },
|
|
62
|
+
'split at the FIRST colon only — a colon inside the model id survives');
|
|
63
|
+
assert.deepEqual(mp.parseModelRef('anthropic-cli:claude-sonnet-5'), { provider: 'anthropic-cli', model: 'anthropic-cli:claude-sonnet-5' },
|
|
64
|
+
'the default is never a prefix');
|
|
65
|
+
assert.ok(mp.listModelProviders().includes('anthropic-cli'));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('the Claude spawn is the default provider, with the identical argv', { skip: process.platform === 'win32' && 'the fake CLI is a shebang script' }, async () => {
|
|
69
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'grade-provider-'));
|
|
70
|
+
const bin = path.join(dir, 'claude');
|
|
71
|
+
fs.writeFileSync(bin, '#!/usr/bin/env node\nprocess.stdin.resume(); process.stdin.on("end", () => process.stdout.write(JSON.stringify({ type: "result", result: JSON.stringify(process.argv.slice(2)), total_cost_usd: 0.5 })));\n');
|
|
72
|
+
fs.chmodSync(bin, 0o755);
|
|
73
|
+
try {
|
|
74
|
+
const r = await subagent.runSubagent({ prompt: 'hi', opts: { claudeBin: bin, model: 'claude-sonnet-5', cwd: dir } });
|
|
75
|
+
const argv = JSON.parse(subagent.extractAssistantResult(r.stdout));
|
|
76
|
+
const perm = typeof process.getuid === 'function' && process.getuid() === 0 ? 'acceptEdits' : 'bypassPermissions';
|
|
77
|
+
assert.deepEqual(argv, ['-p', '--print', '--model', 'claude-sonnet-5', '--output-format', 'json', '--tools', 'Read', '--disable-slash-commands', '--permission-mode', perm]);
|
|
78
|
+
assert.equal(r.cost_usd, 0.5);
|
|
79
|
+
const d = await subagent.runSubagent({ prompt: 'hi', opts: { claudeBin: bin, cwd: dir } });
|
|
80
|
+
assert.equal(JSON.parse(subagent.extractAssistantResult(d.stdout))[3], 'claude-sonnet-5', 'no model → the default grader model, as before');
|
|
81
|
+
} finally {
|
|
82
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('openai-compatible: request shape, envelope and cost — against a local stub', async () => {
|
|
87
|
+
const s = await stubServer(() => completion('{"code_quality": 8}'));
|
|
88
|
+
try {
|
|
89
|
+
const p = provider(s.base, { price_per_mtok: { 'deepseek-x': { input: 0.25, output: 1 } } });
|
|
90
|
+
const r = await p.run({ prompt: 'PROMPT TEXT', opts: { model: 'deepseek-x', timeoutMs: 5000 } });
|
|
91
|
+
assert.equal(s.seen.length, 1);
|
|
92
|
+
assert.equal(s.seen[0].method, 'POST');
|
|
93
|
+
assert.equal(s.seen[0].url, '/v1/chat/completions');
|
|
94
|
+
assert.equal(s.seen[0].headers.authorization, `Bearer ${KEY}`);
|
|
95
|
+
assert.deepEqual(s.seen[0].body, { model: 'deepseek-x', messages: [{ role: 'user', content: 'PROMPT TEXT' }] });
|
|
96
|
+
// The same envelope contract the Claude spawn honours, so nothing downstream changes.
|
|
97
|
+
assert.equal(subagent.extractAssistantResult(r.stdout), '{"code_quality": 8}');
|
|
98
|
+
const cost = (1000 * 0.25 + 200 * 1) / 1e6;
|
|
99
|
+
assert.equal(subagent.extractCostUsd(r.stdout), cost);
|
|
100
|
+
assert.equal(r.cost_usd, cost);
|
|
101
|
+
assert.equal(r.exit_code, 0);
|
|
102
|
+
const unpriced = await p.run({ prompt: 'x', opts: { model: 'other-model' } });
|
|
103
|
+
assert.equal(unpriced.cost_usd, null, 'no declared price → no cost number, never a guess');
|
|
104
|
+
assert.equal(subagent.extractCostUsd(unpriced.stdout), null);
|
|
105
|
+
} finally { await s.close(); }
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('openai-compatible: failures map to the codes the worker runner already retries or reports', async () => {
|
|
109
|
+
const s = await stubServer((req, body) => {
|
|
110
|
+
if (body.model === 'http-500') return { status: 500, text: `upstream says ${KEY} is invalid` };
|
|
111
|
+
if (body.model === 'slow') return { delayMs: 300, ...completion('late') };
|
|
112
|
+
if (body.model === 'garbage') return { text: 'not json at all' };
|
|
113
|
+
return completion('ok');
|
|
114
|
+
});
|
|
115
|
+
try {
|
|
116
|
+
const p = provider(s.base);
|
|
117
|
+
await assert.rejects(p.run({ prompt: 'x', opts: { model: 'http-500' } }), (e) => {
|
|
118
|
+
assert.equal(e.code, 'SUBAGENT_NONZERO_EXIT');
|
|
119
|
+
assert.equal(e.exit_code, 500);
|
|
120
|
+
assert.ok(!e.stderr.includes(KEY) && e.stderr.includes('[redacted]'), 'the key is scrubbed from what a grade row stores');
|
|
121
|
+
return true;
|
|
122
|
+
});
|
|
123
|
+
await assert.rejects(p.run({ prompt: 'x', opts: { model: 'slow', timeoutMs: 50 } }), (e) => e.code === 'SUBAGENT_TIMEOUT');
|
|
124
|
+
const g = await p.run({ prompt: 'x', opts: { model: 'garbage' } });
|
|
125
|
+
assert.equal(subagent.extractAssistantResult(g.stdout), '', 'an unparseable 200 is an empty result → the worker parse_error path, retried once');
|
|
126
|
+
} finally { await s.close(); }
|
|
127
|
+
const dead = provider(s.base);
|
|
128
|
+
await assert.rejects(dead.run({ prompt: 'x', opts: { model: 'm' } }), (e) => e.code === 'SUBAGENT_SPAWN_FAILED', 'a closed port is a spawn failure');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('a missing key sends NOTHING anywhere', async () => {
|
|
132
|
+
const s = await stubServer(() => completion('ok'));
|
|
133
|
+
try {
|
|
134
|
+
const p = provider(s.base, {}, {});
|
|
135
|
+
await assert.rejects(p.run({ prompt: 'the diff', opts: { model: 'm' } }), (e) => e.code === 'SUBAGENT_SPAWN_FAILED' && /set STUB_LLM_KEY/.test(e.message));
|
|
136
|
+
assert.equal(s.seen.length, 0);
|
|
137
|
+
} finally { await s.close(); }
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('an open-model judge runs through the SAME worker runner and verdict code', async () => {
|
|
141
|
+
const s = await stubServer(() => completion(JSON.stringify({ code_quality: 8, economy: 8, confidence: 0.8, issues: [], notes_md: 'fine' })));
|
|
142
|
+
try {
|
|
143
|
+
mp.registerModelProvider({ ...provider(s.base, { price_per_mtok: { 'q-coder': { input: 1, output: 1 } } }), name: 'test-stub' });
|
|
144
|
+
const r = await workers.runWorker(workers.quality, {
|
|
145
|
+
task: { id: 1, title: 't', description: 'd' }, valueSummary: 'v', diff: 'diff', changedFiles: [], diffStats: null,
|
|
146
|
+
model: 'test-stub:q-coder', runOpts: {}, runSubagentFn: subagent.runSubagent, extractAssistantResultFn: subagent.extractAssistantResult,
|
|
147
|
+
});
|
|
148
|
+
assert.equal(r.verdict, 'pass');
|
|
149
|
+
assert.equal(r.scores.code_quality, 8);
|
|
150
|
+
assert.equal(r.model, 'test-stub:q-coder', 'the record names the provider');
|
|
151
|
+
assert.equal(r.cost_usd, 1200 / 1e6);
|
|
152
|
+
assert.equal(s.seen[0].body.model, 'q-coder', 'the provider receives its own model id');
|
|
153
|
+
} finally { await s.close(); }
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('provider config: validated, and a bad instance file fails to vanilla', () => {
|
|
157
|
+
const bad = (name, def) => mp.validateProviderDef(name, def).error;
|
|
158
|
+
assert.match(bad('deepinfra', { kind: 'openai-compatible', base_url: 'http://api.example.com/v1', api_key_env: 'K_KEY' }), /https/);
|
|
159
|
+
assert.match(bad('deepinfra', { kind: 'openai-compatible', base_url: 'https://api.example.com/v1', api_key_env: 'sk-live-abc' }), /env var NAME/);
|
|
160
|
+
assert.match(bad('deepinfra', { kind: 'openai-compatible', base_url: 'https://api.example.com/v1' }), /needs api_key_env/);
|
|
161
|
+
assert.match(bad('anthropic-cli', { kind: 'openai-compatible', base_url: 'https://x.example/v1', api_key_env: 'K' }), /must match|not be/);
|
|
162
|
+
assert.match(bad('x-prov', { kind: 'grpc', base_url: 'https://x.example/v1', api_key_env: 'KK' }), /kind/);
|
|
163
|
+
assert.equal(bad('local-ollama', { kind: 'openai-compatible', base_url: 'http://localhost:11434/v1' }), undefined, 'keyless http is allowed to this machine only');
|
|
164
|
+
|
|
165
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'grade-provider-cfg-'));
|
|
166
|
+
try {
|
|
167
|
+
const neutral = path.join(gradingDir(), 'grader-providers.neutral.json');
|
|
168
|
+
assert.deepEqual(mp.loadProviderConfig({ instancePath: path.join(dir, 'absent.json'), neutralPath: neutral }).defs, [],
|
|
169
|
+
'the shipped neutral starter declares no provider — a vanilla instance is Claude-only');
|
|
170
|
+
const broken = path.join(dir, 'broken.json');
|
|
171
|
+
fs.writeFileSync(broken, '{ not json');
|
|
172
|
+
assert.equal(mp.loadProviderConfig({ instancePath: broken, neutralPath: neutral }).source, neutral);
|
|
173
|
+
const mixed = path.join(dir, 'mixed.json');
|
|
174
|
+
fs.writeFileSync(mixed, JSON.stringify({ providers: {
|
|
175
|
+
deepinfra: { kind: 'openai-compatible', base_url: 'https://api.deepinfra.com/v1/openai', api_key_env: 'DEEPINFRA_API_KEY' },
|
|
176
|
+
leaky: { kind: 'openai-compatible', base_url: 'http://api.example.com/v1', api_key_env: 'LEAKY_KEY' },
|
|
177
|
+
} }));
|
|
178
|
+
const cfg = mp.loadProviderConfig({ instancePath: mixed, neutralPath: neutral });
|
|
179
|
+
assert.deepEqual(cfg.defs.map((d) => d.name), ['deepinfra']);
|
|
180
|
+
assert.equal(cfg.refused.length, 1, 'one bad entry is refused by name; the rest load');
|
|
181
|
+
} finally {
|
|
182
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
function gradingDir() {
|
|
187
|
+
return path.dirname(require.resolve('../modules/grading/model-provider.js'));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
test('the judge table reproduces the old if/else, and refuses a same-family or non-Claude judge', () => {
|
|
191
|
+
const oldIfElse = (bm) => {
|
|
192
|
+
const s = bm.toLowerCase();
|
|
193
|
+
if (s.includes('fable') || s.includes('mythos')) return 'claude-sonnet-5';
|
|
194
|
+
if (s.includes('opus')) return 'claude-sonnet-5';
|
|
195
|
+
if (s.includes('sonnet')) return 'claude-opus-5';
|
|
196
|
+
if (s.includes('haiku')) return 'claude-opus-5';
|
|
197
|
+
return null;
|
|
198
|
+
};
|
|
199
|
+
const tokens = ['fable', 'mythos', 'opus', 'sonnet', 'haiku'];
|
|
200
|
+
const ids = ['claude-fable-5', 'claude-opus-5-5', 'claude-sonnet-5', 'claude-haiku-4-5', 'Claude-OPUS-4-8'];
|
|
201
|
+
for (const a of tokens) for (const b of tokens) ids.push(`claude-${a}-${b}`);
|
|
202
|
+
for (const id of ids) assert.equal(model.selectGraderModel({ builderModel: id }), oldIfElse(id), id);
|
|
203
|
+
assert.deepEqual(model.JUDGE_TABLE, model.BUILTIN_JUDGE_TABLE, 'the rubric table is exactly the built-in fallback');
|
|
204
|
+
|
|
205
|
+
const logged = [];
|
|
206
|
+
const orig = console.error;
|
|
207
|
+
console.error = (...a) => { logged.push(a.join(' ')); };
|
|
208
|
+
try {
|
|
209
|
+
assert.equal(model.selectGraderModel({ builderModel: 'gpt-9' }), 'claude-sonnet-5', 'an unknown builder warns and grades on the default — never throws (task 1002659)');
|
|
210
|
+
} finally { console.error = orig; }
|
|
211
|
+
assert.match(logged.join('\n'), /unrecognized builder model/);
|
|
212
|
+
|
|
213
|
+
assert.match(model.judgeTableProblems([{ builder_families: ['opus'], judge_model: 'claude-opus-5' }]).join(), /family it would grade/);
|
|
214
|
+
assert.match(model.judgeTableProblems([{ builder_families: ['opus'], judge_model: 'deepinfra:deepseek-v3' }]).join(), /plain Claude model id/);
|
|
215
|
+
assert.match(model.judgeTableProblems([]).join(), /non-empty/);
|
|
216
|
+
assert.deepEqual(model.judgeTableProblems(model.BUILTIN_JUDGE_TABLE), []);
|
|
217
|
+
});
|