@link-assistant/agent 0.26.15 → 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.
@@ -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';
@@ -21,21 +22,36 @@ import { RetryFetch } from './retry-fetch';
21
22
  import { SSEUsageExtractor } from '../util/sse-usage-extractor';
22
23
  import { OpenCodeZen } from './opencode-zen';
23
24
  import { Kilo } from './kilo';
25
+ import { EnvTemplate } from './env-template';
26
+ import { ExtraProviders } from './extra-providers';
27
+ import { ModelDiscovery } from './model-discovery';
28
+ import { ParityLoaders } from './parity-loaders';
24
29
 
25
30
  // Direct imports for bundled providers - these are pre-installed to avoid runtime installation hangs
26
31
  // @see https://github.com/link-assistant/agent/issues/173
27
32
  // @see https://github.com/oven-sh/bun/issues/5831 (bun install hangs sporadically)
28
33
  import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
34
+ import { createBedrockMantle } from '@ai-sdk/amazon-bedrock/mantle';
29
35
  import { createAnthropic } from '@ai-sdk/anthropic';
30
36
  import { createAzure } from '@ai-sdk/azure';
31
37
  import { createGoogleGenerativeAI } from '@ai-sdk/google';
32
38
  import { createVertex } from '@ai-sdk/google-vertex';
39
+ import { createVertexAnthropic } from '@ai-sdk/google-vertex/anthropic';
33
40
  import { createOpenAI } from '@ai-sdk/openai';
34
41
  import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
35
42
  import { createOpenRouter } from '@openrouter/ai-sdk-provider';
36
43
  import { createXai } from '@ai-sdk/xai';
37
44
  import { createMistral } from '@ai-sdk/mistral';
38
45
  import { createGroq } from '@ai-sdk/groq';
46
+ import { createAlibaba } from '@ai-sdk/alibaba';
47
+ import { createCerebras } from '@ai-sdk/cerebras';
48
+ import { createCohere } from '@ai-sdk/cohere';
49
+ import { createDeepInfra } from '@ai-sdk/deepinfra';
50
+ import { createDeepSeek } from '@ai-sdk/deepseek';
51
+ import { createFireworks } from '@ai-sdk/fireworks';
52
+ import { createGateway } from '@ai-sdk/gateway';
53
+ import { createPerplexity } from '@ai-sdk/perplexity';
54
+ import { createTogetherAI } from '@ai-sdk/togetherai';
39
55
 
40
56
  export namespace Provider {
41
57
  const log = Log.create({ service: 'provider' });
@@ -70,17 +86,24 @@ export namespace Provider {
70
86
  '@ai-sdk/xai': createXai,
71
87
  '@ai-sdk/mistral': createMistral,
72
88
  '@ai-sdk/groq': createGroq,
89
+ // Packages models.dev (and OpenCode) use for the remaining providers and
90
+ // per-model overrides (#334).
91
+ '@ai-sdk/amazon-bedrock/mantle': createBedrockMantle,
92
+ '@ai-sdk/google-vertex/anthropic': createVertexAnthropic,
93
+ '@ai-sdk/alibaba': createAlibaba,
94
+ '@ai-sdk/cerebras': createCerebras,
95
+ '@ai-sdk/cohere': createCohere,
96
+ '@ai-sdk/deepinfra': createDeepInfra,
97
+ '@ai-sdk/deepseek': createDeepSeek,
98
+ '@ai-sdk/fireworks': createFireworks,
99
+ '@ai-sdk/gateway': createGateway,
100
+ '@ai-sdk/perplexity': createPerplexity,
101
+ '@ai-sdk/togetherai': createTogetherAI,
73
102
  };
74
103
 
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
- }>;
104
+ type CustomLoader = (
105
+ provider: ModelsDev.Provider
106
+ ) => Promise<ParityLoaders.LoaderResult>;
84
107
 
85
108
  type Source = 'env' | 'config' | 'custom' | 'api';
86
109
 
@@ -224,8 +247,14 @@ export namespace Provider {
224
247
  };
225
248
  },
