@x12i/ai-providers-router
v4.9.2
Published
Unified router for all LLM provider implementations
Readme
@x12i/ai-providers-router
Unified LLM provider router for Node.js. Routes requests to installed provider packages using the ProviderModule architecture from @x12i/ai-provider-interface.
Highlights
- Multi-provider routing — OpenAI, Grok, and more via lazy-loaded provider packages
- OpenRouter mode — Access 350+ models from 60+ providers through one API key
- Sync, stream, and batch — Gated by each provider's declared capabilities
- Fallback chains — Automatic provider/model failover with full attempt traces
- Structured diagnostics — Usage, cost, timing, and ordered
metadata.attempts[] - Reasoning support — Cross-vendor effort, visibility, and encrypted trace handling
- ERC 2.0 — Zero-config initialization from environment variables
- Structured logging — Powered by
@x12i/logxer
This router never installs provider packages at runtime. You must install the packages you intend to use.
Table of contents
- Install
- Quick start
- Architecture
- Provider IDs
- OpenRouter mode
- Configuration
- Logging
- API usage
- AIGateway
- Response normalization and cost
- Error types
- Manual setup (advanced)
- Public API exports
- Provider packages
- Development and testing
- Related documentation
- License
Install
npm i @x12i/ai-providers-routerBundled provider packages (included as dependencies):
@x12i/ai-provider-openai— OpenAI and OpenRouter-compatible APIs@x12i/ai-provider-grok— Grok / xAI
Optional provider packages (install when you need direct access):
npm i @x12i/ai-provider-anthropic
npm i @x12i/ai-provider-google
npm i @x12i/ai-provider-groq
# ... other @x12i/ai-provider-* packagesFor OpenRouter mode, only @x12i/ai-provider-openai is required to reach models from many vendors through OpenRouter's unified API.
Quick start
import { createRouter, type AIRouterRequest, type AIResponse } from '@x12i/ai-providers-router';
const router = await createRouter();
const req: AIRouterRequest = {
request: {
messages: [{ role: 'user', content: 'Write 3 bullets about routers.' }],
config: { model: 'gpt-4o-mini', maxTokens: 200 },
},
provider: 'openai',
mode: 'sync',
};
const res: AIResponse = await router.invoke(req);
console.log(res.outputText);
console.log(res.usage);
console.log(res.rawResponse); // always present — lossless provider payloadSet provider API keys in your environment (see Configuration). With no arguments, createRouter() auto-discovers settings via ERC.
Architecture
AIRouterRequest
→ request interceptors (e.g. OpenRouter routing)
→ ProviderModule (from installed @x12i/ai-provider-* package)
→ router-side adapter (request → ProviderSDKCallSpec)
→ provider.execute() | stream() | submitBatch()
→ router-side adapter (ProviderSDKExecResult → AIResponse)
→ response interceptors
→ AIResponse (with lossless rawResponse)| Layer | Role |
|-------|------|
| ProviderModule | Provider packages implement @x12i/ai-provider-interface |
| Router adapters | Convert router requests to ProviderSDKCallSpec and parse responses |
| Capability gating | Router checks provider.capabilities.modes.sync/stream/batch |
| Execution semantics | Router owns timeoutMs, retries, idempotencyKey, AbortSignal |
Provider IDs
Direct providers (require matching @x12i/ai-provider-* package and API key):
| ID | Vendor |
|----|--------|
| openai | OpenAI |
| grok | Grok / xAI |
| anthropic | Claude |
| google | Gemini |
| groq | GroqCloud |
OpenRouter (unified gateway):
| ID | Role |
|----|------|
| openrouter | Explicit OpenRouter transport |
| Any vendor ID | Routed through OpenRouter when preferred (USE_OPENROUTER=true, default) or as fallback when no direct key |
Grok ≠ Groq — Grok is xAI (
grok/xai). Groq is GroqCloud (groq).
OpenRouter mode
OpenRouter is a unified API gateway. With an OPENROUTER_API_KEY, the router can reach models from many vendors through one key — using familiar provider names (openai, grok, anthropic, …) and automatic model mapping (e.g. openai + gpt-4o → openai/gpt-4o).
Enable OpenRouter
Set an API key (canonical name preferred):
export OPENROUTER_API_KEY=sk-or-your-key-here
# Legacy alias also supported:
# export OPEN_ROUTER_KEY=sk-or-your-key-hereOptional ranking headers:
export OPENROUTER_HTTP_REFERER=https://your-site.com # legacy: OPEN_ROUTER_HTTP_REFERER
export OPENROUTER_X_TITLE=Your Site Name # legacy: OPEN_ROUTER_X_TITLEUSE_OPENROUTER — prefer vs fallback
USE_OPENROUTER does not turn OpenRouter on or off. The router always registers OpenRouter when a key is present. This flag controls whether OpenRouter is preferred over direct provider keys.
| USE_OPENROUTER | OPENROUTER_API_KEY | Direct provider key (e.g. OPENAI_API_KEY) | What happens |
|------------------|----------------------|---------------------------------------------|--------------|
| unset or true (default) | set | set or unset | Prefer OpenRouter — all vendor calls route through OpenRouter, even when a direct key exists |
| unset or true | set | not set | Route through OpenRouter |
| false | set | set for requested vendor | Direct provider — use the vendor's own key/API |
| false | set | not set for requested vendor | OpenRouter fallback — e.g. request anthropic with no ANTHROPIC_API_KEY still works via OpenRouter |
Default: prefer OpenRouter whenever OPENROUTER_API_KEY is set (USE_OPENROUTER defaults to true).
To use direct provider keys when available, while keeping OpenRouter as fallback for vendors without keys:
export USE_OPENROUTER=false
export OPENROUTER_API_KEY=sk-or-...
export OPENAI_API_KEY=sk-... # openai requests → direct OpenAI
# no ANTHROPIC_API_KEY # anthropic requests → OpenRouter fallbackProgrammatic override:
const router = await createRouter({
useOpenRouter: false, // direct when keys exist; OpenRouter fallback otherwise
});Behavior summary
- OpenRouter is always available when
OPENROUTER_API_KEYis set — used as the default transport or as fallback USE_OPENROUTER=true(default): routes through OpenRouter even ifOPENAI_API_KEY,GROK_API_KEY, etc. are also set; direct provider packages are not auto-registered (avoids singleton config conflicts)USE_OPENROUTER=false: auto-registers direct providers when their API keys exist; OpenRouter handles any vendor without a direct key- Works with
createRouter()andnew LLMProviderRouter()— auto-registration on first call - Provider names stay the same in your code; the router handles transport selection internally
- Catalog data (
.metadata/openrouter_catalog_with_vendor_mapping.json) drives model validation and provider inference - Responses on the OpenRouter path are parsed directly from OpenAI-compatible formats (no
ai-io-normalizer)
Examples
Same provider name, OpenRouter underneath:
const req: AIRouterRequest = {
request: { messages: [{ role: 'user', content: 'Hello!' }], config: { model: 'gpt-4o' } },
provider: 'openai',
mode: 'sync',
};
await router.invoke(req);OpenRouter model format directly:
const req: AIRouterRequest = {
request: {
messages: [{ role: 'user', content: 'Hello!' }],
config: { model: 'anthropic/claude-3-opus' },
},
provider: 'openrouter',
mode: 'sync',
};
await router.invoke(req);Provider inference from model name (no provider field):
const req: AIRouterRequest = {
request: { messages: [{ role: 'user', content: 'Hello!' }], config: { model: 'gpt-4o' } },
mode: 'sync',
};
await router.invoke(req); // infers openaiTroubleshooting
If you see "No provider specified and no providers registered":
- Confirm
OPENROUTER_API_KEY(orOPEN_ROUTER_KEY) is set and non-empty - Ensure the key does not start with
ENV.(unresolved placeholder) - Set
config.providerin the request (e.g.{ provider: 'openai', model: 'gpt-4o' }) - The OpenRouter adapter is always registered — no extra setup required
See also debugging guide.
Configuration
Zero-config (createRouter)
import { createRouter } from '@x12i/ai-providers-router';
const router = await createRouter(); // reads process.envProgrammatic (advanced mode)
const router = await createRouter({
logLevel: 'info',
verbose: false,
timeoutMs: 60_000,
useOpenRouter: true, // default: prefer OpenRouter when OPENROUTER_API_KEY is set
fallbackChain: [{ provider: 'openai', model: 'gpt-4o-mini' }, { provider: 'grok', model: 'grok-2' }],
openrouter: { apiKey: 'sk-or-...', httpReferer: 'https://example.com', xTitle: 'My App' },
usageTracker: {
recordRequest(e) {
// provider, timestamp, duration, tokens, cost, success
},
},
providerConfigs: {
openai: { apiKey: 'sk-...', baseURL: 'https://api.openai.com/v1' },
grok: { apiKey: 'xai-...' },
},
});Passing any explicit config object to createRouter(config) overrides zero-config env discovery for that call.
Environment variables
| Variable | Default | Description |
|----------|---------|-------------|
| Router | | |
| AI_PROVIDER_ROUTER_LOGS_LEVEL | (see logging) | Canonical log threshold via logxer (error, warn, info, debug, verbose, off) |
| AI_PROVIDER_ROUTER_LOG_LEVEL | info | Legacy alias for log level (used when _LOGS_LEVEL is unset) |
| AI_PROVIDER_ROUTER_VERBOSE | false | Log full AI request/response payloads (sanitized) |
| AI_PROVIDER_ROUTER_TIMEOUT_MS | 60000 | Default operation timeout (ms) |
| OpenAI | | |
| OPENAI_API_KEY | — | Required for direct OpenAI calls |
| OPENAI_API_BASE | — | Custom API base URL |
| OPENAI_ORGANIZATION | — | Organization ID |
| Grok / xAI | | |
| GROK_API_KEY | — | Required for direct Grok calls |
| XAI_API_BASE | — | Custom xAI base URL |
| OpenRouter | | |
| OPENROUTER_API_KEY | — | Enables OpenRouter (always registered when set) |
| OPEN_ROUTER_KEY | — | Legacy alias for OPENROUTER_API_KEY |
| USE_OPENROUTER | true | Prefer OpenRouter over direct keys when OR key is set; set false to use direct providers when keys exist (OpenRouter remains fallback) |
| OPENROUTER_HTTP_REFERER | — | Optional ranking header |
| OPENROUTER_X_TITLE | — | Optional ranking header |
| Other providers | | |
| ANTHROPIC_API_KEY, GOOGLE_API_KEY, GROQ_API_KEY, … | — | Used when those providers are installed |
Full reference: Environment variables · Configuration guide
Logging
The router uses @x12i/logxer for structured, package-scoped logging.
Logxer identity (npm package @x12i/ai-providers-router):
| Identifier | Value | Used for |
|------------|-------|----------|
| package (log field) | AIProviderRouter | Structured logs, shadow capture, Mongo package column |
| envPrefix | AI_PROVIDER_ROUTER | Env vars, LOGXER_PACKAGE_LEVELS, stack packageLevels keys |
| debugNamespace | ai-providers-router | DEBUG=ai-providers-router |
Exported constants: ROUTER_LOG_ENV_PREFIX, ROUTER_LOGXER_PACKAGE.
# Canonical (preferred)
AI_PROVIDER_ROUTER_LOGS_LEVEL=info
# Legacy (still supported when _LOGS_LEVEL is unset)
AI_PROVIDER_ROUTER_LOG_LEVEL=info
# Log full AI request/response payloads (requires _LOGS_LEVEL=verbose to print)
AI_PROVIDER_ROUTER_VERBOSE=trueTiered AI interaction logging
| Level | Env threshold | Router verbose flag | What you get |
|-------|---------------|----------------------|--------------|
| info | _LOGS_LEVEL=info | — | One AI call completed line per invoke / stream / batch (provider, model, duration, tokens, requestId) |
| debug | _LOGS_LEVEL=debug | — | Routing resolution, fallback attempts, retries, batch poll progress |
| verbose | _LOGS_LEVEL=verbose | VERBOSE=true | Full sanitized request/response via AI interaction complete |
| error | always (if enabled) | — | Failed invoke/stream/batch with stack |
Log levels: error · warn · info · debug · verbose · off
When neither _LOGS_LEVEL nor _LOG_LEVEL is set, no logLevel / logging is passed, and the logxer registry has no entry, the router defaults to info (not logxer's package-only default of warn). createRouter() loads LOGXER_PACKAGE_LEVELS / LOGXER_PACKAGE_LOGS_DEFAULT via applyPackageLogLevelsFromEnv() after .env.
Host apps (logxer ≥ 4.5) — provider packages (@x12i/ai-provider-openai, etc.) are not on the logxer 4.5 stack. Stack/registry options apply only to this router's logs (AI_PROVIDER_ROUTER). Configure your other libraries from @x12i/logxer in the host; pass the same StackLoggingOptions into createRouter when you want one object for the whole app:
import { configurePackageLogLevels, type StackLoggingOptions } from '@x12i/logxer';
import { createRouter, ROUTER_LOG_ENV_PREFIX } from '@x12i/ai-providers-router';
configurePackageLogLevels({
default: 'warn',
levels: {
MY_GATEWAY: 'info',
[ROUTER_LOG_ENV_PREFIX]: 'debug',
},
});
const logging: StackLoggingOptions = {
packageLevels: { [ROUTER_LOG_ENV_PREFIX]: 'debug' },
};
const router = await createRouter({ logging, verbose: true });Bulk env for this package (loaded by createRouter() after .env):
LOGXER_PACKAGE_LEVELS=AI_PROVIDER_ROUTER:info
AI_PROVIDER_ROUTER_LOGS_LEVEL=error # wins over bulk for this prefix onlyProgrammatic (router only):
import { createRouter, createLogger, ROUTER_LOGXER_PACKAGE } from '@x12i/ai-providers-router';
// info summaries always; payloads when verbose + log level verbose
const router = await createRouter({ logLevel: 'info', verbose: true });
// inject custom logger
const router2 = await createRouter({
logger: createLogger({ level: 'debug', verbose: false }),
});
// stack identity for host config
console.log(ROUTER_LOGXER_PACKAGE.envPrefix); // AI_PROVIDER_ROUTERSee also: Logxer integration checklist (generic, shareable).
Verbose mode logs sanitized AI request/response payloads. Cross-cutting sinks (console, file, format) are configured in the host via @x12i/logxer — not via provider packages.
API usage
Sync call
import { createRouter, type AIRouterRequest, type AIResponse } from '@x12i/ai-providers-router';
const router = await createRouter();
const req: AIRouterRequest = {
request: {
inputData: 'Write 3 bullets about routers.',
config: { model: 'gpt-4o-mini', maxTokens: 200, temperature: 0.7 },
},
provider: 'openai',
mode: 'sync',
exec: {
timeoutMs: 60_000,
idempotencyKey: 'optional-key',
signal: abortController.signal,
},
};
const res: AIResponse = await router.invoke(req);Streaming call
const streamReq: AIRouterRequest = { ...req, mode: 'stream' };
for await (const ev of router.stream(streamReq)) {
switch (ev.type) {
case 'provider_raw':
console.log('Raw:', ev.raw);
break;
case 'output_text_delta':
process.stdout.write(ev.delta);
break;
case 'reasoning_summary_delta':
case 'reasoning_trace_delta':
// reasoning stream chunks
break;
case 'completed':
console.log('Final:', ev.response.outputText);
break;
case 'error':
console.error(ev.error);
break;
}
}Batch requests
Batch is available only when provider.capabilities.modes.batch === true:
const items = [
{ request: { inputData: 'First', config: { model: 'gpt-4o-mini' } } },
{ request: { inputData: 'Second', config: { model: 'gpt-4o-mini' } } },
];
const batchResult = await router.createBatch('openai', items, {
timeoutMs: 120_000,
idempotencyKey: 'batch-1',
});
console.log(batchResult.items);
console.log(batchResult.rawBatch);Request and response types
| Type | Purpose |
|------|---------|
| AIRouterRequest | Router input (request, provider, mode, exec) |
| AIResponse | Sync output (outputText, rawResponse, usage, reasoning, metadata) |
| AIStreamEvent | Streaming events (output_text_delta, completed, error, …) |
| AIBatchResponse | Batch results |
| RouterConfig | Router-level settings |
| ProviderModelRef | { provider?, engine?, model? } for fallback chains |
Trace diagnostics
Every AIResponse includes stable, provider-agnostic diagnostics in metadata:
| Field | Description |
|-------|-------------|
| metadata.provider | Final provider used |
| metadata.modelUsed | Actual model that served the response |
| metadata.costUsd / metadata.cost | USD cost when reported (e.g. OpenRouter usage.cost) |
| metadata.costStatus | 'priced' or 'unpriced' |
| metadata.maxTokensRequested | Effective generation cap |
| metadata.requestIds | { routerRequestId, providerRequestId?, openrouterRequestId? } |
| metadata.timing | { startedAt, endedAt, durationMs } |
| metadata.latencyMs | Alias for timing.durationMs |
| metadata.attempts[] | Ordered retry + fallback trace |
| response.output.parsed | Structured fields when outputContract is set |
Reasoning
Request unified reasoning controls via request.config.reasoning:
config: {
reasoning: {
effort: 'high', // low | medium | high | xhigh (xhigh → high)
maxTokens: 2000, // Anthropic/Gemini max_tokens mode
visibility: 'trace', // none | summary | trace (best-effort)
onUnsupported: 'downgrade', // downgrade | error | ignore
},
}Response fields: response.reasoning.applied, response.reasoning.artifacts, response.reasoning.warnings.
Supported models are tracked in .metadata/reasoning-support.json.
Fallback chains
On failure, the router tries the next candidate in order. Attempts are recorded in metadata.attempts[]. On exhaustion, throws FallbackExhaustedError.
Router-level default chain:
const router = await createRouter({
fallbackChain: [
{ provider: 'openai', model: 'gpt-4o' },
{ provider: 'grok', model: 'grok-2' },
],
});Per-request chain (in request.config):
request: {
config: {
model: 'gpt-4o',
fallbackChain: [
{ provider: 'openai', model: 'gpt-4o-mini' },
{ engine: 'grok', model: 'grok-2' }, // engine is alias for provider
],
// Legacy: provider-only fallback (same model)
// fallbackProviders: ['grok', 'openai'],
},
},Precedence: request.config.fallbackChain → request.config.fallbackEngines → router.fallbackChain → request.config.fallbackProviders.
Interceptors
router.addRequestInterceptor(async (req, provider) => {
// mutate or replace request before execution
return req;
});
router.addResponseInterceptor(async (res, provider) => {
// mutate or replace response after execution
return res;
});OpenRouter registers a request interceptor when USE_OPENROUTER=true (default) to route vendor calls through OpenRouter while preserving the original provider name for model mapping. When USE_OPENROUTER=false, the interceptor is skipped; resolveProviderName uses direct providers when registered and falls back to OpenRouter otherwise.
Health checks
const result = await router.checkHealth('openai');
// { provider: 'openai', healthy: true, latencyMs: 1234 }
// or { provider: 'openai', healthy: false, latencyMs: 5000, error: '...' }Runs a minimal sync invoke with a 5 s timeout.
AIGateway
Thin wrapper around the router for gateway-style requests (instructions + inputData):
import { AIGateway, createRouter } from '@x12i/ai-providers-router';
const gateway = new AIGateway(await createRouter());
const response = await gateway.invoke({
instructions: 'You are a helpful assistant.',
inputData: 'Explain routers in one sentence.',
config: { provider: 'openai', model: 'gpt-4o-mini' },
mode: 'sync',
});Also accepts full AIRouterRequest shapes ({ request, provider, mode }) and unwraps them automatically.
Optional strict provider/model pinning: set config.enforceProviderModel: true to throw on mismatch instead of silently switching.
Response normalization and cost
Exported helpers for downstream activity persistence and output contracts:
import {
applyResponseNormalization,
resolveCostReporting,
extractCostUsdFromRouterResponse,
extractCostUsdFromProviderUsage,
enrichParsedForOutputContract,
resolveOutputContractFieldKeys,
parseMarkdownSectionsFromContent,
} from '@x12i/ai-providers-router';- Cost — Normalizes OpenRouter and provider usage into
metadata.costUsd/costStatus - Output contract — When
outputContractis on the request, markdown sections map to camelCase keys inoutput.parsed
See normalization field support.
Error types
| Error | When |
|-------|------|
| ProviderNotFoundError | Requested provider is not registered |
| ProviderNotInstalledError | Provider package not installed (includes npm install hint) |
| ProviderTimeoutError | Request exceeded timeoutMs (code: 'ETIMEDOUT') |
| FallbackExhaustedError | All fallback candidates failed; check .attempts[] |
On partial provider failures, FallbackExhaustedError may carry a router-shaped partial payload for gateway extraction (PartialRouterPayload).
Manual setup (advanced)
For full control without createRouter():
import { LLMProviderRouter } from '@x12i/ai-providers-router';
import * as openaiModule from '@x12i/ai-provider-openai';
const router = new LLMProviderRouter({ logLevel: 'info', timeoutMs: 60_000 });
router.configureProvider('openai', { apiKey: process.env.OPENAI_API_KEY! });
router.registerProvider(openaiModule, 'initializeClient');
const providers = router.listProviders(); // ['openai']
const registry = router.getProviderRegistry();
const adapters = router.getAdapterRegistry();Providers are auto-registered on first invoke when matching API keys are in the environment. When USE_OPENROUTER=true (default) and OPENROUTER_API_KEY is set, direct providers are skipped in favor of OpenRouter. With USE_OPENROUTER=false, both direct providers and OpenRouter can be registered simultaneously.
Legacy config file support:
import { createRouterFromConfig } from '@x12i/ai-providers-router';
const router = await createRouterFromConfig('./router-config.json');Public API exports
// Router
export { LLMProviderRouter, createRouter, createRouterFromConfig }
// Types
export type { RouterConfig, AIRouterRequest, AIResponse, AIStreamEvent,
AIBatchResponse, AIBatchRequestItem, NormalizedRouterOutput, ProviderModelRef,
HealthCheckResult, ProviderId, CreateRouterConfig }
// Errors
export { ProviderNotFoundError, FallbackExhaustedError,
ProviderNotInstalledError, ProviderTimeoutError }
export type { FallbackAttempt, PartialRouterPayload }
// Interceptors
export type { RequestInterceptor, ResponseInterceptor }
// Logger
export { Logger, getLogger, createLogger }
export type { LogLevel, LoggerConfig }
// Gateway
export { AIGateway }
export type { EnhancedLLMResponse }
// Normalization
export { applyResponseNormalization, resolveCostReporting,
extractCostUsdFromRouterResponse, extractCostUsdFromProviderUsage,
hasNonZeroTokenUsage, enrichParsedForOutputContract,
resolveOutputContractFieldKeys, contractSpecToFieldKeys,
parseMarkdownSectionsFromContent }
export type { ActivityCostStatus, ResolvedCostReporting }
// Registries and adapters (advanced)
export { ProviderRegistry, AdapterRegistry, OpenAIAdapter, GrokAdapter }Provider packages
| Provider ID | Package | API key env |
|-------------|---------|-------------|
| openai | @x12i/ai-provider-openai | OPENAI_API_KEY |
| grok | @x12i/ai-provider-grok | GROK_API_KEY |
| anthropic | @x12i/ai-provider-anthropic | ANTHROPIC_API_KEY |
| google | @x12i/ai-provider-google | GOOGLE_API_KEY |
| groq | @x12i/ai-provider-groq | GROQ_API_KEY |
| OpenRouter mode | @x12i/ai-provider-openai (bundled) | OPENROUTER_API_KEY |
Missing packages produce a clear ProviderNotInstalledError with install instructions.
Development and testing
npm run build # compile TypeScript
npm test # build + run all .tests/**/*.test.js
npm run test:openai # live OpenAI call (requires OPENAI_API_KEY)
npm run test:openrouter
npm run test:reasoning
npm run erc:verify # ERC manifest verificationRequires Node.js ≥ 18.
Related documentation
| Document | Topic | |----------|-------| | Configuration guide | Full request/config reference | | Environment variables | Complete env var list | | Reasoning integration | Reasoning API details | | Reasoning supported models | Model registry | | Request/response flow | Internal flow | | Debugging no-provider error | OpenRouter troubleshooting | | Normalization fields | Output contract and cost |
License
MIT
