amicus 4.9.0 → 4.9.2
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +124 -0
- package/README.md +1 -1
- package/docs/usage.md +1 -1
- package/electron/ipc-setup.js +5 -3
- package/electron/main.js +19 -5
- package/electron/setup-ui.js +28 -31
- package/package.json +1 -1
- package/schemas/council-verdict.schema.json +10 -0
- package/src/council/run-retry-window.js +62 -0
- package/src/council/run-retry.js +7 -10
- package/src/council/run-stage2.js +47 -3
- package/src/council/tally.js +12 -0
- package/src/council/verdict-seats-reviewed.js +60 -0
- package/src/council/verdict.js +23 -0
- package/src/headless.js +90 -9
- package/src/sidecar/fanout-leg-fallback.js +2 -1
- package/src/sidecar/models-render.js +71 -0
- package/src/sidecar/models.js +12 -45
- package/src/sidecar/reopen-spend.js +2 -1
- package/src/sidecar/setup.js +13 -4
- package/src/sidecar/start.js +2 -1
- package/src/utils/alias-audit.js +10 -3
- package/src/utils/alias-shadow.js +2 -2
- package/src/utils/curated-models.js +8 -6
- package/src/utils/degrade.js +8 -0
- package/src/utils/gateway-router.js +11 -1
- package/src/utils/model-canonicalization.js +55 -6
- package/src/utils/model-catalog.js +26 -8
- package/src/utils/model-fetcher.js +69 -16
- package/src/utils/model-shortlist.js +5 -2
- package/src/utils/provider-default-picker.js +6 -3
- package/src/utils/quick-picks.js +45 -7
- package/src/utils/result-schema.js +7 -1
- package/src/utils/session-status.js +73 -0
- package/src/utils/ttft.js +17 -6
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module council/verdict-seats-reviewed
|
|
3
|
+
* #202: the bench-seat census for verdict.json, as a spreadable fragment.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ EXTRACTED, not shaved — release Constraint 6, and the same 300-line gate
|
|
6
|
+
* that put `verdict-seat-loss.js` in its own leaf: adding this to verdict.js
|
|
7
|
+
* took that file to 313/300. It could not join verdict-seat-loss.js either —
|
|
8
|
+
* that module is pinned to export EXACTLY its two functions.
|
|
9
|
+
*
|
|
10
|
+
* This is the ONE place that decides what "a bench seat" means, so the
|
|
11
|
+
* emit-when-set rule and the role filter cannot drift apart. `of` is every
|
|
12
|
+
* `role:'seat'` row — one per bench seat POST-retry, so a healed seat counts
|
|
13
|
+
* once while its first attempt is `role:'superseded'`; judges, chair and
|
|
14
|
+
* repairs are not bench seats. `reviewed` is those whose leg completed: a
|
|
15
|
+
* `timeout` is not a review any more than an `error` is.
|
|
16
|
+
*
|
|
17
|
+
* A LEAF: it requires nothing, matching its seat-loss sibling.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {Array<object>|undefined} runStats
|
|
24
|
+
* @returns {{seatsReviewed?: {reviewed: number, of: number}}}
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Is this runStats row a BENCH seat — something that was asked to review?
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ These are exactly the three roles `seats.js :: buildSeats` mints, and that
|
|
30
|
+
* is the point: it is the producer, so this mirrors it rather than guessing.
|
|
31
|
+
* `role === 'seat'` alone (#219) counted ZERO on a `--lenses` run, where every
|
|
32
|
+
* seat carries `lens:<slug>` — so emit-when-set silently omitted the census from
|
|
33
|
+
* the runs using the richest bench. A critic counts too: it is an adversarial
|
|
34
|
+
* seat, but it reviews.
|
|
35
|
+
*
|
|
36
|
+
* An ALLOWLIST, not a denylist of judge/chair/repair/superseded: a new
|
|
37
|
+
* non-bench role added later must not silently inflate the denominator.
|
|
38
|
+
*/
|
|
39
|
+
function isBenchRole(role) {
|
|
40
|
+
return role === 'seat' || role === 'critic'
|
|
41
|
+
|| (typeof role === 'string' && role.startsWith('lens:'));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function seatsReviewedOf(runStats) {
|
|
45
|
+
// ⚠️ `Array.isArray`, NOT `runStats || []`. buildVerdict is reachable on
|
|
46
|
+
// externally-supplied records that never touched tally() in-process — the MCP
|
|
47
|
+
// `record` param of mcp-tools.js :: amicus_verdict is `z.record(z.any())`,
|
|
48
|
+
// fully permissive — and this file's own tests hand it `runStats: {}`. A
|
|
49
|
+
// truthy non-array sails past `||` and throws on `.filter`, turning a missing
|
|
50
|
+
// census into a crashed verdict build. The closed-literal comment further down
|
|
51
|
+
// makes the same argument about the same caller.
|
|
52
|
+
const seats = (Array.isArray(runStats) ? runStats : []).filter(r => r && isBenchRole(r.role));
|
|
53
|
+
if (seats.length === 0) { return {}; }
|
|
54
|
+
return { seatsReviewed: {
|
|
55
|
+
reviewed: seats.filter(r => r.status === 'complete').length,
|
|
56
|
+
of: seats.length,
|
|
57
|
+
} };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { seatsReviewedOf };
|
package/src/council/verdict.js
CHANGED
|
@@ -13,6 +13,10 @@ const { summarizeSeatLoss, deriveSeatLoss } = require('./verdict-seat-loss');
|
|
|
13
13
|
// opts.overallVerdict, null in every Stage-4 manual path).
|
|
14
14
|
const VERDICT_SCHEMA_VERSION = 2;
|
|
15
15
|
|
|
16
|
+
// #202: the bench-seat census leaf (the 300-line gate — same reason
|
|
17
|
+
// verdict-seat-loss.js is its own module).
|
|
18
|
+
const { seatsReviewedOf } = require('./verdict-seats-reviewed');
|
|
19
|
+
|
|
16
20
|
/**
|
|
17
21
|
* Merge a tally record with Claude's Stage-4 decisions into the verdict record.
|
|
18
22
|
* @param {object} record tally() output
|
|
@@ -123,6 +127,25 @@ function buildVerdict(record, decisions = [], opts = {}) {
|
|
|
123
127
|
// Additive and OPTIONAL (schemaVersion stays 2): present only when a critic
|
|
124
128
|
// was requested, so its absence never has to be interpreted.
|
|
125
129
|
...(opts.seatLoss ? { seatLoss: opts.seatLoss } : {}),
|
|
130
|
+
// #202: how much of the bench actually reviewed. DERIVED here rather than
|
|
131
|
+
// passed in, because every caller that could pass it already has the same
|
|
132
|
+
// `runStats` this reads — and a parameter is one more thing a rebuild path
|
|
133
|
+
// can forget (the `intent` key needed a SECOND carrier for exactly that).
|
|
134
|
+
//
|
|
135
|
+
// Its sibling `seatLoss` cannot serve: `deriveSeatLoss` returns null when no
|
|
136
|
+
// `--critic` was requested, and CI runs `CRITIC: ''` — so seat loss is
|
|
137
|
+
// STRUCTURALLY absent from every CI verdict. MEASURED on run 4424218c, a
|
|
138
|
+
// two-seat bench that published a four-model street-cred table with the dead
|
|
139
|
+
// seats rendered `n/a`, indistinguishable from the legend's "neutral".
|
|
140
|
+
//
|
|
141
|
+
// Counts the BENCH roles buildSeats mints — `seat`, `critic` and `lens:<slug>`
|
|
142
|
+
// (#219 r2: this said "`role:'seat'` ONLY" after the filter was widened, and
|
|
143
|
+
// two seats caught the stale sentence). One row per bench seat POST-retry, so a
|
|
144
|
+
// healed seat is counted once (its first attempt is `role:'superseded'`), and
|
|
145
|
+
// judges/chair/repairs are not bench seats. Emit-when-set — a record with no
|
|
146
|
+
// bench rows carries no key, because `0 of 0` would read as a measurement of
|
|
147
|
+
// an empty bench rather than as the absence it is.
|
|
148
|
+
...seatsReviewedOf(record.runStats),
|
|
126
149
|
// v4.6 Plan 2 (spec §4): the canonical what-was-lost surface. Additive and
|
|
127
150
|
// OPTIONAL — present only when the run actually degraded, so a clean run's
|
|
128
151
|
// verdict is byte-for-byte unchanged. schemaVersion stays 2 (the v4.5.2
|
package/src/headless.js
CHANGED
|
@@ -25,6 +25,8 @@ const { envNumber } = require('./utils/env-num');
|
|
|
25
25
|
const { engineErrorForSession } = require('./utils/engine-log');
|
|
26
26
|
// v4.9 W10 (#133 piece 3): the standing engine version-skew record, if any.
|
|
27
27
|
const { currentEngineSkew, formatSkewSuffix } = require('./utils/engine-skew');
|
|
28
|
+
// #202: the session-status clause on a death report (see utils/session-status.js).
|
|
29
|
+
const { formatSessionStatusSuffix } = require('./utils/session-status');
|
|
28
30
|
// v4.9 W13 Task A (PR #207 round 3, B3): the one honesty predicate every ttftMs
|
|
29
31
|
// emit gate shares — see src/utils/ttft.js for why `typeof` was not it.
|
|
30
32
|
const { isMeasuredTtft } = require('./utils/ttft');
|
|
@@ -85,7 +87,18 @@ const STABLE_FINISHED_POLLS = Number(process.env.AMICUS_STABLE_FINISHED_POLLS) |
|
|
|
85
87
|
const STABLE_IDLE_POLLS = Number(process.env.AMICUS_STABLE_IDLE_POLLS) || 30; // ~60s at 2s — no completion signal
|
|
86
88
|
const POLL_CALL_TIMEOUT_MS = Number(process.env.AMICUS_POLL_CALL_TIMEOUT_MS) || 30000; // per getMessages call (used by a later task)
|
|
87
89
|
const MAX_CONSECUTIVE_POLL_FAILURES = Number(process.env.AMICUS_MAX_CONSECUTIVE_POLL_FAILURES) || 15; // ≈30s at 2s polls
|
|
88
|
-
|
|
90
|
+
// B53: wedged tool call w/ no progress.
|
|
91
|
+
// ⚠️ 180000 -> 300000 (#219, council glm minor). 180 s was condemned by this
|
|
92
|
+
// file's OWN measurement — the 190.6 s `task` call recorded below, taken on a
|
|
93
|
+
// developer machine, not on CI. #202 widened only the CI override and left every
|
|
94
|
+
// local and library consumer on the number the evidence had already disproved.
|
|
95
|
+
// 300000 matches the sibling constant that same measurement set (USAGE/settle
|
|
96
|
+
// deferral below), so one measurement now governs both windows it bears on.
|
|
97
|
+
const TOOL_CALL_STALL_MS = Number(process.env.AMICUS_TOOL_CALL_STALL_MS) || 300000;
|
|
98
|
+
// #202: budget for the ONE session-status read on a death report. Short on
|
|
99
|
+
// purpose — this runs on a leg already known to be dying, so the report must not
|
|
100
|
+
// wait on the same engine that just failed to produce anything.
|
|
101
|
+
const STATUS_PROBE_MS = 5000;
|
|
89
102
|
/**
|
|
90
103
|
* v4.4 B1 — bounded post-loop usage reconciliation. The fold-marker (:~540) and
|
|
91
104
|
* SDK-idle (:~568) fast paths break WITHOUT requiring `info.time.completed`, but
|
|
@@ -120,9 +133,11 @@ const USAGE_SETTLE_CALL_TIMEOUT_MS = envNumber('AMICUS_USAGE_SETTLE_CALL_TIMEOUT
|
|
|
120
133
|
* unbounded wait is not an option.
|
|
121
134
|
*
|
|
122
135
|
* WHY 5 MINUTES. The measured duration of the real subagent call that exposed
|
|
123
|
-
* this is **190.6 s** (`task`, 04:35:08.427 → 04:38:19.061) —
|
|
124
|
-
* than B53's 180 s TOOL_CALL_STALL_MS, so anything at that scale would kill
|
|
125
|
-
* healthy `task` leg 10 s short of its answer.
|
|
136
|
+
* this is **190.6 s** (`task`, 04:35:08.427 → 04:38:19.061) — which was longer
|
|
137
|
+
* than B53's THEN-180 s TOOL_CALL_STALL_MS, so anything at that scale would kill
|
|
138
|
+
* a healthy `task` leg 10 s short of its answer. (#219 finally moved B53 itself
|
|
139
|
+
* to 300 s for this exact reason; for four releases this measurement corrected
|
|
140
|
+
* the neighbour and left its own subject alone.) 300 s clears the measured case
|
|
126
141
|
* with margin and still lands far inside the 15-minute default `--timeout`.
|
|
127
142
|
* Set to 0 to disable the deferral entirely (pre-v4.4 behaviour).
|
|
128
143
|
*
|
|
@@ -228,7 +243,7 @@ function withTimeout(promise, ms, label) {
|
|
|
228
243
|
* engineSkew?: {server: string, installed: string}|null}} args
|
|
229
244
|
* @returns {string}
|
|
230
245
|
*/
|
|
231
|
-
function formatNoOutputBackstopReason({ ms, fromEnv, engineLogExcerpt, engineSkew }) {
|
|
246
|
+
function formatNoOutputBackstopReason({ ms, fromEnv, engineLogExcerpt, engineSkew, sessionStatus }) {
|
|
232
247
|
const observed = 'NO_OUTPUT_BACKSTOP: no output, reasoning, or tool calls in '
|
|
233
248
|
+ `${Math.round(ms / 1000)}s — `
|
|
234
249
|
+ (fromEnv
|
|
@@ -237,7 +252,11 @@ function formatNoOutputBackstopReason({ ms, fromEnv, engineLogExcerpt, engineSke
|
|
|
237
252
|
// Append-only: absent/empty excerpt ⇒ the string above, unchanged byte for byte.
|
|
238
253
|
const quoted = engineLogExcerpt ? `${observed} — engine log: ${engineLogExcerpt}` : observed;
|
|
239
254
|
// Append-only for the same reason: no skew ⇒ formatSkewSuffix returns ''.
|
|
240
|
-
|
|
255
|
+
// #202 adds a THIRD clause on the same terms, and LAST so both clauses above
|
|
256
|
+
// stay byte-stable: no status (or an unreadable one) ⇒ '' — see
|
|
257
|
+
// utils/session-status.js. With none of the three the string is byte-for-byte
|
|
258
|
+
// what it was before any of them existed.
|
|
259
|
+
return `${quoted}${formatSkewSuffix(engineSkew)}${formatSessionStatusSuffix(sessionStatus)}`;
|
|
241
260
|
}
|
|
242
261
|
|
|
243
262
|
/**
|
|
@@ -259,6 +278,45 @@ function engineErrorExcerptSafe(sessionId, engineLogOptions) {
|
|
|
259
278
|
}
|
|
260
279
|
}
|
|
261
280
|
|
|
281
|
+
/**
|
|
282
|
+
* #202: read the engine's session status FOR A DEATH REPORT — best-effort and
|
|
283
|
+
* bounded, with the same "never become the failure it reports on" discipline as
|
|
284
|
+
* `engineErrorExcerptSafe` above, and one more constraint that read does not
|
|
285
|
+
* have: this one does I/O against the very engine that just failed to produce
|
|
286
|
+
* anything, so it must also be unable to HANG. Both belts are load-bearing and
|
|
287
|
+
* both are pinned (S-W4 rejects, S-W5 hangs).
|
|
288
|
+
*
|
|
289
|
+
* A failed probe returns `null`, which `formatSessionStatusSuffix` renders as
|
|
290
|
+
* '' — so a leg whose status could not be read carries the byte-for-byte reason
|
|
291
|
+
* string it carried before #202, rather than a clause claiming nothing was
|
|
292
|
+
* happening. Absence keeps its one meaning.
|
|
293
|
+
*
|
|
294
|
+
* `readStatus` is a parameter rather than a module import because
|
|
295
|
+
* `getSessionStatus` is destructured inside runHeadless from the injectable
|
|
296
|
+
* client module — taking it here keeps this helper pure and directly testable.
|
|
297
|
+
* @returns {Promise<object|null>}
|
|
298
|
+
*/
|
|
299
|
+
async function sessionStatusSafe(readStatus, client, sessionId, dirArgs, ms) {
|
|
300
|
+
// `!(ms > 0)` covers 0 (the documented disable), negatives and NaN — and it is
|
|
301
|
+
// why 0 is never handed to withTimeout, which would read it as UNBOUNDED.
|
|
302
|
+
if (typeof readStatus !== 'function' || !sessionId || !(ms > 0)) { return null; }
|
|
303
|
+
try {
|
|
304
|
+
return await withTimeout(
|
|
305
|
+
readStatus(client, sessionId, ...(dirArgs || [])), ms, 'getSessionStatus(death-report)');
|
|
306
|
+
} catch (err) {
|
|
307
|
+
// #219 (council, deepseek minor): returning null is right for the REPORT —
|
|
308
|
+
// absence keeps its one meaning — but it made a probe that timed out on a
|
|
309
|
+
// loaded engine indistinguishable from a leg whose engine reported nothing,
|
|
310
|
+
// i.e. a silent revert to pre-#202 behaviour. The engine log is where that
|
|
311
|
+
// belongs: the death report stays byte-identical, and the degradation
|
|
312
|
+
// becomes diagnosable instead of invisible.
|
|
313
|
+
logger.debug('session-status probe failed; the death report will carry no session clause', {
|
|
314
|
+
sessionId, error: err && err.message,
|
|
315
|
+
});
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
262
320
|
/**
|
|
263
321
|
* Wait for the OpenCode server to be ready using SDK health check
|
|
264
322
|
*/
|
|
@@ -616,6 +674,19 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
616
674
|
// "time since the leg asked for output" (see the block comment above), so
|
|
617
675
|
// the TTFT probe below measures from exactly the instant the backstop
|
|
618
676
|
// starts counting — the two can never disagree about when the wait began.
|
|
677
|
+
// #202: resolved HERE, not with the poll-loop options below. The pre-send
|
|
678
|
+
// firing site calls noOutputBackstopReason() upstream of that block, so a
|
|
679
|
+
// later `const` put this in its TDZ and the death report became
|
|
680
|
+
// "Cannot access 'statusProbeMs' before initialization" — the exact class of
|
|
681
|
+
// silent-diagnosis loss #202 exists to remove. Pinned by S-W6.
|
|
682
|
+
// ⚠️ `=== undefined`, not `||` (#219 round 2, deepseek): 0 is a meaningful
|
|
683
|
+
// value in this codebase's convention (usageSettlePolls,
|
|
684
|
+
// AMICUS_NO_OUTPUT_BACKSTOP_MS) and must survive injection. It cannot be
|
|
685
|
+
// FORWARDED as 0 though — withTimeout reads `ms <= 0` as NO timeout, so an
|
|
686
|
+
// honest-looking 0 would make this probe unbounded on a leg already known to
|
|
687
|
+
// be dying. sessionStatusSafe therefore SKIPS on a non-positive window.
|
|
688
|
+
const statusProbeMs = options.statusProbeMs === undefined
|
|
689
|
+
? STATUS_PROBE_MS : options.statusProbeMs;
|
|
619
690
|
const outputClockStartedAt = Date.now();
|
|
620
691
|
const noOutputBackstop = createNoOutputBackstop({ ms: noOutputBackstopMs, startedAt: outputClockStartedAt });
|
|
621
692
|
let backstopFired = false;
|
|
@@ -642,10 +713,20 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
642
713
|
// was fixed mid-run, or one that belongs to another server this process
|
|
643
714
|
// also talks to, cannot ride out on this death report. The read is a Map
|
|
644
715
|
// lookup and does no I/O, so unlike the log read it needs no guard.
|
|
645
|
-
|
|
716
|
+
//
|
|
717
|
+
// #202 (piece 4): the closure is now ASYNC, because the third clause costs
|
|
718
|
+
// one bounded HTTP call. The engine's session status is asked for at
|
|
719
|
+
// :~1045 only when `mirror.output.length > 0` — a gate a zero-output leg
|
|
720
|
+
// never satisfies — so the leg that most needs diagnosing was the only one
|
|
721
|
+
// that never asked, and every silent death reported a window with no cause.
|
|
722
|
+
// Asked for HERE instead, at the two firing sites and nowhere else, so a
|
|
723
|
+
// living leg still makes no extra call. The read is best-effort and
|
|
724
|
+
// bounded: `sessionStatusSafe` can neither throw nor hang (S-W4/S-W5).
|
|
725
|
+
const noOutputBackstopReason = async () => formatNoOutputBackstopReason({
|
|
646
726
|
ms: noOutputBackstopMs, fromEnv: backstopFromEnv,
|
|
647
727
|
engineLogExcerpt: engineErrorExcerptSafe(sessionId, options._engineLog),
|
|
648
728
|
engineSkew: currentEngineSkew(client),
|
|
729
|
+
sessionStatus: await sessionStatusSafe(getSessionStatus, client, sessionId, dirArgs, statusProbeMs),
|
|
649
730
|
});
|
|
650
731
|
|
|
651
732
|
// Send prompt asynchronously (returns immediately, we poll for results) —
|
|
@@ -696,7 +777,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
696
777
|
// skipped entirely on this path — see the while-condition and the
|
|
697
778
|
// backstop-abort block further down).
|
|
698
779
|
if (backstopFired) {
|
|
699
|
-
sessionError = noOutputBackstopReason();
|
|
780
|
+
sessionError = await noOutputBackstopReason();
|
|
700
781
|
}
|
|
701
782
|
|
|
702
783
|
// Hard provider failure detected at the client boundary (#37): a non-2xx /
|
|
@@ -1065,7 +1146,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
1065
1146
|
// loop; the post-loop block below mirrors the timeout path.
|
|
1066
1147
|
if (noOutputBackstop.tick(substantiveActivity, Date.now()) === 'fired') {
|
|
1067
1148
|
backstopFired = true;
|
|
1068
|
-
sessionError = noOutputBackstopReason();
|
|
1149
|
+
sessionError = await noOutputBackstopReason();
|
|
1069
1150
|
logger.warn('No-output backstop fired', { taskId, backstopMs: noOutputBackstopMs });
|
|
1070
1151
|
break;
|
|
1071
1152
|
}
|
|
@@ -15,6 +15,7 @@ const fs = require('fs');
|
|
|
15
15
|
const { logger } = require('../utils/logger');
|
|
16
16
|
const { classifyLegError, isRetryable } = require('../utils/error-classify');
|
|
17
17
|
const { deriveChain } = require('./fallback-chains');
|
|
18
|
+
const { gatewayOf } = require('../utils/gateway-router');
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Append ONE attributed ledger row for a single attempt (spec 6.2/7.1). At
|
|
@@ -38,7 +39,7 @@ function recordAttemptSpend({ doc, leg, currentModel, legId, waveId, project, at
|
|
|
38
39
|
// a substitution carries the substitute's resolved gateway on
|
|
39
40
|
// `routeGateway` (threaded in by the loop, incl. v4.2 'local').
|
|
40
41
|
const gateway = routeGateway || (leg && leg.gateway) ||
|
|
41
|
-
(
|
|
42
|
+
gatewayOf(currentModel);
|
|
42
43
|
const row = {
|
|
43
44
|
taskId: legId, waveId, model: currentModel, mode: 'leg', usage,
|
|
44
45
|
op: 'leg', status: doc.status, gateway,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presentation helpers for `amicus models` -- pure string formatting, no I/O.
|
|
3
|
+
*
|
|
4
|
+
* Split out of models.js when the per-provider failure line (issue 209) pushed
|
|
5
|
+
* that file past the 300-line ceiling. Formatting and command flow are
|
|
6
|
+
* separable concerns, so the ceiling picked the seam: everything here takes a
|
|
7
|
+
* plain object and returns a string.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
/** '0.000003' per token → '3.00' per Mtok; '—' when unknown or variable (-1) */
|
|
13
|
+
function perMtok(perToken) {
|
|
14
|
+
if (perToken === null || perToken === undefined) { return '—'; }
|
|
15
|
+
const n = Number(perToken);
|
|
16
|
+
if (Number.isNaN(n) || n < 0) { return '—'; }
|
|
17
|
+
return (n * 1e6).toFixed(2);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fmtRow(m, aliasesById) {
|
|
21
|
+
const alias = aliasesById.get(m.id);
|
|
22
|
+
const aliasCol = alias ? `[${alias}] ` : '';
|
|
23
|
+
const ctx = m.contextLength ?? '—';
|
|
24
|
+
const pIn = perMtok(m.pricing && m.pricing.prompt);
|
|
25
|
+
const pOut = perMtok(m.pricing && m.pricing.completion);
|
|
26
|
+
return `${aliasCol}${m.id}\n ${m.name} ctx ${ctx} $/Mtok in ${pIn} out ${pOut}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One readable line per gateway-route finding (Task 6, #gwid). @param {object} f @returns {string} */
|
|
30
|
+
function fmtGatewayFinding(f) {
|
|
31
|
+
if (f.kind === 'stale') {
|
|
32
|
+
return ` GATEWAY STALE (${f.gateway}): ${f.alias} -> ${f.model}`;
|
|
33
|
+
}
|
|
34
|
+
if (f.kind === 'divergent-missing') {
|
|
35
|
+
return ` GATEWAY DIVERGENT: ${f.alias} has no direct form; catalog confirms ${f.model}`;
|
|
36
|
+
}
|
|
37
|
+
return ` GATEWAY DIVERGENT: ${f.alias} direct form ${f.model} no longer matches catalog (now ${f.expected})`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PROBE_LABELS = { served: 'SERVED', 'accepted-but-silent': 'SILENT', error: 'ERROR' };
|
|
41
|
+
|
|
42
|
+
/** '$0.0004' | '$1.23' | '—' (unknown). Deliberately NOT formatCost (pricing.js):
|
|
43
|
+
* a probe result's `cost` is a bare number (models-probe.js doesn't carry the
|
|
44
|
+
* reported/estimated source tag), so this never claims a precision it can't back. */
|
|
45
|
+
function fmtProbeCost(cost) {
|
|
46
|
+
if (cost === null || cost === undefined || Number.isNaN(cost)) { return '—'; }
|
|
47
|
+
return cost < 1 ? `$${cost.toFixed(4)}` : `$${cost.toFixed(2)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One readable line per probed alias (`--check --live`, v4.6.2 PR3): uppercase
|
|
51
|
+
* class prefix padded to a fixed column, two-space indent — mirrors the STALE/
|
|
52
|
+
* DRIFTED/GATEWAY line style above. @param {object} r probeStoredAliases() row */
|
|
53
|
+
function fmtProbeLine(r) {
|
|
54
|
+
const head = ` ${(PROBE_LABELS[r.outcome] + ':').padEnd(8)}${r.alias} -> ${r.target}`;
|
|
55
|
+
if (r.outcome === 'served') { return `${head} (${fmtProbeCost(r.cost)})`; }
|
|
56
|
+
if (r.outcome === 'accepted-but-silent') { return `${head} — ${r.detail} (no output within the probe window)`; }
|
|
57
|
+
return `${head} — ${r.detail}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One readable line per REJECTED provider fetch (issue 209): a namespace that is
|
|
61
|
+
* empty because its key was refused explains stale/absent aliases downstream.
|
|
62
|
+
* @param {{provider: string, reason: string, status?: number, detail?: string}} f
|
|
63
|
+
* @returns {string} */
|
|
64
|
+
function fmtProviderFailure(f) {
|
|
65
|
+
const why = f.reason === 'http-status' ? `HTTP ${f.status}` : (f.detail || f.reason);
|
|
66
|
+
return `PROVIDER FETCH FAILED: ${f.provider} (${why}) — its models are absent from the catalog`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = {
|
|
70
|
+
perMtok, fmtRow, fmtGatewayFinding, PROBE_LABELS, fmtProbeCost, fmtProbeLine, fmtProviderFailure,
|
|
71
|
+
};
|
package/src/sidecar/models.js
CHANGED
|
@@ -21,25 +21,11 @@ const { getFamilies } = require('../utils/curated-models');
|
|
|
21
21
|
const { pickCurrent } = require('../utils/quick-picks');
|
|
22
22
|
const { probeStoredAliases, selectStoredAliases } = require('./models-probe');
|
|
23
23
|
const { DEFAULT_MAX_LEGS } = require('./fanout-validate');
|
|
24
|
+
const { fmtRow, fmtGatewayFinding, fmtProbeLine, fmtProviderFailure } = require('./models-render');
|
|
24
25
|
|
|
25
26
|
const CHECK_EXIT_CAP = 100;
|
|
26
27
|
|
|
27
|
-
/** '0.000003' per token → '3.00' per Mtok; '—' when unknown or variable (-1) */
|
|
28
|
-
function perMtok(perToken) {
|
|
29
|
-
if (perToken === null || perToken === undefined) { return '—'; }
|
|
30
|
-
const n = Number(perToken);
|
|
31
|
-
if (Number.isNaN(n) || n < 0) { return '—'; }
|
|
32
|
-
return (n * 1e6).toFixed(2);
|
|
33
|
-
}
|
|
34
28
|
|
|
35
|
-
function fmtRow(m, aliasesById) {
|
|
36
|
-
const alias = aliasesById.get(m.id);
|
|
37
|
-
const aliasCol = alias ? `[${alias}] ` : '';
|
|
38
|
-
const ctx = m.contextLength ?? '—';
|
|
39
|
-
const pIn = perMtok(m.pricing && m.pricing.prompt);
|
|
40
|
-
const pOut = perMtok(m.pricing && m.pricing.completion);
|
|
41
|
-
return `${aliasCol}${m.id}\n ${m.name} ctx ${ctx} $/Mtok in ${pIn} out ${pOut}`;
|
|
42
|
-
}
|
|
43
29
|
|
|
44
30
|
/** alias marks: id → comma-joined alias names (effective user aliases) */
|
|
45
31
|
function aliasMarks() {
|
|
@@ -130,36 +116,9 @@ async function runRefresh(args) {
|
|
|
130
116
|
return 0;
|
|
131
117
|
}
|
|
132
118
|
|
|
133
|
-
/** One readable line per gateway-route finding (Task 6, #gwid). @param {object} f @returns {string} */
|
|
134
|
-
function fmtGatewayFinding(f) {
|
|
135
|
-
if (f.kind === 'stale') {
|
|
136
|
-
return ` GATEWAY STALE (${f.gateway}): ${f.alias} -> ${f.model}`;
|
|
137
|
-
}
|
|
138
|
-
if (f.kind === 'divergent-missing') {
|
|
139
|
-
return ` GATEWAY DIVERGENT: ${f.alias} has no direct form; catalog confirms ${f.model}`;
|
|
140
|
-
}
|
|
141
|
-
return ` GATEWAY DIVERGENT: ${f.alias} direct form ${f.model} no longer matches catalog (now ${f.expected})`;
|
|
142
|
-
}
|
|
143
119
|
|
|
144
|
-
const PROBE_LABELS = { served: 'SERVED', 'accepted-but-silent': 'SILENT', error: 'ERROR' };
|
|
145
120
|
|
|
146
|
-
/** '$0.0004' | '$1.23' | '—' (unknown). Deliberately NOT formatCost (pricing.js):
|
|
147
|
-
* a probe result's `cost` is a bare number (models-probe.js doesn't carry the
|
|
148
|
-
* reported/estimated source tag), so this never claims a precision it can't back. */
|
|
149
|
-
function fmtProbeCost(cost) {
|
|
150
|
-
if (cost === null || cost === undefined || Number.isNaN(cost)) { return '—'; }
|
|
151
|
-
return cost < 1 ? `$${cost.toFixed(4)}` : `$${cost.toFixed(2)}`;
|
|
152
|
-
}
|
|
153
121
|
|
|
154
|
-
/** One readable line per probed alias (`--check --live`, v4.6.2 PR3): uppercase
|
|
155
|
-
* class prefix padded to a fixed column, two-space indent — mirrors the STALE/
|
|
156
|
-
* DRIFTED/GATEWAY line style above. @param {object} r probeStoredAliases() row */
|
|
157
|
-
function fmtProbeLine(r) {
|
|
158
|
-
const head = ` ${(PROBE_LABELS[r.outcome] + ':').padEnd(8)}${r.alias} -> ${r.target}`;
|
|
159
|
-
if (r.outcome === 'served') { return `${head} (${fmtProbeCost(r.cost)})`; }
|
|
160
|
-
if (r.outcome === 'accepted-but-silent') { return `${head} — ${r.detail} (no output within the probe window)`; }
|
|
161
|
-
return `${head} — ${r.detail}`;
|
|
162
|
-
}
|
|
163
122
|
|
|
164
123
|
async function runCheck(args) {
|
|
165
124
|
// v4.9 W13 Task B (BACKLOG C5). FIRST — ahead of the catalog-unavailable return
|
|
@@ -171,13 +130,17 @@ async function runCheck(args) {
|
|
|
171
130
|
require('../utils/alias-shadow').auditAliasShadows();
|
|
172
131
|
const catalogInfo = await getCatalogInfo();
|
|
173
132
|
const catalog = catalogInfo.models;
|
|
133
|
+
const providerFailures = Array.isArray(catalogInfo.providerFailures) ? catalogInfo.providerFailures : [];
|
|
174
134
|
if (!catalog || catalog.length === 0) {
|
|
175
135
|
const probeSkipped = args.live ? 'catalog-unavailable' : null;
|
|
176
136
|
if (args.json) {
|
|
177
137
|
process.stdout.write(JSON.stringify(buildAuditDoc({
|
|
178
|
-
stale: [], catalogAvailable: false, probeSkipped
|
|
138
|
+
stale: [], catalogAvailable: false, probeSkipped, providerFailures
|
|
179
139
|
}), null, 2) + '\n');
|
|
180
140
|
} else {
|
|
141
|
+
// Council C2 (PR 215): "catalog unavailable" is precisely when the user
|
|
142
|
+
// needs to know WHICH provider refused them.
|
|
143
|
+
for (const f of providerFailures) { process.stdout.write(fmtProviderFailure(f) + '\n'); }
|
|
181
144
|
process.stdout.write('Catalog unavailable (offline or no providers reachable); cannot check.\n');
|
|
182
145
|
if (probeSkipped) { process.stdout.write(fmtLiveSkipped(probeSkipped) + '\n'); }
|
|
183
146
|
}
|
|
@@ -186,7 +149,7 @@ async function runCheck(args) {
|
|
|
186
149
|
const sources = collectAliasSources();
|
|
187
150
|
const stale = findStaleAliases(sources, catalog)
|
|
188
151
|
.map(s => ({ ...s, suggestions: suggestReplacements(s.model, catalog) }));
|
|
189
|
-
const drifted = findDriftedStoredAliases(sources,
|
|
152
|
+
const drifted = findDriftedStoredAliases(sources, catalogInfo);
|
|
190
153
|
// Task 6 (#gwid): per-gateway-form audit of the curated DEFAULTS
|
|
191
154
|
// (toGatewayRoutes()) — additive to the flat audit above. Informational by
|
|
192
155
|
// default; --strict promotes it to a build-breaking exit code (CI gate).
|
|
@@ -221,10 +184,14 @@ async function runCheck(args) {
|
|
|
221
184
|
|
|
222
185
|
if (args.json) {
|
|
223
186
|
process.stdout.write(JSON.stringify(buildAuditDoc({
|
|
224
|
-
stale, catalogAvailable: true, gatewayFindings, drifted, probe: probeResults
|
|
187
|
+
stale, catalogAvailable: true, gatewayFindings, drifted, probe: probeResults, providerFailures
|
|
225
188
|
}), null, 2) + '\n');
|
|
226
189
|
return exitCode;
|
|
227
190
|
}
|
|
191
|
+
// issue 209: report REJECTED provider fetches before the alias findings -- an
|
|
192
|
+
// empty namespace explains stale/absent aliases downstream, and staying
|
|
193
|
+
// silent about it is the original defect.
|
|
194
|
+
for (const f of providerFailures) { process.stdout.write(fmtProviderFailure(f) + '\n'); }
|
|
228
195
|
const driftLines = buildFallbackDriftReport(catalog);
|
|
229
196
|
if (stale.length === 0 && drifted.length === 0) {
|
|
230
197
|
process.stdout.write(`All aliases resolve to catalog models (${sources.length} checked).\n`);
|
|
@@ -20,7 +20,8 @@ function finalizeSpendForReopen({ taskId, model, mode, op, result, status, proje
|
|
|
20
20
|
metadata.usage = usage; // buildRunResult surfaces metadata.usage into the --json doc for free
|
|
21
21
|
try {
|
|
22
22
|
const { appendSpend } = require('../utils/spend-ledger');
|
|
23
|
-
const
|
|
23
|
+
const { gatewayOf } = require('../utils/gateway-router');
|
|
24
|
+
const gateway = metadata.gateway || gatewayOf(model);
|
|
24
25
|
// v4.7.1 Task 7 D16: null-not-absent, the OPPOSITE convention from
|
|
25
26
|
// metadata.tag's absent-not-null (D13) — same `|| null` idiom as start.js:237.
|
|
26
27
|
appendSpend({ taskId, model, mode, usage, op, status, project, gateway, tag: metadata.tag || null }, ctx);
|
package/src/sidecar/setup.js
CHANGED
|
@@ -475,9 +475,17 @@ async function runReadlineSetup() {
|
|
|
475
475
|
await warnOnLowOpenRouterCredit();
|
|
476
476
|
}
|
|
477
477
|
|
|
478
|
-
const {
|
|
478
|
+
const { getCatalogInfo } = require('../utils/model-catalog');
|
|
479
479
|
let catalog = [];
|
|
480
|
-
|
|
480
|
+
// #208: carry the per-provider fetch outcomes alongside the rows, so the
|
|
481
|
+
// vendor shortlist below cannot synthesise a bare direct id for a
|
|
482
|
+
// namespace whose fetch was rejected rather than never attempted.
|
|
483
|
+
let providerFailures = [];
|
|
484
|
+
try {
|
|
485
|
+
const info = await getCatalogInfo();
|
|
486
|
+
catalog = info.models;
|
|
487
|
+
providerFailures = info.providerFailures || [];
|
|
488
|
+
} catch (_err) { /* offline: pinned */ }
|
|
481
489
|
|
|
482
490
|
// Task 7 (cost-aware defaults P2): per-provider picker, once per keyed
|
|
483
491
|
// provider, BEFORE the mode prompt -- orthogonal to standard-vs-free-council.
|
|
@@ -537,7 +545,7 @@ async function runReadlineSetup() {
|
|
|
537
545
|
}
|
|
538
546
|
|
|
539
547
|
// Read-modify-write — never rebuild the alias table (no-clobber rule).
|
|
540
|
-
const cfg = loadConfig() || { aliases: toLiveSeedAliases(catalog) };
|
|
548
|
+
const cfg = loadConfig() || { aliases: toLiveSeedAliases({ models: catalog, providerFailures }) };
|
|
541
549
|
if (!cfg.aliases) { cfg.aliases = {}; }
|
|
542
550
|
if (chosen.alias) {
|
|
543
551
|
cfg.default = chosen.alias;
|
|
@@ -548,7 +556,7 @@ async function runReadlineSetup() {
|
|
|
548
556
|
// choice), but the alias's VALUE must stay the vendor phase's tier choice --
|
|
549
557
|
// skip the curated-flagship upgrade so it isn't discarded.
|
|
550
558
|
if (pick && !chosen.noUpgrade && !vendorAliasesWritten.has(chosen.alias)) {
|
|
551
|
-
cfg.aliases[chosen.alias] = toStorableRoute(pick);
|
|
559
|
+
cfg.aliases[chosen.alias] = toStorableRoute(pick, { models: catalog, providerFailures });
|
|
552
560
|
} else if (cfg.aliases[chosen.alias] === undefined) {
|
|
553
561
|
const fallback = getDefaultAliases()[chosen.alias];
|
|
554
562
|
if (fallback !== undefined) { cfg.aliases[chosen.alias] = fallback; }
|
|
@@ -599,6 +607,7 @@ async function runReadlineSetup() {
|
|
|
599
607
|
const { buildModelShortlist } = require('../utils/model-shortlist');
|
|
600
608
|
const shortlist = buildModelShortlist(pick.vendorPath, {
|
|
601
609
|
catalog,
|
|
610
|
+
providerFailures,
|
|
602
611
|
recommendedId: cfg.aliases[chosen.alias],
|
|
603
612
|
});
|
|
604
613
|
const specific = await promptForVendorModel(
|
package/src/sidecar/start.js
CHANGED
|
@@ -219,6 +219,7 @@ async function startSidecar(options) {
|
|
|
219
219
|
try {
|
|
220
220
|
const { appendSpend } = require('../utils/spend-ledger');
|
|
221
221
|
const { statusFromResult } = require('../utils/result-schema');
|
|
222
|
+
const { gatewayOf } = require('../utils/gateway-router');
|
|
222
223
|
appendSpend({
|
|
223
224
|
taskId, model, mode: effectiveHeadless ? 'headless' : 'interactive', usage: runUsage,
|
|
224
225
|
op: 'start', status: statusFromResult(result), project: effectiveProject,
|
|
@@ -227,7 +228,7 @@ async function startSidecar(options) {
|
|
|
227
228
|
// inside createSessionMetadata. Reading `metadata.gateway` throws a ReferenceError the
|
|
228
229
|
// best-effort catch swallows → EVERY start-mode spend row silently dropped + start-json.test.js
|
|
229
230
|
// goes red. Use an in-scope value (spec-complete for direct/openrouter):
|
|
230
|
-
gateway:
|
|
231
|
+
gateway: gatewayOf(model),
|
|
231
232
|
// (To also attribute v4.2 'local': thread the resolved route gateway — dropped today at
|
|
232
233
|
// cli-handlers-run.js:47 — into createSessionMetadata and read `meta.gateway`, as continue.js:111 does.)
|
|
233
234
|
// v4.7 F8 D16: same in-scope-value rule as gateway above — `m` is the
|
package/src/utils/alias-audit.js
CHANGED
|
@@ -172,13 +172,20 @@ function suggestReplacements(staleModel, catalog, n = 3) {
|
|
|
172
172
|
* @param {Array<{id:string}>} catalog
|
|
173
173
|
* @returns {Array<{alias:string,stored:string,current:string}>}
|
|
174
174
|
*/
|
|
175
|
-
function findDriftedStoredAliases(sources,
|
|
176
|
-
|
|
175
|
+
function findDriftedStoredAliases(sources, catalogOrInfo) {
|
|
176
|
+
// Council #216 A1/B2/C1: accepts catalogInfo (or a bare array, for existing
|
|
177
|
+
// callers). Passing models WITHOUT providerFailures made this compute the bare
|
|
178
|
+
// direct form for a REJECTED namespace while sidecar/setup.js persists the
|
|
179
|
+
// gateway form -- reporting drift that does not exist, and suggesting a repair
|
|
180
|
+
// that writes back the unservable direct id issue 208 removed.
|
|
181
|
+
const info = Array.isArray(catalogOrInfo) ? { models: catalogOrInfo } : (catalogOrInfo || { models: [] });
|
|
182
|
+
const catalog = info.models || [];
|
|
183
|
+
if (catalog.length === 0) { return []; }
|
|
177
184
|
const { resolveQuickPicks, toStorableRoute } = require('./quick-picks');
|
|
178
185
|
const current = new Map();
|
|
179
186
|
for (const r of resolveQuickPicks(catalog)) {
|
|
180
187
|
if (r.source !== 'live') { continue; }
|
|
181
|
-
const stored = toStorableRoute(r);
|
|
188
|
+
const stored = toStorableRoute(r, info);
|
|
182
189
|
if (stored) { current.set(r.alias, { display: stored, routeValues: new Set(Object.values(r.routes)) }); }
|
|
183
190
|
}
|
|
184
191
|
const byProvider = idsByProvider(catalog);
|
|
@@ -103,7 +103,7 @@ const { collapseExcerpt } = require('./text-sanitize');
|
|
|
103
103
|
*/
|
|
104
104
|
function findAliasShadows(names) {
|
|
105
105
|
const { loadConfig } = require('./config');
|
|
106
|
-
const { toDefaultAliases,
|
|
106
|
+
const { toDefaultAliases, stripGatewayPrefix } = require('./curated-models');
|
|
107
107
|
const cfg = loadConfig();
|
|
108
108
|
const userAliases = (cfg && cfg.aliases && typeof cfg.aliases === 'object') ? cfg.aliases : {};
|
|
109
109
|
// Own keys only: a user config.json can carry a literal `__proto__`/`toString`
|
|
@@ -131,7 +131,7 @@ function findAliasShadows(names) {
|
|
|
131
131
|
// ROUTING has its own audit (`models --check`'s per-gateway section); this
|
|
132
132
|
// notice speaks only when the alias names a different MODEL. The rows still
|
|
133
133
|
// report both sides RAW, so the user can grep their own config.
|
|
134
|
-
if (
|
|
134
|
+
if (stripGatewayPrefix(local) === stripGatewayPrefix(shipped)) { continue; }
|
|
135
135
|
out.push({ alias, local, curated: shipped });
|
|
136
136
|
}
|
|
137
137
|
return out;
|
|
@@ -142,9 +142,11 @@ function getFamilies() {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
145
|
+
* ⚠️ MECHANICAL primitive, NOT a routing decision (renamed from `toCanonicalDefault`,
|
|
146
|
+
* issue 214 — model-canonicalization.js explains why that name was a trap). An id that
|
|
147
|
+
* will be CALLED or STORED must come from directFormIfSafe/directFormIfProven. Strips
|
|
148
|
+
* the `openrouter/` prefix off a pinned route when `<vendor>` has a direct integration
|
|
149
|
+
* (provider-registry `isDirectProvider`), so the resulting bare
|
|
148
150
|
* `<vendor>/<rest>` id is policy-routed by the gateway router (direct when a
|
|
149
151
|
* direct key exists, OpenRouter otherwise). Gateway-only vendors (no direct
|
|
150
152
|
* integration — e.g. qwen, x-ai, z-ai, mistralai, minimax, moonshotai,
|
|
@@ -154,7 +156,7 @@ function getFamilies() {
|
|
|
154
156
|
* @param {string} route
|
|
155
157
|
* @returns {string}
|
|
156
158
|
*/
|
|
157
|
-
function
|
|
159
|
+
function stripGatewayPrefix(route) {
|
|
158
160
|
if (typeof route === 'string' && route.startsWith('openrouter/')) {
|
|
159
161
|
const rest = route.slice('openrouter/'.length); // '<vendor>/<rest...>'
|
|
160
162
|
const slashIdx = rest.indexOf('/');
|
|
@@ -211,7 +213,7 @@ function vendorOf(orRoute) {
|
|
|
211
213
|
function directFormFor(vendorPath, obj) {
|
|
212
214
|
if (obj[vendorPath]) { return obj[vendorPath]; } // explicit, authored, current direct id
|
|
213
215
|
if (DIVERGENT_VENDORS.has(vendorPath)) { return undefined; } // no explicit form + divergent → omit
|
|
214
|
-
const bare =
|
|
216
|
+
const bare = stripGatewayPrefix(obj.openrouter); // safe only when ids are identical across gateways
|
|
215
217
|
return bare !== obj.openrouter ? bare : undefined; // gateway-only vendor → undefined
|
|
216
218
|
}
|
|
217
219
|
|
|
@@ -293,6 +295,6 @@ function toDefaultAliases() {
|
|
|
293
295
|
}
|
|
294
296
|
|
|
295
297
|
module.exports = {
|
|
296
|
-
getFamilies, toDefaultAliases,
|
|
298
|
+
getFamilies, toDefaultAliases, stripGatewayPrefix, listCuratedRoutes, toGatewayRoutes,
|
|
297
299
|
directFormProvenance, DIVERGENT_VENDORS
|
|
298
300
|
};
|
package/src/utils/degrade.js
CHANGED
|
@@ -24,6 +24,14 @@ const DEGRADE_CHANNELS = Object.freeze(new Set([
|
|
|
24
24
|
// a leg that DID match a slot but whose join key names no judge the wave launched.
|
|
25
25
|
// Never a guess — silent mis-attribution is the failure seat identity exists to kill (§4.4).
|
|
26
26
|
'seat-unbound',
|
|
27
|
+
// #202: a Stage-2 JUDGE leg that came back dead — bound to its seat, so
|
|
28
|
+
// neither `seat-unbound` nor an orphan, and until now it had no channel at all
|
|
29
|
+
// and no case in run-stage2.js. Deliberately its own channel rather than
|
|
30
|
+
// `dead-leg`: that one is the Stage-1 BENCH roster's, feeds the retry pass and
|
|
31
|
+
// the seat-loss surface, and a judge death reused on it would be counted as a
|
|
32
|
+
// lost reviewer by consumers that only ever meant seats (verdict-seat-loss.js
|
|
33
|
+
// already gates the Stage-2 notes out of `seat-unbound` for the same reason).
|
|
34
|
+
'stage2-judge',
|
|
27
35
|
'internal',
|
|
28
36
|
// doctor channels
|
|
29
37
|
'doctor-check-failed', 'doctor-fix',
|