opencode-zen-freex 0.2.16 → 0.2.17

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
@@ -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.17",
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
@@ -421,20 +477,50 @@ export function sessionID(headers, body) {
421
477
  }
422
478
  if (typeof body.metadata?.session_id === "string") signal ??= body.metadata.session_id;
423
479
  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;
480
+ const messages = body.messages ?? body.input;
481
+ const first = Array.isArray(messages)
482
+ ? messages.find((message) => message.role === "user")?.content
483
+ : messages;
484
+ // A host signal is the conversation's own name for itself, so it is the key
485
+ // on its own and nothing else is folded in beside it: a host that carries a
486
+ // signal may open a later turn with a shortened history, and keyed by the
487
+ // opening too, the same conversation would answer a new id the moment its
488
+ // first message moved. It is also what keeps two conversations that happen to
489
+ // open with the same words — "hi", a template prompt — in separate Zen
490
+ // sessions rather than one, and the only thing that can: a host that sends no
491
+ // signal at all is keyed by its opening below, and two such hosts with one
492
+ // opening are one key. That limit is pinned in session.test.mjs.
493
+ //
494
+ // `first === ""` is separated from `first === undefined` because an empty
495
+ // opening is a conversation with nothing to key by, which a host signal still
496
+ // names, while no user message at all is a body with nothing to key by. Read
497
+ // by truthiness — as `!first` read both — the two are one branch, and the
498
+ // signal is spent before either can be told from the other.
499
+ if (signal !== undefined) {
500
+ const named = createHash("sha256").update(`ses\0${signal}`).digest("hex");
501
+ return kept(named);
502
+ }
503
+ // An empty opening has nothing to key a conversation by, and one cannot be
504
+ // invented: two of them are indistinguishable from a single one, so any key
505
+ // derived here would either be shared by unrelated conversations — one Zen
506
+ // session, with its affinity and its quota — or differ again on the next
507
+ // turn. Not sharing is the half worth keeping, so this answer is minted per
508
+ // call exactly as the unnamed case below is.
509
+ if (first === "" || first === undefined) {
429
510
  // Nothing names this conversation: no session header, no metadata, and no
430
511
  // user message to key one by. There is no identity to keep for the next
431
512
  // turn, so this one gets an id of its own rather than being folded into
432
513
  // whichever conversation happened to arrive first — but it is still an id
433
514
  // of OpenCode's shape, because that shape is what the tier checks.
434
- if (!first) return opencodeID("ses", true);
435
- signal = JSON.stringify(first);
515
+ return opencodeID("ses", true);
436
516
  }