226
249
  azure: async () => {
250
+ // `agent auth login azure` with Microsoft Entra ID stores no secret:
251
+ // requests mint bearer tokens from the Azure CLI session (#334).
252
+ const entra = await AzureLogin.options(await Auth.get('azure'));
253
+ if (entra) {
254
+ log.info(() => ({ message: 'using azure entra id credentials' }));
255
+ }
227
256
  return {
228
- autoload: false,
257
+ autoload: !!entra,
229
258
  async getModel(
230
259
  sdk: any,
231
260
  modelID: string,
@@ -237,7 +266,7 @@ export namespace Provider {
237
266
  return sdk.responses(modelID);
238
267
  }
239
268
  },
240
- options: {},
269
+ options: entra ?? {},
241
270
  };
242
271
  },
243
272
  'azure-cognitive-services': async () => {
@@ -263,103 +292,6 @@ export namespace Provider {
263
292
  },
264
293
  };
265
294
  },
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
295
  openrouter: async () => {
364
296
  return {
365
297
  autoload: false,
@@ -382,52 +314,6 @@ export namespace Provider {
382
314
  },
383
315
  };
384
316
  },
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
317
  zenmux: async () => {
432
318
  return {
433
319
  autoload: false,
@@ -764,6 +650,9 @@ export namespace Provider {
764
650
  options: {},
765
651
  };
766
652
  },
653
+ // OpenCode parity: Cloudflare, Snowflake, SAP AI Core, Vertex, Bedrock,
654
+ // attribution headers (#334).
655
+ ...ParityLoaders.create(),
767
656
  };
768
657
 
769
658
  export const state = Instance.state(async () => {
@@ -780,6 +669,7 @@ export namespace Provider {
780
669
  modelID: string,
781
670
  options?: Record<string, any>
782
671
  ) => Promise<any>;
672
+ sdkOptions?: ParityLoaders.LoaderResult['sdkOptions'];
783
673
  options: Record<string, any>;
784
674
  };
785
675
  } = {};
@@ -807,24 +697,29 @@ export namespace Provider {
807
697
  sdk: any,
808
698
  modelID: string,
809
699
  options?: Record<string, any>
810
- ) => Promise<any>
700
+ ) => Promise<any>,
701
+ sdkOptions?: ParityLoaders.LoaderResult['sdkOptions']
811
702
  ) {
812
703
  const provider = providers[id];
813
704
  if (!provider) {
814
705
  const info = database[id];
815
706
  if (!info) return;
816
- if (info.api && !options['baseURL']) options['baseURL'] = info.api;
707
+ // models.dev base URLs may contain `${ENV}` placeholders (#334).
708
+ const baseURL = EnvTemplate.resolve(info.api);
709
+ if (baseURL && !options['baseURL']) options['baseURL'] = baseURL;
817
710
  providers[id] = {
818
711
  source,
819
712
  info,
820
713
  options,
821
714
  getModel,
715
+ sdkOptions,
822
716
  };
823
717
  return;
824
718
  }
825
719
  provider.options = mergeDeep(provider.options, options);
826
720
  provider.source = source;
827
721
  provider.getModel = getModel ?? provider.getModel;
722
+ provider.sdkOptions = sdkOptions ?? provider.sdkOptions;
828
723
  }
829
724
 
830
725
  const configProviders = Object.entries(config.provider ?? {});
@@ -939,15 +834,30 @@ export namespace Provider {
939
834
  }
940
835
  }
941
836
 
837
+ // Kilo keeps its curated free aliases and gains the rest of its
838
+ // models.dev catalogue (text models only), served through the same
839
+ // OpenRouter-compatible endpoint (#334).
942
840
  database.kilo = {
943
841
  id: 'kilo',
944
842
  name: 'Kilo Gateway',
945
843
  npm: '@openrouter/ai-sdk-provider',
946
844
  api: 'https://api.kilo.ai/api/openrouter',
947
845
  env: ['KILO_API_KEY'],
948
- models: structuredClone(Kilo.FREE_MODELS),
846
+ models: {
847
+ ...Kilo.catalogueModels(database.kilo),
848
+ ...structuredClone(Kilo.FREE_MODELS),
849
+ },
949
850
  };
950
851
 
