@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.
- package/README.md +3 -3
- package/package.json +43 -33
- package/src/agent/agent.ts +12 -7
- package/src/auth/azure-login.ts +241 -0
- package/src/auth/plugins.ts +9 -0
- package/src/auth/provider-logins.ts +804 -0
- package/src/cli/cmd/auth.ts +4 -0
- package/src/cli/cmd/models.ts +68 -13
- package/src/cli/model-config.js +7 -4
- package/src/index.js +3 -0
- package/src/provider/env-template.ts +115 -0
- package/src/provider/extra-providers.ts +347 -0
- package/src/provider/kilo.ts +19 -0
- package/src/provider/model-discovery.ts +690 -0
- package/src/provider/models.ts +11 -1
- package/src/provider/parity-loaders.ts +595 -0
- package/src/provider/provider.ts +257 -182
- package/src/provider/transform.ts +29 -0
package/src/provider/models.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|