@dan-ai-studio/dshopencodego 0.1.5

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +156 -0
  3. package/README.md +156 -0
  4. package/cordis.patch.yml +3 -0
  5. package/lib/build-info.json +11 -0
  6. package/lib/client.js +1215 -0
  7. package/lib/index.js +2036 -0
  8. package/lib/types/adapter.d.ts +84 -0
  9. package/lib/types/adapter.js +311 -0
  10. package/lib/types/catalog/constants.d.ts +16 -0
  11. package/lib/types/catalog/constants.js +16 -0
  12. package/lib/types/catalog/contract.d.ts +26 -0
  13. package/lib/types/catalog/contract.js +131 -0
  14. package/lib/types/catalog/gateway.d.ts +20 -0
  15. package/lib/types/catalog/gateway.js +59 -0
  16. package/lib/types/catalog/index.d.ts +108 -0
  17. package/lib/types/catalog/index.js +288 -0
  18. package/lib/types/catalog/json-response.d.ts +19 -0
  19. package/lib/types/catalog/json-response.js +72 -0
  20. package/lib/types/catalog/metadata.d.ts +73 -0
  21. package/lib/types/catalog/metadata.js +259 -0
  22. package/lib/types/catalog/protocol.d.ts +65 -0
  23. package/lib/types/catalog/protocol.js +87 -0
  24. package/lib/types/catalog/reading.d.ts +41 -0
  25. package/lib/types/catalog/reading.js +68 -0
  26. package/lib/types/catalog/service.d.ts +32 -0
  27. package/lib/types/catalog/service.js +45 -0
  28. package/lib/types/config.d.ts +93 -0
  29. package/lib/types/config.js +76 -0
  30. package/lib/types/conversion/context.d.ts +55 -0
  31. package/lib/types/conversion/context.js +202 -0
  32. package/lib/types/conversion/index.d.ts +9 -0
  33. package/lib/types/conversion/index.js +7 -0
  34. package/lib/types/conversion/replay.d.ts +56 -0
  35. package/lib/types/conversion/replay.js +242 -0
  36. package/lib/types/conversion/stream.d.ts +46 -0
  37. package/lib/types/conversion/stream.js +203 -0
  38. package/lib/types/go-limits.d.ts +41 -0
  39. package/lib/types/go-limits.js +79 -0
  40. package/lib/types/index.d.ts +54 -0
  41. package/lib/types/index.js +195 -0
  42. package/lib/types/models.d.ts +90 -0
  43. package/lib/types/models.js +86 -0
  44. package/lib/types/remotes.d.ts +12 -0
  45. package/lib/types/remotes.js +28 -0
  46. package/lib/types/session-header.d.ts +36 -0
  47. package/lib/types/session-header.js +45 -0
  48. package/lib/types/usage/contract.d.ts +39 -0
  49. package/lib/types/usage/contract.js +106 -0
  50. package/lib/types/usage/index.d.ts +11 -0
  51. package/lib/types/usage/index.js +8 -0
  52. package/lib/types/usage/meter.d.ts +53 -0
  53. package/lib/types/usage/meter.js +65 -0
  54. package/lib/types/usage/service.d.ts +48 -0
  55. package/lib/types/usage/service.js +74 -0
  56. package/lib/types/usage/windows.d.ts +51 -0
  57. package/lib/types/usage/windows.js +84 -0
  58. package/package.json +147 -0
