amicus 4.9.3 → 4.9.4
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 +242 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +5 -4
- package/docs/architecture-map.md +732 -0
- package/docs/configuration.md +148 -26
- package/docs/council.md +9 -0
- package/docs/doc-system.md +12 -9
- package/docs/testing.md +2 -1
- package/docs/troubleshooting.md +76 -0
- package/docs/usage.md +11 -6
- package/package.json +1 -1
- package/schemas/model-catalog.schema.json +2 -1
- package/schemas/run.schema.json +13 -0
- package/skills/sidecar/SKILL.md +1 -8
- package/src/cli-handlers-doctor.js +3 -0
- package/src/cli-handlers-fanout.js +10 -1
- package/src/cli-handlers-resume-continue.js +25 -0
- package/src/cli.js +5 -8
- package/src/council/briefings-chair.js +4 -2
- package/src/council/run-assemble.js +7 -2
- package/src/council/run-retry-notes.js +21 -1
- package/src/council/run-stages.js +8 -1
- package/src/headless.js +125 -7
- package/src/mcp-server.js +26 -0
- package/src/mcp-tools.js +4 -4
- package/src/opencode-client.js +84 -8
- package/src/pack/pack-validate.js +3 -0
- package/src/session-manager.js +2 -2
- package/src/sidecar/continue.js +6 -1
- package/src/sidecar/conversation-mirror.js +35 -11
- package/src/sidecar/fanout-leg-fallback.js +1 -0
- package/src/sidecar/fanout-leg.js +10 -2
- package/src/sidecar/fanout.js +2 -2
- package/src/sidecar/interactive.js +31 -4
- package/src/sidecar/models-ceiling-line.js +72 -0
- package/src/sidecar/models.js +4 -2
- package/src/sidecar/reopen-notices.js +97 -0
- package/src/sidecar/reopen-spend.js +3 -2
- package/src/sidecar/resume.js +15 -2
- package/src/sidecar/session-finalize.js +4 -1
- package/src/sidecar/session-utils.js +5 -1
- package/src/sidecar/start-metadata.js +1 -1
- package/src/sidecar/start.js +10 -5
- package/src/utils/config.js +33 -12
- package/src/utils/curated-models.js +8 -8
- package/src/utils/degrade.js +7 -0
- package/src/utils/doctor-output-budget-check.js +198 -0
- package/src/utils/engine-output-flag.js +105 -0
- package/src/utils/engine-variants.js +298 -0
- package/src/utils/http-get.js +284 -0
- package/src/utils/model-catalog.js +36 -4
- package/src/utils/model-ceilings-modelsdev.js +230 -0
- package/src/utils/model-fetcher.js +12 -36
- package/src/utils/model-output-limit.js +21 -13
- package/src/utils/output-length.js +90 -0
- package/src/utils/result-schema.js +7 -2
- package/src/utils/spend-ledger.js +5 -1
- package/src/utils/thinking-validators.js +27 -80
- package/src/utils/validators.js +2 -3
|
@@ -20,6 +20,10 @@ const path = require('path');
|
|
|
20
20
|
function _getConfigDir() { return require('./config').getConfigDir(); }
|
|
21
21
|
function _readApiKeyValues() { return require('./api-key-store').readApiKeyValues(); }
|
|
22
22
|
async function _fetchAllModels(keys) { return require('./model-fetcher').fetchAllModelsDetailed(keys); }
|
|
23
|
+
async function _enrichCeilings(rows) { return require('./model-ceilings-modelsdev').enrichCeilings(rows); }
|
|
24
|
+
function _emptyOutcome(failure) { return require('./model-ceilings-modelsdev').emptyOutcome(failure); }
|
|
25
|
+
/** #218 P3 opt-out: `modelsDevCeilings: false` in config.json, and ONLY a literal false. */
|
|
26
|
+
function _modelsDevEnabled() { return require('./config').loadConfig()?.modelsDevCeilings !== false; }
|
|
23
27
|
|
|
24
28
|
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
|
|
25
29
|
const CATALOG_SCHEMA_VERSION = 2;
|
|
@@ -68,8 +72,13 @@ function writeCacheDoc(doc) {
|
|
|
68
72
|
}
|
|
69
73
|
}
|
|
70
74
|
|
|
71
|
-
/**
|
|
72
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Write a successful fetch: fresh models/fetchedAt, outcome fields cleared.
|
|
77
|
+
* @param {Array} models
|
|
78
|
+
* @param {Array} [providerFailures]
|
|
79
|
+
* @param {object|null} [ceilingEnrichment] #218 P3 outcome for THESE rows (model-ceilings-modelsdev.js)
|
|
80
|
+
*/
|
|
81
|
+
function writeCache(models, providerFailures, ceilingEnrichment) {
|
|
73
82
|
writeCacheDoc({
|
|
74
83
|
schemaVersion: CATALOG_SCHEMA_VERSION,
|
|
75
84
|
fetchedAt: Date.now(),
|
|
@@ -78,6 +87,7 @@ function writeCache(models, providerFailures) {
|
|
|
78
87
|
// alongside the rows because it describes THESE rows -- a cache served later
|
|
79
88
|
// is still a catalog whose deepseek namespace is empty for a reason.
|
|
80
89
|
providerFailures: Array.isArray(providerFailures) ? providerFailures : [],
|
|
90
|
+
ceilingEnrichment: ceilingEnrichment || null,
|
|
81
91
|
});
|
|
82
92
|
}
|
|
83
93
|
|
|
@@ -124,7 +134,27 @@ async function refreshCatalog() {
|
|
|
124
134
|
writeRefreshFailure(reason, providerFailures);
|
|
125
135
|
return [];
|
|
126
136
|
}
|
|
127
|
-
|
|
137
|
+
// #218 P3: fill direct-provider ceilings from models.dev AFTER the floor-only
|
|
138
|
+
// check (a failed refresh is never enriched, so "stale cache stands" holds)
|
|
139
|
+
// and IN PLACE on the fresh row objects, so `authoritative`/`local` ride
|
|
140
|
+
// through untouched. enrichCeilings never rejects; the belt-and-braces catch
|
|
141
|
+
// keeps a bug there from failing a refresh that already succeeded.
|
|
142
|
+
// Council #230 D1/C2: `modelsDevCeilings: false` means models.dev is never
|
|
143
|
+
// contacted. The persisted outcome still carries the full counter set, so
|
|
144
|
+
// `--json` readers and the `Ceilings:` line see "disabled", not a blank.
|
|
145
|
+
let ceilingEnrichment;
|
|
146
|
+
if (!_modelsDevEnabled()) {
|
|
147
|
+
ceilingEnrichment = { ..._emptyOutcome(null), skipped: 'disabled' };
|
|
148
|
+
} else {
|
|
149
|
+
try {
|
|
150
|
+
ceilingEnrichment = await _enrichCeilings(models);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
// Same shape enrichCeilings' own failures use (council #230 C4/D5), so every
|
|
153
|
+
// reader of ceilingEnrichment sees the full counter set however it failed.
|
|
154
|
+
ceilingEnrichment = _emptyOutcome({ reason: 'exception', detail: err.message });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
writeCache(models, providerFailures, ceilingEnrichment);
|
|
128
158
|
return models;
|
|
129
159
|
}
|
|
130
160
|
|
|
@@ -154,7 +184,7 @@ async function getCatalog(opts = {}) {
|
|
|
154
184
|
* #13: also threads the last-refresh outcome so callers can tell "current"
|
|
155
185
|
* apart from "stale because refreshing keeps failing" — null/null when the
|
|
156
186
|
* last attempt on record succeeded (or none has happened yet).
|
|
157
|
-
* @returns {Promise<{models: Array, fetchedAt: number|null, lastRefreshAttempt: number|null, lastRefreshError: string|null}>}
|
|
187
|
+
* @returns {Promise<{models: Array, fetchedAt: number|null, lastRefreshAttempt: number|null, lastRefreshError: string|null, providerFailures: Array, ceilingEnrichment: object|null}>}
|
|
158
188
|
*/
|
|
159
189
|
async function getCatalogInfo(opts = {}) {
|
|
160
190
|
const models = await getCatalog(opts);
|
|
@@ -167,6 +197,8 @@ async function getCatalogInfo(opts = {}) {
|
|
|
167
197
|
lastRefreshError: (doc && doc.lastRefreshError) || null,
|
|
168
198
|
// #209: namespace-level fetch outcomes for the CACHED rows above.
|
|
169
199
|
providerFailures: (doc && Array.isArray(doc.providerFailures)) ? doc.providerFailures : [],
|
|
200
|
+
// #218 P3: where the direct-provider ceilings came from, or why they did not.
|
|
201
|
+
ceilingEnrichment: (doc && doc.ceilingEnrichment) || null,
|
|
170
202
|
};
|
|
171
203
|
}
|
|
172
204
|
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module model-ceilings-modelsdev
|
|
3
|
+
* #218 P3 — output ceilings for the direct-provider catalog rows.
|
|
4
|
+
*
|
|
5
|
+
* WHY: computeModelLimit (model-output-limit.js) refuses to emit a `limit`
|
|
6
|
+
* descriptor unless the catalog knows BOTH a model's context and its output
|
|
7
|
+
* ceiling — a blanket budget against an unknown ceiling would send an
|
|
8
|
+
* over-ceiling max_tokens. OpenRouter publishes its ceiling on /models; the
|
|
9
|
+
* direct openai/anthropic/deepseek lists do not (google's does, lifted
|
|
10
|
+
* first-party in model-fetcher.js). models.dev publishes all of them, keyless.
|
|
11
|
+
*
|
|
12
|
+
* WHAT THIS DOES NOT CHANGE: the engine already resolves every `{}` descriptor's
|
|
13
|
+
* limit from its own models.dev copy. This gives AMICUS the same numbers so it
|
|
14
|
+
* can clamp an outputBudget on the direct anthropic/deepseek routes through the
|
|
15
|
+
* descriptor (a route it cannot clamp still gets the budget through the engine
|
|
16
|
+
* flag — engine-output-flag.js) and name a reservation in a dead-leg note. The
|
|
17
|
+
* direct openai route is the exception: the engine drives that provider through
|
|
18
|
+
* the Responses API, whose request carries no output-limit field at all, so the
|
|
19
|
+
* ceiling filled here shows in `amicus models` but neither the descriptor nor
|
|
20
|
+
* the flag reaches the wire there — a direct openai row sends no output
|
|
21
|
+
* reservation at all (#218 PR 4, probe M5/M13/M22). It reads models.dev
|
|
22
|
+
* directly, not the engine's cache file, because that file's path and refresh
|
|
23
|
+
* flags are engine-private.
|
|
24
|
+
*
|
|
25
|
+
* RULES (measured 2026-09-04 against live data, see the plan):
|
|
26
|
+
* - the provider's own value WINS: models.dev fills a field only when the
|
|
27
|
+
* provider gave no usable positive integer — null, 0, negative or malformed
|
|
28
|
+
* — and a usable provider value is never overwritten (OpenRouter and
|
|
29
|
+
* models.dev disagree on 24 of 344 openrouter ceilings);
|
|
30
|
+
* - a zero/absent models.dev limit is never written (openai image rows);
|
|
31
|
+
* - `openrouter/openrouter/*` meta-routers are skipped (models.dev says
|
|
32
|
+
* 2,000,000 for `auto`, a number no underlying model honours);
|
|
33
|
+
* - local rows are skipped; the fill is IN PLACE so `authoritative`/`local`
|
|
34
|
+
* flags on the row objects ride through untouched;
|
|
35
|
+
* - the FETCH ITSELF is skipped when no candidate row is missing a ceiling,
|
|
36
|
+
* and `model-catalog.js` skips this module entirely when the config key
|
|
37
|
+
* `modelsDevCeilings` is `false` (council #230 D1/C2): a refresh that has
|
|
38
|
+
* nothing to fill must not spend up to 10 s asking.
|
|
39
|
+
*/
|
|
40
|
+
'use strict';
|
|
41
|
+
|
|
42
|
+
const { positiveCount } = require('./model-output-limit');
|
|
43
|
+
|
|
44
|
+
const MODELS_DEV_URL = 'https://models.dev/api.json';
|
|
45
|
+
const MODELS_DEV_TIMEOUT_MS = 10000;
|
|
46
|
+
/** The vendors amicus catalogs under these exact id prefixes (model-fetcher.js normalizers). */
|
|
47
|
+
const VENDORS = ['anthropic', 'openai', 'google', 'deepseek', 'openrouter'];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Index a models.dev api.json document by amicus catalog id. A positive
|
|
51
|
+
* finite integer count, or null — reuses `positiveCount` from
|
|
52
|
+
* model-output-limit.js so this module holds the same discipline as that one.
|
|
53
|
+
* @param {*} api parsed https://models.dev/api.json
|
|
54
|
+
* @returns {Map<string, {context: number|null, output: number|null}>}
|
|
55
|
+
*/
|
|
56
|
+
function limitsFromModelsDev(api) {
|
|
57
|
+
const out = new Map();
|
|
58
|
+
if (!api || typeof api !== 'object') { return out; }
|
|
59
|
+
for (const vendor of VENDORS) {
|
|
60
|
+
const models = api[vendor] && api[vendor].models;
|
|
61
|
+
if (!models || typeof models !== 'object') { continue; }
|
|
62
|
+
for (const [modelId, m] of Object.entries(models)) {
|
|
63
|
+
const limit = (m && typeof m === 'object' && m.limit) || {};
|
|
64
|
+
const context = positiveCount(limit.context);
|
|
65
|
+
const output = positiveCount(limit.output);
|
|
66
|
+
if (context === null && output === null) { continue; }
|
|
67
|
+
out.set(`${vendor}/${modelId}`, { context, output });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Why this row is not a fill candidate, or null when it is one. THE single
|
|
75
|
+
* definition: `fillCeilings` counts each class and `needsFillCount` collapses
|
|
76
|
+
* them to a boolean, so the pass and the nothing-to-fill check cannot disagree
|
|
77
|
+
* about which rows the fetch could serve.
|
|
78
|
+
* @param {*} row a catalog row
|
|
79
|
+
* @returns {null|'malformed'|'local'|'router'}
|
|
80
|
+
*/
|
|
81
|
+
function skipClass(row) {
|
|
82
|
+
if (!row || typeof row !== 'object' || typeof row.id !== 'string') { return 'malformed'; }
|
|
83
|
+
if (row.local === true) { return 'local'; }
|
|
84
|
+
if (row.id.startsWith('openrouter/openrouter/')) { return 'router'; }
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Does this row still lack a number outputBudget needs? computeModelLimit
|
|
90
|
+
* refuses to emit a descriptor unless BOTH ceilings are known, so one usable
|
|
91
|
+
* field is not "known" for any purpose the fill exists to serve.
|
|
92
|
+
* @param {object} row a catalog row
|
|
93
|
+
* @returns {boolean}
|
|
94
|
+
*/
|
|
95
|
+
function missingACeiling(row) {
|
|
96
|
+
return positiveCount(row.contextLength) === null || positiveCount(row.maxOutputTokens) === null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Fill contextLength / maxOutputTokens in place. A field is filled ONLY when the
|
|
101
|
+
* provider gave no usable positive integer for it — `positiveCount(...) === null`,
|
|
102
|
+
* i.e. null, 0, negative, below 1 or non-numeric. (`positiveCount` FLOORS, so
|
|
103
|
+
* 1.5 is a usable 1; only a fraction below 1 falls through.) A usable provider
|
|
104
|
+
* value is never overwritten. A filled row is stamped `limitSource: 'models.dev'`,
|
|
105
|
+
* which marks a row where AT LEAST ONE field was filled from models.dev — not a
|
|
106
|
+
* claim that both numbers came from there (council #230 D2).
|
|
107
|
+
*
|
|
108
|
+
* COUNTERS. `filled` / `alreadyKnown` / `unknown` describe what the pass did;
|
|
109
|
+
* `stillMissing` describes the STATE it left behind and deliberately overlaps
|
|
110
|
+
* them. `alreadyKnown` means both fields were usable BEFORE the pass, and is
|
|
111
|
+
* decided BEFORE the models.dev lookup (council #230 A2) — a complete row
|
|
112
|
+
* models.dev does not list is `alreadyKnown`, not `unknown`. A row with one
|
|
113
|
+
* field known and the other unfillable is `stillMissing`, never "already known"
|
|
114
|
+
* (council #230 C1/D5): outputBudget cannot clamp it.
|
|
115
|
+
* @param {Array<object>} rows catalog rows (mutated)
|
|
116
|
+
* @param {Map<string, {context: number|null, output: number|null}>} limits
|
|
117
|
+
* @returns {{filled: number, alreadyKnown: number, unknown: number, stillMissing: number,
|
|
118
|
+
* skippedRouters: number, skippedLocal: number}}
|
|
119
|
+
*/
|
|
120
|
+
function fillCeilings(rows, limits) {
|
|
121
|
+
const counts = { filled: 0, alreadyKnown: 0, unknown: 0, stillMissing: 0, skippedRouters: 0, skippedLocal: 0 };
|
|
122
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
123
|
+
const skip = skipClass(row);
|
|
124
|
+
if (skip === 'malformed') { continue; }
|
|
125
|
+
if (skip === 'local') { counts.skippedLocal++; continue; }
|
|
126
|
+
if (skip === 'router') { counts.skippedRouters++; continue; }
|
|
127
|
+
// BEFORE the lookup (council #230 A2): a row that already carries both
|
|
128
|
+
// usable numbers has nothing for models.dev to fill, so whether models.dev
|
|
129
|
+
// happens to list it is irrelevant. Looking first counted such a row
|
|
130
|
+
// `unknown` when models.dev did not have it — reporting a row outputBudget
|
|
131
|
+
// CAN clamp as one it knows nothing about.
|
|
132
|
+
if (!missingACeiling(row)) { counts.alreadyKnown++; continue; }
|
|
133
|
+
const lim = limits.get(row.id);
|
|
134
|
+
if (!lim) {
|
|
135
|
+
counts.unknown++;
|
|
136
|
+
} else {
|
|
137
|
+
let touched = false;
|
|
138
|
+
if (positiveCount(row.contextLength) === null && lim.context !== null) { row.contextLength = lim.context; touched = true; }
|
|
139
|
+
if (positiveCount(row.maxOutputTokens) === null && lim.output !== null) { row.maxOutputTokens = lim.output; touched = true; }
|
|
140
|
+
if (touched) { row.limitSource = 'models.dev'; counts.filled++; }
|
|
141
|
+
}
|
|
142
|
+
if (missingACeiling(row)) { counts.stillMissing++; }
|
|
143
|
+
}
|
|
144
|
+
return counts;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* How many candidate rows the fetch could possibly help. Zero means the network
|
|
149
|
+
* call has nothing to do and is skipped entirely (council #230 D1/C2).
|
|
150
|
+
* @param {Array<object>} rows catalog rows
|
|
151
|
+
* @returns {number}
|
|
152
|
+
*/
|
|
153
|
+
function needsFillCount(rows) {
|
|
154
|
+
let n = 0;
|
|
155
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
156
|
+
if (skipClass(row) === null && missingACeiling(row)) { n++; }
|
|
157
|
+
}
|
|
158
|
+
return n;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The outcome a FAILED — or SKIPPED — enrichment persists: the failure plus
|
|
163
|
+
* every counter at zero. One shape, so this module's own failure returns and
|
|
164
|
+
* `model-catalog.js :: refreshCatalog`'s belt-and-braces catch cannot drift
|
|
165
|
+
* apart and `models-ceiling-line.js :: fmtCeilingLine` always has the counters
|
|
166
|
+
* it prints. `skipped` defaults to null and a caller that skipped the fetch
|
|
167
|
+
* spreads its own reason over it.
|
|
168
|
+
* @param {null|{reason: string, status?: number, detail?: string}} failure
|
|
169
|
+
* @returns {{source: 'models.dev', failure: object|null, skipped: null, filled: number,
|
|
170
|
+
* alreadyKnown: number, unknown: number, stillMissing: number, skippedRouters: number,
|
|
171
|
+
* skippedLocal: number}}
|
|
172
|
+
*/
|
|
173
|
+
function emptyOutcome(failure) {
|
|
174
|
+
return {
|
|
175
|
+
source: 'models.dev', failure, skipped: null,
|
|
176
|
+
filled: 0, alreadyKnown: 0, unknown: 0, stillMissing: 0, skippedRouters: 0, skippedLocal: 0,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Fetch models.dev and fill `rows`. ALWAYS resolves; the outcome travels with
|
|
182
|
+
* the rows it describes (model-catalog.js persists it as ceilingEnrichment).
|
|
183
|
+
* Failure reasons are http-get's (`timeout`, `network-error`, `http-status`,
|
|
184
|
+
* `too-large`, `parse-error`) plus `bad-shape` and `exception`.
|
|
185
|
+
*
|
|
186
|
+
* NO CALL IS MADE when no candidate row is missing a ceiling: the outcome is
|
|
187
|
+
* `skipped: 'nothing-to-fill'` and models.dev is never contacted (council #230
|
|
188
|
+
* D1/C2 — a refresh on a fully-known catalog paid up to 10 s for nothing).
|
|
189
|
+
* @param {Array<object>} rows catalog rows (mutated in place)
|
|
190
|
+
* @param {{getJson?: Function}} [deps] test seam
|
|
191
|
+
* @returns {Promise<{source: 'models.dev', failure: null|{reason: string, status?: number, detail?: string},
|
|
192
|
+
* skipped: null|'nothing-to-fill', filled: number, alreadyKnown: number, unknown: number,
|
|
193
|
+
* stillMissing: number, skippedRouters: number, skippedLocal: number}>}
|
|
194
|
+
*/
|
|
195
|
+
async function enrichCeilings(rows, deps = {}) {
|
|
196
|
+
const getJson = deps.getJson || require('./http-get').getJson;
|
|
197
|
+
if (needsFillCount(rows) === 0) {
|
|
198
|
+
return { ...emptyOutcome(null), skipped: 'nothing-to-fill' };
|
|
199
|
+
}
|
|
200
|
+
let res;
|
|
201
|
+
try {
|
|
202
|
+
res = await getJson(MODELS_DEV_URL, {
|
|
203
|
+
timeoutMs: MODELS_DEV_TIMEOUT_MS,
|
|
204
|
+
followRedirects: true,
|
|
205
|
+
headers: { 'User-Agent': `amicus/${require('../../package.json').version}` },
|
|
206
|
+
});
|
|
207
|
+
} catch (err) {
|
|
208
|
+
return emptyOutcome({ reason: 'exception', detail: err.message });
|
|
209
|
+
}
|
|
210
|
+
if (!res || !res.ok) {
|
|
211
|
+
return emptyOutcome((res && res.failure) || { reason: 'exception', detail: 'no result' });
|
|
212
|
+
}
|
|
213
|
+
const limits = limitsFromModelsDev(res.json);
|
|
214
|
+
// A 200 that parses but carries no recognised vendor limits — `{}`, an error
|
|
215
|
+
// object, a reshaped api.json — would otherwise persist as a SUCCESSFUL
|
|
216
|
+
// enrichment with every candidate row `unknown`, silently leaving direct-provider
|
|
217
|
+
// ceilings unfilled and outputBudget unable to clamp them (council #230 C1).
|
|
218
|
+
// It is a failure, and the rows are not touched.
|
|
219
|
+
if (limits.size === 0) {
|
|
220
|
+
return emptyOutcome({ reason: 'bad-shape', detail: 'no recognised vendor limits in api.json' });
|
|
221
|
+
}
|
|
222
|
+
return { source: 'models.dev', failure: null, skipped: null, ...fillCeilings(rows, limits) };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// `enrichCeilings` — the entry point — is first so the generated architecture
|
|
226
|
+
// map, which lists a module's first five exports, actually names it.
|
|
227
|
+
module.exports = {
|
|
228
|
+
enrichCeilings, fillCeilings, needsFillCount, limitsFromModelsDev, emptyOutcome,
|
|
229
|
+
MODELS_DEV_URL, MODELS_DEV_TIMEOUT_MS,
|
|
230
|
+
};
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Uses the same HTTPS pattern as api-key-store.js validateApiKey().
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
const
|
|
8
|
+
const { httpGetText } = require('./http-get');
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Hardcoded Anthropic floor: the anthropic/ rows a KEYLESS user (or a
|
|
@@ -64,6 +64,9 @@ const PROVIDER_FETCH_CONFIG = {
|
|
|
64
64
|
id: `google/${m.name.replace('models/', '')}`,
|
|
65
65
|
name: m.displayName || m.name.replace('models/', ''),
|
|
66
66
|
contextLength: m.inputTokenLimit ?? null,
|
|
67
|
+
// #218 P3: Google's ListModels publishes the ceiling first-party; models.dev fills only
|
|
68
|
+
// what the provider left empty or unusable.
|
|
69
|
+
maxOutputTokens: m.outputTokenLimit ?? null,
|
|
67
70
|
pricing: null
|
|
68
71
|
}));
|
|
69
72
|
}
|
|
@@ -148,43 +151,16 @@ function fetchModelsFromProvider(provider, key) {
|
|
|
148
151
|
* @param {string} key - API key
|
|
149
152
|
* @returns {Promise<{rows: Array, failure: {reason: string, status?: number, detail?: string}|null}>}
|
|
150
153
|
*/
|
|
151
|
-
function fetchViaConfigDetailed(provider, key) {
|
|
154
|
+
async function fetchViaConfigDetailed(provider, key) {
|
|
152
155
|
const config = PROVIDER_FETCH_CONFIG[provider];
|
|
153
156
|
const url = config.buildUrl ? config.buildUrl(key) : config.url;
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const timer = setTimeout(() => {
|
|
162
|
-
req.destroy();
|
|
163
|
-
fail({ reason: 'timeout', detail: `no response within ${FETCH_TIMEOUT_MS}ms` });
|
|
164
|
-
}, FETCH_TIMEOUT_MS);
|
|
165
|
-
|
|
166
|
-
const req = https.get(url, { headers }, (res) => {
|
|
167
|
-
if (res.statusCode !== 200) {
|
|
168
|
-
clearTimeout(timer);
|
|
169
|
-
res.on('data', () => {});
|
|
170
|
-
res.on('end', () => fail({ reason: 'http-status', status: res.statusCode }));
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
res.on('data', (chunk) => { chunks += chunk; });
|
|
174
|
-
res.on('end', () => {
|
|
175
|
-
clearTimeout(timer);
|
|
176
|
-
try {
|
|
177
|
-
ok(config.normalize(chunks));
|
|
178
|
-
} catch (err) {
|
|
179
|
-
fail({ reason: 'parse-error', detail: err.message });
|
|
180
|
-
}
|
|
181
|
-
});
|
|
182
|
-
});
|
|
183
|
-
req.on('error', (err) => {
|
|
184
|
-
clearTimeout(timer);
|
|
185
|
-
fail({ reason: 'network-error', detail: err.message });
|
|
186
|
-
});
|
|
187
|
-
});
|
|
157
|
+
const res = await httpGetText(url, { headers: config.authHeader(key), timeoutMs: FETCH_TIMEOUT_MS });
|
|
158
|
+
if (!res.ok) { return { rows: [], failure: res.failure }; }
|
|
159
|
+
try {
|
|
160
|
+
return { rows: config.normalize(res.body), failure: null };
|
|
161
|
+
} catch (err) {
|
|
162
|
+
return { rows: [], failure: { reason: 'parse-error', detail: err.message } };
|
|
163
|
+
}
|
|
188
164
|
}
|
|
189
165
|
|
|
190
166
|
/**
|
|
@@ -17,11 +17,13 @@
|
|
|
17
17
|
* i.e. `ProviderTransform.maxOutputTokens(model) = Math.min(model.limit.output,
|
|
18
18
|
* OUTPUT_TOKEN_MAX)`. Three consequences, each a trap:
|
|
19
19
|
*
|
|
20
|
-
* 1. Supplying the model's REAL ceiling
|
|
21
|
-
* true ceiling is 943,718 and
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
20
|
+
* 1. Supplying the model's REAL ceiling through THIS DESCRIPTOR ALONE is
|
|
21
|
+
* ARITHMETICALLY INERT. kimi-k3's true ceiling is 943,718 and
|
|
22
|
+
* Math.min(943718, 32000) is still 32000. Through the descriptor only a
|
|
23
|
+
* value BELOW OUTPUT_TOKEN_MAX changes the outbound request. Raising
|
|
24
|
+
* OUTPUT_TOKEN_MAX itself is engine-output-flag.js's job (PR 2): with the
|
|
25
|
+
* flag set to the same budget the two levers agree on min(budget, ceiling)
|
|
26
|
+
* — measured, probe rows C2 and K6.
|
|
25
27
|
* 2. `limit.context` is MANDATORY whenever `limit` is present. A `limit` with
|
|
26
28
|
* only `output` is a hard ConfigInvalidError — and it poisons the ENTIRE
|
|
27
29
|
* config for the server's lifetime, not just that model. Measured against
|
|
@@ -30,15 +32,21 @@
|
|
|
30
32
|
*
|
|
31
33
|
* WHAT THIS DOES NOT FIX. #218 conflates two modes that pull in OPPOSITE
|
|
32
34
|
* directions on this one knob. Mode 1 (credit rejection) needs the reservation
|
|
33
|
-
* LOWERED —
|
|
34
|
-
* allowance on reasoning and emitting 0-2 output tokens)
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* `
|
|
38
|
-
*
|
|
35
|
+
* LOWERED — this descriptor does that. Mode 2 (a leg spending its whole
|
|
36
|
+
* allowance on reasoning and emitting 0-2 output tokens) needs it RAISED, which
|
|
37
|
+
* this descriptor cannot do (the Math.min) and the engine flag can
|
|
38
|
+
* (engine-output-flag.js) — but its real cause is reasoning effort, which #218
|
|
39
|
+
* PR 4 now delivers: `--thinking` reaches the engine as its `variant` field, not
|
|
40
|
+
* the `reasoning` object the prompt API never read (F1), and is checked against
|
|
41
|
+
* the model's own declaration first — so on the one route where a variant moves
|
|
42
|
+
* the reservation the leg is refused, not silently overshot (M2: 24000 + 16000 =
|
|
43
|
+
* 40000; the fit that lands the sum on the budget is M17). Lowering the budget
|
|
44
|
+
* makes such a leg fail faster and cheaper; raising it gives the reasoning more
|
|
45
|
+
* room. Neither makes it produce output, and no claim is made that either does.
|
|
39
46
|
*
|
|
40
47
|
* POLICY: opt-in, no default change. With no configured budget every model is
|
|
41
|
-
* still registered as `{}` — byte-identical to
|
|
48
|
+
* still registered as `{}` and no engine flag is set — byte-identical to
|
|
49
|
+
* pre-#218 behaviour.
|
|
42
50
|
*/
|
|
43
51
|
|
|
44
52
|
'use strict';
|
|
@@ -121,4 +129,4 @@ function computeModelLimit(row, budget) {
|
|
|
121
129
|
return { context, output: Math.max(1, Math.min(ceiling, want)) };
|
|
122
130
|
}
|
|
123
131
|
|
|
124
|
-
module.exports = { normalizeOutputBudget, buildLimitLookup, computeModelLimit };
|
|
132
|
+
module.exports = { normalizeOutputBudget, buildLimitLookup, computeModelLimit, positiveCount };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/output-length
|
|
3
|
+
* #218 PR 3: name the "Mode 2" death.
|
|
4
|
+
*
|
|
5
|
+
* THE PROBLEM. A council leg whose provider stopped for length before any
|
|
6
|
+
* answer text -- the whole max_tokens reservation went to reasoning (the #218
|
|
7
|
+
* ledger rows: 32000 reasoning, 0-2 output, $0.63 billed for nothing) -- came
|
|
8
|
+
* back `complete` with an empty summary and was announced as "the leg ended
|
|
9
|
+
* 'complete' with no usable output"; with VISIBLE reasoning it came back
|
|
10
|
+
* `complete` with its thinking promoted to the review and was adjudicated as one.
|
|
11
|
+
*
|
|
12
|
+
* WHAT THE ENGINE RECORDS (scripts/probe-max-tokens.js rows A/H1/L1-L4, engine
|
|
13
|
+
* 1.18.15): `finish: 'length'` on the assistant message on both provider
|
|
14
|
+
* families; a reasoning/output token split on OpenAI-compatible routes
|
|
15
|
+
* (L3: output = completion - reasoning) but NOT on the direct Anthropic route
|
|
16
|
+
* (L4: everything is `output`, reasoning 0); and, with visible reasoning, a
|
|
17
|
+
* `reasoning` part and no `text` part (L2/L4), which
|
|
18
|
+
* sidecar/conversation-mirror.js :: mirrorMessages promotes to `output` -- so
|
|
19
|
+
* `output` cannot be the test; the mirror records the last message's own facts
|
|
20
|
+
* and this module reads only those. No row carries an engine error for the stop.
|
|
21
|
+
*
|
|
22
|
+
* So the death is keyed on `finish` plus "no answer text arrived", never on a
|
|
23
|
+
* token count; the counts are reported, not decided on. Pure: no I/O, no clock.
|
|
24
|
+
* headless.js :: runHeadless calls both functions once, post-loop.
|
|
25
|
+
*/
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const {
|
|
29
|
+
outputTokenFlagValue, ENGINE_DEFAULT_OUTPUT_TOKENS, OUTPUT_TOKEN_FLAG, PLAIN_OUTPUT_TOKEN_FLAG,
|
|
30
|
+
} = require('./engine-output-flag');
|
|
31
|
+
|
|
32
|
+
/** The prefix a consumer can classify on, like `NO_OUTPUT_BACKSTOP:`. */
|
|
33
|
+
const OUTPUT_LENGTH_PREFIX = 'OUTPUT_LENGTH:';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Is this leg the Mode 2 death? The provider stopped for length AND the LAST
|
|
37
|
+
* assistant message carries no answer text: nothing at all (L1), or only
|
|
38
|
+
* reasoning (L2/L4). Decided per message, never on the session's accumulated
|
|
39
|
+
* output (council #232 r1 B2/D1). Named mutants (tests/utils/output-length.test.js):
|
|
40
|
+
* "NOTLENGTH" drops the finish check, "TEXTIGNORED" drops the text check.
|
|
41
|
+
* @param {{finish?: string|null, hasText?: boolean}} last the last assistant message's facts
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
function isOutputLengthDeath({ finish, hasText }) {
|
|
45
|
+
return finish === 'length' && hasText !== true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The reason string. Every clause is an observation: `finish` and the two
|
|
50
|
+
* counts are the engine's own record of the message; the budget clause is what
|
|
51
|
+
* the engine serving the leg was spawned with: the budget (`null` = unset,
|
|
52
|
+
* `undefined` = unknown — no handle value and config unreadable) or, when no
|
|
53
|
+
* budget was set, the ambient flag. The remedy names the one
|
|
54
|
+
* lever that exists today; PR 4 adds the effort lever. Named mutant
|
|
55
|
+
* "BUDGETUNSET": always print the unset clause.
|
|
56
|
+
* @param {{tokens?: {reasoning?: number, output?: number}|null,
|
|
57
|
+
* budget?: number|null, reasoningOnly?: boolean,
|
|
58
|
+
* ambientFlag?: string|null}} args `reasoningOnly` = the
|
|
59
|
+
* message carried reasoning parts and no text -- L2/L4; `ambientFlag` = the
|
|
60
|
+
* ambient `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX` the engine was started with
|
|
61
|
+
* when no budget was set (`null` when none, or when a budget overrode it);
|
|
62
|
+
* named mutant "AMBIENTIGNORED".
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
function formatOutputLengthReason({ tokens, budget, reasoningOnly, ambientFlag }) {
|
|
66
|
+
const t = tokens || {};
|
|
67
|
+
const count = (n) => (Number.isFinite(n) ? n : 0);
|
|
68
|
+
const streamed = reasoningOnly
|
|
69
|
+
? 'only reasoning was streamed, no answer text'
|
|
70
|
+
: 'no answer text arrived';
|
|
71
|
+
// PLAIN_OUTPUT_TOKEN_FLAG is the one form measured to be honoured (C1, K5,
|
|
72
|
+
// K12); 64000abc and 0 fell back to 32000 (D1/D2); every other form is
|
|
73
|
+
// unmeasured, and the clause below says so -- shared with the doctor row
|
|
74
|
+
// (doctor-output-budget-check.js :: evaluateOutputBudget) so the gates agree.
|
|
75
|
+
const ambient = typeof ambientFlag === 'string' ? ambientFlag : null;
|
|
76
|
+
const knob = budget === undefined
|
|
77
|
+
? 'outputBudget could not be read'
|
|
78
|
+
: budget !== null
|
|
79
|
+
? `outputBudget is ${outputTokenFlagValue(budget)}`
|
|
80
|
+
: ambient === null
|
|
81
|
+
? `outputBudget is unset — the engine's ${ENGINE_DEFAULT_OUTPUT_TOKENS} default reservation governs`
|
|
82
|
+
: PLAIN_OUTPUT_TOKEN_FLAG.test(ambient)
|
|
83
|
+
? `outputBudget is unset — the ambient ${OUTPUT_TOKEN_FLAG}=${ambient} the engine was started with governs (each leg reserves min(${ambient}, the ceiling the engine's catalog knows for it))`
|
|
84
|
+
: `outputBudget is unset and the ambient ${OUTPUT_TOKEN_FLAG}=${ambient} the engine was started with is not a plain positive integer — the only form measured to be honoured (probe D1/D2: 64000abc and 0 fell back to ${ENGINE_DEFAULT_OUTPUT_TOKENS} silently); any other form is unmeasured`;
|
|
85
|
+
return `${OUTPUT_LENGTH_PREFIX} the provider stopped at the max_tokens reservation (finish 'length') and ${streamed} — `
|
|
86
|
+
+ `${count(t.reasoning)} reasoning / ${count(t.output)} output tokens; ${knob} — `
|
|
87
|
+
+ 'raise outputBudget in config.json (docs/configuration.md, Output budget)';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { OUTPUT_LENGTH_PREFIX, isOutputLengthDeath, formatOutputLengthReason };
|
|
@@ -50,6 +50,7 @@ function durationBetween(createdAt, completedAt) {
|
|
|
50
50
|
* metadata.pack was recorded (solo session launched via --pack), sourced straight off
|
|
51
51
|
* `metadata` like `usage`/`opencodeSessionId` already are (no new function parameter needed).
|
|
52
52
|
* `tag` (v4.7 F8/D13) is additive the same way — present only when metadata.tag was recorded.
|
|
53
|
+
* `finish` (#218 PR 3) likewise — the engine's finish reason for the leg's last assistant message. `variant` / `variantUnverified` (#218 PR 4) likewise — the effort level SENT, and whether the engine's catalogue knew the model when it was sent.
|
|
53
54
|
*/
|
|
54
55
|
function buildRunResult({ taskId, metadata = {}, result = null, summary = null, modelInput = null, sessionDir = null, waveId = null, usage = null }) {
|
|
55
56
|
const status = result ? statusFromResult(result) : (metadata.status || 'unknown');
|
|
@@ -80,6 +81,8 @@ function buildRunResult({ taskId, metadata = {}, result = null, summary = null,
|
|
|
80
81
|
// (B3): emit-when-VALID via the shared predicate — `metadata` is read off
|
|
81
82
|
// disk, so NaN/±Infinity/negatives/fractions all reach here. See ./ttft.js.
|
|
82
83
|
...(isMeasuredTtft(metadata.ttftMs) ? { ttftMs: metadata.ttftMs } : {}),
|
|
84
|
+
...(typeof metadata.finish === 'string' ? { finish: metadata.finish } : {}), // #218 PR 3: emit-when-set (named mutant FINISHCOERCED)
|
|
85
|
+
...(typeof metadata.variant === 'string' ? { variant: metadata.variant } : {}), ...(metadata.variantUnverified === true ? { variantUnverified: true } : {}), // #218 PR 4: emit-when-sent (named mutants VARIANTCOERCED / UNVERIFIEDCOERCED)
|
|
83
86
|
usage: usage !== null ? usage : (metadata.usage || null),
|
|
84
87
|
...(metadata.pack ? { pack: metadata.pack } : {}),
|
|
85
88
|
...(metadata.tag ? { tag: metadata.tag } : {}),
|
|
@@ -184,10 +187,11 @@ const { buildRunResultFromSession, buildWaveResultFromSession } = require('./res
|
|
|
184
187
|
* #13: lastRefreshAttempt/lastRefreshError are additive — null/null when the
|
|
185
188
|
* last refresh attempt on record succeeded (or none has happened yet).
|
|
186
189
|
* @param {{models: Array, fetchedAt: number|null, refreshed?: boolean, search?: string|null,
|
|
187
|
-
* lastRefreshAttempt?: number|null, lastRefreshError?: string|null
|
|
190
|
+
* lastRefreshAttempt?: number|null, lastRefreshError?: string|null,
|
|
191
|
+
* ceilingEnrichment?: object|null}} opts
|
|
188
192
|
*/
|
|
189
193
|
function buildCatalogDoc({ models, fetchedAt, refreshed = false, search = null,
|
|
190
|
-
lastRefreshAttempt = null, lastRefreshError = null }) {
|
|
194
|
+
lastRefreshAttempt = null, lastRefreshError = null, ceilingEnrichment = null }) {
|
|
191
195
|
return {
|
|
192
196
|
schemaVersion: SCHEMA_VERSION,
|
|
193
197
|
type: 'model-catalog',
|
|
@@ -198,6 +202,7 @@ function buildCatalogDoc({ models, fetchedAt, refreshed = false, search = null,
|
|
|
198
202
|
models,
|
|
199
203
|
lastRefreshAttempt: lastRefreshAttempt || null,
|
|
200
204
|
lastRefreshError: lastRefreshError || null,
|
|
205
|
+
ceilingEnrichment: ceilingEnrichment || null, // #218 P3: additive within SCHEMA_VERSION
|
|
201
206
|
};
|
|
202
207
|
}
|
|
203
208
|
|
|
@@ -62,11 +62,13 @@ const SPEND_LEDGER_FILE = 'spend-ledger.jsonl';
|
|
|
62
62
|
* @param {number} [opts.attempt] fallback attempt count (omitted if absent)
|
|
63
63
|
* @param {string} [opts.substitutedFor] substituted model (omitted if absent)
|
|
64
64
|
* @param {string} [opts.retryOfWaveId] wave id being retried (omitted if absent)
|
|
65
|
+
* @param {string} [opts.finish] the leg's finish reason (omitted if absent) — #218 PR 3: 'length' on a row is the Mode 2 receipt
|
|
66
|
+
* @param {string} [opts.variant] the effort level sent (omitted if absent) — #218 PR 4
|
|
65
67
|
* @param {{dir?:string}} [ctx] test seam — dir overrides getConfigDir()
|
|
66
68
|
*/
|
|
67
69
|
function appendSpend({ taskId, waveId, model, mode, usage,
|
|
68
70
|
op, status, councilRunId, councilName, project, gateway, tag,
|
|
69
|
-
attempt, substitutedFor, retryOfWaveId }, ctx = {}) {
|
|
71
|
+
attempt, substitutedFor, retryOfWaveId, finish, variant }, ctx = {}) {
|
|
70
72
|
if (!usage) { return; }
|
|
71
73
|
try {
|
|
72
74
|
const dir = ctx.dir || getConfigDir();
|
|
@@ -96,6 +98,8 @@ function appendSpend({ taskId, waveId, model, mode, usage,
|
|
|
96
98
|
if (attempt !== undefined) { row.attempt = attempt; }
|
|
97
99
|
if (substitutedFor !== undefined) { row.substitutedFor = substitutedFor; }
|
|
98
100
|
if (retryOfWaveId !== undefined) { row.retryOfWaveId = retryOfWaveId; }
|
|
101
|
+
if (typeof finish === 'string') { row.finish = finish; }
|
|
102
|
+
if (typeof variant === 'string') { row.variant = variant; } // #218 PR 4: emit-when-sent (named mutant "VARIANTNULLED")
|
|
99
103
|
// v4.4.1 CA-2: a leg whose OWN cost is known but which spawned a child
|
|
100
104
|
// session the walk could not price writes a PRICED row — so `unpricedRows`
|
|
101
105
|
// never catches it and `amicus spend` reads as a complete measurement while
|