opencode-zen-freex 0.2.15 → 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/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
@@ -353,7 +409,7 @@ function explain(text, source, toolPolicy) {
353
409
  filled.length
354
410
  ? `The host declares none of ${filled.join(", ")}, so those were added from it.`
355
411
  : `The host declared every one of them; only the wording was replaced, and its own parameters were kept so its tools still run.`,
356
- "OpenCode rewords those tools between releases. Run `bun run check:gate` to see whether that copy is still current; if it is not, capture the wording again from a current OpenCode and update this package.",
412
+ "OpenCode rewords those tools between releases. Run `bun run check:gate` from a clone of this repository to see whether that copy is still current; if it is not, capture the wording again from a current OpenCode and update this package.",
357
413
  ].join(" ");
358
414
  return new Response(
359
415
  JSON.stringify({
@@ -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
@@ -530,7 +750,7 @@ async function server(_input, options = {}) {
530
750
  methods: [
531
751
  {
532
752
  type: "api",
533
- label: "Zen 免费访问(密钥填写 public)",
753
+ label: "Zen 免费访问(密钥填 public;个人 key 请加到内置的 OpenCode Zen)",
534
754
  placeholder: "public",
535
755
  async authorize() {
536
756
  return { type: "success", key: "public" };
@@ -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;