@sahan-sac/ai-core 0.1.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/CHANGELOG.md +7 -0
- package/LICENSE +202 -0
- package/README.md +55 -0
- package/dist/availability.d.ts +13 -0
- package/dist/availability.js +64 -0
- package/dist/env.d.ts +42 -0
- package/dist/env.js +69 -0
- package/dist/guard.d.ts +18 -0
- package/dist/guard.js +0 -0
- package/dist/image.d.ts +71 -0
- package/dist/image.js +246 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +68 -0
- package/dist/log.d.ts +6 -0
- package/dist/log.js +9 -0
- package/dist/models.d.ts +59 -0
- package/dist/models.js +60 -0
- package/dist/providers.d.ts +157 -0
- package/dist/providers.js +436 -0
- package/dist/vertex.d.ts +11 -0
- package/dist/vertex.js +47 -0
- package/package.json +93 -0
|
@@ -0,0 +1,436 @@
|
|
|
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/providers.ts
|
|
95
|
+
function jsonConfig(jsonMode) {
|
|
96
|
+
if (!jsonMode) return {};
|
|
97
|
+
return typeof jsonMode === "object" ? { responseMimeType: "application/json", responseSchema: jsonMode.schema } : { responseMimeType: "application/json" };
|
|
98
|
+
}
|
|
99
|
+
var AI_TIMEOUT_MS = 25e3;
|
|
100
|
+
var AI_COOLDOWN_MS = 6e4;
|
|
101
|
+
async function attempt(provider, prompt, maxTokens, ms, cancel, jsonMode) {
|
|
102
|
+
const controller = new AbortController();
|
|
103
|
+
const onCancel = () => controller.abort();
|
|
104
|
+
cancel.addEventListener("abort", onCancel, { once: true });
|
|
105
|
+
let timer;
|
|
106
|
+
const timeout = new Promise((resolve) => {
|
|
107
|
+
timer = setTimeout(() => {
|
|
108
|
+
controller.abort();
|
|
109
|
+
resolve({ ok: false, errorClass: "timeout", retryable: true });
|
|
110
|
+
}, ms);
|
|
111
|
+
});
|
|
112
|
+
try {
|
|
113
|
+
return await Promise.race([provider.generate(prompt, { maxTokens, signal: controller.signal, jsonMode }), timeout]);
|
|
114
|
+
} catch {
|
|
115
|
+
return { ok: false, errorClass: "transport", retryable: true };
|
|
116
|
+
} finally {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
cancel.removeEventListener("abort", onCancel);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
var createAiHealth = () => ({ cooldownUntil: /* @__PURE__ */ new Map(), latencyMs: /* @__PURE__ */ new Map() });
|
|
122
|
+
var sharedAiHealth = globalThis.sahanAiHealth ??= createAiHealth();
|
|
123
|
+
function cooldownFor(outcome) {
|
|
124
|
+
if (outcome.ok) return 0;
|
|
125
|
+
if (outcome.errorClass === "timeout" || outcome.status === 429) return AI_COOLDOWN_MS;
|
|
126
|
+
if (outcome.status === 401 || outcome.status === 403 || outcome.status === 404) return AI_COOLDOWN_MS * 10;
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
var HEDGE = /* @__PURE__ */ Symbol("hedge");
|
|
130
|
+
function createAiService(deps) {
|
|
131
|
+
const timeoutMs = deps.timeoutMs ?? AI_TIMEOUT_MS;
|
|
132
|
+
const now = deps.now ?? Date.now;
|
|
133
|
+
const health = deps.health ?? createAiHealth();
|
|
134
|
+
const logger = deps.logger ?? consoleAiLogger;
|
|
135
|
+
function rank(at) {
|
|
136
|
+
const cooling = (p) => Number((health.cooldownUntil.get(p.name) ?? 0) > at);
|
|
137
|
+
const speed = (p) => health.latencyMs.get(p.name) ?? Number.POSITIVE_INFINITY;
|
|
138
|
+
const costly = (p) => Number(Boolean(p.lastResort));
|
|
139
|
+
return [...deps.providers].sort((a, b) => costly(a) - costly(b) || cooling(a) - cooling(b) || speed(a) - speed(b));
|
|
140
|
+
}
|
|
141
|
+
async function generate(prompt, options) {
|
|
142
|
+
if (deps.providers.length === 0) {
|
|
143
|
+
return { ok: false, provider: null, errorClass: "no_provider", attempts: [] };
|
|
144
|
+
}
|
|
145
|
+
const startedAll = now();
|
|
146
|
+
const ordered = rank(startedAll);
|
|
147
|
+
const attempts = [];
|
|
148
|
+
const cancelLosers = new AbortController();
|
|
149
|
+
const running = /* @__PURE__ */ new Set();
|
|
150
|
+
let next = 0;
|
|
151
|
+
let stopped = false;
|
|
152
|
+
let result = null;
|
|
153
|
+
let rejected;
|
|
154
|
+
const launch = () => {
|
|
155
|
+
if (result || stopped || next >= ordered.length) return false;
|
|
156
|
+
const remaining = deps.deadlineMs === void 0 ? timeoutMs : deps.deadlineMs - (now() - startedAll);
|
|
157
|
+
if (remaining <= 250) return false;
|
|
158
|
+
const provider = ordered[next++];
|
|
159
|
+
options?.onAttempt?.({ provider: provider.name, stage: "start" });
|
|
160
|
+
const started = now();
|
|
161
|
+
const run = attempt(provider, prompt, options?.maxTokens, Math.min(timeoutMs, remaining), cancelLosers.signal, options?.jsonMode).then((raw) => {
|
|
162
|
+
if (result) return;
|
|
163
|
+
const ms = now() - started;
|
|
164
|
+
let outcome = raw;
|
|
165
|
+
if (outcome.ok && options?.accept) {
|
|
166
|
+
const reason = options.accept(outcome.text);
|
|
167
|
+
if (reason) {
|
|
168
|
+
rejected = { provider: provider.name, text: outcome.text, reason };
|
|
169
|
+
outcome = { ok: false, errorClass: "invalid_output", retryable: true };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (outcome.ok) {
|
|
173
|
+
health.cooldownUntil.delete(provider.name);
|
|
174
|
+
const previous = health.latencyMs.get(provider.name);
|
|
175
|
+
health.latencyMs.set(provider.name, previous === void 0 ? ms : Math.round(previous * 0.7 + ms * 0.3));
|
|
176
|
+
attempts.push({ provider: provider.name, ok: true, ms });
|
|
177
|
+
result = { ok: true, provider: provider.name, text: outcome.text, attempts };
|
|
178
|
+
options?.onAttempt?.({ provider: provider.name, stage: "success" });
|
|
179
|
+
cancelLosers.abort();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
attempts.push({ provider: provider.name, ok: false, errorClass: outcome.errorClass, ms });
|
|
183
|
+
const fallbackTo = next < ordered.length ? ordered[next].name : void 0;
|
|
184
|
+
options?.onAttempt?.({
|
|
185
|
+
provider: provider.name,
|
|
186
|
+
stage: fallbackTo ? "fallback" : "failure",
|
|
187
|
+
errorClass: outcome.errorClass,
|
|
188
|
+
fallbackTo
|
|
189
|
+
});
|
|
190
|
+
const cooldown = cooldownFor(outcome);
|
|
191
|
+
if (cooldown) health.cooldownUntil.set(provider.name, now() + cooldown);
|
|
192
|
+
logger.warn("ai provider failed", {
|
|
193
|
+
provider: provider.name,
|
|
194
|
+
errorClass: outcome.errorClass,
|
|
195
|
+
ms,
|
|
196
|
+
...outcome.errorClass === "invalid_output" && rejected ? { reason: rejected.reason.slice(0, 160) } : {}
|
|
197
|
+
});
|
|
198
|
+
if (!outcome.retryable) stopped = true;
|
|
199
|
+
});
|
|
200
|
+
const tracked = run.finally(() => running.delete(tracked));
|
|
201
|
+
running.add(tracked);
|
|
202
|
+
return true;
|
|
203
|
+
};
|
|
204
|
+
while (!result) {
|
|
205
|
+
if (running.size === 0 && !launch()) break;
|
|
206
|
+
let timer;
|
|
207
|
+
const racers = [...running];
|
|
208
|
+
if (deps.hedgeAfterMs !== void 0 && next < ordered.length) {
|
|
209
|
+
racers.push(new Promise((resolve) => timer = setTimeout(() => resolve(HEDGE), deps.hedgeAfterMs)));
|
|
210
|
+
}
|
|
211
|
+
const first = await Promise.race(racers);
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
if (first === HEDGE) launch();
|
|
214
|
+
}
|
|
215
|
+
return result ?? { ok: false, provider: null, errorClass: attempts[attempts.length - 1]?.errorClass ?? "deadline", attempts, rejected };
|
|
216
|
+
}
|
|
217
|
+
return { generate };
|
|
218
|
+
}
|
|
219
|
+
function httpOutcome(status) {
|
|
220
|
+
return { ok: false, errorClass: `http_${status}`, retryable: true, status };
|
|
221
|
+
}
|
|
222
|
+
var FREE_SUFFIX = ":free";
|
|
223
|
+
function openRouterProvider(config) {
|
|
224
|
+
const model = config.model || DEFAULT_TEXT_MODELS.blog.openrouter;
|
|
225
|
+
const client = createOpenAI({
|
|
226
|
+
apiKey: config.apiKey,
|
|
227
|
+
baseURL: config.baseUrl || "https://openrouter.ai/api/v1",
|
|
228
|
+
fetch: config.fetch
|
|
229
|
+
});
|
|
230
|
+
return {
|
|
231
|
+
name: config.name ?? "openrouter",
|
|
232
|
+
async generate(prompt, options) {
|
|
233
|
+
if (!config.allowPaidModels && !model.endsWith(FREE_SUFFIX)) {
|
|
234
|
+
return { ok: false, errorClass: "paid_model_blocked", retryable: true };
|
|
235
|
+
}
|
|
236
|
+
return sdkGenerate(client.chat(model), prompt, options);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
async function sdkGenerate(model, prompt, options) {
|
|
241
|
+
try {
|
|
242
|
+
const result = await generateText({
|
|
243
|
+
model,
|
|
244
|
+
system: prompt.system,
|
|
245
|
+
prompt: prompt.user,
|
|
246
|
+
maxOutputTokens: options?.maxTokens ?? 1200,
|
|
247
|
+
maxRetries: 0,
|
|
248
|
+
abortSignal: options?.signal
|
|
249
|
+
});
|
|
250
|
+
if (!result.text) return { ok: false, errorClass: "empty_response", retryable: true };
|
|
251
|
+
if (result.finishReason === "length") return { ok: false, errorClass: "truncated", retryable: true };
|
|
252
|
+
return { ok: true, text: result.text };
|
|
253
|
+
} catch (error) {
|
|
254
|
+
const status = error?.statusCode ?? error?.status;
|
|
255
|
+
if (status) return httpOutcome(status);
|
|
256
|
+
return { ok: false, errorClass: options?.signal?.aborted ? "timeout" : "provider_error", retryable: true };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function nvidiaProvider(config) {
|
|
260
|
+
const model = config.model || DEFAULT_TEXT_MODELS.blog.nvidia;
|
|
261
|
+
const client = createOpenAI({
|
|
262
|
+
apiKey: config.apiKey,
|
|
263
|
+
baseURL: "https://integrate.api.nvidia.com/v1",
|
|
264
|
+
fetch: config.fetch
|
|
265
|
+
});
|
|
266
|
+
return {
|
|
267
|
+
name: "nvidia",
|
|
268
|
+
generate: (prompt, options) => sdkGenerate(client.chat(model), prompt, options)
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function geminiOutcome(data) {
|
|
272
|
+
const candidate = data.candidates?.[0];
|
|
273
|
+
const text = candidate?.content?.parts?.filter((part) => !part.thought).map((part) => part.text ?? "").join("") ?? "";
|
|
274
|
+
if (candidate?.finishReason === "MAX_TOKENS") return { ok: false, errorClass: "truncated", retryable: true };
|
|
275
|
+
if (!text) return { ok: false, errorClass: "empty_response", retryable: true };
|
|
276
|
+
return { ok: true, text };
|
|
277
|
+
}
|
|
278
|
+
function geminiProvider(config) {
|
|
279
|
+
const model = config.model || DEFAULT_TEXT_MODELS.blog.gemini;
|
|
280
|
+
const fetchImpl = config.fetchImpl ?? fetch;
|
|
281
|
+
return {
|
|
282
|
+
name: "gemini",
|
|
283
|
+
async generate(prompt, options) {
|
|
284
|
+
try {
|
|
285
|
+
const maxOutputTokens = options?.maxTokens ?? 2400;
|
|
286
|
+
const generationConfig = {
|
|
287
|
+
maxOutputTokens,
|
|
288
|
+
thinkingConfig: thinkingConfigFor(model, maxOutputTokens),
|
|
289
|
+
...jsonConfig(options?.jsonMode)
|
|
290
|
+
};
|
|
291
|
+
let response = await fetchImpl(
|
|
292
|
+
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
|
|
293
|
+
{
|
|
294
|
+
method: "POST",
|
|
295
|
+
// Header, not ?key=: query strings end up in proxy and access logs.
|
|
296
|
+
headers: { "content-type": "application/json", "x-goog-api-key": config.apiKey },
|
|
297
|
+
signal: options?.signal,
|
|
298
|
+
body: JSON.stringify({
|
|
299
|
+
systemInstruction: { parts: [{ text: prompt.system }] },
|
|
300
|
+
contents: [{ role: "user", parts: [{ text: prompt.user }] }],
|
|
301
|
+
generationConfig
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
);
|
|
305
|
+
if (response.status === 503 && !options?.signal?.aborted) {
|
|
306
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
307
|
+
if (!options?.signal?.aborted) {
|
|
308
|
+
response = await fetchImpl(
|
|
309
|
+
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
|
|
310
|
+
{
|
|
311
|
+
method: "POST",
|
|
312
|
+
headers: { "content-type": "application/json", "x-goog-api-key": config.apiKey },
|
|
313
|
+
signal: options?.signal,
|
|
314
|
+
body: JSON.stringify({
|
|
315
|
+
systemInstruction: { parts: [{ text: prompt.system }] },
|
|
316
|
+
contents: [{ role: "user", parts: [{ text: prompt.user }] }],
|
|
317
|
+
generationConfig
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (!response.ok) return httpOutcome(response.status);
|
|
324
|
+
return geminiOutcome(await response.json());
|
|
325
|
+
} catch {
|
|
326
|
+
return { ok: false, errorClass: options?.signal?.aborted ? "timeout" : "transport", retryable: true };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
function vertexProvider(config) {
|
|
332
|
+
const model = config.model || DEFAULT_TEXT_MODELS.blog.vertex;
|
|
333
|
+
const location = config.location ?? (/^gemini-[3-9]/.test(model) ? "global" : "us-central1");
|
|
334
|
+
const host = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`;
|
|
335
|
+
const fetchImpl = config.fetchImpl ?? fetch;
|
|
336
|
+
return {
|
|
337
|
+
name: "vertex",
|
|
338
|
+
lastResort: true,
|
|
339
|
+
async generate(prompt, options) {
|
|
340
|
+
try {
|
|
341
|
+
const accessToken = await getVertexAccessToken(
|
|
342
|
+
{ clientEmail: config.clientEmail, privateKey: config.privateKey, tokenUri: config.tokenUri },
|
|
343
|
+
fetchImpl
|
|
344
|
+
);
|
|
345
|
+
const response = await fetchImpl(
|
|
346
|
+
`https://${host}/v1/projects/${config.project}/locations/${location}/publishers/google/models/${model}:generateContent`,
|
|
347
|
+
{
|
|
348
|
+
method: "POST",
|
|
349
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${accessToken}` },
|
|
350
|
+
signal: options?.signal,
|
|
351
|
+
body: JSON.stringify({
|
|
352
|
+
systemInstruction: { parts: [{ text: prompt.system }] },
|
|
353
|
+
contents: [{ role: "user", parts: [{ text: prompt.user }] }],
|
|
354
|
+
generationConfig: {
|
|
355
|
+
maxOutputTokens: options?.maxTokens ?? 1800,
|
|
356
|
+
thinkingConfig: thinkingConfigFor(model, options?.maxTokens ?? 1800),
|
|
357
|
+
...jsonConfig(options?.jsonMode)
|
|
358
|
+
}
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
);
|
|
362
|
+
if (!response.ok) return httpOutcome(response.status);
|
|
363
|
+
return geminiOutcome(await response.json());
|
|
364
|
+
} catch {
|
|
365
|
+
return { ok: false, errorClass: "transport", retryable: true };
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
function realProviders(env, purpose, fetchImpl) {
|
|
371
|
+
const providers = [];
|
|
372
|
+
const models = textModels(env, purpose);
|
|
373
|
+
if (env.GEMINI_API_KEY) {
|
|
374
|
+
providers.push(geminiProvider({ apiKey: env.GEMINI_API_KEY, model: models.gemini, fetchImpl }));
|
|
375
|
+
}
|
|
376
|
+
const openRouterModel = models.openrouter;
|
|
377
|
+
if (env.OPENROUTER_API_KEY) {
|
|
378
|
+
providers.push(
|
|
379
|
+
openRouterProvider({
|
|
380
|
+
apiKey: env.OPENROUTER_API_KEY,
|
|
381
|
+
model: openRouterModel,
|
|
382
|
+
baseUrl: env.OPENROUTER_BASE_URL,
|
|
383
|
+
allowPaidModels: env.OPENROUTER_ALLOW_PAID_MODELS,
|
|
384
|
+
fetch: fetchImpl
|
|
385
|
+
})
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
if (env.OPENROUTER_API_KEY_2) {
|
|
389
|
+
providers.push(
|
|
390
|
+
openRouterProvider({
|
|
391
|
+
apiKey: env.OPENROUTER_API_KEY_2,
|
|
392
|
+
model: openRouterModel,
|
|
393
|
+
baseUrl: env.OPENROUTER_BASE_URL,
|
|
394
|
+
allowPaidModels: env.OPENROUTER_ALLOW_PAID_MODELS,
|
|
395
|
+
fetch: fetchImpl,
|
|
396
|
+
name: "openrouter-2"
|
|
397
|
+
})
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
if (env.NVIDIA_API_KEY) {
|
|
401
|
+
providers.push(
|
|
402
|
+
nvidiaProvider({
|
|
403
|
+
apiKey: env.NVIDIA_API_KEY,
|
|
404
|
+
model: models.nvidia,
|
|
405
|
+
fetch: fetchImpl
|
|
406
|
+
})
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
if (paidAllowed(env) && vertexConfigured(env)) {
|
|
410
|
+
providers.push(
|
|
411
|
+
vertexProvider({
|
|
412
|
+
clientEmail: env.GOOGLE_CLIENT_EMAIL,
|
|
413
|
+
privateKey: env.GOOGLE_PRIVATE_KEY,
|
|
414
|
+
tokenUri: env.GOOGLE_TOKEN_URI,
|
|
415
|
+
project: env.GOOGLE_CLOUD_PROJECT,
|
|
416
|
+
model: models.vertex,
|
|
417
|
+
fetchImpl
|
|
418
|
+
})
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
return providers;
|
|
422
|
+
}
|
|
423
|
+
export {
|
|
424
|
+
AI_COOLDOWN_MS,
|
|
425
|
+
AI_TIMEOUT_MS,
|
|
426
|
+
HEDGE,
|
|
427
|
+
createAiHealth,
|
|
428
|
+
createAiService,
|
|
429
|
+
geminiOutcome,
|
|
430
|
+
geminiProvider,
|
|
431
|
+
nvidiaProvider,
|
|
432
|
+
openRouterProvider,
|
|
433
|
+
realProviders,
|
|
434
|
+
sharedAiHealth,
|
|
435
|
+
vertexProvider
|
|
436
|
+
};
|
package/dist/vertex.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
interface VertexServiceAccount {
|
|
2
|
+
clientEmail: string;
|
|
3
|
+
privateKey: string;
|
|
4
|
+
tokenUri: string;
|
|
5
|
+
}
|
|
6
|
+
/** Signs a Google OAuth2 service-account JWT and exchanges it for an access token. */
|
|
7
|
+
declare function getVertexAccessToken(account: VertexServiceAccount, fetchImpl?: typeof fetch): Promise<string>;
|
|
8
|
+
/** Test helper: forget the cached token. */
|
|
9
|
+
declare function resetVertexTokenCache(): void;
|
|
10
|
+
|
|
11
|
+
export { type VertexServiceAccount, getVertexAccessToken, resetVertexTokenCache };
|
package/dist/vertex.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
}
|
|
44
|
+
export {
|
|
45
|
+
getVertexAccessToken,
|
|
46
|
+
resetVertexTokenCache
|
|
47
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sahan-sac/ai-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Free-by-default AI provider chain (Gemini, OpenRouter, NVIDIA, Vertex) with hedging, cooldowns and deadlines, env-driven model resolution, image generation and prompt-injection guards. Shared by @sahan-sac/blog-kit and @sahan-sac/chat-kit.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/ks-official-Sahan/Sahan.git",
|
|
9
|
+
"directory": "packages/ai-core"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"ai",
|
|
13
|
+
"llm",
|
|
14
|
+
"gemini",
|
|
15
|
+
"openrouter",
|
|
16
|
+
"nvidia",
|
|
17
|
+
"vertex",
|
|
18
|
+
"flux"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"import": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./env": {
|
|
31
|
+
"types": "./dist/env.d.ts",
|
|
32
|
+
"import": "./dist/env.js"
|
|
33
|
+
},
|
|
34
|
+
"./models": {
|
|
35
|
+
"types": "./dist/models.d.ts",
|
|
36
|
+
"import": "./dist/models.js"
|
|
37
|
+
},
|
|
38
|
+
"./providers": {
|
|
39
|
+
"types": "./dist/providers.d.ts",
|
|
40
|
+
"import": "./dist/providers.js"
|
|
41
|
+
},
|
|
42
|
+
"./vertex": {
|
|
43
|
+
"types": "./dist/vertex.d.ts",
|
|
44
|
+
"import": "./dist/vertex.js"
|
|
45
|
+
},
|
|
46
|
+
"./image": {
|
|
47
|
+
"types": "./dist/image.d.ts",
|
|
48
|
+
"import": "./dist/image.js"
|
|
49
|
+
},
|
|
50
|
+
"./guard": {
|
|
51
|
+
"types": "./dist/guard.d.ts",
|
|
52
|
+
"import": "./dist/guard.js"
|
|
53
|
+
},
|
|
54
|
+
"./availability": {
|
|
55
|
+
"types": "./dist/availability.d.ts",
|
|
56
|
+
"import": "./dist/availability.js"
|
|
57
|
+
},
|
|
58
|
+
"./log": {
|
|
59
|
+
"types": "./dist/log.d.ts",
|
|
60
|
+
"import": "./dist/log.js"
|
|
61
|
+
},
|
|
62
|
+
"./package.json": "./package.json"
|
|
63
|
+
},
|
|
64
|
+
"types": "./dist/index.d.ts",
|
|
65
|
+
"main": "./dist/index.js",
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"@ai-sdk/openai": "^4.0.71",
|
|
68
|
+
"ai": "^7.0.107",
|
|
69
|
+
"server-only": "^0.0.1",
|
|
70
|
+
"zod": "^4.6.5"
|
|
71
|
+
},
|
|
72
|
+
"devDependencies": {
|
|
73
|
+
"@types/node": "^22",
|
|
74
|
+
"tsup": "^8.3.5",
|
|
75
|
+
"tsx": "^4.23.15",
|
|
76
|
+
"typescript": "^5.6.3"
|
|
77
|
+
},
|
|
78
|
+
"files": [
|
|
79
|
+
"dist",
|
|
80
|
+
"README.md",
|
|
81
|
+
"CHANGELOG.md",
|
|
82
|
+
"LICENSE"
|
|
83
|
+
],
|
|
84
|
+
"publishConfig": {
|
|
85
|
+
"access": "public",
|
|
86
|
+
"registry": "https://registry.npmjs.org/"
|
|
87
|
+
},
|
|
88
|
+
"scripts": {
|
|
89
|
+
"build": "tsup",
|
|
90
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json",
|
|
91
|
+
"test": "node --conditions=react-server --import tsx --test \"src/**/*.test.ts\""
|
|
92
|
+
}
|
|
93
|
+
}
|