zayra-provider 0.0.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/README.md ADDED
@@ -0,0 +1,251 @@
1
+ # zayra-provider
2
+
3
+ The universal AI provider integration layer of **ZAYRA AI**. It defines one standard interface every AI platform connects through — Groq, Gemini, OpenAI, OpenRouter, local models, and custom providers — so ZAYRA can connect, manage, and use any of them the same way.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install zayra-provider
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```js
14
+ import { createProvider, ProviderRegistry } from "zayra-provider";
15
+
16
+ const geminiProvider = createProvider({
17
+ name: "gemini",
18
+ async models() {
19
+ return [{ name: "gemini-2.0-flash", capabilities: ["chat"], contextLength: 32000, speed: "fast" }];
20
+ },
21
+ async chat(request) { /* call Gemini's chat API */ },
22
+ async generate(request) { /* call Gemini's generate API */ },
23
+ async status() { return { status: "online" }; },
24
+ });
25
+
26
+ const registry = new ProviderRegistry();
27
+ registry.register(geminiProvider);
28
+
29
+ const response = await registry.chat("gemini", { messages: [{ role: "user", content: "hi" }] });
30
+ ```
31
+
32
+ ## The provider interface
33
+
34
+ Every provider supports exactly five members:
35
+
36
+ ```
37
+ name -> string
38
+ models() -> Promise<ModelInfo[]>
39
+ chat() -> Promise<any>
40
+ generate() -> Promise<any>
41
+ status() -> Promise<{ status, details? }>
42
+ ```
43
+
44
+ ```
45
+ Provider -> Model list -> AI request
46
+ ```
47
+
48
+ Two ways to build one:
49
+
50
+ **Extend `BaseProvider`** (batteries included — a credential helper, and unoverridden methods throw a clear error instead of doing nothing):
51
+
52
+ ```js
53
+ import { BaseProvider } from "zayra-provider";
54
+
55
+ class GroqProvider extends BaseProvider {
56
+ constructor() { super({ name: "groq" }); }
57
+ async models() { /* ... */ }
58
+ async chat(request) {
59
+ const apiKey = this.resolveApiKey(); // never stored on `this`
60
+ /* ... */
61
+ }
62
+ async generate(request) { /* ... */ }
63
+ async status() { /* ... */ }
64
+ }
65
+ ```
66
+
67
+ **Or use `createProvider()`** for a quick functional provider from plain functions (shown in Usage above). Both approaches produce the same validated shape — `isValidProvider(x)` checks structurally, not via `instanceof`.
68
+
69
+ ## Supported provider types
70
+
71
+ Groq, Gemini, OpenAI, OpenRouter, Local Models, and Custom Providers are all just implementations of the same interface — nothing about the architecture favors one platform, and adding a new one means writing one more conforming object. zayra-provider itself doesn't ship concrete Groq/Gemini/OpenAI/OpenRouter clients (those are separate plugin packages, e.g. `groq-provider`, `gemini-provider` — see Plugin Compatibility below); it defines and manages the interface they all implement.
72
+
73
+ ## Dynamic provider registration
74
+
75
+ ```js
76
+ registry.register(GeminiProvider);
77
+ registry.unregister("gemini");
78
+ registry.list(); // ["gemini", ...]
79
+ registry.has("gemini");
80
+ ```
81
+
82
+ ## Model Information System
83
+
84
+ ```js
85
+ await registry.listModels("gemini");
86
+ // [{ name, capabilities, contextLength, speed, available }, ...]
87
+ ```
88
+
89
+ Every provider reports this consistently — `normalizeModelInfo()` fills in defaults (`speed: "balanced"`, `available: true`, `capabilities: []`) for whatever a provider omits.
90
+
91
+ ## Provider health check
92
+
93
+ Every call made *through the registry* (`chat`, `generate`, `checkStatus`) is automatically timed and recorded:
94
+
95
+ ```js
96
+ await registry.chat("gemini", { messages: [...] });
97
+
98
+ registry.getHealth("gemini");
99
+ // { status: "online", lastCheckedAt, lastError, callCount, errorCount, averageResponseTimeMs }
100
+
101
+ await registry.checkAllStatus(); // pings every registered provider, tolerant of individual failures
102
+ ```
103
+
104
+ Status is `"online"` after a recent success, `"offline"` immediately after a failure, and `"degraded"` if the overall failure rate crosses 50% even though the most recent call succeeded.
105
+
106
+ ## API key management
107
+
108
+ zayra-provider never stores an API key. `resolveApiKey()` reads one on demand, checking a `zayra-config`-shaped object first (if given), then environment variables using the same `<PROVIDER_NAME>_API_KEY` convention `zayra-config` uses:
109
+
110
+ ```js
111
+ import { resolveApiKey } from "zayra-provider";
112
+
113
+ const key = resolveApiKey("groq", { config: myZayraConfigManager }); // or just { env: process.env }
114
+ ```
115
+
116
+ ```bash
117
+ # .env
118
+ GROQ_API_KEY=...
119
+ GEMINI_API_KEY=...
120
+ ```
121
+
122
+ ## Automatic provider switching (fallback communication)
123
+
124
+ zayra-provider makes failures typed and observable (`AuthenticationError` / `ConnectionError` / `ModelError`) and tracks health — that's what lets an orchestrator decide to switch. **Choosing which provider to try next is zayra-ai-router's job** (its `FallbackManager`), not this package's — per the "no model selection decisions" rule below. To connect the two:
125
+
126
+ ```js
127
+ import { toRouterProviderPlugin } from "zayra-provider";
128
+ import { AIRouter } from "zayra-ai-router";
129
+
130
+ const router = new AIRouter();
131
+ router.registerProvider(toRouterProviderPlugin(geminiProvider));
132
+ router.registerProvider(toRouterProviderPlugin(groqProvider));
133
+
134
+ await router.discoverModels();
135
+ await router.route({ task: "chat" }); // groq fails -> automatically tries gemini
136
+ ```
137
+
138
+ `toRouterProviderPlugin()` is a pure shape translation (`chat`/`generate` -> `invoke`, `status` -> `isAvailable`, `contextLength` -> `contextSize`) — it doesn't decide anything itself.
139
+
140
+ ## Plugin compatibility
141
+
142
+ Providers are plugins. Real integrations live in their own packages and register into a `ProviderRegistry`:
143
+
144
+ ```
145
+ plugins/
146
+ groq-provider
147
+ gemini-provider
148
+ custom-provider
149
+ ```
150
+
151
+ ## Local provider support
152
+
153
+ Local runners (Ollama, self-hosted models) are wired up via an injectable **transport** — the part that actually talks to your local runner — rather than a hardcoded, unverified HTTP client:
154
+
155
+ ```js
156
+ import { createLocalProvider } from "zayra-provider";
157
+
158
+ const local = createLocalProvider({
159
+ name: "local",
160
+ transport: {
161
+ async chat(request) {
162
+ const res = await fetch("http://localhost:11434/api/chat", {
163
+ method: "POST",
164
+ body: JSON.stringify({ model: request.model, messages: request.messages }),
165
+ });
166
+ if (!res.ok) throw new Error(`Ollama returned ${res.status}`);
167
+ return res.json();
168
+ },
169
+ async ping() {
170
+ const res = await fetch("http://localhost:11434/api/tags");
171
+ return res.ok;
172
+ },
173
+ },
174
+ models: [{ name: "llama3", speed: "balanced" }],
175
+ });
176
+ ```
177
+
178
+ The Ollama wiring above is illustrative — zayra-provider doesn't ship or test a live Ollama integration itself; you supply the transport for whatever you're actually running.
179
+
180
+ ## Errors
181
+
182
+ - `ProviderError` — base error class; carries `err.providerName`
183
+ - `AuthenticationError` — invalid/missing/expired API key
184
+ - `ConnectionError` — network failure (including a local runner being unreachable)
185
+ - `ModelError` — requested model unavailable/unknown
186
+
187
+ ```js
188
+ import { AuthenticationError, ConnectionError } from "zayra-provider";
189
+
190
+ try {
191
+ await registry.chat("groq", { messages: [...] });
192
+ } catch (err) {
193
+ if (err instanceof AuthenticationError) {
194
+ console.error("Bad API key for", err.providerName);
195
+ }
196
+ }
197
+ ```
198
+
199
+ ## What this package deliberately does NOT do
200
+
201
+ Per its design rules:
202
+
203
+ - No frontend UI
204
+ - No user accounts
205
+ - No hardcoded API keys
206
+ - No model selection decisions — every registry method takes an explicit provider `name`; choosing *which* one is zayra-ai-router's job
207
+
208
+ It only manages the connection between ZAYRA and AI platforms.
209
+
210
+ ## Project structure
211
+
212
+ ```
213
+ zayra-provider/
214
+ src/
215
+ index.js # public entry point
216
+ provider.js # standard interface, BaseProvider, createProvider(), router adapter
217
+ registry.js # ProviderRegistry (dynamic registration + health wiring)
218
+ models.js # Model Information System
219
+ health.js # ProviderHealthMonitor
220
+ credentials.js # API key resolution (never stored)
221
+ local.js # local/self-hosted provider support
222
+ errors.js # ProviderError, AuthenticationError, ConnectionError, ModelError
223
+ package.json
224
+ README.md
225
+ ```
226
+
227
+ ## API reference
228
+
229
+ ### `class ProviderRegistry`
230
+
231
+ | Method | Description |
232
+ | --- | --- |
233
+ | `register(provider)` | Registers a provider. |
234
+ | `unregister(name)` | Removes a provider and its health history. |
235
+ | `get(name)` / `has(name)` / `list()` | Lookup. |
236
+ | `listModels(name)` | Delegates to the provider's `models()`. |
237
+ | `chat(name, request)` / `generate(name, request)` | Health-tracked calls. |
238
+ | `checkStatus(name)` / `checkAllStatus()` | Health-tracked `status()` calls. |
239
+ | `getHealth(name)` / `getAllHealth()` | Reads recorded health. |
240
+
241
+ ### Other exports
242
+
243
+ `BaseProvider`, `createProvider()`, `isValidProvider()`, `toRouterProviderPlugin()`, `createLocalProvider()`, `normalizeModelInfo()`, `isValidModelInfo()`, `ProviderHealthMonitor`, `HealthStatus`, `getApiKeyFromEnv()`, `resolveApiKey()`, `ProviderError`, `AuthenticationError`, `ConnectionError`, `ModelError`
244
+
245
+ ## Compatibility
246
+
247
+ Designed to work with `zayra-core`, `zayra-config`, `zayra-ai-router`, and `zayra-plugins`.
248
+
249
+ ## License
250
+
251
+ MIT
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "zayra-provider",
3
+ "version": "0.0.1",
4
+ "description": "Universal AI provider integration layer for ZAYRA AI — a standard connection interface for Groq, Gemini, OpenAI, OpenRouter, local models, and custom providers.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18.0.0"
16
+ },
17
+ "scripts": {
18
+ "test": "node --test \"test/**/*.test.js\""
19
+ },
20
+ "keywords": [
21
+ "zayra",
22
+ "ai",
23
+ "provider",
24
+ "groq",
25
+ "gemini",
26
+ "openai",
27
+ "openrouter",
28
+ "ollama"
29
+ ],
30
+ "author": "",
31
+ "license": "MIT",
32
+ "dependencies": {}
33
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * credentials.js
3
+ *
4
+ * API Key Management (spec section: "API Key Management"). Resolves
5
+ * a provider's credential on demand — it is never stored, cached, or
6
+ * persisted anywhere in this package.
7
+ *
8
+ * Two sources, in priority order:
9
+ * 1. A zayra-config-shaped object, if given (duck-typed — anything
10
+ * with a getProviderApiKey(name) method works, so this package
11
+ * never takes a hard dependency on zayra-config itself).
12
+ * 2. Environment variables, using the same <NAME>_API_KEY
13
+ * convention zayra-config uses, so a key set either place is
14
+ * picked up consistently across the ecosystem.
15
+ */
16
+
17
+ const API_KEY_SUFFIX = "_API_KEY";
18
+
19
+ /**
20
+ * Reads a provider's API key directly from an env-like map, using
21
+ * the `<PROVIDER_NAME>_API_KEY` convention (e.g. "groq" -> GROQ_API_KEY).
22
+ *
23
+ * @param {string} providerName
24
+ * @param {Record<string, string>} [env] - Defaults to process.env.
25
+ * @returns {string|undefined}
26
+ */
27
+ export function getApiKeyFromEnv(providerName, env = process.env) {
28
+ return env[`${providerName.toUpperCase()}${API_KEY_SUFFIX}`];
29
+ }
30
+
31
+ /**
32
+ * Resolves a provider's API key: tries a zayra-config-shaped object
33
+ * first (if given), then falls back to environment variables.
34
+ * Returns undefined (not an error) if no key is configured anywhere
35
+ * — callers decide whether that's fatal for them (e.g. by throwing
36
+ * AuthenticationError themselves).
37
+ *
38
+ * @param {string} providerName
39
+ * @param {object} [options]
40
+ * @param {{ getProviderApiKey?: (name: string) => string|undefined }} [options.config] - A zayra-config ConfigManager instance, or anything shaped like one.
41
+ * @param {Record<string, string>} [options.env] - Defaults to process.env.
42
+ * @returns {string|undefined}
43
+ */
44
+ export function resolveApiKey(providerName, options = {}) {
45
+ const { config, env = process.env } = options;
46
+
47
+ if (config && typeof config.getProviderApiKey === "function") {
48
+ const fromConfig = config.getProviderApiKey(providerName);
49
+ if (fromConfig) {
50
+ return fromConfig;
51
+ }
52
+ }
53
+
54
+ return getApiKeyFromEnv(providerName, env);
55
+ }
56
+
57
+ export default {
58
+ getApiKeyFromEnv,
59
+ resolveApiKey,
60
+ };
package/src/errors.js ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * errors.js
3
+ *
4
+ * Custom error hierarchy for zayra-provider. Never fails silently —
5
+ * every error thrown inside this package is one of these classes, so
6
+ * callers (including zayra-ai-router, deciding whether to fall back)
7
+ * can branch on error type rather than parsing messages.
8
+ */
9
+
10
+ /**
11
+ * Base error class for all zayra-provider errors.
12
+ */
13
+ export class ProviderError extends Error {
14
+ /**
15
+ * @param {string} message
16
+ * @param {object} [options]
17
+ * @param {string} [options.code]
18
+ * @param {string} [options.providerName]
19
+ * @param {unknown} [options.cause]
20
+ */
21
+ constructor(message, options = {}) {
22
+ super(message);
23
+
24
+ this.name = this.constructor.name;
25
+ this.code = options.code ?? "PROVIDER_ERROR";
26
+ this.providerName = options.providerName ?? null;
27
+
28
+ if (options.cause !== undefined) {
29
+ this.cause = options.cause;
30
+ }
31
+
32
+ if (typeof Error.captureStackTrace === "function") {
33
+ Error.captureStackTrace(this, this.constructor);
34
+ }
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Thrown when a provider rejects a request due to a missing, invalid,
40
+ * or expired API key/credential.
41
+ */
42
+ export class AuthenticationError extends ProviderError {
43
+ constructor(message, options = {}) {
44
+ super(message, { code: "AUTHENTICATION_ERROR", ...options });
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Thrown for network-level failures: timeouts, DNS/connection
50
+ * failures, unreachable hosts (including a local runner like Ollama
51
+ * not running).
52
+ */
53
+ export class ConnectionError extends ProviderError {
54
+ constructor(message, options = {}) {
55
+ super(message, { code: "CONNECTION_ERROR", ...options });
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Thrown when a requested model is unavailable, unknown to the
61
+ * provider, or otherwise can't serve the request.
62
+ */
63
+ export class ModelError extends ProviderError {
64
+ constructor(message, options = {}) {
65
+ super(message, { code: "MODEL_ERROR", ...options });
66
+ }
67
+ }
68
+
69
+ export default {
70
+ ProviderError,
71
+ AuthenticationError,
72
+ ConnectionError,
73
+ ModelError,
74
+ };
package/src/health.js ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * health.js
3
+ *
4
+ * Provider Health Check (spec section: "Provider Health Check").
5
+ * Tracks online/offline status, errors, and response time per
6
+ * provider, purely as in-memory bookkeeping. ProviderRegistry
7
+ * updates this automatically whenever it calls through to a
8
+ * provider — see registry.js.
9
+ */
10
+
11
+ /**
12
+ * @readonly
13
+ * @enum {string}
14
+ */
15
+ export const HealthStatus = Object.freeze({
16
+ ONLINE: "online",
17
+ OFFLINE: "offline",
18
+ DEGRADED: "degraded",
19
+ UNKNOWN: "unknown",
20
+ });
21
+
22
+ const DEGRADED_FAILURE_RATE_THRESHOLD = 0.5;
23
+
24
+ /**
25
+ * Tracks a single provider's health over time.
26
+ *
27
+ * @example
28
+ * const health = new ProviderHealthMonitor();
29
+ * health.recordSuccess(120);
30
+ * health.getStatus(); // { status: "online", ... }
31
+ */
32
+ export class ProviderHealthMonitor {
33
+ constructor() {
34
+ this._status = HealthStatus.UNKNOWN;
35
+ this._lastCheckedAt = null;
36
+ this._lastError = null;
37
+ this._callCount = 0;
38
+ this._errorCount = 0;
39
+ this._totalResponseTimeMs = 0;
40
+ this._successResponseCount = 0;
41
+ }
42
+
43
+ /**
44
+ * Records a successful call.
45
+ * @param {number} responseTimeMs
46
+ */
47
+ recordSuccess(responseTimeMs) {
48
+ this._callCount += 1;
49
+ this._successResponseCount += 1;
50
+ this._totalResponseTimeMs += responseTimeMs;
51
+ this._lastCheckedAt = new Date().toISOString();
52
+ this._lastError = null;
53
+ this._status = this._computeStatus();
54
+ }
55
+
56
+ /**
57
+ * Records a failed call.
58
+ * @param {Error|unknown} [error]
59
+ */
60
+ recordFailure(error) {
61
+ this._callCount += 1;
62
+ this._errorCount += 1;
63
+ this._lastCheckedAt = new Date().toISOString();
64
+ this._lastError = error?.message ?? (error !== undefined ? String(error) : "Unknown error");
65
+ this._status = this._computeStatus();
66
+ }
67
+
68
+ /**
69
+ * Derives online/offline/degraded from recent call history. The
70
+ * most recent call matters most (a single failure right now means
71
+ * "offline", not "degraded"), while the overall failure rate
72
+ * nudges an otherwise-successful provider to "degraded".
73
+ * @returns {string}
74
+ * @private
75
+ */
76
+ _computeStatus() {
77
+ if (this._callCount === 0) {
78
+ return HealthStatus.UNKNOWN;
79
+ }
80
+
81
+ if (this._lastError !== null) {
82
+ return HealthStatus.OFFLINE;
83
+ }
84
+
85
+ const failureRate = this._errorCount / this._callCount;
86
+ return failureRate >= DEGRADED_FAILURE_RATE_THRESHOLD ? HealthStatus.DEGRADED : HealthStatus.ONLINE;
87
+ }
88
+
89
+ /**
90
+ * @returns {{
91
+ * status: string,
92
+ * lastCheckedAt: string|null,
93
+ * lastError: string|null,
94
+ * callCount: number,
95
+ * errorCount: number,
96
+ * averageResponseTimeMs: number|null
97
+ * }}
98
+ */
99
+ getStatus() {
100
+ return {
101
+ status: this._status,
102
+ lastCheckedAt: this._lastCheckedAt,
103
+ lastError: this._lastError,
104
+ callCount: this._callCount,
105
+ errorCount: this._errorCount,
106
+ averageResponseTimeMs:
107
+ this._successResponseCount > 0 ? this._totalResponseTimeMs / this._successResponseCount : null,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Resets all recorded history back to the initial UNKNOWN state.
113
+ */
114
+ reset() {
115
+ this._status = HealthStatus.UNKNOWN;
116
+ this._lastCheckedAt = null;
117
+ this._lastError = null;
118
+ this._callCount = 0;
119
+ this._errorCount = 0;
120
+ this._totalResponseTimeMs = 0;
121
+ this._successResponseCount = 0;
122
+ }
123
+ }
124
+
125
+ export default ProviderHealthMonitor;
package/src/index.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * index.js
3
+ *
4
+ * Public entry point for the zayra-provider package.
5
+ *
6
+ * Anything a consumer needs should be exported from here — they
7
+ * should never need to import from "zayra-provider/src/...". This
8
+ * keeps the internal file layout free to change without breaking
9
+ * zayra-core, zayra-config, zayra-ai-router, or zayra-plugins.
10
+ */
11
+
12
+ export { BaseProvider, createProvider, isValidProvider, toRouterProviderPlugin } from "./provider.js";
13
+ export { ProviderRegistry } from "./registry.js";
14
+ export { createLocalProvider } from "./local.js";
15
+ export { normalizeModelInfo, isValidModelInfo } from "./models.js";
16
+ export { ProviderHealthMonitor, HealthStatus } from "./health.js";
17
+ export { getApiKeyFromEnv, resolveApiKey } from "./credentials.js";
18
+ export { ProviderError, AuthenticationError, ConnectionError, ModelError } from "./errors.js";
19
+
20
+ export { ProviderRegistry as default } from "./registry.js";
package/src/local.js ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * local.js
3
+ *
4
+ * Local Provider Support (spec section: "Local Provider Support").
5
+ * Local runners (Ollama, self-hosted models) don't need API keys and
6
+ * usually speak HTTP to localhost, but the exact wire format is
7
+ * specific to whatever runner is in use.
8
+ *
9
+ * Rather than hardcoding one local runner's HTTP API (which this
10
+ * package couldn't verify against a real, running instance from
11
+ * here, and which would risk shipping an untested integration),
12
+ * createLocalProvider() takes an injectable `transport` — the actual
13
+ * chat/generate/model-listing logic your local runner needs — and
14
+ * wraps it into a conforming Provider. This keeps local support
15
+ * genuinely testable (inject a fake transport in tests) while still
16
+ * giving real local runners a ready-made scaffold.
17
+ */
18
+
19
+ import { ConnectionError, ProviderError } from "./errors.js";
20
+ import { normalizeModelInfo } from "./models.js";
21
+
22
+ /**
23
+ * @typedef {object} LocalTransport
24
+ * @property {(request: object) => Promise<any>} [chat]
25
+ * @property {(request: object) => Promise<any>} [generate]
26
+ * @property {() => Promise<object[]>} [listModels]
27
+ * @property {() => Promise<boolean>} [ping] - Should resolve true/false, or reject, to indicate reachability.
28
+ */
29
+
30
+ /**
31
+ * Builds a local-runner provider from a caller-supplied transport.
32
+ *
33
+ * @example
34
+ * // Real Ollama wiring (illustrative — not shipped/tested by this package):
35
+ * const ollama = createLocalProvider({
36
+ * name: "local",
37
+ * transport: {
38
+ * async chat(request) {
39
+ * const res = await fetch("http://localhost:11434/api/chat", {
40
+ * method: "POST",
41
+ * body: JSON.stringify({ model: request.model, messages: request.messages }),
42
+ * });
43
+ * if (!res.ok) throw new Error(`Ollama returned ${res.status}`);
44
+ * return res.json();
45
+ * },
46
+ * async ping() {
47
+ * const res = await fetch("http://localhost:11434/api/tags");
48
+ * return res.ok;
49
+ * },
50
+ * },
51
+ * models: [{ name: "llama3", speed: "balanced" }],
52
+ * });
53
+ *
54
+ * @param {object} options
55
+ * @param {string} options.name
56
+ * @param {LocalTransport} options.transport
57
+ * @param {object[]} [options.models] - Static model list, used if transport.listModels isn't provided.
58
+ * @returns {import("./provider.js").Provider}
59
+ */
60
+ export function createLocalProvider(options = {}) {
61
+ const { name, transport, models: staticModels = [] } = options;
62
+
63
+ if (typeof name !== "string" || name.length === 0) {
64
+ throw new ProviderError("createLocalProvider() requires a non-empty string name.");
65
+ }
66
+ if (!transport || typeof transport !== "object") {
67
+ throw new ProviderError(`Local provider "${name}" requires a transport.`, { providerName: name });
68
+ }
69
+
70
+ return {
71
+ name,
72
+
73
+ async models() {
74
+ const raw = typeof transport.listModels === "function" ? await transport.listModels() : staticModels;
75
+ return raw.map((m) => normalizeModelInfo(m));
76
+ },
77
+
78
+ async chat(request) {
79
+ if (typeof transport.chat !== "function") {
80
+ throw new ProviderError(`Local provider "${name}" transport does not implement chat().`, {
81
+ providerName: name,
82
+ });
83
+ }
84
+ try {
85
+ return await transport.chat(request);
86
+ } catch (err) {
87
+ throw new ConnectionError(`Local provider "${name}" chat request failed: ${err.message}`, {
88
+ providerName: name,
89
+ cause: err,
90
+ });
91
+ }
92
+ },
93
+
94
+ async generate(request) {
95
+ if (typeof transport.generate !== "function") {
96
+ throw new ProviderError(`Local provider "${name}" transport does not implement generate().`, {
97
+ providerName: name,
98
+ });
99
+ }
100
+ try {
101
+ return await transport.generate(request);
102
+ } catch (err) {
103
+ throw new ConnectionError(`Local provider "${name}" generate request failed: ${err.message}`, {
104
+ providerName: name,
105
+ cause: err,
106
+ });
107
+ }
108
+ },
109
+
110
+ async status() {
111
+ if (typeof transport.ping !== "function") {
112
+ // No way to check reachability — report unknown rather than
113
+ // guessing "online".
114
+ return { status: "unknown", details: "Transport does not implement ping()." };
115
+ }
116
+ try {
117
+ const reachable = await transport.ping();
118
+ return reachable ? { status: "online" } : { status: "offline" };
119
+ } catch (err) {
120
+ return { status: "offline", details: err.message };
121
+ }
122
+ },
123
+ };
124
+ }
125
+
126
+ export default {
127
+ createLocalProvider,
128
+ };
package/src/models.js ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * models.js
3
+ *
4
+ * Model Information System (spec section: "Model Information
5
+ * System"). Every provider exposes model metadata through its
6
+ * models() method; this file defines and normalizes that shape so
7
+ * every provider reports it consistently: model name, capabilities,
8
+ * context length, speed, and availability.
9
+ */
10
+
11
+ import { ModelError } from "./errors.js";
12
+
13
+ /**
14
+ * @typedef {object} ModelInfo
15
+ * @property {string} name
16
+ * @property {string[]} capabilities - e.g. ["chat", "coding"]. Empty means general-purpose.
17
+ * @property {number} [contextLength] - Context window size, in tokens.
18
+ * @property {"fast"|"balanced"|"slow"} [speed="balanced"]
19
+ * @property {boolean} [available=true]
20
+ */
21
+
22
+ /**
23
+ * Normalizes a raw model info object (as a provider might return it)
24
+ * by filling in defaults for optional fields.
25
+ *
26
+ * @param {object} raw
27
+ * @returns {ModelInfo}
28
+ * @throws {ModelError} If `raw` is missing a name.
29
+ */
30
+ export function normalizeModelInfo(raw) {
31
+ if (!raw || typeof raw.name !== "string" || raw.name.length === 0) {
32
+ throw new ModelError("Model info requires a non-empty string name.");
33
+ }
34
+
35
+ return {
36
+ name: raw.name,
37
+ capabilities: Array.isArray(raw.capabilities) ? raw.capabilities : [],
38
+ contextLength: typeof raw.contextLength === "number" ? raw.contextLength : undefined,
39
+ speed: raw.speed ?? "balanced",
40
+ available: raw.available !== undefined ? Boolean(raw.available) : true,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Returns true if `value` is at least minimally shaped like a
46
+ * ModelInfo (has a non-empty string name).
47
+ * @param {unknown} value
48
+ * @returns {boolean}
49
+ */
50
+ export function isValidModelInfo(value) {
51
+ return typeof value === "object" && value !== null && typeof value.name === "string" && value.name.length > 0;
52
+ }
53
+
54
+ export default {
55
+ normalizeModelInfo,
56
+ isValidModelInfo,
57
+ };
@@ -0,0 +1,204 @@
1
+ /**
2
+ * provider.js
3
+ *
4
+ * Provider Interface System (spec section: "Provider Interface
5
+ * System"). Defines the standard shape every provider must support:
6
+ *
7
+ * name
8
+ * models()
9
+ * chat()
10
+ * generate()
11
+ * status()
12
+ *
13
+ * Two ways to build a conforming provider:
14
+ * - extend BaseProvider (batteries included: credential resolution helper, clear "not implemented" errors)
15
+ * - or hand-roll a plain object with the same five members (validated structurally, not via instanceof)
16
+ *
17
+ * Either way, zayra-provider itself never implements a real network
18
+ * call to any specific AI platform — concrete providers (groq-provider,
19
+ * gemini-provider, etc.) are separate packages that plug into this
20
+ * shape, matching the ecosystem's Plugin Compatibility model.
21
+ */
22
+
23
+ import { ProviderError } from "./errors.js";
24
+ import { resolveApiKey } from "./credentials.js";
25
+
26
+ /**
27
+ * @typedef {object} Provider
28
+ * @property {string} name
29
+ * @property {() => Promise<import("./models.js").ModelInfo[]>} models
30
+ * @property {(request: object) => Promise<any>} chat
31
+ * @property {(request: object) => Promise<any>} generate
32
+ * @property {() => Promise<{ status: string, details?: string }>} status
33
+ */
34
+
35
+ /**
36
+ * Returns true if `value` structurally satisfies the Provider shape:
37
+ * a name, and models()/chat()/generate()/status() functions.
38
+ *
39
+ * @param {unknown} value
40
+ * @returns {boolean}
41
+ */
42
+ export function isValidProvider(value) {
43
+ return (
44
+ typeof value === "object" &&
45
+ value !== null &&
46
+ typeof value.name === "string" &&
47
+ value.name.length > 0 &&
48
+ typeof value.models === "function" &&
49
+ typeof value.chat === "function" &&
50
+ typeof value.generate === "function" &&
51
+ typeof value.status === "function"
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Optional base class for building a provider. Subclasses override
57
+ * models()/chat()/generate()/status(); the unoverridden defaults
58
+ * throw a clear ProviderError rather than doing nothing, so a
59
+ * forgotten method fails loudly during development instead of
60
+ * silently misbehaving at runtime.
61
+ *
62
+ * @example
63
+ * class MyProvider extends BaseProvider {
64
+ * constructor() { super({ name: "my-provider" }); }
65
+ * async models() { return [{ name: "my-model" }]; }
66
+ * async chat(request) { ... }
67
+ * async generate(request) { ... }
68
+ * async status() { return { status: "online" }; }
69
+ * }
70
+ */
71
+ export class BaseProvider {
72
+ /**
73
+ * @param {object} options
74
+ * @param {string} options.name
75
+ * @param {string} [options.apiKeyEnvName] - Overrides which env var / config key this provider's credential is looked up under. Defaults to `name`.
76
+ */
77
+ constructor(options = {}) {
78
+ if (typeof options.name !== "string" || options.name.length === 0) {
79
+ throw new ProviderError("A provider requires a non-empty string name.");
80
+ }
81
+
82
+ this.name = options.name;
83
+ this._apiKeyEnvName = options.apiKeyEnvName ?? options.name;
84
+ }
85
+
86
+ /**
87
+ * Resolves this provider's API key on demand — never stored on
88
+ * `this` or anywhere else in this package. See credentials.js.
89
+ *
90
+ * @param {object} [options]
91
+ * @param {object} [options.config] - A zayra-config-shaped object.
92
+ * @param {Record<string, string>} [options.env]
93
+ * @returns {string|undefined}
94
+ */
95
+ resolveApiKey(options = {}) {
96
+ return resolveApiKey(this._apiKeyEnvName, options);
97
+ }
98
+
99
+ /** @returns {Promise<import("./models.js").ModelInfo[]>} */
100
+ async models() {
101
+ throw new ProviderError(`Provider "${this.name}" does not implement models().`, { providerName: this.name });
102
+ }
103
+
104
+ /** @param {object} request @returns {Promise<any>} */
105
+ async chat(_request) {
106
+ throw new ProviderError(`Provider "${this.name}" does not implement chat().`, { providerName: this.name });
107
+ }
108
+
109
+ /** @param {object} request @returns {Promise<any>} */
110
+ async generate(_request) {
111
+ throw new ProviderError(`Provider "${this.name}" does not implement generate().`, { providerName: this.name });
112
+ }
113
+
114
+ /** @returns {Promise<{ status: string, details?: string }>} */
115
+ async status() {
116
+ throw new ProviderError(`Provider "${this.name}" does not implement status().`, { providerName: this.name });
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Functional alternative to extending BaseProvider: builds a
122
+ * conforming provider from plain functions. Useful for quick custom
123
+ * providers that don't need a full class.
124
+ *
125
+ * @param {object} options
126
+ * @param {string} options.name
127
+ * @param {() => Promise<object[]>} options.models
128
+ * @param {(request: object) => Promise<any>} options.chat
129
+ * @param {(request: object) => Promise<any>} options.generate
130
+ * @param {() => Promise<{ status: string, details?: string }>} options.status
131
+ * @returns {Provider}
132
+ * @throws {ProviderError} If any required member is missing.
133
+ */
134
+ export function createProvider(options = {}) {
135
+ const provider = {
136
+ name: options.name,
137
+ models: options.models,
138
+ chat: options.chat,
139
+ generate: options.generate,
140
+ status: options.status,
141
+ };
142
+
143
+ if (!isValidProvider(provider)) {
144
+ throw new ProviderError(
145
+ 'createProvider() requires { name, models, chat, generate, status } — a name and all four functions.'
146
+ );
147
+ }
148
+
149
+ return provider;
150
+ }
151
+
152
+ /**
153
+ * Adapts a zayra-provider Provider into the plugin shape
154
+ * zayra-ai-router's ProviderRegistry expects ({ name, isAvailable,
155
+ * listModels, invoke }), so the two packages interoperate without
156
+ * either one hardcoding knowledge of the other. This is a pure
157
+ * shape/method translation — it makes no decisions about which
158
+ * provider or model to use, so it stays within zayra-provider's
159
+ * "connection only" mandate.
160
+ *
161
+ * request.messages present -> delegates to chat(); otherwise -> generate().
162
+ *
163
+ * @param {Provider} provider
164
+ * @returns {{ name: string, isAvailable: () => Promise<boolean>, listModels: () => Promise<object[]>, invoke: (request: object) => Promise<any> }}
165
+ */
166
+ export function toRouterProviderPlugin(provider) {
167
+ if (!isValidProvider(provider)) {
168
+ throw new ProviderError("toRouterProviderPlugin() requires a valid Provider.");
169
+ }
170
+
171
+ return {
172
+ name: provider.name,
173
+ async isAvailable() {
174
+ try {
175
+ const result = await provider.status();
176
+ return result?.status === "online";
177
+ } catch {
178
+ return false;
179
+ }
180
+ },
181
+ async listModels() {
182
+ const models = await provider.models();
183
+ return Array.isArray(models)
184
+ ? models.map((m) => ({
185
+ name: m.name,
186
+ capabilities: m.capabilities ?? [],
187
+ speed: m.speed ?? "balanced",
188
+ contextSize: m.contextLength,
189
+ status: m.available === false ? "unavailable" : "available",
190
+ }))
191
+ : [];
192
+ },
193
+ async invoke(request) {
194
+ return Array.isArray(request?.messages) ? provider.chat(request) : provider.generate(request);
195
+ },
196
+ };
197
+ }
198
+
199
+ export default {
200
+ isValidProvider,
201
+ BaseProvider,
202
+ createProvider,
203
+ toRouterProviderPlugin,
204
+ };
@@ -0,0 +1,199 @@
1
+ /**
2
+ * registry.js
3
+ *
4
+ * Dynamic Provider Registration (spec section: "Dynamic Provider
5
+ * Registration") + Provider Health Check wiring. Registers providers
6
+ * at runtime and routes calls through them while automatically
7
+ * recording health (online/offline, errors, response time) for
8
+ * every call that goes through the registry.
9
+ *
10
+ * Per the package's rules, this never decides *which* provider to
11
+ * use for a request — every method here takes the provider name as
12
+ * an explicit argument. Choosing a provider is zayra-ai-router's job.
13
+ */
14
+
15
+ import { ProviderError, ConnectionError } from "./errors.js";
16
+ import { isValidProvider } from "./provider.js";
17
+ import { ProviderHealthMonitor } from "./health.js";
18
+
19
+ /**
20
+ * Registers and manages Provider instances, and tracks their health.
21
+ *
22
+ * @example
23
+ * const registry = new ProviderRegistry();
24
+ * registry.register(groqProvider);
25
+ * await registry.chat("groq", { messages: [...] });
26
+ * registry.getHealth("groq"); // { status: "online", ... }
27
+ */
28
+ export class ProviderRegistry {
29
+ constructor() {
30
+ /** @type {Map<string, import("./provider.js").Provider>} */
31
+ this._providers = new Map();
32
+ /** @type {Map<string, ProviderHealthMonitor>} */
33
+ this._health = new Map();
34
+ }
35
+
36
+ /**
37
+ * Registers a provider.
38
+ * @param {import("./provider.js").Provider} provider
39
+ * @returns {this}
40
+ * @throws {ProviderError} If malformed or already registered.
41
+ */
42
+ register(provider) {
43
+ if (!isValidProvider(provider)) {
44
+ throw new ProviderError(
45
+ 'A provider requires a string "name" and models()/chat()/generate()/status() functions.'
46
+ );
47
+ }
48
+
49
+ if (this._providers.has(provider.name)) {
50
+ throw new ProviderError(`Provider "${provider.name}" is already registered.`, { providerName: provider.name });
51
+ }
52
+
53
+ this._providers.set(provider.name, provider);
54
+ this._health.set(provider.name, new ProviderHealthMonitor());
55
+ return this;
56
+ }
57
+
58
+ /**
59
+ * Removes a registered provider and its health history.
60
+ * @param {string} name
61
+ * @returns {boolean} True if a provider was removed.
62
+ */
63
+ unregister(name) {
64
+ this._health.delete(name);
65
+ return this._providers.delete(name);
66
+ }
67
+
68
+ /**
69
+ * @param {string} name
70
+ * @returns {import("./provider.js").Provider}
71
+ * @throws {ProviderError} If not registered.
72
+ */
73
+ get(name) {
74
+ const provider = this._providers.get(name);
75
+ if (!provider) {
76
+ throw new ProviderError(`Provider "${name}" is not registered.`, { providerName: name });
77
+ }
78
+ return provider;
79
+ }
80
+
81
+ /** @param {string} name @returns {boolean} */
82
+ has(name) {
83
+ return this._providers.has(name);
84
+ }
85
+
86
+ /** @returns {string[]} Names of every registered provider. */
87
+ list() {
88
+ return [...this._providers.keys()];
89
+ }
90
+
91
+ /**
92
+ * @param {string} name
93
+ * @returns {ReturnType<ProviderHealthMonitor["getStatus"]>|null}
94
+ */
95
+ getHealth(name) {
96
+ return this._health.get(name)?.getStatus() ?? null;
97
+ }
98
+
99
+ /** @returns {Array<{ name: string } & ReturnType<ProviderHealthMonitor["getStatus"]>>} */
100
+ getAllHealth() {
101
+ return this.list().map((name) => ({ name, ...this.getHealth(name) }));
102
+ }
103
+
104
+ /**
105
+ * Lists a provider's models. Not health-tracked (model metadata
106
+ * lookups aren't the primary reliability signal — chat/generate/
107
+ * status calls are).
108
+ * @param {string} name
109
+ * @returns {Promise<import("./models.js").ModelInfo[]>}
110
+ */
111
+ async listModels(name) {
112
+ return this.get(name).models();
113
+ }
114
+
115
+ /**
116
+ * Calls a provider's status() and records the outcome into its
117
+ * health history.
118
+ * @param {string} name
119
+ * @returns {Promise<{ status: string, details?: string }>}
120
+ */
121
+ async checkStatus(name) {
122
+ return this._callTracked(name, "status", []);
123
+ }
124
+
125
+ /**
126
+ * Checks every registered provider's status, tolerant of
127
+ * individual failures (one provider being down doesn't stop the
128
+ * others from being checked).
129
+ * @returns {Promise<Array<{ name: string, status: string, details?: string, error?: string }>>}
130
+ */
131
+ async checkAllStatus() {
132
+ return Promise.all(
133
+ this.list().map(async (name) => {
134
+ try {
135
+ const result = await this.checkStatus(name);
136
+ return { name, ...result };
137
+ } catch (err) {
138
+ return { name, status: "offline", error: err.message };
139
+ }
140
+ })
141
+ );
142
+ }
143
+
144
+ /**
145
+ * Sends a chat request to a specific provider, with health tracking.
146
+ * @param {string} name
147
+ * @param {object} request
148
+ * @returns {Promise<any>}
149
+ */
150
+ async chat(name, request) {
151
+ return this._callTracked(name, "chat", [request]);
152
+ }
153
+
154
+ /**
155
+ * Sends a generate request to a specific provider, with health tracking.
156
+ * @param {string} name
157
+ * @param {object} request
158
+ * @returns {Promise<any>}
159
+ */
160
+ async generate(name, request) {
161
+ return this._callTracked(name, "generate", [request]);
162
+ }
163
+
164
+ /**
165
+ * Calls `provider[method](...args)`, timing it and recording the
166
+ * outcome into that provider's health monitor.
167
+ *
168
+ * @param {string} name
169
+ * @param {"chat"|"generate"|"status"} method
170
+ * @param {any[]} args
171
+ * @returns {Promise<any>}
172
+ * @private
173
+ */
174
+ async _callTracked(name, method, args) {
175
+ const provider = this.get(name);
176
+ const health = this._health.get(name);
177
+ const startedAt = Date.now();
178
+
179
+ try {
180
+ const result = await provider[method](...args);
181
+ health.recordSuccess(Date.now() - startedAt);
182
+ return result;
183
+ } catch (err) {
184
+ health.recordFailure(err);
185
+ // Re-throw as-is if it's already one of this package's typed
186
+ // errors (AuthenticationError/ConnectionError/ModelError/
187
+ // ProviderError) — only wrap genuinely unknown failures.
188
+ if (err instanceof ProviderError) {
189
+ throw err;
190
+ }
191
+ throw new ConnectionError(`Provider "${name}" ${method}() failed: ${err.message}`, {
192
+ providerName: name,
193
+ cause: err,
194
+ });
195
+ }
196
+ }
197
+ }
198
+
199
+ export default ProviderRegistry;