@x12i/ai-providers-router 4.7.7 → 4.8.2

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 CHANGED
@@ -317,7 +317,9 @@ For downstream orchestration, `AIResponse` includes stable, provider-agnostic di
317
317
  - `metadata.provider`: final provider used for the successful call (or last attempt)
318
318
  - `metadata.modelUsed`: the actual model that served the response
319
319
  - `metadata.maxTokensRequested`: final effective generation cap applied (if determinable)
320
- - `metadata.costUsd`: normalized USD cost (if computable)
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)
321
323
  - `metadata.requestIds`: `{ routerRequestId, providerRequestId?, openrouterRequestId? }`
322
324
  - `metadata.timing`: `{ startedAt, endedAt, durationMs }` (provider-call timing)
323
325
  - `metadata.latencyMs`: alias for `metadata.timing.durationMs`
@@ -1,4 +1,5 @@
1
1
  import { openRouterCatalog } from '../../openrouter-catalog.js';
2
+ import { extractCostUsdFromProviderUsage } from '../../normalization/cost.js';
2
3
  import { getReasoningCapabilitiesFromRegistry, hasReasoningParamInCatalog } from './reasoning-capabilities.js';
3
4
  /**
4
5
  * Router-side adapter for OpenRouter provider
@@ -1008,8 +1009,20 @@ export class OpenRouterAdapter {
1008
1009
  reasoning: fullResponse.reasoning,
1009
1010
  // Don't include metadata in the copy to avoid circular reference
1010
1011
  };
1012
+ const usageSources = [
1013
+ rawResponse?.usage,
1014
+ originalResponse?.usage,
1015
+ execResult.rawMeta?.originalResponse?.usage,
1016
+ ];
1017
+ let costUsd;
1018
+ for (const src of usageSources) {
1019
+ costUsd = extractCostUsdFromProviderUsage(src);
1020
+ if (costUsd !== undefined)
1021
+ break;
1022
+ }
1011
1023
  const metadataWithActivities = {
1012
1024
  ...fullResponse.metadata,
1025
+ ...(costUsd !== undefined ? { costUsd, cost: costUsd } : {}),
1013
1026
  // Include full response in metadata for ai-activities storage (without circular ref)
1014
1027
  'ai-activities-response': fullResponseForActivities,
1015
1028
  // Include request for complete audit trail
package/dist/errors.d.ts CHANGED
@@ -5,18 +5,19 @@ import type { ProviderId } from './types.js';
5
5
  export declare class ProviderNotFoundError extends Error {
6
6
  constructor(providerName: ProviderId | string);
7
7
  }
8
+ export type FallbackAttempt = {
9
+ provider: ProviderId | string;
10
+ model?: string;
11
+ httpStatus?: number;
12
+ error: Error;
13
+ responsePreview?: string;
14
+ };
8
15
  /**
9
16
  * Error thrown when all providers in the fallback chain have failed
10
17
  */
11
18
  export declare class FallbackExhaustedError extends Error {
12
- attempts: Array<{
13
- provider: ProviderId;
14
- error: Error;
15
- }>;
16
- constructor(attempts: Array<{
17
- provider: ProviderId;
18
- error: Error;
19
- }>);
19
+ attempts: FallbackAttempt[];
20
+ constructor(attempts: FallbackAttempt[]);
20
21
  }
