agentsw 0.7.1 → 0.8.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 +106 -15
- package/README.zh-CN.md +93 -14
- package/dist/apps.js +48 -44
- package/dist/commands.js +278 -90
- package/dist/config-transaction.js +169 -84
- package/dist/discover.js +55 -17
- package/dist/envfile.js +101 -20
- package/dist/fsutil.js +109 -14
- package/dist/gateway.js +201 -0
- package/dist/i18n.js +24 -7
- package/dist/import.js +2 -2
- package/dist/index.js +42 -2
- package/dist/jsonc.js +4 -10
- package/dist/menu.js +22 -1
- package/dist/metadata.js +400 -0
- package/dist/modelsdev.js +35 -11
- package/dist/provider-identity.js +25 -0
- package/dist/remove.js +84 -38
- package/dist/rename.js +91 -58
- package/dist/store.js +38 -7
- package/dist/target-transaction.js +25 -0
- package/dist/targets/claudecode.js +12 -3
- package/dist/targets/codex.js +34 -3
- package/dist/targets/dsh.js +117 -65
- package/dist/targets/hermes.js +98 -49
- package/dist/targets/omp.js +11 -47
- package/dist/targets/opencode.js +27 -6
- package/dist/targets/pistyle.js +12 -8
- package/dist/targets/wire.js +15 -9
- package/dist/targets/workbuddy.js +151 -76
- package/dist/yaml.js +39 -0
- package/package.json +4 -2
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,
|
|
@@ -75,9 +95,13 @@ export function findModelMeta(catalog, modelId, providerHint) {
|
|
|
75
95
|
if (m)
|
|
76
96
|
return { spec: toModelSpec(modelId, m), provider: hinted.id };
|
|
77
97
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
98
|
+
const providers = Object.values(catalog).filter((p) => p !== hinted);
|
|
99
|
+
for (const p of providers) {
|
|
100
|
+
const m = p.models[modelId];
|
|
101
|
+
if (m)
|
|
102
|
+
return { spec: toModelSpec(modelId, m), provider: p.id };
|
|
103
|
+
}
|
|
104
|
+
for (const p of providers) {
|
|
81
105
|
const m = scan(p);
|
|
82
106
|
if (m)
|
|
83
107
|
return { spec: toModelSpec(modelId, m), provider: p.id };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/** Previous releases used this lossy encoding; recognize it, never generate it. */
|
|
3
|
+
export function legacyManagedCredentialRef(id) {
|
|
4
|
+
return `AGENTSW_${id.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
|
5
|
+
}
|
|
6
|
+
/** Human-readable prefix plus an exact, case-sensitive provider identity digest. */
|
|
7
|
+
export function managedCredentialRef(id) {
|
|
8
|
+
const prefix = id.toUpperCase().replace(/[^A-Z0-9]/g, "_").slice(0, 48) || "PROVIDER";
|
|
9
|
+
const digest = createHash("sha256").update(id).digest("hex").slice(0, 24).toUpperCase();
|
|
10
|
+
return `AGENTSW_${prefix}_${digest}_API_KEY`;
|
|
11
|
+
}
|
|
12
|
+
export function isManagedCredentialRef(ref, id) {
|
|
13
|
+
return ref === managedCredentialRef(id) || ref === legacyManagedCredentialRef(id);
|
|
14
|
+
}
|
|
15
|
+
/** Account-qualified app-local selector; never embeds endpoints or credentials. */
|
|
16
|
+
export function localProviderId(candidate) {
|
|
17
|
+
const identity = JSON.stringify([
|
|
18
|
+
candidate.id,
|
|
19
|
+
candidate.protocol,
|
|
20
|
+
candidate.baseUrl,
|
|
21
|
+
candidate.apiKey ?? null,
|
|
22
|
+
candidate.keyEnv ?? null,
|
|
23
|
+
]);
|
|
24
|
+
return `local-${createHash("sha256").update(identity).digest("hex").slice(0, 32)}`;
|
|
25
|
+
}
|