437
- const key = createHash("sha256").update(`ses\0${signal}`).digest("hex");
517
+ return kept(createHash("sha256").update(`ses\0${JSON.stringify(first)}`).digest("hex"));
518
+ }
519
+
520
+ // The id kept for a key, minted on first sight and reused while the key stays
521
+ // in the map. Every caller that names a conversation goes through here, so the
522
+ // eviction of the oldest entry is written once rather than at each key.
523
+ function kept(key) {
438
524
  let session = SESSIONS.get(key);
439
525
  if (session) {
440
526
  SESSIONS.delete(key);
@@ -464,7 +550,92 @@ async function server(_input, options = {}) {
464
550
  // a 15 s timeout each on every request that names a model it doesn't know.
465
551
  let listedAt = 0;
466
552
 
467
- async function discover(signal) {
553
+ // The discovery in flight, and who is waiting for it. Nineteen of the twenty
554
+ // requests a host makes name a model already known, so this exists for the
555
+ // ones that do not: without it two of them arriving together both read the
556
+ // listing, the catalogue and the docs — six upstream reads for one answer —
557
+ // and then race to write `known`, `metadata` and `protocols`, so which of the
558
+ // two survives is whichever settled last. One flight, one write.
559
+ let inflight = null;
560
+ // What every caller's lease travels on: the reads end when the last caller
561
+ // waiting for them has gone, and not before. A host that walks away from one
562
+ // request is still a host, so one caller's abandonment must not cancel an
563
+ // answer another is still waiting for.
564
+ const leases = new Set();
565
+ const interior = new AbortController();
566
+
567
+ // This caller's own way out, and its membership of the flight. Resolved when
568
+ // the caller's own signal aborts; an already-aborted one — for which no
569
+ // listener fires — ends the lease here rather than holding it for ever.
570
+ function leased(outward) {
571
+ const passing = new AbortController();
572
+ const end = () => passing.abort(outward?.reason ?? new DOMException("Aborted", "AbortError"));
573
+ if (outward?.aborted) end();
574
+ else outward?.addEventListener("abort", end, { once: true });
575
+ leases.add(passing);
576
+ passing.signal.addEventListener(
577
+ "abort",
578
+ () => {
579
+ leases.delete(passing);
580
+ if (!leases.size) interior.abort();
581
+ },
582
+ { once: true },
583
+ );
584
+ return passing.signal;
585
+ }
586
+
587
+ // A caller's wait for a discovery, told which signal says it has given up: a
588
+ // caller that walks away hears about it from its own abort rather than
589
+ // inheriting whoever started the flight's, and the flight itself carries on
590
+ // for the callers still waiting for it.
591
+ function waiting(signal, named) {
592
+ return Promise.race([
593
+ named,
594
+ new Promise((_, reject) => {
595
+ const end = () => reject(signal.reason);
596
+ if (signal.aborted) end();
597
+ else signal.addEventListener("abort", end, { once: true });
598
+ }),
599
+ ]);
600
+ }
601
+
602
+ // The discovery one caller waits for. Every caller takes a lease, the ones
603
+ // joining a flight included, so giving up is the caller's own.
604
+ function shared(observed) {
605
+ const mine = leased(observed);
606
+ // A flight that has already answered is not one to join: its result
607
+ // predates this caller's question, which is what the caller is asking
608
+ // again. `inflight` is kept rather than cleared, so this is the same test
609
+ // as "is it still running" and does not race the settle below.
610
+ if (inflight && !inflight.settled) return waiting(mine, inflight.named);
611
+ const named = reading(interior.signal);
612
+ const flight = { named, settled: false };
613
+ inflight = flight;
614
+ const settled = () => {
615
+ flight.settled = true;
616
+ };
617
+ named.then(settled, settled);
618
+ return waiting(mine, named);
619
+ }
620
+
621
+ // The one gate in front of the reads, and the reason it is here rather than
622
+ // at each caller: every path that can start a discovery goes through this
623
+ // function — a request for a model the last list did not have, the `models()`
624
+ // hook a host calls when it opens its model chooser, and `refresh()` — so a
625
+ // budget stated once cannot be spent by one path and bypassed by another. The
626
+ // hook used to call the reads directly, and every open of the chooser was a
627
+ // full round of discovery however recently the last one ran.
628
+ //
629
+ // A failed ask sets `listedAt` too (see `reading`), so a host whose listing
630
+ // cannot be read waits out the same interval as one whose listing can.
631
+ //
632
+ // `force` is for the one caller that is asking on purpose: `refresh()`.
633
+ async function discover(signal, { force = false } = {}) {
634
+ if (!force && Date.now() - listedAt < RELIST_AFTER) return known;
635
+ return shared(signal);
636
+ }
637
+
638
+ async function reading(signal) {
468
639
  const headers = { authorization: "Bearer public", "x-opencode-client": "cli" };
469
640
  const [listing, catalog, docs] = await Promise.allSettled([
470
641
  readRemote(`${baseURL}/models`, true, headers, signal),
@@ -495,9 +666,58 @@ async function server(_input, options = {}) {
495
666
  const discovered = buildModels(ids, metadata, protocols, baseURL);
496
667
  if (partial) {
497
668
  // Preserve previously verified models only while they remain in the live catalog.
669
+ //
670
+ // "The catalogue describes it" is read here as it is read in models.mjs:
671
+ // a row that is not a description describes nothing, whatever it is. The
672
+ // catalogue writes `null` for a row it could not fill in, and `null` is
673
+ // falsy, so the truthiness this used to test happened to answer the same
674
+ // way for that one shape — but `"junk"` and `7` are not descriptions
675
+ // either and are truthy, and they were read as if they were.
676
+ //
677
+ // An array is named as well as an object, because `typeof []` is
678
+ // `"object"` and the row shape the catalogue must never write is exactly
679
+ // the one a bare `typeof` check waves through: read as a description, the
680
+ // model it describes is not kept, which is a model silently disappearing
681
+ // for a catalogue row that could not be filled in.
682
+ //
683
+ // A row that is not a description is the same fact as no row at all:
684
+ // `buildModels` has already resolved the id without it (through the docs,
685
+ // or the `-free` rule), so what is preserved here is a model this plugin
686
+ // verified before, and holding it back because the catalogue wrote a row
687
+ // it could not fill in would be the same silent disappearance the `-free`
688
+ // rule exists to prevent.
689
+ //
690
+ // The entry kept is read as an object for the same reason: `known` only
691
+ // ever holds maps this plugin built, so anything else there is a shape
692
+ // this loop cannot reason about, and keeping it would carry it onward as
693
+ // a model. Judged rather than assumed, because the cost of being wrong is
694
+ // paid by a host on a hook it is waiting for.
695
+ //
696
+ // All three reads are own-property reads (`Object.hasOwn`), the same rule
697
+ // models.mjs and limits.mjs read their maps by. `known`, `protocols`,
698
+ // `discovered` and the catalogue's own map are all plain objects keyed by
699
+ // ids that arrived from upstream, so a name that is not a key at all —
700
+ // `toString` and the rest of `Object.prototype` — reads as a *value*
701
+ // through the prototype chain: `protocols["toString"]` is a function, so
702
+ // `!protocols[id]` was false and the entry was skipped for an id no
703
+ // catalogue ever described. What is under a name nobody wrote is what the
704
+ // prototype has, and it belongs to no model.
705
+ //
706
+ // `buildModels` skips `"__proto__"` before any of this, so the write below
707
+ // is unreachable for that id today. It is written safely anyway: this
708
+ // branch's job is to put a model in the map, and a `map[id] = value` for
709
+ // that id is the prototype setter rather than a write — a guard in another
710
+ // module is not the place this write's safety is decided.
498
711
  for (const id of ids) {
499
- if (!discovered[id] && !metadata.models?.[id] && !protocols[id] && known[id]) {
500
- discovered[id] = known[id];
712
+ const kept = known[id];
713
+ if (
714
+ !Object.hasOwn(discovered, id) &&
715
+ !describesModel(catalogueRow(metadata, id)) &&
716
+ !Object.hasOwn(protocols, id) &&
717
+ Object.hasOwn(known, id) &&
718
+ describesModel(kept)
719
+ ) {
720
+ own(discovered, id, kept);
501
721
  }
502
722
  }
503
723
  // Deliberately not said as fallen back. Zen's own list was read, and what
@@ -648,9 +868,24 @@ async function server(_input, options = {}) {
648
868
  async models(provider, { auth } = {}) {
649
869
  if (!auth) return provider.models;
650
870
  requirePublic(auth);
871
+ // Asked without a signal, and deliberately: the hook has none to give,
872
+ // and passing `undefined` on as one would read as a caller that cancels
873
+ // nothing — `leased` takes it as "no way out", which is what this call
874
+ // has. Whether it reads at all is `discover()`'s decision, so a host
875
+ // that opens its model chooser twice in a minute is given the list read
876
+ // the first time instead of a second round of upstream reads.
651
877
  return discover();
652
878
  },
653
879
  },
880
+ // Read the list again now, whatever RELIST_AFTER says: the way for a host
881
+ // (and for a test) to ask for the listing on purpose, rather than having to
882
+ // spend a request on a model the last list did not have to get one. It
883
+ // skips the budget and nothing else — it goes through the same single
884
+ // flight, so a refresh asked for while a discovery is already running waits
885
+ // for that answer instead of reading beside it.
886
+ async refresh() {
887
+ return discover(undefined, { force: true });
888
+ },
654
889
  async "chat.headers"(input, output) {
655
890
  if (![PROVIDER, `${PROVIDER}-plugin`].includes(input.model?.providerID) || !input.sessionID)
656
891
  return;