@sahan-sac/ai-core 0.3.0 → 0.3.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.
package/dist/providers.js CHANGED
@@ -1,585 +1,23 @@
1
- // src/providers.ts
2
- import "server-only";
3
- import { createOpenAI } from "@ai-sdk/openai";
4
- import { generateText } from "ai";
5
-
6
- // src/log.ts
7
- var consoleAiLogger = {
8
- warn(message, meta) {
9
- console.warn(JSON.stringify({ level: "warn", message, ...meta }));
10
- }
11
- };
12
-
13
- // src/models.ts
14
- var DEFAULT_TEXT_MODELS = {
15
- // Long structured JSON: a full flash model, not lite.
16
- blog: {
17
- gemini: "gemini-2.5-flash",
18
- openrouter: "nvidia/nemotron-3-super-120b-a12b:free",
19
- nvidia: "nvidia/nemotron-3-super-120b-a12b",
20
- vertex: "gemini-2.5-flash"
21
- },
22
- // Short, latency-sensitive replies: the fastest free model first.
23
- chat: {
24
- gemini: "gemini-3.1-flash-lite",
25
- openrouter: "nvidia/nemotron-3-super-120b-a12b:free",
26
- nvidia: "nvidia/nemotron-3-super-120b-a12b",
27
- vertex: "gemini-2.5-flash"
28
- }
29
- };
30
- var pick = (...values) => values.find((value) => value && value.trim()).trim();
31
- function textModels(env, purpose) {
32
- const defaults = DEFAULT_TEXT_MODELS[purpose];
33
- const own = purpose === "blog" ? { gemini: env.BLOG_GEMINI_MODEL, openrouter: env.BLOG_OPENROUTER_MODEL, nvidia: env.BLOG_NVIDIA_MODEL, vertex: env.BLOG_VERTEX_MODEL } : { gemini: env.CHAT_GEMINI_MODEL, openrouter: env.CHAT_OPENROUTER_MODEL, nvidia: env.CHAT_NVIDIA_MODEL, vertex: env.CHAT_VERTEX_MODEL };
34
- return {
35
- gemini: pick(own.gemini, env.GEMINI_MODEL, defaults.gemini),
36
- openrouter: pick(own.openrouter, env.OPENROUTER_MODEL, defaults.openrouter),
37
- nvidia: pick(own.nvidia, env.NVIDIA_MODEL, defaults.nvidia),
38
- vertex: pick(own.vertex, env.VERTEX_MODEL, defaults.vertex)
39
- };
40
- }
41
- var paidAllowed = (env) => env.AI_ALLOW_PAID === true;
42
- function vertexConfigured(env) {
43
- return Boolean(env.GOOGLE_CLIENT_EMAIL && env.GOOGLE_PRIVATE_KEY && env.GOOGLE_CLOUD_PROJECT);
44
- }
45
- function thinkingConfigFor(model, maxOutputTokens) {
46
- const long = maxOutputTokens >= 2e3;
47
- if (/^gemini-[3-9]/.test(model)) {
48
- return { thinkingLevel: long ? "low" : /flash/.test(model) ? "minimal" : "low" };
49
- }
50
- return { thinkingBudget: long ? Math.min(800, Math.floor(maxOutputTokens / 5)) : 0 };
51
- }
52
-
53
- // src/vertex.ts
54
- import "server-only";
55
- var cachedToken = null;
56
- function base64UrlEncode(input) {
57
- return Buffer.from(input).toString("base64url");
58
- }
59
- async function getVertexAccessToken(account, fetchImpl = fetch) {
60
- const now = Date.now();
61
- if (cachedToken && cachedToken.expiresAt - 6e4 > now) return cachedToken.accessToken;
62
- const { createSign } = await import("crypto");
63
- const issuedAt = Math.floor(now / 1e3);
64
- const expiresIn = 3600;
65
- const header = base64UrlEncode(JSON.stringify({ alg: "RS256", typ: "JWT" }));
66
- const claims = base64UrlEncode(
67
- JSON.stringify({
68
- iss: account.clientEmail,
69
- scope: "https://www.googleapis.com/auth/cloud-platform",
70
- aud: account.tokenUri,
71
- iat: issuedAt,
72
- exp: issuedAt + expiresIn
73
- })
74
- );
75
- const signingInput = `${header}.${claims}`;
76
- const signature = createSign("RSA-SHA256").update(signingInput).sign(account.privateKey);
77
- const jwt = `${signingInput}.${base64UrlEncode(signature)}`;
78
- const response = await fetchImpl(account.tokenUri, {
79
- method: "POST",
80
- headers: { "content-type": "application/x-www-form-urlencoded" },
81
- body: new URLSearchParams({
82
- grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
83
- assertion: jwt
84
- })
85
- });
86
- if (!response.ok) {
87
- throw new Error(`Vertex token exchange failed: HTTP ${response.status}`);
88
- }
89
- const data = await response.json();
90
- cachedToken = { accessToken: data.access_token, expiresAt: now + data.expires_in * 1e3 };
91
- return data.access_token;
92
- }
93
-
94
- // src/adapters.ts
95
- function modelFor(env, provider, purpose, fallback) {
96
- const record = env;
97
- return record[`${purpose.toUpperCase()}_${provider}_MODEL`] || record[`${provider}_MODEL`] || fallback[purpose];
98
- }
99
- var need = (env, ...names) => names.filter((name) => !env[name]);
100
- var PAID_TEXT_MODELS = {
101
- openai: { blog: "gpt-5-mini", chat: "gpt-5-nano" },
102
- anthropic: { blog: "claude-sonnet-5-5", chat: "claude-haiku-4-5-20251001" },
103
- deepseek: { blog: "deepseek-chat", chat: "deepseek-chat" },
104
- xai: { blog: "grok-3-mini", chat: "grok-3-mini" },
105
- perplexity: { blog: "sonar", chat: "sonar" }
106
- };
107
- function compatibleAdapter(spec) {
108
- return {
109
- id: spec.id,
110
- label: spec.label,
111
- paid: () => true,
112
- missing: (env) => need(env, spec.keyVar),
113
- create: ({ env, purpose, fetch: fetch2 }) => openAiCompatibleProvider({
114
- name: spec.id,
115
- apiKey: env[spec.keyVar],
116
- baseUrl: spec.baseUrlVar && env[spec.baseUrlVar] || spec.baseUrl,
117
- model: modelFor(env, spec.envPrefix, purpose, PAID_TEXT_MODELS[spec.id]),
118
- fetch: fetch2
119
- })
120
- };
121
- }
122
- var openRouterAdapter = (id, keyVar) => ({
123
- id,
124
- label: id === "openrouter" ? "OpenRouter" : "OpenRouter (second key)",
125
- // Free models only, unless OPENROUTER_ALLOW_PAID_MODELS: the adapter itself refuses a paid model id.
126
- paid: () => false,
127
- missing: (env) => need(env, keyVar),
128
- create: ({ env, purpose, fetch: fetch2 }) => openRouterProvider({
129
- apiKey: env[keyVar],
130
- model: textModels(env, purpose).openrouter,
131
- baseUrl: env.OPENROUTER_BASE_URL,
132
- allowPaidModels: env.OPENROUTER_ALLOW_PAID_MODELS,
133
- fetch: fetch2,
134
- name: id
135
- })
136
- });
137
- var BUILTIN_ADAPTERS = [
138
- {
139
- id: "gemini",
140
- label: "Gemini",
141
- paid: () => false,
142
- missing: (env) => need(env, "GEMINI_API_KEY"),
143
- create: ({ env, purpose, fetch: fetch2 }) => geminiProvider({ apiKey: env.GEMINI_API_KEY, model: textModels(env, purpose).gemini, fetchImpl: fetch2 })
144
- },
145
- openRouterAdapter("openrouter", "OPENROUTER_API_KEY"),
146
- openRouterAdapter("openrouter-2", "OPENROUTER_API_KEY_2"),
147
- {
148
- id: "nvidia",
149
- label: "NVIDIA",
150
- paid: () => false,
151
- missing: (env) => need(env, "NVIDIA_API_KEY"),
152
- create: ({ env, purpose, fetch: fetch2 }) => openAiCompatibleProvider({
153
- name: "nvidia",
154
- apiKey: env.NVIDIA_API_KEY,
155
- baseUrl: "https://integrate.api.nvidia.com/v1",
156
- model: textModels(env, purpose).nvidia,
157
- fetch: fetch2
158
- })
159
- },
160
- compatibleAdapter({ id: "openai", label: "OpenAI", envPrefix: "OPENAI", keyVar: "OPENAI_API_KEY", baseUrl: "https://api.openai.com/v1", baseUrlVar: "OPENAI_BASE_URL" }),
161
- {
162
- id: "anthropic",
163
- label: "Anthropic (Claude)",
164
- paid: () => true,
165
- missing: (env) => need(env, "ANTHROPIC_API_KEY"),
166
- create: ({ env, purpose, fetch: fetch2 }) => anthropicProvider({
167
- apiKey: env.ANTHROPIC_API_KEY,
168
- baseUrl: env.ANTHROPIC_BASE_URL,
169
- model: modelFor(env, "ANTHROPIC", purpose, PAID_TEXT_MODELS.anthropic),
170
- fetchImpl: fetch2
171
- })
172
- },
173
- compatibleAdapter({ id: "deepseek", label: "DeepSeek", envPrefix: "DEEPSEEK", keyVar: "DEEPSEEK_API_KEY", baseUrl: "https://api.deepseek.com/v1" }),
174
- compatibleAdapter({ id: "xai", label: "xAI (Grok)", envPrefix: "XAI", keyVar: "XAI_API_KEY", baseUrl: "https://api.x.ai/v1" }),
175
- compatibleAdapter({ id: "perplexity", label: "Perplexity", envPrefix: "PERPLEXITY", keyVar: "PERPLEXITY_API_KEY", baseUrl: "https://api.perplexity.ai" }),
176
- {
177
- id: "custom",
178
- label: "Custom OpenAI-compatible",
179
- paid: (env) => !env.AI_CUSTOM_FREE,
180
- missing: (env) => need(env, "AI_CUSTOM_BASE_URL", "AI_CUSTOM_MODEL"),
181
- create: ({ env, fetch: fetch2 }) => openAiCompatibleProvider({
182
- name: env.AI_CUSTOM_NAME ? `custom:${env.AI_CUSTOM_NAME}` : "custom",
183
- // A local server may take no key; the header must still be well formed.
184
- apiKey: env.AI_CUSTOM_API_KEY ?? "none",
185
- baseUrl: env.AI_CUSTOM_BASE_URL,
186
- model: env.AI_CUSTOM_MODEL,
187
- fetch: fetch2
188
- })
189
- },
190
- {
191
- id: "vertex",
192
- label: "Vertex AI",
193
- paid: () => true,
194
- missing: (env) => vertexConfigured(env) ? [] : need(env, "GOOGLE_CLIENT_EMAIL", "GOOGLE_PRIVATE_KEY", "GOOGLE_CLOUD_PROJECT"),
195
- create: ({ env, purpose, fetch: fetch2 }) => vertexProvider({
196
- clientEmail: env.GOOGLE_CLIENT_EMAIL,
197
- privateKey: env.GOOGLE_PRIVATE_KEY,
198
- tokenUri: env.GOOGLE_TOKEN_URI,
199
- project: env.GOOGLE_CLOUD_PROJECT,
200
- model: textModels(env, purpose).vertex,
201
- fetchImpl: fetch2
202
- })
203
- }
204
- ];
205
- var DEFAULT_ORDER = BUILTIN_ADAPTERS.map((adapter) => adapter.id);
206
- function configuredOrder(env, purpose) {
207
- const raw = (purpose === "blog" ? env.AI_PROVIDER_ORDER_BLOG : env.AI_PROVIDER_ORDER_CHAT) ?? env.AI_PROVIDER_ORDER;
208
- if (!raw) return null;
209
- const ids = raw.split(",").map((id) => id.trim().toLowerCase()).filter(Boolean);
210
- return ids.length ? [...new Set(ids)] : null;
211
- }
212
- function providerStatuses(env, purpose, adapters = BUILTIN_ADAPTERS) {
213
- const order = configuredOrder(env, purpose);
214
- const ids = order ?? adapters.map((adapter) => adapter.id);
215
- const byId = new Map(adapters.map((adapter) => [adapter.id, adapter]));
216
- let position = 0;
217
- const statuses = [];
218
- for (const id of ids) {
219
- const adapter = byId.get(id);
220
- if (!adapter) {
221
- statuses.push({ id, label: id, paid: false, state: "unknown", missing: [] });
222
- continue;
223
- }
224
- const missing = adapter.missing(env);
225
- const paid = adapter.paid(env);
226
- const state = missing.length ? "not_configured" : paid && !paidAllowed(env) ? "needs_paid" : "active";
227
- statuses.push({ id, label: adapter.label, paid, state, missing, ...state === "active" ? { position: position++ } : {} });
228
- }
229
- for (const adapter of adapters) {
230
- if (!ids.includes(adapter.id)) {
231
- statuses.push({ id: adapter.id, label: adapter.label, paid: adapter.paid(env), state: "not_in_order", missing: adapter.missing(env) });
232
- }
233
- }
234
- return statuses;
235
- }
236
- function chainPlan(env, purpose, adapters = BUILTIN_ADAPTERS) {
237
- const byId = new Map(adapters.map((adapter) => [adapter.id, adapter]));
238
- return providerStatuses(env, purpose, adapters).filter((status) => status.state === "active").map((status) => byId.get(status.id));
239
- }
240
- function realProviders(env, purpose, fetchImpl, adapters = BUILTIN_ADAPTERS) {
241
- const explicit = configuredOrder(env, purpose) !== null;
242
- return chainPlan(env, purpose, adapters).map((adapter, index) => {
243
- const provider = adapter.create({ env, purpose, fetch: fetchImpl });
244
- return explicit ? { name: provider.name, generate: provider.generate, priority: index } : { name: provider.name, generate: provider.generate, lastResort: adapter.paid(env) || provider.lastResort };
245
- });
246
- }
247
-
248
- // src/providers.ts
249
- function jsonConfig(jsonMode) {
250
- if (!jsonMode) return {};
251
- return typeof jsonMode === "object" ? { responseMimeType: "application/json", responseSchema: jsonMode.schema } : { responseMimeType: "application/json" };
252
- }
253
- var AI_TIMEOUT_MS = 25e3;
254
- var AI_COOLDOWN_MS = 6e4;
255
- async function attempt(provider, prompt, maxTokens, ms, cancel, jsonMode) {
256
- const controller = new AbortController();
257
- const onCancel = () => controller.abort();
258
- cancel.addEventListener("abort", onCancel, { once: true });
259
- let timer;
260
- const timeout = new Promise((resolve) => {
261
- timer = setTimeout(() => {
262
- controller.abort();
263
- resolve({ ok: false, errorClass: "timeout", retryable: true });
264
- }, ms);
265
- });
266
- try {
267
- return await Promise.race([provider.generate(prompt, { maxTokens, signal: controller.signal, jsonMode }), timeout]);
268
- } catch {
269
- return { ok: false, errorClass: "transport", retryable: true };
270
- } finally {
271
- clearTimeout(timer);
272
- cancel.removeEventListener("abort", onCancel);
273
- }
274
- }
275
- var createAiHealth = () => ({ cooldownUntil: /* @__PURE__ */ new Map(), latencyMs: /* @__PURE__ */ new Map() });
276
- var sharedAiHealth = globalThis.sahanAiHealth ??= createAiHealth();
277
- function cooldownFor(outcome) {
278
- if (outcome.ok) return 0;
279
- if (outcome.errorClass === "timeout" || outcome.status === 429) return AI_COOLDOWN_MS;
280
- if (outcome.status === 401 || outcome.status === 403 || outcome.status === 404) return AI_COOLDOWN_MS * 10;
281
- return 0;
282
- }
283
- var HEDGE = /* @__PURE__ */ Symbol("hedge");
284
- function createAiService(deps) {
285
- const timeoutMs = deps.timeoutMs ?? AI_TIMEOUT_MS;
286
- const now = deps.now ?? Date.now;
287
- const health = deps.health ?? createAiHealth();
288
- const logger = deps.logger ?? consoleAiLogger;
289
- function rank(at) {
290
- const cooling = (p) => Number((health.cooldownUntil.get(p.name) ?? 0) > at);
291
- const speed = (p) => health.latencyMs.get(p.name) ?? Number.POSITIVE_INFINITY;
292
- const costly = (p) => Number(Boolean(p.lastResort));
293
- const chosen = (a, b) => a.priority !== void 0 && b.priority !== void 0 ? a.priority - b.priority : speed(a) - speed(b);
294
- return [...deps.providers].sort((a, b) => costly(a) - costly(b) || cooling(a) - cooling(b) || chosen(a, b));
295
- }
296
- async function generate(prompt, options) {
297
- if (deps.providers.length === 0) {
298
- return { ok: false, provider: null, errorClass: "no_provider", attempts: [] };
299
- }
300
- const external = options?.signal;
301
- if (external?.aborted) return { ok: false, provider: null, errorClass: "aborted", attempts: [] };
302
- const startedAll = now();
303
- const ordered = rank(startedAll);
304
- const attempts = [];
305
- const cancelLosers = new AbortController();
306
- const running = /* @__PURE__ */ new Set();
307
- let next = 0;
308
- let stopped = false;
309
- let result = null;
310
- let rejected;
311
- let onAbort;
312
- const aborted = new Promise((resolve) => {
313
- onAbort = () => {
314
- stopped = true;
315
- cancelLosers.abort();
316
- resolve();
317
- };
318
- external?.addEventListener("abort", onAbort, { once: true });
319
- });
320
- const launch = () => {
321
- if (result || stopped || next >= ordered.length) return false;
322
- const remaining = deps.deadlineMs === void 0 ? timeoutMs : deps.deadlineMs - (now() - startedAll);
323
- if (remaining <= 250) return false;
324
- const provider = ordered[next++];
325
- options?.onAttempt?.({ provider: provider.name, stage: "start" });
326
- const started = now();
327
- const run = attempt(provider, prompt, options?.maxTokens, Math.min(timeoutMs, remaining), cancelLosers.signal, options?.jsonMode).then((raw) => {
328
- if (result || external?.aborted) return;
329
- const ms = now() - started;
330
- let outcome = raw;
331
- if (outcome.ok && options?.accept) {
332
- const reason = options.accept(outcome.text);
333
- if (reason) {
334
- rejected = { provider: provider.name, text: outcome.text, reason };
335
- outcome = { ok: false, errorClass: "invalid_output", retryable: true };
336
- }
337
- }
338
- if (outcome.ok) {
339
- health.cooldownUntil.delete(provider.name);
340
- const previous = health.latencyMs.get(provider.name);
341
- health.latencyMs.set(provider.name, previous === void 0 ? ms : Math.round(previous * 0.7 + ms * 0.3));
342
- attempts.push({ provider: provider.name, ok: true, ms });
343
- result = { ok: true, provider: provider.name, text: outcome.text, attempts };
344
- options?.onAttempt?.({ provider: provider.name, stage: "success" });
345
- cancelLosers.abort();
346
- return;
347
- }
348
- attempts.push({ provider: provider.name, ok: false, errorClass: outcome.errorClass, ms });
349
- const fallbackTo = next < ordered.length ? ordered[next].name : void 0;
350
- options?.onAttempt?.({
351
- provider: provider.name,
352
- stage: fallbackTo ? "fallback" : "failure",
353
- errorClass: outcome.errorClass,
354
- fallbackTo
355
- });
356
- const cooldown = cooldownFor(outcome);
357
- if (cooldown) health.cooldownUntil.set(provider.name, now() + cooldown);
358
- logger.warn("ai provider failed", {
359
- provider: provider.name,
360
- errorClass: outcome.errorClass,
361
- ms,
362
- ...outcome.errorClass === "invalid_output" && rejected ? { reason: rejected.reason.slice(0, 160) } : {}
363
- });
364
- if (!outcome.retryable) stopped = true;
365
- });
366
- const tracked = run.finally(() => running.delete(tracked));
367
- running.add(tracked);
368
- return true;
369
- };
370
- try {
371
- while (!result && !external?.aborted) {
372
- if (running.size === 0 && !launch()) break;
373
- let timer;
374
- const racers = [...running, aborted];
375
- if (deps.hedgeAfterMs !== void 0 && next < ordered.length) {
376
- racers.push(new Promise((resolve) => timer = setTimeout(() => resolve(HEDGE), deps.hedgeAfterMs)));
377
- }
378
- const first = await Promise.race(racers);
379
- clearTimeout(timer);
380
- if (first === HEDGE) launch();
381
- }
382
- } finally {
383
- if (onAbort) external?.removeEventListener("abort", onAbort);
384
- }
385
- if (result) return result;
386
- if (external?.aborted) return { ok: false, provider: null, errorClass: "aborted", attempts, rejected };
387
- return { ok: false, provider: null, errorClass: attempts[attempts.length - 1]?.errorClass ?? "deadline", attempts, rejected };
388
- }
389
- return { generate };
390
- }
391
- function httpOutcome(status) {
392
- return { ok: false, errorClass: `http_${status}`, retryable: true, status };
393
- }
394
- var FREE_SUFFIX = ":free";
395
- function openRouterProvider(config) {
396
- const model = config.model || DEFAULT_TEXT_MODELS.blog.openrouter;
397
- const client = createOpenAI({
398
- apiKey: config.apiKey,
399
- baseURL: config.baseUrl || "https://openrouter.ai/api/v1",
400
- fetch: config.fetch
401
- });
402
- return {
403
- name: config.name ?? "openrouter",
404
- async generate(prompt, options) {
405
- if (!config.allowPaidModels && !model.endsWith(FREE_SUFFIX)) {
406
- return { ok: false, errorClass: "paid_model_blocked", retryable: true };
407
- }
408
- return sdkGenerate(client.chat(model), prompt, options);
409
- }
410
- };
411
- }
412
- async function sdkGenerate(model, prompt, options) {
413
- try {
414
- const result = await generateText({
415
- model,
416
- system: prompt.system,
417
- prompt: prompt.user,
418
- maxOutputTokens: options?.maxTokens ?? 1200,
419
- maxRetries: 0,
420
- abortSignal: options?.signal
421
- });
422
- if (!result.text) return { ok: false, errorClass: "empty_response", retryable: true };
423
- if (result.finishReason === "length") return { ok: false, errorClass: "truncated", retryable: true };
424
- return { ok: true, text: result.text };
425
- } catch (error) {
426
- const status = error?.statusCode ?? error?.status;
427
- if (status) return httpOutcome(status);
428
- return { ok: false, errorClass: options?.signal?.aborted ? "timeout" : "provider_error", retryable: true };
429
- }
430
- }
431
- function openAiCompatibleProvider(config) {
432
- const client = createOpenAI({ apiKey: config.apiKey, baseURL: config.baseUrl, headers: config.headers, fetch: config.fetch });
433
- return {
434
- name: config.name,
435
- generate: (prompt, options) => sdkGenerate(client.chat(config.model), prompt, options)
436
- };
437
- }
438
- function nvidiaProvider(config) {
439
- return openAiCompatibleProvider({
440
- name: "nvidia",
441
- apiKey: config.apiKey,
442
- baseUrl: "https://integrate.api.nvidia.com/v1",
443
- model: config.model || DEFAULT_TEXT_MODELS.blog.nvidia,
444
- fetch: config.fetch
445
- });
446
- }
447
- function anthropicOutcome(data) {
448
- const reply = data;
449
- if (reply.stop_reason === "max_tokens") return { ok: false, errorClass: "truncated", retryable: true };
450
- if (reply.stop_reason === "refusal") return { ok: false, errorClass: "refused", retryable: true };
451
- const text = reply.content?.filter((block) => block.type === "text").map((block) => block.text ?? "").join("") ?? "";
452
- if (!text) return { ok: false, errorClass: "empty_response", retryable: true };
453
- return { ok: true, text };
454
- }
455
- function anthropicProvider(config) {
456
- const fetchImpl = config.fetchImpl ?? fetch;
457
- const base = (config.baseUrl || "https://api.anthropic.com").replace(/\/+$/, "");
458
- return {
459
- name: "anthropic",
460
- async generate(prompt, options) {
461
- try {
462
- const system = options?.jsonMode ? `${prompt.system}
463
-
464
- Reply with one JSON value only, no prose and no code fences.` : prompt.system;
465
- const response = await fetchImpl(`${base}/v1/messages`, {
466
- method: "POST",
467
- headers: { "content-type": "application/json", "x-api-key": config.apiKey, "anthropic-version": "2023-06-01" },
468
- signal: options?.signal,
469
- body: JSON.stringify({
470
- model: config.model,
471
- max_tokens: options?.maxTokens ?? 1800,
472
- system,
473
- messages: [{ role: "user", content: prompt.user }]
474
- })
475
- });
476
- if (!response.ok) return httpOutcome(response.status);
477
- return anthropicOutcome(await response.json());
478
- } catch {
479
- return { ok: false, errorClass: options?.signal?.aborted ? "timeout" : "transport", retryable: true };
480
- }
481
- }
482
- };
483
- }
484
- function geminiOutcome(data) {
485
- const candidate = data.candidates?.[0];
486
- const text = candidate?.content?.parts?.filter((part) => !part.thought).map((part) => part.text ?? "").join("") ?? "";
487
- if (candidate?.finishReason === "MAX_TOKENS") return { ok: false, errorClass: "truncated", retryable: true };
488
- if (!text) return { ok: false, errorClass: "empty_response", retryable: true };
489
- return { ok: true, text };
490
- }
491
- function geminiProvider(config) {
492
- const model = config.model || DEFAULT_TEXT_MODELS.blog.gemini;
493
- const fetchImpl = config.fetchImpl ?? fetch;
494
- return {
495
- name: "gemini",
496
- async generate(prompt, options) {
497
- try {
498
- const maxOutputTokens = options?.maxTokens ?? 2400;
499
- const generationConfig = {
500
- maxOutputTokens,
501
- thinkingConfig: thinkingConfigFor(model, maxOutputTokens),
502
- ...jsonConfig(options?.jsonMode)
503
- };
504
- let response = await fetchImpl(
505
- `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
506
- {
507
- method: "POST",
508
- // Header, not ?key=: query strings end up in proxy and access logs.
509
- headers: { "content-type": "application/json", "x-goog-api-key": config.apiKey },
510
- signal: options?.signal,
511
- body: JSON.stringify({
512
- systemInstruction: { parts: [{ text: prompt.system }] },
513
- contents: [{ role: "user", parts: [{ text: prompt.user }] }],
514
- generationConfig
515
- })
516
- }
517
- );
518
- if (response.status === 503 && !options?.signal?.aborted) {
519
- await new Promise((r) => setTimeout(r, 500));
520
- if (!options?.signal?.aborted) {
521
- response = await fetchImpl(
522
- `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
523
- {
524
- method: "POST",
525
- headers: { "content-type": "application/json", "x-goog-api-key": config.apiKey },
526
- signal: options?.signal,
527
- body: JSON.stringify({
528
- systemInstruction: { parts: [{ text: prompt.system }] },
529
- contents: [{ role: "user", parts: [{ text: prompt.user }] }],
530
- generationConfig
531
- })
532
- }
533
- );
534
- }
535
- }
536
- if (!response.ok) return httpOutcome(response.status);
537
- return geminiOutcome(await response.json());
538
- } catch {
539
- return { ok: false, errorClass: options?.signal?.aborted ? "timeout" : "transport", retryable: true };
540
- }
541
- }
542
- };
543
- }
544
- function vertexProvider(config) {
545
- const model = config.model || DEFAULT_TEXT_MODELS.blog.vertex;
546
- const location = config.location ?? (/^gemini-[3-9]/.test(model) ? "global" : "us-central1");
547
- const host = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`;
548
- const fetchImpl = config.fetchImpl ?? fetch;
549
- return {
550
- name: "vertex",
551
- lastResort: true,
552
- async generate(prompt, options) {
553
- try {
554
- const accessToken = await getVertexAccessToken(
555
- { clientEmail: config.clientEmail, privateKey: config.privateKey, tokenUri: config.tokenUri },
556
- fetchImpl
557
- );
558
- const response = await fetchImpl(
559
- `https://${host}/v1/projects/${config.project}/locations/${location}/publishers/google/models/${model}:generateContent`,
560
- {
561
- method: "POST",
562
- headers: { "content-type": "application/json", authorization: `Bearer ${accessToken}` },
563
- signal: options?.signal,
564
- body: JSON.stringify({
565
- systemInstruction: { parts: [{ text: prompt.system }] },
566
- contents: [{ role: "user", parts: [{ text: prompt.user }] }],
567
- generationConfig: {
568
- maxOutputTokens: options?.maxTokens ?? 1800,
569
- thinkingConfig: thinkingConfigFor(model, options?.maxTokens ?? 1800),
570
- ...jsonConfig(options?.jsonMode)
571
- }
572
- })
573
- }
574
- );
575
- if (!response.ok) return httpOutcome(response.status);
576
- return geminiOutcome(await response.json());
577
- } catch {
578
- return { ok: false, errorClass: "transport", retryable: true };
579
- }
580
- }
581
- };
582
- }
1
+ import {
2
+ AI_COOLDOWN_MS,
3
+ AI_TIMEOUT_MS,
4
+ HEDGE,
5
+ anthropicOutcome,
6
+ anthropicProvider,
7
+ createAiHealth,
8
+ createAiService,
9
+ geminiOutcome,
10
+ geminiProvider,
11
+ nvidiaProvider,
12
+ openAiCompatibleProvider,
13
+ openRouterProvider,
14
+ realProviders,
15
+ sharedAiHealth,
16
+ vertexProvider
17
+ } from "./chunk-R3YY2Y7S.js";
18
+ import "./chunk-BL4J2JNX.js";
19
+ import "./chunk-PMC3UWQ2.js";
20
+ import "./chunk-RBSLNDFW.js";
583
21
  export {
584
22
  AI_COOLDOWN_MS,
585
23
  AI_TIMEOUT_MS,
package/dist/vertex.js CHANGED
@@ -1,46 +1,7 @@
1
- // src/vertex.ts
2
- import "server-only";
3
- var cachedToken = null;
4
- function base64UrlEncode(input) {
5
- return Buffer.from(input).toString("base64url");
6
- }
7
- async function getVertexAccessToken(account, fetchImpl = fetch) {
8
- const now = Date.now();
9
- if (cachedToken && cachedToken.expiresAt - 6e4 > now) return cachedToken.accessToken;
10
- const { createSign } = await import("crypto");
11
- const issuedAt = Math.floor(now / 1e3);
12
- const expiresIn = 3600;
13
- const header = base64UrlEncode(JSON.stringify({ alg: "RS256", typ: "JWT" }));
14
- const claims = base64UrlEncode(
15
- JSON.stringify({
16
- iss: account.clientEmail,
17
- scope: "https://www.googleapis.com/auth/cloud-platform",
18
- aud: account.tokenUri,
19
- iat: issuedAt,
20
- exp: issuedAt + expiresIn
21
- })
22
- );
23
- const signingInput = `${header}.${claims}`;
24
- const signature = createSign("RSA-SHA256").update(signingInput).sign(account.privateKey);
25
- const jwt = `${signingInput}.${base64UrlEncode(signature)}`;
26
- const response = await fetchImpl(account.tokenUri, {
27
- method: "POST",
28
- headers: { "content-type": "application/x-www-form-urlencoded" },
29
- body: new URLSearchParams({
30
- grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
31
- assertion: jwt
32
- })
33
- });
34
- if (!response.ok) {
35
- throw new Error(`Vertex token exchange failed: HTTP ${response.status}`);
36
- }
37
- const data = await response.json();
38
- cachedToken = { accessToken: data.access_token, expiresAt: now + data.expires_in * 1e3 };
39
- return data.access_token;
40
- }
41
- function resetVertexTokenCache() {
42
- cachedToken = null;
43
- }
1
+ import {
2
+ getVertexAccessToken,
3
+ resetVertexTokenCache
4
+ } from "./chunk-BL4J2JNX.js";
44
5
  export {
45
6
  getVertexAccessToken,
46
7
  resetVertexTokenCache
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sahan-sac/ai-core",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Free-by-default AI provider chain with fallback (chain of responsibility over provider adapters: Gemini, OpenRouter, NVIDIA, OpenAI, Anthropic, DeepSeek, xAI, Perplexity, any OpenAI-compatible endpoint, Vertex), env-configurable order, hedging, cooldowns and deadlines, image generation and prompt-injection guards. Shared by @sahan-sac/blog-kit and @sahan-sac/chat-kit.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {