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.
@@ -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 };
@@ -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 };
@@ -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 };