opencode-zen-freex 0.2.16 → 0.2.18

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/MAINTAINING.md CHANGED
@@ -173,6 +173,12 @@ bun run capture # writes it
173
173
  bun run check:gate # did that fix it
174
174
  ```
175
175
 
176
+ `bun test` reads the tree; a running gateway does not. Magpie's plugin host
177
+ imports the plugin once, when it starts, and no `magpie plugin` subcommand
178
+ reloads it — pointing a gateway at this tree does not make it watch the tree, so
179
+ a change to be measured through a live gateway needs that gateway restarted
180
+ first.
181
+
176
182
  `check:gate` is the one that matters. It asks each generation twice, because the
177
183
  plugin sends two shapes and Zen does not answer them alike:
178
184
 
@@ -304,6 +310,22 @@ it. Rewriting the parameters as well looks tidier and breaks the agent.
304
310
  **Replacing the wording with something of your own.** That is what 0.1.5 and
305
311
  earlier did, and it is a guaranteed refusal.
306
312
 
313
+ **Leaving out a call the agent has no tool for.** A call whose name folded onto
314
+ no declared tool was dropped from the reply with a note in its text, and a reply
315
+ whose every call was dropped ended as a plain `stop`. An autonomous host reads
316
+ that as an answer: DeepSeek Harness ended 16 of the 19 such turns one user
317
+ measured and the model never saw an error it could correct
318
+ (`magpie-community/plugins#209`; upstream made the same change in its 0.1.11).
319
+ The name now goes on as the model wrote it — same name, same id, same arguments,
320
+ a tool-call finish — and the host answers it with its own unknown-tool error,
321
+ which is the correction signal. A name two declared tools fold to is still never
322
+ guessed: it goes on as called. Only a call with **no name** is left out now, and
323
+ `droppedNote` says so. Measured 2026-10-18 through a local magpie pointed at
324
+ this tree, `deepseek-v4.1-flash-free`, declaring `pwsh` and calling `bash`:
325
+ before, `200` + `finish_reason: "stop"` + the note; after, `200` + `tool_calls`
326
+ + `bash` carrying its id. Do not put the drop back to "protect" a host —
327
+ answering is the host's to do, and a silent `stop` is the failure this replaced.
328
+
307
329
  **Merging a tool call's frames over its id.** The route puts `"id": null` on
308
330
  the continuation frames of a call whose first frame named it (measured
309
331
  2026-10-17 on `deepseek-v4.1-flash-free`), and a host that keys a tool result
@@ -315,6 +337,17 @@ invented one. Both live in `tool-response.mjs` — the merge's keep, and
315
337
  `ensureCallIDs` at the two places a call is published — and `stream.mjs`'s
316
338
  collapse reads the same rule.
317
339
 
340
+ Both of those places publish the **chat** shape only. A Responses or Messages
341
+ reply whose call carries no id of its own is handed on as it stands — no id, no
342
+ `call_id` — and both protocols require the field. Measured 2026-10-18, no free
343
+ model is served on either route: the tier's own `/models` listed none,
344
+ `muse-spark-1.3-contributor-free` answered 410 `ModelDeprecated` and the four
345
+ Messages models answered "Model is unavailable". So the gap is real but not
346
+ reachable today, and the sentence above — every published call leaves with a
347
+ non-empty id — is chat's. A free model on either route puts it back in scope,
348
+ and then the id has to be minted once per call across its added, done and
349
+ terminal copies, the way the dropped ones already are.
350
+
318
351
  **Trusting one observation.** The gate is not steady: it moved more than once in
319
352
  a day while this was being worked out, and identical requests were answered both
320
353
  ways minutes apart. Anything measured once is not a fact. Measure it three times,
package/README.md CHANGED
@@ -64,8 +64,8 @@ bun run capture # 确认差异后才写入
64
64
 
65
65
  发往 `https://opencode.ai/inference/v1`,按每个模型原生协议走(Chat Completions / Responses / Anthropic Messages),流式与普通 JSON 都支持。
66
66
 
