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.
- package/README.md +87 -38
- package/index.js +24 -3233
- package/lib/cache.js +161 -0
- package/lib/commands.js +489 -0
- package/lib/estimate.js +232 -0
- package/lib/limits.js +403 -0
- package/lib/state.js +595 -0
- package/lib/v1.js +957 -0
- package/lib/v2.js +692 -0
- package/package.json +4 -4
- package/tui.tsx +711 -30
package/lib/estimate.js
ADDED
|
@@ -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
|
+
};
|