@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.
@@ -359,6 +359,10 @@ export const AuthLoginCommand = cmd({
359
359
  togetherai: 'https://api.together.xyz/settings/api-keys',
360
360
  deepseek: 'https://platform.deepseek.com/api_keys',
361
361
  xai: 'https://console.x.ai',
362
+ poe: 'https://poe.com/api_key',
363
+ digitalocean: 'https://cloud.digitalocean.com/gen-ai/model-access-keys',
364
+ gitlab: 'https://gitlab.com/-/user_settings/personal_access_tokens',
365
+ azure: 'https://ai.azure.com/',
362
366
  };
363
367
 
364
368
  if (apiKeyUrls[provider]) {
@@ -1,10 +1,17 @@
1
1
  import type { Argv } from 'yargs';
2
2
  import { Instance } from '../../project/instance';
3
3
  import { Provider } from '../../provider/provider';
4
+ import { ModelsDev } from '../../provider/models';
5
+ import { ModelDiscovery } from '../../provider/model-discovery';
6
+ import { ExtraProviders } from '../../provider/extra-providers';
4
7
  import { cmd } from './cmd';
5
8
  import { UI } from '../ui';
6
9
  import { EOL } from 'os';
7
10
 
11
+ function isFree(model: ModelsDev.Model) {
12
+ return model.cost?.input === 0 && model.cost?.output === 0;
13
+ }
14
+
8
15
  export const ModelsCommand = cmd({
9
16
  command: 'models [provider]',
10
17
  describe: 'list all available models',
@@ -19,39 +26,87 @@ export const ModelsCommand = cmd({
19
26
  describe:
20
27
  'use more verbose model output (includes metadata like costs)',
21
28
  type: 'boolean',
29
+ })
30
+ .option('discover', {
31
+ describe:
32
+ "also list models from each provider's own /models endpoint (cached for an hour)",
33
+ type: 'boolean',
34
+ })
35
+ .option('refresh', {
36
+ describe:
37
+ 'refetch the models.dev catalogue and provider model listings, ignoring caches (implies --discover)',
38
+ type: 'boolean',
39
+ })
40
+ .option('free', {
41
+ describe: 'only list models that cost nothing to use',
42
+ type: 'boolean',
22
43
  });
23
44
  },
24
45
  handler: async (args) => {
46
+ if (args.refresh) await ModelsDev.refresh();
47
+ const discover = Boolean(args.discover || args.refresh);
48
+
25
49
  await Instance.provide({
26
50
  directory: process.cwd(),
27
51
  async fn() {
28
- const providers = await Provider.list();
29
-
30
- function printModels(providerID: string, verbose?: boolean) {
31
- const provider = providers[providerID];
32
- for (const [modelID, model] of Object.entries(provider.info.models)) {
33
- process.stdout.write(`${providerID}/${modelID}`);
52
+ async function printModels(providerID: string) {
53
+ const provider = await Provider.getProvider(providerID);
54
+ if (!provider) return false;
55
+ const models: Record<string, ModelsDev.Model> = {
56
+ ...provider.info.models,
57
+ };
58
+ const unpriced = new Set<string>();
59
+ const keyless = ExtraProviders.isKeyless(provider.info.id);
60
+ if (discover) {
61
+ const result = await Provider.discoverModels(providerID, {
62
+ force: Boolean(args.refresh),
63
+ });
64
+ if (result.status === 'error') {
65
+ UI.error(
66
+ `${providerID}: model discovery failed: ${result.error}`
67
+ );
68
+ }
69
+ const s = await Provider.state();
70
+ for (const found of result.models) {
71
+ if (models[found.id]) continue;
72
+ const metadata = ModelDiscovery.catalogueMetadata(
73
+ s.database,
74
+ found.model ?? found.id,
75
+ provider.info.id
76
+ );
77
+ // A listing without prices says nothing about the cost, except
78
+ // on keyless providers (gpt4free, local servers), which are free.
79
+ if (!keyless && !found.free && !found.cost && !metadata?.cost) {
80
+ unpriced.add(found.id);
81
+ }
82
+ models[found.id] = ModelDiscovery.toModelInfo(found, {
83
+ metadata,
84
+ });
85
+ }
86
+ }
87
+ for (const [modelID, model] of Object.entries(models)) {
88
+ if (args.free && (unpriced.has(modelID) || !isFree(model)))
89
+ continue;
90
+ process.stdout.write(`${provider.info.id}/${modelID}`);
34
91
  process.stdout.write(EOL);
35
- if (verbose) {
92
+ if (args.verbose) {
36
93
  process.stdout.write(JSON.stringify(model, null, 2));
37
94
  process.stdout.write(EOL);
38
95
  }
39
96
  }
97
+ return true;
40
98
  }
41
99
 
42
100
  if (args.provider) {
43
- const provider = providers[args.provider];
44
- if (!provider) {
101
+ if (!(await printModels(args.provider))) {
45
102
  UI.error(`Provider not found: ${args.provider}`);
46
- return;
47
103
  }
48
-
49
- printModels(args.provider, args.verbose);
50
104
  return;
51
105
  }
52
106
 
107
+ const providers = await Provider.list();
53
108
  for (const providerID of Object.keys(providers)) {
54
- printModels(providerID, args.verbose);
109
+ await printModels(providerID);
55
110
  }
56
111
  },
57
112
  });
@@ -5,6 +5,7 @@ import {
5
5
  getCompactionSafetyMarginFromProcessArgv,
6
6
  } from './argv.ts';
7
7
  import { Log } from '../util/log.ts';
8
+ import { ExtraProviders } from '../provider/extra-providers.ts';
8
9
  import {
9
10
  DEFAULT_PROVIDER_ID,
10
11
  DEFAULT_MODEL_ID,
@@ -124,7 +125,8 @@ export async function parseModelConfig(
124
125
  if (modelArg.includes('/')) {
125
126
  // Explicit provider/model format - respect user's choice
126
127
  const modelParts = modelArg.split('/');
127
- providerID = modelParts[0];
128
+ // Accept alternative spellings such as `g4f/...` for `gpt4free/...` (#334)
129
+ providerID = ExtraProviders.canonicalID(modelParts[0]);
128
130
  modelID = modelParts.slice(1).join('/');
129
131
 
130
132
  // Validate that providerID and modelID are not empty
@@ -152,9 +154,10 @@ export async function parseModelConfig(
152
154
  const isDefaultModel = !cliModelArg && modelArg === defaultModel;
153
155
  try {
154
156
  const { Provider } = await import('../provider/provider.ts');
155
- const s = await Provider.state();
156
- const provider = s.providers[providerID];
157
- if (provider && !provider.info.models[modelID]) {
157
+ // getProvider also enables keyless providers (gpt4free, local servers)
158
+ // on explicit request; routers among them accept unlisted IDs (#334).
159
+ const provider = await Provider.getProvider(providerID);
160
+ if (provider && !(await Provider.hasModel(providerID, modelID))) {
158
161
  const liveInfo = await Provider.refreshLiveModelInfo(
159
162
  providerID,
160
163
  modelID
package/src/index.js CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  } from './json-standard/index.ts';
27
27
  import { McpCommand } from './cli/cmd/mcp.ts';
28
28
  import { AuthCommand } from './cli/cmd/auth.ts';
29
+ import { ModelsCommand } from './cli/cmd/models.ts';
29
30
  import { FormatError } from './cli/error.ts';
30
31
  import { UI } from './cli/ui.ts';
31
32
  import { errorText } from './util/error-text.ts';
@@ -654,6 +655,8 @@ async function main() {
654
655
  .command(McpCommand)
655
656
  // Auth subcommand
656
657
  .command(AuthCommand)
658
+ // Models subcommand (list, --discover, --refresh, --free)
659
+ .command(ModelsCommand)
657
660
  // Default command for agent mode (when no subcommand specified)
658
661
  .command({
659
662
  command: '$0',
@@ -0,0 +1,115 @@
1
+ import type { ModelsDev } from './models';
2
+
3
+ /**
4
+ * Environment handling for models.dev provider entries.
5
+ *
6
+ * models.dev (the catalogue OpenCode uses) describes some providers with
7
+ * templated base URLs such as
8
+ * `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1`
9
+ * and lists the template variables next to the real credentials in
10
+ * `provider.env`. The variables configure *where* to send requests; only the
11
+ * remaining variables carry a credential.
12
+ *
13
+ * @see https://github.com/link-assistant/agent/issues/334
14
+ */
15
+ export namespace EnvTemplate {
16
+ const PATTERN = /\$\{([A-Za-z0-9_]+)\}/g;
17
+
18
+ /**
19
+ * Variable names that configure an endpoint, account, region or project.
20
+ * They must never be sent as an API key.
21
+ */
22
+ const NON_CREDENTIAL =
23
+ /(_ID|_NAME|_REGION|_PROJECT|_LOCATION|_HOST|_URL|_ACCOUNT|_ENDPOINT|_CREDENTIALS|_RESOURCE_GROUP|_VERSION|_ORG|_ORGANIZATION)$/;
24
+
25
+ /** Part of a SigV4 key pair, not a bearer credential. */
26
+ const PAIRED_SECRETS = new Set(['AWS_SECRET_ACCESS_KEY']);
27
+
28
+ export function variables(template?: string): string[] {
29
+ if (!template) return [];
30
+ return Array.from(template.matchAll(PATTERN), (match) => match[1]);
31
+ }
32
+
33
+ export function isCredentialEnv(name: string): boolean {
34
+ return !NON_CREDENTIAL.test(name) && !PAIRED_SECRETS.has(name);
35
+ }
36
+
37
+ /**
38
+ * The env vars of a provider that hold an API key, in catalogue order.
39
+ */
40
+ export function credentialEnv(provider: ModelsDev.Provider): string[] {
41
+ const templated = new Set(variables(provider.api));
42
+ for (const model of Object.values(provider.models ?? {})) {
43
+ for (const name of variables(model.provider?.api)) templated.add(name);
44
+ }
45
+ return (provider.env ?? []).filter(
46
+ (name) => !templated.has(name) && isCredentialEnv(name)
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Values for template variables that OpenCode derives instead of reading
52
+ * verbatim (normalized hosts, Vertex endpoint, default AWS region).
53
+ */
54
+ export function derived(
55
+ env: Record<string, string | undefined> = process.env
56
+ ): Record<string, string | undefined> {
57
+ const location =
58
+ env['GOOGLE_VERTEX_LOCATION'] ??
59
+ env['GOOGLE_CLOUD_LOCATION'] ??
60
+ env['VERTEX_LOCATION'] ??
61
+ 'global';
62
+ return {
63
+ AWS_REGION: env['AWS_REGION'] ?? env['AWS_DEFAULT_REGION'] ?? 'us-east-1',
64
+ DATABRICKS_HOST: stripHost(env['DATABRICKS_HOST']),
65
+ SNOWFLAKE_ACCOUNT: stripHost(env['SNOWFLAKE_ACCOUNT'])?.replace(
66
+ /\.snowflakecomputing\.com$/,
67
+ ''
68
+ ),
69
+ NEON_AI_GATEWAY_BASE_URL: env['NEON_AI_GATEWAY_BASE_URL']?.replace(
70
+ /\/+$/,
71
+ ''
72
+ ),
73
+ GOOGLE_VERTEX_PROJECT:
74
+ env['GOOGLE_VERTEX_PROJECT'] ??
75
+ env['GOOGLE_CLOUD_PROJECT'] ??
76
+ env['GCP_PROJECT'] ??
77
+ env['GCLOUD_PROJECT'],
78
+ GOOGLE_VERTEX_LOCATION: location,
79
+ GOOGLE_VERTEX_ENDPOINT:
80
+ location === 'global'
81
+ ? 'aiplatform.googleapis.com'
82
+ : `${location}-aiplatform.googleapis.com`,
83
+ };
84
+ }
85
+
86
+ function stripHost(value?: string) {
87
+ if (!value) return undefined;
88
+ return value.replace(/^[a-z]+:\/\//i, '').replace(/\/+$/, '');
89
+ }
90
+
91
+ /**
92
+ * Substitute `${VAR}` placeholders. Returns `undefined` when any variable is
93
+ * missing, so callers never send requests to a half-filled URL.
94
+ */
95
+ export function resolve(
96
+ template: string | undefined,
97
+ env: Record<string, string | undefined> = process.env,
98
+ values: Record<string, string | undefined> = {}
99
+ ): string | undefined {
100
+ if (!template) return undefined;
101
+ if (!template.includes('${')) return template;
102
+ const lookup = { ...derived(env), ...values };
103
+ let missing = false;
104
+ const result = template.replace(PATTERN, (_, name: string) => {
105
+ const value = lookup[name] ?? env[name];
106
+ if (!value) missing = true;
107
+ return value ?? '';
108
+ });
109
+ return missing ? undefined : result;
110
+ }
111
+
112
+ export function isTemplate(value?: string): boolean {
113
+ return Boolean(value?.includes('${'));
114
+ }
115
+ }
@@ -0,0 +1,347 @@
1
+ import type { ModelsDev } from './models';
2
+
3
+ /**
4
+ * Providers that models.dev (the catalogue OpenCode uses) does not list:
5
+ * gpt4free (https://github.com/xtekky/gpt4free) and its hosted gateway,
6
+ * Pollinations, other OpenAI-compatible endpoints from gpt4free's provider
7
+ * list, and local inference servers.
8
+ *
9
+ * A definition only fills a gap: if models.dev ever adds the same provider ID,
10
+ * the models.dev entry wins. Seed models are a starting point, the real list
11
+ * comes from the provider's `/models` endpoint (see `ModelDiscovery`).
12
+ *
13
+ * @see https://github.com/link-assistant/agent/issues/334
14
+ */
15
+ export namespace ExtraProviders {
16
+ export interface Definition {
17
+ id: string;
18
+ name: string;
19
+ /** Default OpenAI-compatible base URL. */
20
+ api: string;
21
+ /** Env vars that override the base URL, first match wins. */
22
+ baseURLEnv?: string[];
23
+ /** Optional or required API key env vars. */
24
+ env: string[];
25
+ doc: string;
26
+ /**
27
+ * Usable without credentials (local servers, anonymous free tiers).
28
+ * Such providers stay hidden until explicitly requested with
29
+ * `--model <provider>/<model>` or configured, so they never take over
30
+ * default or short-name resolution.
31
+ */
32
+ keyless: boolean;
33
+ /** Router or local server: accept model IDs not present in any list. */
34
+ passthrough: boolean;
35
+ models: Record<string, ModelsDev.Model>;
36
+ }
37
+
38
+ function seed(
39
+ id: string,
40
+ name: string,
41
+ options: {
42
+ context?: number;
43
+ output?: number;
44
+ reasoning?: boolean;
45
+ attachment?: boolean;
46
+ free?: boolean;
47
+ } = {}
48
+ ): ModelsDev.Model {
49
+ const context = options.context ?? 128000;
50
+ return {
51
+ id,
52
+ name,
53
+ release_date: '',
54
+ attachment: options.attachment ?? false,
55
+ reasoning: options.reasoning ?? false,
56
+ temperature: true,
57
+ tool_call: true,
58
+ cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
59
+ limit: { context, output: options.output ?? Math.min(16384, context) },
60
+ modalities: {
61
+ input: options.attachment ? ['text', 'image'] : ['text'],
62
+ output: ['text'],
63
+ },
64
+ options: {},
65
+ };
66
+ }
67
+
68
+ const LOCAL = { context: 32768, output: 8192 };
69
+
70
+ export const DEFINITIONS: Definition[] = [
71
+ {
72
+ id: 'gpt4free',
73
+ name: 'GPT4Free (local Interference API)',
74
+ api: 'http://localhost:1337/v1',
75
+ baseURLEnv: ['G4F_BASE_URL', 'GPT4FREE_BASE_URL'],
76
+ env: ['G4F_API_KEY'],
77
+ doc: 'https://github.com/xtekky/gpt4free',
78
+ keyless: true,
79
+ passthrough: true,
80
+ models: { auto: seed('auto', 'GPT4Free Auto (provider rotation)') },
81
+ },
82
+ {
83
+ id: 'g4f-space',
84
+ name: 'GPT4Free hosted gateway (g4f.space)',
85
+ api: 'https://g4f.space/v1',
86
+ baseURLEnv: ['G4F_SPACE_BASE_URL'],
87
+ env: ['G4F_SPACE_API_KEY'],
88
+ doc: 'https://g4f.dev/docs',
89
+ keyless: true,
90
+ passthrough: true,
91
+ models: { auto: seed('auto', 'GPT4Free Auto (random public server)') },
92
+ },
93
+ {
94
+ id: 'pollinations',
95
+ name: 'Pollinations',
96
+ api: 'https://gen.pollinations.ai/v1',
97
+ baseURLEnv: ['POLLINATIONS_BASE_URL'],
98
+ env: ['POLLINATIONS_API_KEY'],
99
+ doc: 'https://pollinations.ai',
100
+ keyless: true,
101
+ passthrough: true,
102
+ models: {
103
+ openai: seed('openai', 'GPT-5.4 Nano (Pollinations alias)', {
104
+ context: 400000,
105
+ reasoning: true,
106
+ attachment: true,
107
+ }),
108
+ 'openai-fast': seed('openai-fast', 'GPT-5 Nano (Pollinations alias)', {
109
+ context: 400000,
110
+ reasoning: true,
111
+ attachment: true,
112
+ }),
113
+ deepseek: seed('deepseek', 'DeepSeek V4 Flash (Pollinations alias)', {
114
+ context: 1048576,
115
+ reasoning: true,
116
+ }),
117
+ kimi: seed('kimi', 'Kimi K2.6 (Pollinations alias)', {
118
+ context: 262000,
119
+ reasoning: true,
120
+ }),
121
+ minimax: seed('minimax', 'MiniMax M3 (Pollinations alias)', {
122
+ context: 524288,
123
+ reasoning: true,
124
+ }),
125
+ nemotron: seed('nemotron', 'Nemotron 3 Ultra (Pollinations alias)', {
126
+ context: 262144,
127
+ reasoning: true,
128
+ }),
129
+ },
130
+ },
131
+ {
132
+ id: 'airforce',
133
+ name: 'Airforce',
134
+ api: 'https://api.airforce/v1',
135
+ env: ['AIRFORCE_API_KEY'],
136
+ doc: 'https://api.airforce',
137
+ keyless: false,
138
+ passthrough: true,
139
+ models: {},
140
+ },
141
+ {
142
+ id: 'aibadgr',
143
+ name: 'AI Badgr',
144
+ api: 'https://aibadgr.com/api/v1',
145
+ env: ['AIBADGR_API_KEY'],
146
+ doc: 'https://aibadgr.com',
147
+ keyless: false,
148
+ passthrough: false,
149
+ models: {},
150
+ },
151
+ {
152
+ id: 'api-route',
153
+ name: 'APIRoute',
154
+ api: 'https://global.api-route.com/v1',
155
+ env: ['APIROUTE_API_KEY', 'API_ROUTE_API_KEY'],
156
+ doc: 'https://www.api-route.com',
157
+ keyless: false,
158
+ passthrough: true,
159
+ models: {},
160
+ },
161
+ {
162
+ id: 'cheaperinference',
163
+ name: 'CheaperInference',
164
+ api: 'https://api.cheaperinference.com/v1',
165
+ env: ['CHEAPERINFERENCE_API_KEY'],
166
+ doc: 'https://cheaperinference.com',
167
+ keyless: false,
168
+ passthrough: false,
169
+ models: {},
170
+ },
171
+ {
172
+ id: 'theb',
173
+ name: 'TheB.AI',
174
+ api: 'https://api.theb.ai/v1',
175
+ env: ['THEB_API_KEY'],
176
+ doc: 'https://theb.ai',
177
+ keyless: false,
178
+ passthrough: false,
179
+ models: {},
180
+ },
181
+ {
182
+ id: 'ollama',
183
+ name: 'Ollama (local)',
184
+ api: 'http://127.0.0.1:11434/v1',
185
+ baseURLEnv: ['OLLAMA_BASE_URL', 'OLLAMA_HOST'],
186
+ env: [],
187
+ doc: 'https://ollama.com',
188
+ keyless: true,
189
+ passthrough: true,
190
+ models: {},
191
+ },
192
+ {
193
+ id: 'vllm',
194
+ name: 'vLLM (local)',
195
+ api: 'http://127.0.0.1:8000/v1',
196
+ baseURLEnv: ['VLLM_BASE_URL'],
197
+ env: ['VLLM_API_KEY'],
198
+ doc: 'https://docs.vllm.ai',
199
+ keyless: true,
200
+ passthrough: true,
201
+ models: {},
202
+ },
203
+ {
204
+ id: 'llamacpp',
205
+ name: 'llama.cpp server (local)',
206
+ api: 'http://127.0.0.1:8080/v1',
207
+ baseURLEnv: ['LLAMACPP_BASE_URL'],
208
+ env: ['LLAMACPP_API_KEY'],
209
+ doc: 'https://github.com/ggml-org/llama.cpp',
210
+ keyless: true,
211
+ passthrough: true,
212
+ models: {},
213
+ },
214
+ {
215
+ id: 'omniroute',
216
+ name: 'OmniRoute (local)',
217
+ api: 'http://localhost:20128/v1',
218
+ baseURLEnv: ['OMNIROUTE_BASE_URL'],
219
+ env: ['OMNIROUTE_API_KEY'],
220
+ doc: 'https://github.com/xtekky/gpt4free',
221
+ keyless: true,
222
+ passthrough: true,
223
+ models: {},
224
+ },
225
+ ];
226
+
227
+ /** Alternative spellings accepted in `--model`. */
228
+ export const ALIASES: Record<string, string> = {
229
+ g4f: 'gpt4free',
230
+ 'gpt-4-free': 'gpt4free',
231
+ 'llama.cpp': 'llamacpp',
232
+ };
233
+
234
+ /** Catalogue entries we did not add but treat as local/keyless. */
235
+ const EXTRA_KEYLESS = new Set(['lmstudio']);
236
+ const EXTRA_PASSTHROUGH = new Set(['lmstudio']);
237
+
238
+ /**
239
+ * models.dev packages that do not load with the bundled AI SDK major
240
+ * version, mapped to their OpenAI-compatible endpoint.
241
+ */
242
+ export const NPM_FALLBACKS: Record<string, { npm: string; api: string }> = {
243
+ 'venice-ai-sdk-provider': {
244
+ npm: '@ai-sdk/openai-compatible',
245
+ api: 'https://api.venice.ai/api/v1',
246
+ },
247
+ '@aihubmix/ai-sdk-provider': {
248
+ npm: '@ai-sdk/openai-compatible',
249
+ api: 'https://aihubmix.com/v1',
250
+ },
251
+ };
252
+
253
+ const byID = new Map(DEFINITIONS.map((item) => [item.id, item]));
254
+
255
+ export function canonicalID(providerID: string): string {
256
+ return ALIASES[providerID] ?? providerID;
257
+ }
258
+
259
+ export function get(providerID: string): Definition | undefined {
260
+ return byID.get(canonicalID(providerID));
261
+ }
262
+
263
+ export function isKeyless(providerID: string): boolean {
264
+ const id = canonicalID(providerID);
265
+ return Boolean(byID.get(id)?.keyless) || EXTRA_KEYLESS.has(id);
266
+ }
267
+
268
+ export function allowsUnknownModels(providerID: string): boolean {
269
+ const id = canonicalID(providerID);
270
+ return Boolean(byID.get(id)?.passthrough) || EXTRA_PASSTHROUGH.has(id);
271
+ }
272
+
273
+ function normalizeBaseURL(id: string, value: string): string {
274
+ let url = value.trim().replace(/\/+$/, '');
275
+ if (!/^[a-z]+:\/\//i.test(url)) url = `http://${url}`;
276
+ // OLLAMA_HOST and similar point at the server root, not the API path.
277
+ if (id === 'ollama' && !/\/v1$/.test(url)) url = `${url}/v1`;
278
+ return url;
279
+ }
280
+
281
+ /**
282
+ * Base URL for a definition. `G4F_PROVIDER` pins gpt4free to one of its
283
+ * providers through the `/api/{provider}` route of the Interference API.
284
+ */
285
+ export function baseURL(
286
+ definition: Definition,
287
+ env: Record<string, string | undefined> = process.env
288
+ ): string {
289
+ const fromEnv = definition.baseURLEnv
290
+ ?.map((name) => env[name])
291
+ .find((value) => value && value.trim());
292
+ let url = fromEnv
293
+ ? normalizeBaseURL(definition.id, fromEnv)
294
+ : definition.api;
295
+ const pinned = env['G4F_PROVIDER']?.trim();
296
+ if (
297
+ pinned &&
298
+ (definition.id === 'gpt4free' || definition.id === 'g4f-space')
299
+ ) {
300
+ url = `${url.replace(/\/v1$/, '')}/api/${encodeURIComponent(pinned)}`;
301
+ }
302
+ return url;
303
+ }
304
+
305
+ export function toProvider(
306
+ definition: Definition,
307
+ env: Record<string, string | undefined> = process.env
308
+ ): ModelsDev.Provider {
309
+ return {
310
+ id: definition.id,
311
+ name: definition.name,
312
+ api: baseURL(definition, env),
313
+ npm: '@ai-sdk/openai-compatible',
314
+ env: definition.env,
315
+ doc: definition.doc,
316
+ models: structuredClone(definition.models),
317
+ };
318
+ }
319
+
320
+ /**
321
+ * Add the missing providers and patch catalogue entries the bundled SDKs
322
+ * cannot load. Mutates and returns `database`.
323
+ */
324
+ export function augment(
325
+ database: Record<string, ModelsDev.Provider>,
326
+ env: Record<string, string | undefined> = process.env
327
+ ): Record<string, ModelsDev.Provider> {
328
+ for (const definition of DEFINITIONS) {
329
+ if (database[definition.id]) continue;
330
+ database[definition.id] = toProvider(definition, env);
331
+ }
332
+
333
+ for (const provider of Object.values(database)) {
334
+ const fallback = provider.npm ? NPM_FALLBACKS[provider.npm] : undefined;
335
+ if (!fallback) continue;
336
+ provider.npm = fallback.npm;
337
+ provider.api = provider.api ?? fallback.api;
338
+ }
339
+
340
+ const lmstudio = database['lmstudio'];
341
+ const lmstudioURL = env['LMSTUDIO_BASE_URL']?.trim();
342
+ if (lmstudio && lmstudioURL) {
343
+ lmstudio.api = normalizeBaseURL('lmstudio', lmstudioURL);
344
+ }
345
+ return database;
346
+ }
347
+ }
@@ -46,4 +46,23 @@ export namespace Kilo {
46
46
  65536
47
47
  ),
48
48
  };
49
+
50
+ /**
51
+ * Text models models.dev lists for Kilo. Their IDs are the same on the
52
+ * OpenRouter-compatible route this provider uses, so the curated aliases
53
+ * above only add short names for the free Nemotron models (#334).
54
+ */
55
+ export function catalogueModels(
56
+ provider: ModelsDev.Provider | undefined
57
+ ): Record<string, ModelsDev.Model> {
58
+ const result: Record<string, ModelsDev.Model> = {};
59
+ for (const [key, model] of Object.entries(provider?.models ?? {})) {
60
+ const output = model.modalities?.output ?? ['text'];
61
+ if (!output.includes('text')) continue;
62
+ // The catalogue points at the gateway route; ours is fixed.
63
+ const { provider: _route, ...rest } = model;
64
+ result[key] = structuredClone(rest);
65
+ }
66
+ return result;
67
+ }
49
68
  }