@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
@@ -10,6 +10,7 @@ import { NamedError } from '../util/error';
10
10
  import { Auth } from '../auth';
11
11
  import { ClaudeOAuth } from '../auth/claude-oauth';
12
12
  import { AuthPlugins } from '../auth/plugins';
13
+ import { AzureLogin } from '../auth/azure-login';
13
14
  import { Instance } from '../project/instance';
14
15
  import { Global } from '../global';
15
16
  import { config, isVerbose } from '../config/config';
@@ -19,23 +20,40 @@ import { createEchoModel } from './echo';
19
20
  import { createCacheModel } from './cache';
20
21
  import { RetryFetch } from './retry-fetch';
21
22
  import { SSEUsageExtractor } from '../util/sse-usage-extractor';
23
+ import { sanitizeUrl } from '../util/verbose-fetch';
22
24
  import { OpenCodeZen } from './opencode-zen';
23
25
  import { Kilo } from './kilo';
26
+ import { resolveKiloDeprecatedAlias } from './kilo-deprecation';
27
+ import { EnvTemplate } from './env-template';
28
+ import { ExtraProviders } from './extra-providers';
29
+ import { ModelDiscovery } from './model-discovery';
30
+ import { ParityLoaders } from './parity-loaders';
24
31
 
25
32
  // Direct imports for bundled providers - these are pre-installed to avoid runtime installation hangs
26
33
  // @see https://github.com/link-assistant/agent/issues/173
27
34
  // @see https://github.com/oven-sh/bun/issues/5831 (bun install hangs sporadically)
28
35
  import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
36
+ import { createBedrockMantle } from '@ai-sdk/amazon-bedrock/mantle';
29
37
  import { createAnthropic } from '@ai-sdk/anthropic';
30
38
  import { createAzure } from '@ai-sdk/azure';
31
39
  import { createGoogleGenerativeAI } from '@ai-sdk/google';
32
40
  import { createVertex } from '@ai-sdk/google-vertex';
41
+ import { createVertexAnthropic } from '@ai-sdk/google-vertex/anthropic';
33
42
  import { createOpenAI } from '@ai-sdk/openai';
34
43
  import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
35
44
  import { createOpenRouter } from '@openrouter/ai-sdk-provider';
36
45
  import { createXai } from '@ai-sdk/xai';
37
46
  import { createMistral } from '@ai-sdk/mistral';
38
47
  import { createGroq } from '@ai-sdk/groq';
48
+ import { createAlibaba } from '@ai-sdk/alibaba';
49
+ import { createCerebras } from '@ai-sdk/cerebras';
50
+ import { createCohere } from '@ai-sdk/cohere';
51
+ import { createDeepInfra } from '@ai-sdk/deepinfra';
52
+ import { createDeepSeek } from '@ai-sdk/deepseek';
53
+ import { createFireworks } from '@ai-sdk/fireworks';
54
+ import { createGateway } from '@ai-sdk/gateway';
55
+ import { createPerplexity } from '@ai-sdk/perplexity';
56
+ import { createTogetherAI } from '@ai-sdk/togetherai';
39
57
 
40
58
  export namespace Provider {
41
59
  const log = Log.create({ service: 'provider' });
@@ -70,17 +88,24 @@ export namespace Provider {
70
88
  '@ai-sdk/xai': createXai,
71
89
  '@ai-sdk/mistral': createMistral,
72
90
  '@ai-sdk/groq': createGroq,
91
+ // Packages models.dev (and OpenCode) use for the remaining providers and
92
+ // per-model overrides (#334).
93
+ '@ai-sdk/amazon-bedrock/mantle': createBedrockMantle,
94
+ '@ai-sdk/google-vertex/anthropic': createVertexAnthropic,
95
+ '@ai-sdk/alibaba': createAlibaba,
96
+ '@ai-sdk/cerebras': createCerebras,
97
+ '@ai-sdk/cohere': createCohere,
98
+ '@ai-sdk/deepinfra': createDeepInfra,
99
+ '@ai-sdk/deepseek': createDeepSeek,
100
+ '@ai-sdk/fireworks': createFireworks,
101
+ '@ai-sdk/gateway': createGateway,
102
+ '@ai-sdk/perplexity': createPerplexity,
103
+ '@ai-sdk/togetherai': createTogetherAI,
73
104
  };
74
105
 
75
- type CustomLoader = (provider: ModelsDev.Provider) => Promise<{
76
- autoload: boolean;
77
- getModel?: (
78
- sdk: any,
79
- modelID: string,
80
- options?: Record<string, any>
81
- ) => Promise<any>;
82
- options?: Record<string, any>;
83
- }>;
106
+ type CustomLoader = (
107
+ provider: ModelsDev.Provider
108
+ ) => Promise<ParityLoaders.LoaderResult>;
84
109
 
85
110
  type Source = 'env' | 'config' | 'custom' | 'api';
86
111
 
@@ -224,8 +249,14 @@ export namespace Provider {
224
249
  };
225
250
  },
