@link-assistant/agent 0.26.15 → 0.27.1

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.
Files changed (65) hide show
  1. package/README.md +49 -7
  2. package/package.json +51 -42
  3. package/src/agent/agent.ts +12 -7
  4. package/src/auth/azure-login.ts +241 -0
  5. package/src/auth/plugins.ts +9 -0
  6. package/src/auth/provider-logins.ts +804 -0
  7. package/src/cli/argv.ts +1 -1
  8. package/src/cli/cmd/auth.ts +4 -0
  9. package/src/cli/cmd/models.ts +68 -13
  10. package/src/cli/cmd/run.ts +7 -1
  11. package/src/cli/continuous-mode.js +38 -12
  12. package/src/cli/event-handler.js +3 -1
  13. package/src/cli/model-config.js +27 -8
  14. package/src/cli/run-options.js +144 -78
  15. package/src/config/config.ts +306 -105
  16. package/src/config/defaults.ts +13 -5
  17. package/src/config/file-config.ts +16 -0
  18. package/src/file/ripgrep.ts +38 -5
  19. package/src/file/time.ts +86 -9
  20. package/src/index.js +42 -33
  21. package/src/mcp/index.ts +370 -106
  22. package/src/permission/index.ts +22 -0
  23. package/src/project/instance.ts +5 -1
  24. package/src/project/project.ts +35 -2
  25. package/src/provider/env-template.ts +115 -0
  26. package/src/provider/extra-providers.ts +347 -0
  27. package/src/provider/free-tier-error.ts +1 -1
  28. package/src/provider/kilo-deprecation.ts +30 -0
  29. package/src/provider/kilo.ts +44 -7
  30. package/src/provider/model-discovery.ts +690 -0
  31. package/src/provider/models.ts +11 -1
  32. package/src/provider/parity-loaders.ts +595 -0
  33. package/src/provider/provider.ts +309 -203
  34. package/src/provider/retry-fetch.ts +5 -0
  35. package/src/provider/transform.ts +168 -17
  36. package/src/session/compaction.ts +18 -2
  37. package/src/session/index.ts +30 -3
  38. package/src/session/message-v2.ts +318 -32
  39. package/src/session/processor.ts +274 -140
  40. package/src/session/prompt.ts +154 -128
  41. package/src/session/retry.ts +26 -13
  42. package/src/session/summary.ts +4 -1
  43. package/src/tool/bash.ts +232 -45
  44. package/src/tool/bash.txt +1 -1
  45. package/src/tool/codesearch.ts +15 -78
  46. package/src/tool/edit.ts +111 -30
  47. package/src/tool/glob.ts +2 -1
  48. package/src/tool/grep.ts +175 -57
  49. package/src/tool/ls.ts +2 -1
  50. package/src/tool/multiedit.ts +3 -1
  51. package/src/tool/read.ts +169 -41
  52. package/src/tool/task.ts +26 -18
  53. package/src/tool/tool.ts +4 -1
  54. package/src/tool/webfetch.ts +68 -56
  55. package/src/tool/webfetch.txt +4 -4
  56. package/src/tool/websearch.ts +20 -75
  57. package/src/tool/websearch.txt +2 -0
  58. package/src/tool/write.ts +23 -6
  59. package/src/util/bom.ts +32 -0
  60. package/src/util/exa-mcp.ts +82 -0
  61. package/src/util/legacy-error-names.ts +39 -0
  62. package/src/util/read-bounded.ts +32 -0
  63. package/src/util/sse-usage-extractor.ts +67 -13
  64. package/src/util/timeout.ts +3 -4
  65. package/src/util/verbose-fetch.ts +40 -1
@@ -45,7 +45,12 @@ export namespace Project {
45
45
  }
46
46
  let worktree = path.dirname(git);
47
47
  const timer = log.time('git.rev-parse');
48
- let id = await Bun.file(path.join(git, 'opencode'))
48
+ // In a git worktree or submodule `.git` is a file, so the id cache lives in
49
+ // the common git directory instead (as upstream does). Writing to
50
+ // `.git/opencode` there used to reject with ENOTDIR; the rejection was not
51
+ // handled and the CLI exited with an UnhandledRejection (issue #335).
52
+ const cacheDir = await gitCacheDir(git, worktree);
53
+ let id = await Bun.file(path.join(cacheDir, 'opencode'))
49
54
  .text()
50
55
  .then((x) => x.trim())
51
56
  .catch(() => {});
@@ -63,7 +68,16 @@ export namespace Project {
63
68
  .toSorted()
64
69
  );
65
70
  id = roots[0];
66
- if (id) Bun.file(path.join(git, 'opencode')).write(id);
71
+ // The cache is best effort: a read-only or unusual `.git` must not fail
72
+ // project detection.
73
+ if (id)
74
+ await Bun.write(path.join(cacheDir, 'opencode'), id).catch((error) =>
75
+ log.info(() => ({
76
+ message: 'project id cache not written',
77
+ path: path.join(cacheDir, 'opencode'),
78
+ error: String(error),
79
+ }))
80
+ );
67
81
  }
68
82
  timer.stop();
