dsh-opencode 0.1.0
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/LICENSE +21 -0
- package/README.md +108 -0
- package/cordis.patch.yml +20 -0
- package/lib/adapter.js +109 -0
- package/lib/cache.js +171 -0
- package/lib/catalog.js +499 -0
- package/lib/commands.js +177 -0
- package/lib/config.d.ts +38 -0
- package/lib/config.js +142 -0
- package/lib/credentials.js +88 -0
- package/lib/index.d.ts +19 -0
- package/lib/index.js +208 -0
- package/lib/normalize.d.ts +10 -0
- package/lib/normalize.js +386 -0
- package/lib/snapshot.js +100 -0
- package/lib/transport.js +199 -0
- package/package.json +79 -0
package/lib/normalize.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
//#region src/normalize.ts
|
|
2
|
+
/** DSH route keys this plugin registers; fixed for the plugin's lifetime. */
|
|
3
|
+
const ROUTE_ZEN = "opencode-zen-live";
|
|
4
|
+
const ROUTE_GO = "opencode-go-live";
|
|
5
|
+
const ROUTE_BY_PRODUCT = {
|
|
6
|
+
zen: ROUTE_ZEN,
|
|
7
|
+
go: ROUTE_GO
|
|
8
|
+
};
|
|
9
|
+
const PRODUCT_BY_ROUTE = {
|
|
10
|
+
[ROUTE_ZEN]: "zen",
|
|
11
|
+
[ROUTE_GO]: "go"
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The fixed public sources. Only these URLs are fetched for catalog data, and
|
|
15
|
+
* only the fixed product endpoints receive inference traffic; Models.dev
|
|
16
|
+
* metadata never becomes a request destination.
|
|
17
|
+
*/
|
|
18
|
+
const SOURCES = {
|
|
19
|
+
zen: {
|
|
20
|
+
modelsUrl: "https://opencode.ai/zen/v1/models",
|
|
21
|
+
baseUrl: "https://opencode.ai/zen/v1",
|
|
22
|
+
metadataProvider: "opencode"
|
|
23
|
+
},
|
|
24
|
+
go: {
|
|
25
|
+
modelsUrl: "https://opencode.ai/zen/go/v1/models",
|
|
26
|
+
baseUrl: "https://opencode.ai/zen/go/v1",
|
|
27
|
+
metadataProvider: "opencode-go"
|
|
28
|
+
},
|
|
29
|
+
metadataUrl: "https://models.dev/api.json"
|
|
30
|
+
};
|
|
31
|
+
/** The models.dev provider id holding Zen model metadata. */
|
|
32
|
+
const METADATA_PROVIDER_ZEN = SOURCES.zen.metadataProvider;
|
|
33
|
+
/** The models.dev provider id holding Go model metadata. */
|
|
34
|
+
const METADATA_PROVIDER_GO = SOURCES.go.metadataProvider;
|
|
35
|
+
/**
|
|
36
|
+
* Models.dev SDK identifiers verified against the wire APIs OpenCode Zen / Go
|
|
37
|
+
* actually expose. Model-level `provider.npm` wins over the provider default.
|
|
38
|
+
* Anything else is an unknown protocol: the model stays visible as
|
|
39
|
+
* `unsupported-protocol` and is never executed, and no npm package named by
|
|
40
|
+
* fetched data is ever installed or imported.
|
|
41
|
+
*/
|
|
42
|
+
const SDK_WIRE_APIS = {
|
|
43
|
+
"@ai-sdk/openai-compatible": "openai-completions",
|
|
44
|
+
"@ai-sdk/openai": "openai-responses",
|
|
45
|
+
"@ai-sdk/anthropic": "anthropic-messages",
|
|
46
|
+
"@ai-sdk/google": "google-generative-ai"
|
|
47
|
+
};
|
|
48
|
+
/** The modalities this plugin's adapter stack can actually carry. */
|
|
49
|
+
const SUPPORTED_INPUT = ["text", "image"];
|
|
50
|
+
/** The missing-information labels a diagnostic may name, in display order. */
|
|
51
|
+
const MISSING_ORDER = [
|
|
52
|
+
"metadata",
|
|
53
|
+
"name",
|
|
54
|
+
"context",
|
|
55
|
+
"output",
|
|
56
|
+
"input",
|
|
57
|
+
"api"
|
|
58
|
+
];
|
|
59
|
+
/** Whether one number is a positive finite safe integer. */
|
|
60
|
+
function isPositiveInteger(value) {
|
|
61
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
62
|
+
}
|
|
63
|
+
/** Whether one value is a non-empty plain string. */
|
|
64
|
+
function isNonEmptyString(value) {
|
|
65
|
+
return typeof value === "string" && value.length > 0;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Validate an official `/models` response body.
|
|
69
|
+
*
|
|
70
|
+
* Only a JSON object shaped `{object: 'list', data: [...]}` with a
|
|
71
|
+
* duplicate-free, non-empty id set counts. An empty list is a valid JSON body
|
|
72
|
+
* but a catalog anomaly: the caller treats `ok: true` with zero models as an
|
|
73
|
+
* abnormal candidate and keeps the previous set, which is why emptiness is
|
|
74
|
+
* reported on the value rather than as a parse failure.
|
|
75
|
+
* @param body - the fetched JSON value.
|
|
76
|
+
* @returns the validated model list, or the reason it was refused.
|
|
77
|
+
*/
|
|
78
|
+
function parseOfficialList(body) {
|
|
79
|
+
if (typeof body !== "object" || body === null) return {
|
|
80
|
+
ok: false,
|
|
81
|
+
code: "INVALID_JSON",
|
|
82
|
+
message: "body is not a JSON object"
|
|
83
|
+
};
|
|
84
|
+
const record = body;
|
|
85
|
+
if (!Array.isArray(record.data)) return {
|
|
86
|
+
ok: false,
|
|
87
|
+
code: "INVALID_JSON",
|
|
88
|
+
message: "data is not an array"
|
|
89
|
+
};
|
|
90
|
+
const models = /* @__PURE__ */ new Map();
|
|
91
|
+
for (const entry of record.data) {
|
|
92
|
+
if (typeof entry !== "object" || entry === null) return {
|
|
93
|
+
ok: false,
|
|
94
|
+
code: "INVALID_JSON",
|
|
95
|
+
message: "data entry is not an object"
|
|
96
|
+
};
|
|
97
|
+
const item = entry;
|
|
98
|
+
if (!isNonEmptyString(item.id)) return {
|
|
99
|
+
ok: false,
|
|
100
|
+
code: "INVALID_JSON",
|
|
101
|
+
message: "data entry has no id"
|
|
102
|
+
};
|
|
103
|
+
if (models.has(item.id)) return {
|
|
104
|
+
ok: false,
|
|
105
|
+
code: "DUPLICATE_ID",
|
|
106
|
+
message: `duplicate model id "${item.id}"`
|
|
107
|
+
};
|
|
108
|
+
models.set(item.id, {
|
|
109
|
+
id: item.id,
|
|
110
|
+
...isPositiveInteger(item.created) ? { created: item.created } : {},
|
|
111
|
+
...isNonEmptyString(item.owned_by) ? { ownedBy: item.owned_by } : {}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
value: { models }
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Validate the slice of the Models.dev `api.json` this plugin consumes for
|
|
121
|
+
* one provider. Unknown extra fields are ignored; every consumed field is
|
|
122
|
+
* type-checked, and a consumed field of the wrong type is a refusal, not a
|
|
123
|
+
* silent default.
|
|
124
|
+
* @param body - the fetched JSON value.
|
|
125
|
+
* @param providerId - the Models.dev provider id to extract.
|
|
126
|
+
* @returns the validated provider metadata, or the reason it was refused.
|
|
127
|
+
*/
|
|
128
|
+
function parseMetadataProvider(body, providerId) {
|
|
129
|
+
if (typeof body !== "object" || body === null) return {
|
|
130
|
+
ok: false,
|
|
131
|
+
code: "INVALID_JSON",
|
|
132
|
+
message: "body is not a JSON object"
|
|
133
|
+
};
|
|
134
|
+
const provider = body[providerId];
|
|
135
|
+
if (typeof provider !== "object" || provider === null) return {
|
|
136
|
+
ok: false,
|
|
137
|
+
code: "INVALID_JSON",
|
|
138
|
+
message: `provider "${providerId}" is missing`
|
|
139
|
+
};
|
|
140
|
+
const providerRecord = provider;
|
|
141
|
+
const rawModels = providerRecord.models;
|
|
142
|
+
if (typeof rawModels !== "object" || rawModels === null) return {
|
|
143
|
+
ok: false,
|
|
144
|
+
code: "INVALID_JSON",
|
|
145
|
+
message: `provider "${providerId}" has no models dict`
|
|
146
|
+
};
|
|
147
|
+
const defaultNpm = isNonEmptyString(providerRecord.npm) ? providerRecord.npm : void 0;
|
|
148
|
+
const models = /* @__PURE__ */ new Map();
|
|
149
|
+
for (const [id, raw] of Object.entries(rawModels)) {
|
|
150
|
+
if (!isNonEmptyString(id) || typeof raw !== "object" || raw === null) continue;
|
|
151
|
+
const parsed = parseMetadataModel(id, raw, defaultNpm);
|
|
152
|
+
if (!parsed.ok) return parsed;
|
|
153
|
+
models.set(id, parsed.value);
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
ok: true,
|
|
157
|
+
value: {
|
|
158
|
+
...defaultNpm === void 0 ? {} : { npm: defaultNpm },
|
|
159
|
+
models
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** Validate one Models.dev model entry against the fields this plugin consumes. */
|
|
164
|
+
function parseMetadataModel(id, raw, defaultNpm) {
|
|
165
|
+
const npm = readNpm(raw.provider) ?? defaultNpm;
|
|
166
|
+
const limit = readLimit(raw.limit);
|
|
167
|
+
const modalities = readModalities(raw.modalities);
|
|
168
|
+
const reasoningOptions = readReasoningOptions(raw.reasoning_options);
|
|
169
|
+
if (reasoningOptions && !reasoningOptions.ok) return {
|
|
170
|
+
ok: false,
|
|
171
|
+
code: "INVALID_JSON",
|
|
172
|
+
message: `model "${id}" has invalid reasoning_options`
|
|
173
|
+
};
|
|
174
|
+
const reasoning = readCapability(raw.reasoning);
|
|
175
|
+
const tools = readCapability(raw.tool_call);
|
|
176
|
+
const cost = readCost(raw.cost);
|
|
177
|
+
return {
|
|
178
|
+
ok: true,
|
|
179
|
+
value: {
|
|
180
|
+
...isNonEmptyString(raw.name) ? { name: raw.name } : {},
|
|
181
|
+
...limit?.context !== void 0 ? { contextWindow: limit.context } : {},
|
|
182
|
+
...limit?.output !== void 0 ? { maxOutputTokens: limit.output } : {},
|
|
183
|
+
...modalities !== void 0 ? { input: modalities } : {},
|
|
184
|
+
tools,
|
|
185
|
+
reasoning,
|
|
186
|
+
...reasoningOptions?.ok && reasoningOptions.efforts !== void 0 && reasoningOptions.efforts.length > 0 ? { reasoningEfforts: reasoningOptions.efforts } : {},
|
|
187
|
+
...cost,
|
|
188
|
+
...npm === void 0 ? {} : { npm }
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/** Read a models.dev per-model `provider` dict's npm override. */
|
|
193
|
+
function readNpm(provider) {
|
|
194
|
+
if (typeof provider !== "object" || provider === null) return void 0;
|
|
195
|
+
const npm = provider.npm;
|
|
196
|
+
return isNonEmptyString(npm) ? npm : void 0;
|
|
197
|
+
}
|
|
198
|
+
/** Read the validated `limit` dict. */
|
|
199
|
+
function readLimit(limit) {
|
|
200
|
+
if (typeof limit !== "object" || limit === null) return void 0;
|
|
201
|
+
const record = limit;
|
|
202
|
+
return {
|
|
203
|
+
...isPositiveInteger(record.context) ? { context: record.context } : {},
|
|
204
|
+
...isPositiveInteger(record.output) ? { output: record.output } : {}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Read the input modalities, intersected with what this plugin's adapter stack
|
|
209
|
+
* can carry. Modalities the metadata names beyond that intersection (pdf,
|
|
210
|
+
* video, audio) say nothing about what DSH can send, so they never widen the
|
|
211
|
+
* declaration. The intersection is returned only when the metadata supplies a
|
|
212
|
+
* list; an absent list is `undefined` ("no answer"), while a list whose
|
|
213
|
+
* intersection is empty stays empty and makes the candidate pending.
|
|
214
|
+
*/
|
|
215
|
+
function readModalities(modalities) {
|
|
216
|
+
if (typeof modalities !== "object" || modalities === null) return void 0;
|
|
217
|
+
const raw = modalities.input;
|
|
218
|
+
if (!Array.isArray(raw)) return void 0;
|
|
219
|
+
const declared = new Set(raw.filter((entry) => isNonEmptyString(entry)));
|
|
220
|
+
return SUPPORTED_INPUT.filter((modality) => declared.has(modality));
|
|
221
|
+
}
|
|
222
|
+
/** Read a `true`/`false` capability flag, with absence as 'unknown'. */
|
|
223
|
+
function readCapability(value) {
|
|
224
|
+
if (value === true) return true;
|
|
225
|
+
if (value === false) return false;
|
|
226
|
+
return "unknown";
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Read the verified reasoning-control options. Only an explicit effort list
|
|
230
|
+
* verifies which levels a model accepts: `reasoning: true` alone verifies
|
|
231
|
+
* nothing about request format, and a toggle verifies nothing about effort
|
|
232
|
+
* levels, so both come back as no efforts.
|
|
233
|
+
*/
|
|
234
|
+
function readReasoningOptions(options) {
|
|
235
|
+
if (!Array.isArray(options)) return { ok: true };
|
|
236
|
+
const efforts = [];
|
|
237
|
+
for (const entry of options) {
|
|
238
|
+
if (typeof entry !== "object" || entry === null) return { ok: false };
|
|
239
|
+
const record = entry;
|
|
240
|
+
if (record.type === "effort") {
|
|
241
|
+
if (!Array.isArray(record.values)) return { ok: false };
|
|
242
|
+
for (const value of record.values) {
|
|
243
|
+
if (!isNonEmptyString(value)) return { ok: false };
|
|
244
|
+
if (!efforts.includes(value)) efforts.push(value);
|
|
245
|
+
}
|
|
246
|
+
} else if (record.type !== "toggle" && record.type !== "budget_tokens") return { ok: false };
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
ok: true,
|
|
250
|
+
...efforts.length > 0 ? { efforts } : {}
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/** Read one finite number, or nothing. */
|
|
254
|
+
function rate(value) {
|
|
255
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
256
|
+
}
|
|
257
|
+
/** Read source pricing when it is well-formed; absent pricing is not fabricated. */
|
|
258
|
+
function readCost(cost) {
|
|
259
|
+
if (typeof cost !== "object" || cost === null) return {};
|
|
260
|
+
const record = cost;
|
|
261
|
+
const input = rate(record.input);
|
|
262
|
+
const output = rate(record.output);
|
|
263
|
+
if (input === void 0 || output === void 0) return {};
|
|
264
|
+
return { cost: {
|
|
265
|
+
input,
|
|
266
|
+
output,
|
|
267
|
+
cacheRead: rate(record.cache_read) ?? 0,
|
|
268
|
+
cacheWrite: rate(record.cache_write) ?? 0,
|
|
269
|
+
...readCostTiers(record.tiers)
|
|
270
|
+
} };
|
|
271
|
+
}
|
|
272
|
+
/** Read pricing tiers when well-formed. */
|
|
273
|
+
function readCostTiers(tiers) {
|
|
274
|
+
if (!Array.isArray(tiers)) return {};
|
|
275
|
+
const mapped = [];
|
|
276
|
+
for (const entry of tiers) {
|
|
277
|
+
if (typeof entry !== "object" || entry === null) return {};
|
|
278
|
+
const record = entry;
|
|
279
|
+
const threshold = record.tier;
|
|
280
|
+
const size = typeof threshold === "object" && threshold !== null ? threshold.size : void 0;
|
|
281
|
+
if (!isPositiveInteger(size)) return {};
|
|
282
|
+
const input = rate(record.input);
|
|
283
|
+
const output = rate(record.output);
|
|
284
|
+
if (input === void 0 || output === void 0) return {};
|
|
285
|
+
mapped.push({
|
|
286
|
+
input,
|
|
287
|
+
output,
|
|
288
|
+
cacheRead: rate(record.cache_read) ?? 0,
|
|
289
|
+
cacheWrite: rate(record.cache_write) ?? 0,
|
|
290
|
+
inputTokensAbove: size
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
return { ...mapped.length > 0 ? { tiers: mapped } : {} };
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Join one product's official list with its Models.dev metadata into the
|
|
297
|
+
* candidate map. The union of both sources' id sets is preserved: an unknown
|
|
298
|
+
* id never disappears for lack of information, it just stops being
|
|
299
|
+
* executable.
|
|
300
|
+
*
|
|
301
|
+
* Deletion is conservative in both directions: a candidate the fresh official
|
|
302
|
+
* list no longer names and whose metadata is also gone becomes `removed`;
|
|
303
|
+
* one the metadata still names becomes `catalog-only`. A fresh fetch that
|
|
304
|
+
* yields no official list at all contributes nothing (the caller keeps the
|
|
305
|
+
* previous list for that case).
|
|
306
|
+
* @param product - the product being joined.
|
|
307
|
+
* @param inputs - the validated source payloads.
|
|
308
|
+
* @returns the candidate map, keyed by exact model id.
|
|
309
|
+
*/
|
|
310
|
+
function joinProduct(product, inputs) {
|
|
311
|
+
const route = ROUTE_BY_PRODUCT[product];
|
|
312
|
+
const officialModels = inputs.official?.list.models;
|
|
313
|
+
const metadataModels = inputs.metadata?.provider.models;
|
|
314
|
+
const ids = /* @__PURE__ */ new Set([...officialModels?.keys() ?? [], ...metadataModels?.keys() ?? []]);
|
|
315
|
+
for (const id of inputs.previousOfficialIds) if (!officialModels?.has(id) && !metadataModels?.has(id)) ids.add(id);
|
|
316
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
317
|
+
for (const id of ids) {
|
|
318
|
+
const metadata = metadataModels?.get(id);
|
|
319
|
+
const official = officialModels?.get(id);
|
|
320
|
+
const wasOfficial = inputs.previousOfficialIds.has(id);
|
|
321
|
+
const inOfficial = official !== void 0;
|
|
322
|
+
const inMetadata = metadata !== void 0;
|
|
323
|
+
let state;
|
|
324
|
+
if (inOfficial && !inMetadata) state = "metadata-pending";
|
|
325
|
+
else if (!inOfficial && inMetadata) state = "catalog-only";
|
|
326
|
+
else if (!inOfficial && !inMetadata) state = "removed";
|
|
327
|
+
else state = "ready";
|
|
328
|
+
const missing = [];
|
|
329
|
+
const npm = metadata?.npm;
|
|
330
|
+
const api = npm !== void 0 ? SDK_WIRE_APIS[npm] : void 0;
|
|
331
|
+
if (!inMetadata) missing.push("metadata");
|
|
332
|
+
if (metadata?.name === void 0) missing.push("name");
|
|
333
|
+
if (metadata?.contextWindow === void 0) missing.push("context");
|
|
334
|
+
if (metadata?.maxOutputTokens === void 0) missing.push("output");
|
|
335
|
+
if (metadata?.input === void 0 || metadata.input.length === 0) missing.push("input");
|
|
336
|
+
if (inMetadata && npm === void 0) missing.push("api");
|
|
337
|
+
if (state === "ready") {
|
|
338
|
+
if (missing.length > 0) state = "metadata-pending";
|
|
339
|
+
else if (api === void 0) state = "unsupported-protocol";
|
|
340
|
+
}
|
|
341
|
+
candidates.set(id, Object.freeze({
|
|
342
|
+
product,
|
|
343
|
+
route,
|
|
344
|
+
id,
|
|
345
|
+
name: metadata?.name ?? id,
|
|
346
|
+
state,
|
|
347
|
+
...api !== void 0 ? { api } : {},
|
|
348
|
+
...metadata?.contextWindow !== void 0 ? { contextWindow: metadata.contextWindow } : {},
|
|
349
|
+
...metadata?.maxOutputTokens !== void 0 ? { maxOutputTokens: metadata.maxOutputTokens } : {},
|
|
350
|
+
input: Object.freeze([...metadata?.input ?? []]),
|
|
351
|
+
tools: metadata?.tools ?? "unknown",
|
|
352
|
+
reasoning: metadata?.reasoning ?? "unknown",
|
|
353
|
+
...metadata?.reasoningEfforts !== void 0 ? { reasoningEfforts: Object.freeze([...metadata.reasoningEfforts]) } : {},
|
|
354
|
+
...metadata?.cost !== void 0 ? { cost: metadata.cost } : {},
|
|
355
|
+
missing: Object.freeze(sortMissing(missing)),
|
|
356
|
+
provenance: Object.freeze({
|
|
357
|
+
official: inOfficial ? SOURCES[product].modelsUrl : wasOfficial ? `${SOURCES[product].modelsUrl} (previously listed)` : "absent",
|
|
358
|
+
metadata: inMetadata ? `${SOURCES.metadataUrl}#${SOURCES[product].metadataProvider}` : "absent",
|
|
359
|
+
...npm !== void 0 ? { npm } : {},
|
|
360
|
+
...inputs.official?.successfulAt !== void 0 ? { officialConfirmedAt: new Date(inputs.official.successfulAt).toISOString() } : {},
|
|
361
|
+
...inputs.metadata?.successfulAt !== void 0 ? { metadataConfirmedAt: new Date(inputs.metadata.successfulAt).toISOString() } : {}
|
|
362
|
+
})
|
|
363
|
+
}));
|
|
364
|
+
}
|
|
365
|
+
return candidates;
|
|
366
|
+
}
|
|
367
|
+
/** Order the missing-field labels for stable display. */
|
|
368
|
+
function sortMissing(missing) {
|
|
369
|
+
return [...missing].sort((left, right) => {
|
|
370
|
+
const leftIndex = MISSING_ORDER.indexOf(left);
|
|
371
|
+
const rightIndex = MISSING_ORDER.indexOf(right);
|
|
372
|
+
return (leftIndex === -1 ? MISSING_ORDER.length : leftIndex) - (rightIndex === -1 ? MISSING_ORDER.length : rightIndex);
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
/** One-line human explanation of a candidate's non-ready state. */
|
|
376
|
+
function describeNonReadyState(candidate) {
|
|
377
|
+
switch (candidate.state) {
|
|
378
|
+
case "ready": return "ready";
|
|
379
|
+
case "metadata-pending": return `metadata-pending (missing: ${candidate.missing.join(", ") || "unknown"})`;
|
|
380
|
+
case "unsupported-protocol": return `unsupported-protocol (${candidate.provenance["npm"] ?? "unknown SDK"})`;
|
|
381
|
+
case "catalog-only": return "catalog-only (not in the official product list)";
|
|
382
|
+
case "removed": return "removed (no longer in the official product list)";
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
//#endregion
|
|
386
|
+
export { METADATA_PROVIDER_GO, METADATA_PROVIDER_ZEN, PRODUCT_BY_ROUTE, ROUTE_BY_PRODUCT, ROUTE_GO, ROUTE_ZEN, SOURCES, describeNonReadyState, isPositiveInteger, joinProduct, parseMetadataProvider, parseOfficialList };
|
package/lib/snapshot.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
//#region src/snapshot.ts
|
|
3
|
+
/**
|
|
4
|
+
* Generational immutable catalog snapshots.
|
|
5
|
+
*
|
|
6
|
+
* A snapshot is a frozen view of the candidates plus per-source freshness
|
|
7
|
+
* facts. Its identity is a content hash over the model-meaningful information
|
|
8
|
+
* only — never over fetch timestamps — so a periodic re-check that confirms
|
|
9
|
+
* the catalog is unchanged does not rebuild providers or notify the registry.
|
|
10
|
+
*
|
|
11
|
+
* Everything published here is frozen. Later refreshes build a new snapshot;
|
|
12
|
+
* no published map, array, or descriptor is ever mutated, and an in-flight
|
|
13
|
+
* request that captured one snapshot keeps reading exactly what it captured.
|
|
14
|
+
*
|
|
15
|
+
* @module opencode-live/snapshot
|
|
16
|
+
*/
|
|
17
|
+
const EMPTY_COUNTS = Object.freeze({
|
|
18
|
+
ready: 0,
|
|
19
|
+
"metadata-pending": 0,
|
|
20
|
+
"unsupported-protocol": 0,
|
|
21
|
+
"catalog-only": 0,
|
|
22
|
+
removed: 0
|
|
23
|
+
});
|
|
24
|
+
/** Deterministic serialization: sorted object keys, stable collection order. */
|
|
25
|
+
function stableStringify(value) {
|
|
26
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
27
|
+
if (typeof value === "object" && value !== null) return `{${Object.entries(value).filter(([, member]) => member !== void 0).sort(([left], [right]) => left.localeCompare(right)).map(([key, member]) => `${JSON.stringify(key)}:${stableStringify(member)}`).join(",")}}`;
|
|
28
|
+
return JSON.stringify(value) ?? "null";
|
|
29
|
+
}
|
|
30
|
+
/** Hash the model-meaningful content of one snapshot's candidates. */
|
|
31
|
+
function contentHash(products) {
|
|
32
|
+
const digest = createHash("sha256");
|
|
33
|
+
for (const product of ["zen", "go"]) {
|
|
34
|
+
digest.update(product);
|
|
35
|
+
const models = [...products[product].values()].sort((left, right) => left.id.localeCompare(right.id));
|
|
36
|
+
digest.update(stableStringify(models.map((model) => ({
|
|
37
|
+
id: model.id,
|
|
38
|
+
state: model.state,
|
|
39
|
+
api: model.api,
|
|
40
|
+
name: model.name,
|
|
41
|
+
contextWindow: model.contextWindow,
|
|
42
|
+
maxOutputTokens: model.maxOutputTokens,
|
|
43
|
+
maxInputTokens: model.maxInputTokens,
|
|
44
|
+
input: [...model.input],
|
|
45
|
+
tools: model.tools,
|
|
46
|
+
reasoning: model.reasoning,
|
|
47
|
+
reasoningEfforts: model.reasoningEfforts === void 0 ? void 0 : [...model.reasoningEfforts],
|
|
48
|
+
cost: model.cost,
|
|
49
|
+
missing: [...model.missing]
|
|
50
|
+
}))));
|
|
51
|
+
}
|
|
52
|
+
return digest.digest("hex");
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Build the next snapshot. The generation increments only when the content
|
|
56
|
+
* hash changes; a confirming re-check yields a new snapshot object with fresh
|
|
57
|
+
* freshness facts but the same generation and the same frozen candidate maps.
|
|
58
|
+
* @param previous - the snapshot currently published, if any.
|
|
59
|
+
* @param inputs - what this build has available.
|
|
60
|
+
* @returns the snapshot to publish.
|
|
61
|
+
*/
|
|
62
|
+
function buildSnapshot(previous, inputs) {
|
|
63
|
+
const hash = contentHash(inputs.products);
|
|
64
|
+
const changed = previous === void 0 || previous.contentHash !== hash;
|
|
65
|
+
const products = Object.freeze(Object.fromEntries(["zen", "go"].map((product) => {
|
|
66
|
+
const candidates = changed ? inputs.products[product] : previous.products[product].candidates;
|
|
67
|
+
const counts = { ...EMPTY_COUNTS };
|
|
68
|
+
const readyIds = [];
|
|
69
|
+
for (const [id, candidate] of candidates) {
|
|
70
|
+
counts[candidate.state] += 1;
|
|
71
|
+
if (candidate.state === "ready") readyIds.push(id);
|
|
72
|
+
}
|
|
73
|
+
const officialState = inputs.sources.get(product === "zen" ? "zen-list" : "go-list");
|
|
74
|
+
const officialConfirmed = officialState?.lastSuccessfulAt !== void 0;
|
|
75
|
+
const stale = officialConfirmed && (officialState.lastSuccessfulAt === void 0 || inputs.now - officialState.lastSuccessfulAt > inputs.maxStaleMs);
|
|
76
|
+
return [product, Object.freeze({
|
|
77
|
+
candidates,
|
|
78
|
+
readyIds: Object.freeze(readyIds),
|
|
79
|
+
stale,
|
|
80
|
+
officialConfirmed,
|
|
81
|
+
counts: Object.freeze(counts)
|
|
82
|
+
})];
|
|
83
|
+
})));
|
|
84
|
+
return Object.freeze({
|
|
85
|
+
generation: changed ? (previous?.generation ?? 0) + 1 : previous.generation,
|
|
86
|
+
createdAt: inputs.now,
|
|
87
|
+
contentHash: hash,
|
|
88
|
+
products,
|
|
89
|
+
sources: inputs.sources
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** A hash over each product's ready set; registration replacement keys on this. */
|
|
93
|
+
function readySetHash(snapshot) {
|
|
94
|
+
return stableStringify({
|
|
95
|
+
zen: snapshot.products.zen.readyIds,
|
|
96
|
+
go: snapshot.products.go.readyIds
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
//#endregion
|
|
100
|
+
export { buildSnapshot, readySetHash };
|
package/lib/transport.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { SOURCES } from "./normalize.js";
|
|
2
|
+
import { createProvider } from "@earendil-works/pi-ai";
|
|
3
|
+
import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
|
|
4
|
+
import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy";
|
|
5
|
+
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
6
|
+
import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
|
|
7
|
+
//#region src/transport.ts
|
|
8
|
+
/**
|
|
9
|
+
* Assembly of the pi-ai `Provider` one live route registers.
|
|
10
|
+
*
|
|
11
|
+
* Every ready candidate becomes one pi-ai model whose `api` names one of the
|
|
12
|
+
* four allowlisted wire protocols and whose `baseUrl` matches how pi-ai's own
|
|
13
|
+
* OpenCode providers address the product endpoints: the Anthropic SDK appends
|
|
14
|
+
* `/v1/messages` itself, so Anthropic-wire models use the product base
|
|
15
|
+
* without `/v1`, while the OpenAI and Google implementations are given the
|
|
16
|
+
* fixed `/v1` base.
|
|
17
|
+
*
|
|
18
|
+
* Model identity stays route-local: the provider id is the DSH route key, and
|
|
19
|
+
* the model id is the exact upstream string. Nothing fetched from metadata is
|
|
20
|
+
* imported or installed.
|
|
21
|
+
*
|
|
22
|
+
* The Go product requires honest self-identification and an opaque, stable
|
|
23
|
+
* `x-opencode-session` header. The wrapper copies per-request headers rather
|
|
24
|
+
* than mutating any shared profile object.
|
|
25
|
+
*
|
|
26
|
+
* @module opencode-live/transport
|
|
27
|
+
*/
|
|
28
|
+
/** The plugin's honest client identification value. */
|
|
29
|
+
const PLUGIN_ID = "opencode-live";
|
|
30
|
+
const PLUGIN_VERSION = "0.1.0";
|
|
31
|
+
/** Header OpenCode Go documents for coding-agent session identification. */
|
|
32
|
+
const SESSION_HEADER = "x-opencode-session";
|
|
33
|
+
/** Header carrying this plugin's honest client identity alongside DSH attribution. */
|
|
34
|
+
const CLIENT_HEADER = "x-opencode-client";
|
|
35
|
+
/** The lazily loaded wire-protocol implementations, one per allowlisted API. */
|
|
36
|
+
const WIRE_APIS = {
|
|
37
|
+
"openai-completions": openAICompletionsApi,
|
|
38
|
+
"openai-responses": openAIResponsesApi,
|
|
39
|
+
"anthropic-messages": anthropicMessagesApi,
|
|
40
|
+
"google-generative-ai": googleGenerativeAIApi
|
|
41
|
+
};
|
|
42
|
+
/** Every pi-ai thinking level, in escalation order. */
|
|
43
|
+
const THINKING_LEVELS = [
|
|
44
|
+
"minimal",
|
|
45
|
+
"low",
|
|
46
|
+
"medium",
|
|
47
|
+
"high",
|
|
48
|
+
"xhigh",
|
|
49
|
+
"max"
|
|
50
|
+
];
|
|
51
|
+
/**
|
|
52
|
+
* Pricing for a model whose metadata names no rates. This is the absence of a
|
|
53
|
+
* fact, not a price claim: pi-ai requires numbers, and no consumer of this
|
|
54
|
+
* plugin reports spend from them.
|
|
55
|
+
*/
|
|
56
|
+
const NO_COST = {
|
|
57
|
+
input: 0,
|
|
58
|
+
output: 0,
|
|
59
|
+
cacheRead: 0,
|
|
60
|
+
cacheWrite: 0
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Build the pi-ai provider for one fixed live route over its ready models.
|
|
64
|
+
* @param options - the route facts and validated candidates.
|
|
65
|
+
* @returns the provider to register into the adapter's `Models` collection.
|
|
66
|
+
*/
|
|
67
|
+
function buildRouteProvider(options) {
|
|
68
|
+
const models = options.ready.filter((candidate) => candidate.api !== void 0).map((candidate) => toPiModel(candidate, options.route));
|
|
69
|
+
const provider = createProvider({
|
|
70
|
+
id: options.route,
|
|
71
|
+
name: options.displayName,
|
|
72
|
+
baseUrl: SOURCES[options.product].baseUrl,
|
|
73
|
+
auth: options.auth,
|
|
74
|
+
models,
|
|
75
|
+
api: {
|
|
76
|
+
"openai-completions": WIRE_APIS["openai-completions"](),
|
|
77
|
+
"openai-responses": WIRE_APIS["openai-responses"](),
|
|
78
|
+
"anthropic-messages": WIRE_APIS["anthropic-messages"](),
|
|
79
|
+
"google-generative-ai": WIRE_APIS["google-generative-ai"]()
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
return options.product === "go" ? withGoSessionHeaders(provider, PLUGIN_VERSION) : provider;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Convert one normalized candidate into a pi-ai model descriptor.
|
|
86
|
+
* @param candidate - a `ready` candidate with a resolved wire API.
|
|
87
|
+
* @param route - the owning route key; also the pi-ai provider id.
|
|
88
|
+
* @returns the descriptor for the route's provider model list.
|
|
89
|
+
*/
|
|
90
|
+
function toPiModel(candidate, route) {
|
|
91
|
+
const api = candidate.api;
|
|
92
|
+
if (api === void 0) throw new Error(`opencode-live: candidate "${candidate.id}" has no wire API and must not reach provider construction`);
|
|
93
|
+
return {
|
|
94
|
+
id: candidate.id,
|
|
95
|
+
name: candidate.name,
|
|
96
|
+
api,
|
|
97
|
+
provider: route,
|
|
98
|
+
baseUrl: modelBaseUrl(candidate.product, api),
|
|
99
|
+
reasoning: candidate.reasoning === true,
|
|
100
|
+
...thinkingLevelMap(candidate),
|
|
101
|
+
input: [...candidate.input],
|
|
102
|
+
cost: candidate.cost ?? NO_COST,
|
|
103
|
+
contextWindow: candidate.contextWindow,
|
|
104
|
+
maxTokens: candidate.maxOutputTokens
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The per-wire-API request base. Anthropic clients append `/v1/messages`
|
|
109
|
+
* themselves; the OpenAI and Google clients are handed the fixed `/v1` base,
|
|
110
|
+
* matching the verified endpoint layout of the product APIs.
|
|
111
|
+
* @param product - the product being addressed.
|
|
112
|
+
* @param api - the model's wire protocol.
|
|
113
|
+
* @returns the exact base URL the pi-ai implementation receives.
|
|
114
|
+
*/
|
|
115
|
+
function modelBaseUrl(product, api) {
|
|
116
|
+
const base = SOURCES[product].baseUrl;
|
|
117
|
+
return api === "anthropic-messages" ? base.replace(/\/v1$/, "") : base;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The thinking-level map built only from verified effort values.
|
|
121
|
+
*
|
|
122
|
+
* A model whose reasoning capability is confirmed but whose control format is
|
|
123
|
+
* a toggle (or unpublished) maps every level to `null`: pi-ai then offers no
|
|
124
|
+
* effort control and requests keep the provider's default behavior. A model
|
|
125
|
+
* with a verified effort list maps supported levels to themselves and marks
|
|
126
|
+
* the rest unsupported, so an unverified level is refused rather than sent.
|
|
127
|
+
* @param candidate - the normalized candidate.
|
|
128
|
+
* @returns the map, or nothing when the model is not reasoning-capable.
|
|
129
|
+
*/
|
|
130
|
+
function thinkingLevelMap(candidate) {
|
|
131
|
+
if (candidate.reasoning !== true) return {};
|
|
132
|
+
const verified = candidate.reasoningEfforts;
|
|
133
|
+
const map = {};
|
|
134
|
+
for (const level of THINKING_LEVELS) map[level] = verified === void 0 ? null : verified.includes(level) ? level : null;
|
|
135
|
+
return { thinkingLevelMap: map };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Wrap one provider so every Go request carries the session and client
|
|
139
|
+
* headers. The wrapping delegates dispatch to the wrapped provider's own
|
|
140
|
+
* implementations, so the wire-API map and auth resolution are untouched.
|
|
141
|
+
* @param provider - the provider built for the Go route.
|
|
142
|
+
* @param version - the plugin version for honest client identification.
|
|
143
|
+
* @returns a provider whose requests carry the copied headers.
|
|
144
|
+
*/
|
|
145
|
+
function withGoSessionHeaders(provider, version) {
|
|
146
|
+
const bySession = /* @__PURE__ */ new Map();
|
|
147
|
+
const byRequest = /* @__PURE__ */ new WeakMap();
|
|
148
|
+
const sessionIdFor = (options) => {
|
|
149
|
+
if (options === void 0) return crypto.randomUUID();
|
|
150
|
+
if (typeof options.sessionId === "string" && options.sessionId.length > 0) {
|
|
151
|
+
const existing = bySession.get(options.sessionId);
|
|
152
|
+
if (existing !== void 0) return existing;
|
|
153
|
+
const created = crypto.randomUUID();
|
|
154
|
+
if (bySession.size >= 512) {
|
|
155
|
+
const oldest = bySession.keys().next().value;
|
|
156
|
+
if (oldest !== void 0) bySession.delete(oldest);
|
|
157
|
+
}
|
|
158
|
+
bySession.set(options.sessionId, created);
|
|
159
|
+
return created;
|
|
160
|
+
}
|
|
161
|
+
const existing = byRequest.get(options);
|
|
162
|
+
if (existing !== void 0) return existing;
|
|
163
|
+
const created = crypto.randomUUID();
|
|
164
|
+
byRequest.set(options, created);
|
|
165
|
+
return created;
|
|
166
|
+
};
|
|
167
|
+
const headersFor = (options) => ({
|
|
168
|
+
...options?.headers,
|
|
169
|
+
[SESSION_HEADER]: sessionIdFor(options),
|
|
170
|
+
[CLIENT_HEADER]: `${PLUGIN_ID}/${version}`
|
|
171
|
+
});
|
|
172
|
+
/**
|
|
173
|
+
* Merge the copied headers into one request's options without mutating the
|
|
174
|
+
* caller's object. The no-options path needs a bounded assertion because
|
|
175
|
+
* `ApiStreamOptions` is a deferred per-API union at this untyped dispatch
|
|
176
|
+
* seam; DSH's inference path (`streamSimple`) stays fully typed.
|
|
177
|
+
*/
|
|
178
|
+
function withHeaders(options) {
|
|
179
|
+
if (options === void 0) return { headers: headersFor(void 0) };
|
|
180
|
+
return {
|
|
181
|
+
...options,
|
|
182
|
+
headers: headersFor(options)
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
...provider,
|
|
187
|
+
stream: (model, context, options) => provider.stream(model, context, withHeaders(options)),
|
|
188
|
+
streamSimple: (model, context, options) => {
|
|
189
|
+
const headers = headersFor(options);
|
|
190
|
+
if (options === void 0) return provider.streamSimple(model, context, { headers });
|
|
191
|
+
return provider.streamSimple(model, context, {
|
|
192
|
+
...options,
|
|
193
|
+
headers
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
//#endregion
|
|
199
|
+
export { CLIENT_HEADER, PLUGIN_ID, PLUGIN_VERSION, SESSION_HEADER, buildRouteProvider, modelBaseUrl, toPiModel, withGoSessionHeaders };
|