226
251
  azure: async () => {
252
+ // `agent auth login azure` with Microsoft Entra ID stores no secret:
253
+ // requests mint bearer tokens from the Azure CLI session (#334).
254
+ const entra = await AzureLogin.options(await Auth.get('azure'));
255
+ if (entra) {
256
+ log.info(() => ({ message: 'using azure entra id credentials' }));
257
+ }
227
258
  return {
228
- autoload: false,
259
+ autoload: !!entra,
229
260
  async getModel(
230
261
  sdk: any,
231
262
  modelID: string,
@@ -237,7 +268,7 @@ export namespace Provider {
237
268
  return sdk.responses(modelID);
238
269
  }
239
270
  },
240
- options: {},
271
+ options: entra ?? {},
241
272
  };
242
273
  },
243
274
  'azure-cognitive-services': async () => {
@@ -263,103 +294,6 @@ export namespace Provider {
263
294
  },
264
295
  };
265
296
  },
266
- 'amazon-bedrock': async () => {
267
- if (
268
- !process.env['AWS_PROFILE'] &&
269
- !process.env['AWS_ACCESS_KEY_ID'] &&
270
- !process.env['AWS_BEARER_TOKEN_BEDROCK']
271
- )
272
- return { autoload: false };
273
-
274
- const region = process.env['AWS_REGION'] ?? 'us-east-1';
275
-
276
- const { fromNodeProviderChain } = await import(
277
- await BunProc.install('@aws-sdk/credential-providers')
278
- );
279
- return {
280
- autoload: true,
281
- options: {
282
- region,
283
- credentialProvider: fromNodeProviderChain(),
284
- },
285
- async getModel(
286
- sdk: any,
287
- modelID: string,
288
- _options?: Record<string, any>
289
- ) {
290
- let regionPrefix = region.split('-')[0];
291
-
292
- switch (regionPrefix) {
293
- case 'us': {
294
- const modelRequiresPrefix = [
295
- 'nova-micro',
296
- 'nova-lite',
297
- 'nova-pro',
298
- 'nova-premier',
299
- 'claude',
300
- 'deepseek',
301
- ].some((m) => modelID.includes(m));
302
- const isGovCloud = region.startsWith('us-gov');
303
- if (modelRequiresPrefix && !isGovCloud) {
304
- modelID = `${regionPrefix}.${modelID}`;
305
- }
306
- break;
307
- }
308
- case 'eu': {
309
- const regionRequiresPrefix = [
310
- 'eu-west-1',
311
- 'eu-west-2',
312
- 'eu-west-3',
313
- 'eu-north-1',
314
- 'eu-central-1',
315
- 'eu-south-1',
316
- 'eu-south-2',
317
- ].some((r) => region.includes(r));
318
- const modelRequiresPrefix = [
319
- 'claude',
320
- 'nova-lite',
321
- 'nova-micro',
322
- 'llama3',
323
- 'pixtral',
324
- ].some((m) => modelID.includes(m));
325
- if (regionRequiresPrefix && modelRequiresPrefix) {
326
- modelID = `${regionPrefix}.${modelID}`;
327
- }
328
- break;
329
- }
330
- case 'ap': {
331
- const isAustraliaRegion = [
332
- 'ap-southeast-2',
333
- 'ap-southeast-4',
334
- ].includes(region);
335
- if (
336
- isAustraliaRegion &&
337
- ['anthropic.claude-sonnet-4-5', 'anthropic.claude-haiku'].some(
338
- (m) => modelID.includes(m)
339
- )
340
- ) {
341
- regionPrefix = 'au';
342
- modelID = `${regionPrefix}.${modelID}`;
343
- } else {
344
- const modelRequiresPrefix = [
345
- 'claude',
346
- 'nova-lite',
347
- 'nova-micro',
348
- 'nova-pro',
349
- ].some((m) => modelID.includes(m));
350
- if (modelRequiresPrefix) {
351
- regionPrefix = 'apac';
352
- modelID = `${regionPrefix}.${modelID}`;
353
- }
354
- }
355
- break;
356
- }
357
- }
358
-
359
- return sdk.languageModel(modelID);
360
- },
361
- };
362
- },
363
297
  openrouter: async () => {
364
298
  return {
365
299
  autoload: false,
@@ -382,52 +316,6 @@ export namespace Provider {
382
316
  },
383
317
  };
384
318
  },
