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 +251 -0
- package/package.json +33 -0
- package/src/credentials.js +60 -0
- package/src/errors.js +74 -0
- package/src/health.js +125 -0
- package/src/index.js +20 -0
- package/src/local.js +128 -0
- package/src/models.js +57 -0
- package/src/provider.js +204 -0
- package/src/registry.js +199 -0
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
|
+
};
|
package/src/provider.js
ADDED
|
@@ -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
|
+
};
|
package/src/registry.js
ADDED
|
@@ -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;
|