@x12i/ai-providers-router 4.8.5 → 4.8.6
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 +511 -321
- package/dist/logger.d.ts +11 -14
- package/dist/logger.js +23 -69
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,24 +1,50 @@
|
|
|
1
1
|
# @x12i/ai-providers-router
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Unified **LLM provider router** for Node.js. Routes requests to installed provider packages using the **ProviderModule** architecture from [`@x12i/ai-provider-interface`](https://www.npmjs.com/package/@x12i/ai-provider-interface).
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
- **OpenRouter Mode**: Access 353+ models from 67 providers using catalog-driven routing
|
|
7
|
-
- Chooses a provider/model (and optionally a fallback chain)
|
|
8
|
-
- Loads ProviderModules from installed provider packages (lazy import)
|
|
9
|
-
- Uses router-side adapters to convert requests to ProviderSDKCallSpec
|
|
10
|
-
- Executes via ProviderModule.execute() / stream() / submitBatch()
|
|
11
|
-
- Parses responses using router-side adapters
|
|
12
|
-
- Returns standardized responses with lossless rawResponse
|
|
5
|
+
**Highlights**
|
|
13
6
|
|
|
14
|
-
|
|
7
|
+
- **Multi-provider routing** — OpenAI, Grok, and more via lazy-loaded provider packages
|
|
8
|
+
- **OpenRouter mode** — Access 350+ models from 60+ providers through one API key
|
|
9
|
+
- **Sync, stream, and batch** — Gated by each provider's declared capabilities
|
|
10
|
+
- **Fallback chains** — Automatic provider/model failover with full attempt traces
|
|
11
|
+
- **Structured diagnostics** — Usage, cost, timing, and ordered `metadata.attempts[]`
|
|
12
|
+
- **Reasoning support** — Cross-vendor effort, visibility, and encrypted trace handling
|
|
13
|
+
- **ERC 2.0** — Zero-config initialization from environment variables
|
|
14
|
+
- **Structured logging** — Powered by [`@x12i/logxer`](https://www.npmjs.com/package/@x12i/logxer)
|
|
15
|
+
|
|
16
|
+
> This router **never installs** provider packages at runtime. You must install the packages you intend to use.
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
- **Router Adapters**: Router-side adapters convert router requests to ProviderSDKCallSpec and parse responses
|
|
18
|
-
- **Capability Gating**: Router gates execution by `provider.capabilities.modes.sync/stream/batch` (ProviderModule is source of truth)
|
|
19
|
-
- **Execution Semantics**: Router owns execution semantics (timeoutMs, retries, idempotencyKey, signal)
|
|
18
|
+
---
|
|
20
19
|
|
|
21
|
-
|
|
20
|
+
## Table of contents
|
|
21
|
+
|
|
22
|
+
- [Install](#install)
|
|
23
|
+
- [Quick start](#quick-start)
|
|
24
|
+
- [Architecture](#architecture)
|
|
25
|
+
- [Provider IDs](#provider-ids)
|
|
26
|
+
- [OpenRouter mode](#openrouter-mode)
|
|
27
|
+
- [Configuration](#configuration)
|
|
28
|
+
- [Logging](#logging)
|
|
29
|
+
- [API usage](#api-usage)
|
|
30
|
+
- [Sync](#sync-call)
|
|
31
|
+
- [Streaming](#streaming-call)
|
|
32
|
+
- [Batch](#batch-requests)
|
|
33
|
+
- [Request and response types](#request-and-response-types)
|
|
34
|
+
- [Trace diagnostics](#trace-diagnostics)
|
|
35
|
+
- [Reasoning](#reasoning)
|
|
36
|
+
- [Fallback chains](#fallback-chains)
|
|
37
|
+
- [Interceptors](#interceptors)
|
|
38
|
+
- [Health checks](#health-checks)
|
|
39
|
+
- [AIGateway](#aigateway)
|
|
40
|
+
- [Response normalization and cost](#response-normalization-and-cost)
|
|
41
|
+
- [Error types](#error-types)
|
|
42
|
+
- [Manual setup (advanced)](#manual-setup-advanced)
|
|
43
|
+
- [Public API exports](#public-api-exports)
|
|
44
|
+
- [Provider packages](#provider-packages)
|
|
45
|
+
- [Development and testing](#development-and-testing)
|
|
46
|
+
- [Related documentation](#related-documentation)
|
|
47
|
+
- [License](#license)
|
|
22
48
|
|
|
23
49
|
---
|
|
24
50
|
|
|
@@ -28,470 +54,634 @@ This router:
|
|
|
28
54
|
npm i @x12i/ai-providers-router
|
|
29
55
|
```
|
|
30
56
|
|
|
31
|
-
|
|
57
|
+
**Bundled provider packages** (included as dependencies):
|
|
58
|
+
|
|
59
|
+
- `@x12i/ai-provider-openai` — OpenAI and OpenRouter-compatible APIs
|
|
60
|
+
- `@x12i/ai-provider-grok` — Grok / xAI
|
|
61
|
+
|
|
62
|
+
**Optional provider packages** (install when you need direct access):
|
|
32
63
|
|
|
33
64
|
```bash
|
|
34
|
-
npm i @x12i/ai-provider-openai
|
|
35
65
|
npm i @x12i/ai-provider-anthropic
|
|
36
66
|
npm i @x12i/ai-provider-google
|
|
37
|
-
npm i @x12i/ai-provider-xai
|
|
38
67
|
npm i @x12i/ai-provider-groq
|
|
68
|
+
# ... other @x12i/ai-provider-* packages
|
|
39
69
|
```
|
|
40
70
|
|
|
41
|
-
|
|
71
|
+
For **OpenRouter mode**, only `@x12i/ai-provider-openai` is required to reach models from many vendors through OpenRouter's unified API.
|
|
42
72
|
|
|
43
73
|
---
|
|
44
74
|
|
|
45
|
-
##
|
|
75
|
+
## Quick start
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { createRouter, type AIRouterRequest, type AIResponse } from '@x12i/ai-providers-router';
|
|
79
|
+
|
|
80
|
+
const router = await createRouter();
|
|
46
81
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
82
|
+
const req: AIRouterRequest = {
|
|
83
|
+
request: {
|
|
84
|
+
messages: [{ role: 'user', content: 'Write 3 bullets about routers.' }],
|
|
85
|
+
config: { model: 'gpt-4o-mini', maxTokens: 200 },
|
|
86
|
+
},
|
|
87
|
+
provider: 'openai',
|
|
88
|
+
mode: 'sync',
|
|
89
|
+
};
|
|
54
90
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
91
|
+
const res: AIResponse = await router.invoke(req);
|
|
92
|
+
console.log(res.outputText);
|
|
93
|
+
console.log(res.usage);
|
|
94
|
+
console.log(res.rawResponse); // always present — lossless provider payload
|
|
95
|
+
```
|
|
59
96
|
|
|
60
|
-
|
|
61
|
-
>
|
|
62
|
-
> * Grok is **xAI** (`xai`)
|
|
63
|
-
> * Groq is **GroqCloud** (`groq`)
|
|
97
|
+
Set provider API keys in your environment (see [Configuration](#configuration)). With no arguments, `createRouter()` auto-discovers settings via ERC.
|
|
64
98
|
|
|
65
99
|
---
|
|
66
100
|
|
|
67
|
-
##
|
|
101
|
+
## Architecture
|
|
68
102
|
|
|
69
|
-
|
|
103
|
+
```
|
|
104
|
+
AIRouterRequest
|
|
105
|
+
→ request interceptors (e.g. OpenRouter routing)
|
|
106
|
+
→ ProviderModule (from installed @x12i/ai-provider-* package)
|
|
107
|
+
→ router-side adapter (request → ProviderSDKCallSpec)
|
|
108
|
+
→ provider.execute() | stream() | submitBatch()
|
|
109
|
+
→ router-side adapter (ProviderSDKExecResult → AIResponse)
|
|
110
|
+
→ response interceptors
|
|
111
|
+
→ AIResponse (with lossless rawResponse)
|
|
112
|
+
```
|
|
70
113
|
|
|
71
|
-
|
|
114
|
+
| Layer | Role |
|
|
115
|
+
|-------|------|
|
|
116
|
+
| **ProviderModule** | Provider packages implement `@x12i/ai-provider-interface` |
|
|
117
|
+
| **Router adapters** | Convert router requests to `ProviderSDKCallSpec` and parse responses |
|
|
118
|
+
| **Capability gating** | Router checks `provider.capabilities.modes.sync/stream/batch` |
|
|
119
|
+
| **Execution semantics** | Router owns `timeoutMs`, retries, `idempotencyKey`, `AbortSignal` |
|
|
72
120
|
|
|
73
|
-
|
|
74
|
-
- **Seamless API**: Use the same provider names (`"openai"`, `"grok"`, `"anthropic"`, etc.) - no code changes needed
|
|
75
|
-
- **Smart Provider Inference**: Uses catalog data to automatically infer providers from model names (e.g., `"gpt-4o"` → `"openai"`)
|
|
76
|
-
- **Model Validation**: Validates models against available OpenRouter catalog and warns about invalid models
|
|
77
|
-
- **Provider Aliases**: Supports vendor mappings (e.g., `xai` models route to `grok` provider)
|
|
78
|
-
- **Model Name Mapping**: Automatically converts provider + model to OpenRouter format (e.g., `provider: "openai"` + `model: "gpt-4o"` → `"openai/gpt-4o"`)
|
|
79
|
-
- **Access any OpenRouter model**: Call models even without direct provider packages (e.g., `"meta-llama/llama-3-70b-instruct"`)
|
|
80
|
-
- **Unified Reasoning API**: Cross-vendor reasoning support with effort control and visibility options (see [Reasoning Integration](./docs/reasoning-integration.md))
|
|
81
|
-
- **No ai-io-normalizer**: OpenRouter responses are parsed directly (faster, simpler)
|
|
121
|
+
---
|
|
82
122
|
|
|
83
|
-
|
|
123
|
+
## Provider IDs
|
|
84
124
|
|
|
85
|
-
**
|
|
125
|
+
**Direct providers** (require matching `@x12i/ai-provider-*` package and API key):
|
|
86
126
|
|
|
87
|
-
|
|
127
|
+
| ID | Vendor |
|
|
128
|
+
|----|--------|
|
|
129
|
+
| `openai` | OpenAI |
|
|
130
|
+
| `grok` | Grok / xAI |
|
|
131
|
+
| `anthropic` | Claude |
|
|
132
|
+
| `google` | Gemini |
|
|
133
|
+
| `groq` | GroqCloud |
|
|
88
134
|
|
|
89
|
-
|
|
90
|
-
export OPEN_ROUTER_KEY=sk-or-your-openrouter-api-key-here
|
|
91
|
-
```
|
|
135
|
+
**OpenRouter** (unified gateway):
|
|
92
136
|
|
|
93
|
-
|
|
137
|
+
| ID | Role |
|
|
138
|
+
|----|------|
|
|
139
|
+
| `openrouter` | Explicit OpenRouter transport |
|
|
140
|
+
| Any vendor ID | Routed through OpenRouter when OpenRouter mode is active |
|
|
94
141
|
|
|
95
|
-
|
|
96
|
-
- ✅ **Manual initialization**: `new LLMProviderRouter()` - automatically detects OpenRouter mode via environment variable
|
|
97
|
-
- ✅ **Any provider name**: Use `config.provider: "openai"`, `"grok"`, `"anthropic"`, etc. - all route through OpenRouter automatically
|
|
142
|
+
> **Grok ≠ Groq** — Grok is xAI (`grok` / `xai`). Groq is GroqCloud (`groq`).
|
|
98
143
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## OpenRouter mode
|
|
147
|
+
|
|
148
|
+
OpenRouter is a unified API gateway. When enabled, calls can use familiar provider names (`openai`, `grok`, `anthropic`, …) while traffic routes through OpenRouter with automatic model mapping (e.g. `openai` + `gpt-4o` → `openai/gpt-4o`).
|
|
149
|
+
|
|
150
|
+
### Enable OpenRouter
|
|
151
|
+
|
|
152
|
+
Set an API key (canonical name preferred):
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
export OPENROUTER_API_KEY=sk-or-your-key-here
|
|
156
|
+
# Legacy alias also supported:
|
|
157
|
+
# export OPEN_ROUTER_KEY=sk-or-your-key-here
|
|
158
|
+
```
|
|
104
159
|
|
|
105
|
-
|
|
160
|
+
OpenRouter mode activates automatically when a valid key is present. To disable explicitly:
|
|
106
161
|
|
|
107
162
|
```bash
|
|
108
163
|
export USE_OPENROUTER=false
|
|
109
164
|
```
|
|
110
165
|
|
|
111
|
-
|
|
166
|
+
Optional ranking headers:
|
|
112
167
|
|
|
113
|
-
|
|
168
|
+
```bash
|
|
169
|
+
export OPENROUTER_HTTP_REFERER=https://your-site.com # legacy: OPEN_ROUTER_HTTP_REFERER
|
|
170
|
+
export OPENROUTER_X_TITLE=Your Site Name # legacy: OPEN_ROUTER_X_TITLE
|
|
171
|
+
```
|
|
114
172
|
|
|
115
|
-
|
|
116
|
-
1. ✅ Check that `OPEN_ROUTER_KEY` is set: `echo $OPEN_ROUTER_KEY`
|
|
117
|
-
2. ✅ Verify the key is valid (not empty, doesn't start with "ENV.")
|
|
118
|
-
3. ✅ Ensure `config.provider` is specified in your request (e.g., `config: { provider: "openai", model: "gpt-4o" }`)
|
|
119
|
-
4. ✅ The OpenRouter adapter is always registered - no additional setup needed
|
|
173
|
+
### Behavior
|
|
120
174
|
|
|
121
|
-
|
|
175
|
+
- Works with `createRouter()` and `new LLMProviderRouter()` — auto-registration on first call
|
|
176
|
+
- Provider names stay the same in your code; the router handles routing internally
|
|
177
|
+
- Catalog data (`.metadata/openrouter_catalog_with_vendor_mapping.json`) drives model validation and provider inference
|
|
178
|
+
- When OpenRouter mode is active, direct provider packages are **not** auto-registered (avoids singleton config conflicts)
|
|
179
|
+
- Responses are parsed directly from OpenAI-compatible formats (no `ai-io-normalizer` on the OpenRouter path)
|
|
122
180
|
|
|
123
|
-
###
|
|
181
|
+
### Examples
|
|
124
182
|
|
|
125
|
-
**
|
|
183
|
+
**Same provider name, OpenRouter underneath:**
|
|
126
184
|
|
|
127
185
|
```ts
|
|
128
|
-
const router = await createRouter();
|
|
129
|
-
|
|
130
|
-
// Works exactly the same whether OpenRouter mode is on or off
|
|
131
186
|
const req: AIRouterRequest = {
|
|
132
|
-
request: {
|
|
133
|
-
|
|
134
|
-
config: { model: 'gpt-4o' },
|
|
135
|
-
},
|
|
136
|
-
provider: 'openai', // Still use "openai" - router handles routing
|
|
187
|
+
request: { messages: [{ role: 'user', content: 'Hello!' }], config: { model: 'gpt-4o' } },
|
|
188
|
+
provider: 'openai',
|
|
137
189
|
mode: 'sync',
|
|
138
190
|
};
|
|
139
|
-
|
|
140
|
-
const res = await router.invoke(req);
|
|
141
|
-
// Model automatically mapped to "openai/gpt-4o" when using OpenRouter
|
|
191
|
+
await router.invoke(req);
|
|
142
192
|
```
|
|
143
193
|
|
|
144
|
-
**
|
|
194
|
+
**OpenRouter model format directly:**
|
|
145
195
|
|
|
146
196
|
```ts
|
|
147
|
-
// Router infers provider from model name
|
|
148
197
|
const req: AIRouterRequest = {
|
|
149
198
|
request: {
|
|
150
199
|
messages: [{ role: 'user', content: 'Hello!' }],
|
|
151
|
-
config: { model: '
|
|
200
|
+
config: { model: 'anthropic/claude-3-opus' },
|
|
152
201
|
},
|
|
153
|
-
|
|
202
|
+
provider: 'openrouter',
|
|
154
203
|
mode: 'sync',
|
|
155
204
|
};
|
|
156
|
-
|
|
157
|
-
const res = await router.invoke(req);
|
|
205
|
+
await router.invoke(req);
|
|
158
206
|
```
|
|
159
207
|
|
|
160
|
-
**
|
|
208
|
+
**Provider inference from model name** (no `provider` field):
|
|
161
209
|
|
|
162
210
|
```ts
|
|
163
|
-
// Call any OpenRouter-supported model using OpenRouter's format
|
|
164
211
|
const req: AIRouterRequest = {
|
|
165
|
-
request: {
|
|
166
|
-
messages: [{ role: 'user', content: 'Hello!' }],
|
|
167
|
-
config: { model: 'anthropic/claude-3-opus' }, // Direct OpenRouter format
|
|
168
|
-
},
|
|
169
|
-
provider: 'openrouter', // Use "openrouter" provider
|
|
212
|
+
request: { messages: [{ role: 'user', content: 'Hello!' }], config: { model: 'gpt-4o' } },
|
|
170
213
|
mode: 'sync',
|
|
171
214
|
};
|
|
172
|
-
|
|
173
|
-
const res = await router.invoke(req);
|
|
215
|
+
await router.invoke(req); // infers openai
|
|
174
216
|
```
|
|
175
217
|
|
|
176
|
-
|
|
218
|
+
### Troubleshooting
|
|
177
219
|
|
|
178
|
-
|
|
179
|
-
// Access Meta Llama models without installing @x12i/ai-provider-meta
|
|
180
|
-
const req: AIRouterRequest = {
|
|
181
|
-
request: {
|
|
182
|
-
messages: [{ role: 'user', content: 'Hello!' }],
|
|
183
|
-
config: { model: 'meta-llama/llama-3-70b-instruct' },
|
|
184
|
-
},
|
|
185
|
-
provider: 'openrouter',
|
|
186
|
-
mode: 'sync',
|
|
187
|
-
};
|
|
220
|
+
If you see *"No provider specified and no providers registered"*:
|
|
188
221
|
|
|
189
|
-
|
|
190
|
-
|
|
222
|
+
1. Confirm `OPENROUTER_API_KEY` (or `OPEN_ROUTER_KEY`) is set and non-empty
|
|
223
|
+
2. Ensure the key does not start with `ENV.` (unresolved placeholder)
|
|
224
|
+
3. Set `config.provider` in the request (e.g. `{ provider: 'openai', model: 'gpt-4o' }`)
|
|
225
|
+
4. The OpenRouter adapter is always registered — no extra setup required
|
|
191
226
|
|
|
192
|
-
|
|
227
|
+
See also [debugging guide](./docs/debugging-no-provider-error.md).
|
|
193
228
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Configuration
|
|
197
232
|
|
|
198
|
-
|
|
199
|
-
const geminiReq = { request: { messages: [{ role: 'user', content: 'Hello!' }], config: { model: 'gemini-pro' } }, provider: 'google', mode: 'sync' };
|
|
233
|
+
### Zero-config (`createRouter`)
|
|
200
234
|
|
|
201
|
-
|
|
202
|
-
|
|
235
|
+
```ts
|
|
236
|
+
import { createRouter } from '@x12i/ai-providers-router';
|
|
203
237
|
|
|
204
|
-
|
|
205
|
-
const results = await Promise.all([
|
|
206
|
-
router.invoke(claudeReq),
|
|
207
|
-
router.invoke(geminiReq),
|
|
208
|
-
router.invoke(groqReq),
|
|
209
|
-
]);
|
|
238
|
+
const router = await createRouter(); // reads process.env
|
|
210
239
|
```
|
|
211
240
|
|
|
212
|
-
###
|
|
241
|
+
### Programmatic (advanced mode)
|
|
213
242
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
243
|
+
```ts
|
|
244
|
+
const router = await createRouter({
|
|
245
|
+
logLevel: 'info',
|
|
246
|
+
verbose: false,
|
|
247
|
+
timeoutMs: 60_000,
|
|
248
|
+
fallbackChain: [{ provider: 'openai', model: 'gpt-4o-mini' }, { provider: 'grok', model: 'grok-2' }],
|
|
249
|
+
openrouter: { apiKey: 'sk-or-...', httpReferer: 'https://example.com', xTitle: 'My App' },
|
|
250
|
+
usageTracker: {
|
|
251
|
+
recordRequest(e) {
|
|
252
|
+
// provider, timestamp, duration, tokens, cost, success
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
providerConfigs: {
|
|
256
|
+
openai: { apiKey: 'sk-...', baseURL: 'https://api.openai.com/v1' },
|
|
257
|
+
grok: { apiKey: 'xai-...' },
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
```
|
|
218
261
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
262
|
+
Passing any explicit config object to `createRouter(config)` overrides zero-config env discovery for that call.
|
|
263
|
+
|
|
264
|
+
### Environment variables
|
|
265
|
+
|
|
266
|
+
| Variable | Default | Description |
|
|
267
|
+
|----------|---------|-------------|
|
|
268
|
+
| **Router** | | |
|
|
269
|
+
| `AI_PROVIDER_ROUTER_LOGS_LEVEL` | *(see logging)* | Canonical log threshold via logxer (`error`, `warn`, `info`, `debug`, `verbose`, `off`) |
|
|
270
|
+
| `AI_PROVIDER_ROUTER_LOG_LEVEL` | `info` | Legacy alias for log level (used when `_LOGS_LEVEL` is unset) |
|
|
271
|
+
| `AI_PROVIDER_ROUTER_VERBOSE` | `false` | Log full AI request/response payloads (sanitized) |
|
|
272
|
+
| `AI_PROVIDER_ROUTER_TIMEOUT_MS` | `60000` | Default operation timeout (ms) |
|
|
273
|
+
| **OpenAI** | | |
|
|
274
|
+
| `OPENAI_API_KEY` | — | Required for direct OpenAI calls |
|
|
275
|
+
| `OPENAI_API_BASE` | — | Custom API base URL |
|
|
276
|
+
| `OPENAI_ORGANIZATION` | — | Organization ID |
|
|
277
|
+
| **Grok / xAI** | | |
|
|
278
|
+
| `GROK_API_KEY` | — | Required for direct Grok calls |
|
|
279
|
+
| `XAI_API_BASE` | — | Custom xAI base URL |
|
|
280
|
+
| **OpenRouter** | | |
|
|
281
|
+
| `OPENROUTER_API_KEY` | — | Canonical OpenRouter key |
|
|
282
|
+
| `OPEN_ROUTER_KEY` | — | Legacy alias |
|
|
283
|
+
| `USE_OPENROUTER` | auto | `true` when key present; set `false` to disable |
|
|
284
|
+
| `OPENROUTER_HTTP_REFERER` | — | Optional ranking header |
|
|
285
|
+
| `OPENROUTER_X_TITLE` | — | Optional ranking header |
|
|
286
|
+
| **Other providers** | | |
|
|
287
|
+
| `ANTHROPIC_API_KEY`, `GOOGLE_API_KEY`, `GROQ_API_KEY`, … | — | Used when those providers are installed |
|
|
288
|
+
|
|
289
|
+
Full reference: [Environment variables](./docs/environment-variables.md) · [Configuration guide](./docs/CONFIGURATION_GUIDE.md)
|
|
223
290
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
- Extracts `usage` for token counts from both formats
|
|
228
|
-
- Adds `status: 'completed'` for compatibility
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
## Logging
|
|
229
294
|
|
|
230
|
-
|
|
295
|
+
The router uses [`@x12i/logxer`](https://www.npmjs.com/package/@x12i/logxer) for structured, package-scoped logging.
|
|
231
296
|
|
|
232
|
-
|
|
297
|
+
**Package prefix:** `AI_PROVIDER_ROUTER`
|
|
233
298
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
- **Fallback Patterns**: Uses legacy pattern matching when catalog data is unavailable:
|
|
299
|
+
```bash
|
|
300
|
+
# Canonical (preferred)
|
|
301
|
+
AI_PROVIDER_ROUTER_LOGS_LEVEL=info
|
|
238
302
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
- `grok-*`, `xai/*` → `"grok"`
|
|
242
|
-
- `gemini-*`, `google/*` → `"google"`
|
|
243
|
-
- `llama-*`, `meta-llama/*` → `"meta"`
|
|
244
|
-
- Default → `"openai"` (most common case)
|
|
303
|
+
# Legacy (still supported when _LOGS_LEVEL is unset)
|
|
304
|
+
AI_PROVIDER_ROUTER_LOG_LEVEL=info
|
|
245
305
|
|
|
246
|
-
|
|
306
|
+
# Log full AI request/response payloads (router-specific, separate from log level)
|
|
307
|
+
AI_PROVIDER_ROUTER_VERBOSE=true
|
|
308
|
+
```
|
|
247
309
|
|
|
248
|
-
|
|
310
|
+
**Log levels:** `error` · `warn` · `info` · `debug` · `verbose` · `off`
|
|
249
311
|
|
|
250
|
-
|
|
251
|
-
- **Alias Resolution**: Automatically resolves model aliases to canonical OpenRouter IDs
|
|
252
|
-
- **Capability Checking**: Validates model parameters against supported capabilities
|
|
253
|
-
- **Graceful Fallbacks**: Falls back to legacy logic if catalog loading fails
|
|
254
|
-
- **Format Support**: Handles both OpenAI Chat Completions and Responses API v1 formats
|
|
255
|
-
- **Encrypted Reasoning**: Processes encrypted reasoning traces (model thinking is privacy-protected)
|
|
256
|
-
- **Reasoning Parameter Support**: Enables reasoning effort levels for compatible models
|
|
312
|
+
When neither `_LOGS_LEVEL` nor `_LOG_LEVEL` is set and no programmatic `logLevel` is passed, logxer defaults to `warn`. The router's `createRouter()` zero-config path resolves `AI_PROVIDER_ROUTER_LOG_LEVEL` with default `info`.
|
|
257
313
|
|
|
258
|
-
**
|
|
259
|
-
- **67 Providers**: All current OpenRouter providers
|
|
260
|
-
- **353 Models**: Complete model catalog with aliases and capabilities
|
|
261
|
-
- **Vendor Mappings**: Direct API mappings for accurate routing
|
|
262
|
-
- **Auto-updating**: Uses latest catalog data from OpenRouter APIs
|
|
314
|
+
**Programmatic:**
|
|
263
315
|
|
|
264
|
-
|
|
316
|
+
```ts
|
|
317
|
+
import { createRouter, createLogger, getLogger } from '@x12i/ai-providers-router';
|
|
265
318
|
|
|
266
|
-
|
|
319
|
+
const router = await createRouter({ logLevel: 'debug', verbose: true });
|
|
267
320
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
export OPEN_ROUTER_X_TITLE=Your Site Name
|
|
321
|
+
// Or inject a custom logger instance
|
|
322
|
+
const router2 = await createRouter({ logger: createLogger({ level: 'info', verbose: false }) });
|
|
271
323
|
```
|
|
272
324
|
|
|
273
|
-
|
|
325
|
+
Verbose mode logs sanitized AI request/response payloads (passwords, secrets, and API keys are redacted). Cross-cutting log sinks (console, file, format) are configured at the host/app level via logxer — not per-package.
|
|
274
326
|
|
|
275
327
|
---
|
|
276
328
|
|
|
277
|
-
##
|
|
329
|
+
## API usage
|
|
278
330
|
|
|
279
|
-
|
|
331
|
+
### Sync call
|
|
280
332
|
|
|
281
333
|
```ts
|
|
282
|
-
import { createRouter } from '@x12i/ai-providers-router';
|
|
334
|
+
import { createRouter, type AIRouterRequest, type AIResponse } from '@x12i/ai-providers-router';
|
|
283
335
|
|
|
284
336
|
const router = await createRouter();
|
|
337
|
+
|
|
338
|
+
const req: AIRouterRequest = {
|
|
339
|
+
request: {
|
|
340
|
+
inputData: 'Write 3 bullets about routers.',
|
|
341
|
+
config: { model: 'gpt-4o-mini', maxTokens: 200, temperature: 0.7 },
|
|
342
|
+
},
|
|
343
|
+
provider: 'openai',
|
|
344
|
+
mode: 'sync',
|
|
345
|
+
exec: {
|
|
346
|
+
timeoutMs: 60_000,
|
|
347
|
+
idempotencyKey: 'optional-key',
|
|
348
|
+
signal: abortController.signal,
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const res: AIResponse = await router.invoke(req);
|
|
285
353
|
```
|
|
286
354
|
|
|
287
|
-
|
|
355
|
+
### Streaming call
|
|
288
356
|
|
|
289
357
|
```ts
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
358
|
+
const streamReq: AIRouterRequest = { ...req, mode: 'stream' };
|
|
359
|
+
|
|
360
|
+
for await (const ev of router.stream(streamReq)) {
|
|
361
|
+
switch (ev.type) {
|
|
362
|
+
case 'provider_raw':
|
|
363
|
+
console.log('Raw:', ev.raw);
|
|
364
|
+
break;
|
|
365
|
+
case 'output_text_delta':
|
|
366
|
+
process.stdout.write(ev.delta);
|
|
367
|
+
break;
|
|
368
|
+
case 'reasoning_summary_delta':
|
|
369
|
+
case 'reasoning_trace_delta':
|
|
370
|
+
// reasoning stream chunks
|
|
371
|
+
break;
|
|
372
|
+
case 'completed':
|
|
373
|
+
console.log('Final:', ev.response.outputText);
|
|
374
|
+
break;
|
|
375
|
+
case 'error':
|
|
376
|
+
console.error(ev.error);
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
### Batch requests
|
|
383
|
+
|
|
384
|
+
Batch is available only when `provider.capabilities.modes.batch === true`:
|
|
385
|
+
|
|
386
|
+
```ts
|
|
387
|
+
const items = [
|
|
388
|
+
{ request: { inputData: 'First', config: { model: 'gpt-4o-mini' } } },
|
|
389
|
+
{ request: { inputData: 'Second', config: { model: 'gpt-4o-mini' } } },
|
|
390
|
+
];
|
|
391
|
+
|
|
392
|
+
const batchResult = await router.createBatch('openai', items, {
|
|
393
|
+
timeoutMs: 120_000,
|
|
394
|
+
idempotencyKey: 'batch-1',
|
|
297
395
|
});
|
|
396
|
+
|
|
397
|
+
console.log(batchResult.items);
|
|
398
|
+
console.log(batchResult.rawBatch);
|
|
298
399
|
```
|
|
299
400
|
|
|
300
|
-
|
|
401
|
+
### Request and response types
|
|
301
402
|
|
|
302
|
-
|
|
403
|
+
| Type | Purpose |
|
|
404
|
+
|------|---------|
|
|
405
|
+
| `AIRouterRequest` | Router input (`request`, `provider`, `mode`, `exec`) |
|
|
406
|
+
| `AIResponse` | Sync output (`outputText`, `rawResponse`, `usage`, `reasoning`, `metadata`) |
|
|
407
|
+
| `AIStreamEvent` | Streaming events (`output_text_delta`, `completed`, `error`, …) |
|
|
408
|
+
| `AIBatchResponse` | Batch results |
|
|
409
|
+
| `RouterConfig` | Router-level settings |
|
|
410
|
+
| `ProviderModelRef` | `{ provider?, engine?, model? }` for fallback chains |
|
|
303
411
|
|
|
304
|
-
|
|
412
|
+
### Trace diagnostics
|
|
305
413
|
|
|
306
|
-
|
|
307
|
-
* `AIResponse` (sync output) - includes unified reasoning response
|
|
308
|
-
* `AIStreamEvent` (streaming output) - includes reasoning streaming events
|
|
309
|
-
* `AIBatchResponse` (batch output)
|
|
414
|
+
Every `AIResponse` includes stable, provider-agnostic diagnostics in `metadata`:
|
|
310
415
|
|
|
311
|
-
|
|
416
|
+
| Field | Description |
|
|
417
|
+
|-------|-------------|
|
|
418
|
+
| `metadata.provider` | Final provider used |
|
|
419
|
+
| `metadata.modelUsed` | Actual model that served the response |
|
|
420
|
+
| `metadata.costUsd` / `metadata.cost` | USD cost when reported (e.g. OpenRouter `usage.cost`) |
|
|
421
|
+
| `metadata.costStatus` | `'priced'` or `'unpriced'` |
|
|
422
|
+
| `metadata.maxTokensRequested` | Effective generation cap |
|
|
423
|
+
| `metadata.requestIds` | `{ routerRequestId, providerRequestId?, openrouterRequestId? }` |
|
|
424
|
+
| `metadata.timing` | `{ startedAt, endedAt, durationMs }` |
|
|
425
|
+
| `metadata.latencyMs` | Alias for `timing.durationMs` |
|
|
426
|
+
| `metadata.attempts[]` | Ordered retry + fallback trace |
|
|
427
|
+
| `response.output.parsed` | Structured fields when `outputContract` is set |
|
|
312
428
|
|
|
313
|
-
|
|
429
|
+
### Reasoning
|
|
314
430
|
|
|
315
|
-
|
|
316
|
-
- `response.metadata` (keys when known):
|
|
317
|
-
- `metadata.provider`: final provider used for the successful call (or last attempt)
|
|
318
|
-
- `metadata.modelUsed`: the actual model that served the response
|
|
319
|
-
- `metadata.maxTokensRequested`: final effective generation cap applied (if determinable)
|
|
320
|
-
- `metadata.costUsd` / `metadata.cost`: normalized USD cost when the provider reports it (e.g. OpenRouter `usage.cost`)
|
|
321
|
-
- `metadata.costStatus`: `'priced'` when `costUsd` is set; `'unpriced'` when usage exists but no cost was returned
|
|
322
|
-
- `response.output.parsed`: structured fields when `outputContract` is on the request (markdown sections → camelCase keys)
|
|
323
|
-
- `metadata.requestIds`: `{ routerRequestId, providerRequestId?, openrouterRequestId? }`
|
|
324
|
-
- `metadata.timing`: `{ startedAt, endedAt, durationMs }` (provider-call timing)
|
|
325
|
-
- `metadata.latencyMs`: alias for `metadata.timing.durationMs`
|
|
326
|
-
- `metadata.attempts[]`: ordered attempts across retries + fallbacks (authoritative execution trace)
|
|
431
|
+
Request unified reasoning controls via `request.config.reasoning`:
|
|
327
432
|
|
|
328
433
|
```ts
|
|
329
|
-
import type { AIRouterRequest, AIResponse } from '@x12i/ai-providers-router';
|
|
330
|
-
|
|
331
|
-
// Request reasoning with extended effort levels
|
|
332
434
|
config: {
|
|
333
435
|
reasoning: {
|
|
334
|
-
effort: 'high',
|
|
335
|
-
maxTokens: 2000,
|
|
336
|
-
visibility: 'trace',
|
|
337
|
-
onUnsupported: 'downgrade'
|
|
338
|
-
}
|
|
436
|
+
effort: 'high', // low | medium | high | xhigh (xhigh → high)
|
|
437
|
+
maxTokens: 2000, // Anthropic/Gemini max_tokens mode
|
|
438
|
+
visibility: 'trace', // none | summary | trace (best-effort)
|
|
439
|
+
onUnsupported: 'downgrade', // downgrade | error | ignore
|
|
440
|
+
},
|
|
339
441
|
}
|
|
340
|
-
|
|
341
|
-
// Access unified reasoning response
|
|
342
|
-
response.reasoning.artifacts.encrypted // Encrypted reasoning traces
|
|
343
|
-
response.reasoning.applied.effort // What was actually applied (may differ from requested)
|
|
344
|
-
response.reasoning.applied.visibility // What visibility was actually returned
|
|
345
|
-
response.reasoning.availability // Model capability flags
|
|
346
|
-
response.reasoning.warnings // Any downgrade/normalization warnings
|
|
347
442
|
```
|
|
348
443
|
|
|
349
|
-
|
|
350
|
-
- ✅ **Effort Control**: `low`, `medium`, `high`, `xhigh` (xhigh auto-normalized to high)
|
|
351
|
-
- ✅ **Max Tokens Control**: Direct `maxTokens` budget for Anthropic/Gemini models
|
|
352
|
-
- ✅ **Encrypted Traces**: Access encrypted reasoning artifacts (ciphertext not decryptable by user; only metadata/prefix logged)
|
|
353
|
-
- ✅ **Summary Visibility**: Human-readable reasoning summary (best-effort; returned only if provider returns `reasoning_details` with `reasoning.summary`; otherwise downgraded with warning)
|
|
354
|
-
- ✅ **Trace Visibility**: Encrypted or readable reasoning traces (best-effort; satisfied by either `reasoning.encrypted` artifacts or `reasoning.text` chunks; downgraded if not available)
|
|
355
|
-
- ✅ **Model Detection**: Automatic detection of reasoning-capable models via JSON registry (cross-vendor support)
|
|
356
|
-
- ✅ **Extended Support**: Works with OpenAI o-series models (o1, o3, o4 series - 10+ models), xAI Grok models, Anthropic Claude reasoning models, and Google Gemini reasoning models
|
|
444
|
+
Response fields: `response.reasoning.applied`, `response.reasoning.artifacts`, `response.reasoning.warnings`.
|
|
357
445
|
|
|
358
|
-
|
|
359
|
-
- **OpenAI o-series** (`openai/o*` pattern): `openai/o1`, `openai/o1-pro`, `openai/o3`, `openai/o3-mini`, `openai/o3-pro`, `openai/o3-deep-research`, `openai/o3-mini-high`, `openai/o4-mini`, `openai/o4-mini-deep-research`, `openai/o4-mini-high`
|
|
360
|
-
- **xAI Grok** (`x-ai/grok*` pattern): `x-ai/grok-4.1-fast` and other reasoning-enabled Grok models
|
|
361
|
-
- **Anthropic Claude** (`anthropic/claude*` pattern): Reasoning-enabled Claude models (uses `max_tokens` mode)
|
|
362
|
-
- **Google Gemini** (`google/gemini*` pattern): Reasoning-enabled Gemini models (uses `max_tokens` mode)
|
|
446
|
+
Supported models are tracked in `.metadata/reasoning-support.json`.
|
|
363
447
|
|
|
364
|
-
|
|
448
|
+
- [Reasoning integration guide](./docs/reasoning-integration.md)
|
|
449
|
+
- [Supported models](./docs/reasoning-supported-models.md)
|
|
365
450
|
|
|
366
|
-
|
|
451
|
+
### Fallback chains
|
|
367
452
|
|
|
368
|
-
|
|
453
|
+
On failure, the router tries the next candidate in order. Attempts are recorded in `metadata.attempts[]`. On exhaustion, throws `FallbackExhaustedError`.
|
|
369
454
|
|
|
370
|
-
|
|
455
|
+
**Router-level default chain:**
|
|
371
456
|
|
|
372
457
|
```ts
|
|
373
|
-
|
|
458
|
+
const router = await createRouter({
|
|
459
|
+
fallbackChain: [
|
|
460
|
+
{ provider: 'openai', model: 'gpt-4o' },
|
|
461
|
+
{ provider: 'grok', model: 'grok-2' },
|
|
462
|
+
],
|
|
463
|
+
});
|
|
464
|
+
```
|
|
374
465
|
|
|
375
|
-
|
|
466
|
+
**Per-request chain** (in `request.config`):
|
|
376
467
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
model: '
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
mode: 'sync',
|
|
388
|
-
exec: {
|
|
389
|
-
timeoutMs: 60000, // Optional: override default timeout
|
|
390
|
-
idempotencyKey: 'optional-key', // Optional: for idempotent requests
|
|
468
|
+
```ts
|
|
469
|
+
request: {
|
|
470
|
+
config: {
|
|
471
|
+
model: 'gpt-4o',
|
|
472
|
+
fallbackChain: [
|
|
473
|
+
{ provider: 'openai', model: 'gpt-4o-mini' },
|
|
474
|
+
{ engine: 'grok', model: 'grok-2' }, // engine is alias for provider
|
|
475
|
+
],
|
|
476
|
+
// Legacy: provider-only fallback (same model)
|
|
477
|
+
// fallbackProviders: ['grok', 'openai'],
|
|
391
478
|
},
|
|
392
|
-
}
|
|
479
|
+
},
|
|
480
|
+
```
|
|
393
481
|
|
|
394
|
-
|
|
482
|
+
Precedence: `request.config.fallbackChain` → `request.config.fallbackEngines` → `router.fallbackChain` → `request.config.fallbackProviders`.
|
|
395
483
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
484
|
+
### Interceptors
|
|
485
|
+
|
|
486
|
+
```ts
|
|
487
|
+
router.addRequestInterceptor(async (req, provider) => {
|
|
488
|
+
// mutate or replace request before execution
|
|
489
|
+
return req;
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
router.addResponseInterceptor(async (res, provider) => {
|
|
493
|
+
// mutate or replace response after execution
|
|
494
|
+
return res;
|
|
495
|
+
});
|
|
399
496
|
```
|
|
400
497
|
|
|
498
|
+
OpenRouter mode registers a request interceptor automatically to route vendor calls through OpenRouter while preserving the original provider name for model mapping.
|
|
499
|
+
|
|
500
|
+
### Health checks
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
const result = await router.checkHealth('openai');
|
|
504
|
+
// { provider: 'openai', healthy: true, latencyMs: 1234 }
|
|
505
|
+
// or { provider: 'openai', healthy: false, latencyMs: 5000, error: '...' }
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
Runs a minimal sync invoke with a 5 s timeout.
|
|
509
|
+
|
|
401
510
|
---
|
|
402
511
|
|
|
403
|
-
##
|
|
512
|
+
## AIGateway
|
|
513
|
+
|
|
514
|
+
Thin wrapper around the router for gateway-style requests (instructions + inputData):
|
|
404
515
|
|
|
405
516
|
```ts
|
|
406
|
-
|
|
407
|
-
...req,
|
|
408
|
-
mode: 'stream',
|
|
409
|
-
};
|
|
517
|
+
import { AIGateway, createRouter } from '@x12i/ai-providers-router';
|
|
410
518
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
// Final response
|
|
420
|
-
console.log('Final:', ev.response.outputText);
|
|
421
|
-
} else if (ev.type === 'error') {
|
|
422
|
-
console.error('Error:', ev.error);
|
|
423
|
-
}
|
|
424
|
-
}
|
|
519
|
+
const gateway = new AIGateway(await createRouter());
|
|
520
|
+
|
|
521
|
+
const response = await gateway.invoke({
|
|
522
|
+
instructions: 'You are a helpful assistant.',
|
|
523
|
+
inputData: 'Explain routers in one sentence.',
|
|
524
|
+
config: { provider: 'openai', model: 'gpt-4o-mini' },
|
|
525
|
+
mode: 'sync',
|
|
526
|
+
});
|
|
425
527
|
```
|
|
426
528
|
|
|
529
|
+
Also accepts full `AIRouterRequest` shapes (`{ request, provider, mode }`) and unwraps them automatically.
|
|
530
|
+
|
|
531
|
+
Optional strict provider/model pinning: set `config.enforceProviderModel: true` to throw on mismatch instead of silently switching.
|
|
532
|
+
|
|
427
533
|
---
|
|
428
534
|
|
|
429
|
-
##
|
|
535
|
+
## Response normalization and cost
|
|
430
536
|
|
|
431
|
-
|
|
537
|
+
Exported helpers for downstream activity persistence and output contracts:
|
|
432
538
|
|
|
433
539
|
```ts
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
540
|
+
import {
|
|
541
|
+
applyResponseNormalization,
|
|
542
|
+
resolveCostReporting,
|
|
543
|
+
extractCostUsdFromRouterResponse,
|
|
544
|
+
extractCostUsdFromProviderUsage,
|
|
545
|
+
enrichParsedForOutputContract,
|
|
546
|
+
resolveOutputContractFieldKeys,
|
|
547
|
+
parseMarkdownSectionsFromContent,
|
|
548
|
+
} from '@x12i/ai-providers-router';
|
|
549
|
+
```
|
|
438
550
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
551
|
+
- **Cost** — Normalizes OpenRouter and provider usage into `metadata.costUsd` / `costStatus`
|
|
552
|
+
- **Output contract** — When `outputContract` is on the request, markdown sections map to camelCase keys in `output.parsed`
|
|
553
|
+
|
|
554
|
+
See [normalization field support](./docs/normalization-field-support.md).
|
|
555
|
+
|
|
556
|
+
---
|
|
557
|
+
|
|
558
|
+
## Error types
|
|
559
|
+
|
|
560
|
+
| Error | When |
|
|
561
|
+
|-------|------|
|
|
562
|
+
| `ProviderNotFoundError` | Requested provider is not registered |
|
|
563
|
+
| `ProviderNotInstalledError` | Provider package not installed (includes `npm install` hint) |
|
|
564
|
+
| `ProviderTimeoutError` | Request exceeded `timeoutMs` (`code: 'ETIMEDOUT'`) |
|
|
565
|
+
| `FallbackExhaustedError` | All fallback candidates failed; check `.attempts[]` |
|
|
443
566
|
|
|
444
|
-
|
|
445
|
-
|
|
567
|
+
On partial provider failures, `FallbackExhaustedError` may carry a router-shaped partial payload for gateway extraction (`PartialRouterPayload`).
|
|
568
|
+
|
|
569
|
+
---
|
|
570
|
+
|
|
571
|
+
## Manual setup (advanced)
|
|
572
|
+
|
|
573
|
+
For full control without `createRouter()`:
|
|
574
|
+
|
|
575
|
+
```ts
|
|
576
|
+
import { LLMProviderRouter } from '@x12i/ai-providers-router';
|
|
577
|
+
import * as openaiModule from '@x12i/ai-provider-openai';
|
|
578
|
+
|
|
579
|
+
const router = new LLMProviderRouter({ logLevel: 'info', timeoutMs: 60_000 });
|
|
580
|
+
|
|
581
|
+
router.configureProvider('openai', { apiKey: process.env.OPENAI_API_KEY! });
|
|
582
|
+
router.registerProvider(openaiModule, 'initializeClient');
|
|
583
|
+
|
|
584
|
+
const providers = router.listProviders(); // ['openai']
|
|
585
|
+
const registry = router.getProviderRegistry();
|
|
586
|
+
const adapters = router.getAdapterRegistry();
|
|
446
587
|
```
|
|
447
588
|
|
|
448
|
-
**
|
|
589
|
+
Providers are also **auto-registered on first invoke** when matching API keys are in the environment (unless OpenRouter mode is active).
|
|
590
|
+
|
|
591
|
+
Legacy config file support:
|
|
592
|
+
|
|
593
|
+
```ts
|
|
594
|
+
import { createRouterFromConfig } from '@x12i/ai-providers-router';
|
|
595
|
+
const router = await createRouterFromConfig('./router-config.json');
|
|
596
|
+
```
|
|
449
597
|
|
|
450
598
|
---
|
|
451
599
|
|
|
452
|
-
##
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
600
|
+
## Public API exports
|
|
601
|
+
|
|
602
|
+
```ts
|
|
603
|
+
// Router
|
|
604
|
+
export { LLMProviderRouter, createRouter, createRouterFromConfig }
|
|
605
|
+
|
|
606
|
+
// Types
|
|
607
|
+
export type { RouterConfig, AIRouterRequest, AIResponse, AIStreamEvent,
|
|
608
|
+
AIBatchResponse, AIBatchRequestItem, NormalizedRouterOutput, ProviderModelRef,
|
|
609
|
+
HealthCheckResult, ProviderId, CreateRouterConfig }
|
|
610
|
+
|
|
611
|
+
// Errors
|
|
612
|
+
export { ProviderNotFoundError, FallbackExhaustedError,
|
|
613
|
+
ProviderNotInstalledError, ProviderTimeoutError }
|
|
614
|
+
export type { FallbackAttempt, PartialRouterPayload }
|
|
615
|
+
|
|
616
|
+
// Interceptors
|
|
617
|
+
export type { RequestInterceptor, ResponseInterceptor }
|
|
618
|
+
|
|
619
|
+
// Logger
|
|
620
|
+
export { Logger, getLogger, createLogger }
|
|
621
|
+
export type { LogLevel, LoggerConfig }
|
|
622
|
+
|
|
623
|
+
// Gateway
|
|
624
|
+
export { AIGateway }
|
|
625
|
+
export type { EnhancedLLMResponse }
|
|
626
|
+
|
|
627
|
+
// Normalization
|
|
628
|
+
export { applyResponseNormalization, resolveCostReporting,
|
|
629
|
+
extractCostUsdFromRouterResponse, extractCostUsdFromProviderUsage,
|
|
630
|
+
hasNonZeroTokenUsage, enrichParsedForOutputContract,
|
|
631
|
+
resolveOutputContractFieldKeys, contractSpecToFieldKeys,
|
|
632
|
+
parseMarkdownSectionsFromContent }
|
|
633
|
+
export type { ActivityCostStatus, ResolvedCostReporting }
|
|
634
|
+
|
|
635
|
+
// Registries and adapters (advanced)
|
|
636
|
+
export { ProviderRegistry, AdapterRegistry, OpenAIAdapter, GrokAdapter }
|
|
637
|
+
```
|
|
471
638
|
|
|
472
639
|
---
|
|
473
640
|
|
|
474
|
-
## Provider packages
|
|
641
|
+
## Provider packages
|
|
642
|
+
|
|
643
|
+
| Provider ID | Package | API key env |
|
|
644
|
+
|-------------|---------|-------------|
|
|
645
|
+
| `openai` | `@x12i/ai-provider-openai` | `OPENAI_API_KEY` |
|
|
646
|
+
| `grok` | `@x12i/ai-provider-grok` | `GROK_API_KEY` |
|
|
647
|
+
| `anthropic` | `@x12i/ai-provider-anthropic` | `ANTHROPIC_API_KEY` |
|
|
648
|
+
| `google` | `@x12i/ai-provider-google` | `GOOGLE_API_KEY` |
|
|
649
|
+
| `groq` | `@x12i/ai-provider-groq` | `GROQ_API_KEY` |
|
|
650
|
+
| OpenRouter mode | `@x12i/ai-provider-openai` (bundled) | `OPENROUTER_API_KEY` |
|
|
475
651
|
|
|
476
|
-
|
|
652
|
+
Missing packages produce a clear `ProviderNotInstalledError` with install instructions.
|
|
477
653
|
|
|
478
|
-
|
|
654
|
+
---
|
|
479
655
|
|
|
480
|
-
|
|
481
|
-
- All major providers: OpenAI, Anthropic, Google, xAI (Grok), Groq, Meta, Mistral, Cohere, etc.
|
|
482
|
-
- 67 total providers from the OpenRouter catalog
|
|
483
|
-
- 353 models with full capability support
|
|
656
|
+
## Development and testing
|
|
484
657
|
|
|
485
|
-
|
|
658
|
+
```bash
|
|
659
|
+
npm run build # compile TypeScript
|
|
660
|
+
npm test # build + run all .tests/**/*.test.js
|
|
661
|
+
npm run test:openai # live OpenAI call (requires OPENAI_API_KEY)
|
|
662
|
+
npm run test:openrouter
|
|
663
|
+
npm run test:reasoning
|
|
664
|
+
npm run erc:verify # ERC manifest verification
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
Requires **Node.js ≥ 18**.
|
|
668
|
+
|
|
669
|
+
---
|
|
486
670
|
|
|
487
|
-
|
|
488
|
-
* Provider `grok` requires `@x12i/ai-provider-grok`
|
|
489
|
-
* **OpenRouter mode**: Only requires `@x12i/ai-provider-openai` to access all OpenRouter-supported models
|
|
671
|
+
## Related documentation
|
|
490
672
|
|
|
491
|
-
|
|
673
|
+
| Document | Topic |
|
|
674
|
+
|----------|-------|
|
|
675
|
+
| [Configuration guide](./docs/CONFIGURATION_GUIDE.md) | Full request/config reference |
|
|
676
|
+
| [Environment variables](./docs/environment-variables.md) | Complete env var list |
|
|
677
|
+
| [Reasoning integration](./docs/reasoning-integration.md) | Reasoning API details |
|
|
678
|
+
| [Reasoning supported models](./docs/reasoning-supported-models.md) | Model registry |
|
|
679
|
+
| [Request/response flow](./docs/request-response-flow.md) | Internal flow |
|
|
680
|
+
| [Debugging no-provider error](./docs/debugging-no-provider-error.md) | OpenRouter troubleshooting |
|
|
681
|
+
| [Normalization fields](./docs/normalization-field-support.md) | Output contract and cost |
|
|
492
682
|
|
|
493
683
|
---
|
|
494
684
|
|
|
495
685
|
## License
|
|
496
686
|
|
|
497
|
-
|
|
687
|
+
MIT
|
package/dist/logger.d.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Logging utility for AI Provider Router
|
|
3
3
|
* Provides structured logging with proper log levels and verbose mode support
|
|
4
4
|
*/
|
|
5
|
-
|
|
5
|
+
import { type LogLevel } from '@x12i/logxer';
|
|
6
|
+
export type { LogLevel };
|
|
6
7
|
export interface LoggerConfig {
|
|
7
8
|
verbose?: boolean;
|
|
8
9
|
level?: LogLevel;
|
|
@@ -19,46 +20,42 @@ export declare class Logger {
|
|
|
19
20
|
* Update logger configuration
|
|
20
21
|
*/
|
|
21
22
|
setConfig(config: LoggerConfig): void;
|
|
22
|
-
/**
|
|
23
|
-
* Check if a log level should be logged
|
|
24
|
-
*/
|
|
25
|
-
private shouldLog;
|
|
26
23
|
/**
|
|
27
24
|
* Log error messages
|
|
28
25
|
*/
|
|
29
|
-
error(message: string, data?:
|
|
26
|
+
error(message: string, data?: Record<string, unknown>): void;
|
|
30
27
|
/**
|
|
31
28
|
* Log warning messages
|
|
32
29
|
*/
|
|
33
|
-
warn(message: string, data?:
|
|
30
|
+
warn(message: string, data?: Record<string, unknown>): void;
|
|
34
31
|
/**
|
|
35
32
|
* Log informational messages
|
|
36
33
|
*/
|
|
37
|
-
info(message: string, data?:
|
|
34
|
+
info(message: string, data?: Record<string, unknown>): void;
|
|
38
35
|
/**
|
|
39
36
|
* Log debug messages
|
|
40
37
|
*/
|
|
41
|
-
debug(message: string, data?:
|
|
38
|
+
debug(message: string, data?: Record<string, unknown>): void;
|
|
42
39
|
/**
|
|
43
40
|
* Log verbose messages (only when verbose mode is enabled)
|
|
44
41
|
*/
|
|
45
|
-
logVerbose(message: string, data?:
|
|
42
|
+
logVerbose(message: string, data?: Record<string, unknown>): void;
|
|
46
43
|
/**
|
|
47
44
|
* Log AI request (unfiltered, only in verbose mode)
|
|
48
45
|
*/
|
|
49
|
-
logAIRequest(provider: string, request:
|
|
46
|
+
logAIRequest(provider: string, request: unknown, metadata?: Record<string, unknown>): void;
|
|
50
47
|
/**
|
|
51
48
|
* Log AI response (unfiltered, only in verbose mode)
|
|
52
49
|
*/
|
|
53
|
-
logAIResponse(provider: string, response:
|
|
50
|
+
logAIResponse(provider: string, response: unknown, metadata?: Record<string, unknown>): void;
|
|
54
51
|
/**
|
|
55
52
|
* Log AI request/response pair (unfiltered, only in verbose mode)
|
|
56
53
|
*/
|
|
57
|
-
logAIIteraction(provider: string, request:
|
|
54
|
+
logAIIteraction(provider: string, request: unknown, response: unknown, duration?: number, metadata?: Record<string, unknown>): void;
|
|
58
55
|
/**
|
|
59
56
|
* Sanitize data for logging (remove sensitive info, handle circular refs)
|
|
60
57
|
*/
|
|
61
|
-
sanitizeForLogging(data:
|
|
58
|
+
sanitizeForLogging(data: unknown): unknown;
|
|
62
59
|
}
|
|
63
60
|
/**
|
|
64
61
|
* Get or create the default logger instance
|
package/dist/logger.js
CHANGED
|
@@ -2,16 +2,23 @@
|
|
|
2
2
|
* Logging utility for AI Provider Router
|
|
3
3
|
* Provides structured logging with proper log levels and verbose mode support
|
|
4
4
|
*/
|
|
5
|
-
import
|
|
6
|
-
const
|
|
5
|
+
import { createLogxer } from '@x12i/logxer';
|
|
6
|
+
const LOGXER_PACKAGE = {
|
|
7
|
+
packageName: 'AI Provider Router',
|
|
8
|
+
envPrefix: 'AI_PROVIDER_ROUTER',
|
|
9
|
+
debugNamespace: 'ai-providers-router',
|
|
10
|
+
};
|
|
11
|
+
function createGateway(level) {
|
|
12
|
+
return createLogxer(LOGXER_PACKAGE, level !== undefined ? { logLevel: level } : undefined);
|
|
13
|
+
}
|
|
7
14
|
/**
|
|
8
15
|
* Logger class that wraps logxer with proper log levels
|
|
9
16
|
*/
|
|
10
17
|
export class Logger {
|
|
11
18
|
constructor(config = {}) {
|
|
12
|
-
this.verbose = config.verbose
|
|
13
|
-
this.
|
|
14
|
-
this.
|
|
19
|
+
this.verbose = config.verbose ?? false;
|
|
20
|
+
this.gateway = createGateway(config.level);
|
|
21
|
+
this.level = config.level ?? this.gateway.getConfig().logLevel;
|
|
15
22
|
}
|
|
16
23
|
/**
|
|
17
24
|
* Update logger configuration
|
|
@@ -22,84 +29,40 @@ export class Logger {
|
|
|
22
29
|
}
|
|
23
30
|
if (config.level !== undefined) {
|
|
24
31
|
this.level = config.level;
|
|
32
|
+
this.gateway = createGateway(config.level);
|
|
25
33
|
}
|
|
26
34
|
}
|
|
27
|
-
/**
|
|
28
|
-
* Check if a log level should be logged
|
|
29
|
-
*/
|
|
30
|
-
shouldLog(level) {
|
|
31
|
-
const levels = ['error', 'warn', 'info', 'debug', 'verbose'];
|
|
32
|
-
const currentIndex = levels.indexOf(this.level);
|
|
33
|
-
const messageIndex = levels.indexOf(level);
|
|
34
|
-
return messageIndex <= currentIndex;
|
|
35
|
-
}
|
|
36
35
|
/**
|
|
37
36
|
* Log error messages
|
|
38
37
|
*/
|
|
39
38
|
error(message, data) {
|
|
40
|
-
|
|
41
|
-
return;
|
|
42
|
-
if (this.gateway?.error) {
|
|
43
|
-
this.gateway.error(message, data || {});
|
|
44
|
-
}
|
|
45
|
-
else {
|
|
46
|
-
console.error(`[ERROR] ${message}`, data || '');
|
|
47
|
-
}
|
|
39
|
+
this.gateway.error(message, data ?? {});
|
|
48
40
|
}
|
|
49
41
|
/**
|
|
50
42
|
* Log warning messages
|
|
51
43
|
*/
|
|
52
44
|
warn(message, data) {
|
|
53
|
-
|
|
54
|
-
return;
|
|
55
|
-
if (this.gateway?.warn) {
|
|
56
|
-
this.gateway.warn(message, data || {});
|
|
57
|
-
}
|
|
58
|
-
else {
|
|
59
|
-
console.warn(`[WARN] ${message}`, data || '');
|
|
60
|
-
}
|
|
45
|
+
this.gateway.warn(message, data ?? {});
|
|
61
46
|
}
|
|
62
47
|
/**
|
|
63
48
|
* Log informational messages
|
|
64
49
|
*/
|
|
65
50
|
info(message, data) {
|
|
66
|
-
|
|
67
|
-
return;
|
|
68
|
-
if (this.gateway?.info) {
|
|
69
|
-
this.gateway.info(message, data || {});
|
|
70
|
-
}
|
|
71
|
-
else {
|
|
72
|
-
console.log(`[INFO] ${message}`, data || '');
|
|
73
|
-
}
|
|
51
|
+
this.gateway.info(message, data ?? {});
|
|
74
52
|
}
|
|
75
53
|
/**
|
|
76
54
|
* Log debug messages
|
|
77
55
|
*/
|
|
78
56
|
debug(message, data) {
|
|
79
|
-
|
|
80
|
-
return;
|
|
81
|
-
if (this.gateway?.debug) {
|
|
82
|
-
this.gateway.debug(message, data || {});
|
|
83
|
-
}
|
|
84
|
-
else {
|
|
85
|
-
console.debug(`[DEBUG] ${message}`, data || '');
|
|
86
|
-
}
|
|
57
|
+
this.gateway.debug(message, data ?? {});
|
|
87
58
|
}
|
|
88
59
|
/**
|
|
89
60
|
* Log verbose messages (only when verbose mode is enabled)
|
|
90
61
|
*/
|
|
91
62
|
logVerbose(message, data) {
|
|
92
|
-
if (!this.verbose
|
|
63
|
+
if (!this.verbose)
|
|
93
64
|
return;
|
|
94
|
-
|
|
95
|
-
this.gateway.verbose(message, data || {});
|
|
96
|
-
}
|
|
97
|
-
else if (this.gateway?.trace) {
|
|
98
|
-
this.gateway.trace(message, data || {});
|
|
99
|
-
}
|
|
100
|
-
else {
|
|
101
|
-
console.log(`[VERBOSE] ${message}`, data || '');
|
|
102
|
-
}
|
|
65
|
+
this.gateway.verbose(message, data ?? {});
|
|
103
66
|
}
|
|
104
67
|
/**
|
|
105
68
|
* Log AI request (unfiltered, only in verbose mode)
|
|
@@ -110,7 +73,7 @@ export class Logger {
|
|
|
110
73
|
this.logVerbose('AI Request Sent', {
|
|
111
74
|
provider,
|
|
112
75
|
request: this.sanitizeForLogging(request),
|
|
113
|
-
metadata: metadata
|
|
76
|
+
metadata: metadata ?? {},
|
|
114
77
|
timestamp: new Date().toISOString(),
|
|
115
78
|
});
|
|
116
79
|
}
|
|
@@ -123,7 +86,7 @@ export class Logger {
|
|
|
123
86
|
this.logVerbose('AI Response Received', {
|
|
124
87
|
provider,
|
|
125
88
|
response: this.sanitizeForLogging(response),
|
|
126
|
-
metadata: metadata
|
|
89
|
+
metadata: metadata ?? {},
|
|
127
90
|
timestamp: new Date().toISOString(),
|
|
128
91
|
});
|
|
129
92
|
}
|
|
@@ -138,7 +101,7 @@ export class Logger {
|
|
|
138
101
|
request: this.sanitizeForLogging(request),
|
|
139
102
|
response: this.sanitizeForLogging(response),
|
|
140
103
|
duration: duration ? `${duration}ms` : undefined,
|
|
141
|
-
metadata: metadata
|
|
104
|
+
metadata: metadata ?? {},
|
|
142
105
|
timestamp: new Date().toISOString(),
|
|
143
106
|
});
|
|
144
107
|
}
|
|
@@ -149,43 +112,35 @@ export class Logger {
|
|
|
149
112
|
if (data === null || data === undefined) {
|
|
150
113
|
return data;
|
|
151
114
|
}
|
|
152
|
-
// Handle circular references and large objects
|
|
153
115
|
const seen = new WeakSet();
|
|
154
116
|
const sanitize = (obj, depth = 0) => {
|
|
155
|
-
// Prevent infinite recursion
|
|
156
117
|
if (depth > 10) {
|
|
157
118
|
return '[Max Depth Reached]';
|
|
158
119
|
}
|
|
159
|
-
// Handle primitives
|
|
160
120
|
if (obj === null || obj === undefined) {
|
|
161
121
|
return obj;
|
|
162
122
|
}
|
|
163
123
|
if (typeof obj === 'string' || typeof obj === 'number' || typeof obj === 'boolean') {
|
|
164
124
|
return obj;
|
|
165
125
|
}
|
|
166
|
-
// Handle circular references
|
|
167
126
|
if (typeof obj === 'object') {
|
|
168
127
|
if (seen.has(obj)) {
|
|
169
128
|
return '[Circular Reference]';
|
|
170
129
|
}
|
|
171
130
|
seen.add(obj);
|
|
172
|
-
// Handle arrays
|
|
173
131
|
if (Array.isArray(obj)) {
|
|
174
132
|
return obj.map((item) => sanitize(item, depth + 1));
|
|
175
133
|
}
|
|
176
|
-
// Handle Buffer (common in file uploads)
|
|
177
134
|
if (Buffer.isBuffer(obj)) {
|
|
178
135
|
return `[Buffer: ${obj.length} bytes]`;
|
|
179
136
|
}
|
|
180
|
-
// Handle objects
|
|
181
137
|
const result = {};
|
|
182
138
|
for (const key in obj) {
|
|
183
139
|
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
184
|
-
// Skip sensitive keys (can be extended)
|
|
185
140
|
if (key.toLowerCase().includes('password') ||
|
|
186
141
|
key.toLowerCase().includes('secret') ||
|
|
187
142
|
key.toLowerCase().includes('apikey') ||
|
|
188
|
-
key.toLowerCase().includes('token') && key !== 'token') {
|
|
143
|
+
(key.toLowerCase().includes('token') && key !== 'token')) {
|
|
189
144
|
result[key] = '[REDACTED]';
|
|
190
145
|
}
|
|
191
146
|
else {
|
|
@@ -200,7 +155,6 @@ export class Logger {
|
|
|
200
155
|
return sanitize(data);
|
|
201
156
|
}
|
|
202
157
|
}
|
|
203
|
-
// Default logger instance
|
|
204
158
|
let defaultLogger = null;
|
|
205
159
|
/**
|
|
206
160
|
* Get or create the default logger instance
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@x12i/ai-providers-router",
|
|
3
|
-
"version": "4.8.
|
|
3
|
+
"version": "4.8.6",
|
|
4
4
|
"description": "Unified router for all LLM provider implementations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"@x12i/ai-provider-grok": "^3.2.0",
|
|
47
47
|
"@x12i/ai-provider-interface": "^3.2.1",
|
|
48
48
|
"@x12i/ai-provider-openai": "^3.2.1",
|
|
49
|
-
"@x12i/logxer": "^4.
|
|
49
|
+
"@x12i/logxer": "^4.4.0",
|
|
50
50
|
"ai-io-normalizer": "^6.0.3"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|