67
- - **宿主的工具不会被丢**:Zen 调 OpenCode 工具名时,按大小写与 `_`/`-` 对上唯一匹配的宿主工具(`bash`↔`Bash`、`todowrite`↔`todo_write`);对不上就略去并在正文说明。工具仍在宿主执行,调用一律带非空 id。
68
- - **只借 OpenCode 的样子**:请求头、User-Agent、工具必须同属一代(`bash`/`glob`/`grep`/`read` 是 1.x,`shell`/`read` 是 2.x);宿主缺的 `required` 工具由插件补发描述(背后没有实现,正文注明),宿主自带的 User-Agent 原样透传。
67
+ - **对不上的名字照样交给宿主**:Zen 调 OpenCode 工具名时,按大小写与 `_`/`-` 对上唯一匹配的宿主工具(`bash`↔`Bash`、`todowrite`↔`todo_write`);对不上或有歧义时**不猜也不丢**,按模型写的名字、原样的 id 与参数送达宿主,由宿主回它自己的"没有这个工具",模型据此改名重来。只有**没有名字**的调用会被略去,并在正文说明。工具仍在宿主执行,调用一律带非空 id。
68
+ - **只借 OpenCode 的样子**:请求头、User-Agent、工具必须同属一代(`bash`/`glob`/`grep`/`read` 是 1.x,`shell`/`read` 是 2.x);宿主缺的 `required` 工具由插件补发描述(背后没有实现,描述里注明别调它),宿主自带的 User-Agent 原样透传。
69
69
  - **请求头是刻意挑出的最小集合**:`content-type`、`accept`、`user-agent`、`x-opencode-project`、`x-opencode-session(-id)`(同一 id)、`x-opencode-client: cli`、`authorization: Bearer public`(走 Messages 时 `x-api-key: public` + `anthropic-version`;`anthropic-beta` 这个头插件自己从不设置:只在宿主已经发了的时候原样透传)。**不要加别的头**:两个各自无害的组合起来也会被拒——单试一个得不出结论。
70
70
  - **会话 id** 按会话首条消息生成并记住,同一会话各轮共用;宿主的 id 形状合法就原样传。
71
71
  - **提前掐断的流**:有内容且无未完成调用 → 按普通回答收尾并注明截断,再问会续;否则请求失败,错误写明回复 id 与已收字节。Messages / Responses 被截断同样失败。
