@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,108 @@
|
|
|
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`. The snapshot is cached for the configured
|
|
7
|
+
* refresh interval, and an unknown model id forces one revalidation even inside
|
|
8
|
+
* that interval — that is what makes a model the gateway added this morning
|
|
9
|
+
* callable this afternoon without a plugin release.
|
|
10
|
+
*
|
|
11
|
+
* Failures degrade rather than empty: a listing outage keeps the last known
|
|
12
|
+
* models and marks the snapshot not-live, a metadata outage keeps the last
|
|
13
|
+
* document, and a single unparseable model is reported by id while every other
|
|
14
|
+
* model keeps serving.
|
|
15
|
+
*
|
|
16
|
+
* @module @dan-ai-studio/dshopencodego/catalog
|
|
17
|
+
*/
|
|
18
|
+
import type { Provider } from '@earendil-works/pi-ai';
|
|
19
|
+
import type { LlmDiscoveredModel } from '@deepseek-ai/dsh-llm';
|
|
20
|
+
import type { ModelDefaults, ModelFacts } from './metadata.ts';
|
|
21
|
+
export { PROVIDER_ID, DISPLAY_NAME, DEFAULT_BASE_URL } from './constants.ts';
|
|
22
|
+
export { readModelIds, fetchModelIds } from './gateway.ts';
|
|
23
|
+
export { readOnlineMetadata, toPiModel, modelBaseURL } from './metadata.ts';
|
|
24
|
+
export type { ModelFacts, OnlineMetadata, ModelDefaults } from './metadata.ts';
|
|
25
|
+
export { decideProtocol, inferProtocol, protocolOfNpm } from './protocol.ts';
|
|
26
|
+
export type { ProtocolDecision, ProtocolSource, WireProtocol } from './protocol.ts';
|
|
27
|
+
/** One resolved view of the gateway's models. */
|
|
28
|
+
export interface CatalogSnapshot {
|
|
29
|
+
/** Models that can be called, by id, with the evidence behind each decision. */
|
|
30
|
+
readonly facts: ReadonlyMap<string, ModelFacts>;
|
|
31
|
+
/** Advertised ids this build cannot configure, with the reason. */
|
|
32
|
+
readonly unavailable: ReadonlyMap<string, string>;
|
|
33
|
+
/** pi-ai provider holding exactly the callable models. */
|
|
34
|
+
readonly provider: Provider;
|
|
35
|
+
/** Whether the gateway listing answered during this build. */
|
|
36
|
+
readonly live: boolean;
|
|
37
|
+
/** Retained for explicit discovery when the listing failed. */
|
|
38
|
+
readonly listingFailure?: unknown;
|
|
39
|
+
readonly fetchedAtMs: number;
|
|
40
|
+
}
|
|
41
|
+
/** Observers the plugin logs through. */
|
|
42
|
+
export interface CatalogObservers {
|
|
43
|
+
/** One source fell back to retained data. */
|
|
44
|
+
readonly onFallback?: (detail: {
|
|
45
|
+
url: string;
|
|
46
|
+
error: unknown;
|
|
47
|
+
kept: number;
|
|
48
|
+
}) => void;
|
|
49
|
+
/** Ids that could not be configured, reported once per build. */
|
|
50
|
+
readonly onUnconfigured?: (detail: {
|
|
51
|
+
readonly id: string;
|
|
52
|
+
readonly reason: string;
|
|
53
|
+
}[]) => void;
|
|
54
|
+
}
|
|
55
|
+
/** Inputs one catalog build needs from configuration. */
|
|
56
|
+
export interface CatalogOptions {
|
|
57
|
+
/** Normalized gateway base URL. */
|
|
58
|
+
readonly baseURL: string;
|
|
59
|
+
/** Cache lifetime for requests and pickers. */
|
|
60
|
+
readonly refreshMs: number;
|
|
61
|
+
/** Route defaults for a model no source sizes. */
|
|
62
|
+
readonly defaults: ModelDefaults;
|
|
63
|
+
/** Configured per-id protocol overrides. */
|
|
64
|
+
readonly overrides: Readonly<Record<string, string>>;
|
|
65
|
+
readonly observers?: CatalogObservers;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* One gateway's catalog. Requests reuse a cached snapshot; explicit discovery
|
|
69
|
+
* and unknown ids revalidate it.
|
|
70
|
+
*/
|
|
71
|
+
export declare class OpencodeGoCatalog {
|
|
72
|
+
private readonly options;
|
|
73
|
+
private served;
|
|
74
|
+
private pending;
|
|
75
|
+
private metadataDocument;
|
|
76
|
+
private metadataETag;
|
|
77
|
+
private lastMetadata;
|
|
78
|
+
constructor(options: CatalogOptions);
|
|
79
|
+
/** The cached snapshot, refreshed when it is older than the configured TTL. */
|
|
80
|
+
snapshot(force?: boolean): Promise<CatalogSnapshot>;
|
|
81
|
+
/**
|
|
82
|
+
* The snapshot that can serve one exact model.
|
|
83
|
+
*
|
|
84
|
+
* A model this build has never seen forces one revalidation even inside the
|
|
85
|
+
* TTL, because "unknown" and "newly added" look identical from here.
|
|
86
|
+
* @param id - the requested model id.
|
|
87
|
+
* @returns the snapshot holding the model.
|
|
88
|
+
* @throws {LlmError} `MODEL_METADATA_UNAVAILABLE` when the gateway advertises
|
|
89
|
+
* the id but this build cannot configure it, naming the reason.
|
|
90
|
+
*/
|
|
91
|
+
forModel(id: string): Promise<CatalogSnapshot>;
|
|
92
|
+
/** Revalidate both sources, reusing the metadata document when it is unchanged. */
|
|
93
|
+
private build;
|
|
94
|
+
/** Merge the listing with the metadata document and the installed entries. */
|
|
95
|
+
private assemble;
|
|
96
|
+
/** Facts for an id no source describes, from the ladder and the route defaults. */
|
|
97
|
+
private inferredFacts;
|
|
98
|
+
/** Conditional metadata GET: an unchanged document keeps its parsed form. */
|
|
99
|
+
private refreshMetadata;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Interrogate the gateway for explicit discovery, bypassing the TTL.
|
|
103
|
+
* @param catalog - the catalog to revalidate.
|
|
104
|
+
* @returns the advertised models, including the unconfigured ones so the
|
|
105
|
+
* settings surface can explain what is missing.
|
|
106
|
+
* @throws {LlmError} `DISCOVERY_FAILED` when the listing itself failed.
|
|
107
|
+
*/
|
|
108
|
+
export declare function discoverCatalogModels(catalog: OpencodeGoCatalog): Promise<readonly LlmDiscoveredModel[]>;
|
|
@@ -0,0 +1,288 @@
|
|
|
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`. The snapshot is cached for the configured
|
|
7
|
+
* refresh interval, and an unknown model id forces one revalidation even inside
|
|
8
|
+
* that interval — that is what makes a model the gateway added this morning
|
|
9
|
+
* callable this afternoon without a plugin release.
|
|
10
|
+
*
|
|
11
|
+
* Failures degrade rather than empty: a listing outage keeps the last known
|
|
12
|
+
* models and marks the snapshot not-live, a metadata outage keeps the last
|
|
13
|
+
* document, and a single unparseable model is reported by id while every other
|
|
14
|
+
* model keeps serving.
|
|
15
|
+
*
|
|
16
|
+
* @module @dan-ai-studio/dshopencodego/catalog
|
|
17
|
+
*/
|
|
18
|
+
import { createProvider } from '@earendil-works/pi-ai';
|
|
19
|
+
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all';
|
|
20
|
+
import { anthropicMessagesApi } from '@earendil-works/pi-ai/api/anthropic-messages.lazy';
|
|
21
|
+
import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy';
|
|
22
|
+
import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy';
|
|
23
|
+
import { attributionHeaders, LlmError } from '@deepseek-ai/dsh-llm';
|
|
24
|
+
import { DISPLAY_NAME, MODEL_METADATA_MAX_BYTES, MODEL_METADATA_URL, PROVIDER_ID, METADATA_FETCH_TIMEOUT_MS, } from "./constants.js";
|
|
25
|
+
import { fetchModelIds } from "./gateway.js";
|
|
26
|
+
import { readBoundedJson } from "./json-response.js";
|
|
27
|
+
import { readOnlineMetadata, toPiModel } from "./metadata.js";
|
|
28
|
+
export { PROVIDER_ID, DISPLAY_NAME, DEFAULT_BASE_URL } from "./constants.js";
|
|
29
|
+
export { readModelIds, fetchModelIds } from "./gateway.js";
|
|
30
|
+
export { readOnlineMetadata, toPiModel, modelBaseURL } from "./metadata.js";
|
|
31
|
+
export { decideProtocol, inferProtocol, protocolOfNpm } from "./protocol.js";
|
|
32
|
+
/**
|
|
33
|
+
* The installed catalog, re-pointed at the configured gateway.
|
|
34
|
+
*
|
|
35
|
+
* These entries are the first rung of the ladder and the outage fallback: their
|
|
36
|
+
* `compat` is what keeps DeepSeek-family reasoning replay correct.
|
|
37
|
+
*/
|
|
38
|
+
function builtinModels(baseURL) {
|
|
39
|
+
return new Map(getBuiltinModels(PROVIDER_ID).map(model => [model.id, {
|
|
40
|
+
...model,
|
|
41
|
+
provider: PROVIDER_ID,
|
|
42
|
+
baseUrl: modelBaseURLFor(model.api, baseURL),
|
|
43
|
+
}]));
|
|
44
|
+
}
|
|
45
|
+
function modelBaseURLFor(api, baseURL) {
|
|
46
|
+
const base = baseURL.replace(/\/+$/, '');
|
|
47
|
+
return api === 'anthropic-messages' ? base.replace(/\/v1$/, '') : base;
|
|
48
|
+
}
|
|
49
|
+
/** The adapter resolves and passes the credential for every request. */
|
|
50
|
+
function harnessApiKeyAuth() {
|
|
51
|
+
return {
|
|
52
|
+
apiKey: {
|
|
53
|
+
name: 'OpenCode Go API key',
|
|
54
|
+
resolve: () => Promise.resolve({ auth: {}, source: 'OpenCode Go API key' }),
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function buildProvider(baseURL, models) {
|
|
59
|
+
return createProvider({
|
|
60
|
+
id: PROVIDER_ID,
|
|
61
|
+
name: DISPLAY_NAME,
|
|
62
|
+
baseUrl: baseURL,
|
|
63
|
+
auth: harnessApiKeyAuth(),
|
|
64
|
+
models: [...models],
|
|
65
|
+
api: {
|
|
66
|
+
'anthropic-messages': anthropicMessagesApi(),
|
|
67
|
+
'openai-completions': openAICompletionsApi(),
|
|
68
|
+
'openai-responses': openAIResponsesApi(),
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* One gateway's catalog. Requests reuse a cached snapshot; explicit discovery
|
|
74
|
+
* and unknown ids revalidate it.
|
|
75
|
+
*/
|
|
76
|
+
export class OpencodeGoCatalog {
|
|
77
|
+
options;
|
|
78
|
+
served;
|
|
79
|
+
pending;
|
|
80
|
+
metadataDocument;
|
|
81
|
+
metadataETag;
|
|
82
|
+
lastMetadata;
|
|
83
|
+
constructor(options) {
|
|
84
|
+
this.options = options;
|
|
85
|
+
}
|
|
86
|
+
/** The cached snapshot, refreshed when it is older than the configured TTL. */
|
|
87
|
+
snapshot(force = false) {
|
|
88
|
+
if (!force && this.served !== undefined && Date.now() - this.served.fetchedAtMs < this.options.refreshMs) {
|
|
89
|
+
return Promise.resolve(this.served);
|
|
90
|
+
}
|
|
91
|
+
this.pending ??= this.build()
|
|
92
|
+
.then((snapshot) => {
|
|
93
|
+
this.served = snapshot;
|
|
94
|
+
return snapshot;
|
|
95
|
+
})
|
|
96
|
+
.finally(() => { this.pending = undefined; });
|
|
97
|
+
return this.pending;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The snapshot that can serve one exact model.
|
|
101
|
+
*
|
|
102
|
+
* A model this build has never seen forces one revalidation even inside the
|
|
103
|
+
* TTL, because "unknown" and "newly added" look identical from here.
|
|
104
|
+
* @param id - the requested model id.
|
|
105
|
+
* @returns the snapshot holding the model.
|
|
106
|
+
* @throws {LlmError} `MODEL_METADATA_UNAVAILABLE` when the gateway advertises
|
|
107
|
+
* the id but this build cannot configure it, naming the reason.
|
|
108
|
+
*/
|
|
109
|
+
async forModel(id) {
|
|
110
|
+
const cached = this.served;
|
|
111
|
+
let snapshot = await this.snapshot();
|
|
112
|
+
if (!snapshot.facts.has(id) && snapshot === cached)
|
|
113
|
+
snapshot = await this.snapshot(true);
|
|
114
|
+
const reason = snapshot.unavailable.get(id);
|
|
115
|
+
if (reason !== undefined) {
|
|
116
|
+
throw new LlmError(`opencode-go model "${id}" is advertised but cannot be configured: ${reason};`
|
|
117
|
+
+ ' refresh the model list to retry', 'MODEL_METADATA_UNAVAILABLE');
|
|
118
|
+
}
|
|
119
|
+
return snapshot;
|
|
120
|
+
}
|
|
121
|
+
/** Revalidate both sources, reusing the metadata document when it is unchanged. */
|
|
122
|
+
async build() {
|
|
123
|
+
const builtin = builtinModels(this.options.baseURL);
|
|
124
|
+
const [listing, metadata] = await Promise.allSettled([
|
|
125
|
+
fetchModelIds(this.options.baseURL),
|
|
126
|
+
this.refreshMetadata(builtin),
|
|
127
|
+
]);
|
|
128
|
+
if (metadata.status === 'rejected') {
|
|
129
|
+
this.options.observers?.onFallback?.({
|
|
130
|
+
url: MODEL_METADATA_URL,
|
|
131
|
+
error: metadata.reason,
|
|
132
|
+
kept: this.lastMetadata?.models.size ?? 0,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
if (listing.status === 'rejected') {
|
|
136
|
+
// Once observed, an outage must not resurrect retired models: the last
|
|
137
|
+
// successful listing stays authoritative until a new one arrives.
|
|
138
|
+
const previous = this.served;
|
|
139
|
+
this.options.observers?.onFallback?.({
|
|
140
|
+
url: `${this.options.baseURL.replace(/\/+$/, '')}/models`,
|
|
141
|
+
error: listing.reason,
|
|
142
|
+
kept: previous?.facts.size ?? 0,
|
|
143
|
+
});
|
|
144
|
+
const facts = previous?.facts ?? new Map();
|
|
145
|
+
return {
|
|
146
|
+
facts,
|
|
147
|
+
unavailable: previous?.unavailable ?? new Map(),
|
|
148
|
+
provider: buildProvider(this.options.baseURL, [...facts.values()].map(fact => toPiModel(fact, this.options.baseURL))),
|
|
149
|
+
live: false,
|
|
150
|
+
listingFailure: listing.reason,
|
|
151
|
+
fetchedAtMs: Date.now(),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
// A metadata outage keeps the last successful parse: the previous document
|
|
155
|
+
// is strictly better evidence than the installed catalog alone, because it
|
|
156
|
+
// carries the capacities, modalities, and lifecycle the catalog may not.
|
|
157
|
+
return this.assemble(listing.value, metadata.status === 'fulfilled' ? metadata.value : this.lastMetadata, builtin);
|
|
158
|
+
}
|
|
159
|
+
/** Merge the listing with the metadata document and the installed entries. */
|
|
160
|
+
assemble(ids, online, builtin) {
|
|
161
|
+
const facts = new Map();
|
|
162
|
+
const unavailable = new Map();
|
|
163
|
+
for (const id of ids) {
|
|
164
|
+
const parsed = online?.models.get(id);
|
|
165
|
+
if (parsed !== undefined) {
|
|
166
|
+
facts.set(id, parsed);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const reason = online?.errors.get(id);
|
|
170
|
+
if (reason !== undefined) {
|
|
171
|
+
// A malformed record is a data fault: report it rather than inventing
|
|
172
|
+
// capacities, because the model is described and the description is wrong.
|
|
173
|
+
unavailable.set(id, reason);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
// No source describes this id. The gateway's own provider default
|
|
177
|
+
// (`@ai-sdk/openai-compatible`) makes Chat Completions the best available
|
|
178
|
+
// guess, and the route defaults size it; the settings surface marks it as
|
|
179
|
+
// inferred so a wrong guess is visible and overridable.
|
|
180
|
+
facts.set(id, this.inferredFacts(id, builtin));
|
|
181
|
+
}
|
|
182
|
+
if (unavailable.size > 0) {
|
|
183
|
+
this.options.observers?.onUnconfigured?.([...unavailable].map(([id, reason]) => ({ id, reason })));
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
facts,
|
|
187
|
+
unavailable,
|
|
188
|
+
provider: buildProvider(this.options.baseURL, [...facts.values()].map(fact => toPiModel(fact, this.options.baseURL))),
|
|
189
|
+
live: true,
|
|
190
|
+
fetchedAtMs: Date.now(),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/** Facts for an id no source describes, from the ladder and the route defaults. */
|
|
194
|
+
inferredFacts(id, builtin) {
|
|
195
|
+
const exact = builtin.get(id);
|
|
196
|
+
const api = exact?.api ?? inferFamily(id);
|
|
197
|
+
const contextWindow = exact !== undefined ? exact.contextWindow : this.options.defaults.contextWindow;
|
|
198
|
+
const maxTokens = exact !== undefined ? exact.maxTokens : this.options.defaults.maxTokens;
|
|
199
|
+
return {
|
|
200
|
+
id,
|
|
201
|
+
name: exact?.name ?? id,
|
|
202
|
+
api,
|
|
203
|
+
protocolSource: exact === undefined ? 'inferred' : 'builtin',
|
|
204
|
+
contextWindow,
|
|
205
|
+
maxInputTokens: undefined,
|
|
206
|
+
maxTokens,
|
|
207
|
+
assumedLimits: exact === undefined,
|
|
208
|
+
input: exact?.input ?? this.options.defaults.input,
|
|
209
|
+
reasoning: exact?.reasoning ?? false,
|
|
210
|
+
thinkingLevelMap: exact?.thinkingLevelMap,
|
|
211
|
+
compat: exact?.compat ?? {},
|
|
212
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
213
|
+
deprecated: false,
|
|
214
|
+
releaseDate: undefined,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/** Conditional metadata GET: an unchanged document keeps its parsed form. */
|
|
218
|
+
async refreshMetadata(builtin) {
|
|
219
|
+
const response = await fetch(MODEL_METADATA_URL, {
|
|
220
|
+
redirect: 'error',
|
|
221
|
+
headers: {
|
|
222
|
+
...attributionHeaders(),
|
|
223
|
+
accept: 'application/json',
|
|
224
|
+
'cache-control': 'no-cache',
|
|
225
|
+
...this.metadataETag === undefined ? {} : { 'if-none-match': this.metadataETag },
|
|
226
|
+
},
|
|
227
|
+
signal: AbortSignal.timeout(METADATA_FETCH_TIMEOUT_MS),
|
|
228
|
+
});
|
|
229
|
+
if (response.status === 304 && this.metadataDocument !== undefined) {
|
|
230
|
+
await response.body?.cancel().catch(() => { });
|
|
231
|
+
const parsed = readOnlineMetadata(this.metadataDocument, {
|
|
232
|
+
builtin,
|
|
233
|
+
defaults: this.options.defaults,
|
|
234
|
+
overrides: this.options.overrides,
|
|
235
|
+
});
|
|
236
|
+
this.lastMetadata = parsed;
|
|
237
|
+
return parsed;
|
|
238
|
+
}
|
|
239
|
+
if (!response.ok) {
|
|
240
|
+
await response.body?.cancel().catch(() => { });
|
|
241
|
+
throw new Error(`models.dev answered ${response.status}`);
|
|
242
|
+
}
|
|
243
|
+
const document = await readBoundedJson(response, MODEL_METADATA_URL, MODEL_METADATA_MAX_BYTES);
|
|
244
|
+
this.metadataDocument = document;
|
|
245
|
+
this.metadataETag = response.headers.get('etag') ?? undefined;
|
|
246
|
+
const parsed = readOnlineMetadata(document, {
|
|
247
|
+
builtin,
|
|
248
|
+
defaults: this.options.defaults,
|
|
249
|
+
overrides: this.options.overrides,
|
|
250
|
+
});
|
|
251
|
+
this.lastMetadata = parsed;
|
|
252
|
+
return parsed;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/** The family rule, kept beside the ladder so both stay in one vocabulary. */
|
|
256
|
+
function inferFamily(id) {
|
|
257
|
+
const lower = id.toLowerCase();
|
|
258
|
+
return ['grok', 'gpt', 'muse'].some(family => lower.startsWith(family))
|
|
259
|
+
? 'openai-responses'
|
|
260
|
+
: 'openai-completions';
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Interrogate the gateway for explicit discovery, bypassing the TTL.
|
|
264
|
+
* @param catalog - the catalog to revalidate.
|
|
265
|
+
* @returns the advertised models, including the unconfigured ones so the
|
|
266
|
+
* settings surface can explain what is missing.
|
|
267
|
+
* @throws {LlmError} `DISCOVERY_FAILED` when the listing itself failed.
|
|
268
|
+
*/
|
|
269
|
+
export async function discoverCatalogModels(catalog) {
|
|
270
|
+
const snapshot = await catalog.snapshot(true);
|
|
271
|
+
if (!snapshot.live) {
|
|
272
|
+
const detail = snapshot.listingFailure instanceof LlmError
|
|
273
|
+
? snapshot.listingFailure.message
|
|
274
|
+
: 'the live model listing is unreachable';
|
|
275
|
+
throw new LlmError(`dshopencodego: ${detail}; refresh the model list to retry`, 'DISCOVERY_FAILED', {
|
|
276
|
+
cause: snapshot.listingFailure,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
return [
|
|
280
|
+
...[...snapshot.facts.values()].map(fact => ({
|
|
281
|
+
id: fact.id,
|
|
282
|
+
name: fact.name,
|
|
283
|
+
contextWindow: fact.contextWindow,
|
|
284
|
+
maxTokens: fact.maxTokens,
|
|
285
|
+
})),
|
|
286
|
+
...[...snapshot.unavailable].map(([id, reason]) => ({ id, name: `${id} (unconfigured: ${reason})` })),
|
|
287
|
+
];
|
|
288
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded JSON reads shared by the catalog and usage endpoints.
|
|
3
|
+
*
|
|
4
|
+
* Every outbound metadata read is capped before parsing: the metadata document
|
|
5
|
+
* is third-party, and a truncated or oversized reply must surface as a named
|
|
6
|
+
* failure rather than as memory growth.
|
|
7
|
+
*
|
|
8
|
+
* @module @dan-ai-studio/dshopencodego/catalog/json-response
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Read a response body as JSON without trusting its length.
|
|
12
|
+
* @param response - an OK response whose body is the document.
|
|
13
|
+
* @param url - the URL, for diagnostics.
|
|
14
|
+
* @param maxBytes - hard cap on the decoded document.
|
|
15
|
+
* @returns the parsed JSON value.
|
|
16
|
+
* @throws {LlmError} `DISCOVERY_FAILED` on a transport fault, an over-cap body,
|
|
17
|
+
* or invalid JSON.
|
|
18
|
+
*/
|
|
19
|
+
export declare function readBoundedJson(response: Response, url: string, maxBytes: number): Promise<unknown>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded JSON reads shared by the catalog and usage endpoints.
|
|
3
|
+
*
|
|
4
|
+
* Every outbound metadata read is capped before parsing: the metadata document
|
|
5
|
+
* is third-party, and a truncated or oversized reply must surface as a named
|
|
6
|
+
* failure rather than as memory growth.
|
|
7
|
+
*
|
|
8
|
+
* @module @dan-ai-studio/dshopencodego/catalog/json-response
|
|
9
|
+
*/
|
|
10
|
+
import { LlmError } from '@deepseek-ai/dsh-llm';
|
|
11
|
+
/**
|
|
12
|
+
* Read a response body as JSON without trusting its length.
|
|
13
|
+
* @param response - an OK response whose body is the document.
|
|
14
|
+
* @param url - the URL, for diagnostics.
|
|
15
|
+
* @param maxBytes - hard cap on the decoded document.
|
|
16
|
+
* @returns the parsed JSON value.
|
|
17
|
+
* @throws {LlmError} `DISCOVERY_FAILED` on a transport fault, an over-cap body,
|
|
18
|
+
* or invalid JSON.
|
|
19
|
+
*/
|
|
20
|
+
export async function readBoundedJson(response, url, maxBytes) {
|
|
21
|
+
const declared = Number(response.headers.get('content-length') ?? Number.NaN);
|
|
22
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
23
|
+
await response.body?.cancel().catch(() => { });
|
|
24
|
+
throw new LlmError(`${url} declares ${declared} bytes, over the ${maxBytes}-byte cap`, 'DISCOVERY_FAILED');
|
|
25
|
+
}
|
|
26
|
+
let text;
|
|
27
|
+
try {
|
|
28
|
+
text = await readBoundedText(response, maxBytes);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error instanceof LlmError)
|
|
32
|
+
throw error;
|
|
33
|
+
throw new LlmError(`could not read ${url}`, 'DISCOVERY_FAILED', { cause: error });
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(text);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
throw new LlmError(`${url} did not answer with JSON`, 'DISCOVERY_FAILED', { cause: error });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Decode a body, aborting once it passes the cap. */
|
|
43
|
+
async function readBoundedText(response, maxBytes) {
|
|
44
|
+
if (response.body === null)
|
|
45
|
+
return '';
|
|
46
|
+
const reader = response.body.getReader();
|
|
47
|
+
const chunks = [];
|
|
48
|
+
let total = 0;
|
|
49
|
+
try {
|
|
50
|
+
while (true) {
|
|
51
|
+
const { done, value } = await reader.read();
|
|
52
|
+
if (done)
|
|
53
|
+
break;
|
|
54
|
+
total += value.byteLength;
|
|
55
|
+
if (total > maxBytes) {
|
|
56
|
+
await reader.cancel().catch(() => { });
|
|
57
|
+
throw new LlmError(`response exceeds the ${maxBytes}-byte cap`, 'DISCOVERY_FAILED');
|
|
58
|
+
}
|
|
59
|
+
chunks.push(value);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
reader.releaseLock();
|
|
64
|
+
}
|
|
65
|
+
const merged = new Uint8Array(total);
|
|
66
|
+
let offset = 0;
|
|
67
|
+
for (const chunk of chunks) {
|
|
68
|
+
merged.set(chunk, offset);
|
|
69
|
+
offset += chunk.byteLength;
|
|
70
|
+
}
|
|
71
|
+
return new TextDecoder().decode(merged);
|
|
72
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
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 type { Api, Model, ModelCost, ThinkingLevelMap } from '@earendil-works/pi-ai';
|
|
18
|
+
import type { ProtocolSource, WireProtocol } from './protocol.ts';
|
|
19
|
+
/** Everything this plugin knows about one advertised model. */
|
|
20
|
+
export interface ModelFacts {
|
|
21
|
+
readonly id: string;
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly api: WireProtocol;
|
|
24
|
+
/** Which level of the ladder produced {@link api}. */
|
|
25
|
+
readonly protocolSource: ProtocolSource;
|
|
26
|
+
readonly contextWindow: number;
|
|
27
|
+
/** Maximum input tokens models.dev states; absent for most models. */
|
|
28
|
+
readonly maxInputTokens: number | undefined;
|
|
29
|
+
readonly maxTokens: number;
|
|
30
|
+
/** True when a capacity came from the route default rather than a source. */
|
|
31
|
+
readonly assumedLimits: boolean;
|
|
32
|
+
readonly input: readonly ('text' | 'image')[];
|
|
33
|
+
readonly reasoning: boolean;
|
|
34
|
+
readonly thinkingLevelMap: ThinkingLevelMap | undefined;
|
|
35
|
+
readonly compat: Model<Api>['compat'];
|
|
36
|
+
readonly cost: ModelCost;
|
|
37
|
+
readonly deprecated: boolean;
|
|
38
|
+
readonly releaseDate: string | undefined;
|
|
39
|
+
}
|
|
40
|
+
/** Parsed online metadata for every id models.dev describes. */
|
|
41
|
+
export interface OnlineMetadata {
|
|
42
|
+
readonly models: ReadonlyMap<string, ModelFacts>;
|
|
43
|
+
/** Per-id parse failures: the id stays visible with a diagnostic. */
|
|
44
|
+
readonly errors: ReadonlyMap<string, string>;
|
|
45
|
+
}
|
|
46
|
+
/** Route-level fallbacks for a model no source sizes. */
|
|
47
|
+
export interface ModelDefaults {
|
|
48
|
+
readonly contextWindow: number;
|
|
49
|
+
readonly maxTokens: number;
|
|
50
|
+
readonly input: readonly ('text' | 'image')[];
|
|
51
|
+
}
|
|
52
|
+
/** The base URL one protocol's SDK expects for this gateway. */
|
|
53
|
+
export declare function modelBaseURL(api: WireProtocol, baseURL: string): string;
|
|
54
|
+
/** Inputs the ladder needs beyond the document itself. */
|
|
55
|
+
export interface MetadataSources {
|
|
56
|
+
/** Installed catalog entries by id, the first level of the ladder. */
|
|
57
|
+
readonly builtin: ReadonlyMap<string, Model<Api>>;
|
|
58
|
+
/** Route defaults for a model no source sizes. */
|
|
59
|
+
readonly defaults: ModelDefaults;
|
|
60
|
+
/** Configured per-id protocol overrides, the last level of the ladder. */
|
|
61
|
+
readonly overrides: Readonly<Record<string, string>>;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Parse the `opencode-go` record of a models.dev document.
|
|
65
|
+
* @param body - the whole models.dev document.
|
|
66
|
+
* @param sources - installed entries, defaults, and configured overrides.
|
|
67
|
+
* @returns per-id facts and per-id parse failures; a bad entry never discards
|
|
68
|
+
* the rest of the document.
|
|
69
|
+
* @throws {Error} when the document has no usable `opencode-go` models object.
|
|
70
|
+
*/
|
|
71
|
+
export declare function readOnlineMetadata(body: unknown, sources: MetadataSources): OnlineMetadata;
|
|
72
|
+
/** Project one parsed model into the shape pi-ai's providers consume. */
|
|
73
|
+
export declare function toPiModel(facts: ModelFacts, baseURL: string): Model<Api>;
|