@sahan-sac/ai-core 0.2.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.
@@ -32,6 +32,12 @@ interface AiProvider {
32
32
  readonly name: string;
33
33
  /** Paid or otherwise costly: tried only after every other provider, whatever its speed or health. */
34
34
  readonly lastResort?: boolean;
35
+ /**
36
+ * Position in an order the operator chose (AI_PROVIDER_ORDER): lower goes
37
+ * first, and speed no longer reorders it. A cooling provider still drops
38
+ * behind the healthy ones.
39
+ */
40
+ readonly priority?: number;
35
41
  generate(prompt: ModelPrompt, options?: AiGenerateOptions): Promise<AiOutcome>;
36
42
  }
37
43
  interface AiAttempt {
@@ -106,6 +112,13 @@ declare function createAiService(deps: AiServiceDeps): {
106
112
  * (malformed JSON from a weak model) from beating a slower valid one.
107
113
  */
108
114
  accept?: (text: string) => string | null;
115
+ /**
116
+ * The caller gave up (an admin cancelled, a client disconnected): every
117
+ * running attempt is aborted, no further provider starts, and the result
118
+ * is `errorClass: "aborted"`. An aborted attempt is not the provider's
119
+ * fault, so it is neither logged nor cooled down.
120
+ */
121
+ signal?: AbortSignal;
109
122
  }) => Promise<AiResult>;
110
123
  };
111
124
  /** OpenRouter, OpenAI-compatible. Refuses a paid model id unless the owner opted in. */
@@ -117,12 +130,33 @@ declare function openRouterProvider(config: {
117
130
  fetch?: typeof fetch;
118
131
  name?: string;
119
132
  }): AiProvider;
133
+ /**
134
+ * Any OpenAI-compatible chat completions API: OpenAI itself, DeepSeek, xAI
135
+ * (Grok), Perplexity, a self-hosted gateway (vLLM, Ollama, LiteLLM).
136
+ */
137
+ declare function openAiCompatibleProvider(config: {
138
+ name: string;
139
+ apiKey: string;
140
+ baseUrl: string;
141
+ model: string;
142
+ headers?: Record<string, string>;
143
+ fetch?: typeof fetch;
144
+ }): AiProvider;
120
145
  /** NVIDIA NIM, OpenAI-compatible. */
121
146
  declare function nvidiaProvider(config: {
122
147
  apiKey: string;
123
148
  model?: string;
124
149
  fetch?: typeof fetch;
125
150
  }): AiProvider;
151
+ /** Text of an Anthropic Messages reply; a reply stopped at the token cap is "truncated", a refusal "refused". */
152
+ declare function anthropicOutcome(data: unknown): AiOutcome;
153
+ /** Anthropic's Messages API over plain REST (no extra SDK for one provider). */
154
+ declare function anthropicProvider(config: {
155
+ apiKey: string;
156
+ model: string;
157
+ baseUrl?: string;
158
+ fetchImpl?: typeof fetch;
159
+ }): AiProvider;
126
160
  /** Text of a Gemini/Vertex generateContent reply; a reply stopped at the token cap is "truncated", not a success. */
127
161
  declare function geminiOutcome(data: unknown): AiOutcome;
128
162
  /** Gemini's own REST API (not OpenAI-compatible), called directly so no extra SDK is added for one provider. */
@@ -144,14 +178,116 @@ declare function vertexProvider(config: {
144
178
  model?: string;
145
179
  fetchImpl?: typeof fetch;
146
180
  }): AiProvider;
