@dan-ai-studio/dshopencodego 0.1.43 → 0.1.45
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/README.en.md +48 -28
- package/README.md +47 -27
- package/lib/client.js +47 -1
- package/lib/index.js +288 -123
- package/lib/types/adapter.d.ts +26 -3
- package/lib/types/catalog/go-doc.d.ts +9 -5
- package/lib/types/catalog/index.d.ts +22 -7
- package/lib/types/catalog/service.d.ts +44 -1
- package/lib/types/config.d.ts +4 -3
- package/lib/types/conversion/replay.d.ts +30 -1
- package/lib/types/models.d.ts +14 -0
- package/lib/types/usage/meter.d.ts +89 -2
- package/package.json +20 -21
- package/lib/types/adapter.js +0 -384
- package/lib/types/catalog/constants.js +0 -18
- package/lib/types/catalog/contract.js +0 -147
- package/lib/types/catalog/gateway.js +0 -59
- package/lib/types/catalog/go-doc.js +0 -192
- package/lib/types/catalog/index.js +0 -377
- package/lib/types/catalog/json-response.js +0 -73
- package/lib/types/catalog/metadata.js +0 -278
- package/lib/types/catalog/protocol.js +0 -87
- package/lib/types/catalog/reading.js +0 -73
- package/lib/types/catalog/service.js +0 -45
- package/lib/types/config.js +0 -91
- package/lib/types/conversion/context.js +0 -212
- package/lib/types/conversion/index.js +0 -7
- package/lib/types/conversion/replay.js +0 -242
- package/lib/types/conversion/stream.js +0 -221
- package/lib/types/go-limits.js +0 -91
- package/lib/types/index.js +0 -198
- package/lib/types/models.js +0 -86
- package/lib/types/remotes.js +0 -29
- package/lib/types/session-header.js +0 -49
- package/lib/types/update/check.js +0 -138
- package/lib/types/update/contract.js +0 -64
- package/lib/types/update/index.js +0 -10
- package/lib/types/update/service.js +0 -39
- package/lib/types/update/version.js +0 -16
- package/lib/types/usage/contract.js +0 -106
- package/lib/types/usage/index.js +0 -8
- package/lib/types/usage/meter.js +0 -65
- package/lib/types/usage/service.js +0 -74
- package/lib/types/usage/windows.js +0 -84
package/lib/types/adapter.js
DELETED
|
@@ -1,384 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The OpenCode Go adapter: one route, one live catalog, one mandatory header.
|
|
3
|
-
*
|
|
4
|
-
* The gateway has two requirements a generic pi-ai route cannot express — a
|
|
5
|
-
* model list that rotates faster than any shipped catalog, and a per
|
|
6
|
-
* conversation `x-opencode-session` routing header — so this adapter owns both
|
|
7
|
-
* instead of delegating them to configuration.
|
|
8
|
-
*
|
|
9
|
-
* Each operation reads the current configuration and resolves the catalog
|
|
10
|
-
* snapshot once, before its first await, so a settings change reaches the next
|
|
11
|
-
* request and never mixes two catalog generations inside one call.
|
|
12
|
-
*
|
|
13
|
-
* @module @dan-ai-studio/dshopencodego/adapter
|
|
14
|
-
*/
|
|
15
|
-
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
|
|
16
|
-
if (value !== null && value !== void 0) {
|
|
17
|
-
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
|
|
18
|
-
var dispose, inner;
|
|
19
|
-
if (async) {
|
|
20
|
-
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
|
|
21
|
-
dispose = value[Symbol.asyncDispose];
|
|
22
|
-
}
|
|
23
|
-
if (dispose === void 0) {
|
|
24
|
-
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
|
|
25
|
-
dispose = value[Symbol.dispose];
|
|
26
|
-
if (async) inner = dispose;
|
|
27
|
-
}
|
|
28
|
-
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
|
|
29
|
-
if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
|
|
30
|
-
env.stack.push({ value: value, dispose: dispose, async: async });
|
|
31
|
-
}
|
|
32
|
-
else if (async) {
|
|
33
|
-
env.stack.push({ async: true });
|
|
34
|
-
}
|
|
35
|
-
return value;
|
|
36
|
-
};
|
|
37
|
-
var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
|
|
38
|
-
return function (env) {
|
|
39
|
-
function fail(e) {
|
|
40
|
-
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
|
|
41
|
-
env.hasError = true;
|
|
42
|
-
}
|
|
43
|
-
var r, s = 0;
|
|
44
|
-
function next() {
|
|
45
|
-
while (r = env.stack.pop()) {
|
|
46
|
-
try {
|
|
47
|
-
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
|
|
48
|
-
if (r.dispose) {
|
|
49
|
-
var result = r.dispose.call(r.value);
|
|
50
|
-
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
|
|
51
|
-
}
|
|
52
|
-
else s |= 1;
|
|
53
|
-
}
|
|
54
|
-
catch (e) {
|
|
55
|
-
fail(e);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
|
|
59
|
-
if (env.hasError) throw env.error;
|
|
60
|
-
}
|
|
61
|
-
return next();
|
|
62
|
-
};
|
|
63
|
-
})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
64
|
-
var e = new Error(message);
|
|
65
|
-
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
66
|
-
});
|
|
67
|
-
// Subpath imports: the package root also re-exports every provider's wire
|
|
68
|
-
// implementation, which this plugin does not mount. See catalog/index.ts.
|
|
69
|
-
import { getSupportedThinkingLevels } from '@earendil-works/pi-ai/models';
|
|
70
|
-
import { normalizeContext } from '@earendil-works/pi-ai/utils/transcript';
|
|
71
|
-
import { attributionHeaders, contentHasImage, LlmAdapter, LlmError, ReasoningEffortId, resolveRetryPolicy, } from '@deepseek-ai/dsh-llm';
|
|
72
|
-
import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout';
|
|
73
|
-
import { DISPLAY_NAME, PROVIDER_ID, OpencodeGoCatalog } from './catalog/index.js';
|
|
74
|
-
import { toPiModel } from './catalog/metadata.js';
|
|
75
|
-
import { assertBaseURL } from './config.js';
|
|
76
|
-
import { toPiContext, toStreamChunks } from './conversion/index.js';
|
|
77
|
-
import { isModelEnabled, recommendedIds } from './models.js';
|
|
78
|
-
import { providerHeaders } from './session-header.js';
|
|
79
|
-
/**
|
|
80
|
-
* pi-ai thinking formats that answer an unset effort with an explicit disable.
|
|
81
|
-
* Every other format omits the parameter and lets the provider decide, so only
|
|
82
|
-
* these need a default effort to avoid silently turning thinking off.
|
|
83
|
-
*/
|
|
84
|
-
const DISABLES_THINKING_WHEN_UNSET = new Set(['deepseek', 'zai', 'qwen', 'qwen-chat-template']);
|
|
85
|
-
/** Apply one request's configured capacities without touching the catalog. */
|
|
86
|
-
function withModelLimit(model, limits) {
|
|
87
|
-
const limit = limits[model.id];
|
|
88
|
-
if (limit === null || limit === undefined)
|
|
89
|
-
return model;
|
|
90
|
-
return {
|
|
91
|
-
...model,
|
|
92
|
-
contextWindow: limit.contextWindow ?? model.contextWindow,
|
|
93
|
-
maxTokens: limit.maxTokens ?? model.maxTokens,
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Resolve one request's stop sequences against the model's protocol.
|
|
98
|
-
*
|
|
99
|
-
* The seam's stop control reaches a route only through the provider's own
|
|
100
|
-
* field, and pi-ai models none: its Anthropic options carry no stop sequences
|
|
101
|
-
* and that adapter ignores `samplingParams`, and its Responses options define
|
|
102
|
-
* no stop-sequence parameter at all. Only the OpenAI-compatible adapters merge
|
|
103
|
-
* arbitrary body fields, and `stop` is exactly the field Chat Completions
|
|
104
|
-
* defines, so a completions model can honor it. Every other protocol refuses:
|
|
105
|
-
* silently dropping the sequences would let a caller believe generation halts
|
|
106
|
-
* at a string the model never sees.
|
|
107
|
-
* @param model - the exact model this request will use.
|
|
108
|
-
* @param stop - the caller's stop sequences, when it named any.
|
|
109
|
-
* @returns the sequences to send, or undefined when the caller named none.
|
|
110
|
-
* @throws {LlmError} `UNSUPPORTED_OPTION` for a protocol with no reachable
|
|
111
|
-
* stop-sequence parameter.
|
|
112
|
-
*/
|
|
113
|
-
function stopSequencesFor(model, stop) {
|
|
114
|
-
if (stop === undefined)
|
|
115
|
-
return undefined;
|
|
116
|
-
if (model.api === 'openai-completions')
|
|
117
|
-
return stop;
|
|
118
|
-
throw new LlmError(`opencode-go model "${model.id}" speaks ${model.api}, which has no stop-sequence parameter this adapter can reach;`
|
|
119
|
-
+ ' drop GenerateOptions.stop for this model', 'UNSUPPORTED_OPTION');
|
|
120
|
-
}
|
|
121
|
-
/**
|
|
122
|
-
* The single `opencode-go` route's adapter.
|
|
123
|
-
*
|
|
124
|
-
* The catalog is cached per endpoint, refresh interval, and protocol-override
|
|
125
|
-
* set: changing any of those builds a new one, while a settings change that
|
|
126
|
-
* touches none of them keeps the cached snapshot for its full TTL.
|
|
127
|
-
*/
|
|
128
|
-
export class OpencodeGoAdapter extends LlmAdapter {
|
|
129
|
-
cache;
|
|
130
|
-
options;
|
|
131
|
-
constructor(options) {
|
|
132
|
-
super();
|
|
133
|
-
this.options = options;
|
|
134
|
-
}
|
|
135
|
-
/** The catalog for one configuration, rebuilt when its owned facts change. */
|
|
136
|
-
catalogOf(config) {
|
|
137
|
-
const key = `${config.baseURL}|${String(config.refreshMinutes)}|${JSON.stringify(config.modelProtocols)}`;
|
|
138
|
-
if (this.cache?.key !== key) {
|
|
139
|
-
this.cache = {
|
|
140
|
-
key,
|
|
141
|
-
catalog: new OpencodeGoCatalog({
|
|
142
|
-
baseURL: assertBaseURL(config.baseURL),
|
|
143
|
-
refreshMs: config.refreshMinutes * 60_000,
|
|
144
|
-
defaults: {
|
|
145
|
-
contextWindow: 262_144,
|
|
146
|
-
maxTokens: 32_768,
|
|
147
|
-
input: ['text'],
|
|
148
|
-
},
|
|
149
|
-
overrides: config.modelProtocols,
|
|
150
|
-
...this.options.readDocument === undefined ? {} : { readDocument: this.options.readDocument },
|
|
151
|
-
observers: {
|
|
152
|
-
...this.options.onFallback === undefined ? {} : { onFallback: this.options.onFallback },
|
|
153
|
-
...this.options.onUnconfigured === undefined ? {} : { onUnconfigured: this.options.onUnconfigured },
|
|
154
|
-
},
|
|
155
|
-
}),
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
return this.cache.catalog;
|
|
159
|
-
}
|
|
160
|
-
providerInfo(provider) {
|
|
161
|
-
return { id: provider, name: DISPLAY_NAME };
|
|
162
|
-
}
|
|
163
|
-
async listModels(_provider) {
|
|
164
|
-
const config = this.options.config();
|
|
165
|
-
const snapshot = await this.catalogOf(config).snapshot();
|
|
166
|
-
// DSH resolves every listed model before showing the provider, so an
|
|
167
|
-
// unconfigurable id belongs in the settings diagnostics, not this list.
|
|
168
|
-
const facts = [...snapshot.facts.values()];
|
|
169
|
-
// The same default the settings page marks: without explicit switches the
|
|
170
|
-
// top few by published monthly estimate stay enabled.
|
|
171
|
-
const recommended = recommendedIds(facts);
|
|
172
|
-
return facts
|
|
173
|
-
.map(fact => ({ ...fact, recommended: recommended.has(fact.id) }))
|
|
174
|
-
.filter(fact => isModelEnabled(fact, config.modelVisibility))
|
|
175
|
-
.map(fact => ({
|
|
176
|
-
provider: PROVIDER_ID,
|
|
177
|
-
id: fact.id,
|
|
178
|
-
name: fact.name,
|
|
179
|
-
inputModalities: [...fact.input],
|
|
180
|
-
}));
|
|
181
|
-
}
|
|
182
|
-
async resolveModel(_provider, model, _signal) {
|
|
183
|
-
const config = this.options.config();
|
|
184
|
-
const snapshot = await this.catalogOf(config).forModel(model);
|
|
185
|
-
const facts = snapshot.facts.get(model);
|
|
186
|
-
if (facts === undefined)
|
|
187
|
-
throw new LlmError(`opencode-go has no model "${model}"`, 'UNKNOWN_MODEL');
|
|
188
|
-
return this.modelInfo(withModelLimit(toPiModel(facts, config.baseURL), config.modelLimits));
|
|
189
|
-
}
|
|
190
|
-
/** Describe one model: capacities plus the reasoning levels it really offers. */
|
|
191
|
-
modelInfo(model) {
|
|
192
|
-
const reasoning = {};
|
|
193
|
-
const levels = model.reasoning ? getSupportedThinkingLevels(model) : [];
|
|
194
|
-
if (levels.length > 0) {
|
|
195
|
-
// A format that disables thinking when no effort is named would silently
|
|
196
|
-
// strip reasoning from a model that offers levels, so it gets a default.
|
|
197
|
-
const format = model.compat?.thinkingFormat;
|
|
198
|
-
const fallback = format !== undefined && DISABLES_THINKING_WHEN_UNSET.has(format)
|
|
199
|
-
? levels.includes('high') ? 'high' : levels.findLast(level => level !== 'off')
|
|
200
|
-
: undefined;
|
|
201
|
-
reasoning.reasoning = {
|
|
202
|
-
efforts: levels.map(level => ({
|
|
203
|
-
id: ReasoningEffortId(level),
|
|
204
|
-
name: `${level.charAt(0).toUpperCase()}${level.slice(1)}`,
|
|
205
|
-
})),
|
|
206
|
-
...fallback === undefined ? {} : { defaultEffort: ReasoningEffortId(fallback) },
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
return {
|
|
210
|
-
provider: PROVIDER_ID,
|
|
211
|
-
id: model.id,
|
|
212
|
-
name: model.name,
|
|
213
|
-
inputModalities: [...model.input],
|
|
214
|
-
context: { contextWindow: model.contextWindow },
|
|
215
|
-
// The route's own output ceiling, so the host materializes it when a
|
|
216
|
-
// caller names none and a caller that does name one still wins.
|
|
217
|
-
defaultMaxTokens: model.maxTokens,
|
|
218
|
-
...reasoning,
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
/**
|
|
222
|
-
* This route's retry policy, resolved from configuration on every read so a
|
|
223
|
-
* profile edit reaches the next failed step. Absent configuration leaves the
|
|
224
|
-
* host's own default in force.
|
|
225
|
-
* @param _provider - unused; the mount owns exactly one route.
|
|
226
|
-
* @returns the resolved policy, or undefined when none is configured.
|
|
227
|
-
*/
|
|
228
|
-
providerRetryPolicy(_provider) {
|
|
229
|
-
const configured = this.options.config().retryPolicy;
|
|
230
|
-
return configured === undefined
|
|
231
|
-
? undefined
|
|
232
|
-
: resolveRetryPolicy(configured, 'dshopencodego: retryPolicy');
|
|
233
|
-
}
|
|
234
|
-
/** Validate an explicit effort against the model's own levels, without clamping. */
|
|
235
|
-
resolveReasoningLevel(model, effort) {
|
|
236
|
-
// "off" is not a level to validate: it is the absence of a request, exactly
|
|
237
|
-
// like an unset effort, and the caller drops both before the wire.
|
|
238
|
-
if (effort === undefined || effort === 'off')
|
|
239
|
-
return undefined;
|
|
240
|
-
const supported = getSupportedThinkingLevels(model);
|
|
241
|
-
if (supported.some(level => level === effort))
|
|
242
|
-
return effort;
|
|
243
|
-
throw new LlmError(`opencode-go model "${model.id}" does not support reasoning effort "${effort}"`, 'UNSUPPORTED_REASONING_EFFORT');
|
|
244
|
-
}
|
|
245
|
-
async prepareCall(_provider, model, _signal) {
|
|
246
|
-
// Freeze one generation: the config, catalog snapshot, and model facts are
|
|
247
|
-
// captured before the first await, so a settings change between preparation
|
|
248
|
-
// and dispatch cannot combine one generation's capabilities with another's
|
|
249
|
-
// endpoint. Mirrors llm-pi-ai's snapshot discipline.
|
|
250
|
-
const config = this.options.config();
|
|
251
|
-
const snapshot = await this.catalogOf(config).forModel(model);
|
|
252
|
-
const facts = snapshot.facts.get(model);
|
|
253
|
-
if (facts === undefined)
|
|
254
|
-
throw new LlmError(`opencode-go has no model "${model}"`, 'UNKNOWN_MODEL');
|
|
255
|
-
const prepared = this.modelInfo(withModelLimit(toPiModel(facts, config.baseURL), config.modelLimits));
|
|
256
|
-
return {
|
|
257
|
-
model: prepared,
|
|
258
|
-
stream: options => this.streamWithSnapshot(options, config, snapshot),
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
async *stream(options) {
|
|
262
|
-
const config = this.options.config();
|
|
263
|
-
const snapshot = await this.catalogOf(config).forModel(options.model);
|
|
264
|
-
yield* this.streamWithSnapshot(options, config, snapshot);
|
|
265
|
-
}
|
|
266
|
-
async *streamWithSnapshot(options, config, snapshot) {
|
|
267
|
-
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
268
|
-
try {
|
|
269
|
-
const facts = snapshot.facts.get(options.model);
|
|
270
|
-
if (facts === undefined)
|
|
271
|
-
throw new LlmError(`opencode-go has no model "${options.model}"`, 'UNKNOWN_MODEL');
|
|
272
|
-
const model = withModelLimit(toPiModel(facts, config.baseURL), config.modelLimits);
|
|
273
|
-
// Resolved before the first await so an unsupported protocol fails the
|
|
274
|
-
// request before any credential is read or any byte is sent.
|
|
275
|
-
const stop = stopSequencesFor(model, options.stop);
|
|
276
|
-
// `defaultMaxTokens` already carries the route's ceiling for a caller that
|
|
277
|
-
// names none, so only a caller's own cap travels, clamped by a configured
|
|
278
|
-
// per-model ceiling when one exists.
|
|
279
|
-
const ceiling = config.modelLimits[model.id]?.maxTokens;
|
|
280
|
-
const maxTokens = options.maxTokens === undefined || ceiling === null || ceiling === undefined
|
|
281
|
-
? options.maxTokens
|
|
282
|
-
: Math.min(options.maxTokens, ceiling);
|
|
283
|
-
const apiKey = await this.options.resolveApiKey();
|
|
284
|
-
if (apiKey === undefined || apiKey.length === 0) {
|
|
285
|
-
throw new LlmError('dshopencodego: no credential resolved for the opencode-go route', 'MISSING_CREDENTIAL');
|
|
286
|
-
}
|
|
287
|
-
const reasoning = this.resolveReasoningLevel(model, options.reasoningEffort);
|
|
288
|
-
const consumer = new AbortController();
|
|
289
|
-
const upstream = options.signal === undefined ? consumer.signal : AbortSignal.any([options.signal, consumer.signal]);
|
|
290
|
-
const watchdog = __addDisposableResource(env_1, idleWatchdog(upstream, config.streamIdleTimeoutMs, 'LLM_STREAM_IDLE_TIMEOUT'), false);
|
|
291
|
-
try {
|
|
292
|
-
const containsImage = options.messages.some(message => contentHasImage(message.content));
|
|
293
|
-
if (containsImage && !model.input.includes('image')) {
|
|
294
|
-
throw new LlmError(`opencode-go model "${model.id}" does not support image input`, 'UNSUPPORTED_CONTENT');
|
|
295
|
-
}
|
|
296
|
-
let imageRequest;
|
|
297
|
-
if (containsImage) {
|
|
298
|
-
const access = this.options.imageAccess;
|
|
299
|
-
const store = access?.resolveAttachments();
|
|
300
|
-
if (access === undefined || store === undefined) {
|
|
301
|
-
throw new LlmError('dshopencodego image input requires the durable attachment service', 'UNSUPPORTED_CONTENT');
|
|
302
|
-
}
|
|
303
|
-
imageRequest = {
|
|
304
|
-
attachments: store,
|
|
305
|
-
resolveImageAccess: ref => access.resolveImageAccess(store, ref),
|
|
306
|
-
maxRequestImageBytes: config.maxRequestImageBytes,
|
|
307
|
-
requestImagePolicy: { maxPixels: config.requestImagePixelBudget, maxBytes: config.requestImageMaxBytes },
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
const context = imageRequest === undefined
|
|
311
|
-
? await toPiContext(options, undefined, this.options.onReplayDegrade)
|
|
312
|
-
: await toPiContext({ ...options, signal: watchdog.signal }, imageRequest, this.options.onReplayDegrade);
|
|
313
|
-
const events = snapshot.provider.streamSimple(model, normalizeContext(context), {
|
|
314
|
-
apiKey,
|
|
315
|
-
...reasoning === undefined || reasoning === 'off' ? {} : { reasoning },
|
|
316
|
-
...options.temperature === undefined ? {} : { temperature: options.temperature },
|
|
317
|
-
...maxTokens === undefined ? {} : { maxTokens },
|
|
318
|
-
// `samplingParams` is merged into the request body as-is by the
|
|
319
|
-
// OpenAI-compatible adapters, and `stop` is Chat Completions' own
|
|
320
|
-
// stop-sequence field — the one place this control reaches the wire.
|
|
321
|
-
...stop === undefined ? {} : { samplingParams: { stop: [...stop] } },
|
|
322
|
-
...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) },
|
|
323
|
-
signal: watchdog.signal,
|
|
324
|
-
// The gateway refuses a request without `x-opencode-session`; the
|
|
325
|
-
// attribution user-agent rides along on the same object.
|
|
326
|
-
headers: providerHeaders(options.sessionId === undefined ? undefined : String(options.sessionId)),
|
|
327
|
-
// The agent recovery layer owns visible attempts; one adapter call is
|
|
328
|
-
// one SDK attempt.
|
|
329
|
-
maxRetries: 0,
|
|
330
|
-
});
|
|
331
|
-
const iterator = toStreamChunks(events, model.contextWindow, options.signal, model.id)[Symbol.asyncIterator]();
|
|
332
|
-
let exhausted = false;
|
|
333
|
-
try {
|
|
334
|
-
while (true) {
|
|
335
|
-
const result = await watchdog.next(iterator);
|
|
336
|
-
if (timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT') !== undefined) {
|
|
337
|
-
throw new LlmError('opencode-go stream idle timeout', 'TIMEOUT');
|
|
338
|
-
}
|
|
339
|
-
if (result.done) {
|
|
340
|
-
exhausted = true;
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
if (result.value.type === 'usage') {
|
|
344
|
-
this.options.onUsage?.({ model: options.model, usage: result.value.usage });
|
|
345
|
-
}
|
|
346
|
-
yield result.value;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
finally {
|
|
350
|
-
if (!exhausted) {
|
|
351
|
-
consumer.abort('opencode-go stream consumer stopped');
|
|
352
|
-
try {
|
|
353
|
-
await iterator.return(undefined);
|
|
354
|
-
}
|
|
355
|
-
catch (_abortedSdkTeardown) {
|
|
356
|
-
// The stable signal already owns SDK termination.
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
catch (error) {
|
|
362
|
-
if (timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT') !== undefined) {
|
|
363
|
-
throw new LlmError('opencode-go stream idle timeout', 'TIMEOUT', { cause: error });
|
|
364
|
-
}
|
|
365
|
-
if (options.signal?.aborted) {
|
|
366
|
-
throw new LlmError('opencode-go request aborted by caller', 'ABORTED', { cause: error });
|
|
367
|
-
}
|
|
368
|
-
throw error;
|
|
369
|
-
}
|
|
370
|
-
finally {
|
|
371
|
-
consumer.abort('opencode-go stream consumer stopped');
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
catch (e_1) {
|
|
375
|
-
env_1.error = e_1;
|
|
376
|
-
env_1.hasError = true;
|
|
377
|
-
}
|
|
378
|
-
finally {
|
|
379
|
-
__disposeResources(env_1);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
/** Re-exported so a composition can assert the attribution contract in one place. */
|
|
384
|
-
export { attributionHeaders };
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
/** Shared identities and endpoints for the OpenCode Go route. */
|
|
2
|
-
/** DSH provider route this plugin owns. */
|
|
3
|
-
export const PROVIDER_ID = 'opencode-go';
|
|
4
|
-
/** Selector label for the route. */
|
|
5
|
-
export const DISPLAY_NAME = 'OpenCode Go';
|
|
6
|
-
/** Gateway base URL; `/models` and `/usage` hang off it. */
|
|
7
|
-
export const DEFAULT_BASE_URL = 'https://opencode.ai/inference/go/v1';
|
|
8
|
-
/** Console base URL (on-demand / free models, pay-as-you-go). */
|
|
9
|
-
export const CONSOLE_BASE_URL = 'https://opencode.ai/inference/v1';
|
|
10
|
-
/** Online model metadata: capability, lifecycle, and pricing per model id. */
|
|
11
|
-
export const MODEL_METADATA_URL = 'https://models.dev/api.json';
|
|
12
|
-
/** The models.dev provider key this plugin reads. */
|
|
13
|
-
export const MODEL_METADATA_PROVIDER = 'opencode-go';
|
|
14
|
-
/** Timeout for the two metadata GETs. */
|
|
15
|
-
export const METADATA_FETCH_TIMEOUT_MS = 10_000;
|
|
16
|
-
/** Response caps: the listing is tiny, the metadata document is not. */
|
|
17
|
-
export const MODEL_LISTING_MAX_BYTES = 1024 * 1024;
|
|
18
|
-
export const MODEL_METADATA_MAX_BYTES = 16 * 1024 * 1024;
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Wire contract for the catalog Remote.
|
|
3
|
-
*
|
|
4
|
-
* `read` serves the cached snapshot; `refresh` revalidates both sources first.
|
|
5
|
-
* The distinction is the whole point of the settings page's refresh button: the
|
|
6
|
-
* runtime TTL exists so sessions do not re-fetch on every request, and a human
|
|
7
|
-
* asking "what does the gateway serve right now" must not be answered from
|
|
8
|
-
* that cache.
|
|
9
|
-
*
|
|
10
|
-
* @module @dan-ai-studio/dshopencodego/catalog/contract
|
|
11
|
-
*/
|
|
12
|
-
import { INPUT_MODALITIES } from './metadata.js';
|
|
13
|
-
function parseGoQuota(value) {
|
|
14
|
-
if (value === null || typeof value !== 'object')
|
|
15
|
-
throw new Error('invalid go quota');
|
|
16
|
-
const row = value;
|
|
17
|
-
const usd = row['monthlyUsd'];
|
|
18
|
-
if (usd !== 'unlimited' && (typeof usd !== 'number' || !Number.isFinite(usd))) {
|
|
19
|
-
throw new Error('invalid go quota monthlyUsd');
|
|
20
|
-
}
|
|
21
|
-
const requests = row['monthlyRequests'];
|
|
22
|
-
if (requests !== undefined && requests !== 'unlimited'
|
|
23
|
-
&& (typeof requests !== 'number' || !Number.isFinite(requests))) {
|
|
24
|
-
throw new Error('invalid go quota monthlyRequests');
|
|
25
|
-
}
|
|
26
|
-
return {
|
|
27
|
-
monthlyUsd: usd,
|
|
28
|
-
...requests === undefined ? {} : { monthlyRequests: requests },
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
function parseCost(value) {
|
|
32
|
-
if (value === null || typeof value !== 'object')
|
|
33
|
-
throw new Error('invalid model cost');
|
|
34
|
-
const row = value;
|
|
35
|
-
const rate = (key) => {
|
|
36
|
-
const entry = row[key];
|
|
37
|
-
if (typeof entry !== 'number' || !Number.isFinite(entry) || entry < 0)
|
|
38
|
-
throw new Error(`invalid model cost "${key}"`);
|
|
39
|
-
return entry;
|
|
40
|
-
};
|
|
41
|
-
return {
|
|
42
|
-
input: rate('input'),
|
|
43
|
-
output: rate('output'),
|
|
44
|
-
...typeof row['cacheRead'] === 'number' ? { cacheRead: rate('cacheRead') } : {},
|
|
45
|
-
...typeof row['cacheWrite'] === 'number' ? { cacheWrite: rate('cacheWrite') } : {},
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
/** Recognised input modalities out of one wire value; absent when none survive. */
|
|
49
|
-
function parseModalities(value) {
|
|
50
|
-
if (!Array.isArray(value))
|
|
51
|
-
return {};
|
|
52
|
-
const kept = INPUT_MODALITIES.filter(modality => value.includes(modality));
|
|
53
|
-
return kept.length === 0 ? {} : { inputModalities: kept };
|
|
54
|
-
}
|
|
55
|
-
function parseModel(value) {
|
|
56
|
-
if (value === null || typeof value !== 'object')
|
|
57
|
-
throw new Error('invalid catalog model');
|
|
58
|
-
const row = value;
|
|
59
|
-
if (typeof row['id'] !== 'string' || row['id'].length === 0)
|
|
60
|
-
throw new Error('invalid catalog model id');
|
|
61
|
-
return {
|
|
62
|
-
id: row['id'],
|
|
63
|
-
name: typeof row['name'] === 'string' ? row['name'] : row['id'],
|
|
64
|
-
...typeof row['contextWindow'] === 'number' ? { contextWindow: row['contextWindow'] } : {},
|
|
65
|
-
...typeof row['maxInputTokens'] === 'number' ? { maxInputTokens: row['maxInputTokens'] } : {},
|
|
66
|
-
...typeof row['maxTokens'] === 'number' ? { maxTokens: row['maxTokens'] } : {},
|
|
67
|
-
...typeof row['deprecated'] === 'boolean' ? { deprecated: row['deprecated'] } : {},
|
|
68
|
-
...typeof row['releaseDate'] === 'string' ? { releaseDate: row['releaseDate'] } : {},
|
|
69
|
-
...row['goQuota'] === undefined ? {} : { goQuota: parseGoQuota(row['goQuota']) },
|
|
70
|
-
...row['cost'] === undefined ? {} : { cost: parseCost(row['cost']) },
|
|
71
|
-
...row['protocolSource'] === 'builtin' || row['protocolSource'] === 'online'
|
|
72
|
-
|| row['protocolSource'] === 'inferred' || row['protocolSource'] === 'override'
|
|
73
|
-
? { protocolSource: row['protocolSource'] } : {},
|
|
74
|
-
...typeof row['assumedLimits'] === 'boolean' ? { assumedLimits: row['assumedLimits'] } : {},
|
|
75
|
-
...parseModalities(row['inputModalities']),
|
|
76
|
-
...typeof row['configurationMissing'] === 'string' ? { configurationMissing: row['configurationMissing'] } : {},
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
/** Validate a catalog reading crossing the wire. */
|
|
80
|
-
export function parseCatalogReading(value) {
|
|
81
|
-
if (value === null || typeof value !== 'object')
|
|
82
|
-
throw new Error('invalid catalog reading');
|
|
83
|
-
const row = value;
|
|
84
|
-
const counts = row['counts'];
|
|
85
|
-
if (!Array.isArray(row['models']) || typeof row['stale'] !== 'boolean'
|
|
86
|
-
|| typeof row['fetchedAtMs'] !== 'number' || counts === null || typeof counts !== 'object') {
|
|
87
|
-
throw new Error('invalid catalog reading');
|
|
88
|
-
}
|
|
89
|
-
const numbers = counts;
|
|
90
|
-
const count = (key) => {
|
|
91
|
-
const entry = numbers[key];
|
|
92
|
-
if (typeof entry !== 'number' || !Number.isFinite(entry) || entry < 0)
|
|
93
|
-
throw new Error(`invalid catalog count "${key}"`);
|
|
94
|
-
return entry;
|
|
95
|
-
};
|
|
96
|
-
const quotaSource = row['quotaSource'];
|
|
97
|
-
// Absent means the payload predates the field — a cached client bundle with a
|
|
98
|
-
// newer host — and the seed is what such a reading actually carries.
|
|
99
|
-
if (quotaSource !== undefined && quotaSource !== 'document' && quotaSource !== 'seed') {
|
|
100
|
-
throw new Error('invalid catalog quota source');
|
|
101
|
-
}
|
|
102
|
-
return {
|
|
103
|
-
models: row['models'].map(parseModel),
|
|
104
|
-
stale: row['stale'],
|
|
105
|
-
...typeof row['error'] === 'string' ? { error: row['error'] } : {},
|
|
106
|
-
fetchedAtMs: row['fetchedAtMs'],
|
|
107
|
-
quotaSource: quotaSource ?? 'seed',
|
|
108
|
-
counts: {
|
|
109
|
-
total: count('total'),
|
|
110
|
-
enabled: count('enabled'),
|
|
111
|
-
deprecated: count('deprecated'),
|
|
112
|
-
unconfigured: count('unconfigured'),
|
|
113
|
-
inferred: count('inferred'),
|
|
114
|
-
},
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
/** Codec shape accepted by both released and source DSH builds. */
|
|
118
|
-
const codec = {
|
|
119
|
-
mode: 'strict',
|
|
120
|
-
typeSymbol: '@dan-ai-studio/dshopencodego#CatalogReading',
|
|
121
|
-
schema: { parse: parseCatalogReading },
|
|
122
|
-
create: () => ({ parse: parseCatalogReading }),
|
|
123
|
-
};
|
|
124
|
-
/** Remote methods the catalog owns. */
|
|
125
|
-
export const catalogRemote = {
|
|
126
|
-
package: '@dan-ai-studio/dshopencodego',
|
|
127
|
-
descriptors: [
|
|
128
|
-
{
|
|
129
|
-
id: '@dan-ai-studio/dshopencodego#opencodeGoCatalog/read',
|
|
130
|
-
service: 'opencodeGoCatalog',
|
|
131
|
-
namespace: 'opencodeGoCatalog',
|
|
132
|
-
method: 'read',
|
|
133
|
-
invocation: { kind: 'direct' },
|
|
134
|
-
parameters: [],
|
|
135
|
-
result: codec,
|
|
136
|
-
},
|
|
137
|
-
{
|
|
138
|
-
id: '@dan-ai-studio/dshopencodego#opencodeGoCatalog/refresh',
|
|
139
|
-
service: 'opencodeGoCatalog',
|
|
140
|
-
namespace: 'opencodeGoCatalog',
|
|
141
|
-
method: 'refresh',
|
|
142
|
-
invocation: { kind: 'direct' },
|
|
143
|
-
parameters: [],
|
|
144
|
-
result: codec,
|
|
145
|
-
},
|
|
146
|
-
],
|
|
147
|
-
};
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The gateway model listing: which ids the subscription can call right now.
|
|
3
|
-
*
|
|
4
|
-
* The endpoint answers an OpenAI-shaped `{ object: 'list', data: [{ id, … }] }`
|
|
5
|
-
* with no capability information at all, so it decides *membership* and nothing
|
|
6
|
-
* else. A malformed reply is a failure (never "the gateway serves nothing").
|
|
7
|
-
*
|
|
8
|
-
* @module @dan-ai-studio/dshopencodego/catalog/gateway
|
|
9
|
-
*/
|
|
10
|
-
import { attributionHeaders, LlmError } from '@deepseek-ai/dsh-llm';
|
|
11
|
-
import { MODEL_LISTING_MAX_BYTES, METADATA_FETCH_TIMEOUT_MS } from './constants.js';
|
|
12
|
-
import { readBoundedJson } from './json-response.js';
|
|
13
|
-
/** Read the ids out of a model-listing body. */
|
|
14
|
-
export function readModelIds(body) {
|
|
15
|
-
const data = body?.data;
|
|
16
|
-
if (!Array.isArray(data))
|
|
17
|
-
throw new Error('the model listing has no "data" array');
|
|
18
|
-
const ids = [];
|
|
19
|
-
for (const entry of data) {
|
|
20
|
-
const id = entry?.id;
|
|
21
|
-
if (typeof id === 'string' && id.length > 0)
|
|
22
|
-
ids.push(id);
|
|
23
|
-
}
|
|
24
|
-
return [...new Set(ids)];
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* Fetch the ids the gateway currently advertises.
|
|
28
|
-
* @param baseURL - normalized gateway base without a trailing slash.
|
|
29
|
-
* @param signal - caller cancellation, if any.
|
|
30
|
-
* @returns the advertised ids in endpoint order, deduplicated.
|
|
31
|
-
* @throws {LlmError} `DISCOVERY_FAILED` when the endpoint is unreachable, not
|
|
32
|
-
* OK, or does not answer a model listing.
|
|
33
|
-
*/
|
|
34
|
-
export async function fetchModelIds(baseURL, signal) {
|
|
35
|
-
const url = `${baseURL.replace(/\/+$/, '')}/models`;
|
|
36
|
-
const timeout = AbortSignal.timeout(METADATA_FETCH_TIMEOUT_MS);
|
|
37
|
-
const response = await fetch(url, {
|
|
38
|
-
method: 'GET',
|
|
39
|
-
redirect: 'error',
|
|
40
|
-
// An explicit request header rather than `RequestInit.cache`: Node's fetch
|
|
41
|
-
// does not implement HTTP caching, and the header is what any intermediary
|
|
42
|
-
// in front of the gateway reads.
|
|
43
|
-
headers: { ...attributionHeaders(), accept: 'application/json', 'cache-control': 'no-cache' },
|
|
44
|
-
signal: signal === undefined ? timeout : AbortSignal.any([signal, timeout]),
|
|
45
|
-
}).catch((error) => {
|
|
46
|
-
throw new LlmError(`could not reach ${url}`, 'DISCOVERY_FAILED', { cause: error });
|
|
47
|
-
});
|
|
48
|
-
if (!response.ok) {
|
|
49
|
-
await response.body?.cancel().catch(() => { });
|
|
50
|
-
throw new LlmError(`${url} answered HTTP ${response.status}`, 'DISCOVERY_FAILED');
|
|
51
|
-
}
|
|
52
|
-
const body = await readBoundedJson(response, url, MODEL_LISTING_MAX_BYTES);
|
|
53
|
-
try {
|
|
54
|
-
return readModelIds(body);
|
|
55
|
-
}
|
|
56
|
-
catch (error) {
|
|
57
|
-
throw new LlmError(`${url} returned an invalid model listing`, 'DISCOVERY_FAILED', { cause: error });
|
|
58
|
-
}
|
|
59
|
-
}
|