@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.
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 +288 -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 +20 -21
  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,192 +0,0 @@
1
- /**
2
- * The provider's own Go documentation as a data source.
3
- *
4
- * No API exposes the per-model allowances: the gateway's `/models` answers ids,
5
- * and `/usage` answers account-wide percentages. The numbers live on a docs page
6
- * whose source is a markdown file in the provider's repository, so this module
7
- * fetches that file and parses its tables — allowances (with the provider's own
8
- * request estimates) and the endpoint table that names each model's protocol.
9
- *
10
- * The document is the primary source. The transcribed table in `go-limits.ts`
11
- * stays behind it as a startup seed and a permanent fallback for the day the
12
- * URL moves, and its age is surfaced wherever it is used.
13
- *
14
- * @module @dan-ai-studio/dshopencodego/catalog/go-doc
15
- */
16
- import { attributionHeaders, LlmError } from '@deepseek-ai/dsh-llm';
17
- import { METADATA_FETCH_TIMEOUT_MS } from './constants.js';
18
- import { readBoundedText } from './json-response.js';
19
- /**
20
- * Documentation sources, tried in order.
21
- *
22
- * The repository file is the source of truth; the jsDelivr entry caches the
23
- * same path and is tried when GitHub itself is unreachable.
24
- */
25
- export const GO_DOC_URLS = [
26
- 'https://raw.githubusercontent.com/anomalyco/opencode/dev/packages/web/src/content/docs/go.mdx',
27
- 'https://cdn.jsdelivr.net/gh/anomalyco/opencode@dev/packages/web/src/content/docs/go.mdx',
28
- ];
29
- /** The document is about 30 KB; the cap leaves room without inviting a flood. */
30
- const GO_DOC_MAX_BYTES = 1024 * 1024;
31
- /** The protocol each documented endpoint path names. */
32
- const ENDPOINT_PROTOCOLS = {
33
- '/chat/completions': 'openai-completions',
34
- '/messages': 'anthropic-messages',
35
- '/responses': 'openai-responses',
36
- };
37
- /** Strip markdown decoration (`**bold**`, `` `code` ``) from one cell. */
38
- function cell(value) {
39
- return (value ?? '').replace(/[*`]/g, '').trim();
40
- }
41
- /**
42
- * A display name without its tier suffix, so the three tables join.
43
- * `Qwen3.7 Plus (≤ 256K tokens)` and `DeepSeek V4 Pro (Peak)` both reduce to the
44
- * name the endpoint table carries.
45
- */
46
- function baseName(name) {
47
- return name.replace(/\s*\(.*\)\s*$/, '').trim();
48
- }
49
- /** `$60` → 60; `Unlimited 限时` → `unlimited`; anything else → undefined. */
50
- function parseAllowance(value) {
51
- if (/^unlimited\b/i.test(value) || value.startsWith('无限制'))
52
- return 'unlimited';
53
- const match = value.match(/\$([\d,]+(?:\.\d+)?)/);
54
- if (match?.[1] === undefined)
55
- return undefined;
56
- const amount = Number(match[1].replace(/,/g, ''));
57
- return Number.isFinite(amount) ? amount : undefined;
58
- }
59
- /** `6,320` → 6320; `Unlimited` → `unlimited`; anything else → undefined. */
60
- function parseCount(value) {
61
- if (/^unlimited\b/i.test(value) || value.startsWith('无限制'))
62
- return 'unlimited';
63
- const amount = Number(value.replace(/,/g, ''));
64
- return value.length > 0 && Number.isFinite(amount) ? amount : undefined;
65
- }
66
- /**
67
- * The protocol a documented endpoint names.
68
- *
69
- * Matched by path suffix: the table spells full URLs, and every supported API
70
- * lives at a distinct tail (`/chat/completions`, `/messages`, `/responses`).
71
- */
72
- function protocolOfEndpoint(endpoint) {
73
- for (const [suffix, protocol] of Object.entries(ENDPOINT_PROTOCOLS)) {
74
- if (endpoint.endsWith(suffix))
75
- return protocol;
76
- }
77
- return undefined;
78
- }
79
- /**
80
- * The data rows of the table whose header carries `required`.
81
- *
82
- * Data starts after the header and its separator, and ends at the first line
83
- * that is not a table row, so three tables in one document stay apart.
84
- * @param lines - the document, split into lines.
85
- * @param required - a header cell that table alone carries.
86
- * @returns one array of trimmed cells per data row.
87
- */
88
- function tableRows(lines, required) {
89
- const start = lines.findIndex(line => line.startsWith('|') && line.includes(required));
90
- if (start < 0)
91
- return [];
92
- const rows = [];
93
- for (let index = start + 2; index < lines.length; index++) {
94
- const line = lines[index] ?? '';
95
- if (!line.startsWith('|'))
96
- break;
97
- rows.push(line.split('|').slice(1, -1).map(entry => entry.trim()));
98
- }
99
- return rows;
100
- }
101
- /**
102
- * Parse the Go documentation into the two data sets this plugin consumes.
103
- *
104
- * Titles are the join key: the allowance tables name models the way the page
105
- * presents them while the endpoint table carries the ids, so ids come from the
106
- * endpoint table and the other two attach by normalised display name. A row
107
- * that names no known model, or states no usable number, is skipped rather than
108
- * guessed; a tiered model whose rows disagree is dropped entirely.
109
- * @param markdown - the raw document text.
110
- * @returns the parsed allowances and protocols; both may be empty when the
111
- * document does not carry the expected tables.
112
- */
113
- export function parseGoDocument(markdown) {
114
- const lines = markdown.split('\n');
115
- const ids = new Map();
116
- const protocols = new Map();
117
- for (const row of tableRows(lines, 'Model ID')) {
118
- const name = cell(row[0]);
119
- const id = cell(row[1]);
120
- if (name.length === 0 || id.length === 0)
121
- continue;
122
- ids.set(name, id);
123
- const protocol = protocolOfEndpoint(cell(row[2]));
124
- if (protocol !== undefined)
125
- protocols.set(id, protocol);
126
- }
127
- const quotas = new Map();
128
- for (const row of tableRows(lines, 'Monthly limit')) {
129
- const id = ids.get(baseName(cell(row[0])));
130
- if (id === undefined)
131
- continue;
132
- const monthlyUsd = parseAllowance(cell(row[5]));
133
- if (monthlyUsd === undefined)
134
- continue;
135
- const previous = quotas.get(id);
136
- // Tiered rows (token bands, peak/off-peak) repeat one allowance; the first
137
- // row wins, and a disagreement drops the model instead of picking a band.
138
- if (previous === undefined)
139
- quotas.set(id, { monthlyUsd });
140
- else if (previous.monthlyUsd !== monthlyUsd)
141
- quotas.delete(id);
142
- }
143
- for (const row of tableRows(lines, 'requests per month')) {
144
- const id = ids.get(baseName(cell(row[0])));
145
- if (id === undefined)
146
- continue;
147
- const monthlyRequests = parseCount(cell(row[3]));
148
- const quota = quotas.get(id);
149
- if (monthlyRequests === undefined || quota === undefined)
150
- continue;
151
- quotas.set(id, { ...quota, monthlyRequests });
152
- }
153
- return { quotas, protocols };
154
- }
155
- /**
156
- * Fetch and parse the documentation, trying each source in turn.
157
- *
158
- * A source that answers without a usable allowance table counts as a failure,
159
- * not as an empty document: that is what a moved or restructured page looks
160
- * like, and the caller must keep its previous data rather than blank the page.
161
- * @param signal - caller cancellation, if any.
162
- * @returns the parsed document from the first source that carried one.
163
- * @throws {LlmError} `DOCUMENT_UNAVAILABLE` when every source failed.
164
- */
165
- export async function fetchGoDocument(signal) {
166
- const failures = [];
167
- for (const url of GO_DOC_URLS) {
168
- try {
169
- const timeout = AbortSignal.timeout(METADATA_FETCH_TIMEOUT_MS);
170
- const response = await fetch(url, {
171
- redirect: 'error',
172
- headers: { ...attributionHeaders(), accept: 'text/plain', 'cache-control': 'no-cache' },
173
- signal: signal === undefined ? timeout : AbortSignal.any([signal, timeout]),
174
- });
175
- if (!response.ok) {
176
- await response.body?.cancel().catch(() => { });
177
- failures.push(`${url} answered HTTP ${response.status}`);
178
- continue;
179
- }
180
- const document = parseGoDocument(await readBoundedText(response, GO_DOC_MAX_BYTES));
181
- if (document.quotas.size === 0) {
182
- failures.push(`${url} carried no allowance table`);
183
- continue;
184
- }
185
- return document;
186
- }
187
- catch (error) {
188
- failures.push(`${url}: ${error instanceof Error ? error.message : String(error)}`);
189
- }
190
- }
191
- throw new LlmError(`could not read the Go documentation (${failures.join('; ')})`, 'DOCUMENT_UNAVAILABLE');
192
- }
@@ -1,377 +0,0 @@
1
- /**
2
- * The live catalog: which models exist, and how to call each one.
3
- *
4
- * Membership comes from the gateway listing, capability from models.dev, and
5
- * exact protocol/wire quirks from the installed pi-ai catalog, merged through
6
- * the ladder in `./protocol.ts`. Per-model allowances, and a protocol for the
7
- * models nothing else covers, come from the provider's own documentation (see
8
- * `./go-doc.ts`). The snapshot is cached for the configured refresh interval,
9
- * and an unknown model id forces one revalidation even inside that interval —
10
- * that is what makes a model the gateway added this morning callable this
11
- * afternoon without a plugin release.
12
- *
13
- * Failures degrade rather than empty: a listing outage keeps the last known
14
- * models and marks the snapshot not-live, a metadata outage keeps the last
15
- * document, a documentation outage keeps the last parsed allowances (or leaves
16
- * the page on its frozen seed), and a single unparseable model is reported by
17
- * id while every other model keeps serving.
18
- *
19
- * @module @dan-ai-studio/dshopencodego/catalog
20
- */
21
- // Subpath imports on purpose. The package root re-exports `./api/lazy.js` (every
22
- // provider's wire implementation) and `providers/all.js` loads every provider's
23
- // model data — 991 KB under `providers/data` alone. This plugin mounts three
24
- // APIs for one provider, so each symbol is taken from the narrowest public entry
25
- // that exports it. Types still come from the root: they vanish at build time.
26
- import { createProvider } from '@earendil-works/pi-ai/models';
27
- import { OPENCODE_GO_MODELS } from '@earendil-works/pi-ai/providers/opencode-go.models';
28
- import { anthropicMessagesApi } from '@earendil-works/pi-ai/api/anthropic-messages.lazy';
29
- import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy';
30
- import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy';
31
- import { attributionHeaders, LlmError } from '@deepseek-ai/dsh-llm';
32
- import { DISPLAY_NAME, MODEL_METADATA_MAX_BYTES, MODEL_METADATA_URL, PROVIDER_ID, METADATA_FETCH_TIMEOUT_MS, } from './constants.js';
33
- import { fetchModelIds } from './gateway.js';
34
- import { fetchGoDocument, GO_DOC_URLS } from './go-doc.js';
35
- import { readBoundedJson } from './json-response.js';
36
- import { readOnlineMetadata, toPiModel } from './metadata.js';
37
- import { asWireProtocol } from './protocol.js';
38
- export { PROVIDER_ID, DISPLAY_NAME, DEFAULT_BASE_URL, CONSOLE_BASE_URL } from './constants.js';
39
- export { readModelIds, fetchModelIds } from './gateway.js';
40
- export { readOnlineMetadata, toPiModel, modelBaseURL } from './metadata.js';
41
- export { decideProtocol, inferProtocol, protocolOfNpm } from './protocol.js';
42
- /**
43
- * The installed catalog, re-pointed at the configured gateway.
44
- *
45
- * These entries are the first rung of the ladder and the outage fallback: their
46
- * `compat` is what keeps DeepSeek-family reasoning replay correct.
47
- */
48
- function builtinModels(baseURL) {
49
- // `OPENCODE_GO_MODELS` is the same table `getBuiltinModels('opencode-go')`
50
- // reads — verified entry for entry — reached without loading the aggregate
51
- // `models.generated.js` that carries every provider.
52
- return new Map(Object.values(OPENCODE_GO_MODELS).map(model => [model.id, {
53
- ...model,
54
- provider: PROVIDER_ID,
55
- baseUrl: modelBaseURLFor(model.api, baseURL),
56
- }]));
57
- }
58
- function modelBaseURLFor(api, baseURL) {
59
- const base = baseURL.replace(/\/+$/, '');
60
- return api === 'anthropic-messages' ? base.replace(/\/v1$/, '') : base;
61
- }
62
- /** The adapter resolves and passes the credential for every request. */
63
- function harnessApiKeyAuth() {
64
- return {
65
- apiKey: {
66
- name: 'OpenCode Go API key',
67
- resolve: () => Promise.resolve({ auth: {}, source: 'OpenCode Go API key' }),
68
- },
69
- };
70
- }
71
- /**
72
- * The installed catalog's protocol, when this plugin cannot serve it.
73
- *
74
- * `Model<Api>['api']` is pi-ai's wider union; this plugin mounts exactly the
75
- * three in `WIRE_PROTOCOLS`. The check exists because the catalog is wider
76
- * than the plugin and grows on its own release cadence.
77
- * @param api - the raw `api` string the installed catalog states.
78
- * @returns the unusable protocol, or undefined when there is nothing to refuse.
79
- */
80
- function unsupportedBuiltinApi(api) {
81
- return api === undefined || asWireProtocol(api) !== undefined ? undefined : api;
82
- }
83
- function buildProvider(baseURL, models) {
84
- return createProvider({
85
- id: PROVIDER_ID,
86
- name: DISPLAY_NAME,
87
- baseUrl: baseURL,
88
- auth: harnessApiKeyAuth(),
89
- models: [...models],
90
- api: {
91
- 'anthropic-messages': anthropicMessagesApi(),
92
- 'openai-completions': openAICompletionsApi(),
93
- 'openai-responses': openAIResponsesApi(),
94
- },
95
- });
96
- }
97
- /**
98
- * One gateway's catalog. Requests reuse a cached snapshot; explicit discovery
99
- * and unknown ids revalidate it.
100
- */
101
- export class OpencodeGoCatalog {
102
- options;
103
- served;
104
- pending;
105
- metadataDocument;
106
- metadataETag;
107
- lastMetadata;
108
- /** The last parsed documentation; retained across failed fetches. */
109
- document;
110
- constructor(options) {
111
- this.options = options;
112
- }
113
- /** The cached snapshot, refreshed when it is older than the configured TTL. */
114
- snapshot(force = false) {
115
- if (!force && this.served !== undefined && Date.now() - this.served.fetchedAtMs < this.options.refreshMs) {
116
- return Promise.resolve(this.served);
117
- }
118
- this.pending ??= this.build()
119
- .then((snapshot) => {
120
- this.served = snapshot;
121
- return snapshot;
122
- })
123
- .finally(() => { this.pending = undefined; });
124
- return this.pending;
125
- }
126
- /**
127
- * The snapshot that can serve one exact model.
128
- *
129
- * A model this build has never seen forces one revalidation even inside the
130
- * TTL, because "unknown" and "newly added" look identical from here.
131
- * @param id - the requested model id.
132
- * @returns the snapshot holding the model.
133
- * @throws {LlmError} `MODEL_METADATA_UNAVAILABLE` when the gateway advertises
134
- * the id but this build cannot configure it, naming the reason.
135
- */
136
- async forModel(id) {
137
- let snapshot = await this.snapshot();
138
- // A snapshot that cannot serve this id forces one revalidation, because
139
- // "this build has not seen the id" and "the id was just published" are
140
- // indistinguishable from here.
141
- //
142
- // The condition must not compare the snapshot with what was cached before
143
- // the await: on a cold catalog there is nothing cached, so that comparison
144
- // was false and the revalidation was skipped — an id missing from the
145
- // very first build then failed as UNKNOWN_MODEL instead of being retried.
146
- // Presence in `unavailable` is a positive statement (the gateway
147
- // advertises the id but this build cannot configure it), so it needs no
148
- // revalidation and is reported below.
149
- if (!snapshot.facts.has(id) && !snapshot.unavailable.has(id)) {
150
- snapshot = await this.snapshot(true);
151
- }
152
- const reason = snapshot.unavailable.get(id);
153
- if (reason !== undefined) {
154
- throw new LlmError(`opencode-go model "${id}" is advertised but cannot be configured: ${reason};`
155
- + ' refresh the model list to retry', 'MODEL_METADATA_UNAVAILABLE');
156
- }
157
- return snapshot;
158
- }
159
- /** Revalidate every source, reusing the metadata document when it is unchanged. */
160
- async build() {
161
- const builtin = builtinModels(this.options.baseURL);
162
- const readDocument = this.options.readDocument ?? fetchGoDocument;
163
- const [listing, metadata, documentation] = await Promise.allSettled([
164
- fetchModelIds(this.options.baseURL),
165
- this.refreshMetadata(builtin),
166
- readDocument(),
167
- ]);
168
- if (metadata.status === 'rejected') {
169
- this.options.observers?.onFallback?.({
170
- url: MODEL_METADATA_URL,
171
- error: metadata.reason,
172
- kept: this.lastMetadata?.models.size ?? 0,
173
- });
174
- }
175
- if (documentation.status === 'fulfilled') {
176
- this.document = documentation.value;
177
- }
178
- else {
179
- // Not a page-stale event: the allowances keep their previous source, and
180
- // the page says so wherever the frozen seed is in use.
181
- this.options.observers?.onFallback?.({
182
- url: GO_DOC_URLS[0] ?? 'the Go documentation',
183
- error: documentation.reason,
184
- kept: this.document?.quotas.size ?? 0,
185
- });
186
- }
187
- if (listing.status === 'rejected') {
188
- // Once observed, an outage must not resurrect retired models: the last
189
- // successful listing stays authoritative until a new one arrives.
190
- const previous = this.served;
191
- this.options.observers?.onFallback?.({
192
- url: `${this.options.baseURL.replace(/\/+$/, '')}/models`,
193
- error: listing.reason,
194
- kept: previous?.facts.size ?? 0,
195
- });
196
- const facts = previous?.facts ?? new Map();
197
- return {
198
- facts,
199
- unavailable: previous?.unavailable ?? new Map(),
200
- provider: buildProvider(this.options.baseURL, [...facts.values()].map(fact => toPiModel(fact, this.options.baseURL))),
201
- live: false,
202
- listingFailure: listing.reason,
203
- fetchedAtMs: Date.now(),
204
- ...this.documentQuotas(),
205
- };
206
- }
207
- // A metadata outage keeps the last successful parse: the previous document
208
- // is strictly better evidence than the installed catalog alone, because it
209
- // carries the capacities, modalities, and lifecycle the catalog may not.
210
- return this.assemble(listing.value, metadata.status === 'fulfilled' ? metadata.value : this.lastMetadata, builtin);
211
- }
212
- /** Merge the listing with the metadata document and the installed entries. */
213
- assemble(ids, online, builtin) {
214
- const facts = new Map();
215
- const unavailable = new Map();
216
- for (const id of ids) {
217
- const parsed = online?.models.get(id);
218
- if (parsed !== undefined) {
219
- facts.set(id, parsed);
220
- continue;
221
- }
222
- const reason = online?.errors.get(id);
223
- if (reason !== undefined) {
224
- // A malformed record is a data fault: report it rather than inventing
225
- // capacities, because the model is described and the description is wrong.
226
- unavailable.set(id, reason);
227
- continue;
228
- }
229
- // The installed catalog may name a protocol this plugin cannot mount.
230
- // pi-ai 1.3.0 labels `gemini-4-argon-contributor` as
231
- // `google-generative-ai`, which `buildProvider` has no entry for, and
232
- // the string used to reach the model list through an unchecked cast.
233
- // Listing it as usable would defer the failure to the first stream —
234
- // the one outcome this plugin refuses to ship — so it is refused here,
235
- // exactly like a malformed record.
236
- const unsupported = unsupportedBuiltinApi(builtin.get(id)?.api);
237
- if (unsupported !== undefined) {
238
- unavailable.set(id, `its wire protocol "${unsupported}" is not implemented by this plugin`);
239
- continue;
240
- }
241
- // No source describes this id. The gateway's own provider default
242
- // (`@ai-sdk/openai-compatible`) makes Chat Completions the best available
243
- // guess, and the route defaults size it; the settings surface marks it as
244
- // inferred so a wrong guess is visible and overridable.
245
- facts.set(id, this.withDocumentedProtocol(this.inferredFacts(id, builtin)));
246
- }
247
- if (unavailable.size > 0) {
248
- this.options.observers?.onUnconfigured?.([...unavailable].map(([id, reason]) => ({ id, reason })));
249
- }
250
- return {
251
- facts,
252
- unavailable,
253
- provider: buildProvider(this.options.baseURL, [...facts.values()].map(fact => toPiModel(fact, this.options.baseURL))),
254
- live: true,
255
- fetchedAtMs: Date.now(),
256
- ...this.documentQuotas(),
257
- };
258
- }
259
- /** The document allowances as a snapshot field, absent until one fetch lands. */
260
- documentQuotas() {
261
- return this.document === undefined ? {} : { documentQuotas: this.document.quotas };
262
- }
263
- /**
264
- * Prefer the documented protocol over anything below the installed catalog.
265
- *
266
- * The endpoint table is the provider's own statement, so it outranks both the
267
- * family guess and models.dev's SDK hint — this turns an `inferred` badge into
268
- * a known answer for ids the installed catalog misses. It never overrides a
269
- * builtin entry or a configured override: those carry the wire quirks the
270
- * adapter relies on.
271
- */
272
- withDocumentedProtocol(facts) {
273
- if (facts.protocolSource !== 'inferred' && facts.protocolSource !== 'online')
274
- return facts;
275
- const documented = this.document?.protocols.get(facts.id);
276
- return documented === undefined ? facts : { ...facts, api: documented, protocolSource: 'document' };
277
- }
278
- /** Facts for an id no source describes, from the ladder and the route defaults. */
279
- inferredFacts(id, builtin) {
280
- const exact = builtin.get(id);
281
- // Guarded for the same reason `assemble` refuses these ids up front: the
282
- // installed catalog states protocols this plugin cannot mount.
283
- const builtinApi = asWireProtocol(exact?.api);
284
- const api = builtinApi ?? inferFamily(id);
285
- const contextWindow = exact !== undefined ? exact.contextWindow : this.options.defaults.contextWindow;
286
- const maxTokens = exact !== undefined ? exact.maxTokens : this.options.defaults.maxTokens;
287
- return {
288
- id,
289
- name: exact?.name ?? id,
290
- api,
291
- protocolSource: builtinApi === undefined ? 'inferred' : 'builtin',
292
- contextWindow,
293
- maxInputTokens: undefined,
294
- maxTokens,
295
- assumedLimits: exact === undefined,
296
- input: exact?.input ?? this.options.defaults.input,
297
- inputModalities: undefined,
298
- reasoning: exact?.reasoning ?? false,
299
- thinkingLevelMap: exact?.thinkingLevelMap,
300
- compat: exact?.compat ?? {},
301
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
302
- deprecated: false,
303
- releaseDate: undefined,
304
- };
305
- }
306
- /** Conditional metadata GET: an unchanged document keeps its parsed form. */
307
- async refreshMetadata(builtin) {
308
- const response = await fetch(MODEL_METADATA_URL, {
309
- redirect: 'error',
310
- headers: {
311
- ...attributionHeaders(),
312
- accept: 'application/json',
313
- 'cache-control': 'no-cache',
314
- ...this.metadataETag === undefined ? {} : { 'if-none-match': this.metadataETag },
315
- },
316
- signal: AbortSignal.timeout(METADATA_FETCH_TIMEOUT_MS),
317
- });
318
- if (response.status === 304 && this.metadataDocument !== undefined) {
319
- await response.body?.cancel().catch(() => { });
320
- const parsed = readOnlineMetadata(this.metadataDocument, {
321
- builtin,
322
- defaults: this.options.defaults,
323
- overrides: this.options.overrides,
324
- });
325
- this.lastMetadata = parsed;
326
- return parsed;
327
- }
328
- if (!response.ok) {
329
- await response.body?.cancel().catch(() => { });
330
- throw new Error(`models.dev answered ${response.status}`);
331
- }
332
- const document = await readBoundedJson(response, MODEL_METADATA_URL, MODEL_METADATA_MAX_BYTES);
333
- this.metadataDocument = document;
334
- this.metadataETag = response.headers.get('etag') ?? undefined;
335
- const parsed = readOnlineMetadata(document, {
336
- builtin,
337
- defaults: this.options.defaults,
338
- overrides: this.options.overrides,
339
- });
340
- this.lastMetadata = parsed;
341
- return parsed;
342
- }
343
- }
344
- /** The family rule, kept beside the ladder so both stay in one vocabulary. */
345
- function inferFamily(id) {
346
- const lower = id.toLowerCase();
347
- return ['grok', 'gpt', 'muse'].some(family => lower.startsWith(family))
348
- ? 'openai-responses'
349
- : 'openai-completions';
350
- }
351
- /**
352
- * Interrogate the gateway for explicit discovery, bypassing the TTL.
353
- * @param catalog - the catalog to revalidate.
354
- * @returns the advertised models, including the unconfigured ones so the
355
- * settings surface can explain what is missing.
356
- * @throws {LlmError} `DISCOVERY_FAILED` when the listing itself failed.
357
- */
358
- export async function discoverCatalogModels(catalog) {
359
- const snapshot = await catalog.snapshot(true);
360
- if (!snapshot.live) {
361
- const detail = snapshot.listingFailure instanceof LlmError
362
- ? snapshot.listingFailure.message
363
- : 'the live model listing is unreachable';
364
- throw new LlmError(`dshopencodego: ${detail}; refresh the model list to retry`, 'DISCOVERY_FAILED', {
365
- cause: snapshot.listingFailure,
366
- });
367
- }
368
- return [
369
- ...[...snapshot.facts.values()].map(fact => ({
370
- id: fact.id,
371
- name: fact.name,
372
- contextWindow: fact.contextWindow,
373
- maxTokens: fact.maxTokens,
374
- })),
375
- ...[...snapshot.unavailable].map(([id, reason]) => ({ id, name: `${id} (unconfigured: ${reason})` })),
376
- ];
377
- }
@@ -1,73 +0,0 @@
1
- /**
2
- * Bounded body reads shared by the catalog, the usage endpoint, and the Go
3
- * documentation fetch.
4
- *
5
- * Every outbound metadata read is capped before parsing: the documents are
6
- * third-party, and a truncated or oversized reply must surface as a named
7
- * failure rather than as memory growth.
8
- *
9
- * @module @dan-ai-studio/dshopencodego/catalog/json-response
10
- */
11
- import { LlmError } from '@deepseek-ai/dsh-llm';
12
- /**
13
- * Read a response body as JSON without trusting its length.
14
- * @param response - an OK response whose body is the document.
15
- * @param url - the URL, for diagnostics.
16
- * @param maxBytes - hard cap on the decoded document.
17
- * @returns the parsed JSON value.
18
- * @throws {LlmError} `DISCOVERY_FAILED` on a transport fault, an over-cap body,
19
- * or invalid JSON.
20
- */
21
- export async function readBoundedJson(response, url, maxBytes) {
22
- const declared = Number(response.headers.get('content-length') ?? Number.NaN);
23
- if (Number.isFinite(declared) && declared > maxBytes) {
24
- await response.body?.cancel().catch(() => { });
25
- throw new LlmError(`${url} declares ${declared} bytes, over the ${maxBytes}-byte cap`, 'DISCOVERY_FAILED');
26
- }
27
- let text;
28
- try {
29
- text = await readBoundedText(response, maxBytes);
30
- }
31
- catch (error) {
32
- if (error instanceof LlmError)
33
- throw error;
34
- throw new LlmError(`could not read ${url}`, 'DISCOVERY_FAILED', { cause: error });
35
- }
36
- try {
37
- return JSON.parse(text);
38
- }
39
- catch (error) {
40
- throw new LlmError(`${url} did not answer with JSON`, 'DISCOVERY_FAILED', { cause: error });
41
- }
42
- }
43
- /** Decode a body as text, aborting once it passes the cap. */
44
- export async function readBoundedText(response, maxBytes) {
45
- if (response.body === null)
46
- return '';
47
- const reader = response.body.getReader();
48
- const chunks = [];
49
- let total = 0;
50
- try {
51
- while (true) {
52
- const { done, value } = await reader.read();
53
- if (done)
54
- break;
55
- total += value.byteLength;
56
- if (total > maxBytes) {
57
- await reader.cancel().catch(() => { });
58
- throw new LlmError(`response exceeds the ${maxBytes}-byte cap`, 'DISCOVERY_FAILED');
59
- }
60
- chunks.push(value);
61
- }
62
- }
63
- finally {
64
- reader.releaseLock();
65
- }
66
- const merged = new Uint8Array(total);
67
- let offset = 0;
68
- for (const chunk of chunks) {
69
- merged.set(chunk, offset);
70
- offset += chunk.byteLength;
71
- }
72
- return new TextDecoder().decode(merged);
73
- }