@@ -0,0 +1,84 @@
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
+ import { attributionHeaders, LlmAdapter } from '@deepseek-ai/dsh-llm';
16
+ import type { GenerateOptions, ImageAttachmentAccess, LlmModelInfo, LlmResolvedModelInfo, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm';
17
+ import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment';
18
+ import { OpencodeGoCatalog } from './catalog/index.ts';
19
+ import type { OpencodeGoConfig } from './config.ts';
20
+ /** Image machinery the adapter reads once per request. */
21
+ export interface OpencodeGoImageAccess {
22
+ /** Resolve the durable attachment service at request time. */
23
+ readonly resolveAttachments: () => AttachmentStore | undefined;
24
+ /** Bridge one attachment reference into the current tool execution world. */
25
+ readonly resolveImageAccess: (attachments: AttachmentStore, ref: ImageAttachmentRef) => ImageAttachmentAccess | undefined;
26
+ }
27
+ /** Constructor inputs for {@link OpencodeGoAdapter}. */
28
+ export interface OpencodeGoAdapterOptions {
29
+ /** Current configuration, re-read at every operation. */
30
+ readonly config: () => OpencodeGoConfig;
31
+ /** Resolve the route credential; a miss must fail loud, never fall back. */
32
+ readonly resolveApiKey: () => Promise<string | undefined>;
33
+ /** Image input machinery; absent refuses image content. */
34
+ readonly imageAccess?: OpencodeGoImageAccess;
35
+ /** Observe a catalog source falling back to retained data. */
36
+ readonly onFallback?: (detail: {
37
+ url: string;
38
+ error: unknown;
39
+ kept: number;
40
+ }) => void;
41
+ /** Observe gateway models this build cannot configure. */
42
+ readonly onUnconfigured?: (detail: {
43
+ id: string;
44
+ reason: string;
45
+ }[]) => void;
46
+ /** Observe assistant history degrading to provider-neutral conversion. */
47
+ readonly onReplayDegrade?: (reason: string) => void;
48
+ /**
49
+ * Observe the provider's own usage for one completed call. This is the only
50
+ * honest source of token counts: the gateway's `/usage` endpoint reports
51
+ * account percentages, not tokens.
52
+ */
53
+ readonly onUsage?: (detail: {
54
+ model: string;
55
+ usage: TokenUsage;
56
+ }) => void;
57
+ }
58
+ /**
59
+ * The single `opencode-go` route's adapter.
60
+ *
61
+ * The catalog is cached per endpoint, refresh interval, and protocol-override
62
+ * set: changing any of those builds a new one, while a settings change that
63
+ * touches none of them keeps the cached snapshot for its full TTL.
64
+ */
65
+ export declare class OpencodeGoAdapter extends LlmAdapter {
66
+ private cache;
67
+ private readonly options;
68
+ constructor(options: OpencodeGoAdapterOptions);
69
+ /** The catalog for one configuration, rebuilt when its owned facts change. */
70
+ catalogOf(config: OpencodeGoConfig): OpencodeGoCatalog;
71
+ providerInfo(provider: string): {
72
+ id: string;
73
+ name: string;
74
+ };
75
+ listModels(_provider: string): Promise<readonly LlmModelInfo[]>;
76
+ resolveModel(_provider: string, model: string, _signal?: AbortSignal): Promise<LlmResolvedModelInfo>;
77
+ /** Describe one model: capacities plus the reasoning levels it really offers. */
78
+ private modelInfo;
79
+ /** Validate an explicit effort against the model's own levels, without clamping. */
80
+ private resolveReasoningLevel;
81
+ stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
82
+ }
83
+ /** Re-exported so a composition can assert the attribution contract in one place. */
84
+ export { attributionHeaders };
@@ -0,0 +1,311 @@
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
+ import { getSupportedThinkingLevels, normalizeContext } from '@earendil-works/pi-ai';
68
+ import { attributionHeaders, contentHasImage, LlmAdapter, LlmError, ReasoningEffortId, } from '@deepseek-ai/dsh-llm';
69
+ import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout';
70
+ import { DISPLAY_NAME, PROVIDER_ID, OpencodeGoCatalog } from "./catalog/index.js";
71
+ import { toPiModel } from "./catalog/metadata.js";
72
+ import { assertBaseURL } from "./config.js";
73
+ import { toPiContext, toStreamChunks } from "./conversion/index.js";
74
+ import { isModelEnabled, recommendedIds } from "./models.js";
75
+ import { providerHeaders } from "./session-header.js";
76
+ /**
77
+ * pi-ai thinking formats that answer an unset effort with an explicit disable.
78
+ * Every other format omits the parameter and lets the provider decide, so only
79
+ * these need a default effort to avoid silently turning thinking off.
80
+ */
81
+ const DISABLES_THINKING_WHEN_UNSET = new Set(['deepseek', 'zai', 'qwen', 'qwen-chat-template']);
82
+ /** Apply one request's configured capacities without touching the catalog. */
83
+ function withModelLimit(model, limits) {
84
+ const limit = limits[model.id];
85
+ if (limit === null || limit === undefined)
86
+ return model;
87
+ return {
88
+ ...model,
89
+ contextWindow: limit.contextWindow ?? model.contextWindow,
90
+ maxTokens: limit.maxTokens ?? model.maxTokens,
91
+ };
92
+ }
93
+ /**
94
+ * The single `opencode-go` route's adapter.
95
+ *
96
+ * The catalog is cached per endpoint, refresh interval, and protocol-override
97
+ * set: changing any of those builds a new one, while a settings change that
98
+ * touches none of them keeps the cached snapshot for its full TTL.
99
+ */
100
+ export class OpencodeGoAdapter extends LlmAdapter {
101
+ cache;
102
+ options;
103
+ constructor(options) {
104
+ super();
105
+ this.options = options;
106
+ }
107
+ /** The catalog for one configuration, rebuilt when its owned facts change. */
108
+ catalogOf(config) {
109
+ const key = `${config.baseURL}|${String(config.refreshMinutes)}|${JSON.stringify(config.modelProtocols)}`;
110
+ if (this.cache?.key !== key) {
111
+ this.cache = {
112
+ key,
113
+ catalog: new OpencodeGoCatalog({
114
+ baseURL: assertBaseURL(config.baseURL),
115
+ refreshMs: config.refreshMinutes * 60_000,
116
+ defaults: {
117
+ contextWindow: 262_144,
118
+ maxTokens: 32_768,
119
+ input: ['text'],
120
+ },
121
+ overrides: config.modelProtocols,
122
+ observers: {
123
+ ...this.options.onFallback === undefined ? {} : { onFallback: this.options.onFallback },
124
+ ...this.options.onUnconfigured === undefined ? {} : { onUnconfigured: this.options.onUnconfigured },
125
+ },
126
+ }),
127
+ };
128
+ }
129
+ return this.cache.catalog;
130
+ }
131
+ providerInfo(provider) {
132
+ return { id: provider, name: DISPLAY_NAME };
133
+ }
134
+ async listModels(_provider) {
135
+ const config = this.options.config();
136
+ const snapshot = await this.catalogOf(config).snapshot();
137
+ // DSH resolves every listed model before showing the provider, so an
138
+ // unconfigurable id belongs in the settings diagnostics, not this list.
139
+ const facts = [...snapshot.facts.values()];
140
+ // The same default the settings page marks: without explicit switches the
141
+ // top few by published monthly estimate stay enabled.
142
+ const recommended = recommendedIds(facts);
143
+ return facts
144
+ .map(fact => ({ ...fact, recommended: recommended.has(fact.id) }))
145
+ .filter(fact => isModelEnabled(fact, config.modelVisibility))
146
+ .map(fact => ({
147
+ provider: PROVIDER_ID,
148
+ id: fact.id,
149
+ name: fact.name,
150
+ inputModalities: [...fact.input],
151
+ }));
152
+ }
153
+ async resolveModel(_provider, model, _signal) {
154
+ const config = this.options.config();
155
+ const snapshot = await this.catalogOf(config).forModel(model);
156
+ const facts = snapshot.facts.get(model);
157
+ if (facts === undefined)
158
+ throw new LlmError(`opencode-go has no model "${model}"`, 'UNKNOWN_MODEL');
159
+ return this.modelInfo(withModelLimit(toPiModel(facts, config.baseURL), config.modelLimits));
160
+ }
161
+ /** Describe one model: capacities plus the reasoning levels it really offers. */
162
+ modelInfo(model) {
163
+ const reasoning = {};
164
+ const levels = model.reasoning ? getSupportedThinkingLevels(model) : [];
165
+ if (levels.length > 0) {
166
+ // A format that disables thinking when no effort is named would silently
167
+ // strip reasoning from a model that offers levels, so it gets a default.
168
+ const format = model.compat?.thinkingFormat;
169
+ const fallback = format !== undefined && DISABLES_THINKING_WHEN_UNSET.has(format)
170
+ ? levels.includes('high') ? 'high' : levels.findLast(level => level !== 'off')
171
+ : undefined;
172
+ reasoning.reasoning = {
173
+ efforts: levels.map(level => ({
174
+ id: ReasoningEffortId(level),
175
+ name: `${level.charAt(0).toUpperCase()}${level.slice(1)}`,
176
+ })),
177
+ ...fallback === undefined ? {} : { defaultEffort: ReasoningEffortId(fallback) },
178
+ };
179
+ }
180
+ return {
181
+ provider: PROVIDER_ID,
182
+ id: model.id,
183
+ name: model.name,
184
+ inputModalities: [...model.input],
185
+ context: { contextWindow: model.contextWindow },
186
+ ...reasoning,
187
+ };
188
+ }
189
+ /** Validate an explicit effort against the model's own levels, without clamping. */
190
+ resolveReasoningLevel(model, effort) {
191
+ if (effort === undefined)
192
+ return undefined;
193
+ const supported = getSupportedThinkingLevels(model);
194
+ if (supported.some(level => level === effort))
195
+ return effort;
196
+ throw new LlmError(`opencode-go model "${model.id}" does not support reasoning effort "${effort}"`, 'UNSUPPORTED_REASONING_EFFORT');
197
+ }
198
+ async *stream(options) {
199
+ const env_1 = { stack: [], error: void 0, hasError: false };
200
+ try {
201
+ if (options.stop !== undefined) {
202
+ throw new LlmError('dshopencodego does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION');
203
+ }
204
+ const config = this.options.config();
205
+ const snapshot = await this.catalogOf(config).forModel(options.model);
206
+ const facts = snapshot.facts.get(options.model);
207
+ if (facts === undefined)
208
+ throw new LlmError(`opencode-go has no model "${options.model}"`, 'UNKNOWN_MODEL');
209
+ const model = withModelLimit(toPiModel(facts, config.baseURL), config.modelLimits);
210
+ const outputLimit = config.modelLimits[model.id]?.maxTokens;
211
+ const maxTokens = outputLimit === null || outputLimit === undefined
212
+ ? options.maxTokens
213
+ : Math.min(options.maxTokens ?? outputLimit, outputLimit);
214
+ const apiKey = await this.options.resolveApiKey();
215
+ if (apiKey === undefined || apiKey.length === 0) {
216
+ throw new LlmError('dshopencodego: no credential resolved for the opencode-go route', 'MISSING_CREDENTIAL');
217
+ }
218
+ const reasoning = this.resolveReasoningLevel(model, options.reasoningEffort);
219
+ const consumer = new AbortController();
220
+ const upstream = options.signal === undefined ? consumer.signal : AbortSignal.any([options.signal, consumer.signal]);
221
+ const watchdog = __addDisposableResource(env_1, idleWatchdog(upstream, config.streamIdleTimeoutMs, 'LLM_STREAM_IDLE_TIMEOUT'), false);
222
+ try {
223
+ const containsImage = options.messages.some(message => contentHasImage(message.content));
224
+ if (containsImage && !model.input.includes('image')) {
225
+ throw new LlmError(`opencode-go model "${model.id}" does not support image input`, 'UNSUPPORTED_CONTENT');
226
+ }
227
+ let imageRequest;
228
+ if (containsImage) {
229
+ const access = this.options.imageAccess;
230
+ const store = access?.resolveAttachments();
231
+ if (access === undefined || store === undefined) {
232
+ throw new LlmError('dshopencodego image input requires the durable attachment service', 'UNSUPPORTED_CONTENT');
233
+ }
234
+ imageRequest = {
235
+ attachments: store,
236
+ resolveImageAccess: ref => access.resolveImageAccess(store, ref),
237
+ maxRequestImageBytes: config.maxRequestImageBytes,
238
+ requestImagePolicy: { maxPixels: config.requestImagePixelBudget, maxBytes: config.requestImageMaxBytes },
239
+ };
240
+ }
241
+ const context = imageRequest === undefined
242
+ ? await toPiContext(options, undefined, this.options.onReplayDegrade)
243
+ : await toPiContext({ ...options, signal: watchdog.signal }, imageRequest, this.options.onReplayDegrade);
244
+ const events = snapshot.provider.streamSimple(model, normalizeContext(context), {
245
+ apiKey,
246
+ ...reasoning === undefined || reasoning === 'off' ? {} : { reasoning },
247
+ ...options.temperature === undefined ? {} : { temperature: options.temperature },
248
+ ...maxTokens === undefined ? {} : { maxTokens },
249
+ ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) },
250
+ signal: watchdog.signal,
251
+ // The gateway refuses a request without `x-opencode-session`; the
252
+ // attribution user-agent rides along on the same object.
253
+ headers: providerHeaders(options.sessionId === undefined ? undefined : String(options.sessionId)),
254
+ // The agent recovery layer owns visible attempts; one adapter call is
255
+ // one SDK attempt.
256
+ maxRetries: 0,
257
+ });
258
+ const iterator = toStreamChunks(events, model.contextWindow, options.signal, model.id)[Symbol.asyncIterator]();
259
+ let exhausted = false;
260
+ try {
261
+ while (true) {
262
+ const result = await watchdog.next(iterator);
263
+ if (timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT') !== undefined) {
264
+ throw new LlmError('opencode-go stream idle timeout', 'TIMEOUT');
265
+ }
266
+ if (result.done) {
267
+ exhausted = true;
268
+ return;
269
+ }
270
+ if (result.value.type === 'usage') {
271
+ this.options.onUsage?.({ model: options.model, usage: result.value.usage });
272
+ }
273
+ yield result.value;
274
+ }
275
+ }
276
+ finally {
277
+ if (!exhausted) {
278
+ consumer.abort('opencode-go stream consumer stopped');
279
+ try {
280
+ await iterator.return(undefined);
281
+ }
282
+ catch (_abortedSdkTeardown) {
283
+ // The stable signal already owns SDK termination.
284
+ }
285
+ }
286
+ }
287
+ }
288
+ catch (error) {
289
+ if (timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT') !== undefined) {
290
+ throw new LlmError('opencode-go stream idle timeout', 'TIMEOUT', { cause: error });
291
+ }
292
+ if (options.signal?.aborted) {
293
+ throw new LlmError('opencode-go request aborted by caller', 'ABORTED', { cause: error });
294
+ }
295
+ throw error;
296
+ }
297
+ finally {
298
+ consumer.abort('opencode-go stream consumer stopped');
299
+ }
300
+ }
301
+ catch (e_1) {
302
+ env_1.error = e_1;
303
+ env_1.hasError = true;
304
+ }
305
+ finally {
306
+ __disposeResources(env_1);
307
+ }
308
+ }
309
+ }
310
+ /** Re-exported so a composition can assert the attribution contract in one place. */
311
+ export { attributionHeaders };
@@ -0,0 +1,16 @@
1
+ /** Shared identities and endpoints for the OpenCode Go route. */
2
+ /** DSH provider route this plugin owns. */
3
+ export declare const PROVIDER_ID = "opencode-go";
4
+ /** Selector label for the route. */
5
+ export declare const DISPLAY_NAME = "OpenCode Go";
6
+ /** Gateway base URL; `/models` and `/usage` hang off it. */
7
+ export declare const DEFAULT_BASE_URL = "https://opencode.ai/zen/go/v1";
8
+ /** Online model metadata: capability, lifecycle, and pricing per model id. */
9
+ export declare const MODEL_METADATA_URL = "https://models.dev/api.json";
10
+ /** The models.dev provider key this plugin reads. */
11
+ export declare const MODEL_METADATA_PROVIDER = "opencode-go";
12
+ /** Timeout for the two metadata GETs. */
13
+ export declare const METADATA_FETCH_TIMEOUT_MS = 10000;
14
+ /** Response caps: the listing is tiny, the metadata document is not. */
15
+ export declare const MODEL_LISTING_MAX_BYTES: number;
16
+ export declare const MODEL_METADATA_MAX_BYTES: number;
@@ -0,0 +1,16 @@
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/zen/go/v1';
8
+ /** Online model metadata: capability, lifecycle, and pricing per model id. */
9
+ export const MODEL_METADATA_URL = 'https://models.dev/api.json';
10
+ /** The models.dev provider key this plugin reads. */
11
+ export const MODEL_METADATA_PROVIDER = 'opencode-go';
12
+ /** Timeout for the two metadata GETs. */
13
+ export const METADATA_FETCH_TIMEOUT_MS = 10_000;
14
+ /** Response caps: the listing is tiny, the metadata document is not. */
15
+ export const MODEL_LISTING_MAX_BYTES = 1024 * 1024;
16
+ export const MODEL_METADATA_MAX_BYTES = 16 * 1024 * 1024;
@@ -0,0 +1,26 @@
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 type { RemoteResult, TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol';
13
+ import type { CatalogReading } from './reading.ts';
14
+ export type { CatalogReading } from './reading.ts';
15
+ /** Validate a catalog reading crossing the wire. */
16
+ export declare function parseCatalogReading(value: unknown): CatalogReading;
17
+ declare module '@deepseek-ai/dsh-typert-protocol' {
18
+ interface TypertRemoteNamespaceMap {
19
+ opencodeGoCatalog: {
20
+ read(): Promise<RemoteResult<CatalogReading>>;
21
+ refresh(): Promise<RemoteResult<CatalogReading>>;
22
+ };
23
+ }
24
+ }
25
+ /** Remote methods the catalog owns. */
26
+ export declare const catalogRemote: TypertRemoteContribution;
@@ -0,0 +1,131 @@
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
+ function parseGoQuota(value) {
13
+ if (value === null || typeof value !== 'object')
14
+ throw new Error('invalid go quota');
15
+ const row = value;
16
+ const usd = row['monthlyUsd'];
17
+ if (usd !== 'unlimited' && (typeof usd !== 'number' || !Number.isFinite(usd))) {
18
+ throw new Error('invalid go quota monthlyUsd');
19
+ }
20
+ const requests = row['monthlyRequests'];
21
+ if (requests !== undefined && requests !== 'unlimited'
22
+ && (typeof requests !== 'number' || !Number.isFinite(requests))) {
23
+ throw new Error('invalid go quota monthlyRequests');
24
+ }
25
+ return {
26
+ monthlyUsd: usd,
27
+ ...requests === undefined ? {} : { monthlyRequests: requests },
28
+ };
29
+ }
30
+ function parseCost(value) {
31
+ if (value === null || typeof value !== 'object')
32
+ throw new Error('invalid model cost');
33
+ const row = value;
34
+ const rate = (key) => {
35
+ const entry = row[key];
36
+ if (typeof entry !== 'number' || !Number.isFinite(entry) || entry < 0)
37
+ throw new Error(`invalid model cost "${key}"`);
38
+ return entry;
39
+ };
40
+ return {
41
+ input: rate('input'),
42
+ output: rate('output'),
43
+ ...typeof row['cacheRead'] === 'number' ? { cacheRead: rate('cacheRead') } : {},
44
+ ...typeof row['cacheWrite'] === 'number' ? { cacheWrite: rate('cacheWrite') } : {},
45
+ };
46
+ }
47
+ function parseModel(value) {
48
+ if (value === null || typeof value !== 'object')
49
+ throw new Error('invalid catalog model');
50
+ const row = value;
51
+ if (typeof row['id'] !== 'string' || row['id'].length === 0)
52
+ throw new Error('invalid catalog model id');
53
+ return {
54
+ id: row['id'],
55
+ name: typeof row['name'] === 'string' ? row['name'] : row['id'],
56
+ ...typeof row['contextWindow'] === 'number' ? { contextWindow: row['contextWindow'] } : {},
57
+ ...typeof row['maxInputTokens'] === 'number' ? { maxInputTokens: row['maxInputTokens'] } : {},
58
+ ...typeof row['maxTokens'] === 'number' ? { maxTokens: row['maxTokens'] } : {},
59
+ ...typeof row['deprecated'] === 'boolean' ? { deprecated: row['deprecated'] } : {},
60
+ ...typeof row['releaseDate'] === 'string' ? { releaseDate: row['releaseDate'] } : {},
61
+ ...row['goQuota'] === undefined ? {} : { goQuota: parseGoQuota(row['goQuota']) },
62
+ ...row['cost'] === undefined ? {} : { cost: parseCost(row['cost']) },
63
+ ...row['protocolSource'] === 'builtin' || row['protocolSource'] === 'online'
64
+ || row['protocolSource'] === 'inferred' || row['protocolSource'] === 'override'
65
+ ? { protocolSource: row['protocolSource'] } : {},
66
+ ...typeof row['assumedLimits'] === 'boolean' ? { assumedLimits: row['assumedLimits'] } : {},
67
+ ...typeof row['configurationMissing'] === 'string' ? { configurationMissing: row['configurationMissing'] } : {},
68
+ };
69
+ }
70
+ /** Validate a catalog reading crossing the wire. */
71
+ export function parseCatalogReading(value) {
72
+ if (value === null || typeof value !== 'object')
73
+ throw new Error('invalid catalog reading');
74
+ const row = value;
75
+ const counts = row['counts'];
76
+ if (!Array.isArray(row['models']) || typeof row['stale'] !== 'boolean'
77
+ || typeof row['fetchedAtMs'] !== 'number' || counts === null || typeof counts !== 'object') {
78
+ throw new Error('invalid catalog reading');
79
+ }
80
+ const numbers = counts;
81
+ const count = (key) => {
82
+ const entry = numbers[key];
83
+ if (typeof entry !== 'number' || !Number.isFinite(entry) || entry < 0)
84
+ throw new Error(`invalid catalog count "${key}"`);
85
+ return entry;
86
+ };
87
+ return {
88
+ models: row['models'].map(parseModel),
89
+ stale: row['stale'],
90
+ ...typeof row['error'] === 'string' ? { error: row['error'] } : {},
91
+ fetchedAtMs: row['fetchedAtMs'],
92
+ counts: {
93
+ total: count('total'),
94
+ enabled: count('enabled'),
95
+ deprecated: count('deprecated'),
96
+ unconfigured: count('unconfigured'),
97
+ inferred: count('inferred'),
98
+ },
99
+ };
100
+ }
101
+ /** Codec shape accepted by both released and source DSH builds. */
102
+ const codec = {
103
+ mode: 'strict',
104
+ typeSymbol: '@dan-ai-studio/dshopencodego#CatalogReading',
105
+ schema: { parse: parseCatalogReading },
106
+ create: () => ({ parse: parseCatalogReading }),
107
+ };
108
+ /** Remote methods the catalog owns. */
109
+ export const catalogRemote = {
110
+ package: '@dan-ai-studio/dshopencodego',
111
+ descriptors: [
112
+ {
113
+ id: '@dan-ai-studio/dshopencodego#opencodeGoCatalog/read',
114
+ service: 'opencodeGoCatalog',
115
+ namespace: 'opencodeGoCatalog',
116
+ method: 'read',
117
+ invocation: { kind: 'direct' },
118
+ parameters: [],
119
+ result: codec,
120
+ },
121
+ {
122
+ id: '@dan-ai-studio/dshopencodego#opencodeGoCatalog/refresh',
123
+ service: 'opencodeGoCatalog',
124
+ namespace: 'opencodeGoCatalog',
125
+ method: 'refresh',
126
+ invocation: { kind: 'direct' },
127
+ parameters: [],
128
+ result: codec,
129
+ },
130
+ ],
131
+ };
@@ -0,0 +1,20 @@
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
+ /** Read the ids out of a model-listing body. */
11
+ export declare function readModelIds(body: unknown): readonly string[];
12
+ /**
13
+ * Fetch the ids the gateway currently advertises.
14
+ * @param baseURL - normalized gateway base without a trailing slash.
15
+ * @param signal - caller cancellation, if any.
16
+ * @returns the advertised ids in endpoint order, deduplicated.
17
+ * @throws {LlmError} `DISCOVERY_FAILED` when the endpoint is unreachable, not
18
+ * OK, or does not answer a model listing.
19
+ */
20
+ export declare function fetchModelIds(baseURL: string, signal?: AbortSignal): Promise<readonly string[]>;
@@ -0,0 +1,59 @@
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
+ }