@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.
- package/LICENSE +21 -0
- package/README.en.md +156 -0
- package/README.md +156 -0
- package/cordis.patch.yml +3 -0
- package/lib/build-info.json +11 -0
- package/lib/client.js +1215 -0
- package/lib/index.js +2036 -0
- package/lib/types/adapter.d.ts +84 -0
- package/lib/types/adapter.js +311 -0
- package/lib/types/catalog/constants.d.ts +16 -0
- package/lib/types/catalog/constants.js +16 -0
- package/lib/types/catalog/contract.d.ts +26 -0
- package/lib/types/catalog/contract.js +131 -0
- package/lib/types/catalog/gateway.d.ts +20 -0
- package/lib/types/catalog/gateway.js +59 -0
- package/lib/types/catalog/index.d.ts +108 -0
- package/lib/types/catalog/index.js +288 -0
- package/lib/types/catalog/json-response.d.ts +19 -0
- package/lib/types/catalog/json-response.js +72 -0
- package/lib/types/catalog/metadata.d.ts +73 -0
- package/lib/types/catalog/metadata.js +259 -0
- package/lib/types/catalog/protocol.d.ts +65 -0
- package/lib/types/catalog/protocol.js +87 -0
- package/lib/types/catalog/reading.d.ts +41 -0
- package/lib/types/catalog/reading.js +68 -0
- package/lib/types/catalog/service.d.ts +32 -0
- package/lib/types/catalog/service.js +45 -0
- package/lib/types/config.d.ts +93 -0
- package/lib/types/config.js +76 -0
- package/lib/types/conversion/context.d.ts +55 -0
- package/lib/types/conversion/context.js +202 -0
- package/lib/types/conversion/index.d.ts +9 -0
- package/lib/types/conversion/index.js +7 -0
- package/lib/types/conversion/replay.d.ts +56 -0
- package/lib/types/conversion/replay.js +242 -0
- package/lib/types/conversion/stream.d.ts +46 -0
- package/lib/types/conversion/stream.js +203 -0
- package/lib/types/go-limits.d.ts +41 -0
- package/lib/types/go-limits.js +79 -0
- package/lib/types/index.d.ts +54 -0
- package/lib/types/index.js +195 -0
- package/lib/types/models.d.ts +90 -0
- package/lib/types/models.js +86 -0
- package/lib/types/remotes.d.ts +12 -0
- package/lib/types/remotes.js +28 -0
- package/lib/types/session-header.d.ts +36 -0
- package/lib/types/session-header.js +45 -0
- package/lib/types/usage/contract.d.ts +39 -0
- package/lib/types/usage/contract.js +106 -0
- package/lib/types/usage/index.d.ts +11 -0
- package/lib/types/usage/index.js +8 -0
- package/lib/types/usage/meter.d.ts +53 -0
- package/lib/types/usage/meter.js +65 -0
- package/lib/types/usage/service.d.ts +48 -0
- package/lib/types/usage/service.js +74 -0
- package/lib/types/usage/windows.d.ts +51 -0
- package/lib/types/usage/windows.js +84 -0
- package/package.json +147 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Online model metadata: how to call each advertised model.
|
|
3
|
+
*
|
|
4
|
+
* models.dev is the only source that knows about a model the day it ships, so
|
|
5
|
+
* it supplies names, capacities, modalities, pricing, lifecycle, and the
|
|
6
|
+
* protocol hint. The installed pi-ai catalog still wins where it knows the
|
|
7
|
+
* exact id, because its entries carry the wire quirks (`thinkingFormat`,
|
|
8
|
+
* reasoning-content replay, affinity format) that a generic metadata document
|
|
9
|
+
* cannot express.
|
|
10
|
+
*
|
|
11
|
+
* Every field degrades independently: a model missing a capacity falls back to
|
|
12
|
+
* the installed entry, then to the route default, and is flagged `assumedLimits`
|
|
13
|
+
* so the settings surface can say so instead of presenting a guess as a fact.
|
|
14
|
+
*
|
|
15
|
+
* @module @dan-ai-studio/dshopencodego/catalog/metadata
|
|
16
|
+
*/
|
|
17
|
+
import { asWireProtocol, decideProtocol, protocolOfNpm } from "./protocol.js";
|
|
18
|
+
import { MODEL_METADATA_PROVIDER } from "./constants.js";
|
|
19
|
+
const LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
20
|
+
function record(value) {
|
|
21
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
22
|
+
? value : {};
|
|
23
|
+
}
|
|
24
|
+
function positiveInteger(value) {
|
|
25
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
26
|
+
}
|
|
27
|
+
function nonEmptyString(value) {
|
|
28
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
29
|
+
}
|
|
30
|
+
/** A calendar date models.dev states without a timezone. */
|
|
31
|
+
function validReleaseDate(value) {
|
|
32
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
33
|
+
return undefined;
|
|
34
|
+
return Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value ? value : undefined;
|
|
35
|
+
}
|
|
36
|
+
function rates(value) {
|
|
37
|
+
const cost = record(value);
|
|
38
|
+
const rate = (key) => {
|
|
39
|
+
const entry = cost[key];
|
|
40
|
+
return typeof entry === 'number' && Number.isFinite(entry) && entry >= 0 ? entry : 0;
|
|
41
|
+
};
|
|
42
|
+
return { input: rate('input'), output: rate('output'), cacheRead: rate('cache_read'), cacheWrite: rate('cache_write') };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Reasoning levels a model actually offers.
|
|
46
|
+
*
|
|
47
|
+
* An absent `reasoning_options` list is not "no levels": it means the document
|
|
48
|
+
* does not describe them, so an installed entry's map is kept when there is
|
|
49
|
+
* one. Levels the model does not offer stay `null`, which is what stops the
|
|
50
|
+
* seam from offering a control the provider would ignore.
|
|
51
|
+
*/
|
|
52
|
+
function thinkingLevels(metadata, known) {
|
|
53
|
+
const options = metadata['reasoning_options'];
|
|
54
|
+
if (!Array.isArray(options))
|
|
55
|
+
return known?.thinkingLevelMap;
|
|
56
|
+
const map = Object.fromEntries(LEVELS.map(level => [level, null]));
|
|
57
|
+
for (const item of options) {
|
|
58
|
+
const option = record(item);
|
|
59
|
+
if (option['type'] === 'toggle' || option['type'] === 'budget_tokens') {
|
|
60
|
+
map.off = 'off';
|
|
61
|
+
map.high = 'high';
|
|
62
|
+
}
|
|
63
|
+
if (option['type'] !== 'effort' || !Array.isArray(option['values']))
|
|
64
|
+
continue;
|
|
65
|
+
for (const value of option['values']) {
|
|
66
|
+
const level = value === 'none' ? 'off' : value;
|
|
67
|
+
if (typeof level === 'string' && LEVELS.includes(level)) {
|
|
68
|
+
map[level] = String(value);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// An established transport may support disabling thinking on top of the
|
|
73
|
+
// advertised effort levels (DeepSeek's separate thinking flag, for example).
|
|
74
|
+
if (known?.reasoning === true && known.thinkingLevelMap?.off !== null) {
|
|
75
|
+
map.off ??= known.thinkingLevelMap?.off ?? 'off';
|
|
76
|
+
}
|
|
77
|
+
return map;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The closest installed entry in the same family, for inheriting wire quirks.
|
|
81
|
+
*
|
|
82
|
+
* models.dev's own `family` field is preferred when the document lists a
|
|
83
|
+
* sibling the installed catalog knows. When it does not — the common case for a
|
|
84
|
+
* model that just shipped — the longest shared id prefix stands in, which is
|
|
85
|
+
* how `deepseek-v4.1-flash` inherits `deepseek-v4-flash`'s thinking format.
|
|
86
|
+
* A shared prefix shorter than three characters is treated as no evidence.
|
|
87
|
+
*/
|
|
88
|
+
function familySibling(id, api, documentFamily, entries, builtin) {
|
|
89
|
+
if (documentFamily !== undefined) {
|
|
90
|
+
for (const [other, value] of Object.entries(entries)) {
|
|
91
|
+
if (other === id || record(value)['family'] !== documentFamily)
|
|
92
|
+
continue;
|
|
93
|
+
const candidate = builtin.get(other);
|
|
94
|
+
if (candidate !== undefined && candidate.api === api)
|
|
95
|
+
return candidate;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
let best;
|
|
99
|
+
const lower = id.toLowerCase();
|
|
100
|
+
for (const [other, candidate] of builtin) {
|
|
101
|
+
if (other === id || candidate.api !== api)
|
|
102
|
+
continue;
|
|
103
|
+
const score = commonPrefixLength(lower, other.toLowerCase());
|
|
104
|
+
if (score < 3)
|
|
105
|
+
continue;
|
|
106
|
+
if (best === undefined || score > best.score)
|
|
107
|
+
best = { model: candidate, score };
|
|
108
|
+
}
|
|
109
|
+
return best?.model;
|
|
110
|
+
}
|
|
111
|
+
function commonPrefixLength(left, right) {
|
|
112
|
+
let index = 0;
|
|
113
|
+
while (index < left.length && index < right.length && left[index] === right[index])
|
|
114
|
+
index += 1;
|
|
115
|
+
return index;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Wire quirks for one protocol.
|
|
119
|
+
*
|
|
120
|
+
* The installed entry's `compat` is authoritative when the protocol matches,
|
|
121
|
+
* including for a *family sibling*: a brand-new id in a family the catalog
|
|
122
|
+
* already serves inherits that family's quirks instead of starting from the
|
|
123
|
+
* generic defaults.
|
|
124
|
+
*/
|
|
125
|
+
function compatFor(api, metadata, exact, sibling) {
|
|
126
|
+
const inherited = exact !== undefined && exact.api === api
|
|
127
|
+
? exact.compat
|
|
128
|
+
: sibling !== undefined && sibling.api === api ? sibling.compat : undefined;
|
|
129
|
+
if (api === 'openai-completions') {
|
|
130
|
+
return {
|
|
131
|
+
supportsStore: false,
|
|
132
|
+
supportsDeveloperRole: false,
|
|
133
|
+
maxTokensField: 'max_tokens',
|
|
134
|
+
...(record(metadata['interleaved'])['field'] === 'reasoning_content'
|
|
135
|
+
? { requiresReasoningContentOnAssistantMessages: true }
|
|
136
|
+
: {}),
|
|
137
|
+
...inherited,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (api === 'openai-responses') {
|
|
141
|
+
return { sessionAffinityFormat: 'openai-nosession', ...inherited };
|
|
142
|
+
}
|
|
143
|
+
return inherited ?? {};
|
|
144
|
+
}
|
|
145
|
+
/** The base URL one protocol's SDK expects for this gateway. */
|
|
146
|
+
export function modelBaseURL(api, baseURL) {
|
|
147
|
+
const base = baseURL.replace(/\/+$/, '');
|
|
148
|
+
// The Anthropic SDK appends `/v1/messages`; the OpenAI SDKs append paths
|
|
149
|
+
// below `/v1`, which the configured base already carries.
|
|
150
|
+
return api === 'anthropic-messages' ? base.replace(/\/v1$/, '') : base;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Parse the `opencode-go` record of a models.dev document.
|
|
154
|
+
* @param body - the whole models.dev document.
|
|
155
|
+
* @param sources - installed entries, defaults, and configured overrides.
|
|
156
|
+
* @returns per-id facts and per-id parse failures; a bad entry never discards
|
|
157
|
+
* the rest of the document.
|
|
158
|
+
* @throws {Error} when the document has no usable `opencode-go` models object.
|
|
159
|
+
*/
|
|
160
|
+
export function readOnlineMetadata(body, sources) {
|
|
161
|
+
const provider = record(record(body)[MODEL_METADATA_PROVIDER]);
|
|
162
|
+
const rawModels = provider['models'];
|
|
163
|
+
if (rawModels === null || typeof rawModels !== 'object' || Array.isArray(rawModels)) {
|
|
164
|
+
throw new Error(`models.dev has no ${MODEL_METADATA_PROVIDER} models object`);
|
|
165
|
+
}
|
|
166
|
+
const providerNpm = provider['npm'];
|
|
167
|
+
const entries = record(rawModels);
|
|
168
|
+
const models = new Map();
|
|
169
|
+
const errors = new Map();
|
|
170
|
+
for (const [id, value] of Object.entries(entries)) {
|
|
171
|
+
const metadata = record(value);
|
|
172
|
+
try {
|
|
173
|
+
const exact = sources.builtin.get(id);
|
|
174
|
+
const override = asWireProtocol(sources.overrides[id]);
|
|
175
|
+
if (sources.overrides[id] !== undefined && override === undefined) {
|
|
176
|
+
throw new Error(`configured protocol "${sources.overrides[id]}" is not one of anthropic-messages, openai-completions, openai-responses`);
|
|
177
|
+
}
|
|
178
|
+
const decision = decideProtocol(id, {
|
|
179
|
+
...exact?.api === undefined ? {} : { builtin: exact.api },
|
|
180
|
+
...protocolOfNpm(metadata['provider'] === undefined ? providerNpm : record(metadata['provider'])['npm'] ?? providerNpm) === undefined
|
|
181
|
+
? {}
|
|
182
|
+
: { online: protocolOfNpm(record(metadata['provider'])['npm'] ?? providerNpm) },
|
|
183
|
+
...override === undefined ? {} : { override },
|
|
184
|
+
});
|
|
185
|
+
if (decision === undefined)
|
|
186
|
+
throw new Error('no protocol could be decided');
|
|
187
|
+
const api = decision.api;
|
|
188
|
+
const limit = record(metadata['limit']);
|
|
189
|
+
const onlineContext = positiveInteger(limit['context']);
|
|
190
|
+
const onlineInputLimit = positiveInteger(limit['input']);
|
|
191
|
+
const onlineOutput = positiveInteger(limit['output']);
|
|
192
|
+
const builtinContext = exact?.api === api ? positiveInteger(exact.contextWindow) : undefined;
|
|
193
|
+
const builtinOutput = exact?.api === api ? positiveInteger(exact.maxTokens) : undefined;
|
|
194
|
+
const contextWindow = onlineContext ?? builtinContext ?? sources.defaults.contextWindow;
|
|
195
|
+
const maxTokens = onlineOutput ?? builtinOutput ?? sources.defaults.maxTokens;
|
|
196
|
+
const onlineInput = record(metadata['modalities'])['input'];
|
|
197
|
+
const input = Array.isArray(onlineInput) && onlineInput.includes('text')
|
|
198
|
+
? (onlineInput.includes('image') ? ['text', 'image'] : ['text'])
|
|
199
|
+
: exact?.api === api ? exact.input : sources.defaults.input;
|
|
200
|
+
const reasoning = typeof metadata['reasoning'] === 'boolean'
|
|
201
|
+
? metadata['reasoning']
|
|
202
|
+
: exact?.api === api ? exact.reasoning : false;
|
|
203
|
+
const family = nonEmptyString(metadata['family']);
|
|
204
|
+
const sibling = familySibling(id, api, family, entries, sources.builtin);
|
|
205
|
+
const cost = rates(metadata['cost']);
|
|
206
|
+
const tiers = record(metadata['cost'])['tiers'];
|
|
207
|
+
if (Array.isArray(tiers)) {
|
|
208
|
+
const parsed = tiers.flatMap((item) => {
|
|
209
|
+
const tier = record(record(item)['tier']);
|
|
210
|
+
const size = positiveInteger(tier['size']);
|
|
211
|
+
return tier['type'] === 'context' && size !== undefined
|
|
212
|
+
? [{ ...rates(item), inputTokensAbove: size }]
|
|
213
|
+
: [];
|
|
214
|
+
}).sort((a, b) => a.inputTokensAbove - b.inputTokensAbove);
|
|
215
|
+
if (parsed.length > 0)
|
|
216
|
+
cost.tiers = parsed;
|
|
217
|
+
}
|
|
218
|
+
models.set(id, {
|
|
219
|
+
id,
|
|
220
|
+
name: nonEmptyString(metadata['name']) ?? id,
|
|
221
|
+
api,
|
|
222
|
+
protocolSource: decision.source,
|
|
223
|
+
contextWindow,
|
|
224
|
+
maxInputTokens: onlineInputLimit,
|
|
225
|
+
maxTokens,
|
|
226
|
+
assumedLimits: onlineContext === undefined && builtinContext === undefined
|
|
227
|
+
|| onlineOutput === undefined && builtinOutput === undefined,
|
|
228
|
+
input,
|
|
229
|
+
reasoning,
|
|
230
|
+
thinkingLevelMap: reasoning ? thinkingLevels(metadata, exact?.api === api ? exact : sibling) : undefined,
|
|
231
|
+
compat: compatFor(api, metadata, exact, sibling),
|
|
232
|
+
cost,
|
|
233
|
+
deprecated: metadata['status'] === 'deprecated',
|
|
234
|
+
releaseDate: validReleaseDate(metadata['release_date']),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
errors.set(id, error instanceof Error ? error.message : String(error));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return { models, errors };
|
|
242
|
+
}
|
|
243
|
+
/** Project one parsed model into the shape pi-ai's providers consume. */
|
|
244
|
+
export function toPiModel(facts, baseURL) {
|
|
245
|
+
return {
|
|
246
|
+
id: facts.id,
|
|
247
|
+
name: facts.name,
|
|
248
|
+
provider: 'opencode-go',
|
|
249
|
+
api: facts.api,
|
|
250
|
+
baseUrl: modelBaseURL(facts.api, baseURL),
|
|
251
|
+
reasoning: facts.reasoning,
|
|
252
|
+
...facts.thinkingLevelMap === undefined ? {} : { thinkingLevelMap: facts.thinkingLevelMap },
|
|
253
|
+
input: [...facts.input],
|
|
254
|
+
contextWindow: facts.contextWindow,
|
|
255
|
+
maxTokens: facts.maxTokens,
|
|
256
|
+
cost: facts.cost,
|
|
257
|
+
compat: facts.compat,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-protocol vocabulary and the inference ladder.
|
|
3
|
+
*
|
|
4
|
+
* The gateway is a multi-protocol front door: the same base URL answers
|
|
5
|
+
* `chat/completions`, `responses`, and `messages`, and each model speaks
|
|
6
|
+
* exactly one of them. Four evidence levels decide which, first hit wins:
|
|
7
|
+
*
|
|
8
|
+
* 1. the installed pi-ai catalog entry for that exact id (protocol *and* the
|
|
9
|
+
* wire quirks in its `compat`);
|
|
10
|
+
* 2. models.dev's per-model `provider.npm`, which names the AI SDK package the
|
|
11
|
+
* OpenCode Go console itself uses;
|
|
12
|
+
* 3. a family rule, for ids neither source describes;
|
|
13
|
+
* 4. an explicit configuration override, which always wins.
|
|
14
|
+
*
|
|
15
|
+
* Measured 2026-09-24: of the 12 gateway ids pi-ai 0.87.1 does not know, 10 are
|
|
16
|
+
* fully described by models.dev and 2 (`deepseek-flash`, `hy3-preview`) are
|
|
17
|
+
* absent from it — the family rule exists for those two and for any future id
|
|
18
|
+
* that lands the same way, not to rescue a large set.
|
|
19
|
+
*
|
|
20
|
+
* @module @dan-ai-studio/dshopencodego/catalog/protocol
|
|
21
|
+
*/
|
|
22
|
+
import type { Api } from '@earendil-works/pi-ai';
|
|
23
|
+
/** The three wire protocols the OpenCode Go gateway serves. */
|
|
24
|
+
export type WireProtocol = 'anthropic-messages' | 'openai-completions' | 'openai-responses';
|
|
25
|
+
/** Every protocol this plugin can drive, in catalog order. */
|
|
26
|
+
export declare const WIRE_PROTOCOLS: readonly WireProtocol[];
|
|
27
|
+
/** Which evidence level produced a protocol decision. */
|
|
28
|
+
export type ProtocolSource = 'builtin' | 'online' | 'inferred' | 'override';
|
|
29
|
+
/** One model's protocol decision with the evidence that produced it. */
|
|
30
|
+
export interface ProtocolDecision {
|
|
31
|
+
readonly api: WireProtocol;
|
|
32
|
+
readonly source: ProtocolSource;
|
|
33
|
+
}
|
|
34
|
+
/** The protocol a models.dev `npm` field names, when it names one. */
|
|
35
|
+
export declare function protocolOfNpm(npm: unknown): WireProtocol | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* The family rule for one id: the Responses families by prefix, everything else
|
|
38
|
+
* over Chat Completions, which is what the gateway's provider-level default
|
|
39
|
+
* (`@ai-sdk/openai-compatible`) implies for an id no per-model record covers.
|
|
40
|
+
* @param id - the gateway model id.
|
|
41
|
+
* @returns the inferred protocol; the caller records it as `inferred`.
|
|
42
|
+
*/
|
|
43
|
+
export declare function inferProtocol(id: string): WireProtocol;
|
|
44
|
+
/** Narrow an arbitrary configured string to a protocol this plugin can drive. */
|
|
45
|
+
export declare function asWireProtocol(value: string | undefined): WireProtocol | undefined;
|
|
46
|
+
/** One model's evidence, as gathered from the three automatic sources. */
|
|
47
|
+
export interface ProtocolEvidence {
|
|
48
|
+
/** Protocol the installed pi-ai catalog declares for this exact id. */
|
|
49
|
+
readonly builtin?: WireProtocol;
|
|
50
|
+
/** Protocol models.dev names for this model, or its provider-level default. */
|
|
51
|
+
readonly online?: WireProtocol;
|
|
52
|
+
/** The configured override for this id, if any. */
|
|
53
|
+
readonly override?: WireProtocol;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Walk the ladder for one model.
|
|
57
|
+
* @param id - the gateway model id, used only by the family rule.
|
|
58
|
+
* @param evidence - what the automatic sources and configuration say.
|
|
59
|
+
* @returns the decision, or `undefined` when the override names a protocol this
|
|
60
|
+
* build cannot drive (the caller reports that as a configuration error rather
|
|
61
|
+
* than silently falling back to a guess).
|
|
62
|
+
*/
|
|
63
|
+
export declare function decideProtocol(id: string, evidence: ProtocolEvidence): ProtocolDecision | undefined;
|
|
64
|
+
/** The pi-ai API string for one wire protocol. */
|
|
65
|
+
export declare function piApiOf(api: WireProtocol): Api;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-protocol vocabulary and the inference ladder.
|
|
3
|
+
*
|
|
4
|
+
* The gateway is a multi-protocol front door: the same base URL answers
|
|
5
|
+
* `chat/completions`, `responses`, and `messages`, and each model speaks
|
|
6
|
+
* exactly one of them. Four evidence levels decide which, first hit wins:
|
|
7
|
+
*
|
|
8
|
+
* 1. the installed pi-ai catalog entry for that exact id (protocol *and* the
|
|
9
|
+
* wire quirks in its `compat`);
|
|
10
|
+
* 2. models.dev's per-model `provider.npm`, which names the AI SDK package the
|
|
11
|
+
* OpenCode Go console itself uses;
|
|
12
|
+
* 3. a family rule, for ids neither source describes;
|
|
13
|
+
* 4. an explicit configuration override, which always wins.
|
|
14
|
+
*
|
|
15
|
+
* Measured 2026-09-24: of the 12 gateway ids pi-ai 0.87.1 does not know, 10 are
|
|
16
|
+
* fully described by models.dev and 2 (`deepseek-flash`, `hy3-preview`) are
|
|
17
|
+
* absent from it — the family rule exists for those two and for any future id
|
|
18
|
+
* that lands the same way, not to rescue a large set.
|
|
19
|
+
*
|
|
20
|
+
* @module @dan-ai-studio/dshopencodego/catalog/protocol
|
|
21
|
+
*/
|
|
22
|
+
/** Every protocol this plugin can drive, in catalog order. */
|
|
23
|
+
export const WIRE_PROTOCOLS = [
|
|
24
|
+
'anthropic-messages',
|
|
25
|
+
'openai-completions',
|
|
26
|
+
'openai-responses',
|
|
27
|
+
];
|
|
28
|
+
/** The AI SDK package names models.dev uses to name a model's protocol. */
|
|
29
|
+
const NPM_PROTOCOLS = {
|
|
30
|
+
'@ai-sdk/anthropic': 'anthropic-messages',
|
|
31
|
+
'@ai-sdk/openai': 'openai-responses',
|
|
32
|
+
'@ai-sdk/openai-compatible': 'openai-completions',
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Families whose models are served over the OpenAI Responses API.
|
|
36
|
+
*
|
|
37
|
+
* Only families where every known member agrees are listed. Qwen and MiniMax
|
|
38
|
+
* are deliberately absent: the installed catalog serves `qwen3.8-flash` and
|
|
39
|
+
* `minimax-m3` over Anthropic Messages while serving `qwen3.6-plus`,
|
|
40
|
+
* `qwen3.7-*`, `qwen3.8-max`, and `minimax-m2.7` over Chat Completions, so a
|
|
41
|
+
* family rule would be wrong about half of them.
|
|
42
|
+
*/
|
|
43
|
+
const RESPONSES_FAMILIES = ['grok', 'gpt', 'muse'];
|
|
44
|
+
/** The protocol a models.dev `npm` field names, when it names one. */
|
|
45
|
+
export function protocolOfNpm(npm) {
|
|
46
|
+
return typeof npm === 'string' ? NPM_PROTOCOLS[npm] : undefined;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The family rule for one id: the Responses families by prefix, everything else
|
|
50
|
+
* over Chat Completions, which is what the gateway's provider-level default
|
|
51
|
+
* (`@ai-sdk/openai-compatible`) implies for an id no per-model record covers.
|
|
52
|
+
* @param id - the gateway model id.
|
|
53
|
+
* @returns the inferred protocol; the caller records it as `inferred`.
|
|
54
|
+
*/
|
|
55
|
+
export function inferProtocol(id) {
|
|
56
|
+
const lower = id.toLowerCase();
|
|
57
|
+
return RESPONSES_FAMILIES.some(family => lower.startsWith(family))
|
|
58
|
+
? 'openai-responses'
|
|
59
|
+
: 'openai-completions';
|
|
60
|
+
}
|
|
61
|
+
/** Narrow an arbitrary configured string to a protocol this plugin can drive. */
|
|
62
|
+
export function asWireProtocol(value) {
|
|
63
|
+
return value !== undefined && WIRE_PROTOCOLS.includes(value)
|
|
64
|
+
? value
|
|
65
|
+
: undefined;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Walk the ladder for one model.
|
|
69
|
+
* @param id - the gateway model id, used only by the family rule.
|
|
70
|
+
* @param evidence - what the automatic sources and configuration say.
|
|
71
|
+
* @returns the decision, or `undefined` when the override names a protocol this
|
|
72
|
+
* build cannot drive (the caller reports that as a configuration error rather
|
|
73
|
+
* than silently falling back to a guess).
|
|
74
|
+
*/
|
|
75
|
+
export function decideProtocol(id, evidence) {
|
|
76
|
+
if (evidence.override !== undefined)
|
|
77
|
+
return { api: evidence.override, source: 'override' };
|
|
78
|
+
if (evidence.builtin !== undefined)
|
|
79
|
+
return { api: evidence.builtin, source: 'builtin' };
|
|
80
|
+
if (evidence.online !== undefined)
|
|
81
|
+
return { api: evidence.online, source: 'online' };
|
|
82
|
+
return { api: inferProtocol(id), source: 'inferred' };
|
|
83
|
+
}
|
|
84
|
+
/** The pi-ai API string for one wire protocol. */
|
|
85
|
+
export function piApiOf(api) {
|
|
86
|
+
return api;
|
|
87
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings-facing reading of the catalog.
|
|
3
|
+
*
|
|
4
|
+
* The settings page needs more than the picker's model list: it needs to say
|
|
5
|
+
* which models the gateway advertises, which of them this build cannot
|
|
6
|
+
* configure, how each protocol was decided, and whether the reading is stale
|
|
7
|
+
* because a refresh failed. That projection lives here as a pure function so it
|
|
8
|
+
* can be asserted without a Cordis context.
|
|
9
|
+
*
|
|
10
|
+
* @module @dan-ai-studio/dshopencodego/catalog/reading
|
|
11
|
+
*/
|
|
12
|
+
import type { ModelSummary } from '../models.ts';
|
|
13
|
+
import type { CatalogSnapshot } from './index.ts';
|
|
14
|
+
/** One settings-page reading of the catalog. */
|
|
15
|
+
export interface CatalogReading {
|
|
16
|
+
/** Every advertised model, new first, deprecated last. */
|
|
17
|
+
readonly models: readonly ModelSummary[];
|
|
18
|
+
/** True when the gateway listing failed and this is the last known set. */
|
|
19
|
+
readonly stale: boolean;
|
|
20
|
+
/** Why the reading is stale, when it is. */
|
|
21
|
+
readonly error?: string;
|
|
22
|
+
/** When the underlying snapshot was built. */
|
|
23
|
+
readonly fetchedAtMs: number;
|
|
24
|
+
/** Counts the settings page shows without walking the list again. */
|
|
25
|
+
readonly counts: {
|
|
26
|
+
readonly total: number;
|
|
27
|
+
readonly enabled: number;
|
|
28
|
+
readonly deprecated: number;
|
|
29
|
+
readonly unconfigured: number;
|
|
30
|
+
readonly inferred: number;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Project one snapshot for the settings page.
|
|
35
|
+
* @param snapshot - the catalog snapshot, live or retained.
|
|
36
|
+
* @param visibility - per-model switches; absent entries keep the default.
|
|
37
|
+
* @param listingFailure - the failure message when the listing failed.
|
|
38
|
+
* @returns the reading, with unconfigurable ids kept visible so the page can
|
|
39
|
+
* explain them instead of hiding them.
|
|
40
|
+
*/
|
|
41
|
+
export declare function catalogReading(snapshot: CatalogSnapshot, visibility: Readonly<Record<string, boolean>>, listingFailure?: string): CatalogReading;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings-facing reading of the catalog.
|
|
3
|
+
*
|
|
4
|
+
* The settings page needs more than the picker's model list: it needs to say
|
|
5
|
+
* which models the gateway advertises, which of them this build cannot
|
|
6
|
+
* configure, how each protocol was decided, and whether the reading is stale
|
|
7
|
+
* because a refresh failed. That projection lives here as a pure function so it
|
|
8
|
+
* can be asserted without a Cordis context.
|
|
9
|
+
*
|
|
10
|
+
* @module @dan-ai-studio/dshopencodego/catalog/reading
|
|
11
|
+
*/
|
|
12
|
+
import { isModelEnabled, recommendedIds, sortModels } from "../models.js";
|
|
13
|
+
import { goQuotaFor } from "../go-limits.js";
|
|
14
|
+
/**
|
|
15
|
+
* Project one snapshot for the settings page.
|
|
16
|
+
* @param snapshot - the catalog snapshot, live or retained.
|
|
17
|
+
* @param visibility - per-model switches; absent entries keep the default.
|
|
18
|
+
* @param listingFailure - the failure message when the listing failed.
|
|
19
|
+
* @returns the reading, with unconfigurable ids kept visible so the page can
|
|
20
|
+
* explain them instead of hiding them.
|
|
21
|
+
*/
|
|
22
|
+
export function catalogReading(snapshot, visibility, listingFailure) {
|
|
23
|
+
const rows = [
|
|
24
|
+
...[...snapshot.facts.values()].map(fact => {
|
|
25
|
+
const quota = goQuotaFor(fact.id);
|
|
26
|
+
const priced = fact.cost.input > 0 || fact.cost.output > 0;
|
|
27
|
+
return {
|
|
28
|
+
id: fact.id,
|
|
29
|
+
name: fact.name,
|
|
30
|
+
contextWindow: fact.contextWindow,
|
|
31
|
+
maxTokens: fact.maxTokens,
|
|
32
|
+
...fact.maxInputTokens === undefined ? {} : { maxInputTokens: fact.maxInputTokens },
|
|
33
|
+
deprecated: fact.deprecated,
|
|
34
|
+
...fact.releaseDate === undefined ? {} : { releaseDate: fact.releaseDate },
|
|
35
|
+
...quota === undefined ? {} : { goQuota: quota },
|
|
36
|
+
...!priced ? {} : {
|
|
37
|
+
cost: {
|
|
38
|
+
input: fact.cost.input,
|
|
39
|
+
output: fact.cost.output,
|
|
40
|
+
cacheRead: fact.cost.cacheRead,
|
|
41
|
+
cacheWrite: fact.cost.cacheWrite,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
protocolSource: fact.protocolSource,
|
|
45
|
+
assumedLimits: fact.assumedLimits,
|
|
46
|
+
};
|
|
47
|
+
}),
|
|
48
|
+
...[...snapshot.unavailable].map(([id, reason]) => ({ id, name: id, configurationMissing: reason })),
|
|
49
|
+
];
|
|
50
|
+
// Nobody configured switches? Then the default configuration keeps the top
|
|
51
|
+
// few by published monthly estimate, and every row carries that default.
|
|
52
|
+
const recommended = recommendedIds(rows);
|
|
53
|
+
const models = rows.map(row => ({ ...row, recommended: recommended.has(row.id) }));
|
|
54
|
+
const enabled = models.filter(model => isModelEnabled(model, visibility)).length;
|
|
55
|
+
return {
|
|
56
|
+
models: sortModels(models),
|
|
57
|
+
stale: !snapshot.live,
|
|
58
|
+
...!snapshot.live && listingFailure !== undefined ? { error: listingFailure } : {},
|
|
59
|
+
fetchedAtMs: snapshot.fetchedAtMs,
|
|
60
|
+
counts: {
|
|
61
|
+
total: models.length,
|
|
62
|
+
enabled,
|
|
63
|
+
deprecated: models.filter(model => model.deprecated === true).length,
|
|
64
|
+
unconfigured: models.filter(model => model.configurationMissing !== undefined).length,
|
|
65
|
+
inferred: models.filter(model => model.protocolSource === 'inferred').length,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of the catalog Remote.
|
|
3
|
+
*
|
|
4
|
+
* Both methods return the same projection; they differ only in whether the
|
|
5
|
+
* cache may answer. `refresh` is what the settings page's button calls, and it
|
|
6
|
+
* revalidates the gateway listing and the online metadata before projecting.
|
|
7
|
+
*
|
|
8
|
+
* @module @dan-ai-studio/dshopencodego/catalog/service
|
|
9
|
+
*/
|
|
10
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
11
|
+
import { RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
12
|
+
import type { OpencodeGoCatalog } from './index.ts';
|
|
13
|
+
import type { CatalogReading } from './reading.ts';
|
|
14
|
+
/** Host inputs for the catalog service. */
|
|
15
|
+
export interface CatalogServiceOptions {
|
|
16
|
+
/** The route's current catalog resolver. */
|
|
17
|
+
readonly catalog: () => OpencodeGoCatalog;
|
|
18
|
+
/** Current per-model visibility switches. */
|
|
19
|
+
readonly visibility: () => Readonly<Record<string, boolean>>;
|
|
20
|
+
}
|
|
21
|
+
/** Catalog Remote: what the gateway serves, and how each model is called. */
|
|
22
|
+
export declare class OpencodeGoCatalogService extends TypertRemoteService {
|
|
23
|
+
private readonly options;
|
|
24
|
+
constructor(ctx: Context, options: CatalogServiceOptions);
|
|
25
|
+
/** The cached reading. */
|
|
26
|
+
read(): Promise<CatalogReading>;
|
|
27
|
+
/** Revalidate both sources, then read. */
|
|
28
|
+
refresh(): Promise<CatalogReading>;
|
|
29
|
+
private project;
|
|
30
|
+
}
|
|
31
|
+
/** The domain failure the settings page renders when a refresh cannot run. */
|
|
32
|
+
export declare function catalogFailure(error: unknown): RemoteError;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of the catalog Remote.
|
|
3
|
+
*
|
|
4
|
+
* Both methods return the same projection; they differ only in whether the
|
|
5
|
+
* cache may answer. `refresh` is what the settings page's button calls, and it
|
|
6
|
+
* revalidates the gateway listing and the online metadata before projecting.
|
|
7
|
+
*
|
|
8
|
+
* @module @dan-ai-studio/dshopencodego/catalog/service
|
|
9
|
+
*/
|
|
10
|
+
import { RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
11
|
+
import { LlmError } from '@deepseek-ai/dsh-llm';
|
|
12
|
+
import { catalogReading } from "./reading.js";
|
|
13
|
+
/** Catalog Remote: what the gateway serves, and how each model is called. */
|
|
14
|
+
export class OpencodeGoCatalogService extends TypertRemoteService {
|
|
15
|
+
options;
|
|
16
|
+
constructor(ctx, options) {
|
|
17
|
+
super(ctx, 'opencodeGoCatalog');
|
|
18
|
+
this.options = options;
|
|
19
|
+
}
|
|
20
|
+
/** The cached reading. */
|
|
21
|
+
read() {
|
|
22
|
+
return this.project(false);
|
|
23
|
+
}
|
|
24
|
+
/** Revalidate both sources, then read. */
|
|
25
|
+
refresh() {
|
|
26
|
+
return this.project(true);
|
|
27
|
+
}
|
|
28
|
+
async project(force) {
|
|
29
|
+
const catalog = this.options.catalog();
|
|
30
|
+
const snapshot = await catalog.snapshot(force);
|
|
31
|
+
const failure = snapshot.listingFailure;
|
|
32
|
+
return catalogReading(snapshot, this.options.visibility(), failure === undefined
|
|
33
|
+
? undefined
|
|
34
|
+
: failure instanceof LlmError || failure instanceof Error ? failure.message : String(failure));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** The domain failure the settings page renders when a refresh cannot run. */
|
|
38
|
+
export function catalogFailure(error) {
|
|
39
|
+
return new RemoteError('dshopencodego/usage-unavailable', error instanceof Error
|
|
40
|
+
? error.message
|
|
41
|
+
: 'The OpenCode Go catalog is unavailable', {
|
|
42
|
+
retryable: true,
|
|
43
|
+
retainPrevious: true,
|
|
44
|
+
}, { cause: error });
|
|
45
|
+
}
|