saros-proxy 1.0.0 → 1.1.0

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.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * models-refresh.ts — Deterministic generator for the bundled model catalog.
3
+ *
4
+ * `npm run models:refresh` (scripts/refresh-models.ts) is the only writer of
5
+ * `assets/models.json`. It merges models.dev metadata for the two providers
6
+ * this proxy serves with the live catalogs, so a model that no longer exists
7
+ * upstream leaves the bundle and a new one enters without a hand edit.
8
+ *
9
+ * The merge is a pure function (`mergeModelCatalog`) so the catalog rules are
10
+ * testable without network access:
11
+ * - membership follows the live catalogs: a model the provider serves enters
12
+ * with the metadata the live payload carries even when models.dev omits it,
13
+ * and it stays while the provider keeps advertising it;
14
+ * - `opencode-go` metadata wins when both providers know a model;
15
+ * - a field already in the current file survives when models.dev is silent
16
+ * about it (models.dev drops fields occasionally and a regeneration must
17
+ * not discard the value);
18
+ * - two raw ids that collapse to the same bare key abort the refresh;
19
+ * - output keys, including nested object keys, are sorted so a refresh diff
20
+ * only shows real changes;
21
+ * - a failed fetch — or an empty/partial response — aborts before the file is
22
+ * read, and a merge that would drop more than half of the current models
23
+ * needs `allowShrink` before it writes anything.
24
+ */
25
+ /** Public opencode-go catalog — no auth, but Cloudflare wants a CLI signature. */
26
+ export declare const OPENCODE_GO_MODELS_URL = "https://opencode.ai/zen/go/v1/models";
27
+ /** Public CommandCode catalog (200 with any or no Authorization header). */
28
+ export declare const COMMANDCODE_MODELS_URL = "https://api.commandcode.ai/provider/v1/models";
29
+ /**
30
+ * models.dev sits behind Cloudflare and answers 403 `error code: 1010` to
31
+ * browser-like or missing signatures. Any stable non-browser UA works.
32
+ */
33
+ export declare const MODELS_REFRESH_USER_AGENT = "saros-proxy-models-refresh";
34
+ /** Minimal fetch surface, injected in tests. */
35
+ export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
36
+ /** Fetch and validate the models.dev catalog. */
37
+ export declare function fetchModelsDevCatalog(fetchImpl?: FetchLike, timeoutMs?: number): Promise<Record<string, unknown>>;
38
+ /**
39
+ * One entry of a live provider catalog: the id plus the metadata the payload
40
+ * carries. `name` and `context_length` seed a catalog entry when models.dev
41
+ * does not know the model yet.
42
+ */
43
+ export interface LiveModel {
44
+ id: string;
45
+ name?: string;
46
+ context_length?: number;
47
+ }
48
+ /**
49
+ * Fetch the opencode-go live catalog.
50
+ *
51
+ * The list is authoritative for membership: models.dev keeps models the
52
+ * provider already retired (`space-bunny`, `cat-jam-free`, …), and publishing
53
+ * those as connectable models is what this gate prevents.
54
+ */
55
+ export declare function fetchOpencodeGoModels(fetchImpl?: FetchLike, timeoutMs?: number): Promise<LiveModel[]>;
56
+ /**
57
+ * Fetch the CommandCode live catalog.
58
+ *
59
+ * Only models that advertise `/chat/completions` are usable through the proxy,
60
+ * which relays chat/completions shapes. `claude-*` entries list `/messages`
61
+ * only (verified live: chat/completions → unsupported_model); a vendor-prefixed
62
+ * Claude model would slip past a bare `^claude` test, so the endpoint field —
63
+ * not the id — decides.
64
+ */
65
+ export declare function fetchCommandcodeModels(fetchImpl?: FetchLike, timeoutMs?: number): Promise<LiveModel[]>;
66
+ /** Default network sources for the CLI. */
67
+ export interface RefreshFetchers {
68
+ modelsDev: () => Promise<Record<string, unknown>>;
69
+ opencodeModels: () => Promise<readonly LiveModel[]>;
70
+ commandcodeModels: () => Promise<readonly LiveModel[]>;
71
+ }
72
+ /** Build the real fetchers — the only place the CLI touches the network. */
73
+ export declare function defaultRefreshFetchers(): RefreshFetchers;
74
+ /**
75
+ * Return one provider's `models` map from a parsed models.dev payload.
76
+ *
77
+ * Thin delegate: the parse lives in `models-dev.ts` so the generator and the
78
+ * runtime read agree on what a provider slice is.
79
+ */
80
+ export declare function extractModelsDevProviderModels(parsed: Record<string, unknown>, providerId: string): Record<string, Record<string, unknown>>;
81
+ export interface ModelCatalogMergeInput {
82
+ /** Parsed `https://models.dev/api.json`. */
83
+ modelsDev: Record<string, unknown>;
84
+ /** Live opencode-go `/models` entries. */
85
+ opencodeModels: readonly LiveModel[];
86
+ /** Live CommandCode `/provider/v1/models` entries (chat/completions only). */
87
+ commandcodeModels: readonly LiveModel[];
88
+ /** Current bundled catalog, parsed; null/absent for a fresh file. */
89
+ current?: Record<string, unknown> | null;
90
+ }
91
+ export interface ModelCatalogMergeResult {
92
+ /** Merged catalog with alphabetically sorted keys (top level and nested). */
93
+ models: Record<string, Record<string, unknown>>;
94
+ /** Keys that were absent from the current file. */
95
+ added: string[];
96
+ /** Keys that disappeared from both upstream catalogs. */
97
+ removed: string[];
98
+ /** Keys present in both files whose content changed. */
99
+ changed: string[];
100
+ }
101
+ /**
102
+ * Merge models.dev metadata with the live catalogs and the current file.
103
+ *
104
+ * Membership is decided by upstream: only ids that the live catalog advertises
105
+ * are kept, and a live id enters even when models.dev has never heard of it.
106
+ * Fields inside a surviving entry are additive — the current file's value
107
+ * survives whenever the upstream source does not carry that field.
108
+ */
109
+ export declare function mergeModelCatalog(input: ModelCatalogMergeInput): ModelCatalogMergeResult;
110
+ /** Stable on-disk format: 2-space indent, sorted keys, trailing newline. */
111
+ export declare function serializeModelCatalog(models: Record<string, unknown>): string;
112
+ /** Parse a catalog file. `null`/empty content is an empty catalog. */
113
+ export declare function parseCatalogContent(raw: string | null): Record<string, unknown>;
114
+ export interface RefreshIo {
115
+ /** Read the current catalog file, or null when it does not exist. */
116
+ read: () => string | null;
117
+ /** Write the new catalog file. */
118
+ write: (content: string) => void;
119
+ }
120
+ export interface RefreshOptions {
121
+ /** Show the diff and skip the write. */
122
+ dryRun?: boolean;
123
+ /** Allow a merge that removes more than half of the current models. */
124
+ allowShrink?: boolean;
125
+ }
126
+ export interface ModelCatalogRefreshResult extends ModelCatalogMergeResult {
127
+ previousContent: string | null;
128
+ nextContent: string;
129
+ /** False on a dry run and when the generated bytes already match the file. */
130
+ written: boolean;
131
+ /** True when only key order/whitespace differs from the file on disk. */
132
+ formattingOnly: boolean;
133
+ }
134
+ /**
135
+ * Fetch, merge, and (unless dry-running) write the catalog.
136
+ *
137
+ * Sources are validated before the file is read: a 2xx with an empty body (a
138
+ * Cloudflare hiccup, a partial rollout) used to empty the catalog and mark
139
+ * every model removed. A merge that would drop more than half of the current
140
+ * models is treated the same way unless `allowShrink` opts in.
141
+ */
142
+ export declare function refreshModelCatalog(io: RefreshIo, fetchers?: RefreshFetchers, options?: RefreshOptions): Promise<ModelCatalogRefreshResult>;
143
+ /** Human-readable model-level diff between the old and the new catalog. */
144
+ export declare function formatCatalogDiff(previous: Record<string, unknown> | null, next: Record<string, unknown>): string;
@@ -0,0 +1,522 @@
1
+ /**
2
+ * models-refresh.ts — Deterministic generator for the bundled model catalog.
3
+ *
4
+ * `npm run models:refresh` (scripts/refresh-models.ts) is the only writer of
5
+ * `assets/models.json`. It merges models.dev metadata for the two providers
6
+ * this proxy serves with the live catalogs, so a model that no longer exists
7
+ * upstream leaves the bundle and a new one enters without a hand edit.
8
+ *
9
+ * The merge is a pure function (`mergeModelCatalog`) so the catalog rules are
10
+ * testable without network access:
11
+ * - membership follows the live catalogs: a model the provider serves enters
12
+ * with the metadata the live payload carries even when models.dev omits it,
13
+ * and it stays while the provider keeps advertising it;
14
+ * - `opencode-go` metadata wins when both providers know a model;
15
+ * - a field already in the current file survives when models.dev is silent
16
+ * about it (models.dev drops fields occasionally and a regeneration must
17
+ * not discard the value);
18
+ * - two raw ids that collapse to the same bare key abort the refresh;
19
+ * - output keys, including nested object keys, are sorted so a refresh diff
20
+ * only shows real changes;
21
+ * - a failed fetch — or an empty/partial response — aborts before the file is
22
+ * read, and a merge that would drop more than half of the current models
23
+ * needs `allowShrink` before it writes anything.
24
+ */
25
+ import { isDeepStrictEqual } from 'node:util';
26
+ import { DEFAULT_UPSTREAM_COMMANDCODE, DEFAULT_UPSTREAM_URL, MODELS_DEV_COMMANDCODE_PROVIDER_ID, MODELS_DEV_SAFE_FIELDS, MODELS_DEV_TIMEOUT_MS, MODELS_DEV_URL, MODELS_DEV_PROVIDER_ID, MODELS_FETCH_TIMEOUT_MS, } from './constants.js';
27
+ import { OPENCODE_GO_USER_AGENT } from './providers/opencode-go.js';
28
+ import { conservativeEffortOptions } from './model-ladder.js';
29
+ import { parseCatalogObject, serializeCatalogObject } from './models-catalog.js';
30
+ import { extractProviderModels } from './models-dev.js';
31
+ // ---------------------------------------------------------------------------
32
+ // Sources
33
+ // ---------------------------------------------------------------------------
34
+ /** Public opencode-go catalog — no auth, but Cloudflare wants a CLI signature. */
35
+ export const OPENCODE_GO_MODELS_URL = `${DEFAULT_UPSTREAM_URL}/zen/go/v1/models`;
36
+ /** Public CommandCode catalog (200 with any or no Authorization header). */
37
+ export const COMMANDCODE_MODELS_URL = `${DEFAULT_UPSTREAM_COMMANDCODE}/provider/v1/models`;
38
+ /**
39
+ * models.dev sits behind Cloudflare and answers 403 `error code: 1010` to
40
+ * browser-like or missing signatures. Any stable non-browser UA works.
41
+ */
42
+ export const MODELS_REFRESH_USER_AGENT = 'saros-proxy-models-refresh';
43
+ // ---------------------------------------------------------------------------
44
+ // Fetching
45
+ // ---------------------------------------------------------------------------
46
+ /** GET a JSON document with one User-Agent header. Throws on any non-2xx. */
47
+ async function fetchJson(url, userAgent, label, fetchImpl, timeoutMs) {
48
+ const response = await fetchImpl(url, {
49
+ headers: { 'User-Agent': userAgent },
50
+ signal: AbortSignal.timeout(timeoutMs),
51
+ });
52
+ if (!response.ok)
53
+ throw new Error(`${label} returned HTTP ${response.status}`);
54
+ return response.json();
55
+ }
56
+ /** Fetch and validate the models.dev catalog. */
57
+ export async function fetchModelsDevCatalog(fetchImpl = fetch, timeoutMs = MODELS_DEV_TIMEOUT_MS) {
58
+ const parsed = await fetchJson(MODELS_DEV_URL, MODELS_REFRESH_USER_AGENT, 'models.dev', fetchImpl, timeoutMs);
59
+ if (!isPlainObject(parsed))
60
+ throw new Error('models.dev returned a non-object payload');
61
+ return parsed;
62
+ }
63
+ /**
64
+ * Fetch the opencode-go live catalog.
65
+ *
66
+ * The list is authoritative for membership: models.dev keeps models the
67
+ * provider already retired (`space-bunny`, `cat-jam-free`, …), and publishing
68
+ * those as connectable models is what this gate prevents.
69
+ */
70
+ export async function fetchOpencodeGoModels(fetchImpl = fetch, timeoutMs = MODELS_FETCH_TIMEOUT_MS) {
71
+ return fetchLiveModels(OPENCODE_GO_MODELS_URL, OPENCODE_GO_USER_AGENT, 'opencode-go models', fetchImpl, timeoutMs, () => true);
72
+ }
73
+ /**
74
+ * Fetch the CommandCode live catalog.
75
+ *
76
+ * Only models that advertise `/chat/completions` are usable through the proxy,
77
+ * which relays chat/completions shapes. `claude-*` entries list `/messages`
78
+ * only (verified live: chat/completions → unsupported_model); a vendor-prefixed
79
+ * Claude model would slip past a bare `^claude` test, so the endpoint field —
80
+ * not the id — decides.
81
+ */
82
+ export async function fetchCommandcodeModels(fetchImpl = fetch, timeoutMs = MODELS_FETCH_TIMEOUT_MS) {
83
+ return fetchLiveModels(COMMANDCODE_MODELS_URL, MODELS_REFRESH_USER_AGENT, 'commandcode models', fetchImpl, timeoutMs, supportsChatCompletions);
84
+ }
85
+ /** True when the live entry advertises the OpenAI chat/completions endpoint. */
86
+ function supportsChatCompletions(entry) {
87
+ const endpoints = entry.supported_endpoints;
88
+ return Array.isArray(endpoints) && endpoints.includes('/chat/completions');
89
+ }
90
+ /** Shared `{ object: 'list', data: [{ id }] }` parser. */
91
+ async function fetchLiveModels(url, userAgent, label, fetchImpl, timeoutMs, keep) {
92
+ const body = await fetchJson(url, userAgent, label, fetchImpl, timeoutMs);
93
+ if (!isPlainObject(body) || !Array.isArray(body.data)) {
94
+ throw new Error(`${label} returned an unexpected payload`);
95
+ }
96
+ const models = [];
97
+ for (const entry of body.data) {
98
+ if (!isPlainObject(entry) || typeof entry.id !== 'string' || entry.id.length === 0)
99
+ continue;
100
+ if (keep(entry))
101
+ models.push(toLiveModel(entry, entry.id));
102
+ }
103
+ return models;
104
+ }
105
+ /** Keep the fields the live payload can seed a catalog entry with. */
106
+ function toLiveModel(entry, id) {
107
+ const model = { id };
108
+ if (typeof entry.name === 'string' && entry.name.length > 0)
109
+ model.name = entry.name;
110
+ if (typeof entry.context_length === 'number' && Number.isFinite(entry.context_length)) {
111
+ model.context_length = entry.context_length;
112
+ }
113
+ return model;
114
+ }
115
+ /** Build the real fetchers — the only place the CLI touches the network. */
116
+ export function defaultRefreshFetchers() {
117
+ return {
118
+ modelsDev: () => fetchModelsDevCatalog(),
119
+ opencodeModels: () => fetchOpencodeGoModels(),
120
+ commandcodeModels: () => fetchCommandcodeModels(),
121
+ };
122
+ }
123
+ // ---------------------------------------------------------------------------
124
+ // models.dev extraction
125
+ // ---------------------------------------------------------------------------
126
+ /**
127
+ * Return one provider's `models` map from a parsed models.dev payload.
128
+ *
129
+ * Thin delegate: the parse lives in `models-dev.ts` so the generator and the
130
+ * runtime read agree on what a provider slice is.
131
+ */
132
+ export function extractModelsDevProviderModels(parsed, providerId) {
133
+ return extractProviderModels(parsed, providerId);
134
+ }
135
+ /**
136
+ * Merge models.dev metadata with the live catalogs and the current file.
137
+ *
138
+ * Membership is decided by upstream: only ids that the live catalog advertises
139
+ * are kept, and a live id enters even when models.dev has never heard of it.
140
+ * Fields inside a surviving entry are additive — the current file's value
141
+ * survives whenever the upstream source does not carry that field.
142
+ */
143
+ export function mergeModelCatalog(input) {
144
+ const metadata = collectCandidates(input);
145
+ const live = indexLiveModels(input);
146
+ const current = isPlainObject(input.current) ? input.current : {};
147
+ const models = {};
148
+ const added = [];
149
+ const changed = [];
150
+ const entries = [...live].sort(([left], [right]) => (left < right ? -1 : 1));
151
+ for (const [key, liveModel] of entries) {
152
+ const base = isPlainObject(current[key]) ? current[key] : {};
153
+ const fromModelsDev = metadata.get(key);
154
+ // models.dev wins over the current file (it is the richer source); the
155
+ // live payload only fills the holes models.dev leaves, so a value the user
156
+ // customized is never replaced by a bare live stub.
157
+ const entry = fromModelsDev ? overlay(base, { ...fromModelsDev, id: key }) : base;
158
+ const merged = fillMissing(entry, liveStub(key, liveModel));
159
+ models[key] = sortKeysDeep({ ...merged, id: key });
160
+ if (!(key in current))
161
+ added.push(key);
162
+ else if (!isDeepStrictEqual(current[key], models[key]))
163
+ changed.push(key);
164
+ }
165
+ return {
166
+ models,
167
+ added,
168
+ removed: Object.keys(current)
169
+ .filter((key) => !live.has(key))
170
+ .sort(),
171
+ changed,
172
+ };
173
+ }
174
+ /** Build the models.dev candidate map: opencode-go first, commandcode fills the gaps. */
175
+ function collectCandidates(input) {
176
+ const opencodeCatalog = extractModelsDevProviderModels(input.modelsDev, MODELS_DEV_PROVIDER_ID);
177
+ const commandcodeCatalog = extractModelsDevProviderModels(input.modelsDev, MODELS_DEV_COMMANDCODE_PROVIDER_ID);
178
+ const opencodeKeys = indexProviderIds(MODELS_DEV_PROVIDER_ID, input.opencodeModels);
179
+ const commandcodeKeys = indexProviderIds(MODELS_DEV_COMMANDCODE_PROVIDER_ID, input.commandcodeModels);
180
+ const candidates = new Map();
181
+ addProviderModels(candidates, MODELS_DEV_PROVIDER_ID, opencodeCatalog, opencodeKeys, false);
182
+ addProviderModels(candidates, MODELS_DEV_COMMANDCODE_PROVIDER_ID, commandcodeCatalog, commandcodeKeys, true);
183
+ return candidates;
184
+ }
185
+ /**
186
+ * Index both live catalogs by key. opencode-go is the primary provider, so its
187
+ * entry wins when the same model is served by both.
188
+ */
189
+ function indexLiveModels(input) {
190
+ const byKey = new Map();
191
+ for (const model of [...input.opencodeModels, ...input.commandcodeModels]) {
192
+ const key = normalizeModelKey(model.id);
193
+ if (key && !byKey.has(key))
194
+ byKey.set(key, model);
195
+ }
196
+ return byKey;
197
+ }
198
+ /**
199
+ * Index one provider's live ids by catalog key, rejecting ambiguity.
200
+ *
201
+ * Two different raw ids (`vendorA/foo`, `vendorB/foo`) that collapse to the
202
+ * same bare key would silently overwrite each other, so the refresh aborts
203
+ * instead of picking one.
204
+ */
205
+ function indexProviderIds(providerId, live) {
206
+ const byKey = new Map();
207
+ for (const model of live) {
208
+ const key = normalizeModelKey(model.id);
209
+ if (!key)
210
+ continue;
211
+ const existing = byKey.get(key);
212
+ if (existing)
213
+ throw collisionError(providerId, existing.id, model.id, key);
214
+ byKey.set(key, model);
215
+ }
216
+ return byKey;
217
+ }
218
+ /** The bare-name collision message: which raw ids fought over one key. */
219
+ function collisionError(providerId, first, second, key) {
220
+ return new Error(`bare-name collision in ${providerId}: "${first}" and "${second}" both map to "${key}"; `
221
+ + 'rename one of them upstream or teach normalizeModelKey about the difference');
222
+ }
223
+ /**
224
+ * Add one provider's models.dev entries that exist in its live catalog.
225
+ *
226
+ * @param fillOnly — keep the existing entry's fields and only fill the holes
227
+ * (CommandCode is secondary metadata; opencode-go is primary)
228
+ */
229
+ function addProviderModels(target, providerId, models, allowed, fillOnly) {
230
+ for (const [modelId, raw] of Object.entries(models)) {
231
+ const key = normalizeModelKey(modelId);
232
+ const liveModel = allowed.get(key);
233
+ if (!liveModel)
234
+ continue;
235
+ // The same model under a different raw id in models.dev and the live
236
+ // payload is the cross-source form of the bare-name collision.
237
+ if (liveModel.id !== modelId)
238
+ throw collisionError(providerId, liveModel.id, modelId, key);
239
+ const entry = sanitizeEntry(raw);
240
+ if (!entry)
241
+ continue;
242
+ const existing = target.get(key);
243
+ if (!existing) {
244
+ target.set(key, entry);
245
+ }
246
+ else if (fillOnly) {
247
+ target.set(key, fillMissing(existing, entry));
248
+ }
249
+ }
250
+ }
251
+ /**
252
+ * Fallback context window for a live-only model whose payload carries no
253
+ * `context_length`; same default `providerCatalogToCanonical` uses.
254
+ */
255
+ const LIVE_STUB_CONTEXT_FALLBACK = 262_144;
256
+ /** Fallback max output tokens; same default `providerCatalogToCanonical` uses. */
257
+ const LIVE_STUB_OUTPUT_FALLBACK = 65_536;
258
+ /**
259
+ * Minimal catalog entry from a live payload: id, name, context window and the
260
+ * canonical defaults the harness pipeline (`providerCatalogToCanonical`) gives
261
+ * a model with no models.dev metadata. Every value here only fills a hole, so
262
+ * models.dev and the current file still win wherever they have a field.
263
+ */
264
+ function liveStub(key, model) {
265
+ const stub = {
266
+ id: key,
267
+ tool_call: true,
268
+ reasoning: true,
269
+ limit: {
270
+ context: model.context_length ?? LIVE_STUB_CONTEXT_FALLBACK,
271
+ output: LIVE_STUB_OUTPUT_FALLBACK,
272
+ },
273
+ modalities: { input: ['text'], output: ['text'] },
274
+ // Conservative effort ladder: the commandcode gateway accepts
275
+ // low|medium|high|xhigh and rejects minimal, so declare the safe three.
276
+ reasoning_options: conservativeEffortOptions(),
277
+ };
278
+ if (model.name !== undefined)
279
+ stub.name = model.name;
280
+ return stub;
281
+ }
282
+ /**
283
+ * Normalize an id to catalog key form: bare name, lower-cased, `_` → `-`.
284
+ * Vendor-prefixed names (`zai-org/GLM-5.3`) collapse to their model name.
285
+ */
286
+ function normalizeModelKey(modelId) {
287
+ const bare = modelId.substring(modelId.lastIndexOf('/') + 1);
288
+ return bare.toLowerCase().replaceAll('_', '-');
289
+ }
290
+ /** Keep only the fields the catalog schema documents. */
291
+ function sanitizeEntry(raw) {
292
+ if (!isPlainObject(raw))
293
+ return null;
294
+ const entry = {};
295
+ for (const field of MODELS_DEV_SAFE_FIELDS) {
296
+ if (field in raw)
297
+ entry[field] = raw[field];
298
+ }
299
+ return Object.keys(entry).length > 0 ? entry : null;
300
+ }
301
+ /** Recursive overlay: `upstream` wins at every leaf. */
302
+ function overlay(base, upstream) {
303
+ const merged = { ...base };
304
+ for (const [field, value] of Object.entries(upstream)) {
305
+ const current = merged[field];
306
+ merged[field] = isPlainObject(current) && isPlainObject(value)
307
+ ? overlay(current, value)
308
+ : value;
309
+ }
310
+ return merged;
311
+ }
312
+ /** Recursive fill: values already in `base` survive, holes come from `extra`. */
313
+ function fillMissing(base, extra) {
314
+ const merged = { ...base };
315
+ for (const [field, value] of Object.entries(extra)) {
316
+ const current = merged[field];
317
+ if (!(field in merged)) {
318
+ merged[field] = value;
319
+ }
320
+ else if (isPlainObject(current) && isPlainObject(value)) {
321
+ merged[field] = fillMissing(current, value);
322
+ }
323
+ }
324
+ return merged;
325
+ }
326
+ /** Sort object keys recursively; arrays keep their order. */
327
+ function sortKeysDeep(value) {
328
+ if (Array.isArray(value))
329
+ return value.map(sortKeysDeep);
330
+ if (!isPlainObject(value))
331
+ return value;
332
+ const sorted = {};
333
+ for (const key of Object.keys(value).sort()) {
334
+ sorted[key] = sortKeysDeep(value[key]);
335
+ }
336
+ return sorted;
337
+ }
338
+ /** Plain object (not null, not an array). */
339
+ function isPlainObject(value) {
340
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
341
+ }
342
+ // ---------------------------------------------------------------------------
343
+ // Serialization & orchestration
344
+ // ---------------------------------------------------------------------------
345
+ /** Stable on-disk format: 2-space indent, sorted keys, trailing newline. */
346
+ export function serializeModelCatalog(models) {
347
+ return serializeCatalogObject(sortKeysDeep(models));
348
+ }
349
+ /** Parse a catalog file. `null`/empty content is an empty catalog. */
350
+ export function parseCatalogContent(raw) {
351
+ if (raw === null || raw.trim() === '')
352
+ return {};
353
+ return parseCatalogObject(raw);
354
+ }
355
+ /**
356
+ * Fetch, merge, and (unless dry-running) write the catalog.
357
+ *
358
+ * Sources are validated before the file is read: a 2xx with an empty body (a
359
+ * Cloudflare hiccup, a partial rollout) used to empty the catalog and mark
360
+ * every model removed. A merge that would drop more than half of the current
361
+ * models is treated the same way unless `allowShrink` opts in.
362
+ */
363
+ export async function refreshModelCatalog(io, fetchers = defaultRefreshFetchers(), options = {}) {
364
+ const [modelsDev, opencodeModels, commandcodeModels] = await Promise.all([
365
+ fetchers.modelsDev(),
366
+ fetchers.opencodeModels(),
367
+ fetchers.commandcodeModels(),
368
+ ]);
369
+ assertRefreshSources(modelsDev, opencodeModels, commandcodeModels);
370
+ const previousContent = io.read();
371
+ const current = parseCatalogContent(previousContent);
372
+ const merge = mergeModelCatalog({
373
+ modelsDev,
374
+ opencodeModels,
375
+ commandcodeModels,
376
+ current,
377
+ });
378
+ assertShrinkAllowed(merge, current, options.allowShrink);
379
+ const nextContent = serializeModelCatalog(merge.models);
380
+ const bytesChanged = nextContent !== previousContent;
381
+ const formattingOnly = bytesChanged
382
+ && merge.added.length === 0
383
+ && merge.removed.length === 0
384
+ && merge.changed.length === 0;
385
+ const written = options.dryRun !== true && bytesChanged;
386
+ if (written)
387
+ io.write(nextContent);
388
+ return { ...merge, previousContent, nextContent, written, formattingOnly };
389
+ }
390
+ /**
391
+ * Reject a source that answered 2xx with an empty or partial payload.
392
+ *
393
+ * models.dev must carry both provider maps; each live catalog must carry at
394
+ * least one usable id. Anything else is a network hiccup, not a catalog.
395
+ */
396
+ function assertRefreshSources(modelsDev, opencodeModels, commandcodeModels) {
397
+ for (const providerId of [MODELS_DEV_PROVIDER_ID, MODELS_DEV_COMMANDCODE_PROVIDER_ID]) {
398
+ const models = extractModelsDevProviderModels(modelsDev, providerId);
399
+ if (Object.keys(models).length === 0) {
400
+ throw new Error(`models.dev returned no ${providerId} models; refusing to treat an empty response as a catalog`);
401
+ }
402
+ }
403
+ if (opencodeModels.length === 0) {
404
+ throw new Error('opencode-go returned no models; refusing to treat an empty response as a catalog');
405
+ }
406
+ if (commandcodeModels.length === 0) {
407
+ throw new Error('commandcode returned no models; refusing to treat an empty response as a catalog');
408
+ }
409
+ }
410
+ /**
411
+ * Refuse a merge that removes more than half of the current models.
412
+ *
413
+ * A partial upstream response looks exactly like a mass retirement, and the
414
+ * resulting file would drop models the proxy still serves. `allowShrink` is
415
+ * the operator's statement that the removal is intentional.
416
+ */
417
+ function assertShrinkAllowed(merge, current, allowShrink) {
418
+ if (allowShrink)
419
+ return;
420
+ const total = Object.keys(current).length;
421
+ if (total === 0 || merge.removed.length * 2 <= total)
422
+ return;
423
+ const percent = Math.round((merge.removed.length / total) * 100);
424
+ throw new Error(`refusing to remove ${merge.removed.length} of ${total} models (${percent}%): `
425
+ + 'neither live catalog lists them any more, which is how a partial upstream response looks. '
426
+ + 'Rerun with --allow-shrink if the removal is intentional');
427
+ }
428
+ // ---------------------------------------------------------------------------
429
+ // Diff rendering (--dry-run)
430
+ // ---------------------------------------------------------------------------
431
+ /** Human-readable model-level diff between the old and the new catalog. */
432
+ export function formatCatalogDiff(previous, next) {
433
+ const lines = [];
434
+ const previousModels = previous ?? {};
435
+ const nextKeys = Object.keys(next);
436
+ for (const key of Object.keys(previousModels).sort()) {
437
+ if (!(key in next))
438
+ lines.push(...entryBlock('-', key, previousModels[key]));
439
+ }
440
+ for (const key of nextKeys) {
441
+ if (!(key in previousModels))
442
+ lines.push(...entryBlock('+', key, next[key]));
443
+ }
444
+ for (const key of nextKeys) {
445
+ if (!(key in previousModels))
446
+ continue;
447
+ if (isDeepStrictEqual(previousModels[key], next[key]))
448
+ continue;
449
+ lines.push(`~ ${key}`);
450
+ lines.push(...diffLines(prettyLines(previousModels[key]), prettyLines(next[key])));
451
+ }
452
+ return lines.length > 0 ? lines.join('\n') : 'no changes';
453
+ }
454
+ /** Render one model entry as a prefixed JSON block. */
455
+ function entryBlock(prefix, key, value) {
456
+ const [first, ...rest] = prettyLines(value);
457
+ return [`${prefix} ${JSON.stringify(key)}: ${first}`, ...rest.map((line) => `${prefix} ${line}`)];
458
+ }
459
+ /** Pretty-print a value as lines. */
460
+ function prettyLines(value) {
461
+ return JSON.stringify(value, null, 2).split('\n');
462
+ }
463
+ /** Line diff with common prefix/suffix trimmed (LCS over the middle). */
464
+ function diffLines(before, after) {
465
+ let start = 0;
466
+ while (start < before.length && start < after.length && before[start] === after[start])
467
+ start++;
468
+ let endBefore = before.length;
469
+ let endAfter = after.length;
470
+ while (endBefore > start &&
471
+ endAfter > start &&
472
+ before[endBefore - 1] === after[endAfter - 1]) {
473
+ endBefore--;
474
+ endAfter--;
475
+ }
476
+ const middleBefore = before.slice(start, endBefore);
477
+ const middleAfter = after.slice(start, endAfter);
478
+ const out = before.slice(0, start).map((line) => ` ${line}`);
479
+ out.push(...emitDiff(middleBefore, middleAfter));
480
+ out.push(...before.slice(endBefore).map((line) => ` ${line}`));
481
+ return out;
482
+ }
483
+ /** Walk the LCS table to emit ` `/`-`/`+` lines for the changed middle. */
484
+ function emitDiff(before, after) {
485
+ const table = lcsTable(before, after);
486
+ const out = [];
487
+ let i = 0;
488
+ let j = 0;
489
+ while (i < before.length && j < after.length) {
490
+ if (before[i] === after[j]) {
491
+ out.push(` ${before[i]}`);
492
+ i++;
493
+ j++;
494
+ }
495
+ else if (table[i + 1][j] >= table[i][j + 1]) {
496
+ out.push(`- ${before[i]}`);
497
+ i++;
498
+ }
499
+ else {
500
+ out.push(`+ ${after[j]}`);
501
+ j++;
502
+ }
503
+ }
504
+ for (; i < before.length; i++)
505
+ out.push(`- ${before[i]}`);
506
+ for (; j < after.length; j++)
507
+ out.push(`+ ${after[j]}`);
508
+ return out;
509
+ }
510
+ /** LCS length table where `table[i][j]` covers `before[i:]` × `after[j:]`. */
511
+ function lcsTable(before, after) {
512
+ const table = Array.from({ length: before.length + 1 }, () => new Array(after.length + 1).fill(0));
513
+ for (let i = before.length - 1; i >= 0; i--) {
514
+ for (let j = after.length - 1; j >= 0; j--) {
515
+ table[i][j] = before[i] === after[j]
516
+ ? table[i + 1][j + 1] + 1
517
+ : Math.max(table[i + 1][j], table[i][j + 1]);
518
+ }
519
+ }
520
+ return table;
521
+ }
522
+ //# sourceMappingURL=models-refresh.js.map