agentsw 0.7.2 → 0.9.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.
- package/README.md +82 -4
- package/README.zh-CN.md +67 -3
- package/dist/commands.js +60 -18
- package/dist/fsutil.js +14 -1
- package/dist/gateway.js +201 -0
- package/dist/i18n.js +26 -6
- package/dist/index.js +26 -1
- package/dist/jsonc.js +4 -5
- package/dist/menu.js +62 -4
- package/dist/metadata.js +400 -0
- package/dist/modelsdev.js +28 -8
- package/dist/targets/codex.js +15 -4
- package/dist/yaml.js +28 -7
- package/package.json +1 -1
package/dist/metadata.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { findModelMeta, getCatalogFetchedAt, toModelSpec } from "./modelsdev.js";
|
|
2
|
+
import { loadGatewayCatalog } from "./gateway.js";
|
|
3
|
+
const forbiddenKeys = new Set(["__proto__", "prototype", "constructor"]);
|
|
4
|
+
const canonicalId = /^[^/\s]+\/[^/\s]+(?:\/[^/\s]+)*$/;
|
|
5
|
+
export function getMetadataMode(settings) {
|
|
6
|
+
if (settings.gatewayMetadata === undefined || settings.gatewayMetadata === "auto")
|
|
7
|
+
return "auto";
|
|
8
|
+
if (settings.gatewayMetadata === true)
|
|
9
|
+
return "on";
|
|
10
|
+
if (settings.gatewayMetadata === false)
|
|
11
|
+
return "off";
|
|
12
|
+
throw new Error("Gateway metadata must be a boolean or auto");
|
|
13
|
+
}
|
|
14
|
+
function validateSettings(settings) {
|
|
15
|
+
const result = {};
|
|
16
|
+
if (settings.gatewayMetadata !== undefined) {
|
|
17
|
+
getMetadataMode(settings);
|
|
18
|
+
result.gatewayMetadata = settings.gatewayMetadata;
|
|
19
|
+
}
|
|
20
|
+
const aliases = settings.gatewayModelAliases;
|
|
21
|
+
if (aliases !== undefined) {
|
|
22
|
+
if (!aliases || typeof aliases !== "object" || Array.isArray(aliases)
|
|
23
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(aliases))) {
|
|
24
|
+
throw new Error("Gateway model mappings must be a plain object of exact IDs to creator/model IDs");
|
|
25
|
+
}
|
|
26
|
+
const entries = Object.entries(aliases);
|
|
27
|
+
for (const [id, target] of entries) {
|
|
28
|
+
if (!id || /\s/.test(id) || forbiddenKeys.has(id) || typeof target !== "string" || !canonicalId.test(target)) {
|
|
29
|
+
throw new Error("Gateway model mappings must contain valid exact IDs and creator/model IDs");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
result.gatewayModelAliases = Object.fromEntries(entries);
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
/** Validate before any mutation or fetch; omitted settings preserve the previous values. */
|
|
37
|
+
export function resolveMetadataOptions(opts, previous) {
|
|
38
|
+
const settings = validateSettings(previous ?? {});
|
|
39
|
+
if (opts.gatewayMetadata !== undefined) {
|
|
40
|
+
if (typeof opts.gatewayMetadata !== "boolean")
|
|
41
|
+
throw new Error("Gateway metadata must be a boolean");
|
|
42
|
+
settings.gatewayMetadata = opts.gatewayMetadata;
|
|
43
|
+
}
|
|
44
|
+
if (opts.metadataMode !== undefined) {
|
|
45
|
+
if (opts.metadataMode !== "auto" && opts.metadataMode !== "on" && opts.metadataMode !== "off") {
|
|
46
|
+
throw new Error("Metadata mode must be auto, on, or off");
|
|
47
|
+
}
|
|
48
|
+
const mode = opts.metadataMode === "auto" ? "auto" : opts.metadataMode === "on";
|
|
49
|
+
if (opts.gatewayMetadata !== undefined && opts.gatewayMetadata !== mode) {
|
|
50
|
+
throw new Error("Metadata mode conflicts with Gateway metadata flag");
|
|
51
|
+
}
|
|
52
|
+
settings.gatewayMetadata = mode;
|
|
53
|
+
}
|
|
54
|
+
if (opts.gatewayModels !== undefined) {
|
|
55
|
+
if (typeof opts.gatewayModels !== "string")
|
|
56
|
+
throw new Error("Gateway model mappings must be a JSON object");
|
|
57
|
+
let aliases;
|
|
58
|
+
try {
|
|
59
|
+
aliases = JSON.parse(opts.gatewayModels);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new Error("Gateway model mappings must be a JSON object");
|
|
63
|
+
}
|
|
64
|
+
settings.gatewayModelAliases = validateSettings({ gatewayModelAliases: aliases }).gatewayModelAliases;
|
|
65
|
+
}
|
|
66
|
+
return settings;
|
|
67
|
+
}
|
|
68
|
+
const fields = [
|
|
69
|
+
"name", "contextWindow", "maxInput", "maxOutput", "reasoning", "reasoningEfforts", "imageInput",
|
|
70
|
+
"cost.input", "cost.output", "cost.cacheRead", "cost.cacheWrite",
|
|
71
|
+
];
|
|
72
|
+
const gatewayFields = fields.filter((field) => !field.startsWith("cost."));
|
|
73
|
+
const limits = ["maxInput", "maxOutput"];
|
|
74
|
+
const coreFields = ["contextWindow", "maxOutput", "reasoning", "imageInput"];
|
|
75
|
+
function fieldValue(spec, field) {
|
|
76
|
+
if (field.startsWith("cost."))
|
|
77
|
+
return spec.cost?.[field.slice(5)];
|
|
78
|
+
return spec[field];
|
|
79
|
+
}
|
|
80
|
+
function setField(spec, field, value) {
|
|
81
|
+
if (field.startsWith("cost.")) {
|
|
82
|
+
const leaf = field.slice(5);
|
|
83
|
+
if (value === undefined) {
|
|
84
|
+
if (spec.cost)
|
|
85
|
+
delete spec.cost[leaf];
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
spec.cost ??= {};
|
|
89
|
+
spec.cost[leaf] = value;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const record = spec;
|
|
94
|
+
if (value === undefined)
|
|
95
|
+
delete record[field];
|
|
96
|
+
else
|
|
97
|
+
record[field] = Array.isArray(value) ? [...value] : value;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function equalValue(a, b) {
|
|
101
|
+
if (Array.isArray(a) && Array.isArray(b))
|
|
102
|
+
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
103
|
+
return a === b;
|
|
104
|
+
}
|
|
105
|
+
function indexCatalog(catalog) {
|
|
106
|
+
const exact = new Map();
|
|
107
|
+
const canonical = new Map();
|
|
108
|
+
for (const provider of Object.values(catalog)) {
|
|
109
|
+
for (const [key, model] of Object.entries(provider.models)) {
|
|
110
|
+
const modelId = model.id.includes("/") ? model.id : `${provider.id}/${model.id}`;
|
|
111
|
+
const entry = { provider: provider.id, modelId, model };
|
|
112
|
+
for (const id of new Set([key, model.id])) {
|
|
113
|
+
const matches = exact.get(id);
|
|
114
|
+
if (matches)
|
|
115
|
+
matches.push(entry);
|
|
116
|
+
else
|
|
117
|
+
exact.set(id, [entry]);
|
|
118
|
+
}
|
|
119
|
+
const matches = canonical.get(modelId);
|
|
120
|
+
if (matches)
|
|
121
|
+
matches.push(entry);
|
|
122
|
+
else
|
|
123
|
+
canonical.set(modelId, [entry]);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { exact, canonical };
|
|
127
|
+
}
|
|
128
|
+
function indexGatewayBareIds(gateway) {
|
|
129
|
+
const result = new Map();
|
|
130
|
+
for (const id of Object.keys(gateway.models)) {
|
|
131
|
+
const slash = id.indexOf("/");
|
|
132
|
+
if (slash < 1 || slash !== id.lastIndexOf("/") || !canonicalId.test(id))
|
|
133
|
+
continue;
|
|
134
|
+
const bare = id.slice(slash + 1);
|
|
135
|
+
result.set(bare, result.has(bare) ? null : id);
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
function selectExact(index, id, hint, aliases, gateway, gatewayBareIds) {
|
|
140
|
+
const alias = aliases && Object.hasOwn(aliases, id) ? aliases[id] : undefined;
|
|
141
|
+
const bare = !id.includes("/");
|
|
142
|
+
let gatewayId = alias;
|
|
143
|
+
if (gatewayId === undefined && gateway) {
|
|
144
|
+
if (canonicalId.test(id) && Object.hasOwn(gateway.models, id))
|
|
145
|
+
gatewayId = id;
|
|
146
|
+
else if (bare) {
|
|
147
|
+
const candidate = gatewayBareIds?.get(id);
|
|
148
|
+
const evidence = index?.exact.get(id);
|
|
149
|
+
const hintedEvidence = hint ? evidence?.filter((entry) => entry.provider === hint) : undefined;
|
|
150
|
+
const identities = new Set((hintedEvidence?.length ? hintedEvidence : evidence)?.map((entry) => entry.modelId));
|
|
151
|
+
// A unique exact basename is safe only with absent or agreeing primary evidence.
|
|
152
|
+
// Explicit provider hints can resolve primary ambiguity, but never Gateway ambiguity.
|
|
153
|
+
if (candidate && (identities.size === 0 || (identities.size === 1 && identities.has(candidate))))
|
|
154
|
+
gatewayId = candidate;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const candidates = gatewayId !== undefined
|
|
158
|
+
? index?.canonical.get(gatewayId)
|
|
159
|
+
: (bare && hint ? index?.canonical.get(`${hint}/${id}`) : undefined)
|
|
160
|
+
?? index?.canonical.get(id) ?? index?.exact.get(id);
|
|
161
|
+
const hinted = candidates?.find((entry) => entry.provider === hint);
|
|
162
|
+
const identities = new Set(candidates?.map((entry) => entry.modelId));
|
|
163
|
+
const primary = hinted ?? (identities.size === 1
|
|
164
|
+
? candidates?.find((entry) => entry.modelId.startsWith(`${entry.provider}/`)) ?? candidates?.[0]
|
|
165
|
+
: undefined);
|
|
166
|
+
return { primary, gatewayId, alias };
|
|
167
|
+
}
|
|
168
|
+
function legacyPrimary(catalog, id, hint) {
|
|
169
|
+
const hit = findModelMeta(catalog, id, hint);
|
|
170
|
+
if (!hit)
|
|
171
|
+
return undefined;
|
|
172
|
+
const provider = Object.values(catalog).find((candidate) => candidate.id === hit.provider);
|
|
173
|
+
const base = id.slice(id.lastIndexOf("/") + 1).toLowerCase();
|
|
174
|
+
const model = provider && (Object.hasOwn(provider.models, id) ? provider.models[id]
|
|
175
|
+
: Object.entries(provider.models).find(([key]) => key.slice(key.lastIndexOf("/") + 1).toLowerCase() === base)?.[1]);
|
|
176
|
+
if (!model)
|
|
177
|
+
return undefined;
|
|
178
|
+
hit.spec.id = model.id;
|
|
179
|
+
return { spec: hit.spec, modelId: model.id.includes("/") ? model.id : `${hit.provider}/${model.id}` };
|
|
180
|
+
}
|
|
181
|
+
function clearGatewayIdentity(spec) {
|
|
182
|
+
const metadata = spec.metadata;
|
|
183
|
+
if (!metadata)
|
|
184
|
+
return;
|
|
185
|
+
for (const field of fields) {
|
|
186
|
+
const origin = metadata.fields?.[field];
|
|
187
|
+
if (origin?.source !== "ai-gateway")
|
|
188
|
+
continue;
|
|
189
|
+
if (equalValue(fieldValue(spec, field), origin.value))
|
|
190
|
+
setField(spec, field, undefined);
|
|
191
|
+
delete metadata.fields[field];
|
|
192
|
+
}
|
|
193
|
+
delete metadata.gateway;
|
|
194
|
+
if (metadata.conflicts)
|
|
195
|
+
metadata.conflicts = metadata.conflicts.filter((conflict) => conflict.source !== "ai-gateway");
|
|
196
|
+
}
|
|
197
|
+
function clearStaleIdentity(spec, modelId) {
|
|
198
|
+
const metadata = spec.metadata;
|
|
199
|
+
if (!metadata)
|
|
200
|
+
return;
|
|
201
|
+
for (const field of fields) {
|
|
202
|
+
const origin = metadata.fields?.[field];
|
|
203
|
+
if (!origin || origin.modelId === modelId)
|
|
204
|
+
continue;
|
|
205
|
+
if (equalValue(fieldValue(spec, field), origin.value))
|
|
206
|
+
setField(spec, field, undefined);
|
|
207
|
+
delete metadata.fields[field];
|
|
208
|
+
}
|
|
209
|
+
if (metadata.gateway && metadata.gateway.modelId !== modelId)
|
|
210
|
+
delete metadata.gateway;
|
|
211
|
+
if (metadata.conflicts)
|
|
212
|
+
metadata.conflicts = metadata.conflicts.filter((conflict) => conflict.modelId === modelId);
|
|
213
|
+
}
|
|
214
|
+
/** Apply a source as a set of field proposals, checking bounds against retained values first. */
|
|
215
|
+
function mergeSource(spec, incoming, source, modelId, now, fetchedAt, supplementalGateway) {
|
|
216
|
+
const sourceFields = source === "ai-gateway" ? gatewayFields : fields;
|
|
217
|
+
const proposals = new Map();
|
|
218
|
+
const conflicts = [];
|
|
219
|
+
const conflict = (field, value, keptValue, blockedBy) => {
|
|
220
|
+
conflicts.push({ field, source, modelId, value: Array.isArray(value) ? [...value] : value,
|
|
221
|
+
keptValue: Array.isArray(keptValue) ? [...keptValue] : keptValue, ...(blockedBy ? { blockedBy } : {}) });
|
|
222
|
+
};
|
|
223
|
+
for (const field of sourceFields) {
|
|
224
|
+
const value = fieldValue(incoming, field);
|
|
225
|
+
if (value === undefined)
|
|
226
|
+
continue;
|
|
227
|
+
if (field === "name" && (value === incoming.id || value === modelId))
|
|
228
|
+
continue;
|
|
229
|
+
const current = fieldValue(spec, field);
|
|
230
|
+
const origin = spec.metadata?.fields?.[field];
|
|
231
|
+
if (current === undefined || (origin && (source === "models.dev" || origin.source === source))) {
|
|
232
|
+
proposals.set(field, value);
|
|
233
|
+
}
|
|
234
|
+
else if (!equalValue(current, value)) {
|
|
235
|
+
conflict(field, value, current);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// A proposed context may shrink only when every retained limit still fits.
|
|
239
|
+
let context = proposals.get("contextWindow") ?? spec.contextWindow;
|
|
240
|
+
if (typeof context === "number" && proposals.has("contextWindow")) {
|
|
241
|
+
for (const field of limits) {
|
|
242
|
+
const proposed = proposals.get(field);
|
|
243
|
+
const primaryFits = typeof proposed === "number" && proposed <= context;
|
|
244
|
+
let retained = primaryFits ? proposed : fieldValue(spec, field);
|
|
245
|
+
// The supplement runs next: account for a still-owned limit that it can
|
|
246
|
+
// lower in this same refresh, rather than requiring a second refresh.
|
|
247
|
+
const next = supplementalGateway && spec.metadata?.fields?.[field]?.source === "ai-gateway"
|
|
248
|
+
? supplementalGateway[field] : undefined;
|
|
249
|
+
if (!primaryFits && typeof next === "number" && next <= context)
|
|
250
|
+
retained = next;
|
|
251
|
+
if (typeof retained === "number" && retained > context) {
|
|
252
|
+
conflict("contextWindow", context, retained, field);
|
|
253
|
+
proposals.delete("contextWindow");
|
|
254
|
+
context = spec.contextWindow;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
for (const field of limits) {
|
|
260
|
+
const value = proposals.get(field);
|
|
261
|
+
if (typeof value === "number" && typeof context === "number" && value > context) {
|
|
262
|
+
conflict(field, value, context, "contextWindow");
|
|
263
|
+
proposals.delete(field);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const reasoning = proposals.get("reasoning") ?? spec.reasoning;
|
|
267
|
+
if (reasoning === false) {
|
|
268
|
+
const efforts = proposals.get("reasoningEfforts");
|
|
269
|
+
const emptyEfforts = Array.isArray(efforts) && efforts.length === 0;
|
|
270
|
+
if (efforts !== undefined && !emptyEfforts) {
|
|
271
|
+
conflict("reasoningEfforts", efforts, false, "reasoning");
|
|
272
|
+
proposals.delete("reasoningEfforts");
|
|
273
|
+
}
|
|
274
|
+
// Do not introduce a contradictory false flag over retained effort settings.
|
|
275
|
+
if (proposals.has("reasoning") && spec.reasoning !== false && !emptyEfforts && spec.reasoningEfforts?.length) {
|
|
276
|
+
conflict("reasoning", false, spec.reasoningEfforts, "reasoningEfforts");
|
|
277
|
+
proposals.delete("reasoning");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const metadata = spec.metadata ??= {};
|
|
281
|
+
if (metadata.conflicts)
|
|
282
|
+
metadata.conflicts = metadata.conflicts.filter((entry) => entry.source !== source);
|
|
283
|
+
if (conflicts.length)
|
|
284
|
+
metadata.conflicts = [...(metadata.conflicts ?? []), ...conflicts];
|
|
285
|
+
for (const [field, value] of proposals) {
|
|
286
|
+
const previous = metadata.fields?.[field];
|
|
287
|
+
const unchanged = previous?.source === source && previous.modelId === modelId && equalValue(previous.value, value);
|
|
288
|
+
const origin = {
|
|
289
|
+
source, modelId, value: Array.isArray(value) ? [...value] : value,
|
|
290
|
+
updatedAt: unchanged ? previous.updatedAt : now,
|
|
291
|
+
};
|
|
292
|
+
const timestamp = fetchedAt ?? (unchanged ? previous.fetchedAt : undefined);
|
|
293
|
+
if (timestamp !== undefined)
|
|
294
|
+
origin.fetchedAt = timestamp;
|
|
295
|
+
(metadata.fields ??= {})[field] = origin;
|
|
296
|
+
setField(spec, field, value);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/** Supplement metadata only; IDs, routes, protocol and provider-effective pricing remain untouched. */
|
|
300
|
+
export async function enrichProviderModels(catalog, ids, settings, options = {}) {
|
|
301
|
+
const validated = validateSettings(settings);
|
|
302
|
+
const mode = getMetadataMode(validated);
|
|
303
|
+
if (ids.length === 0)
|
|
304
|
+
return [];
|
|
305
|
+
const enabled = mode !== "off";
|
|
306
|
+
const index = enabled && catalog ? indexCatalog(catalog) : undefined;
|
|
307
|
+
const existing = new Map(settings.models?.map((model) => [model.id, model]));
|
|
308
|
+
const fetchedAt = catalog ? getCatalogFetchedAt(catalog) : undefined;
|
|
309
|
+
const now = new Date().toISOString();
|
|
310
|
+
const enrich = (gateway) => {
|
|
311
|
+
const gatewayBareIds = gateway ? indexGatewayBareIds(gateway) : undefined;
|
|
312
|
+
return ids.map((id) => {
|
|
313
|
+
const previous = existing.get(id);
|
|
314
|
+
const spec = previous ? structuredClone(previous) : { id };
|
|
315
|
+
for (const field of fields) {
|
|
316
|
+
const origin = spec.metadata?.fields?.[field];
|
|
317
|
+
if (origin && !equalValue(fieldValue(spec, field), origin.value))
|
|
318
|
+
delete spec.metadata.fields[field];
|
|
319
|
+
}
|
|
320
|
+
let primary;
|
|
321
|
+
let supplement;
|
|
322
|
+
let match;
|
|
323
|
+
if (enabled) {
|
|
324
|
+
match = selectExact(index, id, settings.modelsDevId, validated.gatewayModelAliases, gateway, gatewayBareIds);
|
|
325
|
+
const oldGateway = spec.metadata?.gateway;
|
|
326
|
+
if (oldGateway && (oldGateway.alias !== match.alias
|
|
327
|
+
|| (match.gatewayId !== undefined && oldGateway.modelId !== match.gatewayId))) {
|
|
328
|
+
clearGatewayIdentity(spec);
|
|
329
|
+
}
|
|
330
|
+
if (match.primary)
|
|
331
|
+
primary = { spec: toModelSpec(match.primary.model.id, match.primary.model), modelId: match.primary.modelId };
|
|
332
|
+
if (gateway && match.gatewayId !== undefined && Object.hasOwn(gateway.models, match.gatewayId)) {
|
|
333
|
+
supplement = gateway.models[match.gatewayId];
|
|
334
|
+
}
|
|
335
|
+
// An explicit mapping establishes identity even offline. An outage without
|
|
336
|
+
// a mapping or exact catalog evidence does not justify deleting metadata.
|
|
337
|
+
const confirmedId = match.alias ?? match.primary?.modelId ?? supplement?.id;
|
|
338
|
+
if (confirmedId !== undefined)
|
|
339
|
+
clearStaleIdentity(spec, confirmedId);
|
|
340
|
+
}
|
|
341
|
+
else if (catalog) {
|
|
342
|
+
primary = legacyPrimary(catalog, id, settings.modelsDevId);
|
|
343
|
+
}
|
|
344
|
+
if (primary)
|
|
345
|
+
mergeSource(spec, primary.spec, "models.dev", primary.modelId, now, fetchedAt, supplement?.spec);
|
|
346
|
+
if (supplement && gateway && match) {
|
|
347
|
+
mergeSource(spec, supplement.spec, "ai-gateway", supplement.id, now, gateway.fetchedAt);
|
|
348
|
+
const audit = { modelId: supplement.id, fetchedAt: gateway.fetchedAt };
|
|
349
|
+
if (match.alias !== undefined)
|
|
350
|
+
audit.alias = match.alias;
|
|
351
|
+
if (supplement.referenceCost !== undefined)
|
|
352
|
+
audit.referenceCost = structuredClone(supplement.referenceCost);
|
|
353
|
+
if (supplement.pricingIsVariable !== undefined)
|
|
354
|
+
audit.pricingIsVariable = supplement.pricingIsVariable;
|
|
355
|
+
(spec.metadata ??= {}).gateway = audit;
|
|
356
|
+
}
|
|
357
|
+
if (spec.metadata?.fields && Object.keys(spec.metadata.fields).length === 0)
|
|
358
|
+
delete spec.metadata.fields;
|
|
359
|
+
if (spec.metadata?.conflicts?.length === 0)
|
|
360
|
+
delete spec.metadata.conflicts;
|
|
361
|
+
if (spec.metadata && Object.keys(spec.metadata).length === 0)
|
|
362
|
+
delete spec.metadata;
|
|
363
|
+
return spec;
|
|
364
|
+
});
|
|
365
|
+
};
|
|
366
|
+
if (!enabled)
|
|
367
|
+
return enrich();
|
|
368
|
+
if (options.gateway !== undefined)
|
|
369
|
+
return enrich(options.gateway ?? undefined);
|
|
370
|
+
const primaryOnly = mode === "auto" ? enrich() : undefined;
|
|
371
|
+
if (primaryOnly && !primaryOnly.some((spec) => {
|
|
372
|
+
if (coreFields.some((field) => spec[field] === undefined)
|
|
373
|
+
|| gatewayFields.some((field) => spec.metadata?.fields?.[field]?.source === "ai-gateway"))
|
|
374
|
+
return true;
|
|
375
|
+
// Complete legacy basename metadata still needs an exact identity check,
|
|
376
|
+
// unless an explicit mapping or current primary match already confirms it.
|
|
377
|
+
if (!canonicalId.test(spec.id) || !fields.some((field) => {
|
|
378
|
+
const origin = spec.metadata?.fields?.[field];
|
|
379
|
+
return origin && origin.modelId !== spec.id;
|
|
380
|
+
}))
|
|
381
|
+
return false;
|
|
382
|
+
const match = selectExact(index, spec.id, settings.modelsDevId, validated.gatewayModelAliases, undefined);
|
|
383
|
+
const confirmedId = match.alias ?? match.primary?.modelId ?? spec.id;
|
|
384
|
+
return fields.some((field) => {
|
|
385
|
+
const origin = spec.metadata?.fields?.[field];
|
|
386
|
+
return origin && origin.modelId !== confirmedId;
|
|
387
|
+
});
|
|
388
|
+
}))
|
|
389
|
+
return primaryOnly;
|
|
390
|
+
let gateway;
|
|
391
|
+
try {
|
|
392
|
+
gateway = await (options.gatewayLoader ?? loadGatewayCatalog)() ?? null;
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
gateway = null;
|
|
396
|
+
}
|
|
397
|
+
// Never feed the primary pre-pass back into merging: identity and coordinated
|
|
398
|
+
// bounds must be resolved with the supplement against the original values.
|
|
399
|
+
return gateway ? enrich(gateway) : primaryOnly ?? enrich();
|
|
400
|
+
}
|
package/dist/modelsdev.js
CHANGED
|
@@ -5,39 +5,57 @@ import { readJsonIfExists, writeFileAtomic } from "./fsutil.js";
|
|
|
5
5
|
const API_URL = "https://models.dev/api.json";
|
|
6
6
|
const CACHE_FILE = path.join(configDir, "models-dev.json");
|
|
7
7
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
const catalogFetchedAt = new WeakMap();
|
|
9
|
+
/** Fetch time is kept outside the provider dictionary and never changes its public shape. */
|
|
10
|
+
export function getCatalogFetchedAt(catalog) {
|
|
11
|
+
return catalogFetchedAt.get(catalog);
|
|
12
|
+
}
|
|
13
|
+
function readCachedCatalog() {
|
|
14
|
+
const catalog = readJsonIfExists(CACHE_FILE);
|
|
15
|
+
if (catalog) {
|
|
16
|
+
try {
|
|
17
|
+
catalogFetchedAt.set(catalog, fs.statSync(CACHE_FILE).mtime.toISOString());
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// A concurrently removed cache still supplies useful metadata without a fetch time.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return catalog;
|
|
24
|
+
}
|
|
8
25
|
export async function loadCatalog(opts = {}) {
|
|
9
26
|
if (!opts.refresh) {
|
|
10
27
|
try {
|
|
11
28
|
const stat = fs.statSync(CACHE_FILE);
|
|
12
29
|
const fresh = Date.now() - stat.mtimeMs < CACHE_TTL_MS;
|
|
13
30
|
if (fresh || opts.offline)
|
|
14
|
-
return
|
|
31
|
+
return readCachedCatalog();
|
|
15
32
|
}
|
|
16
33
|
catch {
|
|
17
34
|
/* no cache */
|
|
18
35
|
}
|
|
19
36
|
}
|
|
20
37
|
if (opts.offline)
|
|
21
|
-
return
|
|
38
|
+
return readCachedCatalog();
|
|
22
39
|
try {
|
|
23
40
|
const res = await fetch(API_URL, { signal: AbortSignal.timeout(15000) });
|
|
24
41
|
if (!res.ok)
|
|
25
42
|
throw new Error(`models.dev responded ${res.status}`);
|
|
26
43
|
const text = await res.text();
|
|
27
|
-
JSON.parse(text); // validate before caching
|
|
44
|
+
const catalog = JSON.parse(text); // validate before caching
|
|
28
45
|
writeFileAtomic(CACHE_FILE, text);
|
|
29
|
-
|
|
46
|
+
catalogFetchedAt.set(catalog, new Date().toISOString());
|
|
47
|
+
return catalog;
|
|
30
48
|
}
|
|
31
49
|
catch (err) {
|
|
32
50
|
// network failure: fall back to stale cache if present
|
|
33
|
-
const cached =
|
|
51
|
+
const cached = readCachedCatalog();
|
|
34
52
|
if (cached)
|
|
35
53
|
return cached;
|
|
36
54
|
process.stderr.write(`warning: could not fetch models.dev catalog: ${err.message}\n`);
|
|
37
55
|
return undefined;
|
|
38
56
|
}
|
|
39
57
|
}
|
|
40
|
-
function toModelSpec(id, m) {
|
|
58
|
+
export function toModelSpec(id, m) {
|
|
41
59
|
const efforts = m.reasoning_options?.find((o) => o.type === "effort")?.values;
|
|
42
60
|
return {
|
|
43
61
|
id,
|
|
@@ -46,8 +64,10 @@ function toModelSpec(id, m) {
|
|
|
46
64
|
maxInput: m.limit?.input,
|
|
47
65
|
maxOutput: m.limit?.output,
|
|
48
66
|
reasoning: m.reasoning,
|
|
49
|
-
reasoningEfforts: efforts
|
|
50
|
-
imageInput: m.attachment || m.modalities?.input
|
|
67
|
+
reasoningEfforts: efforts,
|
|
68
|
+
imageInput: m.attachment !== undefined || m.modalities?.input !== undefined
|
|
69
|
+
? m.attachment === true || m.modalities?.input?.includes("image") === true
|
|
70
|
+
: undefined,
|
|
51
71
|
cost: m.cost
|
|
52
72
|
? { input: m.cost.input, output: m.cost.output, cacheRead: m.cost.cache_read, cacheWrite: m.cost.cache_write }
|
|
53
73
|
: undefined,
|
package/dist/targets/codex.js
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
4
|
-
import { backupFile, home, readJsonIfExists, readTextIfExists, writeFileAtomic } from "../fsutil.js";
|
|
4
|
+
import { SafeConfigError, backupFile, home, readJsonIfExists, readTextIfExists, writeFileAtomic } from "../fsutil.js";
|
|
5
5
|
import { isJsonObject } from "../jsonc.js";
|
|
6
6
|
import { transactionalTarget } from "../target-transaction.js";
|
|
7
7
|
const dir = path.join(home, ".codex");
|
|
8
8
|
const configFile = path.join(dir, "config.toml");
|
|
9
9
|
const authFile = path.join(dir, "auth.json");
|
|
10
|
+
/** smol-toml quotes the offending source line, which may hold a key: report only the position. */
|
|
11
|
+
function parseConfigToml(file, text) {
|
|
12
|
+
try {
|
|
13
|
+
return parseToml(text);
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
const { line, column } = err;
|
|
17
|
+
const position = line === undefined ? "" : ` (line ${line}${column === undefined ? "" : `, col ${column}`})`;
|
|
18
|
+
throw new SafeConfigError(`${file}: invalid TOML configuration${position}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
10
21
|
/**
|
|
11
22
|
* Codex CLI: [model_providers.<id>] in ~/.codex/config.toml selects a custom
|
|
12
23
|
* OpenAI-protocol endpoint; the key ships via ~/.codex/auth.json OPENAI_API_KEY.
|
|
@@ -21,7 +32,7 @@ export const codex = transactionalTarget({
|
|
|
21
32
|
async apply(provider) {
|
|
22
33
|
const notes = [];
|
|
23
34
|
const text = readTextIfExists(configFile);
|
|
24
|
-
const config =
|
|
35
|
+
const config = text ? parseConfigToml(configFile, text) : {};
|
|
25
36
|
const authValue = readJsonIfExists(authFile);
|
|
26
37
|
const auth = authValue === undefined ? {} : authValue;
|
|
27
38
|
if (!isJsonObject(auth))
|
|
@@ -84,7 +95,7 @@ export const codex = transactionalTarget({
|
|
|
84
95
|
const text = readTextIfExists(configFile);
|
|
85
96
|
if (!text)
|
|
86
97
|
return { app: this.id, changed: [], notes: [], skipped: "no config.toml" };
|
|
87
|
-
const config =
|
|
98
|
+
const config = parseConfigToml(configFile, text);
|
|
88
99
|
if (config.model_providers !== undefined && !isJsonObject(config.model_providers)) {
|
|
89
100
|
throw new Error(`${configFile}: expected model_providers to be a table`);
|
|
90
101
|
}
|
|
@@ -114,7 +125,7 @@ export const codex = transactionalTarget({
|
|
|
114
125
|
if (!text)
|
|
115
126
|
return undefined;
|
|
116
127
|
try {
|
|
117
|
-
const config =
|
|
128
|
+
const config = parseConfigToml(configFile, text);
|
|
118
129
|
if (!config.model_provider)
|
|
119
130
|
return undefined;
|
|
120
131
|
return `${String(config.model_provider)} · ${String(config.model ?? "?")}`;
|
package/dist/yaml.js
CHANGED
|
@@ -1,12 +1,33 @@
|
|
|
1
1
|
import YAML from "yaml";
|
|
2
|
+
import { SafeConfigError } from "./fsutil.js";
|
|
3
|
+
/** YAML errors embed the offending source line, which may hold credentials: keep only the code and position. */
|
|
4
|
+
function describeYamlErrors(file, errors) {
|
|
5
|
+
const details = [...new Set(errors.map((error) => {
|
|
6
|
+
const position = error.linePos?.[0];
|
|
7
|
+
return [
|
|
8
|
+
error.code,
|
|
9
|
+
position ? `line ${position.line}` : undefined,
|
|
10
|
+
position ? `col ${position.col}` : undefined,
|
|
11
|
+
].filter(Boolean).join(" ");
|
|
12
|
+
}))].slice(0, 3);
|
|
13
|
+
return details.length
|
|
14
|
+
? `${file}: invalid YAML configuration (${details.join("; ")})`
|
|
15
|
+
: `${file}: invalid YAML configuration`;
|
|
16
|
+
}
|
|
2
17
|
/** Resolve aliases in document context before edits can mutate or remove anchors. */
|
|
3
18
|
export function parseYamlMapping(file, text) {
|
|
19
|
+
let doc;
|
|
20
|
+
try {
|
|
21
|
+
doc = text ? YAML.parseDocument(text) : new YAML.Document({});
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new SafeConfigError(`${file}: invalid YAML configuration`);
|
|
25
|
+
}
|
|
26
|
+
if (doc.errors.length)
|
|
27
|
+
throw new SafeConfigError(describeYamlErrors(file, doc.errors));
|
|
28
|
+
if (doc.contents == null)
|
|
29
|
+
doc.contents = doc.createNode({});
|
|
4
30
|
try {
|
|
5
|
-
const doc = text ? YAML.parseDocument(text) : new YAML.Document({});
|
|
6
|
-
if (doc.errors.length)
|
|
7
|
-
throw new Error("parse error");
|
|
8
|
-
if (doc.contents == null)
|
|
9
|
-
doc.contents = doc.createNode({});
|
|
10
31
|
// YAML permits cycles; application configuration does not. The document
|
|
11
32
|
// conversion also enforces YAML's alias expansion limit before we detach.
|
|
12
33
|
JSON.stringify(doc.toJS());
|
|
@@ -27,8 +48,8 @@ export function parseYamlMapping(file, text) {
|
|
|
27
48
|
return doc;
|
|
28
49
|
}
|
|
29
50
|
catch {
|
|
30
|
-
// Parser messages include source lines, which may contain credentials.
|
|
31
|
-
throw new
|
|
51
|
+
// Parser and conversion messages include source lines, which may contain credentials.
|
|
52
|
+
throw new SafeConfigError(`${file}: invalid YAML configuration (expected an acyclic mapping with valid aliases)`);
|
|
32
53
|
}
|
|
33
54
|
}
|
|
34
55
|
/** Validate the emitted configuration before any backup or write. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentsw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "CLI provider switcher for coding agents: manage OpenAI/Anthropic-protocol providers in one place and sync them, with models.dev metadata, into Claude Code, Codex, omp, pi, prime-agent, opencode, Hermes, WorkBuddy and DeepSeek Harness",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|