385
- 'google-vertex': async () => {
386
- const project =
387
- process.env['GOOGLE_CLOUD_PROJECT'] ??
388
- process.env['GCP_PROJECT'] ??
389
- process.env['GCLOUD_PROJECT'];
390
- const location =
391
- process.env['GOOGLE_CLOUD_LOCATION'] ??
392
- process.env['VERTEX_LOCATION'] ??
393
- 'us-east5';
394
- const autoload = Boolean(project);
395
- if (!autoload) return { autoload: false };
396
- return {
397
- autoload: true,
398
- options: {
399
- project,
400
- location,
401
- },
402
- async getModel(sdk: any, modelID: string) {
403
- const id = String(modelID).trim();
404
- return sdk.languageModel(id);
405
- },
406
- };
407
- },
408
- 'google-vertex-anthropic': async () => {
409
- const project =
410
- process.env['GOOGLE_CLOUD_PROJECT'] ??
411
- process.env['GCP_PROJECT'] ??
412
- process.env['GCLOUD_PROJECT'];
413
- const location =
414
- process.env['GOOGLE_CLOUD_LOCATION'] ??
415
- process.env['VERTEX_LOCATION'] ??
416
- 'global';
417
- const autoload = Boolean(project);
418
- if (!autoload) return { autoload: false };
419
- return {
420
- autoload: true,
421
- options: {
422
- project,
423
- location,
424
- },
425
- async getModel(sdk: any, modelID: string) {
426
- const id = String(modelID).trim();
427
- return sdk.languageModel(id);
428
- },
429
- };
430
- },
431
319
  zenmux: async () => {
432
320
  return {
433
321
  autoload: false,
@@ -764,6 +652,9 @@ export namespace Provider {
764
652
  options: {},
765
653
  };
766
654
  },
655
+ // OpenCode parity: Cloudflare, Snowflake, SAP AI Core, Vertex, Bedrock,
656
+ // attribution headers (#334).
657
+ ...ParityLoaders.create(),
767
658
  };
768
659
 
769
660
  export const state = Instance.state(async () => {
@@ -780,6 +671,7 @@ export namespace Provider {
780
671
  modelID: string,
781
672
  options?: Record<string, any>
782
673
  ) => Promise<any>;
674
+ sdkOptions?: ParityLoaders.LoaderResult['sdkOptions'];
783
675
  options: Record<string, any>;
784
676
  };
785
677
  } = {};
@@ -807,24 +699,29 @@ export namespace Provider {
807
699
  sdk: any,
808
700
  modelID: string,
809
701
  options?: Record<string, any>
810
- ) => Promise<any>
702
+ ) => Promise<any>,
703
+ sdkOptions?: ParityLoaders.LoaderResult['sdkOptions']
811
704
  ) {
812
705
  const provider = providers[id];
813
706
  if (!provider) {
814
707
  const info = database[id];
815
708
  if (!info) return;
816
- if (info.api && !options['baseURL']) options['baseURL'] = info.api;
709
+ // models.dev base URLs may contain `${ENV}` placeholders (#334).
710
+ const baseURL = EnvTemplate.resolve(info.api);
711
+ if (baseURL && !options['baseURL']) options['baseURL'] = baseURL;
817
712
  providers[id] = {
818
713
  source,
819
714
  info,
820
715
  options,
821
716
  getModel,
717
+ sdkOptions,
822
718
  };
823
719
  return;
824
720
  }
825
721
  provider.options = mergeDeep(provider.options, options);
826
722
  provider.source = source;
827
723
  provider.getModel = getModel ?? provider.getModel;
724
+ provider.sdkOptions = sdkOptions ?? provider.sdkOptions;
828
725
  }
829
726
 
830
727
  const configProviders = Object.entries(config.provider ?? {});
@@ -939,15 +836,30 @@ export namespace Provider {
939
836
  }
940
837
  }
941
838
 
839
+ // Kilo keeps its curated free aliases and gains the rest of its
840
+ // models.dev catalogue (text models only), served through the same
841
+ // OpenRouter-compatible endpoint (#334).
942
842
  database.kilo = {
943
843
  id: 'kilo',
944
844
  name: 'Kilo Gateway',
945
845
  npm: '@openrouter/ai-sdk-provider',
946
846
  api: 'https://api.kilo.ai/api/openrouter',
947
847
  env: ['KILO_API_KEY'],
948
- models: structuredClone(Kilo.FREE_MODELS),
848
+ models: {
849
+ ...Kilo.catalogueModels(database.kilo),
850
+ ...structuredClone(Kilo.FREE_MODELS),
851
+ },
949
852
  };
950
853
 