181
+
182
+ interface AdapterContext {
183
+ env: AiEnv;
184
+ purpose: TextPurpose;
185
+ fetch?: typeof fetch;
186
+ }
187
+ interface ProviderAdapter {
188
+ /** Lowercase id used in AI_PROVIDER_ORDER. */
189
+ readonly id: string;
190
+ readonly label: string;
191
+ /** Costs money: runs only with AI_ALLOW_PAID. */
192
+ paid(env: AiEnv): boolean;
193
+ /** Env variable names still missing; empty means configured. Never values. */
194
+ missing(env: AiEnv): string[];
195
+ create(context: AdapterContext): AiProvider;
196
+ }
197
+ /**
198
+ * Defaults for the paid adapters. Model names change often: set the
199
+ * provider's *_MODEL variable rather than relying on these.
200
+ */
201
+ declare const PAID_TEXT_MODELS: {
202
+ readonly openai: {
203
+ readonly blog: "gpt-5-mini";
204
+ readonly chat: "gpt-5-nano";
205
+ };
206
+ readonly anthropic: {
207
+ readonly blog: "claude-sonnet-5-5";
208
+ readonly chat: "claude-haiku-4-5-20251001";
209
+ };
210
+ readonly deepseek: {
211
+ readonly blog: "deepseek-chat";
212
+ readonly chat: "deepseek-chat";
213
+ };
214
+ readonly xai: {
215
+ readonly blog: "grok-3-mini";
216
+ readonly chat: "grok-3-mini";
217
+ };
218
+ readonly perplexity: {
219
+ readonly blog: "sonar";
220
+ readonly chat: "sonar";
221
+ };
222
+ };
223
+ declare const BUILTIN_ADAPTERS: readonly ProviderAdapter[];
224
+ /** Free tiers first (fastest, most reliable first), paid last. */
225
+ declare const DEFAULT_ORDER: string[];
226
+ /** The order variable that applies to a purpose, parsed: lowercase ids, no repeats. Null when unset. */
227
+ declare function configuredOrder(env: AiEnv, purpose: TextPurpose): string[] | null;
228
+ type AdapterState = "active" | "not_configured" | "needs_paid" | "not_in_order" | "unknown";
229
+ interface AdapterStatus {
230
+ id: string;
231
+ label: string;
232
+ paid: boolean;
233
+ state: AdapterState;
234
+ /** Position in the chain (0 first) when active. */
235
+ position?: number;
236
+ /** Missing variable names when not configured. */
237
+ missing: string[];
238
+ }
239
+ /** Every adapter's place for a purpose: in the chain or why not. Pure, no network; for health screens. */
240
+ declare function providerStatuses(env: AiEnv, purpose: TextPurpose, adapters?: readonly ProviderAdapter[]): AdapterStatus[];
241
+ /** The adapters that run for a purpose, in order, without building anything. */
242
+ declare function chainPlan(env: AiEnv, purpose: TextPurpose, adapters?: readonly ProviderAdapter[]): ProviderAdapter[];
243
+ /** The prompt a manual check sends: one word back, a few tokens at most. */
244
+ declare const CHECK_PROMPT: {
245
+ readonly system: "You are a connectivity check. Reply with the single word OK.";
246
+ readonly user: "Reply with OK.";
247
+ };
248
+ interface ProviderCheck {
249
+ id: string;
250
+ ok: boolean;
251
+ /** Provider name that answered (for a chain check, the one that won). */
252
+ provider?: string;
253
+ errorClass?: string;
254
+ ms: number;
255
+ /** Every provider tried, for a chain check. */
256
+ attempts?: Array<{
257
+ provider: string;
258
+ ok: boolean;
259
+ errorClass?: string;
260
+ }>;
261
+ }
262
+ /**
263
+ * Sends CHECK_PROMPT through one adapter, whatever the chain order or
264
+ * AI_ALLOW_PAID says: an operator asked for it, and it spends a few tokens.
265
+ * Never throws; a missing variable answers `not_configured` without a call.
266
+ */
267
+ declare function checkProvider(env: AiEnv, id: string, options?: {
268
+ purpose?: TextPurpose;
269
+ fetch?: typeof fetch;
270
+ timeoutMs?: number;
271
+ adapters?: readonly ProviderAdapter[];
272
+ now?: () => number;
273
+ }): Promise<ProviderCheck>;
274
+ /**
275
+ * Sends CHECK_PROMPT through a purpose's whole chain, the same way a real
276
+ * request goes: the first provider that answers wins, and `attempts` shows
277
+ * every provider that failed before it. A fresh health record, so a check
278
+ * never cools down a provider for real traffic.
279
+ */
280
+ declare function checkChain(env: AiEnv, purpose: TextPurpose, options?: {
281
+ fetch?: typeof fetch;
282
+ deadlineMs?: number;
283
+ adapters?: readonly ProviderAdapter[];
284
+ now?: () => number;
285
+ }): Promise<ProviderCheck>;
147
286
  /**
148
- * The chain from decision D15, free tiers first and paid last:
149
- * 1. Gemini (free API key, fast)
150
- * 2. OpenRouter (free-tier models, heavily rate-limited)
151
- * 3. NVIDIA (free developer API; key may be dead or model retired)
152
- * 4. Vertex (pay-as-you-go): only with AI_ALLOW_PAID, and only after the free ones
153
- * Models per purpose come from lib/ai/models.ts (env override, else a verified free default).
287
+ * The provider chain for a purpose. Pass `adapters` to add your own (for
288
+ * example `[...BUILTIN_ADAPTERS, myAdapter]`) and list its id in
289
+ * AI_PROVIDER_ORDER, or rely on the default order (built-ins, then yours).
154
290
  */
