@dan-ai-studio/dshopencodego 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +156 -0
  3. package/README.md +156 -0
  4. package/cordis.patch.yml +3 -0
  5. package/lib/build-info.json +11 -0
  6. package/lib/client.js +1215 -0
  7. package/lib/index.js +2036 -0
  8. package/lib/types/adapter.d.ts +84 -0
  9. package/lib/types/adapter.js +311 -0
  10. package/lib/types/catalog/constants.d.ts +16 -0
  11. package/lib/types/catalog/constants.js +16 -0
  12. package/lib/types/catalog/contract.d.ts +26 -0
  13. package/lib/types/catalog/contract.js +131 -0
  14. package/lib/types/catalog/gateway.d.ts +20 -0
  15. package/lib/types/catalog/gateway.js +59 -0
  16. package/lib/types/catalog/index.d.ts +108 -0
  17. package/lib/types/catalog/index.js +288 -0
  18. package/lib/types/catalog/json-response.d.ts +19 -0
  19. package/lib/types/catalog/json-response.js +72 -0
  20. package/lib/types/catalog/metadata.d.ts +73 -0
  21. package/lib/types/catalog/metadata.js +259 -0
  22. package/lib/types/catalog/protocol.d.ts +65 -0
  23. package/lib/types/catalog/protocol.js +87 -0
  24. package/lib/types/catalog/reading.d.ts +41 -0
  25. package/lib/types/catalog/reading.js +68 -0
  26. package/lib/types/catalog/service.d.ts +32 -0
  27. package/lib/types/catalog/service.js +45 -0
  28. package/lib/types/config.d.ts +93 -0
  29. package/lib/types/config.js +76 -0
  30. package/lib/types/conversion/context.d.ts +55 -0
  31. package/lib/types/conversion/context.js +202 -0
  32. package/lib/types/conversion/index.d.ts +9 -0
  33. package/lib/types/conversion/index.js +7 -0
  34. package/lib/types/conversion/replay.d.ts +56 -0
  35. package/lib/types/conversion/replay.js +242 -0
  36. package/lib/types/conversion/stream.d.ts +46 -0
  37. package/lib/types/conversion/stream.js +203 -0
  38. package/lib/types/go-limits.d.ts +41 -0
  39. package/lib/types/go-limits.js +79 -0
  40. package/lib/types/index.d.ts +54 -0
  41. package/lib/types/index.js +195 -0
  42. package/lib/types/models.d.ts +90 -0
  43. package/lib/types/models.js +86 -0
  44. package/lib/types/remotes.d.ts +12 -0
  45. package/lib/types/remotes.js +28 -0
  46. package/lib/types/session-header.d.ts +36 -0
  47. package/lib/types/session-header.js +45 -0
  48. package/lib/types/usage/contract.d.ts +39 -0
  49. package/lib/types/usage/contract.js +106 -0
  50. package/lib/types/usage/index.d.ts +11 -0
  51. package/lib/types/usage/index.js +8 -0
  52. package/lib/types/usage/meter.d.ts +53 -0
  53. package/lib/types/usage/meter.js +65 -0
  54. package/lib/types/usage/service.d.ts +48 -0
  55. package/lib/types/usage/service.js +74 -0
  56. package/lib/types/usage/windows.d.ts +51 -0
  57. package/lib/types/usage/windows.js +84 -0
  58. package/package.json +147 -0
@@ -0,0 +1,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
+ }