69
83
  if (!id) {
@@ -95,6 +109,25 @@ export namespace Project {
95
109
  return project;
96
110
  }
97
111
 
112
+ /**
113
+ * Directory that holds the project id cache: `.git` itself when it is a
114
+ * directory, otherwise the common git directory reported by git (worktrees
115
+ * and submodules have a `.git` file pointing elsewhere).
116
+ */
117
+ async function gitCacheDir(git: string, worktree: string): Promise<string> {
118
+ const stat = await fs.stat(git).catch(() => undefined);
119
+ if (stat?.isDirectory()) return git;
120
+ const common =
121
+ await $`git rev-parse --path-format=absolute --git-common-dir`
122
+ .quiet()
123
+ .nothrow()
124
+ .cwd(worktree)
125
+ .text()
126
+ .then((x) => x.trim())
127
+ .catch(() => '');
128
+ return common || git;
129
+ }
130
+
98
131
  export async function setInitialized(projectID: string) {
99
132
  await Storage.update<Info>(['project', projectID], (draft) => {
100
133
  draft.time.initialized = Date.now();
@@ -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
+ }
@@ -13,4 +13,4 @@ export function isFreeTierError(
13
13
  }
14
14
 
15
15
  export const FREE_TIER_ERROR_MESSAGE =
16
- "OpenCode Zen's free tier is limited to OpenCode clients. Use kilo/nemotron-3-super-free (authenticate with agent auth login or KILO_API_KEY), or select a paid model.";
16
+ "OpenCode Zen's free tier is limited to OpenCode clients. Use kilo/nemotron-3-ultra-free (authenticate with agent auth login or KILO_API_KEY), or select a paid model.";
@@ -0,0 +1,30 @@
1
+ import { Log } from '../util/log';
2
+ import { Kilo } from './kilo';
3
+
4
+ const log = Log.create({ service: 'kilo' });
5
+
6
+ // Aliases already warned about. A stored session or config that names an
7
+ // alias reaches getModel()/parseModel() many times in one process, so warn
8
+ // once per alias; the set is bounded by `Kilo.DEPRECATED_ALIASES`.
9
+ const warned = new Set<string>();
10
+
11
+ /**
12
+ * Map a deprecated Kilo alias (`Kilo.DEPRECATED_ALIASES`) to its replacement
13
+ * and warn about it (once per alias and process); any other model ID is
14
+ * returned unchanged (#335).
15
+ *
16
+ * Kept out of `kilo.ts` so that module has no runtime dependencies.
17
+ */
18
+ export function resolveKiloDeprecatedAlias(modelID: string): string {
19
+ const replacement = Kilo.deprecatedAliasTarget(modelID);
20
+ if (!replacement) return modelID;
21
+ if (warned.has(modelID)) return replacement;
22
+ warned.add(modelID);
23
+ log.warn(() => ({
24
+ message: `kilo/${modelID} is deprecated and no longer served by Kilo; using kilo/${replacement} instead`,
25
+ deprecatedModel: `kilo/${modelID}`,
26
+ replacement: `kilo/${replacement}`,
27
+ hint: `Pass --model kilo/${replacement} to silence this warning`,
28
+ }));
29
+ return replacement;
30
+ }
@@ -25,14 +25,7 @@ export namespace Kilo {
25
25
  };
26
26
  }
27
27
 
28
- // Super's output cap is deliberately conservative for existing integrations.
29
28
  export const FREE_MODELS: Record<string, ModelsDev.Model> = {
30
- 'nemotron-3-super-free': freeModel(
31
- 'nvidia/nemotron-3-super-120b-a12b:free',
32
- 'Nemotron 3 Super (Free)',
33
- 262144,
34
- 32768
35
- ),
36
29
  'nemotron-3-ultra-free': freeModel(
37
30
  'nvidia/nemotron-3-ultra-550b-a55b:free',
38
31
  'Nemotron 3 Ultra (Free)',
@@ -46,4 +39,48 @@ export namespace Kilo {
46
39
  65536
47
40
  ),
48
41
  };
42
+
43
+ /**
44
+ * Withdrawn built-in aliases that keep working temporarily. Each one maps to
45
+ * the served alias in `FREE_MODELS` that replaces it, and every resolution
46
+ * logs a warning naming the replacement. These are not catalog entries, so
47
+ * the drift check (`scripts/check-model-drift.mjs`) does not look them up.
48
+ *
49
+ * Kilo stopped serving `nvidia/nemotron-3-super-120b-a12b:free`; only the
50
+ * paid Super model remains on the gateway (#335).
51
+ */
52
+ export const DEPRECATED_ALIASES: Readonly<Record<string, string>> = {
53
+ 'nemotron-3-super-free': 'nemotron-3-ultra-free',
54
+ };
55
+
56
+ /**
57
+ * Replacement for a deprecated alias, or `undefined`. Logs nothing; use
58
+ * `resolveKiloDeprecatedAlias` from `./kilo-deprecation` to also warn.
59
+ * This module stays free of runtime dependencies because
60
+ * `scripts/check-default-models.mjs` imports it without installed packages.
61
+ */
62
+ export function deprecatedAliasTarget(modelID: string): string | undefined {
63
+ return Object.hasOwn(DEPRECATED_ALIASES, modelID)
64
+ ? DEPRECATED_ALIASES[modelID]
65
+ : undefined;
66
+ }
67
+
68
+ /**
69
+ * Text models models.dev lists for Kilo. Their IDs are the same on the
70
+ * OpenRouter-compatible route this provider uses, so the curated aliases
71
+ * above only add short names for the free Nemotron models (#334).
72
+ */
73
+ export function catalogueModels(
74
+ provider: ModelsDev.Provider | undefined
75
+ ): Record<string, ModelsDev.Model> {
76
+ const result: Record<string, ModelsDev.Model> = {};
77
+ for (const [key, model] of Object.entries(provider?.models ?? {})) {
78
+ const output = model.modalities?.output ?? ['text'];
79
+ if (!output.includes('text')) continue;
80
+ // The catalogue points at the gateway route; ours is fixed.
81
+ const { provider: _route, ...rest } = model;
82
+ result[key] = structuredClone(rest);
83
+ }
84
+ return result;
85
+ }
49
86
  }