@dan-ai-studio/dshopencodego 0.1.43 → 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.
- package/README.en.md +48 -28
- package/README.md +47 -27
- package/lib/client.js +47 -1
- package/lib/index.js +321 -123
- package/lib/types/adapter.d.ts +26 -3
- package/lib/types/catalog/go-doc.d.ts +9 -5
- package/lib/types/catalog/index.d.ts +22 -7
- package/lib/types/catalog/service.d.ts +44 -1
- package/lib/types/config.d.ts +4 -3
- package/lib/types/conversion/replay.d.ts +30 -1
- package/lib/types/models.d.ts +14 -0
- package/lib/types/usage/meter.d.ts +89 -2
- package/package.json +20 -21
- package/lib/types/adapter.js +0 -384
- package/lib/types/catalog/constants.js +0 -18
- package/lib/types/catalog/contract.js +0 -147
- package/lib/types/catalog/gateway.js +0 -59
- package/lib/types/catalog/go-doc.js +0 -192
- package/lib/types/catalog/index.js +0 -377
- package/lib/types/catalog/json-response.js +0 -73
- package/lib/types/catalog/metadata.js +0 -278
- package/lib/types/catalog/protocol.js +0 -87
- package/lib/types/catalog/reading.js +0 -73
- package/lib/types/catalog/service.js +0 -45
- package/lib/types/config.js +0 -91
- package/lib/types/conversion/context.js +0 -212
- package/lib/types/conversion/index.js +0 -7
- package/lib/types/conversion/replay.js +0 -242
- package/lib/types/conversion/stream.js +0 -221
- package/lib/types/go-limits.js +0 -91
- package/lib/types/index.js +0 -198
- package/lib/types/models.js +0 -86
- package/lib/types/remotes.js +0 -29
- package/lib/types/session-header.js +0 -49
- package/lib/types/update/check.js +0 -138
- package/lib/types/update/contract.js +0 -64
- package/lib/types/update/index.js +0 -10
- package/lib/types/update/service.js +0 -39
- package/lib/types/update/version.js +0 -16
- package/lib/types/usage/contract.js +0 -106
- package/lib/types/usage/index.js +0 -8
- package/lib/types/usage/meter.js +0 -65
- package/lib/types/usage/service.js +0 -74
- package/lib/types/usage/windows.js +0 -84
|
@@ -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
|
-
}
|