package/limits.mjs CHANGED
@@ -471,7 +471,13 @@ export function limitOverrides(
471
471
  // catalogue never made, feeding the staleness compare and the raise-only
472
472
  // check with a number nobody declared.
473
473
  const declaredValue = declared && Object.hasOwn(declared, axis) ? declared[axis] : undefined;
474
- const entry = record?.[axis];
474
+ // Own property, the same rule as the two lines around it. Read through the
475
+ // prototype, an axis the record merely inherits — from a table built with
476
+ // `Object.create`, or from a polluted `Object.prototype` — read exactly like
477
+ // a measurement: it passed the guard below, because the guard is about the
478
+ // declaration and not about the record, and it then took an override past
479
+ // the staleness check and past the raise-only rule.
480
+ const entry = record && Object.hasOwn(record, axis) ? record[axis] : undefined;
475
481
  // Only the axes a refusal names have a live form — a window and a
476
482
  // completion ceiling. An input axis is answered from the table alone: the
477
483
  // refusals this route sends name the window that bounds it, never an input
package/models.mjs CHANGED
@@ -32,17 +32,55 @@ function sdkProtocol(npm) {
32
32
  if (npm?.includes("openai-compatible")) return "chat";
33
33
  }
34
34
 
35
+ // Whether a value the catalogue wrote is a description of a model, as opposed to
36
+ // a row it could not fill in. `null` is the shape the catalogue uses for the
37
+ // latter, and an array is the same fact: `typeof []` is `"object"`, but the
38
+ // catalogue writes one row per model and a list is not a row — every axis a
39
+ // description carries is absent from it, so reading one as a description reads
40
+ // nothing as something.
41
+ //
42
+ // The same test, by rule, as `provider.mjs`'s own `describesModel` — each file
43
+ // keeps its own name for it, and this comment is where the two names are tied
44
+ // together. The reader there decides whether a row may stand in for a model, and
45
+ // the two sides deciding differently about one row is a model whose fate depends
46
+ // on which file looked at it. This one is this file's own — the two are kept in
47
+ // step by their comments and their tests, not by a shared export, so neither
48
+ // file's public surface grows for the other's sake.
49
+ //
50
+ // Exported nowhere, deliberately: a test asks about the rule through the models
51
+ // it produces, which is the thing the rule is for.
52
+ function isDescription(row) {
53
+ return typeof row === "object" && row !== null && !Array.isArray(row);
54
+ }
55
+
35
56
  // Set, and not merely present: the catalogue writes both a missing date and an
36
57
  // explicit null, and neither retires a model. A blank string is not a date
37
58
  // either — see `isSet` below, which is the same rule stated for the two axes
38
59
  // the retirement checks read.
39
60
  const hasValue = (value) => value !== null && value !== undefined;
40
61
 
62
+ // Whether what the catalogue wrote is a container — a list, an object, a
63
+ // function — rather than the single value these fields are. A status is a word
64
+ // and a date is a date; `["2026-01-01"]` is neither, and `String` writes a list
65
+ // as one non-empty string, so a field nobody filled in passed the "does it say
66
+ // something" test below and retired a model: it left the offered list with
67
+ // nothing in the table and nothing in a note to say why, which is the silent
68
+ // loss every shape rule in this file exists to prevent. `typeof null` is
69
+ // `"object"`, so the null `hasValue` refuses on its own account is refused here
70
+ // as well.
71
+ const isContainer = (value) => typeof value === "object" || typeof value === "function";
72
+
41
73
  // A status has to say something to be one. The catalogue writes an empty string
42
74
  // as well as a null, and either one used to stand in front of the `lifecycle`
43
75
  // that does retire the model, so a model retired through `lifecycle` kept being
44
76
  // offered. A status that is set — "active" included — is still read as itself.
45
- const isSet = (value) => hasValue(value) && String(value).trim() !== "";
77
+ //
78
+ // The shape of what was written is part of "says something": a list or an object
79
+ // is not a status and not a date, and reading one as either is how a field that
80
+ // states nothing retired a model. `hasValue` is left as it is — "was anything
81
+ // written here at all" and "does what was written say something" are two rules,
82
+ // and only this one is about the shape of the answer.
83
+ const isSet = (value) => hasValue(value) && !isContainer(value) && String(value).trim() !== "";
46
84
 
47
85
  // A status ends a model when it says the model is gone: "retired" and
48
86
  // "disabled" are endings. "deprecated" is not one — it says the catalogue
@@ -104,7 +142,27 @@ function protocolFor(id, model, raw, provider, protocols) {
104
142
  // name, and `protocols["__proto__"]` would be an inherited protocol nobody
105
143
  // wrote. What the docs actually say about an id is an own property or nothing.
106
144
  if (Object.hasOwn(protocols, id)) return protocols[id];
107
- if (raw) return sdkProtocol(model.provider?.npm ?? provider.npm);
145
+ // The catalogue resolving to no protocol is not the same fact for both of the
146
+ // branches below, and they are read in that order for it.
147
+ //
148
+ // A catalogue entry whose package maps to no protocol is one this plugin
149
+ // cannot ask as the catalogue describes it — the "half-described" id, where
150
+ // the entry exists and its `npm` names a package `SDK` has never heard of.
151
+ // That is the same hole the `-free` fallback exists for, and it used to be the
152
+ // one shape the fallback could not reach: the entry was there, so `raw` was
153
+ // truthy, so the fallback was skipped and the id was dropped silently — while
154
+ // the very same id with no catalogue entry at all was offered.
155
+ //
156
+ // An id whose protocol *was* resolved — by the docs, to a name inside
157
+ // `PROTOCOLS` that this plugin has no SDK for, `systemone` above all — is a
158
+ // different fact and never falls through to the guess: the docs have said
159
+ // what this id speaks, and it is not something this plugin asks. That name
160
+ // comes back from the lookup above, so it never reaches this branch at all;
161
+ // what a package that maps to nothing answers is `undefined`, which is what
162
+ // keeps "no protocol could be read here" apart from "this id is not one this
163
+ // plugin asks".
164
+ const catalogProtocol = raw ? sdkProtocol(model.provider?.npm ?? provider.npm) : undefined;
165
+ if (catalogProtocol !== undefined) return catalogProtocol;
108
166
  if (listedFree(id)) return sdkProtocol(provider.npm) ?? "chat";
109
167
  return undefined;
110
168
  }
@@ -128,8 +186,15 @@ const usable = (value) => Number.isSafeInteger(value) && value > 0;
128
186
  // evidence for — each level only contributing axes it can actually state.
129
187
  function limitFor(id, declared) {
130
188
  const limit = { context: 32768, output: 4096 };
131
- for (const [axis, value] of Object.entries(declared ?? {})) if (usable(value)) limit[axis] = value;
132
- for (const [axis, value] of Object.entries(limitOverrides(id, declared))) limit[axis] = value;
189
+ // An axis map has to be one, by the same rule the row is read by. `limit` is
190
+ // an object of named axes, and `Object.entries` reads a list as its indices:
191
+ // a row whose `limit` was `[111, 222]` handed the host
192
+ // `{0: 111, 1: 222, context: 32768, output: 4096}` — axes no catalogue named,
193
+ // on the one object a host plans a prompt from. The evidence lookup is handed
194
+ // the same map, so neither level can read an axis off a shape that has none.
195
+ const axes = isDescription(declared) ? declared : {};
196
+ for (const [axis, value] of Object.entries(axes)) if (usable(value)) limit[axis] = value;
197
+ for (const [axis, value] of Object.entries(limitOverrides(id, axes))) limit[axis] = value;
133
198
  return limit;
134
199
  }
135
200
 
@@ -148,15 +213,47 @@ export function buildModels(ids, provider, protocols, baseURL) {
148
213
  // describes. An id with no entry here is one the catalogue has not caught up
149
214
  // with, which the `-free` rule below is for.
150
215
  const catalogue = provider.models ?? {};
151
- const raw = Object.hasOwn(catalogue, id) ? catalogue[id] : undefined;
216
+ // ...and an entry that is not a description describes nothing either, so it
217
+ // is left unresolved exactly like a missing one instead of being carried out
218
+ // of this loop as a description. `null` is the shape the catalogue writes
219
+ // for a row it could not fill in, and a list is the same fact one step
220
+ // harder to see — see `isDescription` above, which is the rule both sides of
221
+ // the tree read a row by. Such a row is read as a description by everything
222
+ // downstream of here too: `provider.mjs` reads `.cost` off
223
+ // `metadata.models[id]` on the partial-catalogue path, where a `null` is a
224
+ // TypeError thrown out of a hook a host is waiting on. One dirty row
225
+ // upstream must not be the whole list: a model is not a thing worth failing
226
+ // discovery over, and the `-free` name is already this file's rule for an id
227
+ // the catalogue cannot speak for.
228
+ //
229
+ // Skipped rather than reported, and the difference matters: a report would
230
+ // need a sink on this path, and the only sinks in this tree are
231
+ // limits.mjs's, which are about limits. What a skipped row costs is a model
232
+ // the catalogue itself could not describe — no protocol, no limits, no
233
+ // capabilities — and the models that are described are still offered, which
234
+ // is the outcome this file exists for. A row this malformed is also visible
235
+ // where it is made: it is the catalogue's own data, upstream of discovery.
236
+ const listed = Object.hasOwn(catalogue, id) ? catalogue[id] : undefined;
237
+ const raw = isDescription(listed) ? listed : undefined;
152
238
  const model = raw ?? {};
153
239
  const protocol = protocolFor(id, model, raw, provider, protocols);
154
240
  if (!isFree(id, model) || !Object.hasOwn(SDK, protocol)) continue;
155
241
  const input = model.modalities?.input ?? ["text"];
156
242
  const output = model.modalities?.output ?? ["text"];
157
- const efforts = (model.reasoning_options ?? [])
158
- .filter((option) => option.type === "effort")
159
- .flatMap((option) => option.values ?? []);
243
+ // The efforts a model offers, from the list the catalogue writes them in.
244
+ // `?? []` covers a list the catalogue left out and nothing else, and this is
245
+ // the one place in the loop where a wrong shape does not read as a wrong
246
+ // value but ends the whole call: `.filter` is not a function on an object or
247
+ // a string, and `option.type` on a `null` element is a TypeError — either one
248
+ // threw out of `buildModels`, so one dirty row upstream took discovery down
249
+ // with it, which is the failure the row rule above exists to prevent. A list
250
+ // that is not a list, and an element that is not a description, are read as
251
+ // no efforts: the model is still offered, without the variants nobody could
252
+ // state.
253
+ const options = Array.isArray(model.reasoning_options) ? model.reasoning_options : [];
254
+ const efforts = options
255
+ .filter((option) => isDescription(option) && option.type === "effort")
256
+ .flatMap((option) => (Array.isArray(option.values) ? option.values : []));
160
257
  result[id] = {
161
258
  id,
162
259
  providerID: PROVIDER,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-zen-freex",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
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",
package/provider.mjs CHANGED
@@ -20,8 +20,19 @@ export const CATALOG = "https://models.opencode.ai/api.json";
20
20
  const DOCS =
21
21
  "https://raw.githubusercontent.com/anomalyco/opencode/dev/services/www/src/docs/content/console/models.mdx";
22
22
  const PATHS = { chat: "/chat/completions", responses: "/responses", anthropic: "/messages" };
23
- // Which generation to present when the host says nothing that tells us: the
24
- // one opencode-ai publishes as latest.
23
+ // Which generation to present when the host's tools name neither one: a floor,
24
+ // and not a claim about what opencode-ai publishes as latest. What it publishes
25
+ // as latest is 2.x, and `fingerprint.json` captures it — the profile whose
26
+ // channel is `latest`, opencode 2.0.29 — so a comment saying this constant is
27
+ // "the latest generation" would be the one thing here that is not true.
28
+ //
29
+ // It stays 1.x because a default is only reached when nothing identified the
30
+ // host, and 1.x is the generation with more to say for it in that case: its
31
+ // `required` set is the wider one, `normalizeTools` can stand in for every name
32
+ // in it, and `bash`/`glob`/`grep`/`read` are the names a host that never sent
33
+ // `shell` still carries. The floor is what a request falls back to, so it is
34
+ // the generation a body is likeliest to already agree with — not the newest one
35
+ // opencode-ai ships, which only a host that says so should be presented as.
25
36
  const DEFAULT_GENERATION = "1.x";
26
37
  // Said only of a tool this plugin added, which the agent has no such tool for.
27
38
  // It has to be phrased as being about that one tool: a blanket "these tools are
@@ -193,6 +204,47 @@ function httpURL(value) {
193
204
  return url.href.replace(/\/+$/, "");
194
205
  }
195
206
 
207
+ // Whether a catalogue's row for an id is a description of a model at all. The
208
+ // shape rule lives in one place because two readings depend on it — what the
209
+ // catalogue says about an id, and what this plugin kept for it — and a row read
210
+ // as a description by one of them and not the other is a model that disappears
211
+ // without anything saying so.
212
+ //
213
+ // An array is not one, though `typeof []` is `"object"`: the catalogue writes a
214
+ // row per model and never a list, so a list there is a row it could not fill in,
215
+ // the same fact as `null`. `models.mjs` reads a row as a description by the same
216
+ // test; the two must agree.
217
+ function describesModel(row) {
218
+ return typeof row === "object" && row !== null && !Array.isArray(row);
219
+ }
220
+
221
+ // Give a model map an entry under an id that came from upstream, as an own
222
+ // property whatever that id is. `map[id] = value` is not enough: for the id
223
+ // `"__proto__"` a plain write is the prototype setter, so the entry is not
224
+ // stored at all and the map's prototype is what changes — the one write here
225
+ // whose id is not this package's to trust. An id may name a model, and it may
226
+ // not reach past the map to do it.
227
+ function own(map, id, value) {
228
+ if (id === "__proto__") {
229
+ return Object.defineProperty(map, id, {
230
+ value,
231
+ writable: true,
232
+ enumerable: true,
233
+ configurable: true,
234
+ });
235
+ }
236
+ map[id] = value;
237
+ return map;
238
+ }
239
+
240
+ // The catalogue's own row for an id, or `undefined` when the catalogue has none
241
+ // under that name. Own properties only, for the reason the preservation loop
242
+ // states: `models?.["toString"]` is the prototype's function, and reading it as
243
+ // a description of a model invents one nothing described.
244
+ function catalogueRow(metadata, id) {
245
+ return Object.hasOwn(metadata.models ?? {}, id) ? metadata.models[id] : undefined;
246
+ }
247
+
196
248
  function requirePublic(auth) {
197
249
  if (auth?.type !== "api" || auth.key !== "public") {
198
250
  throw new Error(
@@ -237,6 +289,10 @@ function normalizeTools(body, protocol) {
237
289
  // it carries say which: `shell` is 2.x, `bash` is 1.x. Names move far less
238
290
  // often than descriptions do, so this survives a rewording that a pinned
239
291
  // User-Agent would not.
292
+ //
293
+ // Neither name being there is not the same as 1.x being there: the fallback
294
+ // is the floor described on DEFAULT_GENERATION, and it decides the names this
295
+ // loop then fills in as well as the User-Agent `agentForRequest` composes.
240
296
  const generation = generationOf(body.tools) ?? DEFAULT_GENERATION;
241
297
  // Through `profileFor`, not `PROFILES[generation]`: a capture that holds one
242
298
  // generation leaves the other key absent, and the direct read fails below on
@@ -283,10 +339,12 @@ function normalizeTools(body, protocol) {
283
339
  continue;
284
340
  }
285
341
  filled.push(name);
286
- // A tool added here has nothing behind it: the agent has no such tool and
287
- // a call to it is dropped. Describing it as OpenCode describes it is what
288
- // Zen asks for, and it also makes the model want to call it, so the note
289
- // goes after the opening Zen reads and before the closing it does not.
342
+ // A tool added here has nothing behind it: the agent has no such tool, so a
343
+ // call to it goes to the agent as called and comes back as the agent's own
344
+ // unknown-tool error, which the model corrects. Describing it as OpenCode
345
+ // describes it is what Zen asks for, and that description also makes the
346
+ // model want to call it, so the note goes after the opening Zen reads and
347
+ // before the closing it does not.
290
348
  body.tools.push(shapeOf({ ...spoken, description: `${spoken.description}\n\n${PLACEHOLDER}` }, protocol));
291
349
  }
292
350
  return { declared, filled, generation };
@@ -421,20 +479,50 @@ export function sessionID(headers, body) {
421
479
  }
422
480
  if (typeof body.metadata?.session_id === "string") signal ??= body.metadata.session_id;
423
481
  if (realSessionID(signal)) return signal;
424
- if (!signal) {
425
- const messages = body.messages ?? body.input;
426
- const first = Array.isArray(messages)
427
- ? messages.find((message) => message.role === "user")?.content
428
- : messages;
482
+ const messages = body.messages ?? body.input;
483
+ const first = Array.isArray(messages)
484
+ ? messages.find((message) => message.role === "user")?.content
485
+ : messages;
486
+ // A host signal is the conversation's own name for itself, so it is the key
487
+ // on its own and nothing else is folded in beside it: a host that carries a
488
+ // signal may open a later turn with a shortened history, and keyed by the
489
+ // opening too, the same conversation would answer a new id the moment its
490
+ // first message moved. It is also what keeps two conversations that happen to
491
+ // open with the same words — "hi", a template prompt — in separate Zen
492
+ // sessions rather than one, and the only thing that can: a host that sends no
493
+ // signal at all is keyed by its opening below, and two such hosts with one
494
+ // opening are one key. That limit is pinned in session.test.mjs.
495
+ //
496
+ // `first === ""` is separated from `first === undefined` because an empty
497
+ // opening is a conversation with nothing to key by, which a host signal still
498
+ // names, while no user message at all is a body with nothing to key by. Read
499
+ // by truthiness — as `!first` read both — the two are one branch, and the
500
+ // signal is spent before either can be told from the other.
501
+ if (signal !== undefined) {
502
+ const named = createHash("sha256").update(`ses\0${signal}`).digest("hex");
503
+ return kept(named);
504
+ }
505
+ // An empty opening has nothing to key a conversation by, and one cannot be
506
+ // invented: two of them are indistinguishable from a single one, so any key
507
+ // derived here would either be shared by unrelated conversations — one Zen
508
+ // session, with its affinity and its quota — or differ again on the next
509
+ // turn. Not sharing is the half worth keeping, so this answer is minted per
510
+ // call exactly as the unnamed case below is.
511
+ if (first === "" || first === undefined) {
429
512
  // Nothing names this conversation: no session header, no metadata, and no
430
513
  // user message to key one by. There is no identity to keep for the next
431
514
  // turn, so this one gets an id of its own rather than being folded into
432
515
  // whichever conversation happened to arrive first — but it is still an id
433
516
  // of OpenCode's shape, because that shape is what the tier checks.
434
- if (!first) return opencodeID("ses", true);
435
- signal = JSON.stringify(first);
517
+ return opencodeID("ses", true);
436
518
  }
437
- const key = createHash("sha256").update(`ses\0${signal}`).digest("hex");
519
+ return kept(createHash("sha256").update(`ses\0${JSON.stringify(first)}`).digest("hex"));
520
+ }
521
+
522
+ // The id kept for a key, minted on first sight and reused while the key stays
523
+ // in the map. Every caller that names a conversation goes through here, so the
524
+ // eviction of the oldest entry is written once rather than at each key.
525
+ function kept(key) {
438
526
  let session = SESSIONS.get(key);
439
527
  if (session) {
440
528
  SESSIONS.delete(key);
@@ -464,7 +552,92 @@ async function server(_input, options = {}) {
464
552
  // a 15 s timeout each on every request that names a model it doesn't know.
465
553
  let listedAt = 0;
466
554
 
467
- async function discover(signal) {
555
+ // The discovery in flight, and who is waiting for it. Nineteen of the twenty
556
+ // requests a host makes name a model already known, so this exists for the
557
+ // ones that do not: without it two of them arriving together both read the
558
+ // listing, the catalogue and the docs — six upstream reads for one answer —
559
+ // and then race to write `known`, `metadata` and `protocols`, so which of the
560
+ // two survives is whichever settled last. One flight, one write.
561
+ let inflight = null;
562
+ // What every caller's lease travels on: the reads end when the last caller
563
+ // waiting for them has gone, and not before. A host that walks away from one
564
+ // request is still a host, so one caller's abandonment must not cancel an
565
+ // answer another is still waiting for.
566
+ const leases = new Set();
567
+ const interior = new AbortController();
568
+
569
+ // This caller's own way out, and its membership of the flight. Resolved when
570
+ // the caller's own signal aborts; an already-aborted one — for which no
571
+ // listener fires — ends the lease here rather than holding it for ever.
572
+ function leased(outward) {
573
+ const passing = new AbortController();
574
+ const end = () => passing.abort(outward?.reason ?? new DOMException("Aborted", "AbortError"));
575
+ if (outward?.aborted) end();
576
+ else outward?.addEventListener("abort", end, { once: true });
577
+ leases.add(passing);
578
+ passing.signal.addEventListener(
579
+ "abort",
580
+ () => {
581
+ leases.delete(passing);
582
+ if (!leases.size) interior.abort();
583
+ },
584
+ { once: true },
585
+ );
586
+ return passing.signal;
587
+ }
588
+
589
+ // A caller's wait for a discovery, told which signal says it has given up: a
590
+ // caller that walks away hears about it from its own abort rather than
591
+ // inheriting whoever started the flight's, and the flight itself carries on
592
+ // for the callers still waiting for it.
593
+ function waiting(signal, named) {
594
+ return Promise.race([
595
+ named,
596
+ new Promise((_, reject) => {
597
+ const end = () => reject(signal.reason);
598
+ if (signal.aborted) end();
599
+ else signal.addEventListener("abort", end, { once: true });
600
+ }),
601
+ ]);
602
+ }
603
+
604
+ // The discovery one caller waits for. Every caller takes a lease, the ones
605
+ // joining a flight included, so giving up is the caller's own.
606
+ function shared(observed) {
607
+ const mine = leased(observed);
608
+ // A flight that has already answered is not one to join: its result
609
+ // predates this caller's question, which is what the caller is asking
610
+ // again. `inflight` is kept rather than cleared, so this is the same test
611
+ // as "is it still running" and does not race the settle below.
612
+ if (inflight && !inflight.settled) return waiting(mine, inflight.named);
613
+ const named = reading(interior.signal);
614
+ const flight = { named, settled: false };
615
+ inflight = flight;
616
+ const settled = () => {
617
+ flight.settled = true;
618
+ };
619
+ named.then(settled, settled);
620
+ return waiting(mine, named);
621
+ }
622
+
623
+ // The one gate in front of the reads, and the reason it is here rather than
624
+ // at each caller: every path that can start a discovery goes through this
625
+ // function — a request for a model the last list did not have, the `models()`
626
+ // hook a host calls when it opens its model chooser, and `refresh()` — so a
627
+ // budget stated once cannot be spent by one path and bypassed by another. The
628
+ // hook used to call the reads directly, and every open of the chooser was a
629
+ // full round of discovery however recently the last one ran.
630
+ //
631
+ // A failed ask sets `listedAt` too (see `reading`), so a host whose listing
632
+ // cannot be read waits out the same interval as one whose listing can.
633
+ //
634
+ // `force` is for the one caller that is asking on purpose: `refresh()`.
635
+ async function discover(signal, { force = false } = {}) {
636
+ if (!force && Date.now() - listedAt < RELIST_AFTER) return known;
637
+ return shared(signal);
638
+ }
639
+
640
+ async function reading(signal) {
468
641
  const headers = { authorization: "Bearer public", "x-opencode-client": "cli" };
469
642
  const [listing, catalog, docs] = await Promise.allSettled([
470
643
  readRemote(`${baseURL}/models`, true, headers, signal),
@@ -495,9 +668,58 @@ async function server(_input, options = {}) {
495
668
  const discovered = buildModels(ids, metadata, protocols, baseURL);
496
669
  if (partial) {
497
670
  // Preserve previously verified models only while they remain in the live catalog.
671
+ //
672
+ // "The catalogue describes it" is read here as it is read in models.mjs:
673
+ // a row that is not a description describes nothing, whatever it is. The
674
+ // catalogue writes `null` for a row it could not fill in, and `null` is
675
+ // falsy, so the truthiness this used to test happened to answer the same
676
+ // way for that one shape — but `"junk"` and `7` are not descriptions
677
+ // either and are truthy, and they were read as if they were.
678
+ //
679
+ // An array is named as well as an object, because `typeof []` is
680
+ // `"object"` and the row shape the catalogue must never write is exactly
681
+ // the one a bare `typeof` check waves through: read as a description, the
682
+ // model it describes is not kept, which is a model silently disappearing
683
+ // for a catalogue row that could not be filled in.
684
+ //
685
+ // A row that is not a description is the same fact as no row at all:
686
+ // `buildModels` has already resolved the id without it (through the docs,
687
+ // or the `-free` rule), so what is preserved here is a model this plugin
688
+ // verified before, and holding it back because the catalogue wrote a row
689
+ // it could not fill in would be the same silent disappearance the `-free`
690
+ // rule exists to prevent.
691
+ //
692
+ // The entry kept is read as an object for the same reason: `known` only
693
+ // ever holds maps this plugin built, so anything else there is a shape
694
+ // this loop cannot reason about, and keeping it would carry it onward as
695
+ // a model. Judged rather than assumed, because the cost of being wrong is
696
+ // paid by a host on a hook it is waiting for.
697
+ //
698
+ // All three reads are own-property reads (`Object.hasOwn`), the same rule
699
+ // models.mjs and limits.mjs read their maps by. `known`, `protocols`,
700
+ // `discovered` and the catalogue's own map are all plain objects keyed by
701
+ // ids that arrived from upstream, so a name that is not a key at all —
702
+ // `toString` and the rest of `Object.prototype` — reads as a *value*
703
+ // through the prototype chain: `protocols["toString"]` is a function, so
704
+ // `!protocols[id]` was false and the entry was skipped for an id no
705
+ // catalogue ever described. What is under a name nobody wrote is what the
706
+ // prototype has, and it belongs to no model.
707
+ //
708
+ // `buildModels` skips `"__proto__"` before any of this, so the write below
709
+ // is unreachable for that id today. It is written safely anyway: this
710
+ // branch's job is to put a model in the map, and a `map[id] = value` for
711
+ // that id is the prototype setter rather than a write — a guard in another
712
+ // module is not the place this write's safety is decided.
498
713
  for (const id of ids) {
499
- if (!discovered[id] && !metadata.models?.[id] && !protocols[id] && known[id]) {
500
- discovered[id] = known[id];
714
+ const kept = known[id];
715
+ if (
716
+ !Object.hasOwn(discovered, id) &&
717
+ !describesModel(catalogueRow(metadata, id)) &&
718
+ !Object.hasOwn(protocols, id) &&
719
+ Object.hasOwn(known, id) &&
720
+ describesModel(kept)
721
+ ) {
722
+ own(discovered, id, kept);
501
723
  }
502
724
  }
503
725
  // Deliberately not said as fallen back. Zen's own list was read, and what
@@ -648,9 +870,24 @@ async function server(_input, options = {}) {
648
870
  async models(provider, { auth } = {}) {
649
871
  if (!auth) return provider.models;
650
872
  requirePublic(auth);
873
+ // Asked without a signal, and deliberately: the hook has none to give,
874
+ // and passing `undefined` on as one would read as a caller that cancels
875
+ // nothing — `leased` takes it as "no way out", which is what this call
876
+ // has. Whether it reads at all is `discover()`'s decision, so a host
877
+ // that opens its model chooser twice in a minute is given the list read
878
+ // the first time instead of a second round of upstream reads.
651
879
  return discover();
652
880
  },
653
881
  },
882
+ // Read the list again now, whatever RELIST_AFTER says: the way for a host
883
+ // (and for a test) to ask for the listing on purpose, rather than having to
884
+ // spend a request on a model the last list did not have to get one. It
885
+ // skips the budget and nothing else — it goes through the same single
886
+ // flight, so a refresh asked for while a discovery is already running waits
887
+ // for that answer instead of reading beside it.
888
+ async refresh() {
889
+ return discover(undefined, { force: true });
890
+ },
654
891
  async "chat.headers"(input, output) {
655
892
  if (![PROVIDER, `${PROVIDER}-plugin`].includes(input.model?.providerID) || !input.sessionID)
656
893
  return;