155
- declare function realProviders(env: AiEnv, purpose: TextPurpose, fetchImpl?: typeof fetch): AiProvider[];
291
+ declare function realProviders(env: AiEnv, purpose: TextPurpose, fetchImpl?: typeof fetch, adapters?: readonly ProviderAdapter[]): AiProvider[];
156
292
 
157
- export { AI_COOLDOWN_MS, AI_TIMEOUT_MS, type AiAttempt, type AiAttemptStatus, type AiGenerateOptions, type AiHealth, type AiOutcome, type AiProvider, type AiResult, type AiServiceDeps, HEDGE, type JsonMode, createAiHealth, createAiService, geminiOutcome, geminiProvider, nvidiaProvider, openRouterProvider, realProviders, sharedAiHealth, vertexProvider };
293
+ export { type AdapterContext as A, AI_COOLDOWN_MS, AI_TIMEOUT_MS, type AiAttempt, type AiAttemptStatus, type AiGenerateOptions, type AiHealth, type AiOutcome, type AiProvider, type AiResult, type AiServiceDeps, BUILTIN_ADAPTERS as B, CHECK_PROMPT as C, DEFAULT_ORDER as D, HEDGE, type JsonMode, PAID_TEXT_MODELS as P, type AdapterState as a, anthropicOutcome, anthropicProvider, type AdapterStatus as b, type ProviderAdapter as c, createAiHealth, createAiService, type ProviderCheck as d, chainPlan as e, checkChain as f, checkProvider as g, geminiOutcome, geminiProvider, configuredOrder as h, nvidiaProvider, openAiCompatibleProvider, openRouterProvider, providerStatuses as p, realProviders, sharedAiHealth, vertexProvider };
package/dist/providers.js CHANGED
@@ -91,6 +91,160 @@ async function getVertexAccessToken(account, fetchImpl = fetch) {
91
91
  return data.access_token;
92
92
  }
93
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
+
94
248
  // src/providers.ts
95
249
  function jsonConfig(jsonMode) {
96
250
  if (!jsonMode) return {};
@@ -136,12 +290,15 @@ function createAiService(deps) {
136
290
  const cooling = (p) => Number((health.cooldownUntil.get(p.name) ?? 0) > at);
137
291
  const speed = (p) => health.latencyMs.get(p.name) ?? Number.POSITIVE_INFINITY;
138
292
  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));
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));
140
295
  }