21
22
  /**
22
23
  * Error thrown when a provider package is not installed
package/dist/index.d.ts CHANGED
@@ -1,9 +1,18 @@
1
1
  export { LLMProviderRouter } from './router.js';
2
- export type { RouterConfig, HealthCheckResult, ProviderId, AIRouterRequest, AIResponse, AIStreamEvent, AIBatchResponse, AIBatchRequestItem, } from './router.js';
2
+ export type { RouterConfig, HealthCheckResult, ProviderId, AIRouterRequest, AIResponse, AIStreamEvent, AIBatchResponse, AIBatchRequestItem, NormalizedRouterOutput, ProviderModelRef, } from './router.js';
3
3
  export { createRouter, createRouterFromConfig } from './factory.js';
4
4
  export type { CreateRouterConfig } from './factory.js';
5
5
  export { ProviderNotFoundError, FallbackExhaustedError, ProviderNotInstalledError, ProviderTimeoutError } from './errors.js';
6
+ export type { FallbackAttempt } from './errors.js';
7
+ export type { PartialRouterPayload } from './router/partialErrorPayload.js';
6
8
  export type { RequestInterceptor, ResponseInterceptor } from './interceptors.js';
7
9
  export type { UsageTracker, AdapterLoader, ProviderInit } from './types.js';
8
10
  export { Logger, getLogger, createLogger } from './logger.js';
9
11
  export type { LogLevel, LoggerConfig } from './logger.js';
12
+ export { AIGateway } from './gateway.js';
13
+ export type { EnhancedLLMResponse } from './gateway.js';
14
+ export { applyResponseNormalization } from './normalization/applyResponseNormalization.js';
15
+ export { resolveCostReporting, extractCostUsdFromRouterResponse, extractCostUsdFromProviderUsage, hasNonZeroTokenUsage } from './normalization/cost.js';
16
+ export type { ActivityCostStatus, ResolvedCostReporting } from './normalization/cost.js';
17
+ export { resolveOutputContractFieldKeys, enrichParsedForOutputContract, contractSpecToFieldKeys } from './normalization/outputContract.js';
18
+ export { parseMarkdownSectionsFromContent } from './normalization/markdownSections.js';
package/dist/index.js CHANGED
@@ -40,3 +40,10 @@ export { createRouter, createRouterFromConfig } from './factory.js';
40
40
  export { ProviderNotFoundError, FallbackExhaustedError, ProviderNotInstalledError, ProviderTimeoutError } from './errors.js';
41
41
  // Logger
42
42
  export { Logger, getLogger, createLogger } from './logger.js';
43
+ // Gateway (thin invoke wrapper)
44
+ export { AIGateway } from './gateway.js';
45
+ // Response normalization (Run Analysis G6/G8)
46
+ export { applyResponseNormalization } from './normalization/applyResponseNormalization.js';
47
+ export { resolveCostReporting, extractCostUsdFromRouterResponse, extractCostUsdFromProviderUsage, hasNonZeroTokenUsage } from './normalization/cost.js';
48
+ export { resolveOutputContractFieldKeys, enrichParsedForOutputContract, contractSpecToFieldKeys } from './normalization/outputContract.js';
49
+ export { parseMarkdownSectionsFromContent } from './normalization/markdownSections.js';
@@ -0,0 +1,5 @@
1
+ import type { AIResponse, AIRouterRequest } from '../router/RouterTypes.js';
2
+ /**
3
+ * Canonical post-parse normalization: cost reporting (G8) and output contract → parsed (G6/G9).
4
+ */
5
+ export declare function applyResponseNormalization(response: AIResponse, requestContext?: AIRouterRequest['request']): AIResponse;
@@ -0,0 +1,36 @@
1
+ import { extractCostUsdFromRouterResponse, resolveCostReporting } from './cost.js';
2
+ import { enrichParsedForOutputContract, resolveOutputContractFieldKeys } from './outputContract.js';
3
+ /**
4
+ * Canonical post-parse normalization: cost reporting (G8) and output contract → parsed (G6/G9).
5
+ */
6
+ export function applyResponseNormalization(response, requestContext) {
7
+ const costUsd = extractCostUsdFromRouterResponse({
8
+ metadata: response.metadata,
9
+ rawResponse: response.rawResponse,
10
+ costUsd: response.metadata?.costUsd,
11
+ cost: response.metadata?.cost
12
+ });
13
+ const costReporting = resolveCostReporting(response.usage, costUsd);
14
+ const contractKeys = resolveOutputContractFieldKeys(requestContext);
15
+ const rawText = response.outputText ?? '';
16
+ const existingParsed = response.output?.parsed ??
17
+ response.metadata?.parsedContent ??
18
+ (rawText.trim() ? { rawText } : {});
19
+ const parsed = enrichParsedForOutputContract(existingParsed, rawText, contractKeys);
20
+ const metadata = {
21
+ ...(response.metadata || {}),
22
+ ...(costReporting.costUsd !== undefined ? { costUsd: costReporting.costUsd, cost: costReporting.costUsd } : {}),
23
+ ...(costReporting.costStatus ? { costStatus: costReporting.costStatus } : {})
24
+ };
25
+ if (contractKeys?.length && Object.keys(parsed).length > 0) {
26
+ metadata.parsedContent = parsed;
27
+ }
28
+ const output = {
29
+ parsed: contractKeys?.length ? parsed : (response.output?.parsed ?? parsed)
30
+ };
31
+ return {
32
+ ...response,
33
+ metadata,
34
+ output
35
+ };
36
+ }
@@ -0,0 +1,23 @@
1
+ import type { NormalizedUsage } from '../router/RouterTypes.js';
2
+ /** Activity billing state when token usage is recorded (Run Analysis G8). */
3
+ export type ActivityCostStatus = 'priced' | 'unpriced';
4
+ export type ResolvedCostReporting = {
5
+ costUsd?: number;
6
+ costStatus?: ActivityCostStatus;
7
+ };
8
+ /** Extract USD cost from a provider usage object (e.g. OpenRouter `usage.cost`). */
9
+ export declare function extractCostUsdFromProviderUsage(usage: unknown): number | undefined;
10
+ /**
11
+ * Best-effort USD cost from router response: metadata, attempts, rawResponse.usage.
12
+ */
13
+ export declare function extractCostUsdFromRouterResponse(routerResponse: {
14
+ metadata?: Record<string, unknown>;
15
+ rawResponse?: unknown;
16
+ costUsd?: unknown;
17
+ cost?: unknown;
18
+ }): number | undefined;
19
+ export declare function hasNonZeroTokenUsage(usage?: NormalizedUsage): boolean;
20
+ /**
21
+ * When usage exists, never leave cost unexplained: numeric cost when priced, else `costStatus: "unpriced"`.
22
+ */
23
+ export declare function resolveCostReporting(usage: NormalizedUsage | undefined, costUsd: number | undefined): ResolvedCostReporting;
@@ -0,0 +1,64 @@
1
+ function firstFiniteNumber(...vals) {
2
+ for (const v of vals) {
3
+ if (typeof v === 'number' && Number.isFinite(v))
4
+ return v;
5
+ }
6
+ return undefined;
7
+ }
8
+ /** Extract USD cost from a provider usage object (e.g. OpenRouter `usage.cost`). */
9
+ export function extractCostUsdFromProviderUsage(usage) {
10
+ if (usage == null || typeof usage !== 'object')
11
+ return undefined;
12
+ const u = usage;
13
+ return firstFiniteNumber(u.cost, u.costUsd, u.total_cost, u.totalCost);
14
+ }
15
+ /**
16
+ * Best-effort USD cost from router response: metadata, attempts, rawResponse.usage.
17
+ */
18
+ export function extractCostUsdFromRouterResponse(routerResponse) {
19
+ const meta = routerResponse.metadata;
20
+ const fromMeta = firstFiniteNumber(meta?.costUsd, meta?.cost);
21
+ if (fromMeta !== undefined)
22
+ return fromMeta;
23
+ const fromRoot = firstFiniteNumber(routerResponse.costUsd, routerResponse.cost);
24
+ if (fromRoot !== undefined)
25
+ return fromRoot;
26
+ const attempts = meta?.attempts;
27
+ if (Array.isArray(attempts)) {
28
+ for (let i = attempts.length - 1; i >= 0; i--) {
29
+ const a = attempts[i];
30
+ if (a != null && typeof a === 'object') {
31
+ const o = a;
32
+ const c = firstFiniteNumber(o.costUsd, o.cost);
33
+ if (c !== undefined)
34
+ return c;
35
+ }
36
+ }
37
+ }
38
+ const raw = routerResponse.rawResponse;
39
+ if (raw != null && typeof raw === 'object') {
40
+ const rawObj = raw;
41
+ const fromUsage = extractCostUsdFromProviderUsage(rawObj.usage);
42
+ if (fromUsage !== undefined)
43
+ return fromUsage;
44
+ return firstFiniteNumber(rawObj.cost, rawObj.costUsd);
45
+ }
46
+ return undefined;
47
+ }
48
+ export function hasNonZeroTokenUsage(usage) {
49
+ if (!usage)
50
+ return false;
51
+ return !!(usage.promptTokens || usage.completionTokens || usage.totalTokens);
52
+ }
53
+ /**
54
+ * When usage exists, never leave cost unexplained: numeric cost when priced, else `costStatus: "unpriced"`.
55
+ */
56
+ export function resolveCostReporting(usage, costUsd) {
57
+ if (typeof costUsd === 'number' && Number.isFinite(costUsd)) {
58
+ return { costUsd, costStatus: 'priced' };
59
+ }
60
+ if (hasNonZeroTokenUsage(usage)) {
61
+ return { costStatus: 'unpriced' };
62
+ }
63
+ return {};
64
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Section-based markdown → camelCase fields (e.g. `### Short Answer` → `shortAnswer`).
3
+ */
4
+ /** Parse markdown section headers into camelCase keys; returns {} when no sections found. */
5
+ export declare function parseMarkdownSectionsFromContent(content: string): Record<string, unknown>;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Section-based markdown → camelCase fields (e.g. `### Short Answer` → `shortAnswer`).
3
+ */
4
+ function parseMarkdownList(content) {
5
+ const listItems = [];
6
+ let currentItem = '';
7
+ let hasBulletPoints = false;
8
+ let hasNumberedItems = false;
9
+ for (const line of content) {
10
+ const trimmed = line.trim();
11
+ if (trimmed.startsWith('- ')) {
12
+ hasBulletPoints = true;
13
+ if (currentItem)
14
+ listItems.push(currentItem.trim());
15
+ currentItem = trimmed.substring(2).trim();
16
+ }
17
+ else if (/^\d+\.\s/.test(trimmed)) {
18
+ hasNumberedItems = true;
19
+ if (currentItem)
20
+ listItems.push(currentItem.trim());
21
+ const match = trimmed.match(/^\d+\.\s(.+)$/);
22
+ currentItem = match ? match[1].trim() : trimmed;
23
+ }
24
+ else if (currentItem && trimmed) {
25
+ currentItem += ' ' + trimmed;
26
+ }
27
+ else if (!currentItem && trimmed) {
28
+ currentItem = trimmed;
29
+ }
30
+ }
31
+ if (currentItem)
32
+ listItems.push(currentItem.trim());
33
+ return listItems.length > 1 || hasBulletPoints || hasNumberedItems
34
+ ? listItems
35
+ : content.join('\n').trim();
36
+ }
37
+ function headerToCamelCase(header) {
38
+ return header
39
+ .toLowerCase()
40
+ .replace(/[^a-zA-Z0-9\s]/g, '')
41
+ .replace(/\s+/g, ' ')
42
+ .trim()
43
+ .replace(/\s+(\w)/g, (_, letter) => letter.toUpperCase());
44
+ }
45
+ function parseMarkdownSections(content) {
46
+ const result = {};
47
+ const lines = content.split('\n');
48
+ let currentSection = '';
49
+ let currentContent = [];
50
+ const flush = () => {
51
+ if (currentSection && currentContent.length > 0) {
52
+ result[headerToCamelCase(currentSection)] = parseMarkdownList(currentContent);
53
+ }
54
+ };
55
+ for (const line of lines) {
56
+ const headerMatch = line.match(/^(#{1,6})\s*(.+?)\s*$/);
57
+ if (headerMatch) {
58
+ flush();
59
+ currentSection = headerMatch[2].trim();
60
+ currentContent = [];
61
+ }
62
+ else if (currentSection) {
63
+ currentContent.push(line);
64
+ }
65
+ }
66
+ flush();
67
+ return result;
68
+ }
69
+ /** Parse markdown section headers into camelCase keys; returns {} when no sections found. */
70
+ export function parseMarkdownSectionsFromContent(content) {
71
+ if (!content || typeof content !== 'string' || !content.trim())
72
+ return {};
73
+ const sections = parseMarkdownSections(content);
74
+ const keys = Object.keys(sections);
75
+ if (keys.length === 1 && keys[0] === 'rawText')
76
+ return {};
77
+ return sections;
78
+ }
@@ -0,0 +1,12 @@
1
+ export type OutputContractSpec = string[] | {
2
+ properties: Record<string, unknown>;
3
+ };
4
+ export declare function contractSpecToFieldKeys(contract: unknown): string[];
5
+ /**
6
+ * Resolves field keys from explicit `outputContract` on the request or graph-forwarded inputs.
7
+ */
8
+ export declare function resolveOutputContractFieldKeys(requestContext: unknown): string[] | undefined;
9
+ /**
10
+ * Fills missing contract keys from markdown sections. Only runs when explicit contract keys were supplied.
11
+ */
12
+ export declare function enrichParsedForOutputContract(parsed: unknown, rawContent: string, contractKeys: string[] | undefined): Record<string, unknown>;
@@ -0,0 +1,124 @@
1
+ import { parseMarkdownSectionsFromContent } from './markdownSections.js';
2
+ function isPlainObject(value) {
3
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
4
+ }
5
+ function hasMeaningfulContractValue(value) {
6
+ if (value === undefined || value === null)
7
+ return false;
8
+ if (typeof value === 'string')
9
+ return value.trim().length > 0;
10
+ if (Array.isArray(value))
11
+ return value.length > 0;
12
+ if (typeof value === 'object')
13
+ return Object.keys(value).length > 0;
14
+ return true;
15
+ }
16
+ export function contractSpecToFieldKeys(contract) {
17
+ if (contract == null)
18
+ return [];
19
+ if (Array.isArray(contract)) {
20
+ return contract.filter((k) => typeof k === 'string' && k.trim().length > 0);
21
+ }
22
+ if (!isPlainObject(contract))
23
+ return [];
24
+ const properties = contract.properties;
25
+ if (isPlainObject(properties)) {
26
+ return Object.keys(properties);
27
+ }
28
+ return [];
29
+ }
30
+ function readOutputContractFromWorkingMemory(workingMemory) {
31
+ if (!isPlainObject(workingMemory))
32
+ return undefined;
33
+ const inputs = workingMemory.inputs;
34
+ if (isPlainObject(inputs) && inputs.outputContract !== undefined) {
35
+ return inputs.outputContract;
36
+ }
37
+ const input = workingMemory.input;
38
+ if (typeof input === 'string') {
39
+ try {
40
+ const parsed = JSON.parse(input);
41
+ if (isPlainObject(parsed) && parsed.outputContract !== undefined) {
42
+ return parsed.outputContract;
43
+ }
44
+ }
45
+ catch {
46
+ // ignore
47
+ }
48
+ }
49
+ else if (isPlainObject(input) && input.outputContract !== undefined) {
50
+ return input.outputContract;
51
+ }
52
+ if (workingMemory.outputContract !== undefined)
53
+ return workingMemory.outputContract;
54
+ return undefined;
55
+ }
56
+ /**
57
+ * Resolves field keys from explicit `outputContract` on the request or graph-forwarded inputs.
58
+ */
59
+ export function resolveOutputContractFieldKeys(requestContext) {
60
+ if (requestContext == null || typeof requestContext !== 'object')
61
+ return undefined;
62
+ const r = requestContext;
63
+ const candidates = [
64
+ r.outputContract,
65
+ isPlainObject(r.config) ? r.config.outputContract : undefined,
66
+ readOutputContractFromWorkingMemory(r.workingMemory)
67
+ ];
68
+ for (const candidate of candidates) {
69
+ const keys = contractSpecToFieldKeys(candidate);
70
+ if (keys.length > 0)
71
+ return keys;
72
+ }
73
+ return undefined;
74
+ }
75
+ function asParsedRecord(parsed) {
76
+ if (!isPlainObject(parsed))
77
+ return {};
78
+ return { ...parsed };
79
+ }
80
+ function pickAliasValue(source, key) {
81
+ if (hasMeaningfulContractValue(source[key]))
82
+ return source[key];
83
+ const spaced = key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase());
84
+ const title = spaced.charAt(0).toUpperCase() + spaced.slice(1);
85
+ const aliases = [
86
+ key,
87
+ key.toLowerCase(),
88
+ title,
89
+ title.replace(/\s+/g, ' '),
90
+ key.replace(/([A-Z])/g, '_$1').toLowerCase()
91
+ ];
92
+ for (const alias of aliases) {
93
+ if (hasMeaningfulContractValue(source[alias]))
94
+ return source[alias];
95
+ }
96
+ return undefined;
97
+ }
98
+ /**
99
+ * Fills missing contract keys from markdown sections. Only runs when explicit contract keys were supplied.
100
+ */
101
+ export function enrichParsedForOutputContract(parsed, rawContent, contractKeys) {
102
+ const base = asParsedRecord(parsed);
103
+ if (!contractKeys?.length)
104
+ return base;
105
+ const missing = contractKeys.filter((k) => !hasMeaningfulContractValue(base[k]));
106
+ if (missing.length === 0)
107
+ return base;
108
+ const content = typeof rawContent === 'string' && rawContent.trim().length > 0
109
+ ? rawContent
110
+ : typeof base.rawText === 'string'
111
+ ? base.rawText
112
+ : '';
113
+ if (!content.trim())
114
+ return base;
115
+ const fromMarkdown = parseMarkdownSectionsFromContent(content);
116
+ const merged = { ...base };
117
+ for (const key of missing) {
118
+ const value = pickAliasValue(fromMarkdown, key);
119
+ if (hasMeaningfulContractValue(value)) {
120
+ merged[key] = value;
121
+ }
122
+ }
123
+ return merged;
124
+ }
@@ -4,6 +4,21 @@
4
4
  */
5
5
  import { OpenRouter } from '@openrouter/sdk';
6
6
  import { ProviderTimeoutError } from '../errors.js';
7
+ function preservePartialResponseOnError(error) {
8
+ if (error == null || typeof error !== 'object')
9
+ return;
10
+ const e = error;
11
+ if (e.rawResponse !== undefined)
12
+ return;
13
+ const response = e.response;
14
+ if (response == null || typeof response !== 'object')
15
+ return;
16
+ const resp = response;
17
+ const body = resp.data ?? resp.body ?? resp;
18
+ if (body != null && typeof body === 'object') {
19
+ e.rawResponse = body;
20
+ }
21
+ }
7
22
  /**
8
23
  * Create OpenRouter ProviderModule using @openrouter/sdk directly
9
24
  */
@@ -124,6 +139,7 @@ export function createOpenRouterProvider(config) {
124
139
  if (timedOut && typeof timeoutMs === 'number') {
125
140
  throw new ProviderTimeoutError('openrouter', timeoutMs, operation);
126
141
  }
142
+ preservePartialResponseOnError(error);
127
143
  throw error;
128
144
  }
129
145
  finally {
@@ -223,6 +239,7 @@ export function createOpenRouterProvider(config) {
223
239
  if (timedOut && typeof timeoutMs === 'number') {
224
240
  throw new ProviderTimeoutError('openrouter', timeoutMs, operation);
225
241
  }
242
+ preservePartialResponseOnError(error);
226
243
  throw error;
227
244
  }
228
245
  finally {
@@ -1,6 +1,6 @@
1
1
  import type { ProviderRegistry } from '../registry/ProviderRegistry.js';
2
2
  import type { AdapterRegistry } from '../registry/AdapterRegistry.js';
3
- import type { AIRouterRequest, AIResponse, AIStreamEvent, AIBatchResponse, AIBatchRequestItem } from './RouterTypes.js';
3
+ import type { AIRouterRequest, AIResponse, AIStreamEvent, AIBatchResponse, AIBatchRequestItem, RouterConfig } from './RouterTypes.js';
4
4
  /**
5
5
  * Main router class
6
6
  * Orchestrates provider execution using ProviderModules and router-side adapters
@@ -8,7 +8,12 @@ import type { AIRouterRequest, AIResponse, AIStreamEvent, AIBatchResponse, AIBat
8
8
  export declare class AIRouter {
9
9
  private providers;
10
10
  private adapters;
11
- constructor(providers: ProviderRegistry, adapters: AdapterRegistry);
11
+ private routerConfig;
12
+ constructor(providers: ProviderRegistry, adapters: AdapterRegistry, routerConfig?: RouterConfig);
13
+ /**
14
+ * Resolve provider module name for a specific fallback candidate.
15
+ */
16
+ private resolveProviderNameForCandidate;
12
17
  /**
13
18
  * Resolve provider name from request, checking OpenRouter mode first
14
19
  */
@@ -1,12 +1,34 @@
1
1
  import { newId } from '../utils/ids.js';
2
+ import { applyResponseNormalization } from '../normalization/applyResponseNormalization.js';
3
+ import { extractCostUsdFromRouterResponse } from '../normalization/cost.js';
4
+ import { FallbackExhaustedError } from '../errors.js';
5
+ import { buildCandidateRequest, buildFallbackCandidates, isRetryableError, summarizeError, toError, } from './fallbackUtils.js';
6
+ import { attachPartialRouterPayload, buildPartialRouterPayload } from './partialErrorPayload.js';
2
7
  /**
3
8
  * Main router class
4
9
  * Orchestrates provider execution using ProviderModules and router-side adapters
5
10
  */
6
11
  export class AIRouter {
7
- constructor(providers, adapters) {
12
+ constructor(providers, adapters, routerConfig = {}) {
8
13
  this.providers = providers;
9
14
  this.adapters = adapters;
15
+ this.routerConfig = routerConfig;
16
+ }
17
+ /**
18
+ * Resolve provider module name for a specific fallback candidate.
19
+ */
20
+ resolveProviderNameForCandidate(input, candidateProvider) {
21
+ const candidateInput = {
22
+ ...input,
23
+ request: {
24
+ ...input.request,
25
+ config: {
26
+ ...(input.request?.config ?? {}),
27
+ provider: candidateProvider,
28
+ },
29
+ },
30
+ };
31
+ return this.resolveProviderName(candidateInput);
10
32
  }
11
33
  /**
12
34
  * Resolve provider name from request, checking OpenRouter mode first
@@ -91,12 +113,15 @@ export class AIRouter {
91
113
  async runSync(input) {
92
114
  const requestId = input.requestId ?? newId();
93
115
  const primaryProviderName = this.resolveProviderName(input);
94
- const fallbackProvidersRaw = input.request?.config?.fallbackProviders;
95
- const fallbackProviders = Array.isArray(fallbackProvidersRaw)
96
- ? fallbackProvidersRaw.filter((p) => typeof p === 'string' && p.trim().length > 0).map((p) => p.trim())
97
- : [];
98
- // Candidates are ordered: primary, then fallbacks.
99
- const providerCandidates = [primaryProviderName, ...fallbackProviders.filter((p) => p !== primaryProviderName)];
116
+ const requestConfig = input.request?.config;
117
+ const primaryModel = (typeof requestConfig?.model === 'string' ? requestConfig.model : undefined) ??
118
+ (typeof input.request?.model === 'string' ? input.request.model : undefined);
119
+ const fallbackCandidates = buildFallbackCandidates({
120
+ primaryProvider: primaryProviderName,
121
+ primaryModel,
122
+ requestConfig: requestConfig && typeof requestConfig === 'object' ? requestConfig : undefined,
123
+ routerFallbackChain: this.routerConfig.fallbackChain,
124
+ });
100
125
  const maxRetries = Math.max(0, Math.min(10, Number(input.exec?.retries ?? 0) || 0));
101
126
  const attempts = [];
102
127
  const normalizeUsage = (usage) => {
@@ -127,61 +152,73 @@ export class AIRouter {
127
152
  (typeof args.maxTokens === 'number' ? args.maxTokens : undefined);
128
153
  return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
129
154
  };
130
- const summarizeError = (err) => {
131
- const e = err instanceof Error ? err : new Error(String(err));
132
- const name = typeof e.name === 'string' ? e.name : 'Error';
133
- const message = typeof e.message === 'string' ? e.message : String(err);
134
- // Size cap to avoid shipping huge provider payloads.
135
- const capped = message.length > 500 ? message.slice(0, 500) : message;
136
- return { name, message: capped };
137
- };
138
155
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
139
- const isRetryableError = (err) => {
140
- const msg = err instanceof Error ? err.message : String(err);
141
- const name = err instanceof Error ? err.name : '';
142
- const combined = `${name} ${msg}`.toLowerCase();
143
- return (combined.includes('timeout') ||
144
- combined.includes('timed out') ||
145
- combined.includes('rate limit') ||
146
- combined.includes('429') ||
147
- combined.includes('econnreset') ||
148
- combined.includes('etimedout') ||
149
- combined.includes('503') ||
150
- combined.includes('502') ||
151
- combined.includes('504') ||
152
- combined.includes('temporarily unavailable'));
153
- };
154
156
  const backoffMs = (retryIndex) => {
155
157
  const base = 250 * Math.pow(2, Math.max(0, retryIndex));
156
158
  const jitter = Math.floor(Math.random() * 100);
157
159
  return Math.min(5000, base + jitter);
158
160
  };
159
161
  let lastError = undefined;
160
- for (let fallbackIndex = 0; fallbackIndex < providerCandidates.length; fallbackIndex++) {
161
- const providerName = providerCandidates[fallbackIndex];
162
+ const failedAttemptErrors = [];
163
+ let lastPartialPayload;
164
+ for (let fallbackIndex = 0; fallbackIndex < fallbackCandidates.length; fallbackIndex++) {
165
+ const candidate = fallbackCandidates[fallbackIndex];
166
+ const candidateInput = buildCandidateRequest(input, candidate);
167
+ const providerName = this.resolveProviderNameForCandidate(candidateInput, candidate.provider);
168
+ if (!this.providers.has(providerName)) {
169
+ const err = new Error(`Provider not registered: ${providerName}. Available: ${this.providers.list().join(', ')}`);
170
+ lastError = err;
171
+ failedAttemptErrors.push(err);
172
+ const partialPayload = buildPartialRouterPayload({
173
+ requestId,
174
+ engineProvider: candidate.provider,
175
+ providerModule: providerName,
176
+ modelUsed: candidate.model,
177
+ attempts,
178
+ providerError: err,
179
+ });
180
+ lastPartialPayload = partialPayload;
181
+ attachPartialRouterPayload(err, partialPayload);
182
+ attempts.push({
183
+ ok: false,
184
+ routing: {
185
+ provider: candidate.provider,
186
+ retryIndex: 0,
187
+ fallbackIndex,
188
+ requestIds: { routerRequestId: requestId },
189
+ },
190
+ modelUsed: candidate.model,
191
+ error: summarizeError(err),
192
+ });
193
+ continue;
194
+ }
162
195
  const provider = this.providers.get(providerName);
163
196
  const adapter = this.adapters.get(providerName);
164
197
  // Check capabilities
165
198
  if (!provider.capabilities.modes.sync) {
166
199
  throw new Error(`Provider '${providerName}' does not support sync mode`);
167
200
  }
168
- // Build call spec (once per provider candidate; retries reuse the same spec)
201
+ // Build call spec (once per fallback candidate; retries reuse the same spec)
169
202
  const spec = await adapter.buildCallSpec({
170
203
  requestId,
171
204
  mode: 'sync',
172
- request: input.request,
205
+ request: candidateInput.request,
173
206
  exec: input.exec,
174
207
  });
175
208
  const maxTokensRequested = extractMaxTokensRequested(spec);
176
- const requestedModel = spec?.args?.model ?? input.request?.config?.model ?? input.request?.model;
209
+ const requestedModel = spec?.args?.model ??
210
+ candidate.model ??
211
+ candidateInput.request?.config?.model ??
212
+ candidateInput.request?.model;
177
213
  for (let retryIndex = 0; retryIndex <= maxRetries; retryIndex++) {
178
214
  const startedAt = Date.now();
215
+ let execResult;
179
216
  try {
180
- const execResult = await provider.execute(spec);
217
+ execResult = await provider.execute(spec);
181
218
  const endedAt = Date.now();
182
219
  const parsed = adapter.parseResponse({
183
220
  requestId,
184
- request: { ...input.request, _callSpec: spec }, // Include call spec for adapter access
221
+ request: { ...candidateInput.request, _callSpec: spec },
185
222
  execResult,
186
223
  });
187
224
  const usage = normalizeUsage(parsed.usage);
@@ -194,10 +231,14 @@ export class AIRouter {
194
231
  parsed.rawResponse?.id;
195
232
  const openrouterRequestId = providerName === 'openrouter' ? providerRequestId : undefined;
196
233
  const timing = { startedAt, endedAt, durationMs: endedAt - startedAt };
234
+ const resolvedCostUsd = extractCostUsdFromRouterResponse({
235
+ metadata: parsed.metadata,
236
+ rawResponse: parsed.rawResponse,
237
+ });
197
238
  const attempt = {
198
239
  ok: true,
199
240
  routing: {
200
- provider: providerName,
241
+ provider: candidate.provider,
201
242
  retryIndex,
202
243
  fallbackIndex,
203
244
  requestIds: {
@@ -210,7 +251,7 @@ export class AIRouter {
210
251
  usage,
211
252
  maxTokensRequested,
212
253
  modelUsed,
213
- costUsd: typeof parsed.metadata?.costUsd === 'number' ? parsed.metadata.costUsd : undefined,
254
+ costUsd: resolvedCostUsd,
214
255
  };
215
256
  attempts.push(attempt);
216
257
  const mergedMetadata = {
@@ -218,7 +259,7 @@ export class AIRouter {
218
259
  provider: providerName,
219
260
  modelUsed,
220
261
  maxTokensRequested,
221
- costUsd: attempt.costUsd,
262
+ ...(resolvedCostUsd !== undefined ? { costUsd: resolvedCostUsd, cost: resolvedCostUsd } : {}),
222
263
  requestIds: {
223
264
  ...parsed.metadata?.requestIds,
224
265
  routerRequestId: requestId,
@@ -229,30 +270,49 @@ export class AIRouter {
229
270
  latencyMs: timing.durationMs,
230
271
  attempts,
231
272
  };
232
- return {
273
+ const baseResponse = {
233
274
  ...parsed,
234
275
  requestId,
235
276
  provider: parsed.provider || providerName,
236
277
  usage,
237
278
  metadata: mergedMetadata,
238
279
  };
280
+ return applyResponseNormalization(baseResponse, candidateInput.request);
239
281
  }
240
282
  catch (err) {
241
283
  const endedAt = Date.now();
242
284
  lastError = err;
285
+ failedAttemptErrors.push(err);
286
+ const modelUsed = typeof requestedModel === 'string'
287
+ ? requestedModel
288
+ : candidate.model;
289
+ const timing = { startedAt, endedAt, durationMs: endedAt - startedAt };
243
290
  attempts.push({
244
291
  ok: false,
245
292
  routing: {
246
- provider: providerName,
293
+ provider: candidate.provider,
247
294
  retryIndex,
248
295
  fallbackIndex,
249
296
  requestIds: { routerRequestId: requestId },
250
297
  },
251
- timing: { startedAt, endedAt, durationMs: endedAt - startedAt },
298
+ timing,
252
299
  maxTokensRequested,
253
- modelUsed: typeof requestedModel === 'string' ? requestedModel : undefined,
300
+ modelUsed,
254
301
  error: summarizeError(err),
255
302
  });
303
+ const partialPayload = buildPartialRouterPayload({
304
+ requestId,
305
+ engineProvider: candidate.provider,
306
+ providerModule: providerName,
307
+ modelUsed,
308
+ maxTokensRequested,
309
+ timing,
310
+ attempts,
311
+ execResult,
312
+ providerError: err,
313
+ });
314
+ lastPartialPayload = partialPayload;
315
+ attachPartialRouterPayload(err, partialPayload);
256
316
  const shouldRetry = retryIndex < maxRetries && isRetryableError(err);
257
317
  if (shouldRetry) {
258
318
  await sleep(backoffMs(retryIndex));
@@ -262,10 +322,19 @@ export class AIRouter {
262
322
  }
263
323
  }
264
324
  }
265
- // Fallback chain exhausted
266
- const error = lastError instanceof Error ? lastError : new Error(String(lastError ?? 'Unknown error'));
267
- error.attempts = attempts;
268
- throw error;
325
+ const failedTraces = attempts.filter((a) => !a.ok);
326
+ const exhaustedAttempts = failedTraces.map((trace, i) => ({
327
+ provider: trace.routing.provider,
328
+ model: trace.modelUsed,
329
+ httpStatus: trace.error?.httpStatus,
330
+ error: toError(failedAttemptErrors[i] ?? lastError),
331
+ ...(trace.error?.responsePreview !== undefined ? { responsePreview: trace.error.responsePreview } : {}),
332
+ }));
333
+ const exhaustedError = new FallbackExhaustedError(exhaustedAttempts);
334
+ if (lastPartialPayload) {
335
+ attachPartialRouterPayload(exhaustedError, lastPartialPayload);
336
+ }
337
+ throw exhaustedError;
269
338
  }
270
339
  /**
271
340
  * Execute a streaming request
@@ -8,6 +8,8 @@ export type NormalizedUsage = {
8
8
  completionTokens: number;
9
9
  totalTokens: number;
10
10
  };
11
+ /** Activity billing state when token usage is recorded (Run Analysis G8). */
12
+ export type ActivityCostStatus = 'priced' | 'unpriced';
11
13
  export type AttemptTiming = {
12
14
  startedAt: number;
13
15
  endedAt: number;
@@ -16,6 +18,14 @@ export type AttemptTiming = {
16
18
  export type AttemptErrorSummary = {
17
19
  name: string;
18
20
  message: string;
21
+ httpStatus?: number;
22
+ responsePreview?: string;
23
+ };
24
+ /** Provider + model reference used in fallback chains (`engine` is a gateway alias for `provider`). */
25
+ export type ProviderModelRef = {
26
+ engine?: string;
27
+ provider?: string;
28
+ model?: string;
19
29
  };
20
30
  export type AttemptRouting = {
21
31
  provider: string;
@@ -41,6 +51,10 @@ export type DiagnosticsMetadata = {
41
51
  /** The actual model that served the response (may differ after normalization/routing). */
42
52
  modelUsed?: string;
43
53
  costUsd?: number;
54
+ /** Alias for activity consumers; mirrors `costUsd` when priced. */
55
+ cost?: number;
56
+ /** Set to `unpriced` when usage exists but no USD cost was returned by the provider. */
57
+ costStatus?: ActivityCostStatus;
44
58
  maxTokensRequested?: number;
45
59
  /** Stable correlation ids across router/provider proxies. */
46
60
  requestIds?: Record<string, string | undefined>;
@@ -87,6 +101,8 @@ export interface RouterConfig {
87
101
  httpReferer?: string;
88
102
  xTitle?: string;
89
103
  };
104
+ /** Default provider/model fallback chain when request config does not specify one. */
105
+ fallbackChain?: ProviderModelRef[];
90
106
  }
91
107
  /**
92
108
  * Router request - the input to the router
@@ -106,11 +122,17 @@ export interface AIRouterRequest {
106
122
  /**
107
123
  * Router response for sync mode
108
124
  */
125
+ export type NormalizedRouterOutput = {
126
+ /** Structured fields when an output contract is present (Run Analysis G6/G9). */
127
+ parsed?: Record<string, unknown>;
128
+ };
109
129
  export interface AIResponse {
110
130
  requestId: string;
111
131
  provider: string;
112
132
  rawResponse: unknown;
113
133
  outputText?: string;
134
+ /** Gateway-normalized structured output (`output.parsed` on persisted activities). */
135
+ output?: NormalizedRouterOutput;
114
136
  usage?: NormalizedUsage;
115
137
  reasoning: {
116
138
  requested: {
@@ -60,7 +60,7 @@ export class LLMProviderRouter {
60
60
  this.adapterRegistry.register(new GrokAdapter());
61
61
  this.adapterRegistry.register(new OpenRouterAdapter());
62
62
  // Create router
63
- this.router = new AIRouter(this.providerRegistry, this.adapterRegistry);
63
+ this.router = new AIRouter(this.providerRegistry, this.adapterRegistry, this.config);
64
64
  this.logger.info('Router initialized with ProviderModule architecture', {
65
65
  verbose: this.logger.verbose,
66
66
  logLevel: this.logger.level,
@@ -241,7 +241,7 @@ export class LLMProviderRouter {
241
241
  output: result.usage?.completionTokens || 0,
242
242
  total: result.usage?.totalTokens || 0,
243
243
  },
244
- cost: result.metadata?.costUsd,
244
+ cost: result.metadata?.costUsd ?? result.metadata?.cost,
245
245
  success: true,
246
246
  });
247
247
  }
@@ -0,0 +1,38 @@
1
+ import type { AttemptErrorSummary } from './RouterTypes.js';
2
+ export type FallbackCandidate = {
3
+ provider: string;
4
+ model?: string;
5
+ };
6
+ export type ProviderModelRef = {
7
+ engine?: string;
8
+ provider?: string;
9
+ model?: string;
10
+ };
11
+ export declare function extractHttpStatus(err: unknown): number | undefined;
12
+ export declare function extractResponsePreview(err: unknown): string | undefined;
13
+ export declare function toError(err: unknown): Error;
14
+ export declare function summarizeError(err: unknown): AttemptErrorSummary;
15
+ export declare function isModelNotFoundError(err: unknown): boolean;
16
+ /** Transient failures — retry the same provider/model candidate. */
17
+ export declare function isRetryableError(err: unknown): boolean;
18
+ /** Failures that should advance to the next fallback candidate (not retry same candidate). */
19
+ export declare function isFallbackEligibleError(err: unknown): boolean;
20
+ export declare function buildFallbackCandidates(input: {
21
+ primaryProvider: string;
22
+ primaryModel?: string;
23
+ requestConfig?: Record<string, unknown>;
24
+ routerFallbackChain?: ProviderModelRef[];
25
+ }): FallbackCandidate[];
26
+ export declare function buildCandidateRequest(input: AIRouterRequestLike, candidate: FallbackCandidate): AIRouterRequestLike;
27
+ type AIRouterRequestLike = {
28
+ request?: {
29
+ config?: Record<string, unknown>;
30
+ model?: string;
31
+ [key: string]: unknown;
32
+ };
33
+ provider?: string;
34
+ mode?: string;
35
+ exec?: unknown;
36
+ requestId?: string;
37
+ };
38
+ export {};
@@ -0,0 +1,176 @@
1
+ const PREVIEW_CAP = 500;
2
+ export function extractHttpStatus(err) {
3
+ const e = err;
4
+ if (!e)
5
+ return undefined;
6
+ for (const key of ['status', 'statusCode', 'httpStatus']) {
7
+ const v = e[key];
8
+ if (typeof v === 'number' && Number.isFinite(v))
9
+ return v;
10
+ }
11
+ const response = e.response;
12
+ if (response && typeof response.status === 'number')
13
+ return response.status;
14
+ const msg = err instanceof Error ? err.message : String(err);
15
+ const httpMatch = msg.match(/\bHTTP\s+(\d{3})\b/i);
16
+ if (httpMatch)
17
+ return Number(httpMatch[1]);
18
+ return undefined;
19
+ }
20
+ export function extractResponsePreview(err) {
21
+ const e = err;
22
+ if (!e)
23
+ return undefined;
24
+ const raw = e.body ??
25
+ e.responseBody ??
26
+ e.response?.data ??
27
+ e.data ??
28
+ e.error;
29
+ if (raw === undefined || raw === null)
30
+ return undefined;
31
+ let text;
32
+ if (typeof raw === 'string') {
33
+ text = raw;
34
+ }
35
+ else {
36
+ try {
37
+ text = JSON.stringify(raw);
38
+ }
39
+ catch {
40
+ text = String(raw);
41
+ }
42
+ }
43
+ return text.length > PREVIEW_CAP ? text.slice(0, PREVIEW_CAP) : text;
44
+ }
45
+ export function toError(err) {
46
+ return err instanceof Error ? err : new Error(String(err));
47
+ }
48
+ export function summarizeError(err) {
49
+ const e = toError(err);
50
+ const name = typeof e.name === 'string' ? e.name : 'Error';
51
+ const message = typeof e.message === 'string' ? e.message : String(err);
52
+ const capped = message.length > PREVIEW_CAP ? message.slice(0, PREVIEW_CAP) : message;
53
+ const httpStatus = extractHttpStatus(err);
54
+ const responsePreview = extractResponsePreview(err);
55
+ return {
56
+ name,
57
+ message: capped,
58
+ ...(httpStatus !== undefined ? { httpStatus } : {}),
59
+ ...(responsePreview !== undefined ? { responsePreview } : {}),
60
+ };
61
+ }
62
+ export function isModelNotFoundError(err) {
63
+ const status = extractHttpStatus(err);
64
+ if (status === 404)
65
+ return true;
66
+ const msg = err instanceof Error ? err.message : String(err);
67
+ const name = err instanceof Error ? err.name : '';
68
+ const combined = `${name} ${msg}`.toLowerCase();
69
+ return (combined.includes('model not found') ||
70
+ combined.includes('model_not_found') ||
71
+ combined.includes('model does not exist') ||
72
+ combined.includes('unknown model') ||
73
+ combined.includes('invalid model'));
74
+ }
75
+ /** Transient failures — retry the same provider/model candidate. */
76
+ export function isRetryableError(err) {
77
+ if (isModelNotFoundError(err))
78
+ return false;
79
+ const msg = err instanceof Error ? err.message : String(err);
80
+ const name = err instanceof Error ? err.name : '';
81
+ const combined = `${name} ${msg}`.toLowerCase();
82
+ return (combined.includes('timeout') ||
83
+ combined.includes('timed out') ||
84
+ combined.includes('rate limit') ||
85
+ combined.includes('429') ||
86
+ combined.includes('econnreset') ||
87
+ combined.includes('etimedout') ||
88
+ combined.includes('503') ||
89
+ combined.includes('502') ||
90
+ combined.includes('504') ||
91
+ combined.includes('temporarily unavailable'));
92
+ }
93
+ /** Failures that should advance to the next fallback candidate (not retry same candidate). */
94
+ export function isFallbackEligibleError(err) {
95
+ if (isModelNotFoundError(err))
96
+ return true;
97
+ const status = extractHttpStatus(err);
98
+ if (status !== undefined) {
99
+ if (status === 404)
100
+ return true;
101
+ if (status >= 500)
102
+ return false;
103
+ if (status === 429)
104
+ return false;
105
+ }
106
+ return !isRetryableError(err);
107
+ }
108
+ function parseProviderModelRef(entry) {
109
+ if (!entry || typeof entry !== 'object')
110
+ return undefined;
111
+ const ref = entry;
112
+ const providerRaw = ref.engine ?? ref.provider;
113
+ if (typeof providerRaw !== 'string' || !providerRaw.trim())
114
+ return undefined;
115
+ const provider = providerRaw.trim();
116
+ const model = typeof ref.model === 'string' && ref.model.trim() ? ref.model.trim() : undefined;
117
+ return { provider, model };
118
+ }
119
+ function dedupeCandidates(candidates) {
120
+ const seen = new Set();
121
+ const out = [];
122
+ for (const c of candidates) {
123
+ const key = `${c.provider}\0${c.model ?? ''}`;
124
+ if (seen.has(key))
125
+ continue;
126
+ seen.add(key);
127
+ out.push(c);
128
+ }
129
+ return out;
130
+ }
131
+ export function buildFallbackCandidates(input) {
132
+ const { primaryProvider, primaryModel, requestConfig, routerFallbackChain } = input;
133
+ const candidates = [{ provider: primaryProvider, model: primaryModel }];
134
+ const chainRaw = (Array.isArray(requestConfig?.fallbackChain) ? requestConfig.fallbackChain : undefined) ??
135
+ (Array.isArray(requestConfig?.fallbackEngines) ? requestConfig.fallbackEngines : undefined) ??
136
+ (Array.isArray(routerFallbackChain) ? routerFallbackChain : undefined);
137
+ if (Array.isArray(chainRaw) && chainRaw.length > 0) {
138
+ for (const entry of chainRaw) {
139
+ const parsed = parseProviderModelRef(entry);
140
+ if (parsed)
141
+ candidates.push(parsed);
142
+ }
143
+ }
144
+ else {
145
+ const fallbackProvidersRaw = requestConfig?.fallbackProviders;
146
+ const fallbackProviders = Array.isArray(fallbackProvidersRaw)
147
+ ? fallbackProvidersRaw
148
+ .filter((p) => typeof p === 'string' && p.trim().length > 0)
149
+ .map((p) => p.trim())
150
+ : [];
151
+ for (const provider of fallbackProviders) {
152
+ if (provider !== primaryProvider) {
153
+ candidates.push({ provider, model: primaryModel });
154
+ }
155
+ }
156
+ }
157
+ return dedupeCandidates(candidates);
158
+ }
159
+ export function buildCandidateRequest(input, candidate) {
160
+ const baseConfig = (input.request?.config && typeof input.request.config === 'object')
161
+ ? input.request.config
162
+ : {};
163
+ const model = candidate.model ?? (typeof baseConfig.model === 'string' ? baseConfig.model : undefined);
164
+ return {
165
+ ...input,
166
+ request: {
167
+ ...input.request,
168
+ ...(model !== undefined ? { model } : {}),
169
+ config: {
170
+ ...baseConfig,
171
+ provider: candidate.provider,
172
+ ...(model !== undefined ? { model } : {}),
173
+ },
174
+ },
175
+ };
176
+ }
@@ -0,0 +1,26 @@
1
+ import type { ProviderSDKExecResult } from '@x12i/ai-provider-interface';
2
+ import type { AttemptTrace, DiagnosticsMetadata, NormalizedUsage } from './RouterTypes.js';
3
+ export type PartialRouterPayload = {
4
+ requestId: string;
5
+ provider: string;
6
+ usage?: NormalizedUsage;
7
+ metadata: DiagnosticsMetadata;
8
+ rawResponse?: unknown;
9
+ };
10
+ export declare function attachPartialRouterPayload(err: unknown, payload: PartialRouterPayload): void;
11
+ export declare function normalizeUsageFromUnknown(usage: unknown): NormalizedUsage | undefined;
12
+ export declare function buildPartialRouterPayload(args: {
13
+ requestId: string;
14
+ engineProvider: string;
15
+ providerModule?: string;
16
+ modelUsed?: string;
17
+ maxTokensRequested?: number;
18
+ timing?: {
19
+ startedAt: number;
20
+ endedAt: number;
21
+ durationMs: number;
22
+ };
23
+ attempts?: AttemptTrace[];
24
+ execResult?: ProviderSDKExecResult;
25
+ providerError?: unknown;
26
+ }): PartialRouterPayload;
@@ -0,0 +1,133 @@
1
+ export function attachPartialRouterPayload(err, payload) {
2
+ if (err == null || typeof err !== 'object')
3
+ return;
4
+ err.response = payload;
5
+ }
6
+ function firstString(...values) {
7
+ for (const v of values) {
8
+ if (typeof v === 'string' && v.trim())
9
+ return v.trim();
10
+ }
11
+ return undefined;
12
+ }
13
+ export function normalizeUsageFromUnknown(usage) {
14
+ if (!usage || typeof usage !== 'object')
15
+ return undefined;
16
+ const u = usage;
17
+ const promptTokens = (typeof u.promptTokens === 'number' ? u.promptTokens : undefined) ??
18
+ (typeof u.inputTokens === 'number' ? u.inputTokens : undefined) ??
19
+ (typeof u.prompt_tokens === 'number' ? u.prompt_tokens : undefined) ??
20
+ (typeof u.input_tokens === 'number' ? u.input_tokens : undefined) ??
21
+ (typeof u.prompt === 'number' ? u.prompt : undefined);
22
+ const completionTokens = (typeof u.completionTokens === 'number' ? u.completionTokens : undefined) ??
23
+ (typeof u.outputTokens === 'number' ? u.outputTokens : undefined) ??
24
+ (typeof u.completion_tokens === 'number' ? u.completion_tokens : undefined) ??
25
+ (typeof u.output_tokens === 'number' ? u.output_tokens : undefined) ??
26
+ (typeof u.completion === 'number' ? u.completion : undefined);
27
+ const totalTokens = (typeof u.totalTokens === 'number' ? u.totalTokens : undefined) ??
28
+ (typeof u.total_tokens === 'number' ? u.total_tokens : undefined) ??
29
+ (typeof u.total === 'number' ? u.total : undefined);
30
+ if (promptTokens === undefined && completionTokens === undefined && totalTokens === undefined) {
31
+ return undefined;
32
+ }
33
+ const p = promptTokens ?? 0;
34
+ const c = completionTokens ?? 0;
35
+ return { promptTokens: p, completionTokens: c, totalTokens: totalTokens ?? p + c };
36
+ }
37
+ function extractIdsFromRaw(raw) {
38
+ if (!raw || typeof raw !== 'object')
39
+ return {};
40
+ const r = raw;
41
+ const id = firstString(r.id, r.request_id, r.requestId);
42
+ if (!id)
43
+ return {};
44
+ return { providerRequestId: id, openrouterRequestId: id };
45
+ }
46
+ function extractRawBody(err) {
47
+ if (err == null || typeof err !== 'object')
48
+ return undefined;
49
+ const e = err;
50
+ const response = e.response;
51
+ if (response != null && typeof response === 'object') {
52
+ const resp = response;
53
+ if (resp.data !== undefined)
54
+ return resp.data;
55
+ if (resp.body !== undefined)
56
+ return resp.body;
57
+ }
58
+ for (const key of ['rawResponse', 'body', 'data', 'lastResponse', 'routerResponse']) {
59
+ if (e[key] !== undefined)
60
+ return e[key];
61
+ }
62
+ const nestedError = e.error;
63
+ if (nestedError != null && typeof nestedError === 'object') {
64
+ return extractRawBody(nestedError);
65
+ }
66
+ return undefined;
67
+ }
68
+ function extractHeaderRequestId(err) {
69
+ if (err == null || typeof err !== 'object')
70
+ return undefined;
71
+ const e = err;
72
+ const response = e.response;
73
+ if (response == null || typeof response !== 'object')
74
+ return undefined;
75
+ const headers = response.headers;
76
+ if (headers == null || typeof headers !== 'object')
77
+ return undefined;
78
+ const h = headers;
79
+ return firstString(h['x-request-id'], h['x-openrouter-request-id'], h['request-id'], h['openrouter-request-id']);
80
+ }
81
+ function usageToGatewayTokens(usage) {
82
+ return {
83
+ prompt: usage.promptTokens,
84
+ completion: usage.completionTokens,
85
+ total: usage.totalTokens,
86
+ };
87
+ }
88
+ export function buildPartialRouterPayload(args) {
89
+ let usage = args.execResult?.rawResponse
90
+ ? normalizeUsageFromUnknown(args.execResult.rawResponse?.usage)
91
+ : undefined;
92
+ let rawResponse = args.execResult?.rawResponse;
93
+ let ids = rawResponse ? extractIdsFromRaw(rawResponse) : {};
94
+ const errorBody = extractRawBody(args.providerError);
95
+ if (errorBody !== undefined) {
96
+ rawResponse = rawResponse ?? errorBody;
97
+ usage = usage ?? normalizeUsageFromUnknown(errorBody?.usage);
98
+ const bodyIds = extractIdsFromRaw(errorBody);
99
+ ids = { ...bodyIds, ...ids };
100
+ }
101
+ const headerRequestId = extractHeaderRequestId(args.providerError);
102
+ if (headerRequestId) {
103
+ ids = {
104
+ providerRequestId: ids.providerRequestId ?? headerRequestId,
105
+ openrouterRequestId: ids.openrouterRequestId ?? headerRequestId,
106
+ };
107
+ }
108
+ const providerRequestId = ids.providerRequestId;
109
+ const openrouterRequestId = args.providerModule === 'openrouter' ? providerRequestId : ids.openrouterRequestId;
110
+ const metadata = {
111
+ provider: args.providerModule ?? args.engineProvider,
112
+ ...(args.modelUsed !== undefined ? { modelUsed: args.modelUsed } : {}),
113
+ ...(args.maxTokensRequested !== undefined ? { maxTokensRequested: args.maxTokensRequested } : {}),
114
+ requestIds: {
115
+ routerRequestId: args.requestId,
116
+ ...(providerRequestId ? { providerRequestId } : {}),
117
+ ...(openrouterRequestId ? { openrouterRequestId } : {}),
118
+ },
119
+ ...(args.timing ? { timing: args.timing, latencyMs: args.timing.durationMs } : {}),
120
+ ...(args.attempts ? { attempts: args.attempts } : {}),
121
+ };
122
+ if (usage) {
123
+ metadata.usage = usage;
124
+ metadata.tokens = usageToGatewayTokens(usage);
125
+ }
126
+ return {
127
+ requestId: args.requestId,
128
+ provider: args.engineProvider,
129
+ ...(usage ? { usage } : {}),
130
+ ...(rawResponse !== undefined ? { rawResponse } : {}),
131
+ metadata,
132
+ };
133
+ }
package/dist/router.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { LLMProviderRouter } from './router/RouterWrapper.js';
2
- export type { RouterConfig, AIRouterRequest, AIResponse, AIStreamEvent, AIBatchResponse, AIBatchRequestItem } from './router/RouterTypes.js';
2
+ export type { RouterConfig, AIRouterRequest, AIResponse, AIStreamEvent, AIBatchResponse, AIBatchRequestItem, ActivityCostStatus, NormalizedRouterOutput, ProviderModelRef, } from './router/RouterTypes.js';
3
3
  export { ProviderRegistry } from './registry/ProviderRegistry.js';
4
4
  export { AdapterRegistry } from './registry/AdapterRegistry.js';
5
5
  export { OpenAIAdapter } from './adapters/openai/OpenAIAdapter.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x12i/ai-providers-router",
3
- "version": "4.7.7",
3
+ "version": "4.8.2",
4
4
  "description": "Unified router for all LLM provider implementations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -18,6 +18,7 @@
18
18
  "fix:imports": "node fix-import-extensions.js",
19
19
  "prepublishOnly": "rm -rf dist && npm run build && node -e \"import('fs').then(fs => fs.accessSync('dist/index.js'))\"",
20
20
  "test": "npm run build && node --test .tests/**/*.test.js",
21
+ "test:cost-output": "npm run build && node --test .tests/cost-and-output-contract.test.js",
21
22
  "test:openai": "ts-node .tests/callOpenAI.ts",
22
23
  "test:grok": "ts-node .tests/testOpenRouter.ts",
23
24
  "test:catalog": "ts-node .tests/testCatalog.ts",