@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/dist/image.js ADDED
@@ -0,0 +1,246 @@
1
+ // src/image.ts
2
+ import "server-only";
3
+
4
+ // src/models.ts
5
+ var DEFAULT_IMAGE_MODELS = {
6
+ nvidia: "black-forest-labs/flux.1-dev",
7
+ gemini: "gemini-3.1-flash-image",
8
+ vertex: "gemini-3.1-flash-image"
9
+ };
10
+ var pick = (...values) => values.find((value) => value && value.trim()).trim();
11
+ function imageModels(env) {
12
+ return {
13
+ nvidia: pick(env.IMAGE_NVIDIA_MODEL, DEFAULT_IMAGE_MODELS.nvidia),
14
+ gemini: pick(env.IMAGE_GEMINI_MODEL, DEFAULT_IMAGE_MODELS.gemini),
15
+ vertex: pick(env.IMAGE_VERTEX_MODEL, env.IMAGEN_MODEL, DEFAULT_IMAGE_MODELS.vertex)
16
+ };
17
+ }
18
+ var paidAllowed = (env) => env.AI_ALLOW_PAID === true;
19
+ function vertexConfigured(env) {
20
+ return Boolean(env.GOOGLE_CLIENT_EMAIL && env.GOOGLE_PRIVATE_KEY && env.GOOGLE_CLOUD_PROJECT);
21
+ }
22
+
23
+ // src/vertex.ts
24
+ import "server-only";
25
+ var cachedToken = null;
26
+ function base64UrlEncode(input) {
27
+ return Buffer.from(input).toString("base64url");
28
+ }
29
+ async function getVertexAccessToken(account, fetchImpl = fetch) {
30
+ const now = Date.now();
31
+ if (cachedToken && cachedToken.expiresAt - 6e4 > now) return cachedToken.accessToken;
32
+ const { createSign } = await import("crypto");
33
+ const issuedAt = Math.floor(now / 1e3);
34
+ const expiresIn = 3600;
35
+ const header = base64UrlEncode(JSON.stringify({ alg: "RS256", typ: "JWT" }));
36
+ const claims = base64UrlEncode(
37
+ JSON.stringify({
38
+ iss: account.clientEmail,
39
+ scope: "https://www.googleapis.com/auth/cloud-platform",
40
+ aud: account.tokenUri,
41
+ iat: issuedAt,
42
+ exp: issuedAt + expiresIn
43
+ })
44
+ );
45
+ const signingInput = `${header}.${claims}`;
46
+ const signature = createSign("RSA-SHA256").update(signingInput).sign(account.privateKey);
47
+ const jwt = `${signingInput}.${base64UrlEncode(signature)}`;
48
+ const response = await fetchImpl(account.tokenUri, {
49
+ method: "POST",
50
+ headers: { "content-type": "application/x-www-form-urlencoded" },
51
+ body: new URLSearchParams({
52
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
53
+ assertion: jwt
54
+ })
55
+ });
56
+ if (!response.ok) {
57
+ throw new Error(`Vertex token exchange failed: HTTP ${response.status}`);
58
+ }
59
+ const data = await response.json();
60
+ cachedToken = { accessToken: data.access_token, expiresAt: now + data.expires_in * 1e3 };
61
+ return data.access_token;
62
+ }
63
+
64
+ // src/image.ts
65
+ var DEFAULT_IMAGE_MODELS2 = [
66
+ { model: "gemini-3.1-flash-image", location: "global" },
67
+ { model: "gemini-2.5-flash-image", location: "us-central1" }
68
+ ];
69
+ function defaultLocation(model) {
70
+ return /^gemini-3/.test(model) ? "global" : "us-central1";
71
+ }
72
+ function imageModelChain(config) {
73
+ const chain = config.model ? [{ model: config.model, location: config.location ?? defaultLocation(config.model) }] : [];
74
+ for (const candidate of DEFAULT_IMAGE_MODELS2) {
75
+ if (!chain.some((entry) => entry.model === candidate.model)) chain.push(candidate);
76
+ }
77
+ return chain;
78
+ }
79
+ function endpoint(project, target, method) {
80
+ const host = target.location === "global" ? "aiplatform.googleapis.com" : `${target.location}-aiplatform.googleapis.com`;
81
+ return `https://${host}/v1/projects/${project}/locations/${target.location}/publishers/google/models/${target.model}:${method}`;
82
+ }
83
+ function requestFor(target, prompt, aspectRatio) {
84
+ if (target.model.startsWith("imagen-")) {
85
+ return {
86
+ method: "predict",
87
+ body: {
88
+ instances: [{ prompt }],
89
+ parameters: { sampleCount: 1, aspectRatio, safetySetting: "block_medium_and_above", personGeneration: "allow_adult" }
90
+ }
91
+ };
92
+ }
93
+ return {
94
+ method: "generateContent",
95
+ body: {
96
+ contents: [{ role: "user", parts: [{ text: prompt }] }],
97
+ generationConfig: { responseModalities: ["IMAGE"], imageConfig: { aspectRatio } }
98
+ }
99
+ };
100
+ }
101
+ function extractImage(data) {
102
+ const prediction = data?.predictions?.[0];
103
+ if (prediction?.bytesBase64Encoded) return { base64: prediction.bytesBase64Encoded, mimeType: prediction.mimeType || "image/png" };
104
+ const parts = data?.candidates?.[0]?.content?.parts;
105
+ const inline = parts?.find((part) => part.inlineData?.data)?.inlineData;
106
+ return inline?.data ? { base64: inline.data, mimeType: inline.mimeType || "image/png" } : null;
107
+ }
108
+ async function generateImageVertex(prompt, config, options = {}) {
109
+ const fetchImpl = options.fetchImpl ?? fetch;
110
+ const aspectRatio = options.aspectRatio ?? "16:9";
111
+ let accessToken;
112
+ try {
113
+ accessToken = await getVertexAccessToken(
114
+ { clientEmail: config.clientEmail, privateKey: config.privateKey, tokenUri: config.tokenUri },
115
+ fetchImpl
116
+ );
117
+ } catch (error) {
118
+ return { ok: false, error: `Image generation failed: ${error instanceof Error ? error.message : "authentication error"}` };
119
+ }
120
+ const failures = [];
121
+ for (const target of imageModelChain(config)) {
122
+ if (options.signal?.aborted) return { ok: false, error: "Image generation timed out." };
123
+ const { method, body } = requestFor(target, prompt, aspectRatio);
124
+ try {
125
+ const response = await fetchImpl(endpoint(config.project, target, method), {
126
+ method: "POST",
127
+ headers: { "content-type": "application/json", authorization: `Bearer ${accessToken}` },
128
+ signal: options.signal,
129
+ body: JSON.stringify(body)
130
+ });
131
+ if (!response.ok) {
132
+ failures.push(`${target.model}: HTTP ${response.status}`);
133
+ continue;
134
+ }
135
+ const image = extractImage(await response.json());
136
+ if (image) return { ok: true, ...image };
137
+ failures.push(`${target.model}: no image data`);
138
+ } catch (error) {
139
+ if (options.signal?.aborted) return { ok: false, error: "Image generation timed out." };
140
+ failures.push(`${target.model}: ${error instanceof Error ? error.message : "transport error"}`);
141
+ }
142
+ }
143
+ return { ok: false, error: `Image generation failed (${failures.join("; ")}).` };
144
+ }
145
+ function vertexImageAvailable(env) {
146
+ return vertexConfigured(env);
147
+ }
148
+ var FLUX_SIZE = {
149
+ "16:9": { width: 1344, height: 768 },
150
+ "4:3": { width: 1024, height: 768 },
151
+ "1:1": { width: 1024, height: 1024 }
152
+ };
153
+ function nvidiaImageRequest(model, prompt, aspectRatio, seed) {
154
+ const size = FLUX_SIZE[aspectRatio];
155
+ if (/schnell/.test(model)) return { prompt, ...size, steps: 4, seed };
156
+ return { prompt, ...size, steps: 30, cfg_scale: 3.5, mode: "base", seed };
157
+ }
158
+ function extractNvidiaImage(data) {
159
+ const artifact = data?.artifacts?.[0];
160
+ if (!artifact?.base64 || artifact.finishReason && artifact.finishReason !== "SUCCESS") return null;
161
+ const mimeType = artifact.base64.startsWith("/9j/") ? "image/jpeg" : artifact.base64.startsWith("UklG") ? "image/webp" : "image/png";
162
+ return { base64: artifact.base64, mimeType };
163
+ }
164
+ async function postForImage(model, url, init, extract, options) {
165
+ try {
166
+ const response = await (options.fetchImpl ?? fetch)(url, {
167
+ method: "POST",
168
+ headers: { "content-type": "application/json", ...init.headers },
169
+ signal: options.signal,
170
+ body: JSON.stringify(init.body)
171
+ });
172
+ if (!response.ok) return { ok: false, error: `${model}: HTTP ${response.status}` };
173
+ const image = extract(await response.json());
174
+ return image ? { ok: true, ...image } : { ok: false, error: `${model}: no image (filtered or empty)` };
175
+ } catch (error) {
176
+ if (options.signal?.aborted) return { ok: false, error: "Image generation timed out." };
177
+ return { ok: false, error: `${model}: ${error instanceof Error ? error.message : "transport error"}` };
178
+ }
179
+ }
180
+ function generateImageNvidia(prompt, config, aspectRatio, options) {
181
+ return postForImage(
182
+ config.model,
183
+ `https://ai.api.nvidia.com/v1/genai/${config.model}`,
184
+ {
185
+ headers: { authorization: `Bearer ${config.apiKey}`, accept: "application/json" },
186
+ body: nvidiaImageRequest(config.model, prompt, aspectRatio, Math.floor(Math.random() * 2 ** 31))
187
+ },
188
+ extractNvidiaImage,
189
+ options
190
+ );
191
+ }
192
+ function generateImageGemini(prompt, config, aspectRatio, options) {
193
+ return postForImage(
194
+ config.model,
195
+ `https://generativelanguage.googleapis.com/v1beta/models/${config.model}:generateContent`,
196
+ {
197
+ headers: { "x-goog-api-key": config.apiKey },
198
+ body: { contents: [{ role: "user", parts: [{ text: prompt }] }], generationConfig: { responseModalities: ["IMAGE"], imageConfig: { aspectRatio } } }
199
+ },
200
+ extractImage,
201
+ options
202
+ );
203
+ }
204
+ function imageConfigFromEnv(env) {
205
+ const models = imageModels(env);
206
+ const paid = paidAllowed(env);
207
+ const config = {};
208
+ if (env.NVIDIA_API_KEY) config.nvidia = { apiKey: env.NVIDIA_API_KEY, model: models.nvidia };
209
+ if (paid && env.GEMINI_API_KEY) config.gemini = { apiKey: env.GEMINI_API_KEY, model: models.gemini };
210
+ if (paid && vertexImageAvailable(env)) {
211
+ config.vertex = {
212
+ clientEmail: env.GOOGLE_CLIENT_EMAIL,
213
+ privateKey: env.GOOGLE_PRIVATE_KEY,
214
+ tokenUri: env.GOOGLE_TOKEN_URI,
215
+ project: env.GOOGLE_CLOUD_PROJECT,
216
+ model: models.vertex
217
+ };
218
+ }
219
+ return config.nvidia || config.gemini || config.vertex ? config : null;
220
+ }
221
+ async function generateImage(prompt, config, options = {}) {
222
+ const aspectRatio = options.aspectRatio ?? "16:9";
223
+ const attempts = [];
224
+ if (config.nvidia) attempts.push(() => generateImageNvidia(prompt, config.nvidia, aspectRatio, options));
225
+ if (config.gemini) attempts.push(() => generateImageGemini(prompt, config.gemini, aspectRatio, options));
226
+ if (config.vertex) attempts.push(() => generateImageVertex(prompt, config.vertex, options));
227
+ const failures = [];
228
+ for (const run of attempts) {
229
+ if (options.signal?.aborted) return { ok: false, error: "Image generation timed out." };
230
+ const outcome = await run();
231
+ if (outcome.ok) return outcome;
232
+ failures.push(outcome.error);
233
+ }
234
+ return { ok: false, error: failures.length ? `Image generation failed (${failures.join("; ")}).` : "No image provider is configured." };
235
+ }
236
+ export {
237
+ DEFAULT_IMAGE_MODELS2 as DEFAULT_IMAGE_MODELS,
238
+ extractImage,
239
+ extractNvidiaImage,
240
+ generateImage,
241
+ generateImageVertex,
242
+ imageConfigFromEnv,
243
+ imageModelChain,
244
+ nvidiaImageRequest,
245
+ vertexImageAvailable
246
+ };
@@ -0,0 +1,6 @@
1
+ export { AiEnv, aiEnvFromProcess, aiEnvSchema, parseAiEnv } from './env.js';
2
+ export { ImageModels, TextModels, TextPurpose } from './models.js';
3
+ export { AiHealth, AiProvider, AiResult } from './providers.js';
4
+ export { ModelPrompt } from './guard.js';
5
+ export { AiLogger } from './log.js';
6
+ import 'zod';
package/dist/index.js ADDED
@@ -0,0 +1,68 @@
1
+ // src/env.ts
2
+ import { z } from "zod";
3
+ var text = z.string().optional().transform((value) => {
4
+ const trimmed = value?.trim();
5
+ return trimmed ? trimmed : void 0;
6
+ });
7
+ var ON = ["1", "true", "yes", "on"];
8
+ var flag = z.string().optional().transform((value) => ON.includes((value ?? "").trim().toLowerCase()));
9
+ var flagDefaultOn = z.string().optional().transform((value) => {
10
+ const normalized = (value ?? "").trim().toLowerCase();
11
+ return normalized === "" || ON.includes(normalized);
12
+ });
13
+ var privateKey = text.transform((value) => value?.replace(/\\n/g, "\n"));
14
+ var DEFAULT_GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token";
15
+ var aiEnvSchema = z.object({
16
+ // Paid providers (Vertex text and images, Gemini images) join the chains
17
+ // only with AI_ALLOW_PAID; off by default, so AI costs $0.
18
+ AI_ALLOW_PAID: flag,
19
+ // Feature switches. The blog assistant is off until turned on; the chatbot
20
+ // is on unless turned off. Each also needs a text provider key.
21
+ ENABLE_BLOG_AI: flag,
22
+ ENABLE_CHATBOT: flagDefaultOn,
23
+ OPENROUTER_BASE_URL: text,
24
+ OPENROUTER_API_KEY: text,
25
+ OPENROUTER_API_KEY_2: text,
26
+ OPENROUTER_ALLOW_PAID_MODELS: flag,
27
+ GEMINI_API_KEY: text,
28
+ NVIDIA_API_KEY: text,
29
+ // Model overrides, all optional (models.ts): purpose variable, then the
30
+ // provider-wide *_MODEL, then a verified free default.
31
+ OPENROUTER_MODEL: text,
32
+ GEMINI_MODEL: text,
33
+ NVIDIA_MODEL: text,
34
+ VERTEX_MODEL: text,
35
+ IMAGEN_MODEL: text,
36
+ BLOG_GEMINI_MODEL: text,
37
+ BLOG_OPENROUTER_MODEL: text,
38
+ BLOG_NVIDIA_MODEL: text,
39
+ BLOG_VERTEX_MODEL: text,
40
+ CHAT_GEMINI_MODEL: text,
41
+ CHAT_OPENROUTER_MODEL: text,
42
+ CHAT_NVIDIA_MODEL: text,
43
+ CHAT_VERTEX_MODEL: text,
44
+ IMAGE_NVIDIA_MODEL: text,
45
+ IMAGE_GEMINI_MODEL: text,
46
+ IMAGE_VERTEX_MODEL: text,
47
+ // Google Vertex service account (paid).
48
+ GOOGLE_CLIENT_EMAIL: text,
49
+ GOOGLE_PRIVATE_KEY: privateKey,
50
+ GOOGLE_CLOUD_PROJECT: text,
51
+ GOOGLE_TOKEN_URI: text.transform((value) => value ?? DEFAULT_GOOGLE_TOKEN_URI)
52
+ });
53
+ function parseAiEnv(source) {
54
+ const result = aiEnvSchema.safeParse(source);
55
+ if (!result.success) {
56
+ const names = [...new Set(result.error.issues.map((issue) => String(issue.path[0] ?? "?")))];
57
+ throw new Error(`Invalid AI environment variables: ${names.join(", ")}`);
58
+ }
59
+ return result.data;
60
+ }
61
+ function aiEnvFromProcess() {
62
+ return parseAiEnv(process.env);
63
+ }
64
+ export {
65
+ aiEnvFromProcess,
66
+ aiEnvSchema,
67
+ parseAiEnv
68
+ };
package/dist/log.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ interface AiLogger {
2
+ warn(message: string, meta?: Record<string, unknown>): void;
3
+ }
4
+ declare const consoleAiLogger: AiLogger;
5
+
6
+ export { type AiLogger, consoleAiLogger };
package/dist/log.js ADDED
@@ -0,0 +1,9 @@
1
+ // src/log.ts
2
+ var consoleAiLogger = {
3
+ warn(message, meta) {
4
+ console.warn(JSON.stringify({ level: "warn", message, ...meta }));
5
+ }
6
+ };
7
+ export {
8
+ consoleAiLogger
9
+ };
@@ -0,0 +1,59 @@
1
+ type TextPurpose = "blog" | "chat";
2
+ interface TextModels {
3
+ gemini: string;
4
+ openrouter: string;
5
+ nvidia: string;
6
+ vertex: string;
7
+ }
8
+ interface ImageModels {
9
+ nvidia: string;
10
+ gemini: string;
11
+ vertex: string;
12
+ }
13
+ /**
14
+ * Verified 2026-09-26 on free keys: gemini-2.5-flash 1.8 s valid JSON,
15
+ * gemini-3.1-flash-lite 1.6 s, nemotron-3-super-120b 5-7 s on both OpenRouter
16
+ * (":free") and NVIDIA; the older defaults (deepseek-v4.1-flash on NVIDIA,
17
+ * nemotron-3.5-lightning on OpenRouter) timed out or took 8 s+.
18
+ */
19
+ declare const DEFAULT_TEXT_MODELS: Record<TextPurpose, TextModels>;
20
+ /** FLUX.1-dev on NVIDIA's free API: 1344x768 in about 6 s (verified 2026-09-26). The Gemini ones are paid-only. */
21
+ declare const DEFAULT_IMAGE_MODELS: ImageModels;
22
+ /** The subset of the parsed env this module reads. */
23
+ interface ModelEnv {
24
+ AI_ALLOW_PAID?: boolean;
25
+ GOOGLE_CLIENT_EMAIL?: string;
26
+ GOOGLE_PRIVATE_KEY?: string;
27
+ GOOGLE_CLOUD_PROJECT?: string;
28
+ GEMINI_MODEL?: string;
29
+ OPENROUTER_MODEL?: string;
30
+ NVIDIA_MODEL?: string;
31
+ VERTEX_MODEL?: string;
32
+ IMAGEN_MODEL?: string;
33
+ BLOG_GEMINI_MODEL?: string;
34
+ BLOG_OPENROUTER_MODEL?: string;
35
+ BLOG_NVIDIA_MODEL?: string;
36
+ BLOG_VERTEX_MODEL?: string;
37
+ CHAT_GEMINI_MODEL?: string;
38
+ CHAT_OPENROUTER_MODEL?: string;
39
+ CHAT_NVIDIA_MODEL?: string;
40
+ CHAT_VERTEX_MODEL?: string;
41
+ IMAGE_NVIDIA_MODEL?: string;
42
+ IMAGE_GEMINI_MODEL?: string;
43
+ IMAGE_VERTEX_MODEL?: string;
44
+ }
45
+ declare function textModels(env: ModelEnv, purpose: TextPurpose): TextModels;
46
+ declare function imageModels(env: ModelEnv): ImageModels;
47
+ /** Paid providers (Vertex, Gemini image) join a chain only when this is true. */
48
+ declare const paidAllowed: (env: ModelEnv) => boolean;
49
+ /** The Vertex service account is complete. Vertex still only runs with AI_ALLOW_PAID. */
50
+ declare function vertexConfigured(env: ModelEnv): boolean;
51
+ /**
52
+ * Gemini's thinking control differs by generation: 2.x takes a token budget
53
+ * (0 = off), 3.x rejects `thinkingBudget` with HTTP 400 and takes a level.
54
+ * Short replies get no thinking at all (it would eat a small output cap and
55
+ * return empty text); long structured output gets a little.
56
+ */
57
+ declare function thinkingConfigFor(model: string, maxOutputTokens: number): Record<string, unknown>;
58
+
59
+ export { DEFAULT_IMAGE_MODELS, DEFAULT_TEXT_MODELS, type ImageModels, type ModelEnv, type TextModels, type TextPurpose, imageModels, paidAllowed, textModels, thinkingConfigFor, vertexConfigured };
package/dist/models.js ADDED
@@ -0,0 +1,60 @@
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 DEFAULT_IMAGE_MODELS = {
19
+ nvidia: "black-forest-labs/flux.1-dev",
20
+ gemini: "gemini-3.1-flash-image",
21
+ vertex: "gemini-3.1-flash-image"
22
+ };
23
+ var pick = (...values) => values.find((value) => value && value.trim()).trim();
24
+ function textModels(env, purpose) {
25
+ const defaults = DEFAULT_TEXT_MODELS[purpose];
26
+ 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 };
27
+ return {
28
+ gemini: pick(own.gemini, env.GEMINI_MODEL, defaults.gemini),
29
+ openrouter: pick(own.openrouter, env.OPENROUTER_MODEL, defaults.openrouter),
30
+ nvidia: pick(own.nvidia, env.NVIDIA_MODEL, defaults.nvidia),
31
+ vertex: pick(own.vertex, env.VERTEX_MODEL, defaults.vertex)
32
+ };
33
+ }
34
+ function imageModels(env) {
35
+ return {
36
+ nvidia: pick(env.IMAGE_NVIDIA_MODEL, DEFAULT_IMAGE_MODELS.nvidia),
37
+ gemini: pick(env.IMAGE_GEMINI_MODEL, DEFAULT_IMAGE_MODELS.gemini),
38
+ vertex: pick(env.IMAGE_VERTEX_MODEL, env.IMAGEN_MODEL, DEFAULT_IMAGE_MODELS.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
+ export {
53
+ DEFAULT_IMAGE_MODELS,
54
+ DEFAULT_TEXT_MODELS,
55
+ imageModels,
56
+ paidAllowed,
57
+ textModels,
58
+ thinkingConfigFor,
59
+ vertexConfigured
60
+ };
@@ -0,0 +1,157 @@
1
+ import { AiEnv } from './env.js';
2
+ import { TextPurpose } from './models.js';
3
+ import { ModelPrompt } from './guard.js';
4
+ import { AiLogger } from './log.js';
5
+ import 'zod';
6
+
7
+ type AiOutcome = {
8
+ ok: true;
9
+ text: string;
10
+ } | {
11
+ ok: false;
12
+ errorClass: string;
13
+ retryable: boolean;
14
+ status?: number;
15
+ };
16
+ interface AiGenerateOptions {
17
+ maxTokens?: number;
18
+ /** Aborted when the attempt times out, so a slow provider stops consuming a socket. */
19
+ signal?: AbortSignal;
20
+ /**
21
+ * JSON output. `true` asks for JSON (Gemini/Vertex responseMimeType); an
22
+ * object also passes a response schema, which Gemini/Vertex enforce while
23
+ * decoding, so the reply is always syntactically valid, correctly escaped
24
+ * JSON of that shape. Providers without the feature ignore it.
25
+ */
26
+ jsonMode?: JsonMode;
27
+ }
28
+ type JsonMode = boolean | {
29
+ schema: Record<string, unknown>;
30
+ };
31
+ interface AiProvider {
32
+ readonly name: string;
33
+ /** Paid or otherwise costly: tried only after every other provider, whatever its speed or health. */
34
+ readonly lastResort?: boolean;
35
+ generate(prompt: ModelPrompt, options?: AiGenerateOptions): Promise<AiOutcome>;
36
+ }
37
+ interface AiAttempt {
38
+ provider: string;
39
+ ok: boolean;
40
+ errorClass?: string;
41
+ ms: number;
42
+ }
43
+ interface AiResult {
44
+ ok: boolean;
45
+ provider: string | null;
46
+ text?: string;
47
+ errorClass?: string;
48
+ attempts: AiAttempt[];
49
+ /** The last reply `accept` turned down, kept so a caller can ask a model to repair it. */
50
+ rejected?: {
51
+ provider: string;
52
+ text: string;
53
+ reason: string;
54
+ };
55
+ }
56
+ declare const AI_TIMEOUT_MS = 25000;
57
+ /** A provider that timed out or was rate limited goes to the back of the line for this long. */
58
+ declare const AI_COOLDOWN_MS = 60000;
59
+ /** What the chain has learned about each provider: when it may lead again, and how fast it answers. */
60
+ interface AiHealth {
61
+ cooldownUntil: Map<string, number>;
62
+ latencyMs: Map<string, number>;
63
+ }
64
+ declare const createAiHealth: () => AiHealth;
65
+ /** One per server instance, for request handlers; tests and scripts get a fresh one by default. */
66
+ declare const sharedAiHealth: AiHealth;
67
+ interface AiServiceDeps {
68
+ providers: readonly AiProvider[];
69
+ /** Per-provider attempt budget. */
70
+ timeoutMs?: number;
71
+ /** Whole-chain budget; the remaining time caps each later attempt. */
72
+ deadlineMs?: number;
73
+ /**
74
+ * Hedged requests: when the running attempt has not answered after this
75
+ * long, the next provider starts too and the first success wins (the
76
+ * others are aborted). Off by default, so attempts run one at a time.
77
+ */
78
+ hedgeAfterMs?: number;
79
+ health?: AiHealth;
80
+ now?: () => number;
81
+ /** Where provider failures are reported; defaults to one JSON line on stderr. */
82
+ logger?: AiLogger;
83
+ }
84
+ type AiAttemptStatus = {
85
+ provider: string;
86
+ stage: "start" | "failure" | "fallback" | "success";
87
+ errorClass?: string;
88
+ fallbackTo?: string;
89
+ };
90
+ declare const HEDGE: unique symbol;
91
+ /**
92
+ * Tries providers until one succeeds, a non-retryable failure stops the chain,
93
+ * or the deadline passes. Healthy providers lead, fastest observed first;
94
+ * cooling ones go last instead of being dropped, so a request always gets a
95
+ * real attempt even when every provider is cooling.
96
+ */
97
+ declare function createAiService(deps: AiServiceDeps): {
98
+ generate: (prompt: ModelPrompt, options?: {
99
+ maxTokens?: number;
100
+ jsonMode?: JsonMode;
101
+ onAttempt?: (status: AiAttemptStatus) => void;
102
+ /**
103
+ * Output check run on each reply before it counts as a success: return
104
+ * null to accept, or a reason to treat the reply as that provider's
105
+ * failure. With hedging on, this is what stops a fast but broken reply
106
+ * (malformed JSON from a weak model) from beating a slower valid one.
107
+ */
108
+ accept?: (text: string) => string | null;
109
+ }) => Promise<AiResult>;
110
+ };
111
+ /** OpenRouter, OpenAI-compatible. Refuses a paid model id unless the owner opted in. */
112
+ declare function openRouterProvider(config: {
113
+ apiKey: string;
114
+ model?: string;
115
+ baseUrl?: string;
116
+ allowPaidModels: boolean;
117
+ fetch?: typeof fetch;
118
+ name?: string;
119
+ }): AiProvider;
120
+ /** NVIDIA NIM, OpenAI-compatible. */
121
+ declare function nvidiaProvider(config: {
122
+ apiKey: string;
123
+ model?: string;
124
+ fetch?: typeof fetch;
125
+ }): AiProvider;
126
+ /** Text of a Gemini/Vertex generateContent reply; a reply stopped at the token cap is "truncated", not a success. */
127
+ declare function geminiOutcome(data: unknown): AiOutcome;
128
+ /** Gemini's own REST API (not OpenAI-compatible), called directly so no extra SDK is added for one provider. */
129
+ declare function geminiProvider(config: {
130
+ apiKey: string;
131
+ model?: string;
132
+ fetchImpl?: typeof fetch;
133
+ }): AiProvider;
134
+ /** Google Vertex AI's Gemini endpoint, authenticated with a service-account JWT
135
+ * (lib/ai/vertex.ts) instead of an API key. Last resort in the chain: it is the
136
+ * slowest to authenticate (a token exchange before every cold call) and the
137
+ * most involved to configure correctly, so faster, simpler providers go first. */
138
+ declare function vertexProvider(config: {
139
+ clientEmail: string;
140
+ privateKey: string;
141
+ tokenUri: string;
142
+ project: string;
143
+ location?: string;
144
+ model?: string;
145
+ fetchImpl?: typeof fetch;
146
+ }): AiProvider;
147
+ /**
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).
154
+ */
155
+ declare function realProviders(env: AiEnv, purpose: TextPurpose, fetchImpl?: typeof fetch): AiProvider[];
156
+
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 };