852
+ // Providers models.dev does not list (gpt4free, Pollinations, local
853
+ // servers) and packages the bundled AI SDK cannot load (#334).
854
+ ExtraProviders.augment(database);
855
+ ParityLoaders.patchCloudflareGateway(
856
+ database,
857
+ process.env,
858
+ config.provider?.['cloudflare-ai-gateway']?.options?.['baseURL']
859
+ );
860
+
951
861
  for (const [providerID, provider] of configProviders) {
952
862
  const existing = database[providerID];
953
863
  const parsed: ModelsDev.Provider = {
@@ -1030,8 +940,9 @@ export namespace Provider {
1030
940
  for (const [providerID, provider] of Object.entries(database)) {
1031
941
  if (disabled.has(providerID)) continue;
1032
942
  // 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
943
+ // GOOGLE_GENERATIVE_AI_API_KEY and GEMINI_API_KEY). Variables that only
944
+ // fill a templated URL or name an account/region are not keys (#334).
945
+ const apiKey = EnvTemplate.credentialEnv(provider)
1035
946
  .map((item) => process.env[item])
1036
947
  .find(Boolean);
1037
948
  if (!apiKey) continue;
@@ -1044,7 +955,31 @@ export namespace Provider {
1044
955
  if (disabled.has(providerID)) continue;
1045
956
  if (provider.type === 'api') {
1046
957
  mergeProvider(providerID, { apiKey: provider.key }, 'api');
958
+ continue;
1047
959
  }
960
+ // Subscription logins without a dedicated custom loader (xAI,
961
+ // DigitalOcean, GitLab) resolve to a key or fetch through their plugin (#334).
962
+ if (provider.type !== 'oauth' || CUSTOM_LOADERS[providerID]) continue;
963
+ if (!database[providerID]) continue;
964
+ const loaderFn = await AuthPlugins.getLoader(providerID);
965
+ if (!loaderFn) continue;
966
+ const result = await loaderFn(
967
+ () => Auth.get(providerID),
968
+ database[providerID]
969
+ ).catch((error) => {
970
+ log.warn(() => ({
971
+ message: 'oauth credentials could not be loaded',
972
+ providerID,
973
+ error: String(error),
974
+ }));
975
+ return undefined;
976
+ });
977
+ if (!result?.apiKey && !result?.fetch) continue;
978
+ const options: Record<string, any> = { ...result.options };
979
+ if (result.apiKey) options['apiKey'] = result.apiKey;
980
+ if (result.baseURL) options['baseURL'] = result.baseURL;
981
+ if (result.fetch) options['fetch'] = result.fetch;
982
+ mergeProvider(providerID, options, 'api');
1048
983
  }
1049
984
 
1050
985
  // load custom
@@ -1056,7 +991,8 @@ export namespace Provider {
1056
991
  providerID,
1057
992
  result.options ?? {},
1058
993
  'custom',
1059
- result.getModel
994
+ result.getModel,
995
+ result.sdkOptions
1060
996
  );
1061
997
  }
1062
998
  }
@@ -1095,11 +1031,40 @@ export namespace Provider {
1095
1031
  available: Object.keys(providers),
1096
1032
  }));
1097
1033
 
1034
+ /**
1035
+ * Enable a keyless provider (gpt4free, Pollinations, local servers) on
1036
+ * first explicit use. They stay out of `providers` until then, so they
1037
+ * never take part in default or short-name resolution (#334).
1038
+ */
1039
+ async function activate(providerID: string): Promise<boolean> {
1040
+ if (providers[providerID]) return true;
1041
+ if (disabled.has(providerID)) return false;
1042
+ if (!database[providerID] || !ExtraProviders.isKeyless(providerID)) {
1043
+ return false;
1044
+ }
1045
+ const loader = CUSTOM_LOADERS[providerID];
1046
+ const result = loader ? await loader(database[providerID]) : undefined;
1047
+ mergeProvider(
1048
+ providerID,
1049
+ result?.options ?? {},
1050
+ 'custom',
1051
+ result?.getModel,
1052
+ result?.sdkOptions
1053
+ );
1054
+ registryLog.info(() => ({
1055
+ message: 'keyless provider enabled on explicit request',
1056
+ provider: providerID,
1057
+ }));
1058
+ return Boolean(providers[providerID]);
1059
+ }
1060
+
1098
1061
  return {
1099
1062
  models,
1100
1063
  providers,
1101
1064
  sdk,
1102
1065
  realIdByKey,
1066
+ database,
1067
+ activate,
1103
1068
  };
