@link-assistant/agent 0.26.14 → 0.27.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 +3 -3
- package/package.json +43 -33
- package/src/agent/agent.ts +12 -7
- package/src/auth/azure-login.ts +241 -0
- package/src/auth/plugins.ts +9 -0
- package/src/auth/provider-logins.ts +804 -0
- package/src/cli/cmd/auth.ts +4 -0
- package/src/cli/cmd/models.ts +68 -13
- package/src/cli/model-config.js +7 -4
- package/src/index.js +3 -0
- package/src/provider/env-template.ts +115 -0
- package/src/provider/extra-providers.ts +347 -0
- package/src/provider/kilo.ts +19 -0
- package/src/provider/model-discovery.ts +690 -0
- package/src/provider/models.ts +11 -1
- package/src/provider/parity-loaders.ts +595 -0
- package/src/provider/provider.ts +257 -182
- package/src/provider/transform.ts +29 -0
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import { Global } from '../global';
|
|
5
|
+
import { Log } from '../util/log';
|
|
6
|
+
import type { ModelsDev } from './models';
|
|
7
|
+
import { EnvTemplate } from './env-template';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Dynamic model discovery.
|
|
11
|
+
*
|
|
12
|
+
* Asks a provider's own model-listing endpoint (`GET /models` and friends)
|
|
13
|
+
* which models it serves, so new models work without a code or docs update.
|
|
14
|
+
*
|
|
15
|
+
* Rules borrowed from hive-mind's model catalogue
|
|
16
|
+
* (src/model-catalogue-sources.lib.mjs, src/model-catalogue-fetch.lib.mjs):
|
|
17
|
+
* - listing requests never hit a billable endpoint (`assertTokenFreeUrl`);
|
|
18
|
+
* - results are cached for at least one hour, an env override may only
|
|
19
|
+
* raise the TTL;
|
|
20
|
+
* - live sources decide availability, models.dev only fills in metadata.
|
|
21
|
+
*
|
|
22
|
+
* @see https://github.com/link-assistant/agent/issues/334
|
|
23
|
+
* @see https://github.com/link-assistant/hive-mind
|
|
24
|
+
*/
|
|
25
|
+
export namespace ModelDiscovery {
|
|
26
|
+
const log = Log.create({ service: 'model-discovery' });
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_TTL_MS = 60 * 60 * 1000;
|
|
29
|
+
export const TTL_ENV = 'LINK_ASSISTANT_AGENT_MODEL_DISCOVERY_TTL_MINUTES';
|
|
30
|
+
export const DISABLE_ENV = 'LINK_ASSISTANT_AGENT_MODEL_DISCOVERY';
|
|
31
|
+
export const DEFAULT_TIMEOUT_MS = 10_000;
|
|
32
|
+
|
|
33
|
+
export type Shape = 'openai' | 'anthropic' | 'gemini' | 'cohere' | 'azure';
|
|
34
|
+
|
|
35
|
+
export interface DiscoveredModel {
|
|
36
|
+
id: string;
|
|
37
|
+
name?: string;
|
|
38
|
+
context?: number;
|
|
39
|
+
output?: number;
|
|
40
|
+
toolCall?: boolean;
|
|
41
|
+
reasoning?: boolean;
|
|
42
|
+
attachment?: boolean;
|
|
43
|
+
input?: Array<'text' | 'image' | 'audio' | 'video' | 'pdf'>;
|
|
44
|
+
free?: boolean;
|
|
45
|
+
cost?: { input: number; output: number };
|
|
46
|
+
releaseDate?: string;
|
|
47
|
+
/** Alternative IDs the provider accepts for the same model (Pollinations). */
|
|
48
|
+
aliases?: string[];
|
|
49
|
+
/** Underlying model of a deployment (Azure), used for catalogue metadata. */
|
|
50
|
+
model?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ListingRequest {
|
|
54
|
+
url: string;
|
|
55
|
+
headers: Record<string, string>;
|
|
56
|
+
shape: Shape;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface DiscoveryResult {
|
|
60
|
+
status: 'ok' | 'skipped' | 'error';
|
|
61
|
+
models: DiscoveredModel[];
|
|
62
|
+
url?: string;
|
|
63
|
+
error?: string;
|
|
64
|
+
cached?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Paths that spend tokens. A listing request matching any of them is a
|
|
69
|
+
* programming error and is refused before it leaves the process.
|
|
70
|
+
*/
|
|
71
|
+
export const BILLABLE_PATH_PATTERNS: RegExp[] = [
|
|
72
|
+
/\/v1\/messages(\/|$|\?)/i,
|
|
73
|
+
/\/v1\/complete(\/|$|\?)/i,
|
|
74
|
+
/\/chat\/completions(\/|$|\?)/i,
|
|
75
|
+
/\/completions(\/|$|\?)/i,
|
|
76
|
+
/\/responses(\/|$|\?)/i,
|
|
77
|
+
/\/embeddings(\/|$|\?)/i,
|
|
78
|
+
/:generatecontent/i,
|
|
79
|
+
/:streamgeneratecontent/i,
|
|
80
|
+
/:counttokens/i,
|
|
81
|
+
/\/v1\/batches/i,
|
|
82
|
+
/\/v1\/messages\/batches/i,
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
export function isBillableUrl(url: string): boolean {
|
|
86
|
+
let pathname = url;
|
|
87
|
+
try {
|
|
88
|
+
pathname = new URL(url).pathname;
|
|
89
|
+
} catch {
|
|
90
|
+
// keep the raw string
|
|
91
|
+
}
|
|
92
|
+
return BILLABLE_PATH_PATTERNS.some((pattern) => pattern.test(pathname));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function assertTokenFreeUrl(url: string) {
|
|
96
|
+
if (isBillableUrl(url)) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Refusing to call billable endpoint for model discovery: ${url}`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function isEnabled(
|
|
104
|
+
env: Record<string, string | undefined> = process.env
|
|
105
|
+
): boolean {
|
|
106
|
+
const value = env[DISABLE_ENV]?.trim().toLowerCase();
|
|
107
|
+
return !(value === '0' || value === 'false' || value === 'off');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Cache TTL. The env override can only make the cache live longer. */
|
|
111
|
+
export function ttlMs(
|
|
112
|
+
env: Record<string, string | undefined> = process.env
|
|
113
|
+
): number {
|
|
114
|
+
const minutes = Number(env[TTL_ENV]);
|
|
115
|
+
if (!Number.isFinite(minutes) || minutes <= 0) return DEFAULT_TTL_MS;
|
|
116
|
+
return Math.max(DEFAULT_TTL_MS, minutes * 60 * 1000);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Base URLs the SDK packages use when no `baseURL` option is set. */
|
|
120
|
+
export const DEFAULT_BASE_URLS: Record<string, string> = {
|
|
121
|
+
'@ai-sdk/openai': 'https://api.openai.com/v1',
|
|
122
|
+
'@ai-sdk/groq': 'https://api.groq.com/openai/v1',
|
|
123
|
+
'@ai-sdk/mistral': 'https://api.mistral.ai/v1',
|
|
124
|
+
'@ai-sdk/xai': 'https://api.x.ai/v1',
|
|
125
|
+
'@ai-sdk/deepinfra': 'https://api.deepinfra.com/v1/openai',
|
|
126
|
+
'@ai-sdk/togetherai': 'https://api.together.xyz/v1',
|
|
127
|
+
'@ai-sdk/cerebras': 'https://api.cerebras.ai/v1',
|
|
128
|
+
'@ai-sdk/fireworks': 'https://api.fireworks.ai/inference/v1',
|
|
129
|
+
'@ai-sdk/deepseek': 'https://api.deepseek.com/v1',
|
|
130
|
+
'@ai-sdk/alibaba': 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
|
131
|
+
'@openrouter/ai-sdk-provider': 'https://openrouter.ai/api/v1',
|
|
132
|
+
'@ai-sdk/anthropic': 'https://api.anthropic.com/v1',
|
|
133
|
+
'@ai-sdk/google': 'https://generativelanguage.googleapis.com/v1beta',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** Model-listing URLs that differ from `${baseURL}/models`. */
|
|
137
|
+
export const LISTING_URL_OVERRIDES: Record<string, string> = {
|
|
138
|
+
'@ai-sdk/gateway': 'https://ai-gateway.vercel.sh/v1/models',
|
|
139
|
+
kilo: 'https://api.kilo.ai/api/gateway/models',
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Packages whose model list is not reachable with a plain API key
|
|
144
|
+
* (cloud IAM, deployment-scoped or no listing endpoint at all).
|
|
145
|
+
*/
|
|
146
|
+
const UNSUPPORTED_PACKAGES = new Set([
|
|
147
|
+
'@ai-sdk/amazon-bedrock',
|
|
148
|
+
'@ai-sdk/amazon-bedrock/mantle',
|
|
149
|
+
'@ai-sdk/google-vertex',
|
|
150
|
+
'@ai-sdk/google-vertex/anthropic',
|
|
151
|
+
'@ai-sdk/perplexity',
|
|
152
|
+
'gitlab-ai-provider',
|
|
153
|
+
'ai-gateway-provider',
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
/** Providers that are synthetic or handled by a dedicated module. */
|
|
157
|
+
const UNSUPPORTED_PROVIDERS = new Set([
|
|
158
|
+
'link-assistant',
|
|
159
|
+
'link-assistant/cache',
|
|
160
|
+
'opencode',
|
|
161
|
+
'formal-ai',
|
|
162
|
+
'formalai',
|
|
163
|
+
'@link-assistant',
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
function trimSlash(url: string) {
|
|
167
|
+
return url.replace(/\/+$/, '');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const AZURE_HOST =
|
|
171
|
+
/\.(openai\.azure\.com|services\.ai\.azure\.com|cognitiveservices\.azure\.com)$/i;
|
|
172
|
+
const AZURE_RESOURCE = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Root of an Azure resource's `/openai` API. A custom endpoint may expose
|
|
176
|
+
* other deployments than the resource, so it keeps the catalogue.
|
|
177
|
+
*/
|
|
178
|
+
function azureRoot(baseURL?: string, resourceName?: string) {
|
|
179
|
+
if (baseURL) {
|
|
180
|
+
try {
|
|
181
|
+
const url = new URL(baseURL);
|
|
182
|
+
return AZURE_HOST.test(url.hostname)
|
|
183
|
+
? `${url.origin}/openai`
|
|
184
|
+
: undefined;
|
|
185
|
+
} catch {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return resourceName && AZURE_RESOURCE.test(resourceName)
|
|
190
|
+
? `https://${resourceName}.openai.azure.com/openai`
|
|
191
|
+
: undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function listingRequest(input: {
|
|
195
|
+
providerID: string;
|
|
196
|
+
npm?: string;
|
|
197
|
+
baseURL?: string;
|
|
198
|
+
apiKey?: string;
|
|
199
|
+
headers?: Record<string, string>;
|
|
200
|
+
/** Azure resource name (`AZURE_RESOURCE_NAME`). */
|
|
201
|
+
resourceName?: string;
|
|
202
|
+
}): ListingRequest | undefined {
|
|
203
|
+
const npm = input.npm ?? '@ai-sdk/openai-compatible';
|
|
204
|
+
if (UNSUPPORTED_PROVIDERS.has(input.providerID)) return undefined;
|
|
205
|
+
if (UNSUPPORTED_PACKAGES.has(npm)) return undefined;
|
|
206
|
+
const apiKey = input.apiKey || undefined;
|
|
207
|
+
const headers: Record<string, string> = { ...(input.headers ?? {}) };
|
|
208
|
+
|
|
209
|
+
if (npm === '@ai-sdk/azure') {
|
|
210
|
+
// Model IDs on Azure are deployment names. Only data-plane version
|
|
211
|
+
// 2022-12-01 lists a resource's deployments; later versions keep
|
|
212
|
+
// `/models`, the models it *could* deploy (same choice as OpenCode).
|
|
213
|
+
const root = azureRoot(input.baseURL, input.resourceName);
|
|
214
|
+
if (!root) return undefined;
|
|
215
|
+
if (apiKey) headers['api-key'] = apiKey;
|
|
216
|
+
return {
|
|
217
|
+
url: `${root}/deployments?api-version=2022-12-01`,
|
|
218
|
+
headers,
|
|
219
|
+
shape: 'azure',
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const override =
|
|
224
|
+
LISTING_URL_OVERRIDES[input.providerID] ?? LISTING_URL_OVERRIDES[npm];
|
|
225
|
+
const base = input.baseURL ?? DEFAULT_BASE_URLS[npm];
|
|
226
|
+
if (!override && (!base || EnvTemplate.isTemplate(base))) return undefined;
|
|
227
|
+
|
|
228
|
+
if (npm === '@ai-sdk/anthropic') {
|
|
229
|
+
if (apiKey) headers['x-api-key'] = apiKey;
|
|
230
|
+
headers['anthropic-version'] = '2023-06-01';
|
|
231
|
+
return {
|
|
232
|
+
url: `${trimSlash(base!)}/models?limit=1000`,
|
|
233
|
+
headers,
|
|
234
|
+
shape: 'anthropic',
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (npm === '@ai-sdk/google') {
|
|
238
|
+
if (apiKey) headers['x-goog-api-key'] = apiKey;
|
|
239
|
+
return {
|
|
240
|
+
url: `${trimSlash(base!)}/models?pageSize=1000`,
|
|
241
|
+
headers,
|
|
242
|
+
shape: 'gemini',
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (npm === '@ai-sdk/cohere') {
|
|
246
|
+
if (apiKey) headers['authorization'] = `Bearer ${apiKey}`;
|
|
247
|
+
const root = trimSlash(input.baseURL ?? 'https://api.cohere.com/v2');
|
|
248
|
+
return {
|
|
249
|
+
url: `${root.replace(/\/v\d+$/, '')}/v1/models?endpoint=chat&page_size=1000`,
|
|
250
|
+
headers,
|
|
251
|
+
shape: 'cohere',
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
if (apiKey) headers['authorization'] = `Bearer ${apiKey}`;
|
|
255
|
+
return {
|
|
256
|
+
url: override ?? `${trimSlash(base!)}/models`,
|
|
257
|
+
headers,
|
|
258
|
+
shape: 'openai',
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function num(value: unknown): number | undefined {
|
|
263
|
+
const n = typeof value === 'string' ? Number(value) : value;
|
|
264
|
+
return typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function price(value: unknown): number | undefined {
|
|
268
|
+
const n = typeof value === 'string' ? Number(value) : value;
|
|
269
|
+
return typeof n === 'number' && Number.isFinite(n) && n >= 0
|
|
270
|
+
? n
|
|
271
|
+
: undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function date(value: unknown): string | undefined {
|
|
275
|
+
if (typeof value === 'number' && value > 0) {
|
|
276
|
+
const ms = value < 1e12 ? value * 1000 : value;
|
|
277
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
278
|
+
}
|
|
279
|
+
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)) {
|
|
280
|
+
return value.slice(0, 10);
|
|
281
|
+
}
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const MODALITIES = new Set(['text', 'image', 'audio', 'video', 'pdf']);
|
|
286
|
+
|
|
287
|
+
function modalities(value: unknown): DiscoveredModel['input'] {
|
|
288
|
+
if (!Array.isArray(value)) return undefined;
|
|
289
|
+
const list = value
|
|
290
|
+
.map((item) => String(item).toLowerCase())
|
|
291
|
+
.map((item) => (item === 'file' ? 'pdf' : item))
|
|
292
|
+
.filter((item) => MODALITIES.has(item));
|
|
293
|
+
return list.length
|
|
294
|
+
? (Array.from(new Set(list)) as DiscoveredModel['input'])
|
|
295
|
+
: undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function isFreeId(id: string) {
|
|
299
|
+
return /(:free|-free)$/i.test(id) || id === 'openrouter/free';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function normalizeOpenAI(item: any): DiscoveredModel | undefined {
|
|
303
|
+
if (!item || typeof item.id !== 'string' || !item.id) return undefined;
|
|
304
|
+
if (item.type === 'embedding' || item.type === 'embeddings') return;
|
|
305
|
+
if (item.supports_chat === false) return undefined;
|
|
306
|
+
// gpt4free marks image generators with `image: true`
|
|
307
|
+
if (item.image === true && item.vision !== true) return undefined;
|
|
308
|
+
const outputs = modalities(
|
|
309
|
+
item.architecture?.output_modalities ??
|
|
310
|
+
item.output_modalities ??
|
|
311
|
+
item.modalities?.output
|
|
312
|
+
);
|
|
313
|
+
if (outputs && !outputs.includes('text')) return undefined;
|
|
314
|
+
|
|
315
|
+
const input =
|
|
316
|
+
modalities(
|
|
317
|
+
item.architecture?.input_modalities ??
|
|
318
|
+
item.input_modalities ??
|
|
319
|
+
item.modalities?.input
|
|
320
|
+
) ?? (item.vision === true ? ['text', 'image'] : undefined);
|
|
321
|
+
const params: string[] = Array.isArray(item.supported_parameters)
|
|
322
|
+
? item.supported_parameters
|
|
323
|
+
: [];
|
|
324
|
+
const pricing = item.pricing ?? {};
|
|
325
|
+
const inputPrice = price(pricing.prompt ?? pricing.input);
|
|
326
|
+
const outputPrice = price(pricing.completion ?? pricing.output);
|
|
327
|
+
const cost =
|
|
328
|
+
inputPrice !== undefined && outputPrice !== undefined
|
|
329
|
+
? {
|
|
330
|
+
input: Math.round(inputPrice * 1e6 * 1e6) / 1e6,
|
|
331
|
+
output: Math.round(outputPrice * 1e6 * 1e6) / 1e6,
|
|
332
|
+
}
|
|
333
|
+
: undefined;
|
|
334
|
+
const free =
|
|
335
|
+
isFreeId(item.id) ||
|
|
336
|
+
item.tier === 'free' ||
|
|
337
|
+
item.paid_only === false ||
|
|
338
|
+
item.is_free === true ||
|
|
339
|
+
(cost !== undefined && cost.input === 0 && cost.output === 0);
|
|
340
|
+
|
|
341
|
+
const toolCall =
|
|
342
|
+
typeof item.tools === 'boolean'
|
|
343
|
+
? item.tools
|
|
344
|
+
: typeof item.supports_tools === 'boolean'
|
|
345
|
+
? item.supports_tools
|
|
346
|
+
: typeof item.capabilities?.trained_for_tool_use === 'boolean'
|
|
347
|
+
? item.capabilities.trained_for_tool_use
|
|
348
|
+
: params.length
|
|
349
|
+
? params.includes('tools')
|
|
350
|
+
: undefined;
|
|
351
|
+
const reasoning =
|
|
352
|
+
typeof item.reasoning === 'boolean'
|
|
353
|
+
? item.reasoning
|
|
354
|
+
: params.length
|
|
355
|
+
? params.includes('reasoning') || params.includes('include_reasoning')
|
|
356
|
+
: undefined;
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
id: item.id,
|
|
360
|
+
name:
|
|
361
|
+
item.name ?? item.display_name ?? item.title ?? item.description_short,
|
|
362
|
+
context: num(
|
|
363
|
+
item.context_length ??
|
|
364
|
+
item.context_window ??
|
|
365
|
+
item.max_model_len ??
|
|
366
|
+
item.max_context_length ??
|
|
367
|
+
item.top_provider?.context_length ??
|
|
368
|
+
item.limits?.context
|
|
369
|
+
),
|
|
370
|
+
output: num(
|
|
371
|
+
item.top_provider?.max_completion_tokens ??
|
|
372
|
+
item.max_output_tokens ??
|
|
373
|
+
item.max_completion_tokens ??
|
|
374
|
+
item.max_tokens ??
|
|
375
|
+
item.limits?.output
|
|
376
|
+
),
|
|
377
|
+
toolCall,
|
|
378
|
+
reasoning,
|
|
379
|
+
attachment: input ? input.some((m) => m !== 'text') : undefined,
|
|
380
|
+
input,
|
|
381
|
+
free,
|
|
382
|
+
cost: free ? { input: 0, output: 0 } : cost,
|
|
383
|
+
releaseDate: date(item.created ?? item.created_at),
|
|
384
|
+
...(Array.isArray(item.aliases) && item.aliases.length
|
|
385
|
+
? {
|
|
386
|
+
aliases: item.aliases.filter(
|
|
387
|
+
(alias: unknown): alias is string =>
|
|
388
|
+
typeof alias === 'string' && alias.length > 0
|
|
389
|
+
),
|
|
390
|
+
}
|
|
391
|
+
: {}),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function normalizeAnthropic(item: any): DiscoveredModel | undefined {
|
|
396
|
+
if (!item || typeof item.id !== 'string') return undefined;
|
|
397
|
+
return {
|
|
398
|
+
id: item.id,
|
|
399
|
+
name: item.display_name,
|
|
400
|
+
context: num(item.max_input_tokens ?? item.context_window),
|
|
401
|
+
output: num(item.max_tokens ?? item.max_output_tokens),
|
|
402
|
+
releaseDate: date(item.created_at),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function normalizeGemini(item: any): DiscoveredModel | undefined {
|
|
407
|
+
if (!item || typeof item.name !== 'string') return undefined;
|
|
408
|
+
const methods: string[] = item.supportedGenerationMethods ?? [];
|
|
409
|
+
if (methods.length && !methods.includes('generateContent')) return;
|
|
410
|
+
return {
|
|
411
|
+
id: item.name.replace(/^models\//, ''),
|
|
412
|
+
name: item.displayName,
|
|
413
|
+
context: num(item.inputTokenLimit),
|
|
414
|
+
output: num(item.outputTokenLimit),
|
|
415
|
+
reasoning: typeof item.thinking === 'boolean' ? item.thinking : undefined,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function normalizeCohere(item: any): DiscoveredModel | undefined {
|
|
420
|
+
if (!item || typeof item.name !== 'string') return undefined;
|
|
421
|
+
const endpoints: string[] = item.endpoints ?? [];
|
|
422
|
+
if (endpoints.length && !endpoints.includes('chat')) return undefined;
|
|
423
|
+
const features: string[] = item.features ?? [];
|
|
424
|
+
return {
|
|
425
|
+
id: item.name,
|
|
426
|
+
context: num(item.context_length),
|
|
427
|
+
toolCall: features.length ? features.includes('tools') : undefined,
|
|
428
|
+
attachment: features.includes('vision') || undefined,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function normalizeAzure(item: any): DiscoveredModel | undefined {
|
|
433
|
+
if (!item || typeof item.id !== 'string') return undefined;
|
|
434
|
+
if (item.status !== 'succeeded') return undefined;
|
|
435
|
+
return {
|
|
436
|
+
id: item.id,
|
|
437
|
+
model: typeof item.model === 'string' ? item.model : undefined,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Turn a listing payload into discovered models (deduplicated, sorted). */
|
|
442
|
+
export function normalize(shape: Shape, payload: any): DiscoveredModel[] {
|
|
443
|
+
const items: any[] = Array.isArray(payload)
|
|
444
|
+
? payload
|
|
445
|
+
: (payload?.data ?? payload?.models ?? []);
|
|
446
|
+
if (!Array.isArray(items)) return [];
|
|
447
|
+
const fn =
|
|
448
|
+
shape === 'anthropic'
|
|
449
|
+
? normalizeAnthropic
|
|
450
|
+
: shape === 'gemini'
|
|
451
|
+
? normalizeGemini
|
|
452
|
+
: shape === 'cohere'
|
|
453
|
+
? normalizeCohere
|
|
454
|
+
: shape === 'azure'
|
|
455
|
+
? normalizeAzure
|
|
456
|
+
: normalizeOpenAI;
|
|
457
|
+
const seen = new Map<string, DiscoveredModel>();
|
|
458
|
+
for (const item of items) {
|
|
459
|
+
const model = fn(item);
|
|
460
|
+
if (model && !seen.has(model.id)) seen.set(model.id, model);
|
|
461
|
+
}
|
|
462
|
+
return Array.from(seen.values()).sort((a, b) => a.id.localeCompare(b.id));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function catalogueKeys(id: string): string[] {
|
|
466
|
+
const keys = [id];
|
|
467
|
+
const bare = id.replace(/(:free|-free)$/i, '');
|
|
468
|
+
if (bare !== id) keys.push(bare);
|
|
469
|
+
for (const key of [...keys]) {
|
|
470
|
+
const slash = key.lastIndexOf('/');
|
|
471
|
+
if (slash >= 0) keys.push(key.slice(slash + 1));
|
|
472
|
+
}
|
|
473
|
+
return keys;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Find models.dev metadata for a discovered model ID anywhere in the
|
|
478
|
+
* catalogue (exact ID first, then without vendor prefix / free suffix).
|
|
479
|
+
*/
|
|
480
|
+
export function catalogueMetadata(
|
|
481
|
+
database: Record<string, ModelsDev.Provider>,
|
|
482
|
+
id: string,
|
|
483
|
+
preferredProviderID?: string
|
|
484
|
+
): ModelsDev.Model | undefined {
|
|
485
|
+
const providers = Object.values(database);
|
|
486
|
+
if (preferredProviderID && database[preferredProviderID]) {
|
|
487
|
+
providers.unshift(database[preferredProviderID]);
|
|
488
|
+
}
|
|
489
|
+
for (const key of catalogueKeys(id)) {
|
|
490
|
+
for (const provider of providers) {
|
|
491
|
+
const model = provider.models?.[key];
|
|
492
|
+
if (model) return model;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return undefined;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function toModelInfo(
|
|
499
|
+
model: DiscoveredModel,
|
|
500
|
+
options: { npm?: string; metadata?: ModelsDev.Model } = {}
|
|
501
|
+
): ModelsDev.Model {
|
|
502
|
+
const meta = options.metadata;
|
|
503
|
+
const context = model.context ?? meta?.limit?.context ?? 128000;
|
|
504
|
+
const output =
|
|
505
|
+
model.output ?? meta?.limit?.output ?? Math.min(16384, context);
|
|
506
|
+
const input = model.input ?? meta?.modalities?.input ?? ['text'];
|
|
507
|
+
const cost =
|
|
508
|
+
model.cost ??
|
|
509
|
+
(model.free
|
|
510
|
+
? { input: 0, output: 0 }
|
|
511
|
+
: meta?.cost
|
|
512
|
+
? { input: meta.cost.input, output: meta.cost.output }
|
|
513
|
+
: { input: 0, output: 0 });
|
|
514
|
+
return {
|
|
515
|
+
id: model.id,
|
|
516
|
+
name: model.name ?? meta?.name ?? model.id,
|
|
517
|
+
release_date: model.releaseDate ?? meta?.release_date ?? '',
|
|
518
|
+
attachment: model.attachment ?? meta?.attachment ?? false,
|
|
519
|
+
reasoning: model.reasoning ?? meta?.reasoning ?? false,
|
|
520
|
+
temperature: meta?.temperature ?? true,
|
|
521
|
+
tool_call: model.toolCall ?? meta?.tool_call ?? true,
|
|
522
|
+
cost: { ...cost, cache_read: 0, cache_write: 0 },
|
|
523
|
+
limit: { context, output: Math.min(output, context) },
|
|
524
|
+
modalities: { input, output: ['text'] },
|
|
525
|
+
options: {},
|
|
526
|
+
...(options.npm ? { provider: { npm: options.npm } } : {}),
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const memory = new Map<string, { at: number; result: DiscoveryResult }>();
|
|
531
|
+
|
|
532
|
+
const fingerprints = new Map<string, string>();
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Credential fingerprint for cache file names, so two accounts never share
|
|
536
|
+
* a listing. scrypt (memoized) keeps the key unrecoverable from the name.
|
|
537
|
+
*/
|
|
538
|
+
function fingerprint(secret: string) {
|
|
539
|
+
if (!secret) return '';
|
|
540
|
+
let value = fingerprints.get(secret);
|
|
541
|
+
if (!value) {
|
|
542
|
+
value = crypto
|
|
543
|
+
.scryptSync(secret, 'link-assistant-agent/model-discovery', 16)
|
|
544
|
+
.toString('hex');
|
|
545
|
+
fingerprints.set(secret, value);
|
|
546
|
+
}
|
|
547
|
+
return value;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function cacheKey(providerID: string, request: ListingRequest) {
|
|
551
|
+
const secret =
|
|
552
|
+
request.headers['authorization'] ??
|
|
553
|
+
request.headers['x-api-key'] ??
|
|
554
|
+
request.headers['x-goog-api-key'] ??
|
|
555
|
+
request.headers['api-key'] ??
|
|
556
|
+
'';
|
|
557
|
+
return crypto
|
|
558
|
+
.createHash('sha256')
|
|
559
|
+
.update(`${providerID}\n${request.url}\n${fingerprint(secret)}`)
|
|
560
|
+
.digest('hex')
|
|
561
|
+
.slice(0, 24);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function cacheFile(key: string) {
|
|
565
|
+
return path.join(Global.Path.cache, 'model-discovery', `${key}.json`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function readDisk(key: string, ttl: number) {
|
|
569
|
+
try {
|
|
570
|
+
const raw = JSON.parse(await fs.readFile(cacheFile(key), 'utf8'));
|
|
571
|
+
if (typeof raw?.at !== 'number' || Date.now() - raw.at > ttl) return;
|
|
572
|
+
return raw as { at: number; result: DiscoveryResult };
|
|
573
|
+
} catch {
|
|
574
|
+
return undefined;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function writeDisk(key: string, entry: object) {
|
|
579
|
+
try {
|
|
580
|
+
await fs.mkdir(path.dirname(cacheFile(key)), { recursive: true });
|
|
581
|
+
await fs.writeFile(cacheFile(key), JSON.stringify(entry));
|
|
582
|
+
} catch (error) {
|
|
583
|
+
log.debug(() => ({
|
|
584
|
+
message: 'failed to write model discovery cache',
|
|
585
|
+
error: error instanceof Error ? error.message : String(error),
|
|
586
|
+
}));
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Forget cached listings (all providers, memory only). */
|
|
591
|
+
export function clearMemoryCache() {
|
|
592
|
+
memory.clear();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export async function fetchListing(
|
|
596
|
+
request: ListingRequest,
|
|
597
|
+
options: { fetchImpl?: typeof fetch; timeoutMs?: number } = {}
|
|
598
|
+
): Promise<DiscoveredModel[]> {
|
|
599
|
+
assertTokenFreeUrl(request.url);
|
|
600
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
601
|
+
const response = await fetchImpl(request.url, {
|
|
602
|
+
method: 'GET',
|
|
603
|
+
headers: { accept: 'application/json', ...request.headers },
|
|
604
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
605
|
+
});
|
|
606
|
+
if (!response.ok) {
|
|
607
|
+
throw new Error(`HTTP ${response.status} from ${request.url}`);
|
|
608
|
+
}
|
|
609
|
+
return normalize(request.shape, await response.json());
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* List the models a provider currently serves.
|
|
614
|
+
* Never throws; failures come back as `{ status: 'error' }`.
|
|
615
|
+
*/
|
|
616
|
+
export async function discover(input: {
|
|
617
|
+
providerID: string;
|
|
618
|
+
npm?: string;
|
|
619
|
+
baseURL?: string;
|
|
620
|
+
apiKey?: string;
|
|
621
|
+
headers?: Record<string, string>;
|
|
622
|
+
resourceName?: string;
|
|
623
|
+
fetchImpl?: typeof fetch;
|
|
624
|
+
timeoutMs?: number;
|
|
625
|
+
force?: boolean;
|
|
626
|
+
env?: Record<string, string | undefined>;
|
|
627
|
+
}): Promise<DiscoveryResult> {
|
|
628
|
+
const env = input.env ?? process.env;
|
|
629
|
+
if (!isEnabled(env)) {
|
|
630
|
+
return { status: 'skipped', models: [], error: `${DISABLE_ENV}=off` };
|
|
631
|
+
}
|
|
632
|
+
const request = listingRequest(input);
|
|
633
|
+
if (!request) {
|
|
634
|
+
return {
|
|
635
|
+
status: 'skipped',
|
|
636
|
+
models: [],
|
|
637
|
+
error: 'provider has no token-free model listing endpoint',
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
const ttl = ttlMs(env);
|
|
641
|
+
const key = cacheKey(input.providerID, request);
|
|
642
|
+
if (!input.force) {
|
|
643
|
+
const hit = memory.get(key) ?? (await readDisk(key, ttl));
|
|
644
|
+
if (hit && Date.now() - hit.at <= ttl) {
|
|
645
|
+
memory.set(key, hit);
|
|
646
|
+
return { ...hit.result, cached: true };
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
try {
|
|
650
|
+
const models = await fetchListing(request, input);
|
|
651
|
+
const result: DiscoveryResult = {
|
|
652
|
+
status: 'ok',
|
|
653
|
+
models,
|
|
654
|
+
url: request.url,
|
|
655
|
+
};
|
|
656
|
+
const entry = { at: Date.now(), result };
|
|
657
|
+
memory.set(key, entry);
|
|
658
|
+
await writeDisk(key, entry);
|
|
659
|
+
log.info(() => ({
|
|
660
|
+
message: 'discovered provider models',
|
|
661
|
+
provider: input.providerID,
|
|
662
|
+
url: request.url,
|
|
663
|
+
count: models.length,
|
|
664
|
+
}));
|
|
665
|
+
return result;
|
|
666
|
+
} catch (error) {
|
|
667
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
668
|
+
log.info(() => ({
|
|
669
|
+
message: 'model discovery failed',
|
|
670
|
+
provider: input.providerID,
|
|
671
|
+
url: request.url,
|
|
672
|
+
error: message,
|
|
673
|
+
}));
|
|
674
|
+
return { status: 'error', models: [], url: request.url, error: message };
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export function findModel(
|
|
679
|
+
models: DiscoveredModel[],
|
|
680
|
+
modelID: string
|
|
681
|
+
): DiscoveredModel | undefined {
|
|
682
|
+
const wanted = modelID.replace(/^models\//, '');
|
|
683
|
+
const lower = wanted.toLowerCase();
|
|
684
|
+
return (
|
|
685
|
+
models.find((m) => m.id === wanted) ??
|
|
686
|
+
models.find((m) => m.id.toLowerCase() === lower) ??
|
|
687
|
+
models.find((m) => m.aliases?.some((a) => a.toLowerCase() === lower))
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
}
|