@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.
@@ -58,7 +58,16 @@ export namespace ModelsDev {
58
58
  status: z.enum(['alpha', 'beta', 'deprecated']).optional(),
59
59
  options: z.record(z.string(), z.any()),
60
60
  headers: z.record(z.string(), z.string()).optional(),
61
- provider: z.object({ npm: z.string() }).optional(),
61
+ provider: z
62
+ .object({
63
+ npm: z.string().optional(),
64
+ api: z.string().optional(),
65
+ shape: z.enum(['responses', 'completions']).optional(),
66
+ })
67
+ .optional()
68
+ .describe(
69
+ 'Per-model SDK override from models.dev: package, base URL (may contain ${ENV} placeholders) and API shape.'
70
+ ),
62
71
  })
63
72
  .meta({
64
73
  ref: 'Model',
@@ -92,6 +101,7 @@ export namespace ModelsDev {
92
101
  env: z.array(z.string()),
93
102
  id: z.string(),
94
103
  npm: z.string().optional(),
104
+ doc: z.string().optional(),
95
105
  models: z.record(z.string(), Model),
96
106
  })
97
107
  .meta({
@@ -0,0 +1,595 @@
1
+ import type { ModelsDev } from './models';
2
+ import { EnvTemplate } from './env-template';
3
+ import { BunProc } from '../bun';
4
+ import { Log } from '../util/log';
5
+
6
+ /**
7
+ * Provider loaders that bring the agent to parity with OpenCode
8
+ * (https://github.com/anomalyco/opencode, `packages/core/src/plugin/provider`)
9
+ * for providers whose models.dev entry alone is not enough: attribution
10
+ * headers, templated endpoints, cloud credential chains and gateway routing.
11
+ *
12
+ * Each loader receives the catalogue entry (which may be missing) and returns
13
+ * the same shape as the loaders in `provider.ts`.
14
+ *
15
+ * @see https://github.com/link-assistant/agent/issues/334
16
+ */
17
+ export namespace ParityLoaders {
18
+ const log = Log.create({ service: 'provider-parity' });
19
+
20
+ type Env = Record<string, string | undefined>;
21
+
22
+ export interface LoaderResult {
23
+ autoload: boolean;
24
+ getModel?: (
25
+ sdk: any,
26
+ modelID: string,
27
+ options?: Record<string, any>
28
+ ) => Promise<any>;
29
+ options?: Record<string, any>;
30
+ /**
31
+ * Adjust the SDK options for one package. Used by providers whose models
32
+ * are served by several SDK packages (Vertex: Gemini, Anthropic and
33
+ * OpenAI-compatible endpoints) that need different credentials.
34
+ */
35
+ sdkOptions?: (
36
+ pkg: string,
37
+ options: Record<string, any>
38
+ ) => Record<string, any>;
39
+ }
40
+
41
+ export type Loader = (
42
+ provider: ModelsDev.Provider | undefined
43
+ ) => Promise<LoaderResult>;
44
+
45
+ const ATTRIBUTION = {
46
+ 'HTTP-Referer': 'https://opencode.ai/',
47
+ 'X-Title': 'opencode',
48
+ };
49
+
50
+ export const NVIDIA_URL = 'https://integrate.api.nvidia.com/v1';
51
+ export const LLMGATEWAY_URL = 'https://api.llmgateway.io/v1';
52
+
53
+ function first(env: Env, names: string[]) {
54
+ for (const name of names) {
55
+ const value = env[name]?.trim();
56
+ if (value) return value;
57
+ }
58
+ return undefined;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Cloudflare
63
+ // ---------------------------------------------------------------------------
64
+
65
+ export const CLOUDFLARE_WORKERS_TOKEN_ENV = [
66
+ 'CLOUDFLARE_API_KEY',
67
+ 'CLOUDFLARE_WORKERS_AI_TOKEN',
68
+ 'CLOUDFLARE_API_TOKEN',
69
+ ];
70
+ export const CLOUDFLARE_GATEWAY_TOKEN_ENV = [
71
+ 'CLOUDFLARE_API_TOKEN',
72
+ 'CF_AIG_TOKEN',
73
+ ];
74
+ const CLOUDFLARE_GATEWAY_HOST = 'gateway.ai.cloudflare.com';
75
+
76
+ export function cloudflareChatURL(accountID: string) {
77
+ return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountID)}/ai/v1`;
78
+ }
79
+
80
+ function cloudflareGatewayID(env: Env) {
81
+ return env['CLOUDFLARE_GATEWAY_ID']?.trim() || 'default';
82
+ }
83
+
84
+ function cloudflarePassthroughURL(
85
+ accountID: string,
86
+ gatewayID: string,
87
+ route: 'openai' | 'anthropic/v1'
88
+ ) {
89
+ return `https://${CLOUDFLARE_GATEWAY_HOST}/v1/${encodeURIComponent(accountID)}/${encodeURIComponent(gatewayID)}/${route}`;
90
+ }
91
+
92
+ /**
93
+ * Route Cloudflare AI Gateway models the way OpenCode does: `openai/*` to the
94
+ * Responses API and `anthropic/*` to the Messages API through the gateway
95
+ * passthrough (unified billing), everything else to the Workers AI chat
96
+ * endpoint. models.dev ships the gateway as `ai-gateway-provider`, which
97
+ * does not load with the bundled AI SDK, so every model gets a bundled
98
+ * package instead. Mutates `database`.
99
+ */
100
+ export function patchCloudflareGateway(
101
+ database: Record<string, ModelsDev.Provider>,
102
+ env: Env = process.env,
103
+ customBaseURL?: string
104
+ ) {
105
+ const provider = database['cloudflare-ai-gateway'];
106
+ if (!provider) return;
107
+ const accountID = env['CLOUDFLARE_ACCOUNT_ID']?.trim();
108
+ const gatewayID = cloudflareGatewayID(env);
109
+ provider.npm = '@ai-sdk/openai-compatible';
110
+ if (customBaseURL) {
111
+ provider.api = customBaseURL;
112
+ for (const model of Object.values(provider.models)) {
113
+ model.provider = undefined;
114
+ }
115
+ return;
116
+ }
117
+ provider.api = accountID ? cloudflareChatURL(accountID) : undefined;
118
+ for (const [key, model] of Object.entries(provider.models)) {
119
+ if (key.startsWith('openai/') && accountID) {
120
+ model.id = key.slice('openai/'.length);
121
+ model.provider = {
122
+ npm: '@ai-sdk/openai',
123
+ api: cloudflarePassthroughURL(accountID, gatewayID, 'openai'),
124
+ shape: 'responses',
125
+ };
126
+ continue;
127
+ }
128
+ if (key.startsWith('anthropic/') && accountID) {
129
+ model.id = key.slice('anthropic/'.length).replaceAll('.', '-');
130
+ model.provider = {
131
+ npm: '@ai-sdk/anthropic',
132
+ api: cloudflarePassthroughURL(accountID, gatewayID, 'anthropic/v1'),
133
+ };
134
+ continue;
135
+ }
136
+ if (key.startsWith('workers-ai/')) {
137
+ model.id = key.slice('workers-ai/'.length);
138
+ }
139
+ model.provider = undefined;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * The passthrough routes authenticate with `cf-aig-authorization` only;
145
+ * the SDK's own `Authorization` / `x-api-key` header would be forwarded to
146
+ * the upstream provider, so it is removed there.
147
+ */
148
+ export function cloudflareGatewayFetch(
149
+ token: string,
150
+ inner: typeof fetch = fetch
151
+ ): typeof fetch {
152
+ return (async (input: RequestInfo | URL, init?: RequestInit) => {
153
+ const url =
154
+ typeof input === 'string'
155
+ ? input
156
+ : input instanceof URL
157
+ ? input.href
158
+ : input.url;
159
+ if (new URL(url).host !== CLOUDFLARE_GATEWAY_HOST) {
160
+ return inner(input, init);
161
+ }
162
+ const headers = new Headers(init?.headers);
163
+ headers.delete('authorization');
164
+ headers.delete('x-api-key');
165
+ headers.delete('cf-aig-gateway-id');
166
+ headers.set('cf-aig-authorization', `Bearer ${token}`);
167
+ return inner(input, { ...init, headers });
168
+ }) as typeof fetch;
169
+ }
170
+
171
+ function cloudflareGatewayHeaders(env: Env) {
172
+ const headers: Record<string, string> = {
173
+ 'cf-aig-gateway-id': cloudflareGatewayID(env),
174
+ };
175
+ const optional: Record<string, string> = {
176
+ CLOUDFLARE_GATEWAY_METADATA: 'cf-aig-metadata',
177
+ CLOUDFLARE_GATEWAY_CACHE_TTL: 'cf-aig-cache-ttl',
178
+ CLOUDFLARE_GATEWAY_CACHE_KEY: 'cf-aig-cache-key',
179
+ CLOUDFLARE_GATEWAY_SKIP_CACHE: 'cf-aig-skip-cache',
180
+ CLOUDFLARE_GATEWAY_COLLECT_LOG: 'cf-aig-collect-log',
181
+ };
182
+ for (const [name, header] of Object.entries(optional)) {
183
+ const value = env[name]?.trim();
184
+ if (value) headers[header] = value;
185
+ }
186
+ return headers;
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Snowflake Cortex
191
+ // ---------------------------------------------------------------------------
192
+
193
+ export const SNOWFLAKE_TOKEN_ENV = [
194
+ 'SNOWFLAKE_CORTEX_TOKEN',
195
+ 'SNOWFLAKE_CORTEX_PAT',
196
+ ];
197
+
198
+ const SNOWFLAKE_STOP_STREAM =
199
+ 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n';
200
+
201
+ /**
202
+ * Cortex Chat Completions expects `max_completion_tokens` and answers a
203
+ * finished conversation with HTTP 400 "conversation complete"; OpenCode
204
+ * turns that into a normal stop. Same here.
205
+ */
206
+ export function snowflakeFetch(inner: typeof fetch = fetch): typeof fetch {
207
+ return (async (input: RequestInfo | URL, init?: RequestInit) => {
208
+ let request = init;
209
+ if (typeof init?.body === 'string') {
210
+ try {
211
+ const body = JSON.parse(init.body);
212
+ if (
213
+ body &&
214
+ typeof body === 'object' &&
215
+ body.max_tokens !== undefined &&
216
+ body.max_completion_tokens === undefined
217
+ ) {
218
+ body.max_completion_tokens = body.max_tokens;
219
+ delete body.max_tokens;
220
+ request = { ...init, body: JSON.stringify(body) };
221
+ }
222
+ } catch {
223
+ // Not JSON, send unchanged.
224
+ }
225
+ }
226
+ const response = await inner(input, request);
227
+ if (response.status !== 400) return response;
228
+ const text = await response
229
+ .clone()
230
+ .text()
231
+ .catch(() => '');
232
+ let message = text;
233
+ try {
234
+ const parsed = JSON.parse(text);
235
+ message = String(parsed?.message || parsed?.error || '');
236
+ } catch {
237
+ // Keep the raw text.
238
+ }
239
+ if (!message.toLowerCase().includes('conversation complete')) {
240
+ return response;
241
+ }
242
+ return new Response(SNOWFLAKE_STOP_STREAM, {
243
+ headers: { 'content-type': 'text/event-stream' },
244
+ });
245
+ }) as typeof fetch;
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Google Vertex AI
250
+ // ---------------------------------------------------------------------------
251
+
252
+ /** Placeholder key for SDKs that require one; replaced by an ADC token. */
253
+ export const VERTEX_ADC_PLACEHOLDER = 'google-vertex-adc';
254
+
255
+ let googleAuth: Promise<any> | undefined;
256
+
257
+ async function vertexAccessToken(): Promise<string | undefined> {
258
+ googleAuth ??= import('google-auth-library').then(
259
+ ({ GoogleAuth }) =>
260
+ new GoogleAuth({
261
+ scopes: 'https://www.googleapis.com/auth/cloud-platform',
262
+ })
263
+ );
264
+ const auth = await googleAuth;
265
+ const token = await auth.getAccessToken();
266
+ return typeof token === 'string' ? token : (token?.token ?? undefined);
267
+ }
268
+
269
+ /** `aiplatform.googleapis.com` and regional `{location}-aiplatform...`. */
270
+ const VERTEX_HOST = /^(?:[a-z0-9-]+-)?aiplatform\.googleapis\.com$/i;
271
+
272
+ /**
273
+ * Vertex serves partner models (OpenAI-compatible `endpoints/openapi`,
274
+ * Mistral) through plain HTTP SDKs that know nothing about Google
275
+ * credentials. Add an Application Default Credentials bearer token to
276
+ * requests for `*aiplatform.googleapis.com` that carry no real credential.
277
+ */
278
+ export function vertexFetch(
279
+ inner: typeof fetch = fetch,
280
+ token: () => Promise<string | undefined> = vertexAccessToken
281
+ ): typeof fetch {
282
+ return (async (input: RequestInfo | URL, init?: RequestInit) => {
283
+ const url =
284
+ typeof input === 'string'
285
+ ? input
286
+ : input instanceof URL
287
+ ? input.href
288
+ : input.url;
289
+ if (!VERTEX_HOST.test(new URL(url).hostname)) {
290
+ return inner(input, init);
291
+ }
292
+ const headers = new Headers(init?.headers);
293
+ const current = headers.get('authorization');
294
+ if (
295
+ headers.has('x-goog-api-key') ||
296
+ (current && current !== `Bearer ${VERTEX_ADC_PLACEHOLDER}`)
297
+ ) {
298
+ return inner(input, init);
299
+ }
300
+ const access = await token();
301
+ if (access) headers.set('authorization', `Bearer ${access}`);
302
+ else headers.delete('authorization');
303
+ return inner(input, { ...init, headers });
304
+ }) as typeof fetch;
305
+ }
306
+
307
+ function vertexSdkOptions(pkg: string, options: Record<string, any>) {
308
+ if (pkg === '@ai-sdk/google-vertex') return options;
309
+ if (pkg === '@ai-sdk/google-vertex/anthropic') {
310
+ const { apiKey: _apiKey, ...rest } = options;
311
+ return rest;
312
+ }
313
+ // Partner models: the base URL comes from the model entry and the
314
+ // credential from ADC (see `vertexFetch`).
315
+ const {
316
+ apiKey: _apiKey,
317
+ project: _project,
318
+ location: _location,
319
+ ...rest
320
+ } = options;
321
+ return { ...rest, apiKey: VERTEX_ADC_PLACEHOLDER };
322
+ }
323
+
324
+ export function vertexSettings(env: Env = process.env) {
325
+ const derived = EnvTemplate.derived(env);
326
+ return {
327
+ project: derived['GOOGLE_VERTEX_PROJECT'],
328
+ location: derived['GOOGLE_VERTEX_LOCATION'] ?? 'global',
329
+ apiKey: env['GOOGLE_VERTEX_API_KEY']?.trim() || undefined,
330
+ };
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Amazon Bedrock
335
+ // ---------------------------------------------------------------------------
336
+
337
+ /**
338
+ * Ambient inputs the AWS default credential chain can turn into
339
+ * credentials (same list as OpenCode's Bedrock plugin).
340
+ */
341
+ export const AWS_CHAIN_ENV = [
342
+ 'AWS_PROFILE',
343
+ 'AWS_ACCESS_KEY_ID',
344
+ 'AWS_WEB_IDENTITY_TOKEN_FILE',
345
+ 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI',
346
+ 'AWS_CONTAINER_CREDENTIALS_FULL_URI',
347
+ ];
348
+
349
+ export function awsRegion(env: Env = process.env) {
350
+ return env['AWS_REGION'] ?? env['AWS_DEFAULT_REGION'] ?? 'us-east-1';
351
+ }
352
+
353
+ /**
354
+ * Cross-region inference profile prefix for Bedrock model IDs.
355
+ */
356
+ export function bedrockModelID(modelID: string, region: string): string {
357
+ let regionPrefix = region.split('-')[0];
358
+ switch (regionPrefix) {
359
+ case 'us': {
360
+ const modelRequiresPrefix = [
361
+ 'nova-micro',
362
+ 'nova-lite',
363
+ 'nova-pro',
364
+ 'nova-premier',
365
+ 'claude',
366
+ 'deepseek',
367
+ ].some((m) => modelID.includes(m));
368
+ const isGovCloud = region.startsWith('us-gov');
369
+ if (modelRequiresPrefix && !isGovCloud) {
370
+ return `${regionPrefix}.${modelID}`;
371
+ }
372
+ return modelID;
373
+ }
374
+ case 'eu': {
375
+ const regionRequiresPrefix = [
376
+ 'eu-west-1',
377
+ 'eu-west-2',
378
+ 'eu-west-3',
379
+ 'eu-north-1',
380
+ 'eu-central-1',
381
+ 'eu-south-1',
382
+ 'eu-south-2',
383
+ ].some((r) => region.includes(r));
384
+ const modelRequiresPrefix = [
385
+ 'claude',
386
+ 'nova-lite',
387
+ 'nova-micro',
388
+ 'llama3',
389
+ 'pixtral',
390
+ ].some((m) => modelID.includes(m));
391
+ if (regionRequiresPrefix && modelRequiresPrefix) {
392
+ return `${regionPrefix}.${modelID}`;
393
+ }
394
+ return modelID;
395
+ }
396
+ case 'ap': {
397
+ const isAustraliaRegion = ['ap-southeast-2', 'ap-southeast-4'].includes(
398
+ region
399
+ );
400
+ if (
401
+ isAustraliaRegion &&
402
+ ['anthropic.claude-sonnet-4-5', 'anthropic.claude-haiku'].some((m) =>
403
+ modelID.includes(m)
404
+ )
405
+ ) {
406
+ return `au.${modelID}`;
407
+ }
408
+ const modelRequiresPrefix = [
409
+ 'claude',
410
+ 'nova-lite',
411
+ 'nova-micro',
412
+ 'nova-pro',
413
+ ].some((m) => modelID.includes(m));
414
+ if (modelRequiresPrefix) {
415
+ regionPrefix = 'apac';
416
+ return `${regionPrefix}.${modelID}`;
417
+ }
418
+ return modelID;
419
+ }
420
+ }
421
+ return modelID;
422
+ }
423
+
424
+ // ---------------------------------------------------------------------------
425
+ // API shape routing
426
+ // ---------------------------------------------------------------------------
427
+
428
+ export const BEDROCK_MANTLE_NPM = '@ai-sdk/amazon-bedrock/mantle';
429
+
430
+ /**
431
+ * Pick the language model factory a catalogue entry asks for. Returns
432
+ * `undefined` when the entry has no preference, so the provider's own
433
+ * `getModel` (or `sdk.languageModel`) applies.
434
+ */
435
+ export function selectByShape(
436
+ sdk: any,
437
+ info: ModelsDev.Model | undefined,
438
+ modelID: string
439
+ ): any {
440
+ if (!sdk || !info?.provider) return undefined;
441
+ if (info.provider.npm === BEDROCK_MANTLE_NPM) {
442
+ // Same routing as OpenCode: gpt-oss models are Chat Completions only.
443
+ if (modelID.includes('gpt-oss') && typeof sdk.chat === 'function') {
444
+ return sdk.chat(modelID);
445
+ }
446
+ if (typeof sdk.responses === 'function') return sdk.responses(modelID);
447
+ return undefined;
448
+ }
449
+ if (info.provider.shape === 'responses') {
450
+ if (typeof sdk.responses === 'function') return sdk.responses(modelID);
451
+ }
452
+ if (info.provider.shape === 'completions') {
453
+ if (typeof sdk.chat === 'function') return sdk.chat(modelID);
454
+ }
455
+ return undefined;
456
+ }
457
+
458
+ // ---------------------------------------------------------------------------
459
+ // Loaders
460
+ // ---------------------------------------------------------------------------
461
+
462
+ export function create(env: Env = process.env): Record<string, Loader> {
463
+ return {
464
+ async nvidia(input) {
465
+ if (input?.api !== NVIDIA_URL) return { autoload: false };
466
+ return {
467
+ autoload: false,
468
+ options: {
469
+ headers: { ...ATTRIBUTION, 'X-BILLING-INVOKE-ORIGIN': 'OpenCode' },
470
+ },
471
+ };
472
+ },
473
+ async llmgateway(input) {
474
+ if (input?.api !== LLMGATEWAY_URL) return { autoload: false };
475
+ return {
476
+ autoload: false,
477
+ options: { headers: { ...ATTRIBUTION, 'X-Source': 'opencode' } },
478
+ };
479
+ },
480
+ async cerebras() {
481
+ return {
482
+ autoload: false,
483
+ options: {
484
+ headers: { 'X-Cerebras-3rd-Party-Integration': 'opencode' },
485
+ },
486
+ };
487
+ },
488
+ async 'cloudflare-workers-ai'() {
489
+ const token = first(env, CLOUDFLARE_WORKERS_TOKEN_ENV);
490
+ const accountID = env['CLOUDFLARE_ACCOUNT_ID']?.trim();
491
+ const options: Record<string, any> = {};
492
+ if (token) options.apiKey = token;
493
+ if (accountID) options.baseURL = cloudflareChatURL(accountID);
494
+ return { autoload: Boolean(token && accountID), options };
495
+ },
496
+ async 'cloudflare-ai-gateway'() {
497
+ const token = first(env, CLOUDFLARE_GATEWAY_TOKEN_ENV);
498
+ const accountID = env['CLOUDFLARE_ACCOUNT_ID']?.trim();
499
+ if (!token) return { autoload: false };
500
+ return {
501
+ autoload: Boolean(accountID),
502
+ options: {
503
+ apiKey: token,
504
+ headers: cloudflareGatewayHeaders(env),
505
+ fetch: cloudflareGatewayFetch(token),
506
+ },
507
+ };
508
+ },
509
+ async 'snowflake-cortex'(input) {
510
+ const token = first(env, SNOWFLAKE_TOKEN_ENV);
511
+ const baseURL = EnvTemplate.resolve(input?.api, env);
512
+ const options: Record<string, any> = { fetch: snowflakeFetch() };
513
+ if (token) options.apiKey = token;
514
+ if (baseURL) options.baseURL = baseURL;
515
+ return { autoload: Boolean(token && baseURL), options };
516
+ },
517
+ async 'sap-ai-core'() {
518
+ if (!env['AICORE_SERVICE_KEY']) return { autoload: false };
519
+ const options: Record<string, any> = {};
520
+ if (env['AICORE_DEPLOYMENT_ID']) {
521
+ options.deploymentId = env['AICORE_DEPLOYMENT_ID'];
522
+ }
523
+ if (env['AICORE_RESOURCE_GROUP']) {
524
+ options.resourceGroup = env['AICORE_RESOURCE_GROUP'];
525
+ }
526
+ return {
527
+ autoload: true,
528
+ options,
529
+ async getModel(sdk: any, modelID: string) {
530
+ return typeof sdk === 'function'
531
+ ? sdk(modelID)
532
+ : sdk.languageModel(modelID);
533
+ },
534
+ };
535
+ },
536
+ async 'google-vertex'() {
537
+ const { project, location, apiKey } = vertexSettings(env);
538
+ if (!project && !apiKey) return { autoload: false };
539
+ return {
540
+ autoload: true,
541
+ options: {
542
+ ...(project ? { project } : {}),
543
+ location,
544
+ ...(apiKey ? { apiKey } : {}),
545
+ fetch: vertexFetch(),
546
+ },
547
+ sdkOptions: vertexSdkOptions,
548
+ async getModel(sdk: any, modelID: string) {
549
+ return sdk.languageModel(String(modelID).trim());
550
+ },
551
+ };
552
+ },
553
+ async 'google-vertex-anthropic'() {
554
+ const { project, location } = vertexSettings(env);
555
+ if (!project) return { autoload: false };
556
+ return {
557
+ autoload: true,
558
+ options: { project, location },
559
+ async getModel(sdk: any, modelID: string) {
560
+ return sdk.languageModel(String(modelID).trim());
561
+ },
562
+ };
563
+ },
564
+ async 'amazon-bedrock'() {
565
+ const chain = AWS_CHAIN_ENV.some((name) => env[name]);
566
+ const bearer = env['AWS_BEARER_TOKEN_BEDROCK']?.trim();
567
+ if (!chain && !bearer) return { autoload: false };
568
+ const region = awsRegion(env);
569
+ const options: Record<string, any> = { region };
570
+ if (bearer) options.apiKey = bearer;
571
+ if (chain) {
572
+ try {
573
+ const { fromNodeProviderChain } = await import(
574
+ await BunProc.install('@aws-sdk/credential-providers')
575
+ );
576
+ options.credentialProvider = fromNodeProviderChain();
577
+ } catch (error) {
578
+ log.warn(() => ({
579
+ message: 'AWS credential chain unavailable',
580
+ error: error instanceof Error ? error.message : String(error),
581
+ }));
582
+ if (!bearer) return { autoload: false };
583
+ }
584
+ }
585
+ return {
586
+ autoload: true,
587
+ options,
588
+ async getModel(sdk: any, modelID: string) {
589
+ return sdk.languageModel(bedrockModelID(modelID, region));
590
+ },
591
+ };
592
+ },
593
+ };
594
+ }
595
+ }