854
+ // Providers models.dev does not list (gpt4free, Pollinations, local
855
+ // servers) and packages the bundled AI SDK cannot load (#334).
856
+ ExtraProviders.augment(database);
857
+ ParityLoaders.patchCloudflareGateway(
858
+ database,
859
+ process.env,
860
+ config.provider?.['cloudflare-ai-gateway']?.options?.['baseURL']
861
+ );
862
+
951
863
  for (const [providerID, provider] of configProviders) {
952
864
  const existing = database[providerID];
953
865
  const parsed: ModelsDev.Provider = {
@@ -1030,8 +942,9 @@ export namespace Provider {
1030
942
  for (const [providerID, provider] of Object.entries(database)) {
1031
943
  if (disabled.has(providerID)) continue;
1032
944
  // Find the first truthy env var (supports multiple env var options like Google's
1033
- // GOOGLE_GENERATIVE_AI_API_KEY and GEMINI_API_KEY)
1034
- const apiKey = provider.env
945
+ // GOOGLE_GENERATIVE_AI_API_KEY and GEMINI_API_KEY). Variables that only
946
+ // fill a templated URL or name an account/region are not keys (#334).
947
+ const apiKey = EnvTemplate.credentialEnv(provider)
1035
948
  .map((item) => process.env[item])
1036
949
  .find(Boolean);
1037
950
  if (!apiKey) continue;
@@ -1044,7 +957,31 @@ export namespace Provider {
1044
957
  if (disabled.has(providerID)) continue;
1045
958
  if (provider.type === 'api') {
1046
959
  mergeProvider(providerID, { apiKey: provider.key }, 'api');
960
+ continue;
1047
961
  }
962
+ // Subscription logins without a dedicated custom loader (xAI,
963
+ // DigitalOcean, GitLab) resolve to a key or fetch through their plugin (#334).
964
+ if (provider.type !== 'oauth' || CUSTOM_LOADERS[providerID]) continue;
965
+ if (!database[providerID]) continue;
966
+ const loaderFn = await AuthPlugins.getLoader(providerID);
967
+ if (!loaderFn) continue;
968
+ const result = await loaderFn(
969
+ () => Auth.get(providerID),
970
+ database[providerID]
971
+ ).catch((error) => {
972
+ log.warn(() => ({
973
+ message: 'oauth credentials could not be loaded',
974
+ providerID,
975
+ error: String(error),
976
+ }));
977
+ return undefined;
978
+ });
979
+ if (!result?.apiKey && !result?.fetch) continue;
980
+ const options: Record<string, any> = { ...result.options };
981
+ if (result.apiKey) options['apiKey'] = result.apiKey;
982
+ if (result.baseURL) options['baseURL'] = result.baseURL;
983
+ if (result.fetch) options['fetch'] = result.fetch;
984
+ mergeProvider(providerID, options, 'api');
1048
985
  }
1049
986
 
1050
987
  // load custom
@@ -1056,7 +993,8 @@ export namespace Provider {
1056
993
  providerID,
1057
994
  result.options ?? {},
1058
995
  'custom',
1059
- result.getModel
996
+ result.getModel,
997
+ result.sdkOptions
1060
998
  );
1061
999
  }
1062
1000
  }
@@ -1095,11 +1033,40 @@ export namespace Provider {
1095
1033
  available: Object.keys(providers),
1096
1034
  }));
1097
1035
 
1036
+ /**
1037
+ * Enable a keyless provider (gpt4free, Pollinations, local servers) on
1038
+ * first explicit use. They stay out of `providers` until then, so they
1039
+ * never take part in default or short-name resolution (#334).
1040
+ */
1041
+ async function activate(providerID: string): Promise<boolean> {
1042
+ if (providers[providerID]) return true;
1043
+ if (disabled.has(providerID)) return false;
1044
+ if (!database[providerID] || !ExtraProviders.isKeyless(providerID)) {
1045
+ return false;
1046
+ }
1047
+ const loader = CUSTOM_LOADERS[providerID];
1048
+ const result = loader ? await loader(database[providerID]) : undefined;
1049
+ mergeProvider(
1050
+ providerID,
1051
+ result?.options ?? {},
1052
+ 'custom',
1053
+ result?.getModel,
1054
+ result?.sdkOptions
1055
+ );
1056
+ registryLog.info(() => ({
1057
+ message: 'keyless provider enabled on explicit request',
1058
+ provider: providerID,
1059
+ }));
1060
+ return Boolean(providers[providerID]);
1061
+ }
1062
+
1098
1063
  return {
1099
1064
  models,
1100
1065
  providers,
1101
1066
  sdk,
1102
1067
  realIdByKey,
1068
+ database,
1069
+ activate,
1103
1070
  };
1104
1071
  });
1105
1072
 
@@ -1114,7 +1081,38 @@ export namespace Provider {
1114
1081
  });
1115
1082
  const s = await state();
1116
1083
  const pkg = model.provider?.npm ?? provider.npm ?? provider.id;
