opencode-context-indicator 1.1.5 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,232 @@
1
+ // lib/estimate.js
2
+ //
3
+ // Pure, stateless token-estimation and input-sanitisation helpers shared by the
4
+ // context-indicator plugin modules. Nothing here touches the filesystem, the
5
+ // in-memory caches, or the plugin lifecycle: every function is a pure transform
6
+ // of its arguments, which keeps the split modules free of hidden coupling.
7
+ // Extracted 1:1 (code + WHY comments) from index.js.
8
+
9
+ // Token-estimate densities by script (unicode heuristic — estimate, not a
10
+ // tokenizer): Latin/ASCII ~4 chars/token, Cyrillic ~2.5, CJK ~1.5.
11
+ const CHARS_PER_TOKEN_OTHER = 4;
12
+ const CHARS_PER_TOKEN_CYRILLIC = 2.5;
13
+ const CHARS_PER_TOKEN_CJK = 1.5;
14
+
15
+ // Hard upper bound on a plausible model window (tokens). Anything larger is
16
+ // junk from a poisoned/legacy state.json and must not become a denominator.
17
+ const MAX_SANE_LIMIT = 1e9;
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Input-sanitisation helpers (all external data — state.json, plugin context,
21
+ // event payloads, invocation args — is UNTRUSTED).
22
+ // ---------------------------------------------------------------------------
23
+
24
+ // Collapse CR/LF/TAB to spaces and bound the length. Used for anything that ends
25
+ // up in a log line, so a crafted value cannot inject fake log rows (CWE-117).
26
+ function oneLine(s) {
27
+ try {
28
+ return String(s).replace(/[\r\n\t]+/g, " ").slice(0, 500);
29
+ } catch {
30
+ return "";
31
+ }
32
+ }
33
+
34
+ // Sanitise a value for a markdown table cell. The table is echoed VERBATIM by
35
+ // the model (see BREAKDOWN_MODEL_INSTRUCTION), so a newline or a raw `|` in an
36
+ // agent/model name could break the row and smuggle instructions into the model
37
+ // turn (prompt injection). Bounded to ~64 chars.
38
+ //
39
+ // Ordering is security-relevant:
40
+ // 1. DELETE zero-width + bidi control chars (U+200B-200F, U+202A-202E,
41
+ // U+2066-2069): invisible carriers that reorder or hide injected text;
42
+ // 2. COLLAPSE C0/C1 controls (incl. \r\n\t) and U+2028/U+2029 (line/paragraph
43
+ // separators, which some renderers treat as real line breaks) to a space;
44
+ // 3. backtick -> `'` so a cell cannot open/close code formatting;
45
+ // 4. TRUNCATE — code-point safe (Array.from never cuts a surrogate pair) and
46
+ // BEFORE escaping: a cut between the halves of an escape pair would leave
47
+ // a trailing `\` that un-escapes the following `|` row separator and
48
+ // re-opens the table to injection;
49
+ // 5. escape `\` FIRST, else a crafted `a\|b` becomes `a\\|b` and the pipe is
50
+ // still a live row separator;
51
+ // 6. escape `|` -> `\|`.
52
+ // Escaping runs after the cap, so the emitted cell can exceed maxLen slightly —
53
+ // the cap bounds SOURCE content; every emitted `\` is doubled, so the cell can
54
+ // never end in a dangling escape regardless of where the cut fell.
55
+ function safeCell(value, maxLen = 64) {
56
+ let s = value == null ? "" : String(value);
57
+ s = s
58
+ .replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g, "")
59
+ .replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g, " ")
60
+ .replace(/`/g, "'");
61
+ const cps = Array.from(s);
62
+ if (cps.length > maxLen) s = `${cps.slice(0, maxLen - 1).join("")}…`;
63
+ return s.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
64
+ }
65
+
66
+ // A finite, strictly-positive, sane number (rejects NaN/Infinity/1e15 garbage).
67
+ function finitePos(v) {
68
+ return (
69
+ typeof v === "number" && Number.isFinite(v) && v > 0 && v <= MAX_SANE_LIMIT
70
+ );
71
+ }
72
+
73
+ // A finite, non-negative token count; anything else -> 0.
74
+ function safeCount(v) {
75
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
76
+ }
77
+
78
+ // A finite, non-negative token count that may legitimately be null (the
79
+ // hook-only `system` / `toolSchemas` cells).
80
+ function safeCountOrNull(v) {
81
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
82
+ }
83
+
84
+ // Normalise a categories object from untrusted input (state.json) into the 7
85
+ // known numeric fields — never trust its shape.
86
+ function safeCategories(raw) {
87
+ if (!raw || typeof raw !== "object") return null;
88
+ return {
89
+ user: safeCount(raw.user),
90
+ assistant: safeCount(raw.assistant),
91
+ reasoning: safeCount(raw.reasoning),
92
+ toolArgs: safeCount(raw.toolArgs),
93
+ system: safeCountOrNull(raw.system),
94
+ toolSchemas: safeCountOrNull(raw.toolSchemas),
95
+ other: safeCount(raw.other),
96
+ };
97
+ }
98
+
99
+ // A session key safe to use as an object property / map key. Rejects the
100
+ // prototype-pollution vectors ("__proto__", "constructor", "prototype").
101
+ function isSafeSessionKey(k) {
102
+ return (
103
+ typeof k === "string" &&
104
+ k.length > 0 &&
105
+ k !== "__proto__" &&
106
+ k !== "constructor" &&
107
+ k !== "prototype"
108
+ );
109
+ }
110
+
111
+ function fmt(n) {
112
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
113
+ }
114
+
115
+ // Unicode-aware TOKEN estimate (heuristic, NOT a tokenizer). The string is
116
+ // scanned ONCE by code point and split into three density buckets:
117
+ // - Cyrillic (U+0400–U+04FF + supplements/extensions): ~2.5 chars/token
118
+ // - CJK / Japanese / Korean (U+4E00–U+9FFF, U+3040–U+30FF, …): ~1.5
119
+ // - everything else (latin/ASCII/digits/punct): ~4
120
+ // tokens = cyr/2.5 + cjk/1.5 + other/4. Single O(n) pass, no intermediate
121
+ // arrays (these strings can be 100k+ chars). Guards against absurd input.
122
+ function estimateTokens(text) {
123
+ if (!text) return 0;
124
+ const s = typeof text === "string" ? text : String(text);
125
+ const len = s.length;
126
+ if (len === 0) return 0;
127
+ let other = 0;
128
+ let cyr = 0;
129
+ let cjk = 0;
130
+ for (let i = 0; i < len; i++) {
131
+ const cp = s.codePointAt(i);
132
+ if (cp > 0xffff) i++; // astral pair — consume the low surrogate
133
+ if (cp < 0x80) {
134
+ other++;
135
+ } else if (
136
+ (cp >= 0x0400 && cp <= 0x04ff) || // Cyrillic
137
+ (cp >= 0x0500 && cp <= 0x052f) || // Cyrillic Supplement
138
+ (cp >= 0x2de0 && cp <= 0x2dff) || // Cyrillic Extended-A
139
+ (cp >= 0xa640 && cp <= 0xa69f) || // Cyrillic Extended-B
140
+ (cp >= 0x1c80 && cp <= 0x1c8f) // Cyrillic Extended-C
141
+ ) {
142
+ cyr++;
143
+ } else if (
144
+ (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
145
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
146
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
147
+ (cp >= 0x3040 && cp <= 0x30ff) || // Hiragana + Katakana
148
+ (cp >= 0xac00 && cp <= 0xd7af) || // Hangul Syllables
149
+ (cp >= 0x3000 && cp <= 0x303f) || // CJK Symbols and Punctuation
150
+ (cp >= 0xff00 && cp <= 0xffef) // Halfwidth/Fullwidth Forms
151
+ ) {
152
+ cjk++;
153
+ } else {
154
+ other++;
155
+ }
156
+ }
157
+ return Math.round(
158
+ other / CHARS_PER_TOKEN_OTHER +
159
+ cyr / CHARS_PER_TOKEN_CYRILLIC +
160
+ cjk / CHARS_PER_TOKEN_CJK,
161
+ );
162
+ }
163
+
164
+ // Token estimate from a serializable value (tool args / params).
165
+ function estJson(value) {
166
+ if (value == null) return 0;
167
+ try {
168
+ return estimateTokens(JSON.stringify(value));
169
+ } catch {
170
+ return 0;
171
+ }
172
+ }
173
+
174
+ // Native opencode overflow count for a tokens object:
175
+ // count = tokens.total || input + output + cache.read + cache.write
176
+ // (with legacy flat cacheRead/cacheWrite fallbacks) — the exact value opencode
177
+ // compares against the model's usable window in its overflow check.
178
+ function tokenCountOf(tokens) {
179
+ if (!tokens) return 0;
180
+ if (typeof tokens.total === "number" && tokens.total > 0) return tokens.total;
181
+ const cacheRead =
182
+ typeof tokens.cache?.read === "number"
183
+ ? tokens.cache.read
184
+ : typeof tokens.cacheRead === "number"
185
+ ? tokens.cacheRead
186
+ : 0;
187
+ const cacheWrite =
188
+ typeof tokens.cache?.write === "number"
189
+ ? tokens.cache.write
190
+ : typeof tokens.cacheWrite === "number"
191
+ ? tokens.cacheWrite
192
+ : 0;
193
+ return (tokens.input || 0) + (tokens.output || 0) + cacheRead + cacheWrite;
194
+ }
195
+
196
+ // Reject when `promise` has not settled within `ms`. The underlying promise
197
+ // is not cancellable in JS — its late result is simply discarded. The timer
198
+ // is always cleared; never leaves a dangling timeout.
199
+ function withTimeout(promise, ms, label) {
200
+ let timer;
201
+ const gate = new Promise((_, reject) => {
202
+ timer = setTimeout(
203
+ () => reject(new Error(`${label}: no response within ${ms}ms`)),
204
+ ms,
205
+ );
206
+ });
207
+ return Promise.race([promise, gate]).finally(() => clearTimeout(timer));
208
+ }
209
+
210
+ function fmtCell(n) {
211
+ return n == null ? "n/a" : fmt(n);
212
+ }
213
+
214
+ export {
215
+ CHARS_PER_TOKEN_OTHER,
216
+ CHARS_PER_TOKEN_CYRILLIC,
217
+ CHARS_PER_TOKEN_CJK,
218
+ MAX_SANE_LIMIT,
219
+ oneLine,
220
+ safeCell,
221
+ finitePos,
222
+ safeCount,
223
+ safeCountOrNull,
224
+ safeCategories,
225
+ isSafeSessionKey,
226
+ fmt,
227
+ estimateTokens,
228
+ estJson,
229
+ tokenCountOf,
230
+ withTimeout,
231
+ fmtCell,
232
+ };
package/lib/limits.js ADDED
@@ -0,0 +1,403 @@
1
+ // lib/limits.js
2
+ //
3
+ // Model window/price registry: the modelLimits / modelPrices maps, their lookup
4
+ // helpers (modelKey, getModelLimit, getUsableContext, setModelLimit), and the
5
+ // two loaders that populate them (ensureProviderLimits for V1 via
6
+ // client.config.providers(), ensureModelLimits for V2 via ctx.model.list()).
7
+ // hydrateModelLimitsFromState() seeds the maps from the shared state.json
8
+ // snapshot via ./state.js. Extracted 1:1 (code + WHY comments) from index.js.
9
+
10
+ import { finitePos, withTimeout } from "./estimate.js";
11
+ import { logErr, readStateSnapshot } from "./state.js";
12
+
13
+ const PROVIDERS_TTL_MS = 5 * 60 * 1000; // providers()/model.list() cache TTL after SUCCESS
14
+ const PROVIDERS_FAIL_TTL_MS = 30 * 1000; // retry-no-sooner base after FAILURE (doubles per consecutive miss)
15
+ const V2_LIMITS_MAX_BACKOFF_MS = 5 * 60 * 1000; // cap for the model.list retry backoff
16
+ const PROVIDERS_FETCH_TIMEOUT_MS = 2000; // hard cap for one providers() call (V1)
17
+ const V2_MODEL_FETCH_TIMEOUT_MS = 8000; // hard cap for one ctx.model.list() call (V2)
18
+
19
+ // `<providerID>:<modelID>` -> { context, input, output, reserved } limit record,
20
+ // populated lazily from the merged config / providers() / model.list.
21
+ const modelLimits = new Map();
22
+
23
+ // `<providerID>:<modelID>` -> { input, output, cacheRead, cacheWrite } — ONLY
24
+ // when the model config carries explicit pricing (custom providers usually omit).
25
+ const modelPrices = new Map();
26
+
27
+ // Gating for client.config.providers() (V1) fetches:
28
+ // providersLoadedAt — last SUCCESSFUL fetch (full PROVIDERS_TTL_MS cache);
29
+ // providersFailAt — last FAILED/timed-out fetch (short fail TTL, so a
30
+ // broken/hung client does not freeze limits for the
31
+ // full 5 minutes);
32
+ // providersInFlight — in-flight fetch promise (dedups parallel callers).
33
+ let providersLoadedAt = 0;
34
+ let providersFailAt = 0;
35
+ let providersInFlight = null;
36
+
37
+ // Gating for ctx.model.list() (V2) — success TTL + in-flight dedup. Failures
38
+ // are retried after the short gate; limit lookups then simply fall back to
39
+ // absolute tokens (as in V1).
40
+ let v2LimitsLoadedAt = 0;
41
+ let v2LimitsFailAt = 0;
42
+ let v2LimitsFailCount = 0; // consecutive soft-failures -> exponential retry backoff
43
+ let v2LimitsInFlight = null;
44
+
45
+ // Stable lookup key for the model-keyed limit/price/schema maps. providerID may
46
+ // be unknown ("" ) — the result is still unique per modelID, but different
47
+ // providers never share a cache slot.
48
+ function modelKey(providerID, modelID) {
49
+ if (typeof modelID !== "string" || modelID.length === 0) return null;
50
+ const p = typeof providerID === "string" && providerID.length > 0 ? providerID : "";
51
+ return `${p}:${modelID}`;
52
+ }
53
+
54
+ // Raw limit record for a model: { context, input, output, reserved } or null.
55
+ function getModelLimitRecord(providerID, modelID) {
56
+ const key = modelKey(providerID, modelID);
57
+ if (!key) return null;
58
+ const v = modelLimits.get(key);
59
+ return v && typeof v === "object" ? v : null;
60
+ }
61
+
62
+ // Context window for a model (null when unknown). Used for subagent ratios and
63
+ // as the degraded denominator when the native usable window is not derivable.
64
+ function getModelLimit(providerID, modelID) {
65
+ const rec = getModelLimitRecord(providerID, modelID);
66
+ if (!rec) return null;
67
+ return typeof rec.context === "number" && rec.context > 0 ? rec.context : null;
68
+ }
69
+
70
+ // Native opencode usable window (see packages/opencode/src/session/overflow.ts):
71
+ // usable = limit.input ? limit.input − reserved : limit.context − maxOutput
72
+ // reserved = compaction.reserved ?? min(20_000, maxOutput)
73
+ // maxOutput = model.limit.output
74
+ // Returns { usable, limit, maxOutput, reserved } or null when not derivable
75
+ // (callers then degrade to count / limit.context, or to no percentage at all).
76
+ function getUsableContext(providerID, modelID) {
77
+ const rec = getModelLimitRecord(providerID, modelID);
78
+ if (!rec) return null;
79
+ const context =
80
+ typeof rec.context === "number" && rec.context > 0 ? rec.context : null;
81
+ const input =
82
+ typeof rec.input === "number" && rec.input > 0 ? rec.input : null;
83
+ const maxOutput =
84
+ typeof rec.output === "number" && rec.output > 0 ? rec.output : 0;
85
+ if (input == null && context == null) return null;
86
+ // Mirrors opencode overflow.ts: reserved = min(20000, maxOutput), i.e. 0 when
87
+ // the model declares no output limit (NOT a 20000 default — that would
88
+ // understate the usable window).
89
+ const reserved =
90
+ typeof rec.reserved === "number" && rec.reserved >= 0
91
+ ? rec.reserved
92
+ : Math.min(20000, maxOutput);
93
+ const usable = input != null ? input - reserved : context - maxOutput;
94
+ if (!(usable > 0)) return null;
95
+ return { usable, limit: context, maxOutput, reserved };
96
+ }
97
+
98
+ // Store a model limit record under `<providerID>:<modelID>`. Written by
99
+ // `ensureModelLimits` (V2: ctx.model.list()) and `ensureProviderLimits` (V1:
100
+ // client.config.providers()), both carrying the effective merged config.
101
+ function setModelLimit(providerID, modelID, rec) {
102
+ const key = modelKey(providerID, modelID);
103
+ if (!key) return false;
104
+ modelLimits.set(key, rec);
105
+ return true;
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // V1 model limits: effective values from client.config.providers(), cached.
110
+ // ---------------------------------------------------------------------------
111
+
112
+ // Load effective limits for ALL providers from the server config. Preferred
113
+ // over the config() hook snapshot because it reflects live overrides.
114
+ // Gating: a SUCCESSFUL fetch is cached for PROVIDERS_TTL_MS; a failed or
115
+ // timed-out attempt only blocks retries for PROVIDERS_FAIL_TTL_MS, so limits
116
+ // recover quickly instead of staying stale for the full 5 minutes. All
117
+ // failures are caught here — the function never rejects; callers fall back to
118
+ // whatever the config() hook loaded.
119
+ async function ensureProviderLimits(client) {
120
+ const now = Date.now();
121
+ if (now - providersLoadedAt < PROVIDERS_TTL_MS) return; // fresh success
122
+ if (now - providersFailAt < PROVIDERS_FAIL_TTL_MS) return; // recent failure
123
+ if (providersInFlight) return providersInFlight; // dedup parallel callers
124
+ providersInFlight = (async () => {
125
+ try {
126
+ const res = await withTimeout(
127
+ client.config.providers(),
128
+ PROVIDERS_FETCH_TIMEOUT_MS,
129
+ "config.providers",
130
+ );
131
+ const providers = Array.isArray(res)
132
+ ? res
133
+ : res?.data?.providers ?? res?.providers ?? res?.data;
134
+ if (!Array.isArray(providers)) {
135
+ providersFailAt = Date.now(); // malformed payload -> retry soon
136
+ return;
137
+ }
138
+ for (const p of providers) {
139
+ const models = p?.models;
140
+ if (!models || typeof models !== "object") continue;
141
+ // providerID is on the top level of each provider entry (fall back to
142
+ // `id`; both are accepted across builds).
143
+ const providerID =
144
+ typeof p?.providerID === "string" && p.providerID
145
+ ? p.providerID
146
+ : typeof p?.id === "string"
147
+ ? p.id
148
+ : "";
149
+ for (const [modelID, def] of Object.entries(models)) {
150
+ const key = modelKey(providerID, modelID);
151
+ if (!key) continue;
152
+ const lim = def?.limit;
153
+ if (!lim || typeof lim !== "object") continue;
154
+ const context =
155
+ typeof lim.context === "number" && lim.context > 0 ? lim.context : null;
156
+ const input =
157
+ typeof lim.input === "number" && lim.input > 0 ? lim.input : null;
158
+ const output =
159
+ typeof lim.output === "number" && lim.output > 0 ? lim.output : null;
160
+ if (context == null && input == null) continue;
161
+ const prevRec = modelLimits.get(key);
162
+ setModelLimit(providerID, modelID, {
163
+ context,
164
+ input,
165
+ output,
166
+ reserved:
167
+ typeof def?.compaction?.reserved === "number"
168
+ ? def.compaction.reserved
169
+ : (prevRec?.reserved ?? null),
170
+ });
171
+ }
172
+ }
173
+ providersLoadedAt = Date.now(); // set ONLY on success
174
+ } catch (err) {
175
+ providersFailAt = Date.now(); // failed attempt -> short retry gate
176
+ logErr("config.providers failed", err);
177
+ } finally {
178
+ providersInFlight = null;
179
+ }
180
+ })();
181
+ return providersInFlight;
182
+ }
183
+
184
+ // V2 model limits: effective values from ctx.model.list(), cached + gated.
185
+ // { data: ModelInfo[] } where ModelInfo = { modelID, providerID, limit:{context} }.
186
+ async function ensureModelLimits(ctx) {
187
+ const now = Date.now();
188
+ if (now - v2LimitsLoadedAt < PROVIDERS_TTL_MS) return; // fresh success
189
+ // Recent failure: back off exponentially (30s, 60s, 120s … capped at 5 min)
190
+ // so a registry that is slow to expose the provider is retried without
191
+ // hammering it, while a one-off blip still recovers quickly.
192
+ const failBackoff = Math.min(
193
+ PROVIDERS_FAIL_TTL_MS * Math.pow(2, Math.max(0, v2LimitsFailCount - 1)),
194
+ V2_LIMITS_MAX_BACKOFF_MS,
195
+ );
196
+ if (now - v2LimitsFailAt < failBackoff) return; // recent failure
197
+ if (v2LimitsInFlight) return v2LimitsInFlight; // dedup parallel callers
198
+ v2LimitsInFlight = (async () => {
199
+ try {
200
+ const res = await withTimeout(
201
+ ctx.model.list(),
202
+ V2_MODEL_FETCH_TIMEOUT_MS,
203
+ "model.list",
204
+ );
205
+ const models = Array.isArray(res) ? res : res?.data;
206
+ if (!Array.isArray(models)) {
207
+ v2LimitsFailCount++;
208
+ v2LimitsFailAt = Date.now(); // malformed payload -> retry soon
209
+ const seeded = hydrateModelLimitsFromState();
210
+ logErr(
211
+ "model.list returned no model array — limits unavailable, will retry",
212
+ { payloadType: typeof res, hydratedFromState: seeded },
213
+ );
214
+ return;
215
+ }
216
+ let found = 0;
217
+ for (const m of models) {
218
+ const modelID = m?.modelID ?? m?.id;
219
+ const providerID =
220
+ typeof m?.providerID === "string" && m.providerID
221
+ ? m.providerID
222
+ : typeof m?.provider?.id === "string"
223
+ ? m.provider.id
224
+ : "";
225
+ const key = modelKey(providerID, modelID);
226
+ const lim = m?.limit;
227
+ if (!key || !lim || typeof lim !== "object") continue;
228
+ const context =
229
+ typeof lim.context === "number" && lim.context > 0 ? lim.context : null;
230
+ const input =
231
+ typeof lim.input === "number" && lim.input > 0 ? lim.input : null;
232
+ const output =
233
+ typeof lim.output === "number" && lim.output > 0 ? lim.output : null;
234
+ if (context == null && input == null) continue;
235
+ const prevRec = modelLimits.get(key);
236
+ setModelLimit(providerID, modelID, {
237
+ context,
238
+ input,
239
+ output,
240
+ reserved:
241
+ typeof m?.compaction?.reserved === "number"
242
+ ? m.compaction.reserved
243
+ : (prevRec?.reserved ?? null),
244
+ });
245
+ found++;
246
+ }
247
+ // An empty / limit-less payload right after boot (registry not ready yet)
248
+ // must NOT be cached as success, or limits would stay unknown for the
249
+ // full TTL — treat it as a soft failure and retry after the fail gate.
250
+ if (found > 0) {
251
+ v2LimitsLoadedAt = Date.now();
252
+ v2LimitsFailCount = 0; // recovered
253
+ } else {
254
+ // Soft failure: the registry answered but carried no usable limit for
255
+ // any model (provider not registered yet, or `limit` absent). Log WHY
256
+ // once per backoff window, and fall back to the shared snapshot so
257
+ // denominators do not vanish meanwhile.
258
+ const withLimit = models.filter(
259
+ (m) => m?.limit && typeof m.limit === "object",
260
+ ).length;
261
+ const withProvider = models.filter(
262
+ (m) => m?.providerID || m?.provider?.id,
263
+ ).length;
264
+ const seeded = hydrateModelLimitsFromState();
265
+ v2LimitsFailCount++;
266
+ v2LimitsFailAt = Date.now();
267
+ logErr(
268
+ "model.list yielded 0 usable limits — retrying",
269
+ { models: models.length, withLimit, withProvider, hydratedFromState: seeded },
270
+ );
271
+ }
272
+ } catch (err) {
273
+ v2LimitsFailCount++;
274
+ v2LimitsFailAt = Date.now(); // failed attempt -> backoff gate
275
+ const seeded = hydrateModelLimitsFromState();
276
+ logErr(
277
+ `model.list failed (hydrated ${seeded} limit(s) from state.json)`,
278
+ err,
279
+ );
280
+ } finally {
281
+ v2LimitsInFlight = null;
282
+ }
283
+ })();
284
+ return v2LimitsInFlight;
285
+ }
286
+
287
+ // Seed `modelLimits` from previously persisted state.json entries. state.json is
288
+ // a SHARED cross-instance snapshot rewritten at every live step, so it carries a
289
+ // model's window even when THIS instance's ctx.model.list() came back empty (a
290
+ // cold instance, or one whose model registry did not expose the provider).
291
+ // Without this, a transient list miss degraded every denominator to "?" — and
292
+ // because writeStateFile used to rewrite usable/limit from the (empty) in-memory
293
+ // map, the degradation became permanent.
294
+ //
295
+ // The snapshot also holds history: during an earlier outage some instances wrote
296
+ // a WRONG usable window (e.g. 168000 instead of 1015808). A naive
297
+ // first-match seed would then re-inject that stale garbage. Therefore, per
298
+ // `<provider>:<model>`:
299
+ // * candidates are sanity-filtered (drop `usable >= limit`, `usable <= 0`);
300
+ // * when any candidate carries a precise `usable`, only those are considered;
301
+ // * the FRESHEST (max `updatedAt`) candidate wins;
302
+ // * a divergent spread (max > 3× min usable) is logged for diagnosis.
303
+ // Strictly additive: real in-memory records always win (never overwritten).
304
+ // Returns the number of records seeded.
305
+ function hydrateModelLimitsFromState() {
306
+ try {
307
+ const all = readStateSnapshot();
308
+ // Group candidate entries by key first, so "freshest" is decided against
309
+ // ALL records of that model — not the first one encountered.
310
+ const byKey = new Map(); // key -> [ { usable, limit, updatedAt } ]
311
+ for (const entry of Object.values(all)) {
312
+ const providerID =
313
+ typeof entry?.providerID === "string" ? entry.providerID : "";
314
+ const modelID = entry?.model;
315
+ const key = modelKey(providerID, modelID);
316
+ if (!key || !modelID || modelID === "unknown") continue;
317
+ if (modelLimits.has(key)) continue; // a real in-memory record always wins
318
+ // finitePos rejects NaN/Infinity/absurd windows (e.g. a poisoned 1e15).
319
+ const usable = finitePos(entry?.usable) ? entry.usable : null;
320
+ const limit = finitePos(entry?.limit) ? entry.limit : null;
321
+ if (usable == null && limit == null) continue;
322
+ let arr = byKey.get(key);
323
+ if (!arr) {
324
+ arr = [];
325
+ byKey.set(key, arr);
326
+ }
327
+ arr.push({
328
+ usable,
329
+ limit,
330
+ updatedAt: typeof entry?.updatedAt === "string" ? entry.updatedAt : "",
331
+ });
332
+ }
333
+
334
+ let added = 0;
335
+ for (const [key, candidates] of byKey) {
336
+ // (b) Sanity: drop impossible records (usable >= limit, usable <= 0).
337
+ // Candidates without a usable (context-only) are kept as-is.
338
+ const sane = candidates.filter(
339
+ (c) =>
340
+ c.usable == null ||
341
+ (c.usable > 0 && (c.limit == null || c.usable <= c.limit)),
342
+ );
343
+ if (sane.length === 0) continue; // every record was impossible -> skip
344
+
345
+ // (a) Prefer the precise variant (a recorded usable) when any exists.
346
+ const precise = sane.filter((c) => c.usable != null);
347
+ const pool = precise.length > 0 ? precise : sane;
348
+
349
+ // (1) Freshest wins (ISO timestamps compare lexicographically).
350
+ let best = pool[0];
351
+ for (const c of pool) {
352
+ if (String(c.updatedAt) > String(best.updatedAt)) best = c;
353
+ }
354
+
355
+ // (c) Diagnostic: a >3× spread means the snapshot is polluted by a
356
+ // limited/fallback instance — log the numbers once for this model.
357
+ const usables = pool.map((c) => c.usable).filter((u) => u != null);
358
+ if (usables.length > 1) {
359
+ const max = Math.max(...usables);
360
+ const min = Math.min(...usables);
361
+ if (min > 0 && max / min > 3) {
362
+ logErr(
363
+ `limit seed spread for ${key} — using freshest`,
364
+ { min, max, candidates: pool.length, chosen: best.usable ?? best.limit },
365
+ );
366
+ }
367
+ }
368
+
369
+ // Build the record: precise usable-as-input when known (so getUsableContext
370
+ // re-derives EXACTLY the persisted window), else the raw context window.
371
+ let rec = null;
372
+ if (best.usable != null) {
373
+ rec = { context: best.limit, input: best.usable, output: null, reserved: 0 };
374
+ } else if (best.limit != null) {
375
+ rec = { context: best.limit, input: null, output: null, reserved: null };
376
+ }
377
+ if (rec == null) continue;
378
+ modelLimits.set(key, rec);
379
+ added++;
380
+ // (3) One seed line per model: chosen value + provenance.
381
+ console.log(
382
+ `[context-indicator] limit seed ${key} -> usable=${
383
+ best.usable != null ? best.usable : best.limit
384
+ } (from ${best.updatedAt || "unknown"}, ${pool.length} candidates)`,
385
+ );
386
+ }
387
+ return added;
388
+ } catch {
389
+ return 0;
390
+ }
391
+ }
392
+
393
+ export {
394
+ modelLimits,
395
+ modelPrices,
396
+ modelKey,
397
+ getModelLimit,
398
+ getUsableContext,
399
+ setModelLimit,
400
+ ensureProviderLimits,
401
+ ensureModelLimits,
402
+ hydrateModelLimitsFromState,
403
+ };