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/MAINTAINING.md +8 -0
- package/NOTICE +4 -3
- package/README.md +38 -98
- package/limits.mjs +8 -2
- package/models.mjs +105 -8
- package/package.json +1 -1
- package/provider.mjs +250 -15
- package/stream.mjs +244 -40
- package/tool-response.mjs +438 -54
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
|
|
24
|
-
//
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
435
|
-
signal = JSON.stringify(first);
|
|
515
|
+
return opencodeID("ses", true);
|
|
436
516
|
}
|
|
437
|
-
|
|
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
|
-
|
|
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
|
-
|
|
500
|
-
|
|
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
|
|
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;
|