@sahan-sac/ai-core 0.1.0 → 0.3.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.
@@ -0,0 +1,621 @@
1
+ // src/models.ts
2
+ var DEFAULT_TEXT_MODELS = {
3
+ // Long structured JSON: a full flash model, not lite.
4
+ blog: {
5
+ gemini: "gemini-2.5-flash",
6
+ openrouter: "nvidia/nemotron-3-super-120b-a12b:free",
7
+ nvidia: "nvidia/nemotron-3-super-120b-a12b",
8
+ vertex: "gemini-2.5-flash"
9
+ },
10
+ // Short, latency-sensitive replies: the fastest free model first.
11
+ chat: {
12
+ gemini: "gemini-3.1-flash-lite",
13
+ openrouter: "nvidia/nemotron-3-super-120b-a12b:free",
14
+ nvidia: "nvidia/nemotron-3-super-120b-a12b",
15
+ vertex: "gemini-2.5-flash"
16
+ }
17
+ };
18
+ var pick = (...values) => values.find((value) => value && value.trim()).trim();
19
+ function textModels(env, purpose) {
20
+ const defaults = DEFAULT_TEXT_MODELS[purpose];
21
+ 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 };
22
+ return {
23
+ gemini: pick(own.gemini, env.GEMINI_MODEL, defaults.gemini),
24
+ openrouter: pick(own.openrouter, env.OPENROUTER_MODEL, defaults.openrouter),
25
+ nvidia: pick(own.nvidia, env.NVIDIA_MODEL, defaults.nvidia),
26
+ vertex: pick(own.vertex, env.VERTEX_MODEL, defaults.vertex)
27
+ };
28
+ }
29
+ var paidAllowed = (env) => env.AI_ALLOW_PAID === true;
30
+ function vertexConfigured(env) {
31
+ return Boolean(env.GOOGLE_CLIENT_EMAIL && env.GOOGLE_PRIVATE_KEY && env.GOOGLE_CLOUD_PROJECT);
32
+ }
33
+ function thinkingConfigFor(model, maxOutputTokens) {
34
+ const long = maxOutputTokens >= 2e3;
35
+ if (/^gemini-[3-9]/.test(model)) {
36
+ return { thinkingLevel: long ? "low" : /flash/.test(model) ? "minimal" : "low" };
37
+ }
38
+ return { thinkingBudget: long ? Math.min(800, Math.floor(maxOutputTokens / 5)) : 0 };
39
+ }
40
+
41
+ // src/providers.ts
42
+ import "server-only";
43
+ import { createOpenAI } from "@ai-sdk/openai";
44
+ import { generateText } from "ai";
45
+
46
+ // src/log.ts
47
+ var consoleAiLogger = {
48
+ warn(message, meta) {
49
+ console.warn(JSON.stringify({ level: "warn", message, ...meta }));
50
+ }
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
+ const chosen = (a, b) => a.priority !== void 0 && b.priority !== void 0 ? a.priority - b.priority : speed(a) - speed(b);
140
+ return [...deps.providers].sort((a, b) => costly(a) - costly(b) || cooling(a) - cooling(b) || chosen(a, b));
141
+ }
142
+ async function generate(prompt, options) {
143
+ if (deps.providers.length === 0) {
144
+ return { ok: false, provider: null, errorClass: "no_provider", attempts: [] };
145
+ }
146
+ const external = options?.signal;
147
+ if (external?.aborted) return { ok: false, provider: null, errorClass: "aborted", attempts: [] };
148
+ const startedAll = now();
149
+ const ordered = rank(startedAll);
150
+ const attempts = [];
151
+ const cancelLosers = new AbortController();
152
+ const running = /* @__PURE__ */ new Set();
153
+ let next = 0;
154
+ let stopped = false;
155
+ let result = null;
156
+ let rejected;
157
+ let onAbort;
158
+ const aborted = new Promise((resolve) => {
159
+ onAbort = () => {
160
+ stopped = true;
161
+ cancelLosers.abort();
162
+ resolve();
163
+ };
164
+ external?.addEventListener("abort", onAbort, { once: true });
165
+ });
166
+ const launch = () => {
167
+ if (result || stopped || next >= ordered.length) return false;
168
+ const remaining = deps.deadlineMs === void 0 ? timeoutMs : deps.deadlineMs - (now() - startedAll);
169
+ if (remaining <= 250) return false;
170
+ const provider = ordered[next++];
171
+ options?.onAttempt?.({ provider: provider.name, stage: "start" });
172
+ const started = now();
173
+ const run = attempt(provider, prompt, options?.maxTokens, Math.min(timeoutMs, remaining), cancelLosers.signal, options?.jsonMode).then((raw) => {
174
+ if (result || external?.aborted) return;
175
+ const ms = now() - started;
176
+ let outcome = raw;
177
+ if (outcome.ok && options?.accept) {
178
+ const reason = options.accept(outcome.text);
179
+ if (reason) {
180
+ rejected = { provider: provider.name, text: outcome.text, reason };
181
+ outcome = { ok: false, errorClass: "invalid_output", retryable: true };
182
+ }
183
+ }
184
+ if (outcome.ok) {
185
+ health.cooldownUntil.delete(provider.name);
186
+ const previous = health.latencyMs.get(provider.name);
187
+ health.latencyMs.set(provider.name, previous === void 0 ? ms : Math.round(previous * 0.7 + ms * 0.3));
188
+ attempts.push({ provider: provider.name, ok: true, ms });
189
+ result = { ok: true, provider: provider.name, text: outcome.text, attempts };
190
+ options?.onAttempt?.({ provider: provider.name, stage: "success" });
191
+ cancelLosers.abort();
192
+ return;
193
+ }
194
+ attempts.push({ provider: provider.name, ok: false, errorClass: outcome.errorClass, ms });
195
+ const fallbackTo = next < ordered.length ? ordered[next].name : void 0;
196
+ options?.onAttempt?.({
197
+ provider: provider.name,
198
+ stage: fallbackTo ? "fallback" : "failure",
199
+ errorClass: outcome.errorClass,
200
+ fallbackTo
201
+ });
202
+ const cooldown = cooldownFor(outcome);
203
+ if (cooldown) health.cooldownUntil.set(provider.name, now() + cooldown);
204
+ logger.warn("ai provider failed", {
205
+ provider: provider.name,
206
+ errorClass: outcome.errorClass,
207
+ ms,
208
+ ...outcome.errorClass === "invalid_output" && rejected ? { reason: rejected.reason.slice(0, 160) } : {}
209
+ });
210
+ if (!outcome.retryable) stopped = true;
211
+ });
212
+ const tracked = run.finally(() => running.delete(tracked));
213
+ running.add(tracked);
214
+ return true;
215
+ };
216
+ try {
217
+ while (!result && !external?.aborted) {
218
+ if (running.size === 0 && !launch()) break;
219
+ let timer;
220
+ const racers = [...running, aborted];
221
+ if (deps.hedgeAfterMs !== void 0 && next < ordered.length) {
222
+ racers.push(new Promise((resolve) => timer = setTimeout(() => resolve(HEDGE), deps.hedgeAfterMs)));
223
+ }
224
+ const first = await Promise.race(racers);
225
+ clearTimeout(timer);
226
+ if (first === HEDGE) launch();
227
+ }
228
+ } finally {
229
+ if (onAbort) external?.removeEventListener("abort", onAbort);
230
+ }
231
+ if (result) return result;
232
+ if (external?.aborted) return { ok: false, provider: null, errorClass: "aborted", attempts, rejected };
233
+ return { ok: false, provider: null, errorClass: attempts[attempts.length - 1]?.errorClass ?? "deadline", attempts, rejected };
234
+ }
235
+ return { generate };
236
+ }
237
+ function httpOutcome(status) {
238
+ return { ok: false, errorClass: `http_${status}`, retryable: true, status };
239
+ }
240
+ var FREE_SUFFIX = ":free";
241
+ function openRouterProvider(config) {
242
+ const model = config.model || DEFAULT_TEXT_MODELS.blog.openrouter;
243
+ const client = createOpenAI({
244
+ apiKey: config.apiKey,
245
+ baseURL: config.baseUrl || "https://openrouter.ai/api/v1",
246
+ fetch: config.fetch
247
+ });
248
+ return {
249
+ name: config.name ?? "openrouter",
250
+ async generate(prompt, options) {
251
+ if (!config.allowPaidModels && !model.endsWith(FREE_SUFFIX)) {
252
+ return { ok: false, errorClass: "paid_model_blocked", retryable: true };
253
+ }
254
+ return sdkGenerate(client.chat(model), prompt, options);
255
+ }
256
+ };
257
+ }
258
+ async function sdkGenerate(model, prompt, options) {
259
+ try {
260
+ const result = await generateText({
261
+ model,
262
+ system: prompt.system,
263
+ prompt: prompt.user,
264
+ maxOutputTokens: options?.maxTokens ?? 1200,
265
+ maxRetries: 0,
266
+ abortSignal: options?.signal
267
+ });
268
+ if (!result.text) return { ok: false, errorClass: "empty_response", retryable: true };
269
+ if (result.finishReason === "length") return { ok: false, errorClass: "truncated", retryable: true };
270
+ return { ok: true, text: result.text };
271
+ } catch (error) {
272
+ const status = error?.statusCode ?? error?.status;
273
+ if (status) return httpOutcome(status);
274
+ return { ok: false, errorClass: options?.signal?.aborted ? "timeout" : "provider_error", retryable: true };
275
+ }
276
+ }
277
+ function openAiCompatibleProvider(config) {
278
+ const client = createOpenAI({ apiKey: config.apiKey, baseURL: config.baseUrl, headers: config.headers, fetch: config.fetch });
279
+ return {
280
+ name: config.name,
281
+ generate: (prompt, options) => sdkGenerate(client.chat(config.model), prompt, options)
282
+ };
283
+ }
284
+ function anthropicOutcome(data) {
285
+ const reply = data;
286
+ if (reply.stop_reason === "max_tokens") return { ok: false, errorClass: "truncated", retryable: true };
287
+ if (reply.stop_reason === "refusal") return { ok: false, errorClass: "refused", retryable: true };
288
+ const text = reply.content?.filter((block) => block.type === "text").map((block) => block.text ?? "").join("") ?? "";
289
+ if (!text) return { ok: false, errorClass: "empty_response", retryable: true };
290
+ return { ok: true, text };
291
+ }
292
+ function anthropicProvider(config) {
293
+ const fetchImpl = config.fetchImpl ?? fetch;
294
+ const base = (config.baseUrl || "https://api.anthropic.com").replace(/\/+$/, "");
295
+ return {
296
+ name: "anthropic",
297
+ async generate(prompt, options) {
298
+ try {
299
+ const system = options?.jsonMode ? `${prompt.system}
300
+
301
+ Reply with one JSON value only, no prose and no code fences.` : prompt.system;
302
+ const response = await fetchImpl(`${base}/v1/messages`, {
303
+ method: "POST",
304
+ headers: { "content-type": "application/json", "x-api-key": config.apiKey, "anthropic-version": "2023-06-01" },
305
+ signal: options?.signal,
306
+ body: JSON.stringify({
307
+ model: config.model,
308
+ max_tokens: options?.maxTokens ?? 1800,
309
+ system,
310
+ messages: [{ role: "user", content: prompt.user }]
311
+ })
312
+ });
313
+ if (!response.ok) return httpOutcome(response.status);
314
+ return anthropicOutcome(await response.json());
315
+ } catch {
316
+ return { ok: false, errorClass: options?.signal?.aborted ? "timeout" : "transport", retryable: true };
317
+ }
318
+ }
319
+ };
320
+ }
321
+ function geminiOutcome(data) {
322
+ const candidate = data.candidates?.[0];
323
+ const text = candidate?.content?.parts?.filter((part) => !part.thought).map((part) => part.text ?? "").join("") ?? "";
324
+ if (candidate?.finishReason === "MAX_TOKENS") return { ok: false, errorClass: "truncated", retryable: true };
325
+ if (!text) return { ok: false, errorClass: "empty_response", retryable: true };
326
+ return { ok: true, text };
327
+ }
328
+ function geminiProvider(config) {
329
+ const model = config.model || DEFAULT_TEXT_MODELS.blog.gemini;
330
+ const fetchImpl = config.fetchImpl ?? fetch;
331
+ return {
332
+ name: "gemini",
333
+ async generate(prompt, options) {
334
+ try {
335
+ const maxOutputTokens = options?.maxTokens ?? 2400;
336
+ const generationConfig = {
337
+ maxOutputTokens,
338
+ thinkingConfig: thinkingConfigFor(model, maxOutputTokens),
339
+ ...jsonConfig(options?.jsonMode)
340
+ };
341
+ let response = await fetchImpl(
342
+ `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
343
+ {
344
+ method: "POST",
345
+ // Header, not ?key=: query strings end up in proxy and access logs.
346
+ headers: { "content-type": "application/json", "x-goog-api-key": config.apiKey },
347
+ signal: options?.signal,
348
+ body: JSON.stringify({
349
+ systemInstruction: { parts: [{ text: prompt.system }] },
350
+ contents: [{ role: "user", parts: [{ text: prompt.user }] }],
351
+ generationConfig
352
+ })
353
+ }
354
+ );
355
+ if (response.status === 503 && !options?.signal?.aborted) {
356
+ await new Promise((r) => setTimeout(r, 500));
357
+ if (!options?.signal?.aborted) {
358
+ response = await fetchImpl(
359
+ `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
360
+ {
361
+ method: "POST",
362
+ headers: { "content-type": "application/json", "x-goog-api-key": config.apiKey },
363
+ signal: options?.signal,
364
+ body: JSON.stringify({
365
+ systemInstruction: { parts: [{ text: prompt.system }] },
366
+ contents: [{ role: "user", parts: [{ text: prompt.user }] }],
367
+ generationConfig
368
+ })
369
+ }
370
+ );
371
+ }
372
+ }
373
+ if (!response.ok) return httpOutcome(response.status);
374
+ return geminiOutcome(await response.json());
375
+ } catch {
376
+ return { ok: false, errorClass: options?.signal?.aborted ? "timeout" : "transport", retryable: true };
377
+ }
378
+ }
379
+ };
380
+ }
381
+ function vertexProvider(config) {
382
+ const model = config.model || DEFAULT_TEXT_MODELS.blog.vertex;
383
+ const location = config.location ?? (/^gemini-[3-9]/.test(model) ? "global" : "us-central1");
384
+ const host = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`;
385
+ const fetchImpl = config.fetchImpl ?? fetch;
386
+ return {
387
+ name: "vertex",
388
+ lastResort: true,
389
+ async generate(prompt, options) {
390
+ try {
391
+ const accessToken = await getVertexAccessToken(
392
+ { clientEmail: config.clientEmail, privateKey: config.privateKey, tokenUri: config.tokenUri },
393
+ fetchImpl
394
+ );
395
+ const response = await fetchImpl(
396
+ `https://${host}/v1/projects/${config.project}/locations/${location}/publishers/google/models/${model}:generateContent`,
397
+ {
398
+ method: "POST",
399
+ headers: { "content-type": "application/json", authorization: `Bearer ${accessToken}` },
400
+ signal: options?.signal,
401
+ body: JSON.stringify({
402
+ systemInstruction: { parts: [{ text: prompt.system }] },
403
+ contents: [{ role: "user", parts: [{ text: prompt.user }] }],
404
+ generationConfig: {
405
+ maxOutputTokens: options?.maxTokens ?? 1800,
406
+ thinkingConfig: thinkingConfigFor(model, options?.maxTokens ?? 1800),
407
+ ...jsonConfig(options?.jsonMode)
408
+ }
409
+ })
410
+ }
411
+ );
412
+ if (!response.ok) return httpOutcome(response.status);
413
+ return geminiOutcome(await response.json());
414
+ } catch {
415
+ return { ok: false, errorClass: "transport", retryable: true };
416
+ }
417
+ }
418
+ };
419
+ }
420
+
421
+ // src/adapters.ts
422
+ function modelFor(env, provider, purpose, fallback) {
423
+ const record = env;
424
+ return record[`${purpose.toUpperCase()}_${provider}_MODEL`] || record[`${provider}_MODEL`] || fallback[purpose];
425
+ }
426
+ var need = (env, ...names) => names.filter((name) => !env[name]);
427
+ var PAID_TEXT_MODELS = {
428
+ openai: { blog: "gpt-5-mini", chat: "gpt-5-nano" },
429
+ anthropic: { blog: "claude-sonnet-5-5", chat: "claude-haiku-4-5-20251001" },
430
+ deepseek: { blog: "deepseek-chat", chat: "deepseek-chat" },
431
+ xai: { blog: "grok-3-mini", chat: "grok-3-mini" },
432
+ perplexity: { blog: "sonar", chat: "sonar" }
433
+ };
434
+ function compatibleAdapter(spec) {
435
+ return {
436
+ id: spec.id,
437
+ label: spec.label,
438
+ paid: () => true,
439
+ missing: (env) => need(env, spec.keyVar),
440
+ create: ({ env, purpose, fetch: fetch2 }) => openAiCompatibleProvider({
441
+ name: spec.id,
442
+ apiKey: env[spec.keyVar],
443
+ baseUrl: spec.baseUrlVar && env[spec.baseUrlVar] || spec.baseUrl,
444
+ model: modelFor(env, spec.envPrefix, purpose, PAID_TEXT_MODELS[spec.id]),
445
+ fetch: fetch2
446
+ })
447
+ };
448
+ }
449
+ var openRouterAdapter = (id, keyVar) => ({
450
+ id,
451
+ label: id === "openrouter" ? "OpenRouter" : "OpenRouter (second key)",
452
+ // Free models only, unless OPENROUTER_ALLOW_PAID_MODELS: the adapter itself refuses a paid model id.
453
+ paid: () => false,
454
+ missing: (env) => need(env, keyVar),
455
+ create: ({ env, purpose, fetch: fetch2 }) => openRouterProvider({
456
+ apiKey: env[keyVar],
457
+ model: textModels(env, purpose).openrouter,
458
+ baseUrl: env.OPENROUTER_BASE_URL,
459
+ allowPaidModels: env.OPENROUTER_ALLOW_PAID_MODELS,
460
+ fetch: fetch2,
461
+ name: id
462
+ })
463
+ });
464
+ var BUILTIN_ADAPTERS = [
465
+ {
466
+ id: "gemini",
467
+ label: "Gemini",
468
+ paid: () => false,
469
+ missing: (env) => need(env, "GEMINI_API_KEY"),
470
+ create: ({ env, purpose, fetch: fetch2 }) => geminiProvider({ apiKey: env.GEMINI_API_KEY, model: textModels(env, purpose).gemini, fetchImpl: fetch2 })
471
+ },
472
+ openRouterAdapter("openrouter", "OPENROUTER_API_KEY"),
473
+ openRouterAdapter("openrouter-2", "OPENROUTER_API_KEY_2"),
474
+ {
475
+ id: "nvidia",
476
+ label: "NVIDIA",
477
+ paid: () => false,
478
+ missing: (env) => need(env, "NVIDIA_API_KEY"),
479
+ create: ({ env, purpose, fetch: fetch2 }) => openAiCompatibleProvider({
480
+ name: "nvidia",
481
+ apiKey: env.NVIDIA_API_KEY,
482
+ baseUrl: "https://integrate.api.nvidia.com/v1",
483
+ model: textModels(env, purpose).nvidia,
484
+ fetch: fetch2
485
+ })
486
+ },
487
+ compatibleAdapter({ id: "openai", label: "OpenAI", envPrefix: "OPENAI", keyVar: "OPENAI_API_KEY", baseUrl: "https://api.openai.com/v1", baseUrlVar: "OPENAI_BASE_URL" }),
488
+ {
489
+ id: "anthropic",
490
+ label: "Anthropic (Claude)",
491
+ paid: () => true,
492
+ missing: (env) => need(env, "ANTHROPIC_API_KEY"),
493
+ create: ({ env, purpose, fetch: fetch2 }) => anthropicProvider({
494
+ apiKey: env.ANTHROPIC_API_KEY,
495
+ baseUrl: env.ANTHROPIC_BASE_URL,
496
+ model: modelFor(env, "ANTHROPIC", purpose, PAID_TEXT_MODELS.anthropic),
497
+ fetchImpl: fetch2
498
+ })
499
+ },
500
+ compatibleAdapter({ id: "deepseek", label: "DeepSeek", envPrefix: "DEEPSEEK", keyVar: "DEEPSEEK_API_KEY", baseUrl: "https://api.deepseek.com/v1" }),
501
+ compatibleAdapter({ id: "xai", label: "xAI (Grok)", envPrefix: "XAI", keyVar: "XAI_API_KEY", baseUrl: "https://api.x.ai/v1" }),
502
+ compatibleAdapter({ id: "perplexity", label: "Perplexity", envPrefix: "PERPLEXITY", keyVar: "PERPLEXITY_API_KEY", baseUrl: "https://api.perplexity.ai" }),
503
+ {
504
+ id: "custom",
505
+ label: "Custom OpenAI-compatible",
506
+ paid: (env) => !env.AI_CUSTOM_FREE,
507
+ missing: (env) => need(env, "AI_CUSTOM_BASE_URL", "AI_CUSTOM_MODEL"),
508
+ create: ({ env, fetch: fetch2 }) => openAiCompatibleProvider({
509
+ name: env.AI_CUSTOM_NAME ? `custom:${env.AI_CUSTOM_NAME}` : "custom",
510
+ // A local server may take no key; the header must still be well formed.
511
+ apiKey: env.AI_CUSTOM_API_KEY ?? "none",
512
+ baseUrl: env.AI_CUSTOM_BASE_URL,
513
+ model: env.AI_CUSTOM_MODEL,
514
+ fetch: fetch2
515
+ })
516
+ },
517
+ {
518
+ id: "vertex",
519
+ label: "Vertex AI",
520
+ paid: () => true,
521
+ missing: (env) => vertexConfigured(env) ? [] : need(env, "GOOGLE_CLIENT_EMAIL", "GOOGLE_PRIVATE_KEY", "GOOGLE_CLOUD_PROJECT"),
522
+ create: ({ env, purpose, fetch: fetch2 }) => vertexProvider({
523
+ clientEmail: env.GOOGLE_CLIENT_EMAIL,
524
+ privateKey: env.GOOGLE_PRIVATE_KEY,
525
+ tokenUri: env.GOOGLE_TOKEN_URI,
526
+ project: env.GOOGLE_CLOUD_PROJECT,
527
+ model: textModels(env, purpose).vertex,
528
+ fetchImpl: fetch2
529
+ })
530
+ }
531
+ ];
532
+ var DEFAULT_ORDER = BUILTIN_ADAPTERS.map((adapter) => adapter.id);
533
+ function configuredOrder(env, purpose) {
534
+ const raw = (purpose === "blog" ? env.AI_PROVIDER_ORDER_BLOG : env.AI_PROVIDER_ORDER_CHAT) ?? env.AI_PROVIDER_ORDER;
535
+ if (!raw) return null;
536
+ const ids = raw.split(",").map((id) => id.trim().toLowerCase()).filter(Boolean);
537
+ return ids.length ? [...new Set(ids)] : null;
538
+ }
539
+ function providerStatuses(env, purpose, adapters = BUILTIN_ADAPTERS) {
540
+ const order = configuredOrder(env, purpose);
541
+ const ids = order ?? adapters.map((adapter) => adapter.id);
542
+ const byId = new Map(adapters.map((adapter) => [adapter.id, adapter]));
543
+ let position = 0;
544
+ const statuses = [];
545
+ for (const id of ids) {
546
+ const adapter = byId.get(id);
547
+ if (!adapter) {
548
+ statuses.push({ id, label: id, paid: false, state: "unknown", missing: [] });
549
+ continue;
550
+ }
551
+ const missing = adapter.missing(env);
552
+ const paid = adapter.paid(env);
553
+ const state = missing.length ? "not_configured" : paid && !paidAllowed(env) ? "needs_paid" : "active";
554
+ statuses.push({ id, label: adapter.label, paid, state, missing, ...state === "active" ? { position: position++ } : {} });
555
+ }
556
+ for (const adapter of adapters) {
557
+ if (!ids.includes(adapter.id)) {
558
+ statuses.push({ id: adapter.id, label: adapter.label, paid: adapter.paid(env), state: "not_in_order", missing: adapter.missing(env) });
559
+ }
560
+ }
561
+ return statuses;
562
+ }
563
+ function chainPlan(env, purpose, adapters = BUILTIN_ADAPTERS) {
564
+ const byId = new Map(adapters.map((adapter) => [adapter.id, adapter]));
565
+ return providerStatuses(env, purpose, adapters).filter((status) => status.state === "active").map((status) => byId.get(status.id));
566
+ }
567
+ var CHECK_PROMPT = { system: "You are a connectivity check. Reply with the single word OK.", user: "Reply with OK." };
568
+ async function checkProvider(env, id, options = {}) {
569
+ const now = options.now ?? Date.now;
570
+ const adapter = (options.adapters ?? BUILTIN_ADAPTERS).find((candidate) => candidate.id === id);
571
+ if (!adapter) return { id, ok: false, errorClass: "unknown_provider", ms: 0 };
572
+ if (adapter.missing(env).length) return { id, ok: false, errorClass: "not_configured", ms: 0 };
573
+ const started = now();
574
+ try {
575
+ const provider = adapter.create({ env, purpose: options.purpose ?? "chat", fetch: options.fetch });
576
+ const outcome = await provider.generate(CHECK_PROMPT, { maxTokens: 32, signal: AbortSignal.timeout(options.timeoutMs ?? 2e4) });
577
+ return outcome.ok ? { id, ok: true, provider: provider.name, ms: now() - started } : { id, ok: false, provider: provider.name, errorClass: outcome.errorClass, ms: now() - started };
578
+ } catch {
579
+ return { id, ok: false, errorClass: "transport", ms: now() - started };
580
+ }
581
+ }
582
+ async function checkChain(env, purpose, options = {}) {
583
+ const now = options.now ?? Date.now;
584
+ const id = `chain:${purpose}`;
585
+ const providers = realProviders(env, purpose, options.fetch, options.adapters);
586
+ if (providers.length === 0) return { id, ok: false, errorClass: "no_provider", ms: 0, attempts: [] };
587
+ const started = now();
588
+ const result = await createAiService({
589
+ providers,
590
+ timeoutMs: 15e3,
591
+ deadlineMs: options.deadlineMs ?? 4e4,
592
+ logger: { warn: () => void 0 }
593
+ }).generate(CHECK_PROMPT, { maxTokens: 32 });
594
+ return {
595
+ id,
596
+ ok: result.ok,
597
+ ...result.provider ? { provider: result.provider } : {},
598
+ ...result.ok ? {} : { errorClass: result.errorClass },
599
+ ms: now() - started,
600
+ attempts: result.attempts.map(({ provider, ok, errorClass }) => ({ provider, ok, ...errorClass ? { errorClass } : {} }))
601
+ };
602
+ }
603
+ function realProviders(env, purpose, fetchImpl, adapters = BUILTIN_ADAPTERS) {
604
+ const explicit = configuredOrder(env, purpose) !== null;
605
+ return chainPlan(env, purpose, adapters).map((adapter, index) => {
606
+ const provider = adapter.create({ env, purpose, fetch: fetchImpl });
607
+ return explicit ? { name: provider.name, generate: provider.generate, priority: index } : { name: provider.name, generate: provider.generate, lastResort: adapter.paid(env) || provider.lastResort };
608
+ });
609
+ }
610
+ export {
611
+ BUILTIN_ADAPTERS,
612
+ CHECK_PROMPT,
613
+ DEFAULT_ORDER,
614
+ PAID_TEXT_MODELS,
615
+ chainPlan,
616
+ checkChain,
617
+ checkProvider,
618
+ configuredOrder,
619
+ providerStatuses,
620
+ realProviders
621
+ };
@@ -1,9 +1,14 @@
1
1
  import { AiEnv } from './env.js';
2
+ import { TextPurpose } from './models.js';
2
3
  import 'zod';
3
4
 
4
- /** At least one text provider can answer: a free key, or Vertex with AI_ALLOW_PAID. */
5
- declare function textAiConfigured(env: AiEnv): boolean;
6
- /** The blog AI assistant: ENABLE_BLOG_AI=true (off by default) and a text provider. */
5
+ /**
6
+ * At least one text provider can answer for the purpose (both when omitted):
7
+ * configured, in AI_PROVIDER_ORDER when one is set, and paid ones only with
8
+ * AI_ALLOW_PAID (./adapters).
9
+ */
10
+ declare function textAiConfigured(env: AiEnv, purpose?: TextPurpose): boolean;
11
+ /** The blog AI assistant: ENABLE_BLOG_AI=true (off by default) and a blog text provider. */
7
12
  declare function blogAiEnabled(env: AiEnv): boolean;
8
13
  /** Blog AI images (featured and inline): blog AI plus at least one image provider. */
9
14
  declare function blogAiImagesEnabled(env: AiEnv): boolean;