opencode-zen-freex 0.2.8 → 0.2.10

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/limits.mjs CHANGED
@@ -131,93 +131,236 @@ const NAMED_OUTPUT = [
131
131
  const OBSERVED = new Map();
132
132
  const OBSERVED_OUTPUT = new Map();
133
133
 
134
+ // Two decisions this file makes leave no trace of themselves: a live value
135
+ // taken from a refusal is applied past the staleness guard and past the
136
+ // raise-only rule, and an entry the catalogue has moved past is void in a way
137
+ // that reads exactly like an axis nobody ever measured. Neither is worth a line
138
+ // while the plugin is working — an error path that chats is its own kind of
139
+ // failure — so both are only reported when they are asked for: a host hands in
140
+ // a sink with `limitDiagnostics`, or sets this variable and reads stderr.
141
+ // Nothing a note does may reach a request: not the sink, not what it prints.
142
+ export const LIMITS_DEBUG_ENV = "OPENCODE_ZEN_FREE_LIMITS_DEBUG";
143
+ let diagnostic = null;
144
+
145
+ // Install the sink these notes are handed to, and return the one it replaced
146
+ // (null when there was none) so a caller can put back what it found.
147
+ export function limitDiagnostics(sink) {
148
+ const previous = diagnostic;
149
+ diagnostic = typeof sink === "function" ? sink : null;
150
+ return previous;
151
+ }
152
+
153
+ function note(entry) {
154
+ try {
155
+ if (diagnostic) diagnostic(entry);
156
+ else if (globalThis.process?.env?.[LIMITS_DEBUG_ENV])
157
+ console.warn(`[opencode-zen-freex] ${describe(entry)}`);
158
+ } catch {
159
+ // A note is not worth a failed request.
160
+ }
161
+ }
162
+
163
+ function describe({ kind, id, axis, value, measuredAgainst, declared }) {
164
+ if (kind === "route-statement")
165
+ return `${id} ${axis}=${value} — named by a refusal this process saw, so it is applied as it stands (the catalogue declares ${declared === undefined ? "nothing for this axis" : declared})`;
166
+ return `${id} ${axis}=${value} was measured against a catalogue that declared ${measuredAgainst}, and this one declares ${declared === undefined ? "nothing for this axis" : declared}: not applied`;
167
+ }
168
+
134
169
  // The window a refusal names, or undefined when the body says nothing
135
170
  // attributable. Exported for the tests and for anyone reading a capture.
136
171
  export function namedContextWindow(text) {
137
- return namedNumber(NAMED_CONTEXT, text);
172
+ return namedFrom([NAMED_CONTEXT], text)?.value;
138
173
  }
139
174
 
140
175
  // The completion ceiling a refusal names, or undefined. Same rule as the window:
141
- // the body has to name the parameter and a number, or it says nothing.
176
+ // the body has to name the parameter and a number, or it says nothing. Two
177
+ // wordings state it, and one that matches but names no usable number — "supports
178
+ // at most 0 completion tokens" — is not an answer; the next wording may be.
142
179
  export function namedOutputCap(text) {
143
- for (const pattern of NAMED_OUTPUT) {
144
- const value = namedNumber(pattern, text);
145
- if (value !== undefined) return value;
180
+ return namedFrom(NAMED_OUTPUT, text)?.value;
181
+ }
182
+
183
+ // The first of `patterns` that names a usable number, with the match it was read
184
+ // from. The match and the number have to be the same one: a caller that guards a
185
+ // match and then reads its number elsewhere is judging one sentence and
186
+ // returning another.
187
+ function namedFrom(patterns, text) {
188
+ const subject = typeof text === "string" ? text : "";
189
+ for (const pattern of patterns) {
190
+ const match = pattern.exec(subject);
191
+ const value = numberFrom(match);
192
+ if (value !== undefined) return { match, value };
146
193
  }
147
194
  return undefined;
148
195
  }
149
196
 
150
- function namedNumber(pattern, text) {
151
- const match = pattern.exec(typeof text === "string" ? text : "");
197
+ // The number a match names, or undefined when it names nothing usable: a
198
+ // sentence about a parameter that happens to mention zero names no ceiling.
199
+ function numberFrom(match) {
152
200
  if (!match) return undefined;
153
201
  const tokens = Number.parseInt(match[1], 10);
154
202
  return Number.isSafeInteger(tokens) && tokens > 0 ? tokens : undefined;
155
203
  }
156
204
 
157
- // Read a refusal for the names it makes about this model's limits. Only non-2xx
158
- // responses are read, and only the first `limit` characters of one: this is an
159
- // error path on the request's way to a caller that is waiting, so it must never
160
- // be able to fail a request, change a response, or buffer a body that is not
161
- // small. A number the read cut in half names nothing either, since half of one
162
- // is a smaller limit that looks like a whole one. Returns undefined when the
163
- // body names nothing attributable, otherwise whichever axes it named.
164
- export async function observeRouteStatement(id, response, limit = 4096) {
205
+ // Read at most `limit` characters of a response without buffering the rest:
206
+ // this is an error path on a request whose caller is waiting, so a body that is
207
+ // not small is dropped as soon as the part that is read is in hand. Returns the
208
+ // text, and whether the read stopped because the body ended (`ended`) or because
209
+ // the budget was reached (`over`).
210
+ //
211
+ // It reads the response's own body, not a copy of it. `response.clone()` tees a
212
+ // body, and cancelling one branch of a tee settles only once the other branch is
213
+ // drained or cancelled — which for the caller's path is never, because the
214
+ // sibling branch is read later, after this returns. A clone here hangs the whole
215
+ // request on any refusal larger than the budget that arrives in more than one
216
+ // read (measured: a 7 KB 400 in 1400-character chunks never settles, and an
217
+ // abort does not unwedge it). The body is read once and the bytes go back to
218
+ // the caller, so nothing needs a second copy.
219
+ //
220
+ // The read is bounded in time as well as in size, because a body can stall as
221
+ // well as be large. Measured: a 403 whose `content-length` promised 999 bytes
222
+ // and delivered 120 left `read()` pending for good, which hung the request just
223
+ // as surely as the clone did — an upstream that dies mid-body, or a proxy that
224
+ // pads a length, is the same shape. `signal` is the caller's own cancellation;
225
+ // without one the budget below is all the wait this evidence is worth, since a
226
+ // number out of a refusal that is not arriving is not worth a caller's turn.
227
+ //
228
+ // A body that fails halfway is still worth what was read of it, and a read that
229
+ // throws is not an error about the request: the callers of this are error paths
230
+ // themselves.
231
+ const READ_DEADLINE_MS = 3000;
232
+
233
+ export async function readBoundedText(response, limit = 4096, { signal } = {}) {
234
+ let reader;
165
235
  try {
166
- if (typeof id !== "string" || !id || response?.ok !== false) return undefined;
167
- const text = await readPrefix(response, limit);
168
- // `readPrefix` stops at `limit`, so the last character read may be the first
169
- // half of a longer number. Both words this reads are numbers inside prose —
170
- // the window's ends in the literal "tokens", but the second ceiling wording
171
- // ends with the digits it captured, so `/max_tokens: 1000000 is not less or
172
- // equal to 2621` carved out of 262144 reads as a ceiling Zen stated. Live
173
- // values are taken as they stand, past the staleness check and the guard
174
- // below, so that half a number would become the applied max_tokens. A match
175
- // that runs up to the cut is therefore not evidence, on either axis: asking
176
- // again over one character less only finds the same number one digit
177
- // shorter, and reading the rest of the body is what this path must not do.
178
- const { context, output } = namedAxes(text, limit, text.length > limit);
179
- if (context === undefined && output === undefined) return undefined;
180
- if (context !== undefined) OBSERVED.set(id, context);
181
- if (output !== undefined) OBSERVED_OUTPUT.set(id, output);
182
- return { context, output };
236
+ reader = response?.body?.getReader?.();
183
237
  } catch {
184
- return undefined;
238
+ // A body somebody else is holding cannot be read, and nothing of it was
239
+ // read: what came back is not the whole of a body, and says so.
240
+ return { text: "", ended: false, over: true };
185
241
  }
186
- }
187
-
188
- // Read at most `limit` characters of a refusal without buffering the rest: this
189
- // is an error path on a request whose caller is waiting, so a body that is not
190
- // small is dropped as soon as the part that is read is in hand.
191
- async function readPrefix(response, limit) {
192
- const body = response.clone().body;
193
- if (typeof body?.getReader !== "function") return response.clone().text().catch(() => "");
194
- const reader = body.getReader();
242
+ // A response with no stream has no body to read and none to buffer — which is
243
+ // why the fallback that read one whole is gone: the only responses that
244
+ // reached it were body-less, and a body-less response's text is empty either
245
+ // way.
246
+ if (!reader) return { text: "", ended: true, over: false };
247
+ const deadline = AbortSignal.timeout(READ_DEADLINE_MS);
248
+ const bound = signal ? AbortSignal.any([signal, deadline]) : deadline;
249
+ // The wait ending is this read's to remember, not the reader's: cancelling a
250
+ // stream resolves its pending `read()` as done, so a body cut short by the
251
+ // deadline would come back looking like one that ended — and a number at the
252
+ // end of it like a whole one. Measured before this flag existed: a stalled
253
+ // refusal whose last characters were `...is not less or equal to 2621`, out of
254
+ // `262144`, was stored and applied as a completion ceiling.
255
+ let cut = false;
256
+ const abort = () => {
257
+ // The wait is over and the bytes already read are all this evidence is
258
+ // going to be; stop pulling on a body that is not answering.
259
+ cut = true;
260
+ reader.cancel().catch(() => {});
261
+ };
262
+ if (bound.aborted) abort();
263
+ else bound.addEventListener("abort", abort, { once: true });
195
264
  const decoder = new TextDecoder();
196
265
  let text = "";
266
+ let ended = false;
197
267
  try {
198
- while (text.length < limit) {
268
+ // The loop runs while the budget has room for one more character, so a body
269
+ // that lands exactly on the budget still gets the read that says it ended,
270
+ // and one that goes on is caught by the buffer stopping a character past it.
271
+ while (text.length <= limit) {
199
272
  const { value, done } = await reader.read();
200
- if (done) break;
201
- text += decoder.decode(value, { stream: true });
273
+ if (done) {
274
+ ended = true;
275
+ break;
276
+ }
277
+ // At most one character past the budget: enough to know the body went on,
278
+ // and the rest of the chunk is dropped rather than materialised.
279
+ text += decoder.decode(value, { stream: true }).slice(0, limit + 1 - text.length);
202
280
  }
203
281
  } catch {
204
- // A body that fails halfway is still worth what was read of it.
282
+ // Keep what was read; a failed read names nothing new. A read that was
283
+ // cancelled by the deadline lands here too, which is the point of it.
205
284
  }
206
- await reader.cancel().catch(() => {});
207
- return text;
285
+ bound.removeEventListener("abort", abort);
286
+ // A read the wait cut short saw no end of body, whatever the cancelled reader
287
+ // said about it.
288
+ if (cut) ended = false;
289
+ // The reader is this function's to release, and the body is abandoned rather
290
+ // than left open: the caller has what it needs and will not read on.
291
+ //
292
+ // The cancel is deliberately not awaited. It stops the upstream either way,
293
+ // but the promise it returns settles only once the source reacts — and this
294
+ // path is an error body on a request whose caller is waiting. A body that
295
+ // never ends (a stalled upstream, or headers promising more bytes than the
296
+ // response delivers: measured, a 403 with `content-length: 999` over a 120-byte
297
+ // body) would leave the await pending for good, which is the same hang this
298
+ // function was rewritten to remove. Nothing downstream reads this body again,
299
+ // so its release is nobody's to wait for.
300
+ reader.cancel().catch(() => {});
301
+ // `over` is the read having stopped before the body did — the budget reached,
302
+ // or the read failed, or the deadline passing — which is the only honest
303
+ // source for "this may be cut".
304
+ return { text, ended, over: !ended };
208
305
  }
209
306
 
210
- // The axes the first `limit` characters of `text` name. `cut` says the body went
211
- // on past that point, so a match ending on the last character read may have been
212
- // a longer number: that axis is left unread rather than reported a digit short.
307
+ // Read a refusal for the names it makes about this model's limits. Only non-2xx
308
+ // responses are read, and only the first `limit` characters of one.
309
+ //
310
+ // A response body can only be read once, and the response handed in is treated
311
+ // as read once this returns: the bytes come back as `text` so that a caller
312
+ // which has to keep them (provider.mjs rebuilds the message a user reads) can
313
+ // do so without a second read of a body that no longer exists.
314
+ //
315
+ // `cut` says the read stopped at the budget rather than at the end of the body.
316
+ // It is the read that says so, not the length of what was read: a body whose
317
+ // first read lands exactly on the budget leaves `text.length === limit` while
318
+ // the stream is still going, and a number sliced there (`262144` read as
319
+ // `2621`) must not be taken for a limit the route stated.
320
+ //
321
+ // Returns undefined only when nothing was read — a response that is not a
322
+ // refusal, or one with no body to read — and otherwise whichever axes the text
323
+ // named, with that text alongside them. An axis the body did not name is
324
+ // undefined; a body that named nothing still comes back, because a caller that
325
+ // has to hand it on has nowhere else to get it from.
326
+ export async function observeRouteStatement(id, response, limit = 4096, { signal } = {}) {
327
+ try {
328
+ if (typeof id !== "string" || !id || response?.ok !== false) return undefined;
329
+ // A response with no body was not read and is not consumed: the caller
330
+ // still holds the one it had, and undefined says so.
331
+ if (response.body == null) return undefined;
332
+ const { text, over } = await readBoundedText(response, limit, { signal });
333
+ const { context, output } = namedAxes(text, limit, over);
334
+ if (context !== undefined) OBSERVED.set(id, context);
335
+ if (output !== undefined) OBSERVED_OUTPUT.set(id, output);
336
+ return { context, output, text };
337
+ } catch {
338
+ return undefined;
339
+ }
340
+ }
341
+
342
+ // How close to the end of what was read a match has to end before a truncated
343
+ // body makes it unusable. A number ending inside this margin of the last
344
+ // character read may have had digits past it — `2621` where the body said
345
+ // `262144` — and a smaller limit that looks like a real one is the one failure
346
+ // this guard exists to prevent. Numbers further back are inside the buffer
347
+ // entirely and are read as they stand.
348
+ const CUT_MARGIN = 8;
349
+
350
+ // The axes the first `limit` characters of `text` name. `cut` says the read
351
+ // stopped at the budget with the body going on, so a match ending at the margin
352
+ // may have been a longer number: that axis is left unread rather than reported a
353
+ // digit short. Each axis is read from the match its number came from, so the
354
+ // match the guard judges and the number that is returned cannot be two answers.
213
355
  function namedAxes(text, limit, cut) {
214
356
  const head = text.slice(0, limit);
215
- const contextMatch = NAMED_CONTEXT.exec(head);
216
- const outputMatch = NAMED_OUTPUT.map((pattern) => pattern.exec(head)).find(Boolean);
217
- const onCut = (match) => cut && Boolean(match) && match.index + match[0].length >= limit;
357
+ const context = namedFrom([NAMED_CONTEXT], head);
358
+ const output = namedFrom(NAMED_OUTPUT, head);
359
+ const onCut = (found) =>
360
+ cut && Boolean(found) && found.match.index + found.match[0].length >= head.length - CUT_MARGIN;
218
361
  return {
219
- context: onCut(contextMatch) ? undefined : namedContextWindow(head),
220
- output: onCut(outputMatch) ? undefined : namedOutputCap(head),
362
+ context: onCut(context) ? undefined : context?.value,
363
+ output: onCut(output) ? undefined : output?.value,
221
364
  };
222
365
  }
223
366
 
@@ -235,7 +378,11 @@ export function limitOverrides(
235
378
  observedOutput = OBSERVED_OUTPUT,
236
379
  ) {
237
380
  const overrides = {};
238
- const record = table?.[id];
381
+ // A record has to be this table's own. `table[id]` otherwise answers out of
382
+ // Object.prototype for `toString` and the rest, and out of whatever prototype
383
+ // an object handed in as `table` carries — evidence for a model nobody
384
+ // declared, from a table that never named it.
385
+ const record = table && Object.hasOwn(table, id) ? table[id] : undefined;
239
386
  for (const axis of ["context", "output"]) {
240
387
  const declaredValue = declared?.[axis];
241
388
  const entry = record?.[axis];
@@ -250,6 +397,19 @@ export function limitOverrides(
250
397
  value = live;
251
398
  } else if (entry && declaredValue !== undefined && entry.declaredAt === declaredValue) {
252
399
  value = entry.value;
400
+ } else if (entry) {
401
+ // Measured against a catalogue that has moved, or against an axis the
402
+ // catalogue no longer declares. Its value is void, and saying so is the
403
+ // whole point of the note: otherwise this is indistinguishable from a
404
+ // model nothing was ever measured for.
405
+ note({
406
+ kind: "catalogue-drift",
407
+ id,
408
+ axis,
409
+ value: entry.value,
410
+ measuredAgainst: entry.declaredAt,
411
+ declared: declaredValue,
412
+ });
253
413
  }
254
414
  if (value === undefined || !Number.isSafeInteger(value) || value <= 0) continue;
255
415
  if (value === declaredValue) continue;
@@ -264,6 +424,10 @@ export function limitOverrides(
264
424
  )
265
425
  continue;
266
426
  overrides[axis] = value;
427
+ // A live value is the one override taken past the staleness guard and past
428
+ // the raise-only rule, so it is the one nobody can account for afterwards:
429
+ // say what was applied and what it stands in for.
430
+ if (live !== undefined) note({ kind: "route-statement", id, axis, value, declared: declaredValue });
267
431
  }
268
432
  return overrides;
269
433
  }
package/models.mjs CHANGED
@@ -36,10 +36,18 @@ function sdkProtocol(npm) {
36
36
  // explicit null, and neither retires a model.
37
37
  const hasValue = (value) => value !== null && value !== undefined;
38
38
 
39
+ // A status has to say something to be one. The catalogue writes an empty string
40
+ // as well as a null, and either one used to stand in front of the `lifecycle`
41
+ // that does retire the model, so a model retired through `lifecycle` kept being
42
+ // offered. A status that is set — "active" included — is still read as itself.
43
+ const isSet = (value) => hasValue(value) && String(value).trim() !== "";
44
+
39
45
  function isRetired(model) {
40
46
  return (
41
47
  model.deprecated === true ||
42
- ["deprecated", "retired", "disabled"].includes(model.status ?? model.lifecycle) ||
48
+ ["deprecated", "retired", "disabled"].includes(
49
+ isSet(model.status) ? model.status : model.lifecycle,
50
+ ) ||
43
51
  hasValue(model.deprecated_at) ||
44
52
  hasValue(model.retirement_date)
45
53
  );
@@ -72,7 +80,12 @@ function listedFree(id) {
72
80
  // sending it somewhere it is not served: `systemone` and a model the catalogue
73
81
  // drops do not appear.
74
82
  function protocolFor(id, model, raw, provider, protocols) {
75
- if (protocols[id]) return protocols[id];
83
+ // Own properties only. `protocols` is a plain object, so `protocols["toString"]`
84
+ // is a truthy function — the model would be dropped for naming a protocol
85
+ // outside `SDK` instead of being resolved from the catalogue or its `-free`
86
+ // name, and `protocols["__proto__"]` would be an inherited protocol nobody
87
+ // wrote. What the docs actually say about an id is an own property or nothing.
88
+ if (Object.hasOwn(protocols, id)) return protocols[id];
76
89
  if (raw) return sdkProtocol(model.provider?.npm ?? provider.npm);
77
90
  if (listedFree(id)) return sdkProtocol(provider.npm) ?? "chat";
78
91
  return undefined;
@@ -89,8 +102,19 @@ const modalities = (values) =>
89
102
  export function buildModels(ids, provider, protocols, baseURL) {
90
103
  const result = {};
91
104
  for (const id of ids) {
105
+ // `__proto__` is skipped where the id is first known to be a string: it can
106
+ // never be an entry in the catalogue's own map, since on a plain object the
107
+ // write side of it is the prototype. The own-property read below leaves it
108
+ // unresolved for that same reason, so this is the redundant half of a pair —
109
+ // kept because it states the rule at the point the rule is about.
92
110
  if (typeof id !== "string" || !id || id === "__proto__") continue;
93
- const raw = provider.models?.[id];
111
+ // The catalogue's own entry for the id, not whatever Object.prototype has
112
+ // under that name: `models?.["toString"]` is a function, and reading it as a
113
+ // description of the model would invent a protocol for an id nothing
114
+ // describes. An id with no entry here is one the catalogue has not caught up
115
+ // with, which the `-free` rule below is for.
116
+ const catalogue = provider.models ?? {};
117
+ const raw = Object.hasOwn(catalogue, id) ? catalogue[id] : undefined;
94
118
  const model = raw ?? {};
95
119
  const protocol = protocolFor(id, model, raw, provider, protocols);
96
120
  if (!isFree(id, model) || !Object.hasOwn(SDK, protocol)) continue;
package/package.json CHANGED
@@ -1,25 +1,32 @@
1
1
  {
2
2
  "name": "opencode-zen-freex",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "OpenCode Zen's free models, for hosts that are not OpenCode: the public credential, automatic discovery and native streaming protocols",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
- "exports": { ".": "./index.mjs", "./*": "./*" },
7
+ "exports": {
8
+ ".": "./index.mjs",
9
+ "./index.mjs": "./index.mjs",
10
+ "./provider.mjs": "./provider.mjs",
11
+ "./core-tools.mjs": "./core-tools.mjs",
12
+ "./models.mjs": "./models.mjs",
13
+ "./limits.mjs": "./limits.mjs",
14
+ "./stream.mjs": "./stream.mjs",
15
+ "./tool-response.mjs": "./tool-response.mjs"
16
+ },
8
17
  "files": [
9
18
  "index.mjs",
10
19
  "provider.mjs",
11
20
  "core-tools.mjs",
12
21
  "fingerprint.json",
13
22
  "limits.mjs",
14
- "limits-audit.mjs",
15
23
  "models.mjs",
16
24
  "stream.mjs",
17
25
  "tool-response.mjs",
18
- "tools-smoke.mjs",
19
- "capture.mjs",
20
26
  "README.md",
21
27
  "MAINTAINING.md",
22
- "LICENSE"
28
+ "LICENSE",
29
+ "NOTICE"
23
30
  ],
24
31
  "keywords": ["opencode", "opencode-plugin", "zen", "free", "magpie"],
25
32
  "license": "MIT",
@@ -32,5 +39,6 @@
32
39
  "limits:audit": "bun limits-audit.mjs",
33
40
  "capture": "bun capture.mjs"
34
41
  },
42
+ "packageManager": "bun@1.4.3",
35
43
  "magpie": { "icon": "https://opencode.ai/favicon-v3.ico", "maxConcurrency": 2 }
36
44
  }