@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.
- package/CHANGELOG.md +8 -0
- package/README.md +75 -10
- package/dist/adapters.d.ts +6 -0
- package/dist/adapters.js +621 -0
- package/dist/availability.d.ts +8 -3
- package/dist/availability.js +423 -8
- package/dist/env.d.ts +30 -0
- package/dist/env.js +39 -0
- package/dist/guard.d.ts +15 -3
- package/dist/guard.js +0 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +39 -0
- package/dist/providers.d.ts +144 -8
- package/dist/providers.js +233 -70
- package/package.json +6 -2
package/dist/providers.d.ts
CHANGED
|
@@ -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
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
if (first === HEDGE) launch();
|
|
382
|
+
} finally {
|
|
383
|
+
if (onAbort) external?.removeEventListener("abort", onAbort);
|
|
214
384
|
}
|
|
215
|
-
|
|
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
|
-
|
|
261
|
-
|
|
439
|
+
return openAiCompatibleProvider({
|
|
440
|
+
name: "nvidia",
|
|
262
441
|
apiKey: config.apiKey,
|
|
263
|
-
|
|
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: "
|
|
268
|
-
generate
|
|
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.
|
|
4
|
-
"description": "Free-by-default AI provider chain (Gemini, OpenRouter, NVIDIA, Vertex)
|
|
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"
|