1117
- const options = { ...s.providers[provider.id]?.options };
1084
+ const entry = s.providers[provider.id];
1085
+ let options: Record<string, any> = { ...entry?.options };
1086
+ // Per-model endpoint from models.dev (Vertex partner models, Bedrock
1087
+ // Mantle, Cloudflare passthrough routes). A user-set baseURL still wins
1088
+ // for models served by the provider's own package (#334).
1089
+ const providerURL = EnvTemplate.resolve(provider.api);
1090
+ const modelURL = EnvTemplate.resolve(model.provider?.api);
1091
+ const ownPackage = !model.provider?.npm || pkg === provider.npm;
1092
+ if (
1093
+ modelURL &&
1094
+ /^https?:\/\//.test(modelURL) &&
1095
+ (!ownPackage ||
1096
+ !options['baseURL'] ||
1097
+ options['baseURL'] === providerURL)
1098
+ ) {
1099
+ options['baseURL'] = modelURL;
1100
+ }
1101
+ const template = model.provider?.api ?? provider.api;
1102
+ if (
1103
+ !options['baseURL'] &&
1104
+ EnvTemplate.isTemplate(template) &&
1105
+ !EnvTemplate.resolve(template)
1106
+ ) {
1107
+ const missing = EnvTemplate.variables(template).filter(
1108
+ (name) => !EnvTemplate.resolve(`\${${name}}`)
1109
+ );
1110
+ throw new Error(
1111
+ `Provider "${provider.id}" needs ${missing.join(', ')} to build its base URL (${template}). ` +
1112
+ `Set the environment variable(s) or provider.${provider.id}.options.baseURL in the config.`
1113
+ );
1114
+ }
1115
+ if (entry?.sdkOptions) options = entry.sdkOptions(pkg, options);
1118
1116
  if (
1119
1117
  pkg.includes('@ai-sdk/openai-compatible') &&
1120
1118
  options['includeUsage'] === undefined
@@ -1203,19 +1201,23 @@ export namespace Provider {
1203
1201
  const [sdkStream, usageStream] = response.body.tee();
1204
1202
  const sseReqId = SSEUsageExtractor.nextRequestId();
1205
1203
  (async () => {
1204
+ const reader = usageStream.getReader();
1206
1205
  try {
1207
- const reader = usageStream.getReader();
1206
+ // Read the whole branch: a tee branch that stops being read
1207
+ // keeps buffering the rest of the response (issue #335).
1208
1208
  const decoder = new TextDecoder();
1209
- let body = '';
1209
+ const tracker = SSEUsageExtractor.createTracker();
1210
1210
  while (true) {
1211
1211
  const { done, value } = await reader.read();
1212
1212
  if (done) break;
1213
- body += decoder.decode(value, { stream: true });
1214
- if (body.length > 50000) break;
1213
+ tracker.push(decoder.decode(value, { stream: true }));
1215
1214
  }
1216
- SSEUsageExtractor.processStreamForUsage(sseReqId, body);
1215
+ tracker.push(decoder.decode());
1216
+ const usage = tracker.finish();
1217
+ if (usage) SSEUsageExtractor.storeUsage(sseReqId, usage);
1217
1218
  } catch {
1218
- // Never break the SDK stream
1219
+ // Never break the SDK stream; release the branch instead.
1220
+ reader.cancel().catch(() => {});
1219
1221
  }
1220
1222
  })();
1221
1223
  return new Response(sdkStream, {
@@ -1241,12 +1243,14 @@ export namespace Provider {
1241
1243
  });
1242
1244
  }
1243
1245
 
1244
- const url =
1246
+ // Logged only; query-string credentials are masked (issue #335).
1247
+ const url = sanitizeUrl(
1245
1248
  typeof input === 'string'
1246
1249
  ? input
1247
1250
  : input instanceof URL
1248
1251
  ? input.toString()
1249
- : input.url;
1252
+ : input.url
1253
+ );
1250
1254
  const method = init?.method ?? 'GET';
1251
1255
 
1252
1256
  // Wrap all verbose logging in try/catch so it never breaks the actual HTTP request.
@@ -1373,13 +1377,15 @@ export namespace Provider {
1373
1377
  try {
1374
1378
  const reader = logStream.getReader();
1375
1379
  const decoder = new TextDecoder();
1380
+ const tracker = SSEUsageExtractor.createTracker();
1376
1381
  let bodyPreview = '';
1377
1382
  let truncated = false;
1378
1383
  while (true) {
1379
1384
  const { done, value } = await reader.read();
1380
1385
  if (done) break;
1386
+ const chunk = decoder.decode(value, { stream: true });
1387
+ tracker.push(chunk);
1381
1388
  if (!truncated) {
1382
- const chunk = decoder.decode(value, { stream: true });
1383
1389
  bodyPreview += chunk;
1384
1390
  if (bodyPreview.length > responseBodyMaxChars) {
1385
1391
  bodyPreview = bodyPreview.slice(
@@ -1390,11 +1396,12 @@ export namespace Provider {
1390
1396
  }
1391
1397
  }
1392
1398
  }
1393
- // Extract usage from raw SSE stream as AI SDK fallback
1394
- SSEUsageExtractor.processStreamForUsage(
1395
- sseRequestId,
1396
- bodyPreview
1397
- );
1399
+ // Extract usage from the whole raw SSE stream (not just
1400
+ // the logged preview) as AI SDK fallback.
1401
+ tracker.push(decoder.decode());
1402
+ const usage = tracker.finish();
1403
+ if (usage)
1404
+ SSEUsageExtractor.storeUsage(sseRequestId, usage);
1398
1405
  // Use direct (non-lazy) logging for stream body
1399
1406
  // See: https://github.com/link-assistant/agent/issues/211
1400
1407
  log.info('HTTP response body (stream)', {
@@ -1517,17 +1524,9 @@ export namespace Provider {
1517
1524
  installedPath = pkg;
1518
1525
  }
1519
1526
 
1520
- // The `google-vertex-anthropic` provider points to the `@ai-sdk/google-vertex` package.
1521
- // Ref: https://github.com/sst/models.dev/blob/0a87de42ab177bebad0620a889e2eb2b4a5dd4ab/providers/google-vertex-anthropic/provider.toml
1522
- // However, the actual export is at the subpath `@ai-sdk/google-vertex/anthropic`.
1523
- // Ref: https://ai-sdk.dev/providers/ai-sdk-providers/google-vertex#google-vertex-anthropic-provider-usage
1524
- // In addition, Bun's dynamic import logic does not support subpath imports,
1525
- // so we patch the import path to load directly from `dist`.
1526
- const modPath =
1527
- provider.id === 'google-vertex-anthropic'
1528
- ? `${installedPath}/dist/anthropic/index.mjs`
1529
- : installedPath;
1530
- const mod = await import(modPath);
1527
+ // `@ai-sdk/google-vertex/anthropic` and other subpath packages are
1528
+ // bundled above, so no dist path patching is needed here (#334).
1529
+ const mod = await import(installedPath);
1531
1530
 
1532
1531
  const fn = mod[Object.keys(mod).find((key) => key.startsWith('create'))!];
1533
1532
  const loaded = fn({
@@ -1550,27 +1549,98 @@ export namespace Provider {
1550
1549
  }
1551
1550
 
1552
1551
  export async function getProvider(providerID: string) {
1553
- return state().then((s) => s.providers[providerID]);
1552
+ const s = await state();
1553
+ const id = ExtraProviders.canonicalID(providerID);
1554
+ if (!s.providers[id]) await s.activate(id);
1555
+ return s.providers[id];
1554
1556
  }
1555
1557
 
1556
1558
  /**
1557
1559
  * Whether a provider's catalog lists a model. Looks at that one provider
1558
- * only and logs nothing (#313).
1560
+ * only and logs nothing (#313). Routers and local servers that accept any
1561
+ * model ID count as listing every model (#334).
1559
1562
  */
1560
1563
  export async function hasModel(providerID: string, modelID: string) {
1564
+ const s = await state();
1565
+ const provider = s.providers[ExtraProviders.canonicalID(providerID)];
1566
+ if (!provider) return false;
1567
+ if (provider.info.models[modelID]) return true;
1568
+ // Deprecated Kilo aliases still resolve to their replacement (#335).
1569
+ const replacement =
1570
+ provider.info.id === 'kilo'
1571
+ ? Kilo.deprecatedAliasTarget(modelID)
1572
+ : undefined;
1573
+ if (replacement && provider.info.models[replacement]) return true;
1574
+ return ExtraProviders.allowsUnknownModels(provider.info.id);
1575
+ }
1576
+
1577
+ /**
1578
+ * List the models an active provider serves right now, using its
1579
+ * token-free listing endpoint (`/models`). `force` bypasses the cache.
1580
+ */
1581
+ export async function discoverModels(
1582
+ providerID: string,
1583
+ input: { force?: boolean } = {}
1584
+ ): Promise<ModelDiscovery.DiscoveryResult> {
1561
1585
  const provider = await getProvider(providerID);
1562
- return Boolean(provider?.info.models[modelID]);
1586
+ if (!provider) {
1587
+ return { status: 'skipped', models: [], error: 'provider not active' };
1588
+ }
1589
+ const options = provider.options ?? {};
1590
+ return ModelDiscovery.discover({
1591
+ providerID: provider.info.id,
1592
+ npm: provider.info.npm,
1593
+ baseURL: options['baseURL'] ?? EnvTemplate.resolve(provider.info.api),
1594
+ apiKey:
1595
+ typeof options['apiKey'] === 'string' ? options['apiKey'] : undefined,
1596
+ headers: options['headers'],
1597
+ resourceName:
1598
+ options['resourceName'] ??
1599
+ (provider.info.id === 'azure'
1600
+ ? process.env['AZURE_RESOURCE_NAME']
1601
+ : undefined),
1602
+ // Providers that sign requests themselves (OAuth) list models the same way.
1603
+ fetchImpl:
1604
+ typeof options['fetch'] === 'function' ? options['fetch'] : undefined,
1605
+ force: input.force,
1606
+ });
1607
+ }
1608
+
1609
+ /**
1610
+ * Look a model up in the provider's own model listing. OpenCode Zen keeps
1611
+ * its dedicated free-model lookup; every other provider with a token-free
1612
+ * listing endpoint goes through `ModelDiscovery` (#334).
1613
+ */
1614
+ async function getLiveModelInfo(
1615
+ providerID: string,
1616
+ modelID: string
1617
+ ): Promise<ModelsDev.Model | undefined> {
1618
+ if (providerID === 'opencode') {
1619
+ return OpenCodeZen.getLiveFreeModelInfo(modelID);
1620
+ }
1621
+ const s = await state();
1622
+ const result = await discoverModels(providerID);
1623
+ const found = ModelDiscovery.findModel(result.models, modelID);
1624
+ if (!found) return undefined;
1625
+ return ModelDiscovery.toModelInfo(found, {
1626
+ metadata: ModelDiscovery.catalogueMetadata(
1627
+ s.database,
1628
+ found.model ?? found.id,
1629
+ providerID
1630
+ ),
1631
+ });
1563
1632
  }
1564
1633
 
1565
- async function getLiveModelInfo(providerID: string, modelID: string) {
1566
- if (providerID !== 'opencode') return undefined;
1567
- return OpenCodeZen.getLiveFreeModelInfo(modelID);
1634
+ /** Metadata for a model ID a router or local server accepts unlisted. */
1635
+ function passthroughModelInfo(modelID: string): ModelsDev.Model {
1636
+ return ModelDiscovery.toModelInfo({ id: modelID, free: true });
1568
1637
  }
1569
1638
 
1570
1639
  export async function refreshLiveModelInfo(
1571
1640
  providerID: string,
1572
1641
  modelID: string
1573
1642
  ) {
1643
+ providerID = ExtraProviders.canonicalID(providerID);
1574
1644
  const info = await getLiveModelInfo(providerID, modelID);
1575
1645
  if (!info) return undefined;
1576
1646
 
@@ -1588,12 +1658,16 @@ export namespace Provider {
1588
1658
  }
1589
1659
 
1590
1660
  export async function getModel(providerID: string, modelID: string) {
1661
+ providerID = ExtraProviders.canonicalID(providerID);
1662
+ // Sessions and configs may still name a withdrawn Kilo alias (#335).
1663
+ if (providerID === 'kilo') modelID = resolveKiloDeprecatedAlias(modelID);
1591
1664
  const key = `${providerID}/${modelID}`;
1592
1665
  const s = await state();
1593
1666
  if (s.models.has(key)) return s.models.get(key)!;
1594
1667
 
1595
1668
  log.info(() => ({ message: 'getModel', providerID, modelID }));
1596
1669
 
1670
+ if (!s.providers[providerID]) await s.activate(providerID);
1597
1671
  const provider = s.providers[providerID];
1598
1672
  if (!provider) {
1599
1673
  // Check if this model ID might exist in another provider (e.g., OpenRouter)
@@ -1680,6 +1754,22 @@ export namespace Provider {
1680
1754
  }
1681
1755
  }
1682
1756
 
1757
+ if (
1758
+ !isSyntheticProvider &&
1759
+ !info &&
1760
+ ExtraProviders.allowsUnknownModels(providerID)
1761
+ ) {
1762
+ // Routers (gpt4free, Pollinations) and local servers accept model IDs
1763
+ // that are not listed anywhere; pass the ID through as-is (#334).
1764
+ info = passthroughModelInfo(modelID);
1765
+ provider.info.models[modelID] = info;
1766
+ log.info(() => ({
1767
+ message: 'passing unlisted model through to router/local server',
1768
+ providerID,
1769
+ modelID,
1770
+ }));
1771
+ }
1772
+
1683
1773
  if (!isSyntheticProvider && !info) {
1684
1774
  // Model not found even after cache refresh.
1685
1775
  // Check if this is the default model — if so, create synthetic info and proceed (#239).
@@ -1739,9 +1829,13 @@ export namespace Provider {
1739
1829
  } else {
1740
1830
  // For regular providers, load the SDK first
1741
1831
  const sdk = await getSDK(provider.info, info!);
1742
- language = provider.getModel
1743
- ? await provider.getModel(sdk, realID, provider.options)
1744
- : sdk.languageModel(realID);
1832
+ // Models that need a specific API shape (Bedrock Mantle, models.dev
1833
+ // `provider.shape`) pick the SDK method themselves (#334).
1834
+ language =
1835
+ ParityLoaders.selectByShape(sdk, info!, realID) ??
1836
+ (provider.getModel
1837
+ ? await provider.getModel(sdk, realID, provider.options)
1838
+ : sdk.languageModel(realID));
1745
1839
  }
1746
1840
  log.info(() => ({ message: 'found', providerID, modelID }));
1747
1841
  s.models.set(key, {
@@ -1835,7 +1929,6 @@ export namespace Provider {
1835
1929
  'gpt-5-nano',
1836
1930
  'nemotron-3.5-lightning-free',
1837
1931
  'nemotron-3-ultra-free',
1838
- 'nemotron-3-super-free',
1839
1932
  'gpt-5',
1840
1933
  'claude-sonnet-4',
1841
1934
  'gemini-3-pro',
@@ -1909,10 +2002,14 @@ export namespace Provider {
1909
2002
  }
1910
2003
 
1911
2004
  export function parseModel(model: string) {
1912
- const [providerID, ...rest] = model.split('/');
2005
+ const [rawProviderID, ...rest] = model.split('/');
2006
+ const providerID = ExtraProviders.canonicalID(rawProviderID);
2007
+ const modelID = rest.join('/');
1913
2008
  return {
1914
- providerID: providerID,
1915
- modelID: rest.join('/'),
2009
+ providerID,
2010
+ // Withdrawn Kilo aliases keep working with a warning (#335).
2011
+ modelID:
2012
+ providerID === 'kilo' ? resolveKiloDeprecatedAlias(modelID) : modelID,
1916
2013
  };
1917
2014
  }
1918
2015
 
@@ -1932,6 +2029,14 @@ export namespace Provider {
1932
2029
  ): Promise<{ providerID: string; modelID: string } | undefined> {
1933
2030
  const s = await state();
1934
2031
 
2032
+ // A withdrawn built-in alias resolves to its Kilo replacement (#335).
2033
+ if (Kilo.deprecatedAliasTarget(modelID)) {
2034
+ const replacement = resolveKiloDeprecatedAlias(modelID);
2035
+ if (s.providers.kilo?.info.models[replacement]) {
2036
+ return { providerID: 'kilo', modelID: replacement };
2037
+ }
2038
+ }
2039
+
1935
2040
  if (Object.hasOwn(Kilo.FREE_MODELS, modelID)) {
1936
2041
  const kiloProvider = s.providers.kilo;
1937
2042
  if (kiloProvider?.info.models[modelID]) {
@@ -2005,8 +2110,9 @@ export namespace Provider {
2005
2110
  * or a clear error explaining why it's not available.
2006
2111
  *
2007
2112
  * Examples:
2008
- * - "kilo/nemotron-3-super-free" -> { providerID: "kilo", modelID: "nemotron-3-super-free" }
2009
- * - "nemotron-3-super-free" -> { providerID: "kilo", modelID: "nemotron-3-super-free" } (resolved)
2113
+ * - "kilo/nemotron-3-ultra-free" -> { providerID: "kilo", modelID: "nemotron-3-ultra-free" }
2114
+ * - "nemotron-3-ultra-free" -> { providerID: "kilo", modelID: "nemotron-3-ultra-free" } (resolved)
2115
+ * - "nemotron-3-super-free" -> { providerID: "kilo", modelID: "nemotron-3-ultra-free" } (deprecated alias, warns)
2010
2116
  * - "big-pickle" -> { providerID: "opencode", modelID: "big-pickle" } (resolved)
2011
2117
  * - "nonexistent-model" -> throws ModelNotFoundError
2012
2118
  *
@@ -2067,7 +2173,7 @@ export namespace Provider {
2067
2173
  * When one provider hits rate limits, the system can try an alternative.
2068
2174
  *
2069
2175
  * Note: This is only used for models without explicit provider specification.
2070
- * If user specifies "kilo/nemotron-3-super-free", no fallback will occur.
2176
+ * If user specifies "kilo/nemotron-3-ultra-free", no fallback will occur.
2071
2177
  */
2072
2178
  const SHARED_FREE_MODELS: Record<string, string[]> = {
2073
2179
  'nemotron-3.5-lightning-free': ['kilo', 'opencode'],
@@ -2078,7 +2184,7 @@ export namespace Provider {
2078
2184
  * This function returns a list of alternative providers that offer the same model.
2079
2185
  *
2080
2186
  * Note: This only returns alternatives for models without explicit provider specification.
2081
- * If the original request had an explicit provider (like "kilo/nemotron-3-super-free"), this returns empty array.
2187
+ * If the original request had an explicit provider (like "kilo/nemotron-3-ultra-free"), this returns empty array.
2082
2188
  *
2083
2189
  * @param modelID - The model ID to find alternatives for
2084
2190
  * @param failedProviderID - The provider that failed