@sahan-sac/ai-core 0.2.0 → 0.3.1
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 +14 -0
- package/README.md +75 -10
- package/dist/adapters.d.ts +6 -0
- package/dist/adapters.js +27 -0
- package/dist/availability.d.ts +8 -3
- package/dist/availability.js +14 -48
- package/dist/chunk-BL4J2JNX.js +48 -0
- package/dist/chunk-MRNQPHWI.js +194 -0
- package/dist/chunk-PMC3UWQ2.js +10 -0
- package/dist/chunk-R3YY2Y7S.js +571 -0
- package/dist/chunk-RBSLNDFW.js +61 -0
- package/dist/chunk-ZIHRZXPE.js +109 -0
- package/dist/env.d.ts +30 -0
- package/dist/env.js +6 -63
- package/dist/guard.d.ts +15 -3
- package/dist/guard.js +0 -0
- package/dist/image.js +14 -236
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -63
- package/dist/log.js +3 -6
- package/dist/models.js +9 -51
- package/dist/providers.d.ts +144 -8
- package/dist/providers.js +23 -422
- package/dist/vertex.js +4 -43
- package/package.json +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @sahan-sac/ai-core
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 5e7f3cb: The build shares modules between subpaths (code splitting) instead of copying them into each one. Before, a class imported from two subpaths was two different classes, so `instanceof` failed (for example `EmailGuardError` from `@sahan-sac/email-kit/guards` against an error thrown through `./layout`), and module-level state such as caches existed once per subpath.
|
|
8
|
+
|
|
9
|
+
## 0.3.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- b9d2dd6: `createAiService().generate()` takes an optional `signal`. Aborting it stops the chain at once: running attempts are aborted, no further provider starts, and the result is `errorClass: "aborted"`. Aborted attempts are not logged and do not put a provider into cooldown.
|
|
14
|
+
- 4fa21e3: Provider registry and more providers. New `@sahan-sac/ai-core/adapters`: each provider is an adapter with its own env guard and paid flag (`BUILTIN_ADAPTERS`), and `realProviders` builds the chain from it. Added OpenAI, Anthropic (Messages API over REST), DeepSeek, xAI, Perplexity and one custom OpenAI-compatible endpoint (`AI_CUSTOM_*`), all paid and gated by `AI_ALLOW_PAID` (the custom one unless `AI_CUSTOM_FREE`). `AI_PROVIDER_ORDER`, `AI_PROVIDER_ORDER_BLOG` and `AI_PROVIDER_ORDER_CHAT` choose which providers run and in what order; an explicit order is kept as given (`AiProvider.priority`), while cooling providers still drop behind healthy ones. `providerStatuses` explains each adapter's place for health screens, `textAiConfigured(env, purpose?)` checks one purpose's chain, and apps can pass their own adapters to `realProviders`. New factories `openAiCompatibleProvider` and `anthropicProvider`. Existing variables and `realProviders(env, purpose, fetch?)` work as before.
|
|
15
|
+
- f465908: Owner guidance and on-demand checks. `@sahan-sac/ai-core/guard` adds `guidanceSection(text)`, which appends the site owner's standing guidance after a prompt's fixed rules (it steers, never overrides them, and never changes how fenced data is treated), `mergeGuidance(...parts)` to join global and per-feature guidance, and an optional `maxLength` on `wrapUserData`. `@sahan-sac/ai-core/adapters` adds `checkProvider(env, id)` and `checkChain(env, purpose)`: each sends a one-line prompt (a few tokens) through one adapter or a whole chain and reports who answered, how long it took and every fall-through, for a health screen's manual Check button. Neither runs on its own, and neither cools a provider down for real traffic.
|
|
16
|
+
|
|
3
17
|
## 0.2.0
|
|
4
18
|
|
|
5
19
|
No changes in this release.
|
package/README.md
CHANGED
|
@@ -1,23 +1,41 @@
|
|
|
1
1
|
# @sahan-sac/ai-core
|
|
2
2
|
|
|
3
|
-
The AI layer shared by `@sahan-sac/blog-kit` and `@sahan-sac/chat-kit`: a free-by-default provider chain
|
|
3
|
+
The AI layer shared by `@sahan-sac/blog-kit` and `@sahan-sac/chat-kit`: a free-by-default provider chain with automatic fallback, a provider registry you can configure and extend from the environment, model resolution, image generation (NVIDIA FLUX, then paid Gemini/Vertex), and prompt-injection guards.
|
|
4
4
|
|
|
5
5
|
Headless and framework-agnostic: no Prisma, no Next.js APIs, no React. Everything takes a parsed env or config object; only `aiEnvFromProcess()` reads `process.env`.
|
|
6
6
|
|
|
7
|
+
## How a request is answered
|
|
8
|
+
|
|
9
|
+
Two patterns, kept separate:
|
|
10
|
+
|
|
11
|
+
- **Adapters.** Every provider is wrapped as an `AiProvider` with one `generate(prompt, options)` that returns `{ ok: true, text }` or a classified failure (`http_429`, `timeout`, `truncated`, `invalid_output`, ...). It never throws. Built in: Gemini, OpenRouter (two keys), NVIDIA, OpenAI, Anthropic (Claude), DeepSeek, xAI (Grok), Perplexity, one custom OpenAI-compatible endpoint (vLLM, Ollama, LiteLLM, a gateway), and Vertex AI.
|
|
12
|
+
- **Chain of responsibility.** `createAiService({ providers })` hands the prompt to the first provider. On any retryable failure it passes the prompt to the next one, until one answers, a non-retryable failure stops it, the deadline passes, or the caller aborts. On top of that:
|
|
13
|
+
- a provider that timed out or was rate limited cools down and moves behind the healthy ones;
|
|
14
|
+
- optional hedging starts the next provider while a slow one is still running, and the first valid answer wins;
|
|
15
|
+
- an `accept` check turns a malformed reply into a failure, so the chain moves on.
|
|
16
|
+
|
|
17
|
+
The registry (`@sahan-sac/ai-core/adapters`) decides which adapters form a chain and in what order:
|
|
18
|
+
|
|
19
|
+
1. `AI_PROVIDER_ORDER_BLOG` or `AI_PROVIDER_ORDER_CHAT` for that purpose, else `AI_PROVIDER_ORDER`, else the default order: `gemini, openrouter, openrouter-2, nvidia, openai, anthropic, deepseek, xai, perplexity, custom, vertex`.
|
|
20
|
+
2. An adapter runs only when its variables are set. A paid adapter also needs `AI_ALLOW_PAID=true`, even when it is listed.
|
|
21
|
+
3. **Explicit order:** only the listed ids run, in that order. Speed never reorders them; cooling still does.
|
|
22
|
+
4. **Default order:** paid adapters are last resorts, and the free ones are reordered by observed speed.
|
|
23
|
+
|
|
7
24
|
## Subpaths
|
|
8
25
|
|
|
9
26
|
| Import | What |
|
|
10
27
|
| --- | --- |
|
|
11
28
|
| `@sahan-sac/ai-core/env` | `aiEnvSchema` (zod), `parseAiEnv(source)`, `aiEnvFromProcess()`, `AiEnv` |
|
|
12
|
-
| `@sahan-sac/ai-core/
|
|
13
|
-
| `@sahan-sac/ai-core/providers` | `createAiService` (
|
|
29
|
+
| `@sahan-sac/ai-core/adapters` | `BUILTIN_ADAPTERS`, `ProviderAdapter`, `realProviders(env, purpose, fetch?, adapters?)`, `chainPlan`, `providerStatuses` (why each adapter is in or out, for health screens), `checkProvider` / `checkChain` (manual checks), `configuredOrder`, `PAID_TEXT_MODELS` |
|
|
30
|
+
| `@sahan-sac/ai-core/providers` | `createAiService` (the chain), `sharedAiHealth`, `realProviders` (re-exported), the provider factories: `geminiProvider`, `openRouterProvider`, `nvidiaProvider`, `openAiCompatibleProvider`, `anthropicProvider`, `vertexProvider` |
|
|
31
|
+
| `@sahan-sac/ai-core/availability` | `textAiConfigured(env, purpose?)`, `blogAiEnabled`, `blogAiImagesEnabled`, `chatbotEnabled` |
|
|
14
32
|
| `@sahan-sac/ai-core/models` | `textModels`, `imageModels`, verified free defaults, `paidAllowed`, `vertexConfigured` |
|
|
15
33
|
| `@sahan-sac/ai-core/image` | `imageConfigFromEnv`, `generateImage` |
|
|
16
34
|
| `@sahan-sac/ai-core/vertex` | `getVertexAccessToken` (service-account JWT exchange, cached) |
|
|
17
|
-
| `@sahan-sac/ai-core/guard` | `wrapUserData`, `looksLikeLeak`, `ModelPrompt` |
|
|
35
|
+
| `@sahan-sac/ai-core/guard` | `wrapUserData(text, maxLength?)`, `guidanceSection`, `mergeGuidance`, `looksLikeLeak`, `ModelPrompt` |
|
|
18
36
|
| `@sahan-sac/ai-core/log` | `AiLogger`, `consoleAiLogger` |
|
|
19
37
|
|
|
20
|
-
`providers`, `image` and `vertex`
|
|
38
|
+
`providers`, `adapters`, `image` and `vertex` reach `server-only` code: use them from server code only.
|
|
21
39
|
|
|
22
40
|
## Environment
|
|
23
41
|
|
|
@@ -31,14 +49,20 @@ const schema = z.object({ ...aiEnvSchema.shape, DATABASE_URL: z.string() });
|
|
|
31
49
|
|
|
32
50
|
| Variable | Default | Meaning |
|
|
33
51
|
| --- | --- | --- |
|
|
34
|
-
| `ENABLE_BLOG_AI` | `false` | Blog AI assistant (also needs a text provider
|
|
35
|
-
| `ENABLE_CHATBOT` | `true` | Chatbot (also needs a text provider
|
|
36
|
-
| `AI_ALLOW_PAID` | `false` | Lets paid providers (Vertex text and images, Gemini images
|
|
52
|
+
| `ENABLE_BLOG_AI` | `false` | Blog AI assistant (also needs a blog text provider) |
|
|
53
|
+
| `ENABLE_CHATBOT` | `true` | Chatbot (also needs a chat text provider); `false`/`0`/`no`/`off` turns it off |
|
|
54
|
+
| `AI_ALLOW_PAID` | `false` | Lets paid providers join the chains: OpenAI, Anthropic, DeepSeek, xAI, Perplexity, the custom endpoint (unless `AI_CUSTOM_FREE`), Vertex text and images, Gemini images |
|
|
55
|
+
| `AI_PROVIDER_ORDER`, `AI_PROVIDER_ORDER_BLOG`, `AI_PROVIDER_ORDER_CHAT` | built-in order | Comma-separated adapter ids; only the listed ones run, in that order |
|
|
37
56
|
| `GEMINI_API_KEY`, `OPENROUTER_API_KEY`, `OPENROUTER_API_KEY_2`, `NVIDIA_API_KEY` | unset | Free text providers; `NVIDIA_API_KEY` is also the free image provider |
|
|
38
57
|
| `OPENROUTER_BASE_URL`, `OPENROUTER_ALLOW_PAID_MODELS` | OpenRouter API, `false` | OpenRouter settings |
|
|
58
|
+
| `OPENAI_API_KEY`, `OPENAI_BASE_URL` | unset, OpenAI API | OpenAI (paid); the base URL also points it at any OpenAI-compatible gateway |
|
|
59
|
+
| `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL` | unset, Anthropic API | Anthropic Messages API over REST (paid) |
|
|
60
|
+
| `DEEPSEEK_API_KEY`, `XAI_API_KEY`, `PERPLEXITY_API_KEY` | unset | Paid OpenAI-compatible providers |
|
|
61
|
+
| `AI_CUSTOM_BASE_URL`, `AI_CUSTOM_MODEL`, `AI_CUSTOM_API_KEY`, `AI_CUSTOM_NAME`, `AI_CUSTOM_FREE` | unset, `false` | One custom OpenAI-compatible endpoint. The key is optional for a local server. Paid unless `AI_CUSTOM_FREE=true` |
|
|
39
62
|
| `GOOGLE_CLIENT_EMAIL`, `GOOGLE_PRIVATE_KEY`, `GOOGLE_CLOUD_PROJECT` | unset | Vertex service account (paid). Keep the key on one line with literal `\n` |
|
|
40
63
|
| `GOOGLE_TOKEN_URI` | Google's token endpoint | Override only for testing |
|
|
41
|
-
| `BLOG_
|
|
64
|
+
| `BLOG_<P>_MODEL`, `CHAT_<P>_MODEL`, `<P>_MODEL` | built-in defaults | Model per provider `<P>` (`GEMINI`, `OPENROUTER`, `NVIDIA`, `VERTEX`, `OPENAI`, `ANTHROPIC`, `DEEPSEEK`, `XAI`, `PERPLEXITY`): purpose variable, then provider-wide, then default. Paid-provider names change often, so set them |
|
|
65
|
+
| `IMAGE_*_MODEL`, `IMAGEN_MODEL` | verified free defaults | Image models |
|
|
42
66
|
|
|
43
67
|
## Example
|
|
44
68
|
|
|
@@ -50,6 +74,47 @@ import { createAiService, realProviders, sharedAiHealth } from "@sahan-sac/ai-co
|
|
|
50
74
|
const env = aiEnvFromProcess();
|
|
51
75
|
if (chatbotEnabled(env)) {
|
|
52
76
|
const ai = createAiService({ providers: realProviders(env, "chat"), health: sharedAiHealth, timeoutMs: 12_000, deadlineMs: 20_000 });
|
|
53
|
-
const result = await ai.generate({ system: "You are helpful.", user: "Hello" }, { maxTokens: 200 });
|
|
77
|
+
const result = await ai.generate({ system: "You are helpful.", user: "Hello" }, { maxTokens: 200, signal: request.signal });
|
|
78
|
+
// result.provider: who answered; result.attempts: every provider tried, with its error class.
|
|
54
79
|
}
|
|
55
80
|
```
|
|
81
|
+
|
|
82
|
+
Pass the request's `signal` so a client that disconnects stops the chain: every running attempt is aborted, no further provider starts, and the result is `{ ok: false, errorClass: "aborted" }`. An aborted attempt is not logged or cooled down.
|
|
83
|
+
|
|
84
|
+
## Your own adapter
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { BUILTIN_ADAPTERS, realProviders, type ProviderAdapter } from "@sahan-sac/ai-core/adapters";
|
|
88
|
+
import { openAiCompatibleProvider } from "@sahan-sac/ai-core/providers";
|
|
89
|
+
|
|
90
|
+
const groq: ProviderAdapter = {
|
|
91
|
+
id: "groq",
|
|
92
|
+
label: "Groq",
|
|
93
|
+
paid: () => false,
|
|
94
|
+
missing: () => (process.env.GROQ_API_KEY ? [] : ["GROQ_API_KEY"]),
|
|
95
|
+
create: ({ fetch }) =>
|
|
96
|
+
openAiCompatibleProvider({ name: "groq", apiKey: process.env.GROQ_API_KEY!, baseUrl: "https://api.groq.com/openai/v1", model: "llama-3.3-70b-versatile", fetch }),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const providers = realProviders(env, "chat", undefined, [...BUILTIN_ADAPTERS, groq]);
|
|
100
|
+
// Default order: the built-ins, then groq. Or list it: AI_PROVIDER_ORDER=groq,gemini
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
For a settings screen, `providerStatuses(env, purpose)` reports each adapter's state: `active` (with its position), `not_configured` (with the missing variable names, never values), `needs_paid`, `not_in_order` or `unknown`.
|
|
104
|
+
|
|
105
|
+
## Manual checks
|
|
106
|
+
|
|
107
|
+
Reachability for an AI provider means a real prompt, which spends tokens, so run it only when an operator asks:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import { checkChain, checkProvider } from "@sahan-sac/ai-core/adapters";
|
|
111
|
+
|
|
112
|
+
await checkProvider(env, "anthropic"); // { id, ok, provider, ms } or { ok: false, errorClass: "http_401", ... }
|
|
113
|
+
await checkChain(env, "blog"); // the whole chain once: who answered, and `attempts` for every fall-through
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`checkProvider` ignores the order and `AI_ALLOW_PAID` (the operator asked for that provider) and answers `not_configured` without a call when a variable is missing. `checkChain` uses a fresh health record, so a failed check never cools a provider down for real traffic.
|
|
117
|
+
|
|
118
|
+
## Owner guidance
|
|
119
|
+
|
|
120
|
+
`guidanceSection(text)` appends the site owner's standing guidance (voice, facts, do and don't) after a prompt's fixed rules; it returns `""` for empty text and caps the length. `mergeGuidance(global, perFeature)` joins the layers. Guidance steers the model but never overrides the rules above it or how fenced data is treated.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import './env.js';
|
|
2
|
+
import './models.js';
|
|
3
|
+
export { A as AdapterContext, a as AdapterState, b as AdapterStatus, B as BUILTIN_ADAPTERS, C as CHECK_PROMPT, D as DEFAULT_ORDER, P as PAID_TEXT_MODELS, c as ProviderAdapter, d as ProviderCheck, e as chainPlan, f as checkChain, g as checkProvider, h as configuredOrder, p as providerStatuses, realProviders } from './providers.js';
|
|
4
|
+
import 'zod';
|
|
5
|
+
import './guard.js';
|
|
6
|
+
import './log.js';
|
package/dist/adapters.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BUILTIN_ADAPTERS,
|
|
3
|
+
CHECK_PROMPT,
|
|
4
|
+
DEFAULT_ORDER,
|
|
5
|
+
PAID_TEXT_MODELS,
|
|
6
|
+
chainPlan,
|
|
7
|
+
checkChain,
|
|
8
|
+
checkProvider,
|
|
9
|
+
configuredOrder,
|
|
10
|
+
providerStatuses,
|
|
11
|
+
realProviders
|
|
12
|
+
} from "./chunk-R3YY2Y7S.js";
|
|
13
|
+
import "./chunk-BL4J2JNX.js";
|
|
14
|
+
import "./chunk-PMC3UWQ2.js";
|
|
15
|
+
import "./chunk-RBSLNDFW.js";
|
|
16
|
+
export {
|
|
17
|
+
BUILTIN_ADAPTERS,
|
|
18
|
+
CHECK_PROMPT,
|
|
19
|
+
DEFAULT_ORDER,
|
|
20
|
+
PAID_TEXT_MODELS,
|
|
21
|
+
chainPlan,
|
|
22
|
+
checkChain,
|
|
23
|
+
checkProvider,
|
|
24
|
+
configuredOrder,
|
|
25
|
+
providerStatuses,
|
|
26
|
+
realProviders
|
|
27
|
+
};
|
package/dist/availability.d.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { AiEnv } from './env.js';
|
|
2
|
+
import { TextPurpose } from './models.js';
|
|
2
3
|
import 'zod';
|
|
3
4
|
|
|
4
|
-
/**
|
|
5
|
-
|
|
6
|
-
|
|
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;
|
package/dist/availability.js
CHANGED
|
@@ -1,60 +1,26 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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/image.ts
|
|
24
|
-
function vertexImageAvailable(env) {
|
|
25
|
-
return vertexConfigured(env);
|
|
26
|
-
}
|
|
27
|
-
function imageConfigFromEnv(env) {
|
|
28
|
-
const models = imageModels(env);
|
|
29
|
-
const paid = paidAllowed(env);
|
|
30
|
-
const config = {};
|
|
31
|
-
if (env.NVIDIA_API_KEY) config.nvidia = { apiKey: env.NVIDIA_API_KEY, model: models.nvidia };
|
|
32
|
-
if (paid && env.GEMINI_API_KEY) config.gemini = { apiKey: env.GEMINI_API_KEY, model: models.gemini };
|
|
33
|
-
if (paid && vertexImageAvailable(env)) {
|
|
34
|
-
config.vertex = {
|
|
35
|
-
clientEmail: env.GOOGLE_CLIENT_EMAIL,
|
|
36
|
-
privateKey: env.GOOGLE_PRIVATE_KEY,
|
|
37
|
-
tokenUri: env.GOOGLE_TOKEN_URI,
|
|
38
|
-
project: env.GOOGLE_CLOUD_PROJECT,
|
|
39
|
-
model: models.vertex
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
return config.nvidia || config.gemini || config.vertex ? config : null;
|
|
43
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
chainPlan
|
|
3
|
+
} from "./chunk-R3YY2Y7S.js";
|
|
4
|
+
import {
|
|
5
|
+
imageConfigFromEnv
|
|
6
|
+
} from "./chunk-MRNQPHWI.js";
|
|
7
|
+
import "./chunk-BL4J2JNX.js";
|
|
8
|
+
import "./chunk-PMC3UWQ2.js";
|
|
9
|
+
import "./chunk-RBSLNDFW.js";
|
|
44
10
|
|
|
45
11
|
// src/availability.ts
|
|
46
|
-
function textAiConfigured(env) {
|
|
47
|
-
if (
|
|
48
|
-
return
|
|
12
|
+
function textAiConfigured(env, purpose) {
|
|
13
|
+
if (purpose) return chainPlan(env, purpose).length > 0;
|
|
14
|
+
return chainPlan(env, "blog").length > 0 || chainPlan(env, "chat").length > 0;
|
|
49
15
|
}
|
|
50
16
|
function blogAiEnabled(env) {
|
|
51
|
-
return env.ENABLE_BLOG_AI && textAiConfigured(env);
|
|
17
|
+
return env.ENABLE_BLOG_AI && textAiConfigured(env, "blog");
|
|
52
18
|
}
|
|
53
19
|
function blogAiImagesEnabled(env) {
|
|
54
20
|
return blogAiEnabled(env) && imageConfigFromEnv(env) !== null;
|
|
55
21
|
}
|
|
56
22
|
function chatbotEnabled(env) {
|
|
57
|
-
return env.ENABLE_CHATBOT && textAiConfigured(env);
|
|
23
|
+
return env.ENABLE_CHATBOT && textAiConfigured(env, "chat");
|
|
58
24
|
}
|
|
59
25
|
export {
|
|
60
26
|
blogAiEnabled,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// src/vertex.ts
|
|
2
|
+
import "server-only";
|
|
3
|
+
var cachedToken = null;
|
|
4
|
+
function base64UrlEncode(input) {
|
|
5
|
+
return Buffer.from(input).toString("base64url");
|
|
6
|
+
}
|
|
7
|
+
async function getVertexAccessToken(account, fetchImpl = fetch) {
|
|
8
|
+
const now = Date.now();
|
|
9
|
+
if (cachedToken && cachedToken.expiresAt - 6e4 > now) return cachedToken.accessToken;
|
|
10
|
+
const { createSign } = await import("crypto");
|
|
11
|
+
const issuedAt = Math.floor(now / 1e3);
|
|
12
|
+
const expiresIn = 3600;
|
|
13
|
+
const header = base64UrlEncode(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
14
|
+
const claims = base64UrlEncode(
|
|
15
|
+
JSON.stringify({
|
|
16
|
+
iss: account.clientEmail,
|
|
17
|
+
scope: "https://www.googleapis.com/auth/cloud-platform",
|
|
18
|
+
aud: account.tokenUri,
|
|
19
|
+
iat: issuedAt,
|
|
20
|
+
exp: issuedAt + expiresIn
|
|
21
|
+
})
|
|
22
|
+
);
|
|
23
|
+
const signingInput = `${header}.${claims}`;
|
|
24
|
+
const signature = createSign("RSA-SHA256").update(signingInput).sign(account.privateKey);
|
|
25
|
+
const jwt = `${signingInput}.${base64UrlEncode(signature)}`;
|
|
26
|
+
const response = await fetchImpl(account.tokenUri, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
29
|
+
body: new URLSearchParams({
|
|
30
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
31
|
+
assertion: jwt
|
|
32
|
+
})
|
|
33
|
+
});
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
throw new Error(`Vertex token exchange failed: HTTP ${response.status}`);
|
|
36
|
+
}
|
|
37
|
+
const data = await response.json();
|
|
38
|
+
cachedToken = { accessToken: data.access_token, expiresAt: now + data.expires_in * 1e3 };
|
|
39
|
+
return data.access_token;
|
|
40
|
+
}
|
|
41
|
+
function resetVertexTokenCache() {
|
|
42
|
+
cachedToken = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
getVertexAccessToken,
|
|
47
|
+
resetVertexTokenCache
|
|
48
|
+
};
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getVertexAccessToken
|
|
3
|
+
} from "./chunk-BL4J2JNX.js";
|
|
4
|
+
import {
|
|
5
|
+
imageModels,
|
|
6
|
+
paidAllowed,
|
|
7
|
+
vertexConfigured
|
|
8
|
+
} from "./chunk-RBSLNDFW.js";
|
|
9
|
+
|
|
10
|
+
// src/image.ts
|
|
11
|
+
import "server-only";
|
|
12
|
+
var DEFAULT_IMAGE_MODELS = [
|
|
13
|
+
{ model: "gemini-3.1-flash-image", location: "global" },
|
|
14
|
+
{ model: "gemini-2.5-flash-image", location: "us-central1" }
|
|
15
|
+
];
|
|
16
|
+
function defaultLocation(model) {
|
|
17
|
+
return /^gemini-3/.test(model) ? "global" : "us-central1";
|
|
18
|
+
}
|
|
19
|
+
function imageModelChain(config) {
|
|
20
|
+
const chain = config.model ? [{ model: config.model, location: config.location ?? defaultLocation(config.model) }] : [];
|
|
21
|
+
for (const candidate of DEFAULT_IMAGE_MODELS) {
|
|
22
|
+
if (!chain.some((entry) => entry.model === candidate.model)) chain.push(candidate);
|
|
23
|
+
}
|
|
24
|
+
return chain;
|
|
25
|
+
}
|
|
26
|
+
function endpoint(project, target, method) {
|
|
27
|
+
const host = target.location === "global" ? "aiplatform.googleapis.com" : `${target.location}-aiplatform.googleapis.com`;
|
|
28
|
+
return `https://${host}/v1/projects/${project}/locations/${target.location}/publishers/google/models/${target.model}:${method}`;
|
|
29
|
+
}
|
|
30
|
+
function requestFor(target, prompt, aspectRatio) {
|
|
31
|
+
if (target.model.startsWith("imagen-")) {
|
|
32
|
+
return {
|
|
33
|
+
method: "predict",
|
|
34
|
+
body: {
|
|
35
|
+
instances: [{ prompt }],
|
|
36
|
+
parameters: { sampleCount: 1, aspectRatio, safetySetting: "block_medium_and_above", personGeneration: "allow_adult" }
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
method: "generateContent",
|
|
42
|
+
body: {
|
|
43
|
+
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
|
44
|
+
generationConfig: { responseModalities: ["IMAGE"], imageConfig: { aspectRatio } }
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function extractImage(data) {
|
|
49
|
+
const prediction = data?.predictions?.[0];
|
|
50
|
+
if (prediction?.bytesBase64Encoded) return { base64: prediction.bytesBase64Encoded, mimeType: prediction.mimeType || "image/png" };
|
|
51
|
+
const parts = data?.candidates?.[0]?.content?.parts;
|
|
52
|
+
const inline = parts?.find((part) => part.inlineData?.data)?.inlineData;
|
|
53
|
+
return inline?.data ? { base64: inline.data, mimeType: inline.mimeType || "image/png" } : null;
|
|
54
|
+
}
|
|
55
|
+
async function generateImageVertex(prompt, config, options = {}) {
|
|
56
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
57
|
+
const aspectRatio = options.aspectRatio ?? "16:9";
|
|
58
|
+
let accessToken;
|
|
59
|
+
try {
|
|
60
|
+
accessToken = await getVertexAccessToken(
|
|
61
|
+
{ clientEmail: config.clientEmail, privateKey: config.privateKey, tokenUri: config.tokenUri },
|
|
62
|
+
fetchImpl
|
|
63
|
+
);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return { ok: false, error: `Image generation failed: ${error instanceof Error ? error.message : "authentication error"}` };
|
|
66
|
+
}
|
|
67
|
+
const failures = [];
|
|
68
|
+
for (const target of imageModelChain(config)) {
|
|
69
|
+
if (options.signal?.aborted) return { ok: false, error: "Image generation timed out." };
|
|
70
|
+
const { method, body } = requestFor(target, prompt, aspectRatio);
|
|
71
|
+
try {
|
|
72
|
+
const response = await fetchImpl(endpoint(config.project, target, method), {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${accessToken}` },
|
|
75
|
+
signal: options.signal,
|
|
76
|
+
body: JSON.stringify(body)
|
|
77
|
+
});
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
failures.push(`${target.model}: HTTP ${response.status}`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const image = extractImage(await response.json());
|
|
83
|
+
if (image) return { ok: true, ...image };
|
|
84
|
+
failures.push(`${target.model}: no image data`);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (options.signal?.aborted) return { ok: false, error: "Image generation timed out." };
|
|
87
|
+
failures.push(`${target.model}: ${error instanceof Error ? error.message : "transport error"}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { ok: false, error: `Image generation failed (${failures.join("; ")}).` };
|
|
91
|
+
}
|
|
92
|
+
function vertexImageAvailable(env) {
|
|
93
|
+
return vertexConfigured(env);
|
|
94
|
+
}
|
|
95
|
+
var FLUX_SIZE = {
|
|
96
|
+
"16:9": { width: 1344, height: 768 },
|
|
97
|
+
"4:3": { width: 1024, height: 768 },
|
|
98
|
+
"1:1": { width: 1024, height: 1024 }
|
|
99
|
+
};
|
|
100
|
+
function nvidiaImageRequest(model, prompt, aspectRatio, seed) {
|
|
101
|
+
const size = FLUX_SIZE[aspectRatio];
|
|
102
|
+
if (/schnell/.test(model)) return { prompt, ...size, steps: 4, seed };
|
|
103
|
+
return { prompt, ...size, steps: 30, cfg_scale: 3.5, mode: "base", seed };
|
|
104
|
+
}
|
|
105
|
+
function extractNvidiaImage(data) {
|
|
106
|
+
const artifact = data?.artifacts?.[0];
|
|
107
|
+
if (!artifact?.base64 || artifact.finishReason && artifact.finishReason !== "SUCCESS") return null;
|
|
108
|
+
const mimeType = artifact.base64.startsWith("/9j/") ? "image/jpeg" : artifact.base64.startsWith("UklG") ? "image/webp" : "image/png";
|
|
109
|
+
return { base64: artifact.base64, mimeType };
|
|
110
|
+
}
|
|
111
|
+
async function postForImage(model, url, init, extract, options) {
|
|
112
|
+
try {
|
|
113
|
+
const response = await (options.fetchImpl ?? fetch)(url, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { "content-type": "application/json", ...init.headers },
|
|
116
|
+
signal: options.signal,
|
|
117
|
+
body: JSON.stringify(init.body)
|
|
118
|
+
});
|
|
119
|
+
if (!response.ok) return { ok: false, error: `${model}: HTTP ${response.status}` };
|
|
120
|
+
const image = extract(await response.json());
|
|
121
|
+
return image ? { ok: true, ...image } : { ok: false, error: `${model}: no image (filtered or empty)` };
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (options.signal?.aborted) return { ok: false, error: "Image generation timed out." };
|
|
124
|
+
return { ok: false, error: `${model}: ${error instanceof Error ? error.message : "transport error"}` };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function generateImageNvidia(prompt, config, aspectRatio, options) {
|
|
128
|
+
return postForImage(
|
|
129
|
+
config.model,
|
|
130
|
+
`https://ai.api.nvidia.com/v1/genai/${config.model}`,
|
|
131
|
+
{
|
|
132
|
+
headers: { authorization: `Bearer ${config.apiKey}`, accept: "application/json" },
|
|
133
|
+
body: nvidiaImageRequest(config.model, prompt, aspectRatio, Math.floor(Math.random() * 2 ** 31))
|
|
134
|
+
},
|
|
135
|
+
extractNvidiaImage,
|
|
136
|
+
options
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
function generateImageGemini(prompt, config, aspectRatio, options) {
|
|
140
|
+
return postForImage(
|
|
141
|
+
config.model,
|
|
142
|
+
`https://generativelanguage.googleapis.com/v1beta/models/${config.model}:generateContent`,
|
|
143
|
+
{
|
|
144
|
+
headers: { "x-goog-api-key": config.apiKey },
|
|
145
|
+
body: { contents: [{ role: "user", parts: [{ text: prompt }] }], generationConfig: { responseModalities: ["IMAGE"], imageConfig: { aspectRatio } } }
|
|
146
|
+
},
|
|
147
|
+
extractImage,
|
|
148
|
+
options
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
function imageConfigFromEnv(env) {
|
|
152
|
+
const models = imageModels(env);
|
|
153
|
+
const paid = paidAllowed(env);
|
|
154
|
+
const config = {};
|
|
155
|
+
if (env.NVIDIA_API_KEY) config.nvidia = { apiKey: env.NVIDIA_API_KEY, model: models.nvidia };
|
|
156
|
+
if (paid && env.GEMINI_API_KEY) config.gemini = { apiKey: env.GEMINI_API_KEY, model: models.gemini };
|
|
157
|
+
if (paid && vertexImageAvailable(env)) {
|
|
158
|
+
config.vertex = {
|
|
159
|
+
clientEmail: env.GOOGLE_CLIENT_EMAIL,
|
|
160
|
+
privateKey: env.GOOGLE_PRIVATE_KEY,
|
|
161
|
+
tokenUri: env.GOOGLE_TOKEN_URI,
|
|
162
|
+
project: env.GOOGLE_CLOUD_PROJECT,
|
|
163
|
+
model: models.vertex
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return config.nvidia || config.gemini || config.vertex ? config : null;
|
|
167
|
+
}
|
|
168
|
+
async function generateImage(prompt, config, options = {}) {
|
|
169
|
+
const aspectRatio = options.aspectRatio ?? "16:9";
|
|
170
|
+
const attempts = [];
|
|
171
|
+
if (config.nvidia) attempts.push(() => generateImageNvidia(prompt, config.nvidia, aspectRatio, options));
|
|
172
|
+
if (config.gemini) attempts.push(() => generateImageGemini(prompt, config.gemini, aspectRatio, options));
|
|
173
|
+
if (config.vertex) attempts.push(() => generateImageVertex(prompt, config.vertex, options));
|
|
174
|
+
const failures = [];
|
|
175
|
+
for (const run of attempts) {
|
|
176
|
+
if (options.signal?.aborted) return { ok: false, error: "Image generation timed out." };
|
|
177
|
+
const outcome = await run();
|
|
178
|
+
if (outcome.ok) return outcome;
|
|
179
|
+
failures.push(outcome.error);
|
|
180
|
+
}
|
|
181
|
+
return { ok: false, error: failures.length ? `Image generation failed (${failures.join("; ")}).` : "No image provider is configured." };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export {
|
|
185
|
+
DEFAULT_IMAGE_MODELS,
|
|
186
|
+
imageModelChain,
|
|
187
|
+
extractImage,
|
|
188
|
+
generateImageVertex,
|
|
189
|
+
vertexImageAvailable,
|
|
190
|
+
nvidiaImageRequest,
|
|
191
|
+
extractNvidiaImage,
|
|
192
|
+
imageConfigFromEnv,
|
|
193
|
+
generateImage
|
|
194
|
+
};
|