141
296
  async function generate(prompt, options) {
142
297
  if (deps.providers.length === 0) {
143
298
  return { ok: false, provider: null, errorClass: "no_provider", attempts: [] };
144
299
  }
300
+ const external = options?.signal;
301
+ if (external?.aborted) return { ok: false, provider: null, errorClass: "aborted", attempts: [] };
145
302
  const startedAll = now();
146
303
  const ordered = rank(startedAll);
147
304
  const attempts = [];
@@ -151,6 +308,15 @@ function createAiService(deps) {
151
308
  let stopped = false;
152
309
  let result = null;
153
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
+ });
154
320
  const launch = () => {
155
321
  if (result || stopped || next >= ordered.length) return false;
156
322
  const remaining = deps.deadlineMs === void 0 ? timeoutMs : deps.deadlineMs - (now() - startedAll);
@@ -159,7 +325,7 @@ function createAiService(deps) {
159
325
  options?.onAttempt?.({ provider: provider.name, stage: "start" });
160
326
  const started = now();
161
327
  const run = attempt(provider, prompt, options?.maxTokens, Math.min(timeoutMs, remaining), cancelLosers.signal, options?.jsonMode).then((raw) => {
162
- if (result) return;
328
+ if (result || external?.aborted) return;
163
329
  const ms = now() - started;
164
330
  let outcome = raw;
165
331
  if (outcome.ok && options?.accept) {
@@ -201,18 +367,24 @@ function createAiService(deps) {
201
367
  running.add(tracked);
202
368
  return true;
203
369
  };
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)));
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();
210
381
  }
211
- const first = await Promise.race(racers);
212
- clearTimeout(timer);
213
- if (first === HEDGE) launch();
382
+ } finally {
383
+ if (onAbort) external?.removeEventListener("abort", onAbort);
214
384
  }
215
- return result ?? { ok: false, provider: null, errorClass: attempts[attempts.length - 1]?.errorClass ?? "deadline", attempts, rejected };
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 };
216
388
  }
217
389
  return { generate };
218
390
  }
@@ -256,16 +428,57 @@ async function sdkGenerate(model, prompt, options) {
256
428
  return { ok: false, errorClass: options?.signal?.aborted ? "timeout" : "provider_error", retryable: true };
257
429
  }
258
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
+ }
259
438
  function nvidiaProvider(config) {
260
- const model = config.model || DEFAULT_TEXT_MODELS.blog.nvidia;
261
- const client = createOpenAI({
439
+ return openAiCompatibleProvider({
440
+ name: "nvidia",
262
441
  apiKey: config.apiKey,
263
- baseURL: "https://integrate.api.nvidia.com/v1",
442
+ baseUrl: "https://integrate.api.nvidia.com/v1",
443
+ model: config.model || DEFAULT_TEXT_MODELS.blog.nvidia,
264
444
  fetch: config.fetch
265
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(/\/+$/, "");
266
458
  return {
267
- name: "nvidia",
268
- generate: (prompt, options) => sdkGenerate(client.chat(model), prompt, options)
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
+ }
269
482
  };
270
483
  }
271
484
  function geminiOutcome(data) {
@@ -367,68 +580,18 @@ function vertexProvider(config) {
367
580
  }
368
581
  };
369
582
  }
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
583
  export {
424
584
  AI_COOLDOWN_MS,
425
585
  AI_TIMEOUT_MS,
426
586
  HEDGE,
587
+ anthropicOutcome,
588
+ anthropicProvider,
427
589
  createAiHealth,
428
590
  createAiService,
429
591
  geminiOutcome,
430
592
  geminiProvider,
431
593
  nvidiaProvider,
594
+ openAiCompatibleProvider,
432
595
  openRouterProvider,
433
596
  realProviders,
434
597
  sharedAiHealth,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sahan-sac/ai-core",
3
- "version": "0.2.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.",
3
+ "version": "0.3.0",
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": {
7
7
  "type": "git",
@@ -35,6 +35,10 @@
35
35
  "types": "./dist/models.d.ts",
36
36
  "import": "./dist/models.js"
37
37
  },
38
+ "./adapters": {
39
+ "types": "./dist/adapters.d.ts",
40
+ "import": "./dist/adapters.js"
41
+ },
38
42
  "./providers": {
39
43
  "types": "./dist/providers.d.ts",
40
44
  "import": "./dist/providers.js"