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 +7 -1
- package/models.mjs +105 -8
- package/package.json +1 -1
- package/provider.mjs +248 -13
- package/stream.mjs +234 -55
- package/tool-response.mjs +321 -95
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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.
|
|
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
|
|
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
|
|
@@ -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
|
|
@@ -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;
|