@dan-ai-studio/dshopencodego 0.1.42 → 0.1.44

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.
Files changed (44) hide show
  1. package/README.en.md +48 -28
  2. package/README.md +47 -27
  3. package/lib/client.js +47 -1
  4. package/lib/index.js +321 -123
  5. package/lib/types/adapter.d.ts +26 -3
  6. package/lib/types/catalog/go-doc.d.ts +9 -5
  7. package/lib/types/catalog/index.d.ts +22 -7
  8. package/lib/types/catalog/service.d.ts +44 -1
  9. package/lib/types/config.d.ts +4 -3
  10. package/lib/types/conversion/replay.d.ts +30 -1
  11. package/lib/types/models.d.ts +14 -0
  12. package/lib/types/usage/meter.d.ts +89 -2
  13. package/package.json +43 -44
  14. package/lib/types/adapter.js +0 -384
  15. package/lib/types/catalog/constants.js +0 -18
  16. package/lib/types/catalog/contract.js +0 -147
  17. package/lib/types/catalog/gateway.js +0 -59
  18. package/lib/types/catalog/go-doc.js +0 -192
  19. package/lib/types/catalog/index.js +0 -377
  20. package/lib/types/catalog/json-response.js +0 -73
  21. package/lib/types/catalog/metadata.js +0 -278
  22. package/lib/types/catalog/protocol.js +0 -87
  23. package/lib/types/catalog/reading.js +0 -73
  24. package/lib/types/catalog/service.js +0 -45
  25. package/lib/types/config.js +0 -91
  26. package/lib/types/conversion/context.js +0 -212
  27. package/lib/types/conversion/index.js +0 -7
  28. package/lib/types/conversion/replay.js +0 -242
  29. package/lib/types/conversion/stream.js +0 -221
  30. package/lib/types/go-limits.js +0 -91
  31. package/lib/types/index.js +0 -198
  32. package/lib/types/models.js +0 -86
  33. package/lib/types/remotes.js +0 -29
  34. package/lib/types/session-header.js +0 -49
  35. package/lib/types/update/check.js +0 -138
  36. package/lib/types/update/contract.js +0 -64
  37. package/lib/types/update/index.js +0 -10
  38. package/lib/types/update/service.js +0 -39
  39. package/lib/types/update/version.js +0 -16
  40. package/lib/types/usage/contract.js +0 -106
  41. package/lib/types/usage/index.js +0 -8
  42. package/lib/types/usage/meter.js +0 -65
  43. package/lib/types/usage/service.js +0 -74
  44. package/lib/types/usage/windows.js +0 -84
@@ -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
- }