@sahan-sac/ai-core 0.3.1 → 0.4.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 +15 -0
- package/README.md +123 -120
- package/dist/adapters.d.ts +1 -1
- package/dist/adapters.js +3 -1
- package/dist/availability.js +1 -1
- package/dist/{chunk-R3YY2Y7S.js → chunk-BK4B76LD.js} +51 -1
- package/dist/{chunk-ZIHRZXPE.js → chunk-SI7JE5BG.js} +17 -0
- package/dist/env.d.ts +13 -0
- package/dist/env.js +1 -1
- package/dist/index.js +1 -1
- package/dist/providers.d.ts +31 -1
- package/dist/providers.js +3 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# @sahan-sac/ai-core
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 396e692: Three more free-tier adapters:
|
|
8
|
+
- `groq`: Groq free plan, keyed by `GROQ_API_KEY`. Default models `openai/gpt-oss-120b` for the blog and `openai/gpt-oss-20b` for chat.
|
|
9
|
+
- `mistral`: Mistral free Experiment plan, keyed by `MISTRAL_API_KEY`. This plan may train on prompts, so the adapter runs in the blog chain only unless `AI_PROVIDER_ORDER_CHAT` lists it.
|
|
10
|
+
- `cloudflare`: Cloudflare Workers AI, with 10,000 free neurons a day. Needs `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_AI_API_TOKEN`.
|
|
11
|
+
|
|
12
|
+
None of them is paid, so none needs `AI_ALLOW_PAID`. Each model can be overridden per purpose. Adapters gain an optional `defaultPurposes`.
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- 1570cf6: A provider that answers 402 (no credits, or an empty prepaid wallet) now cools down for 30 minutes instead of being retried on every request, so an unfunded account costs one probe per window rather than a failed round trip each time. `cooldownFor` is exported.
|
|
17
|
+
|
|
3
18
|
## 0.3.1
|
|
4
19
|
|
|
5
20
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -1,120 +1,123 @@
|
|
|
1
|
-
# @sahan-sac/ai-core
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
## Subpaths
|
|
25
|
-
|
|
26
|
-
| Import | What |
|
|
27
|
-
| --- | --- |
|
|
28
|
-
| `@sahan-sac/ai-core/env` | `aiEnvSchema` (zod), `parseAiEnv(source)`, `aiEnvFromProcess()`, `AiEnv` |
|
|
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` |
|
|
32
|
-
| `@sahan-sac/ai-core/models` | `textModels`, `imageModels`, verified free defaults, `paidAllowed`, `vertexConfigured` |
|
|
33
|
-
| `@sahan-sac/ai-core/image` | `imageConfigFromEnv`, `generateImage` |
|
|
34
|
-
| `@sahan-sac/ai-core/vertex` | `getVertexAccessToken` (service-account JWT exchange, cached) |
|
|
35
|
-
| `@sahan-sac/ai-core/guard` | `wrapUserData(text, maxLength?)`, `guidanceSection`, `mergeGuidance`, `looksLikeLeak`, `ModelPrompt` |
|
|
36
|
-
| `@sahan-sac/ai-core/log` | `AiLogger`, `consoleAiLogger` |
|
|
37
|
-
|
|
38
|
-
`providers`, `adapters`, `image` and `vertex` reach `server-only` code: use them from server code only.
|
|
39
|
-
|
|
40
|
-
## Environment
|
|
41
|
-
|
|
42
|
-
Spread the schema into your own env module so the names never drift:
|
|
43
|
-
|
|
44
|
-
```ts
|
|
45
|
-
import { aiEnvSchema } from "@sahan-sac/ai-core/env";
|
|
46
|
-
|
|
47
|
-
const schema = z.object({ ...aiEnvSchema.shape, DATABASE_URL: z.string() });
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
| Variable | Default | Meaning |
|
|
51
|
-
| --- | --- | --- |
|
|
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 |
|
|
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 |
|
|
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
|
-
| `
|
|
61
|
-
| `
|
|
62
|
-
| `
|
|
63
|
-
| `
|
|
64
|
-
| `
|
|
65
|
-
| `
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
1
|
+
# @sahan-sac/ai-core
|
|
2
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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
|
+
|
|
24
|
+
## Subpaths
|
|
25
|
+
|
|
26
|
+
| Import | What |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| `@sahan-sac/ai-core/env` | `aiEnvSchema` (zod), `parseAiEnv(source)`, `aiEnvFromProcess()`, `AiEnv` |
|
|
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` |
|
|
32
|
+
| `@sahan-sac/ai-core/models` | `textModels`, `imageModels`, verified free defaults, `paidAllowed`, `vertexConfigured` |
|
|
33
|
+
| `@sahan-sac/ai-core/image` | `imageConfigFromEnv`, `generateImage` |
|
|
34
|
+
| `@sahan-sac/ai-core/vertex` | `getVertexAccessToken` (service-account JWT exchange, cached) |
|
|
35
|
+
| `@sahan-sac/ai-core/guard` | `wrapUserData(text, maxLength?)`, `guidanceSection`, `mergeGuidance`, `looksLikeLeak`, `ModelPrompt` |
|
|
36
|
+
| `@sahan-sac/ai-core/log` | `AiLogger`, `consoleAiLogger` |
|
|
37
|
+
|
|
38
|
+
`providers`, `adapters`, `image` and `vertex` reach `server-only` code: use them from server code only.
|
|
39
|
+
|
|
40
|
+
## Environment
|
|
41
|
+
|
|
42
|
+
Spread the schema into your own env module so the names never drift:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { aiEnvSchema } from "@sahan-sac/ai-core/env";
|
|
46
|
+
|
|
47
|
+
const schema = z.object({ ...aiEnvSchema.shape, DATABASE_URL: z.string() });
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
| Variable | Default | Meaning |
|
|
51
|
+
| --- | --- | --- |
|
|
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 |
|
|
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 |
|
|
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
|
+
| `GROQ_API_KEY` | unset | Groq free plan (no card). Models `openai/gpt-oss-120b` (blog) and `openai/gpt-oss-20b` (chat); override with `GROQ_MODEL` or `BLOG_`/`CHAT_` variants |
|
|
61
|
+
| `MISTRAL_API_KEY` | unset | Mistral free (Experiment) plan, which may train on prompts. Blog chain only, unless `AI_PROVIDER_ORDER_CHAT` lists `mistral` |
|
|
62
|
+
| `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_AI_API_TOKEN` | unset | Cloudflare Workers AI, 10,000 free neurons a day. Models `@cf/openai/gpt-oss-120b` / `-20b`; override with `CLOUDFLARE_AI_MODEL` |
|
|
63
|
+
| `DEEPSEEK_API_KEY`, `XAI_API_KEY`, `PERPLEXITY_API_KEY` | unset | Paid OpenAI-compatible providers |
|
|
64
|
+
| `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` |
|
|
65
|
+
| `GOOGLE_CLIENT_EMAIL`, `GOOGLE_PRIVATE_KEY`, `GOOGLE_CLOUD_PROJECT` | unset | Vertex service account (paid). Keep the key on one line with literal `\n` |
|
|
66
|
+
| `GOOGLE_TOKEN_URI` | Google's token endpoint | Override only for testing |
|
|
67
|
+
| `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 |
|
|
68
|
+
| `IMAGE_*_MODEL`, `IMAGEN_MODEL` | verified free defaults | Image models |
|
|
69
|
+
|
|
70
|
+
## Example
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { chatbotEnabled } from "@sahan-sac/ai-core/availability";
|
|
74
|
+
import { aiEnvFromProcess } from "@sahan-sac/ai-core/env";
|
|
75
|
+
import { createAiService, realProviders, sharedAiHealth } from "@sahan-sac/ai-core/providers";
|
|
76
|
+
|
|
77
|
+
const env = aiEnvFromProcess();
|
|
78
|
+
if (chatbotEnabled(env)) {
|
|
79
|
+
const ai = createAiService({ providers: realProviders(env, "chat"), health: sharedAiHealth, timeoutMs: 12_000, deadlineMs: 20_000 });
|
|
80
|
+
const result = await ai.generate({ system: "You are helpful.", user: "Hello" }, { maxTokens: 200, signal: request.signal });
|
|
81
|
+
// result.provider: who answered; result.attempts: every provider tried, with its error class.
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
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.
|
|
86
|
+
|
|
87
|
+
## Your own adapter
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { BUILTIN_ADAPTERS, realProviders, type ProviderAdapter } from "@sahan-sac/ai-core/adapters";
|
|
91
|
+
import { openAiCompatibleProvider } from "@sahan-sac/ai-core/providers";
|
|
92
|
+
|
|
93
|
+
const groq: ProviderAdapter = {
|
|
94
|
+
id: "groq",
|
|
95
|
+
label: "Groq",
|
|
96
|
+
paid: () => false,
|
|
97
|
+
missing: () => (process.env.GROQ_API_KEY ? [] : ["GROQ_API_KEY"]),
|
|
98
|
+
create: ({ fetch }) =>
|
|
99
|
+
openAiCompatibleProvider({ name: "groq", apiKey: process.env.GROQ_API_KEY!, baseUrl: "https://api.groq.com/openai/v1", model: "llama-3.3-70b-versatile", fetch }),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const providers = realProviders(env, "chat", undefined, [...BUILTIN_ADAPTERS, groq]);
|
|
103
|
+
// Default order: the built-ins, then groq. Or list it: AI_PROVIDER_ORDER=groq,gemini
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
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`.
|
|
107
|
+
|
|
108
|
+
## Manual checks
|
|
109
|
+
|
|
110
|
+
Reachability for an AI provider means a real prompt, which spends tokens, so run it only when an operator asks:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { checkChain, checkProvider } from "@sahan-sac/ai-core/adapters";
|
|
114
|
+
|
|
115
|
+
await checkProvider(env, "anthropic"); // { id, ok, provider, ms } or { ok: false, errorClass: "http_401", ... }
|
|
116
|
+
await checkChain(env, "blog"); // the whole chain once: who answered, and `attempts` for every fall-through
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`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.
|
|
120
|
+
|
|
121
|
+
## Owner guidance
|
|
122
|
+
|
|
123
|
+
`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.
|
package/dist/adapters.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import './env.js';
|
|
2
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';
|
|
3
|
+
export { A as AdapterContext, a as AdapterState, b as AdapterStatus, B as BUILTIN_ADAPTERS, C as CHECK_PROMPT, D as DEFAULT_ORDER, F as FREE_TEXT_MODELS, 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
4
|
import 'zod';
|
|
5
5
|
import './guard.js';
|
|
6
6
|
import './log.js';
|
package/dist/adapters.js
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
BUILTIN_ADAPTERS,
|
|
3
3
|
CHECK_PROMPT,
|
|
4
4
|
DEFAULT_ORDER,
|
|
5
|
+
FREE_TEXT_MODELS,
|
|
5
6
|
PAID_TEXT_MODELS,
|
|
6
7
|
chainPlan,
|
|
7
8
|
checkChain,
|
|
@@ -9,7 +10,7 @@ import {
|
|
|
9
10
|
configuredOrder,
|
|
10
11
|
providerStatuses,
|
|
11
12
|
realProviders
|
|
12
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-BK4B76LD.js";
|
|
13
14
|
import "./chunk-BL4J2JNX.js";
|
|
14
15
|
import "./chunk-PMC3UWQ2.js";
|
|
15
16
|
import "./chunk-RBSLNDFW.js";
|
|
@@ -17,6 +18,7 @@ export {
|
|
|
17
18
|
BUILTIN_ADAPTERS,
|
|
18
19
|
CHECK_PROMPT,
|
|
19
20
|
DEFAULT_ORDER,
|
|
21
|
+
FREE_TEXT_MODELS,
|
|
20
22
|
PAID_TEXT_MODELS,
|
|
21
23
|
chainPlan,
|
|
22
24
|
checkChain,
|
package/dist/availability.js
CHANGED
|
@@ -23,6 +23,11 @@ function modelFor(env, provider, purpose, fallback) {
|
|
|
23
23
|
return record[`${purpose.toUpperCase()}_${provider}_MODEL`] || record[`${provider}_MODEL`] || fallback[purpose];
|
|
24
24
|
}
|
|
25
25
|
var need = (env, ...names) => names.filter((name) => !env[name]);
|
|
26
|
+
var FREE_TEXT_MODELS = {
|
|
27
|
+
groq: { blog: "openai/gpt-oss-120b", chat: "openai/gpt-oss-20b" },
|
|
28
|
+
mistral: { blog: "mistral-medium-latest", chat: "mistral-small-latest" },
|
|
29
|
+
cloudflare: { blog: "@cf/openai/gpt-oss-120b", chat: "@cf/openai/gpt-oss-20b" }
|
|
30
|
+
};
|
|
26
31
|
var PAID_TEXT_MODELS = {
|
|
27
32
|
openai: { blog: "gpt-5-mini", chat: "gpt-5-nano" },
|
|
28
33
|
anthropic: { blog: "claude-sonnet-5-5", chat: "claude-haiku-4-5-20251001" },
|
|
@@ -68,6 +73,19 @@ var BUILTIN_ADAPTERS = [
|
|
|
68
73
|
missing: (env) => need(env, "GEMINI_API_KEY"),
|
|
69
74
|
create: ({ env, purpose, fetch: fetch2 }) => geminiProvider({ apiKey: env.GEMINI_API_KEY, model: textModels(env, purpose).gemini, fetchImpl: fetch2 })
|
|
70
75
|
},
|
|
76
|
+
{
|
|
77
|
+
id: "groq",
|
|
78
|
+
label: "Groq",
|
|
79
|
+
paid: () => false,
|
|
80
|
+
missing: (env) => need(env, "GROQ_API_KEY"),
|
|
81
|
+
create: ({ env, purpose, fetch: fetch2 }) => openAiCompatibleProvider({
|
|
82
|
+
name: "groq",
|
|
83
|
+
apiKey: env.GROQ_API_KEY,
|
|
84
|
+
baseUrl: "https://api.groq.com/openai/v1",
|
|
85
|
+
model: modelFor(env, "GROQ", purpose, FREE_TEXT_MODELS.groq),
|
|
86
|
+
fetch: fetch2
|
|
87
|
+
})
|
|
88
|
+
},
|
|
71
89
|
openRouterAdapter("openrouter", "OPENROUTER_API_KEY"),
|
|
72
90
|
openRouterAdapter("openrouter-2", "OPENROUTER_API_KEY_2"),
|
|
73
91
|
{
|
|
@@ -83,6 +101,35 @@ var BUILTIN_ADAPTERS = [
|
|
|
83
101
|
fetch: fetch2
|
|
84
102
|
})
|
|
85
103
|
},
|
|
104
|
+
{
|
|
105
|
+
id: "mistral",
|
|
106
|
+
label: "Mistral",
|
|
107
|
+
paid: () => false,
|
|
108
|
+
// The free plan may train on prompts: keep visitors' chat text out unless
|
|
109
|
+
// the operator lists it in AI_PROVIDER_ORDER_CHAT.
|
|
110
|
+
defaultPurposes: ["blog"],
|
|
111
|
+
missing: (env) => need(env, "MISTRAL_API_KEY"),
|
|
112
|
+
create: ({ env, purpose, fetch: fetch2 }) => openAiCompatibleProvider({
|
|
113
|
+
name: "mistral",
|
|
114
|
+
apiKey: env.MISTRAL_API_KEY,
|
|
115
|
+
baseUrl: "https://api.mistral.ai/v1",
|
|
116
|
+
model: modelFor(env, "MISTRAL", purpose, FREE_TEXT_MODELS.mistral),
|
|
117
|
+
fetch: fetch2
|
|
118
|
+
})
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "cloudflare",
|
|
122
|
+
label: "Cloudflare Workers AI",
|
|
123
|
+
paid: () => false,
|
|
124
|
+
missing: (env) => need(env, "CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_AI_API_TOKEN"),
|
|
125
|
+
create: ({ env, purpose, fetch: fetch2 }) => openAiCompatibleProvider({
|
|
126
|
+
name: "cloudflare",
|
|
127
|
+
apiKey: env.CLOUDFLARE_AI_API_TOKEN,
|
|
128
|
+
baseUrl: `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(env.CLOUDFLARE_ACCOUNT_ID)}/ai/v1`,
|
|
129
|
+
model: modelFor(env, "CLOUDFLARE_AI", purpose, FREE_TEXT_MODELS.cloudflare),
|
|
130
|
+
fetch: fetch2
|
|
131
|
+
})
|
|
132
|
+
},
|
|
86
133
|
compatibleAdapter({ id: "openai", label: "OpenAI", envPrefix: "OPENAI", keyVar: "OPENAI_API_KEY", baseUrl: "https://api.openai.com/v1", baseUrlVar: "OPENAI_BASE_URL" }),
|
|
87
134
|
{
|
|
88
135
|
id: "anthropic",
|
|
@@ -137,7 +184,7 @@ function configuredOrder(env, purpose) {
|
|
|
137
184
|
}
|
|
138
185
|
function providerStatuses(env, purpose, adapters = BUILTIN_ADAPTERS) {
|
|
139
186
|
const order = configuredOrder(env, purpose);
|
|
140
|
-
const ids = order ?? adapters.map((adapter) => adapter.id);
|
|
187
|
+
const ids = order ?? adapters.filter((adapter) => !adapter.defaultPurposes || adapter.defaultPurposes.includes(purpose)).map((adapter) => adapter.id);
|
|
141
188
|
const byId = new Map(adapters.map((adapter) => [adapter.id, adapter]));
|
|
142
189
|
let position = 0;
|
|
143
190
|
const statuses = [];
|
|
@@ -239,6 +286,7 @@ var sharedAiHealth = globalThis.sahanAiHealth ??= createAiHealth();
|
|
|
239
286
|
function cooldownFor(outcome) {
|
|
240
287
|
if (outcome.ok) return 0;
|
|
241
288
|
if (outcome.errorClass === "timeout" || outcome.status === 429) return AI_COOLDOWN_MS;
|
|
289
|
+
if (outcome.status === 402) return AI_COOLDOWN_MS * 30;
|
|
242
290
|
if (outcome.status === 401 || outcome.status === 403 || outcome.status === 404) return AI_COOLDOWN_MS * 10;
|
|
243
291
|
return 0;
|
|
244
292
|
}
|
|
@@ -548,6 +596,7 @@ export {
|
|
|
548
596
|
AI_COOLDOWN_MS,
|
|
549
597
|
createAiHealth,
|
|
550
598
|
sharedAiHealth,
|
|
599
|
+
cooldownFor,
|
|
551
600
|
HEDGE,
|
|
552
601
|
createAiService,
|
|
553
602
|
openRouterProvider,
|
|
@@ -558,6 +607,7 @@ export {
|
|
|
558
607
|
geminiOutcome,
|
|
559
608
|
geminiProvider,
|
|
560
609
|
vertexProvider,
|
|
610
|
+
FREE_TEXT_MODELS,
|
|
561
611
|
PAID_TEXT_MODELS,
|
|
562
612
|
BUILTIN_ADAPTERS,
|
|
563
613
|
DEFAULT_ORDER,
|
|
@@ -26,6 +26,23 @@ var aiEnvSchema = z.object({
|
|
|
26
26
|
OPENROUTER_ALLOW_PAID_MODELS: flag,
|
|
27
27
|
GEMINI_API_KEY: text,
|
|
28
28
|
NVIDIA_API_KEY: text,
|
|
29
|
+
// More free tiers (./adapters). Groq: free plan, no card. Mistral: free
|
|
30
|
+
// Experiment plan, which may train on prompts, so it joins the chat chain
|
|
31
|
+
// only when AI_PROVIDER_ORDER_CHAT lists it. Cloudflare Workers AI: 10,000
|
|
32
|
+
// free neurons a day; needs the account id and a Workers AI token.
|
|
33
|
+
GROQ_API_KEY: text,
|
|
34
|
+
GROQ_MODEL: text,
|
|
35
|
+
BLOG_GROQ_MODEL: text,
|
|
36
|
+
CHAT_GROQ_MODEL: text,
|
|
37
|
+
MISTRAL_API_KEY: text,
|
|
38
|
+
MISTRAL_MODEL: text,
|
|
39
|
+
BLOG_MISTRAL_MODEL: text,
|
|
40
|
+
CHAT_MISTRAL_MODEL: text,
|
|
41
|
+
CLOUDFLARE_ACCOUNT_ID: text,
|
|
42
|
+
CLOUDFLARE_AI_API_TOKEN: text,
|
|
43
|
+
CLOUDFLARE_AI_MODEL: text,
|
|
44
|
+
BLOG_CLOUDFLARE_AI_MODEL: text,
|
|
45
|
+
CHAT_CLOUDFLARE_AI_MODEL: text,
|
|
29
46
|
// Model overrides, all optional (models.ts): purpose variable, then the
|
|
30
47
|
// provider-wide *_MODEL, then a verified free default.
|
|
31
48
|
OPENROUTER_MODEL: text,
|
package/dist/env.d.ts
CHANGED
|
@@ -11,6 +11,19 @@ declare const aiEnvSchema: z.ZodObject<{
|
|
|
11
11
|
OPENROUTER_ALLOW_PAID_MODELS: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<boolean, string | undefined>>;
|
|
12
12
|
GEMINI_API_KEY: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<string | undefined, string | undefined>>;
|
|
13
13
|
NVIDIA_API_KEY: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<string | undefined, string | undefined>>;
|
|
14
|
+
GROQ_API_KEY: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<string | undefined, string | undefined>>;
|
|
15
|
+
GROQ_MODEL: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<string | undefined, string | undefined>>;
|
|
16
|
+
BLOG_GROQ_MODEL: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<string | undefined, string | undefined>>;
|
|
17
|
+
CHAT_GROQ_MODEL: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<string | undefined, string | undefined>>;
|
|
18
|
+
MISTRAL_API_KEY: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<string | undefined, string | undefined>>;
|
|
19
|
+
MISTRAL_MODEL: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<string | undefined, string | undefined>>;
|
|
20
|
+
BLOG_MISTRAL_MODEL: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<string | undefined, string | undefined>>;
|
|
21
|
+
CHAT_MISTRAL_MODEL: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<string | undefined, string | undefined>>;
|
|
22
|
+
CLOUDFLARE_ACCOUNT_ID: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<string | undefined, string | undefined>>;
|
|
23
|
+
CLOUDFLARE_AI_API_TOKEN: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<string | undefined, string | undefined>>;
|
|
24
|
+
CLOUDFLARE_AI_MODEL: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<string | undefined, string | undefined>>;
|
|
25
|
+
BLOG_CLOUDFLARE_AI_MODEL: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<string | undefined, string | undefined>>;
|
|
26
|
+
CHAT_CLOUDFLARE_AI_MODEL: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<string | undefined, string | undefined>>;
|
|
14
27
|
OPENROUTER_MODEL: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<string | undefined, string | undefined>>;
|
|
15
28
|
GEMINI_MODEL: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<string | undefined, string | undefined>>;
|
|
16
29
|
NVIDIA_MODEL: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<string | undefined, string | undefined>>;
|
package/dist/env.js
CHANGED
package/dist/index.js
CHANGED
package/dist/providers.d.ts
CHANGED
|
@@ -70,6 +70,13 @@ interface AiHealth {
|
|
|
70
70
|
declare const createAiHealth: () => AiHealth;
|
|
71
71
|
/** One per server instance, for request handlers; tests and scripts get a fresh one by default. */
|
|
72
72
|
declare const sharedAiHealth: AiHealth;
|
|
73
|
+
/**
|
|
74
|
+
* Quota and slowness pass quickly; a wrong key or a retired model needs an
|
|
75
|
+
* operator, so it cools longer; an empty prepaid wallet (402) needs someone to
|
|
76
|
+
* add funds, so it cools longest and costs one probe per half hour instead of
|
|
77
|
+
* a failed round trip on every request.
|
|
78
|
+
*/
|
|
79
|
+
declare function cooldownFor(outcome: AiOutcome): number;
|
|
73
80
|
interface AiServiceDeps {
|
|
74
81
|
providers: readonly AiProvider[];
|
|
75
82
|
/** Per-provider attempt budget. */
|
|
@@ -188,12 +195,35 @@ interface ProviderAdapter {
|
|
|
188
195
|
/** Lowercase id used in AI_PROVIDER_ORDER. */
|
|
189
196
|
readonly id: string;
|
|
190
197
|
readonly label: string;
|
|
198
|
+
/**
|
|
199
|
+
* Purposes it joins under the default order; unset means all. An explicit
|
|
200
|
+
* AI_PROVIDER_ORDER(_BLOG|_CHAT) still adds it anywhere it is listed.
|
|
201
|
+
*/
|
|
202
|
+
readonly defaultPurposes?: readonly TextPurpose[];
|
|
191
203
|
/** Costs money: runs only with AI_ALLOW_PAID. */
|
|
192
204
|
paid(env: AiEnv): boolean;
|
|
193
205
|
/** Env variable names still missing; empty means configured. Never values. */
|
|
194
206
|
missing(env: AiEnv): string[];
|
|
195
207
|
create(context: AdapterContext): AiProvider;
|
|
196
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Defaults for the free-tier adapters that take an id per purpose. The blog
|
|
211
|
+
* writes long JSON, so it gets the larger model; chat gets the faster one.
|
|
212
|
+
*/
|
|
213
|
+
declare const FREE_TEXT_MODELS: {
|
|
214
|
+
readonly groq: {
|
|
215
|
+
readonly blog: "openai/gpt-oss-120b";
|
|
216
|
+
readonly chat: "openai/gpt-oss-20b";
|
|
217
|
+
};
|
|
218
|
+
readonly mistral: {
|
|
219
|
+
readonly blog: "mistral-medium-latest";
|
|
220
|
+
readonly chat: "mistral-small-latest";
|
|
221
|
+
};
|
|
222
|
+
readonly cloudflare: {
|
|
223
|
+
readonly blog: "@cf/openai/gpt-oss-120b";
|
|
224
|
+
readonly chat: "@cf/openai/gpt-oss-20b";
|
|
225
|
+
};
|
|
226
|
+
};
|
|
197
227
|
/**
|
|
198
228
|
* Defaults for the paid adapters. Model names change often: set the
|
|
199
229
|
* provider's *_MODEL variable rather than relying on these.
|
|
@@ -290,4 +320,4 @@ declare function checkChain(env: AiEnv, purpose: TextPurpose, options?: {
|
|
|
290
320
|
*/
|
|
291
321
|
declare function realProviders(env: AiEnv, purpose: TextPurpose, fetchImpl?: typeof fetch, adapters?: readonly ProviderAdapter[]): AiProvider[];
|
|
292
322
|
|
|
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 };
|
|
323
|
+
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, FREE_TEXT_MODELS as F, HEDGE, type JsonMode, PAID_TEXT_MODELS as P, type AdapterState as a, anthropicOutcome, anthropicProvider, type AdapterStatus as b, type ProviderAdapter as c, cooldownFor, 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
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
HEDGE,
|
|
5
5
|
anthropicOutcome,
|
|
6
6
|
anthropicProvider,
|
|
7
|
+
cooldownFor,
|
|
7
8
|
createAiHealth,
|
|
8
9
|
createAiService,
|
|
9
10
|
geminiOutcome,
|
|
@@ -14,7 +15,7 @@ import {
|
|
|
14
15
|
realProviders,
|
|
15
16
|
sharedAiHealth,
|
|
16
17
|
vertexProvider
|
|
17
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-BK4B76LD.js";
|
|
18
19
|
import "./chunk-BL4J2JNX.js";
|
|
19
20
|
import "./chunk-PMC3UWQ2.js";
|
|
20
21
|
import "./chunk-RBSLNDFW.js";
|
|
@@ -24,6 +25,7 @@ export {
|
|
|
24
25
|
HEDGE,
|
|
25
26
|
anthropicOutcome,
|
|
26
27
|
anthropicProvider,
|
|
28
|
+
cooldownFor,
|
|
27
29
|
createAiHealth,
|
|
28
30
|
createAiService,
|
|
29
31
|
geminiOutcome,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sahan-sac/ai-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
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": {
|
|
@@ -92,6 +92,6 @@
|
|
|
92
92
|
"scripts": {
|
|
93
93
|
"build": "tsup",
|
|
94
94
|
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json",
|
|
95
|
-
"test": "node --conditions=react-server
|
|
95
|
+
"test": "node ../../scripts/test-package.mjs --conditions=react-server"
|
|
96
96
|
}
|
|
97
97
|
}
|