1104
1069
  });
1105
1070
 
@@ -1114,7 +1079,38 @@ export namespace Provider {
1114
1079
  });
1115
1080
  const s = await state();
1116
1081
  const pkg = model.provider?.npm ?? provider.npm ?? provider.id;
1117
- const options = { ...s.providers[provider.id]?.options };
1082
+ const entry = s.providers[provider.id];
1083
+ let options: Record<string, any> = { ...entry?.options };
1084
+ // Per-model endpoint from models.dev (Vertex partner models, Bedrock
1085
+ // Mantle, Cloudflare passthrough routes). A user-set baseURL still wins
1086
+ // for models served by the provider's own package (#334).
1087
+ const providerURL = EnvTemplate.resolve(provider.api);
1088
+ const modelURL = EnvTemplate.resolve(model.provider?.api);
1089
+ const ownPackage = !model.provider?.npm || pkg === provider.npm;
1090
+ if (
1091
+ modelURL &&
1092
+ /^https?:\/\//.test(modelURL) &&
1093
+ (!ownPackage ||
1094
+ !options['baseURL'] ||
1095
+ options['baseURL'] === providerURL)
1096
+ ) {
1097
+ options['baseURL'] = modelURL;
1098
+ }
1099
+ const template = model.provider?.api ?? provider.api;
1100
+ if (
1101
+ !options['baseURL'] &&
1102
+ EnvTemplate.isTemplate(template) &&
1103
+ !EnvTemplate.resolve(template)
1104
+ ) {
1105
+ const missing = EnvTemplate.variables(template).filter(
1106
+ (name) => !EnvTemplate.resolve(`\${${name}}`)
1107
+ );
1108
+ throw new Error(
1109
+ `Provider "${provider.id}" needs ${missing.join(', ')} to build its base URL (${template}). ` +
1110
+ `Set the environment variable(s) or provider.${provider.id}.options.baseURL in the config.`
1111
+ );
1112
+ }
1113
+ if (entry?.sdkOptions) options = entry.sdkOptions(pkg, options);
1118
1114
  if (
1119
1115
  pkg.includes('@ai-sdk/openai-compatible') &&
1120
1116
  options['includeUsage'] === undefined
@@ -1517,17 +1513,9 @@ export namespace Provider {
1517
1513
  installedPath = pkg;
1518
1514
  }
1519
1515
 
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);
1516
+ // `@ai-sdk/google-vertex/anthropic` and other subpath packages are
1517
+ // bundled above, so no dist path patching is needed here (#334).
1518
+ const mod = await import(installedPath);
1531
1519
 
1532
1520
  const fn = mod[Object.keys(mod).find((key) => key.startsWith('create'))!];
