amicus 4.6.0 → 4.6.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 +128 -0
- package/README.md +2 -2
- package/docs/ROADMAP.md +45 -8
- package/docs/configuration.md +13 -9
- package/docs/council.md +7 -2
- package/docs/publishing.md +1 -1
- package/docs/troubleshooting.md +49 -20
- package/docs/usage.md +21 -1
- package/electron/setup-ui-aliases.js +2 -2
- package/electron/workspace-ui/index.html +3 -0
- package/electron/workspace-ui/live-model.js +71 -0
- package/electron/workspace-ui/workspace-app.js +2 -2
- package/electron/workspace-ui/workspace-panels.js +9 -10
- package/electron/workspace-ui/workspace-seats.js +117 -0
- package/electron/workspace-ui/workspace-verbs.js +1 -0
- package/electron/workspace-ui/workspace.css +6 -0
- package/package.json +1 -1
- package/schemas/alias-audit.schema.json +6 -1
- package/schemas/council-run.schema.json +14 -0
- package/skills/second-opinion/MODEL-NOTES.md +182 -35
- package/src/cli-handlers-doctor.js +16 -4
- package/src/cli.js +4 -0
- package/src/council/run-chair.js +49 -3
- package/src/council/run-launch.js +4 -0
- package/src/council/run-retry-notes.js +74 -0
- package/src/council/run-retry.js +280 -0
- package/src/council/run-stages.js +41 -11
- package/src/council/verdict.js +8 -1
- package/src/headless.js +119 -9
- package/src/mcp-council-awareness.js +1 -0
- package/src/mcp-server.js +17 -2
- package/src/mcp-tools.js +5 -1
- package/src/opencode-client.js +21 -0
- package/src/sidecar/fanout-leg.js +2 -2
- package/src/sidecar/fanout.js +1 -1
- package/src/sidecar/models-probe.js +119 -0
- package/src/sidecar/models.js +81 -6
- package/src/utils/alias-audit.js +52 -1
- package/src/utils/base-url-classify.js +74 -0
- package/src/utils/council-presets.js +6 -2
- package/src/utils/curated-models.js +29 -10
- package/src/utils/degrade.js +1 -0
- package/src/utils/doctor-base-url-check.js +41 -0
- package/src/utils/model-fetcher.js +1 -0
- package/src/utils/model-tiers.js +28 -7
- package/src/utils/no-output-backstop.js +48 -0
- package/src/utils/remediation-hints.js +15 -11
- package/src/utils/result-schema.js +29 -2
- package/src/utils/update-notice.js +171 -0
- package/src/workspace/live-normalize.js +1 -0
package/src/headless.js
CHANGED
|
@@ -454,19 +454,72 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
454
454
|
promptOptions.reasoning = reasoning;
|
|
455
455
|
}
|
|
456
456
|
|
|
457
|
-
//
|
|
457
|
+
// v4.6.2 PR2 amendment (controller live smoke, field evidence): arm BEFORE
|
|
458
|
+
// the prompt send, not after. OpenCode's prompt-send handler can itself
|
|
459
|
+
// block on the upstream provider call before ever returning — a silently-
|
|
460
|
+
// accepting endpoint (the v4.6.1 gemini class) hung the very next line's
|
|
461
|
+
// await for 6+ minutes with the backstop never even created yet, upstream
|
|
462
|
+
// of every mechanism that was supposed to catch it. `startedAt` here means
|
|
463
|
+
// "time since the leg asked for output". Disarmed permanently by the first
|
|
464
|
+
// SUBSTANTIVE-activity tick in the poll loop below (output/tool/result/
|
|
465
|
+
// reasoning/settle — NOT the placeholder-compatible message/assistant-id
|
|
466
|
+
// signals; see substantiveActivity); 0 (or negative) disables — the
|
|
467
|
+
// send itself is unbounded in that case too (see withTimeout below).
|
|
468
|
+
const { resolveNoOutputBackstopMs, createNoOutputBackstop } = require('./utils/no-output-backstop');
|
|
469
|
+
// v4.6.2 PR3 Task 1: Number.isFinite, not `!== undefined` — a non-number
|
|
470
|
+
// (e.g. a string arriving from a CLI/JSON boundary) must fall through to
|
|
471
|
+
// env resolution instead of reaching the deadline arithmetic below.
|
|
472
|
+
// `startedAt + ms` string-concatenates when ms is a string, producing a
|
|
473
|
+
// deadline `nowMs >= deadline` can never satisfy — the backstop would
|
|
474
|
+
// silently never fire. Finite zero (the documented explicit-disable
|
|
475
|
+
// value) still takes the direct branch: Number.isFinite(0) === true.
|
|
476
|
+
const noOutputBackstopMs = Number.isFinite(options.noOutputBackstopMs)
|
|
477
|
+
? options.noOutputBackstopMs : resolveNoOutputBackstopMs(options._env);
|
|
478
|
+
const noOutputBackstop = createNoOutputBackstop({ ms: noOutputBackstopMs, startedAt: Date.now() });
|
|
479
|
+
let backstopFired = false;
|
|
480
|
+
// Single source for the reason string so the pre-send firing site below and
|
|
481
|
+
// the per-poll firing site further down (still ticking the SAME instance)
|
|
482
|
+
// can never drift apart.
|
|
483
|
+
const noOutputBackstopReason = () => 'NO_OUTPUT_BACKSTOP: model produced no '
|
|
484
|
+
+ `output, reasoning, or tool calls in ${Math.round(noOutputBackstopMs / 1000)}s `
|
|
485
|
+
+ '— likely a listed-but-not-serving model or a dead endpoint';
|
|
486
|
+
|
|
487
|
+
// Send prompt asynchronously (returns immediately, we poll for results) —
|
|
488
|
+
// bounded by the backstop: an endpoint that accepts but never answers must
|
|
489
|
+
// not hang this await the way it hung the field-observed leg.
|
|
458
490
|
logger.info('Sending prompt to OpenCode', {
|
|
459
491
|
sessionId,
|
|
460
492
|
model,
|
|
461
493
|
agent: promptOptions.agent,
|
|
462
494
|
userMessageLength: userMessage.length
|
|
463
495
|
});
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
496
|
+
const sendPromptLabel = 'sendPromptAsync';
|
|
497
|
+
const sendPromptPromise = sendPromptAsync(client, sessionId, promptOptions);
|
|
498
|
+
let promptResult = null;
|
|
499
|
+
try {
|
|
500
|
+
promptResult = await withTimeout(sendPromptPromise, noOutputBackstopMs, sendPromptLabel);
|
|
501
|
+
writeProgress(sessionDir, 'prompt_sent');
|
|
502
|
+
logger.info('Prompt sent successfully, entering polling loop', {
|
|
503
|
+
sessionId,
|
|
504
|
+
timeoutMs
|
|
505
|
+
});
|
|
506
|
+
} catch (sendErr) {
|
|
507
|
+
const isBackstopTimeout = noOutputBackstopMs > 0
|
|
508
|
+
&& sendErr.message === `${sendPromptLabel} timed out after ${noOutputBackstopMs}ms`;
|
|
509
|
+
if (!isBackstopTimeout) { throw sendErr; } // a genuine sendPromptAsync failure — unchanged behavior
|
|
510
|
+
// The backstop deadline won the race — OpenCode never returned from the
|
|
511
|
+
// prompt-send call at all; the "accepts, never responds" shape dies
|
|
512
|
+
// upstream of the poll loop entirely. Swallow the orphaned promise so it
|
|
513
|
+
// can never surface as an unhandled rejection whenever/if it eventually
|
|
514
|
+
// settles on its own (Promise.race already subscribes each racer
|
|
515
|
+
// internally, so this is defensive belt-and-suspenders, not load-bearing
|
|
516
|
+
// — verified empirically before relying on it).
|
|
517
|
+
sendPromptPromise.catch(() => {});
|
|
518
|
+
backstopFired = true;
|
|
519
|
+
logger.warn('No-output backstop fired before the prompt send resolved', {
|
|
520
|
+
taskId, sessionId, backstopMs: noOutputBackstopMs,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
470
523
|
|
|
471
524
|
const mirror = createMirrorState();
|
|
472
525
|
let completed = false;
|
|
@@ -474,6 +527,14 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
474
527
|
let aborted = false;
|
|
475
528
|
let sessionError = null; // Captures model/SDK errors from assistant messages
|
|
476
529
|
|
|
530
|
+
// Seed sessionError exactly like the #37 boundary-provider-error case right
|
|
531
|
+
// below does, so the run ends with a usable reason (the poll loop is
|
|
532
|
+
// skipped entirely on this path — see the while-condition and the
|
|
533
|
+
// backstop-abort block further down).
|
|
534
|
+
if (backstopFired) {
|
|
535
|
+
sessionError = noOutputBackstopReason();
|
|
536
|
+
}
|
|
537
|
+
|
|
477
538
|
// Hard provider failure detected at the client boundary (#37): a non-2xx /
|
|
478
539
|
// 402 from promptAsync surfaces here even when the server never emits an
|
|
479
540
|
// assistant message carrying info.error. Seed sessionError so the loop's
|
|
@@ -573,7 +634,14 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
573
634
|
return false;
|
|
574
635
|
};
|
|
575
636
|
|
|
576
|
-
|
|
637
|
+
// `!backstopFired`: a no-op for the pre-existing mid-loop firing path (that
|
|
638
|
+
// branch already `break`s the instant it sets backstopFired, so this outer
|
|
639
|
+
// condition is never re-checked with it true from there) — it only matters
|
|
640
|
+
// for the NEW pre-send-timeout path above, where backstopFired can already
|
|
641
|
+
// be true before the loop ever starts. Skips the loop entirely rather than
|
|
642
|
+
// burning one wasted pollIntervalMs sleep before falling through to the
|
|
643
|
+
// post-loop abort block below.
|
|
644
|
+
while (!completed && !backstopFired && (Date.now() - startTime) < timeoutMs) {
|
|
577
645
|
watchdog.touch();
|
|
578
646
|
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
|
579
647
|
|
|
@@ -729,6 +797,26 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
729
797
|
|| newAssistant || reasoningActivity || settleActivity;
|
|
730
798
|
if (progressed) { lastProgressAt = Date.now(); }
|
|
731
799
|
|
|
800
|
+
// v4.6.2 PR2 amendment 2 (controller live smoke + debug trace): the
|
|
801
|
+
// backstop disarms only on SUBSTANTIVE activity — output, reasoning,
|
|
802
|
+
// or tool motion (the spec's "first token/reasoning/tool_use").
|
|
803
|
+
// messageActivity/newAssistant are excluded: OpenCode creates an empty
|
|
804
|
+
// assistant placeholder on prompt ACCEPTANCE, which is precisely the
|
|
805
|
+
// accepted-but-not-serving bookkeeping the backstop must not trust.
|
|
806
|
+
// `progressed` itself (and every stall/idle consumer of it above) is
|
|
807
|
+
// deliberately untouched — this is a narrower, backstop-only signal.
|
|
808
|
+
const substantiveActivity = outputGrew || toolActivity || resultActivity
|
|
809
|
+
|| reasoningActivity || settleActivity;
|
|
810
|
+
|
|
811
|
+
// No-output backstop: one tick per poll. Fired is terminal — break the
|
|
812
|
+
// loop; the post-loop block below mirrors the timeout path.
|
|
813
|
+
if (noOutputBackstop.tick(substantiveActivity, Date.now()) === 'fired') {
|
|
814
|
+
backstopFired = true;
|
|
815
|
+
sessionError = noOutputBackstopReason();
|
|
816
|
+
logger.warn('No-output backstop fired', { taskId, backstopMs: noOutputBackstopMs });
|
|
817
|
+
break;
|
|
818
|
+
}
|
|
819
|
+
|
|
732
820
|
// B53: a wedged tool call (tool_use emitted, result never arrives) otherwise
|
|
733
821
|
// burns the full --timeout with zero output — the stable-poll idle gate above
|
|
734
822
|
// requires mirror.output.length > 0, which a pre-text wedge never satisfies.
|
|
@@ -832,7 +920,15 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
832
920
|
});
|
|
833
921
|
|
|
834
922
|
// Handle timeout
|
|
835
|
-
|
|
923
|
+
// v4.6.2 PR2 fix wave: `!backstopFired` — the backstop's own break can
|
|
924
|
+
// land after the post-break poll tail (getMessages + mirror processing
|
|
925
|
+
// already inside that iteration) has ALSO crossed timeoutMs when the two
|
|
926
|
+
// thresholds are configured close together, so this block must yield
|
|
927
|
+
// once the backstop already ended the leg. Exactly one terminal-timing
|
|
928
|
+
// signal per leg: statusFromResult() (src/utils/result-schema.js) checks
|
|
929
|
+
// timedOut BEFORE error, so a leg carrying both would misreport as an
|
|
930
|
+
// ordinary 'timeout' instead of the distinctly-named backstop reason.
|
|
931
|
+
if (!completed && !aborted && !backstopFired && (Date.now() - startTime) >= timeoutMs) {
|
|
836
932
|
timedOut = true;
|
|
837
933
|
logger.warn('Task timed out', { taskId, elapsed: Date.now() - startTime });
|
|
838
934
|
|
|
@@ -846,6 +942,20 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
846
942
|
}
|
|
847
943
|
}
|
|
848
944
|
|
|
945
|
+
// Backstop fired: abort the OpenCode session exactly like the timeout path
|
|
946
|
+
// (the agent keeps running otherwise). The leg's error already carries the
|
|
947
|
+
// NO_OUTPUT_BACKSTOP reason; no separate degrade machinery — the ordinary
|
|
948
|
+
// dead-leg path (SL-2 retry, sink announcement, exit codes) inherits it.
|
|
949
|
+
if (backstopFired && !completed && !aborted) {
|
|
950
|
+
try {
|
|
951
|
+
const { abortSession } = require('./opencode-client');
|
|
952
|
+
await abortSession(client, sessionId, ...dirArgs);
|
|
953
|
+
logger.info('Session aborted after no-output backstop', { taskId, sessionId });
|
|
954
|
+
} catch (abortErr) {
|
|
955
|
+
logger.warn('Failed to abort session after backstop', { error: abortErr.message });
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
849
959
|
watchdog.cancel();
|
|
850
960
|
if (uninstallSignals) { uninstallSignals(); }
|
|
851
961
|
|
|
@@ -185,6 +185,7 @@ function buildCouncilStatusPayload(project, taskId) {
|
|
|
185
185
|
legsTotal, legsComplete, elapsed: elapsedOf(run),
|
|
186
186
|
exitCode: run.exitCode !== undefined ? run.exitCode : null,
|
|
187
187
|
version: RUNNING_VERSION,
|
|
188
|
+
degrades: run.degrades || [],
|
|
188
189
|
};
|
|
189
190
|
if (usageLegs.length) { payload.usage = rollupWaveUsage(usageLegs); }
|
|
190
191
|
if (allLegIds.length) {
|
package/src/mcp-server.js
CHANGED
|
@@ -1459,6 +1459,21 @@ async function startMcpServer() {
|
|
|
1459
1459
|
{ capabilities: { roots: {} } }
|
|
1460
1460
|
);
|
|
1461
1461
|
|
|
1462
|
+
// MCP update notice (spec 2026-08-03): the MCP server replaces the CLI's
|
|
1463
|
+
// deliberately-skipped pre-command update check. Fire-and-forget — startup
|
|
1464
|
+
// is never delayed; when the async init resolves with an update known, one
|
|
1465
|
+
// stderr line lands in the client's MCP log. The per-result notice itself
|
|
1466
|
+
// is appended by maybeAppendUpdateNotice in the registration wrapper below.
|
|
1467
|
+
const { initUpdateCheck, getUpdateInfo } = require('./utils/updater');
|
|
1468
|
+
const { maybeAppendUpdateNotice } = require('./utils/update-notice');
|
|
1469
|
+
initUpdateCheck().then(() => {
|
|
1470
|
+
const updateInfo = getUpdateInfo();
|
|
1471
|
+
if (updateInfo && updateInfo.hasUpdate) {
|
|
1472
|
+
process.stderr.write(
|
|
1473
|
+
`[amicus] update available: v${updateInfo.current} -> v${updateInfo.latest}\n`);
|
|
1474
|
+
}
|
|
1475
|
+
}).catch(() => { /* advisory only */ });
|
|
1476
|
+
|
|
1462
1477
|
for (const tool of getTools()) {
|
|
1463
1478
|
const register = (name) => server.registerTool(
|
|
1464
1479
|
name,
|
|
@@ -1466,11 +1481,11 @@ async function startMcpServer() {
|
|
|
1466
1481
|
async (input) => {
|
|
1467
1482
|
try {
|
|
1468
1483
|
const project = await resolveProjectDir(input.project, server);
|
|
1469
|
-
return await handlers[tool.name](input, project, server);
|
|
1484
|
+
return maybeAppendUpdateNotice(await handlers[tool.name](input, project, server));
|
|
1470
1485
|
}
|
|
1471
1486
|
catch (err) {
|
|
1472
1487
|
logger.error(`MCP tool error: ${name}`, { error: err.message });
|
|
1473
|
-
return textResult(`Error: ${err.message}`, true);
|
|
1488
|
+
return maybeAppendUpdateNotice(textResult(`Error: ${err.message}`, true));
|
|
1474
1489
|
}
|
|
1475
1490
|
}
|
|
1476
1491
|
);
|
package/src/mcp-tools.js
CHANGED
|
@@ -574,9 +574,13 @@ function getGuideText() {
|
|
|
574
574
|
.map(([name, model]) => `| ${name} | ${model} |`)
|
|
575
575
|
.join('\n');
|
|
576
576
|
// #33: surface the running version (and a call-time staleness warning) so a
|
|
577
|
-
// post-upgrade agent session can tell it's running old code.
|
|
577
|
+
// post-upgrade agent session can tell it's running old code. Spec 2026-08-03
|
|
578
|
+
// adds the registry-side sibling: a newer release exists (not latched here —
|
|
579
|
+
// the guide is the on-demand surface).
|
|
578
580
|
const warn = versionWarning();
|
|
581
|
+
const updateLine = require('./utils/update-notice').guideUpdateLine();
|
|
579
582
|
const versionLine = `**Running amicus version:** ${RUNNING_VERSION}`
|
|
583
|
+
+ (updateLine ? `\n\n> ${updateLine}` : '')
|
|
580
584
|
+ (warn ? `\n\n> ⚠️ ${warn}` : '');
|
|
581
585
|
|
|
582
586
|
return `# Amicus Usage Guide
|
package/src/opencode-client.js
CHANGED
|
@@ -557,6 +557,27 @@ function buildServerOptions(options = {}) {
|
|
|
557
557
|
: (options.model ? [options.model] : []);
|
|
558
558
|
config.provider = buildProviderModels(resolvedForProvider);
|
|
559
559
|
|
|
560
|
+
// v4.6.2 PR1 (spec §4, D1/D2): a host-form ANTHROPIC_BASE_URL is correct
|
|
561
|
+
// for Anthropic SDKs (they append /v1) and fatal for OpenCode's
|
|
562
|
+
// direct-anthropic provider (it appends /messages -> 404). Carry the
|
|
563
|
+
// normalized full-prefix form as a provider-config override — config-level,
|
|
564
|
+
// no process env is written anywhere. AMICUS_BASE_URL_NORMALIZE=0 disables.
|
|
565
|
+
// Merge order keeps any existing options.baseURL authoritative (M-5 lesson:
|
|
566
|
+
// never clobber a user-authored value with a derived one).
|
|
567
|
+
const { resolveBaseUrlOverride, announceBaseUrlNormalizationOnce } = require('./utils/base-url-classify');
|
|
568
|
+
const baseUrlEnv = options._env || process.env;
|
|
569
|
+
const anthropicBaseUrl = resolveBaseUrlOverride(baseUrlEnv);
|
|
570
|
+
if (anthropicBaseUrl) {
|
|
571
|
+
if (!Object.prototype.hasOwnProperty.call(config.provider, 'anthropic')) {
|
|
572
|
+
config.provider.anthropic = { models: {} };
|
|
573
|
+
}
|
|
574
|
+
config.provider.anthropic.options = {
|
|
575
|
+
baseURL: anthropicBaseUrl,
|
|
576
|
+
...(config.provider.anthropic.options || {}),
|
|
577
|
+
};
|
|
578
|
+
announceBaseUrlNormalizationOnce(baseUrlEnv.ANTHROPIC_BASE_URL, anthropicBaseUrl, options._noticeDeps);
|
|
579
|
+
}
|
|
580
|
+
|
|
560
581
|
// Register custom 'chat' agent: reads auto-approved, writes/bash require permission
|
|
561
582
|
const chatAgent = {
|
|
562
583
|
description: 'Conversational agent — reads are auto-approved, writes and commands require permission',
|
|
@@ -74,7 +74,7 @@ function buildRoutingFailureLeg({ leg, legId, waveId, quiet }) {
|
|
|
74
74
|
* Adds `.reason` (alias of buildRunResult's `.error`) and `.legId` so the
|
|
75
75
|
* fallback loop reads a stable shape without re-deriving them.
|
|
76
76
|
*/
|
|
77
|
-
async function runSingleAttempt({ leg, legId, waveId, project, directory, follow, systemPrompt, userMessage, timeoutMs, agent, client, server, summaryLength, reasoning, quiet, foldNonce }) {
|
|
77
|
+
async function runSingleAttempt({ leg, legId, waveId, project, directory, follow, systemPrompt, userMessage, timeoutMs, agent, client, server, summaryLength, reasoning, quiet, foldNonce, noOutputBackstopMs }) {
|
|
78
78
|
const { IdleWatchdog } = require('../utils/idle-watchdog');
|
|
79
79
|
const { markAborted } = require('../utils/session-abort');
|
|
80
80
|
const { runHeadless } = require('../headless');
|
|
@@ -121,7 +121,7 @@ async function runSingleAttempt({ leg, legId, waveId, project, directory, follow
|
|
|
121
121
|
result = await runHeadless(
|
|
122
122
|
leg.model, systemPrompt, userMessage, legId, project,
|
|
123
123
|
timeoutMs, agent || 'build',
|
|
124
|
-
{ client, server, watchdog, summaryLength, reasoning, nonce: foldNonce, directory }
|
|
124
|
+
{ client, server, watchdog, summaryLength, reasoning, nonce: foldNonce, directory, noOutputBackstopMs }
|
|
125
125
|
);
|
|
126
126
|
} catch (err) {
|
|
127
127
|
result = { summary: '', completed: false, timedOut: false, aborted: false, error: err.message, taskId: legId };
|
package/src/sidecar/fanout.js
CHANGED
|
@@ -267,7 +267,7 @@ async function runFanout(options) {
|
|
|
267
267
|
timeoutMs, agent: options.agent, client, server,
|
|
268
268
|
summaryLength: options.summaryLength, reasoning, quiet: options.quiet,
|
|
269
269
|
foldNonce, directory: options.directory, follow,
|
|
270
|
-
fallback: options.fallback, catalog: options.catalog,
|
|
270
|
+
fallback: options.fallback, catalog: options.catalog, noOutputBackstopMs: options.noOutputBackstopMs,
|
|
271
271
|
});
|
|
272
272
|
}));
|
|
273
273
|
} finally {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// src/sidecar/models-probe.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module models-probe
|
|
6
|
+
* v4.6.2 PR3 (spec §6, D5): `models --check --live` probe tier. Presence in
|
|
7
|
+
* the catalog is not proof of service — a stored alias can point at a model
|
|
8
|
+
* id the catalog still lists but the provider no longer actually serves (the
|
|
9
|
+
* v4.6.1 `gemini` incident: stored `google/gemini-3.1-flash-lite-preview`,
|
|
10
|
+
* catalog-live, silently dead). This module is the check that would have
|
|
11
|
+
* caught it: probe every STORED alias with one ordinary engine leg — real
|
|
12
|
+
* session dir, real spend-ledger row (D5) — on a single quiet fanout wave,
|
|
13
|
+
* and classify each leg served / accepted-but-silent / error.
|
|
14
|
+
*
|
|
15
|
+
* Never called without `--live`; the spend gate lives in the CLI layer
|
|
16
|
+
* (src/sidecar/models.js), not here — this module always spends when called.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Probe backstop override (spec D5) — a fixed constant, NOT env-configurable;
|
|
20
|
+
* the env knob (AMICUS_NO_OUTPUT_BACKSTOP_MS) stays the ordinary 120s leg default. */
|
|
21
|
+
const PROBE_WINDOW_MS = 30000;
|
|
22
|
+
|
|
23
|
+
/** Fixed tiny prompt — a probe leg only needs to prove the model answers at all. */
|
|
24
|
+
const PROBE_PROMPT = 'Reply with exactly: OK';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Classify one leg run-document (buildRunResult shape, src/utils/result-
|
|
28
|
+
* schema.js) per the plan's Global Constraints classification contract.
|
|
29
|
+
* Precedence matters: 'complete' wins outright; otherwise a NO_OUTPUT_
|
|
30
|
+
* BACKSTOP error (PR2's silent-leg detector, armed here at PROBE_WINDOW_MS
|
|
31
|
+
* instead of its 120s default) is the one specific error shape that means
|
|
32
|
+
* "the model accepted the request and never produced a token" rather than an
|
|
33
|
+
* ordinary routing/auth/timeout failure.
|
|
34
|
+
* @param {{status?:string, error?:string|null}} leg
|
|
35
|
+
* @returns {'served'|'accepted-but-silent'|'error'}
|
|
36
|
+
*/
|
|
37
|
+
function classifyLeg(leg) {
|
|
38
|
+
if (leg.status === 'complete') { return 'served'; }
|
|
39
|
+
if (typeof leg.error === 'string' && /^NO_OUTPUT_BACKSTOP:/.test(leg.error)) { return 'accepted-but-silent'; }
|
|
40
|
+
return 'error';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Stored (user-config) aliases only — the `--live` probe's scope (spec §6):
|
|
45
|
+
* defaults/curated-route rows follow the catalog by construction and have no
|
|
46
|
+
* "was it actually served" question for a live probe to answer. Exported so
|
|
47
|
+
* the CLI's cap pre-check (models.js) and this module share one predicate.
|
|
48
|
+
* @param {Array<{source:string}>} sources collectAliasSources() output
|
|
49
|
+
* @returns {Array<{alias:string,model:string,source:string}>}
|
|
50
|
+
*/
|
|
51
|
+
function selectStoredAliases(sources) {
|
|
52
|
+
return sources.filter(s => s.source === 'user-config');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Probe every STORED alias with one ordinary engine leg (real session dirs,
|
|
57
|
+
* real spend rows — D5) on one quiet fanout wave. Returns per-alias outcomes;
|
|
58
|
+
* never called without --live (the spend gate lives in the CLI layer).
|
|
59
|
+
* @param {{project?:string}} opts
|
|
60
|
+
* @param {{runFanout?:Function, collectAliasSources?:Function}} [deps]
|
|
61
|
+
* @returns {Promise<{results:Array<{alias:string,target:string,outcome:'served'|'accepted-but-silent'|'error',detail:string|null,cost:number|null}>, waveId:string|null}>}
|
|
62
|
+
*/
|
|
63
|
+
async function probeStoredAliases(opts = {}, deps = {}) {
|
|
64
|
+
const collectAliasSources = deps.collectAliasSources || require('../utils/alias-audit').collectAliasSources;
|
|
65
|
+
const runFanout = deps.runFanout || require('./fanout').runFanout;
|
|
66
|
+
|
|
67
|
+
const stored = selectStoredAliases(collectAliasSources());
|
|
68
|
+
if (stored.length === 0) { return { results: [], waveId: null }; }
|
|
69
|
+
|
|
70
|
+
// runFanout's `models` is the same comma-separated STRING the CLI --models
|
|
71
|
+
// flag takes (validateFanoutModels -> parseModelsList splits it back apart)
|
|
72
|
+
// — NOT an array; see council/run-launch.js's launchWave for the identical
|
|
73
|
+
// `.join(',')` seam. An array here would parse to [] and fail the whole
|
|
74
|
+
// wave with BAD_ARGS.
|
|
75
|
+
const { wave, errorDoc } = await runFanout({
|
|
76
|
+
models: stored.map(s => s.model).join(','),
|
|
77
|
+
prompt: PROBE_PROMPT,
|
|
78
|
+
quiet: true,
|
|
79
|
+
noOutputBackstopMs: PROBE_WINDOW_MS,
|
|
80
|
+
timeout: 2, // minutes — the overall ceiling behind the backstop
|
|
81
|
+
project: opts.project,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Final-review blocker 1: two classes of wave never reach leg-creation at
|
|
85
|
+
// all — the budget preflight refusing before any session exists (failPre ->
|
|
86
|
+
// {wave: null, errorDoc}) or the shared server failing to start (errorWave ->
|
|
87
|
+
// {legs: [], error: message}) — so EVERY stored alias would otherwise hit
|
|
88
|
+
// the `legs[i] || {}` fallback and fabricate a generic `detail: null` row,
|
|
89
|
+
// which the CLI's `${head} — ${r.detail}` template renders as the literal
|
|
90
|
+
// string "null", masking the real reason (worse with `quiet: true`, which
|
|
91
|
+
// suppresses every other print this failure would normally surface on).
|
|
92
|
+
// Both failure classes carry a real message; thread it onto every row that
|
|
93
|
+
// has no leg of its own to explain itself.
|
|
94
|
+
const waveFailure = errorDoc ? errorDoc.message : ((wave && wave.error) || null);
|
|
95
|
+
|
|
96
|
+
// Positional zip, not a model-id lookup: deriveLegIds (fanout.js) assigns
|
|
97
|
+
// legs 1:1 in --models order, and two stored aliases may legitimately share
|
|
98
|
+
// one target model, so a leg's own identity can't disambiguate which alias
|
|
99
|
+
// it answers for — only its index can.
|
|
100
|
+
const legs = (wave && wave.legs) || [];
|
|
101
|
+
const results = stored.map((s, i) => {
|
|
102
|
+
const leg = legs[i] || {};
|
|
103
|
+
const outcome = classifyLeg(leg);
|
|
104
|
+
const cost = (leg.usage && leg.usage.cost && typeof leg.usage.cost.amount === 'number')
|
|
105
|
+
? leg.usage.cost.amount
|
|
106
|
+
: null;
|
|
107
|
+
return {
|
|
108
|
+
alias: s.alias,
|
|
109
|
+
target: s.model,
|
|
110
|
+
outcome,
|
|
111
|
+
detail: leg.error || waveFailure || null,
|
|
112
|
+
cost,
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return { results, waveId: (wave && wave.waveId) || null };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = { probeStoredAliases, selectStoredAliases, PROBE_WINDOW_MS, PROBE_PROMPT };
|
package/src/sidecar/models.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* amicus models --search <q> substring filter over id+name
|
|
6
6
|
* amicus models --refresh force-refresh the cache
|
|
7
7
|
* amicus models --check stale-alias audit (exit = stale count, max 100)
|
|
8
|
+
* amicus models --check --live + probe every stored alias with a real leg (spends)
|
|
8
9
|
* --json on all of the above versioned documents (result-schema)
|
|
9
10
|
*
|
|
10
11
|
* Returns an exit code; bin/amicus.js plumbs it like fanout's.
|
|
@@ -13,11 +14,13 @@
|
|
|
13
14
|
'use strict';
|
|
14
15
|
|
|
15
16
|
const { getCatalogInfo, refreshCatalog, catalogPath } = require('../utils/model-catalog');
|
|
16
|
-
const { collectAliasSources, findStaleAliases, suggestReplacements } = require('../utils/alias-audit');
|
|
17
|
+
const { collectAliasSources, findStaleAliases, findDriftedStoredAliases, suggestReplacements } = require('../utils/alias-audit');
|
|
17
18
|
const { auditGatewayRoutes } = require('../utils/gateway-route-audit');
|
|
18
19
|
const { buildCatalogDoc, buildAuditDoc } = require('../utils/result-schema');
|
|
19
20
|
const { getFamilies } = require('../utils/curated-models');
|
|
20
21
|
const { pickCurrent } = require('../utils/quick-picks');
|
|
22
|
+
const { probeStoredAliases, selectStoredAliases } = require('./models-probe');
|
|
23
|
+
const { DEFAULT_MAX_LEGS } = require('./fanout-validate');
|
|
21
24
|
|
|
22
25
|
const CHECK_EXIT_CAP = 100;
|
|
23
26
|
|
|
@@ -91,9 +94,19 @@ async function runList(args) {
|
|
|
91
94
|
return 0;
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
// v4.6.2 PR3 Task 4: shared --live skip line; reason doubles as the JSON probeSkipped slug.
|
|
98
|
+
function fmtLiveSkipped(reason) {
|
|
99
|
+
return `--live skipped: ${reason} — nothing was probed`;
|
|
100
|
+
}
|
|
101
|
+
|
|
94
102
|
async function runRefresh(args) {
|
|
95
103
|
const models = await refreshCatalog();
|
|
96
104
|
const { fetchedAt, lastRefreshAttempt, lastRefreshError } = await getCatalogInfo({ maxAgeMs: Number.POSITIVE_INFINITY });
|
|
105
|
+
// --refresh short-circuits --check below (args.check is guaranteed true here) — must announce, not silently skip.
|
|
106
|
+
if (args.live) {
|
|
107
|
+
const line = fmtLiveSkipped('refresh-precedes-check');
|
|
108
|
+
(args.json ? process.stderr : process.stdout).write(line + '\n');
|
|
109
|
+
}
|
|
97
110
|
if (args.json) {
|
|
98
111
|
process.stdout.write(JSON.stringify(buildCatalogDoc({
|
|
99
112
|
models, fetchedAt, refreshed: true, lastRefreshAttempt, lastRefreshError
|
|
@@ -128,41 +141,87 @@ function fmtGatewayFinding(f) {
|
|
|
128
141
|
return ` GATEWAY DIVERGENT: ${f.alias} direct form ${f.model} no longer matches catalog (now ${f.expected})`;
|
|
129
142
|
}
|
|
130
143
|
|
|
144
|
+
const PROBE_LABELS = { served: 'SERVED', 'accepted-but-silent': 'SILENT', error: 'ERROR' };
|
|
145
|
+
|
|
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
|
+
|
|
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} (accepted but not serving)`; }
|
|
161
|
+
return `${head} — ${r.detail}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
131
164
|
async function runCheck(args) {
|
|
132
165
|
const catalogInfo = await getCatalogInfo();
|
|
133
166
|
const catalog = catalogInfo.models;
|
|
134
167
|
if (!catalog || catalog.length === 0) {
|
|
168
|
+
const probeSkipped = args.live ? 'catalog-unavailable' : null;
|
|
135
169
|
if (args.json) {
|
|
136
170
|
process.stdout.write(JSON.stringify(buildAuditDoc({
|
|
137
|
-
stale: [], catalogAvailable: false
|
|
171
|
+
stale: [], catalogAvailable: false, probeSkipped
|
|
138
172
|
}), null, 2) + '\n');
|
|
139
173
|
} else {
|
|
140
174
|
process.stdout.write('Catalog unavailable (offline or no providers reachable); cannot check.\n');
|
|
175
|
+
if (probeSkipped) { process.stdout.write(fmtLiveSkipped(probeSkipped) + '\n'); }
|
|
141
176
|
}
|
|
142
177
|
return 0;
|
|
143
178
|
}
|
|
144
179
|
const sources = collectAliasSources();
|
|
145
180
|
const stale = findStaleAliases(sources, catalog)
|
|
146
181
|
.map(s => ({ ...s, suggestions: suggestReplacements(s.model, catalog) }));
|
|
182
|
+
const drifted = findDriftedStoredAliases(sources, catalog);
|
|
147
183
|
// Task 6 (#gwid): per-gateway-form audit of the curated DEFAULTS
|
|
148
184
|
// (toGatewayRoutes()) — additive to the flat audit above. Informational by
|
|
149
185
|
// default; --strict promotes it to a build-breaking exit code (CI gate).
|
|
150
186
|
const gatewayFindings = auditGatewayRoutes(catalogInfo);
|
|
151
187
|
const legacyExitCode = Math.min(stale.length, CHECK_EXIT_CAP);
|
|
152
|
-
|
|
188
|
+
let exitCode = args.strict
|
|
153
189
|
? Math.max(legacyExitCode, Math.min(gatewayFindings.length, CHECK_EXIT_CAP))
|
|
154
190
|
: legacyExitCode;
|
|
155
191
|
|
|
192
|
+
// v4.6.2 PR3 (spec §6, D5): opt-in --live probe of stored aliases with real
|
|
193
|
+
// engine legs. Never spends without --live — probeStoredAliases is only
|
|
194
|
+
// ever called inside this block (regression-tested: a mocked module must
|
|
195
|
+
// see zero calls when the flag is absent). The cap pre-check runs BEFORE
|
|
196
|
+
// the call so a doomed wave never spends a token (Task 2 review carry-in:
|
|
197
|
+
// without it, runFanout fails wave-creation and models-probe.js degrades
|
|
198
|
+
// every row to a generic error, losing the real reason).
|
|
199
|
+
let probeResults = [];
|
|
200
|
+
if (args.live) {
|
|
201
|
+
const storedCount = selectStoredAliases(sources).length;
|
|
202
|
+
const envCap = Number(process.env.AMICUS_FANOUT_MAX_LEGS);
|
|
203
|
+
const maxLegs = (Number.isInteger(envCap) && envCap > 0) ? envCap : DEFAULT_MAX_LEGS;
|
|
204
|
+
if (storedCount > maxLegs) {
|
|
205
|
+
process.stderr.write(`Error: --live would probe ${storedCount} stored aliases, exceeding the `
|
|
206
|
+
+ `fan-out cap of ${maxLegs} (set AMICUS_FANOUT_MAX_LEGS to raise)\n`);
|
|
207
|
+
return 1;
|
|
208
|
+
}
|
|
209
|
+
const probe = await probeStoredAliases({ project: args.cwd || process.cwd() });
|
|
210
|
+
probeResults = probe.results;
|
|
211
|
+
const nonServed = probeResults.filter(r => r.outcome !== 'served').length;
|
|
212
|
+
exitCode = Math.max(exitCode, Math.min(nonServed, CHECK_EXIT_CAP));
|
|
213
|
+
}
|
|
214
|
+
|
|
156
215
|
if (args.json) {
|
|
157
216
|
process.stdout.write(JSON.stringify(buildAuditDoc({
|
|
158
|
-
stale, catalogAvailable: true, gatewayFindings
|
|
217
|
+
stale, catalogAvailable: true, gatewayFindings, drifted, probe: probeResults
|
|
159
218
|
}), null, 2) + '\n');
|
|
160
219
|
return exitCode;
|
|
161
220
|
}
|
|
162
221
|
const driftLines = buildFallbackDriftReport(catalog);
|
|
163
|
-
if (stale.length === 0) {
|
|
222
|
+
if (stale.length === 0 && drifted.length === 0) {
|
|
164
223
|
process.stdout.write(`All aliases resolve to catalog models (${sources.length} checked).\n`);
|
|
165
|
-
} else {
|
|
224
|
+
} else if (stale.length > 0) {
|
|
166
225
|
for (const s of stale) {
|
|
167
226
|
process.stdout.write(`STALE: ${s.alias} -> ${s.model} (${s.source})\n`);
|
|
168
227
|
if (s.suggestions.length > 0) {
|
|
@@ -173,10 +232,22 @@ async function runCheck(args) {
|
|
|
173
232
|
}
|
|
174
233
|
}
|
|
175
234
|
}
|
|
235
|
+
for (const dr of drifted) {
|
|
236
|
+
process.stdout.write(`DRIFTED: ${dr.alias} -> ${dr.stored} (stored; current resolution: ${dr.current})\n`);
|
|
237
|
+
process.stdout.write(` stored aliases don't follow catalog updates — refresh: amicus setup --add-alias ${dr.alias}=${dr.current}\n`);
|
|
238
|
+
}
|
|
176
239
|
if (driftLines.length > 0) {
|
|
177
240
|
process.stdout.write('Pinned fallback drift:\n');
|
|
178
241
|
for (const l of driftLines) { process.stdout.write(l + '\n'); }
|
|
179
242
|
}
|
|
243
|
+
if (args.live) {
|
|
244
|
+
if (probeResults.length === 0) {
|
|
245
|
+
process.stdout.write('Live probe: no stored aliases to probe\n');
|
|
246
|
+
} else {
|
|
247
|
+
process.stdout.write(`Live probe (${probeResults.length} stored aliases):\n`);
|
|
248
|
+
for (const r of probeResults) { process.stdout.write(fmtProbeLine(r) + '\n'); }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
180
251
|
if (gatewayFindings.length > 0) {
|
|
181
252
|
process.stdout.write('Per-gateway route audit (curated defaults):\n');
|
|
182
253
|
for (const f of gatewayFindings) { process.stdout.write(fmtGatewayFinding(f) + '\n'); }
|
|
@@ -209,6 +280,10 @@ async function handleModels(args) {
|
|
|
209
280
|
process.stderr.write('Error: --search requires a value\n');
|
|
210
281
|
return 1;
|
|
211
282
|
}
|
|
283
|
+
if (args.live && !args.check) {
|
|
284
|
+
process.stderr.write('Error: --live requires --check\n');
|
|
285
|
+
return 1;
|
|
286
|
+
}
|
|
212
287
|
if (args.refresh) { return runRefresh(args); }
|
|
213
288
|
if (args.check) { return runCheck(args); }
|
|
214
289
|
return runList(args);
|
package/src/utils/alias-audit.js
CHANGED
|
@@ -108,4 +108,55 @@ function suggestReplacements(staleModel, catalog, n = 3) {
|
|
|
108
108
|
.slice(0, n);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Stored aliases whose target is LIVE in the catalog but no longer what a
|
|
113
|
+
* fresh `amicus setup` would seed today — the v4.6.1 release-gate class
|
|
114
|
+
* (stored `gemini` -> 3.1-flash-lite-preview: still catalog-listed so
|
|
115
|
+
* findStaleAliases passes it, no longer what the family resolves to).
|
|
116
|
+
* Report + suggest, never auto-repair (this module's charter).
|
|
117
|
+
*
|
|
118
|
+
* Only user-config rows are checked (defaults/curated follow the catalog by
|
|
119
|
+
* construction), only for aliases that are quick-pick families (a custom
|
|
120
|
+
* alias has no "current" to drift from), and only when the stored target is
|
|
121
|
+
* itself catalog-live (a dead target is findStaleAliases's finding, not
|
|
122
|
+
* ours). The `current` display value goes through toStorableRoute() — the
|
|
123
|
+
* guarded 4.1.2 helper — never a bare prefix strip (spec D3).
|
|
124
|
+
*
|
|
125
|
+
* Drift membership, however, is NOT a raw compare against that single
|
|
126
|
+
* canonicalized display string. toStorableRoute() canonicalizes a
|
|
127
|
+
* direct-capable vendor's OpenRouter pick down to the bare direct form
|
|
128
|
+
* (e.g. 'google/gemini-3.6-flash'), but a stored alias may legitimately hold
|
|
129
|
+
* the gateway-prefixed form of that SAME model ('openrouter/google/gemini-
|
|
130
|
+
* 3.6-flash' — the exact route pickCurrent/resolveQuickPicks resolves live,
|
|
131
|
+
* and what a STALE fix's own suggestion may have pointed a user to store).
|
|
132
|
+
* Comparing only against the canonicalized string would false-positive that
|
|
133
|
+
* as drift. Instead, a stored row only counts as drift when its model is
|
|
134
|
+
* absent from the family's FULL live route-value set (every value in that
|
|
135
|
+
* family's `routes` map — openrouter form and any direct form together, per
|
|
136
|
+
* resolveQuickPicks) — i.e. it names a genuinely different model, not the
|
|
137
|
+
* same model under a different gateway form.
|
|
138
|
+
* @param {Array<{alias:string,model:string,source:string}>} sources
|
|
139
|
+
* @param {Array<{id:string}>} catalog
|
|
140
|
+
* @returns {Array<{alias:string,stored:string,current:string}>}
|
|
141
|
+
*/
|
|
142
|
+
function findDriftedStoredAliases(sources, catalog) {
|
|
143
|
+
if (!catalog || catalog.length === 0) { return []; }
|
|
144
|
+
const { resolveQuickPicks, toStorableRoute } = require('./quick-picks');
|
|
145
|
+
const current = new Map();
|
|
146
|
+
for (const r of resolveQuickPicks(catalog)) {
|
|
147
|
+
if (r.source !== 'live') { continue; }
|
|
148
|
+
const stored = toStorableRoute(r);
|
|
149
|
+
if (stored) { current.set(r.alias, { display: stored, routeValues: new Set(Object.values(r.routes)) }); }
|
|
150
|
+
}
|
|
151
|
+
const byProvider = idsByProvider(catalog);
|
|
152
|
+
return sources
|
|
153
|
+
.filter(({ source }) => source === 'user-config')
|
|
154
|
+
.filter(({ model }) => {
|
|
155
|
+
const ids = byProvider.get(model.split('/')[0]);
|
|
156
|
+
return !!(ids && ids.has(model));
|
|
157
|
+
})
|
|
158
|
+
.filter(({ alias, model }) => current.has(alias) && !current.get(alias).routeValues.has(model))
|
|
159
|
+
.map(({ alias, model }) => ({ alias, stored: model, current: current.get(alias).display }));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { collectAliasSources, findStaleAliases, findDriftedStoredAliases, suggestReplacements };
|