1533
1521
  const loaded = fn({
@@ -1550,27 +1538,92 @@ export namespace Provider {
1550
1538
  }
1551
1539
 
1552
1540
  export async function getProvider(providerID: string) {
1553
- return state().then((s) => s.providers[providerID]);
1541
+ const s = await state();
1542
+ const id = ExtraProviders.canonicalID(providerID);
1543
+ if (!s.providers[id]) await s.activate(id);
1544
+ return s.providers[id];
1554
1545
  }
1555
1546
 
1556
1547
  /**
1557
1548
  * Whether a provider's catalog lists a model. Looks at that one provider
1558
- * only and logs nothing (#313).
1549
+ * only and logs nothing (#313). Routers and local servers that accept any
1550
+ * model ID count as listing every model (#334).
1559
1551
  */
1560
1552
  export async function hasModel(providerID: string, modelID: string) {
1553
+ const s = await state();
1554
+ const provider = s.providers[ExtraProviders.canonicalID(providerID)];
1555
+ if (!provider) return false;
1556
+ if (provider.info.models[modelID]) return true;
1557
+ return ExtraProviders.allowsUnknownModels(provider.info.id);
1558
+ }
1559
+
1560
+ /**
1561
+ * List the models an active provider serves right now, using its
1562
+ * token-free listing endpoint (`/models`). `force` bypasses the cache.
1563
+ */
1564
+ export async function discoverModels(
1565
+ providerID: string,
1566
+ input: { force?: boolean } = {}
1567
+ ): Promise<ModelDiscovery.DiscoveryResult> {
1561
1568
  const provider = await getProvider(providerID);
1562
- return Boolean(provider?.info.models[modelID]);
1569
+ if (!provider) {
1570
+ return { status: 'skipped', models: [], error: 'provider not active' };
1571
+ }
1572
+ const options = provider.options ?? {};
1573
+ return ModelDiscovery.discover({
1574
+ providerID: provider.info.id,
1575
+ npm: provider.info.npm,
1576
+ baseURL: options['baseURL'] ?? EnvTemplate.resolve(provider.info.api),
1577
+ apiKey:
1578
+ typeof options['apiKey'] === 'string' ? options['apiKey'] : undefined,
1579
+ headers: options['headers'],
1580
+ resourceName:
1581
+ options['resourceName'] ??
1582
+ (provider.info.id === 'azure'
1583
+ ? process.env['AZURE_RESOURCE_NAME']
1584
+ : undefined),
1585
+ // Providers that sign requests themselves (OAuth) list models the same way.
1586
+ fetchImpl:
1587
+ typeof options['fetch'] === 'function' ? options['fetch'] : undefined,
1588
+ force: input.force,
1589
+ });
1590
+ }
1591
+
1592
+ /**
1593
+ * Look a model up in the provider's own model listing. OpenCode Zen keeps
1594
+ * its dedicated free-model lookup; every other provider with a token-free
1595
+ * listing endpoint goes through `ModelDiscovery` (#334).
1596
+ */
1597
+ async function getLiveModelInfo(
1598
+ providerID: string,
1599
+ modelID: string
1600
+ ): Promise<ModelsDev.Model | undefined> {
1601
+ if (providerID === 'opencode') {
1602
+ return OpenCodeZen.getLiveFreeModelInfo(modelID);
1603
+ }
1604
+ const s = await state();
1605
+ const result = await discoverModels(providerID);
1606
+ const found = ModelDiscovery.findModel(result.models, modelID);
1607
+ if (!found) return undefined;
1608
+ return ModelDiscovery.toModelInfo(found, {
1609
+ metadata: ModelDiscovery.catalogueMetadata(
1610
+ s.database,
1611
+ found.model ?? found.id,
1612
+ providerID
1613
+ ),
1614
+ });
1563
1615
  }
1564
1616
 
1565
- async function getLiveModelInfo(providerID: string, modelID: string) {
1566
- if (providerID !== 'opencode') return undefined;
1567
- return OpenCodeZen.getLiveFreeModelInfo(modelID);
1617
+ /** Metadata for a model ID a router or local server accepts unlisted. */
1618
+ function passthroughModelInfo(modelID: string): ModelsDev.Model {
1619
+ return ModelDiscovery.toModelInfo({ id: modelID, free: true });
1568
1620
  }
1569
1621
 
1570
1622
  export async function refreshLiveModelInfo(
1571
1623
  providerID: string,
1572
1624
  modelID: string
1573
1625
  ) {
1626
+ providerID = ExtraProviders.canonicalID(providerID);
1574
1627
  const info = await getLiveModelInfo(providerID, modelID);
1575
1628
  if (!info) return undefined;
1576
1629
 
@@ -1588,12 +1641,14 @@ export namespace Provider {
1588
1641
  }
1589
1642
 
1590
1643
  export async function getModel(providerID: string, modelID: string) {
1644
+ providerID = ExtraProviders.canonicalID(providerID);
1591
1645
  const key = `${providerID}/${modelID}`;
1592
1646
  const s = await state();
1593
1647
  if (s.models.has(key)) return s.models.get(key)!;
1594
1648
 
1595
1649
  log.info(() => ({ message: 'getModel', providerID, modelID }));
1596
1650
 
1651
+ if (!s.providers[providerID]) await s.activate(providerID);
1597
1652
  const provider = s.providers[providerID];
1598
1653
  if (!provider) {
1599
1654
  // Check if this model ID might exist in another provider (e.g., OpenRouter)
@@ -1680,6 +1735,22 @@ export namespace Provider {
1680
1735
  }
1681
1736
  }
1682
1737
 
1738
+ if (
1739
+ !isSyntheticProvider &&
1740
+ !info &&
1741
+ ExtraProviders.allowsUnknownModels(providerID)
1742
+ ) {
1743
+ // Routers (gpt4free, Pollinations) and local servers accept model IDs
1744
+ // that are not listed anywhere; pass the ID through as-is (#334).
1745
+ info = passthroughModelInfo(modelID);
1746
+ provider.info.models[modelID] = info;
1747
+ log.info(() => ({
1748
+ message: 'passing unlisted model through to router/local server',
1749
+ providerID,
1750
+ modelID,
1751
+ }));
1752
+ }
1753
+
1683
1754
  if (!isSyntheticProvider && !info) {
1684
1755
  // Model not found even after cache refresh.
1685
1756
  // Check if this is the default model — if so, create synthetic info and proceed (#239).
@@ -1739,9 +1810,13 @@ export namespace Provider {
1739
1810
  } else {
1740
1811
  // For regular providers, load the SDK first
1741
1812
  const sdk = await getSDK(provider.info, info!);
1742
- language = provider.getModel
1743
- ? await provider.getModel(sdk, realID, provider.options)
1744
- : sdk.languageModel(realID);
1813
+ // Models that need a specific API shape (Bedrock Mantle, models.dev
1814
+ // `provider.shape`) pick the SDK method themselves (#334).
1815
+ language =
1816
+ ParityLoaders.selectByShape(sdk, info!, realID) ??
1817
+ (provider.getModel
1818
+ ? await provider.getModel(sdk, realID, provider.options)
1819
+ : sdk.languageModel(realID));
1745
1820
  }
1746
1821
  log.info(() => ({ message: 'found', providerID, modelID }));
1747
1822
  s.models.set(key, {
@@ -1911,7 +1986,7 @@ export namespace Provider {
1911
1986
  export function parseModel(model: string) {
1912
1987
  const [providerID, ...rest] = model.split('/');
1913
1988
  return {
1914
- providerID: providerID,
1989
+ providerID: ExtraProviders.canonicalID(providerID),
1915
1990
  modelID: rest.join('/'),
1916
1991
  };
1917
1992
  }
@@ -159,6 +159,27 @@ export namespace ProviderTransform {
159
159
  return undefined;
160
160
  }
161
161
 
162
+ /**
163
+ * Catalogue providers whose chat-completions endpoint accepts an unscoped
164
+ * `prompt_cache_key`, as audited by OpenCode
165
+ * (packages/core/src/plugin/provider/prompt-cache-key.ts).
166
+ * @see https://github.com/link-assistant/agent/issues/334
167
+ */
168
+ export const PROMPT_CACHE_KEY_PROVIDERS = new Set([
169
+ 'abliteration-ai',
170
+ 'aihubmix',
171
+ 'anyapi',
172
+ 'auriko',
173
+ 'edenai',
174
+ 'jalapeno',
175
+ 'llama',
176
+ 'morph',
177
+ 'snowflake-cortex',
178
+ 'tencent-tokenhub',
179
+ 'upstage',
180
+ 'venice',
181
+ ]);
182
+
162
183
  export function options(
163
184
  providerID: string,
164
185
  modelID: string,
@@ -167,6 +188,14 @@ export namespace ProviderTransform {
167
188
  ): Record<string, any> | undefined {
168
189
  const result: Record<string, any> = {};
169
190
 
191
+ if (
192
+ npm === '@ai-sdk/openai-compatible' &&
193
+ PROMPT_CACHE_KEY_PROVIDERS.has(providerID)
194
+ ) {
195
+ // Unknown openai-compatible options are sent verbatim in the body.
196
+ result['prompt_cache_key'] = sessionID;
197
+ }
198
+
170
199
  if (providerID === 'openai') {
171
200
  result['promptCacheKey'] = sessionID;
172
201
  }