@x12i/ai-gateway 10.0.4 → 10.0.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 CHANGED
@@ -9,9 +9,9 @@ Unified gateway for LLM provider routing, structured logging, optional Activix a
9
9
  | **Routing** | Registers providers (or lazy-registers from env), invokes the router with merged model config, retries, and optional fallback chain. |
10
10
  | **`invoke()`** | Builds messages from instructions + prompt templates + `workingMemory`; requires runtime **identity** and **actionType** / **actionRef**. |
11
11
  | **`invokeChat()`** | Raw chat-style requests; no instruction builder or action classification. |
12
- | **Cost** | Forwards router `costStatus` when present; otherwise prices via **@x12i/ai-tools** open-assets catalogs (`calculateFromRecord`). |
13
- | **Activix** | Optional Mongo-backed activity rows (`ai-actions`, `bad-requests`, `skill-executions`) with root billing fields and `outer` I/O. |
14
- | **Trace mode** | `diagnostics.mode === 'trace'` adds `metadata.attempts[]`, `metadata.usage`, and per-attempt billing when priced. |
12
+ | **Cost** | Steps A→D on every successful **`invoke()`** / **`invokeChat()`**: router cost first, then **`@x12i/ai-tools`** catalog via **`calculateFromRecord`** when still unpriced. Single path — **`resolveCostCompletionWithAiTools`**. |
13
+ | **Activix** | Optional Mongo-backed activity rows; billing written from gateway-computed slice on **`completeRecord`** (`outer.cost` + root fields). No Activix **`autoCost`** re-pricing. |
14
+ | **Trace mode** | `diagnostics.mode === 'trace'` adds `metadata.attempts[]`, `metadata.usage`, and per-attempt **`costUsd`** / **`costStatus`**. |
15
15
 
16
16
  Pinned dependency versions are in `package.json` (currently **Activix ^8.5**, **ai-tools ^2.5**, **ai-providers-router ^4.9**).
17
17
 
@@ -171,6 +171,7 @@ Hosts wrapping the gateway should expose on **their** public API:
171
171
  | `temperature`, `topP`, `frequencyPenalty`, `presencePenalty`, `maxTokens` | Optional | Document defaults from `GATEWAY_DEFAULT_*` |
172
172
  | `retry` | Optional | Same shape as `RetryConfig`; defaults from `GATEWAY_DEFAULT_RETRY` |
173
173
  | `mode` | Optional | `'dev'` \| `'debug'` \| `'prod'` — pass through to `GatewayConfig.mode` |
174
+ | Billing | Read-only on response | **`response.metadata.costUsd`**, **`costStatus`**, **`tokens`** — gateway-owned; do not re-price |
174
175
  | `templateRenderOptions` / `smartInput` | Optional | Rendrix overrides |
175
176
 
176
177
  Instructions must be **complete caller text** — the gateway no longer injects packaged instruction blocks.
@@ -210,19 +211,43 @@ Exports: `GATEWAY_LOGXER_PACKAGE`, `GATEWAY_LOG_ENV_PREFIX`, `createGatewayLogge
210
211
 
211
212
  ## @x12i/ai-tools v2 (models + cost)
212
213
 
213
- - **No Catalox / Firestore**catalogs come from ai-tools open-assets JSON (optional `bundledOnly`).
214
- - **`aiTools.enabled`** — bootstrap catalog client + calculator.
215
- - **`aiTools.resolveModels`** — `mergeConfig()` calls `resolveInvokeModel()` (catalog + OpenRouter/direct routing).
216
- - **`aiTools.modelsOnly`** — **`true` by default** — reject profile shortcuts (`cheapest`, `cheap/default`, …); pass concrete model ids only.
217
- - **`aiTools.calculateCost`** — prices usage before Activix `completeRecord` when the router did not mark the call priced.
214
+ Engine-owned catalog bootstrap and post-call billing. Consumers read **`metadata.costUsd`** / **`costStatus`** only no direct `@x12i/ai-tools` dependency for cost.
218
215
 
219
- Gateway exports the orchestrator from `@x12i/ai-tools` ≥ **2.5.0** (`resolveInvokeModel`, …) see [AI_TOOLS_INVOKE_MODEL_RESOLUTION_ORCHESTRATOR_SPEC.md](./docs/upstream-reports/AI_TOOLS_INVOKE_MODEL_RESOLUTION_ORCHESTRATOR_SPEC.md).
216
+ ### Resolution order (after every successful LLM call)
220
217
 
221
- Gateway helpers (also exported): `resolveCostCompletionWithAiTools`, `buildTraceUsageSummary`, `enrichTraceAttemptsWithBilling`.
218
+ | Step | Condition | Result |
219
+ |------|-----------|--------|
220
+ | A | Router/provider returned finite **`costUsd`** (or equivalent) | **`costStatus: "priced"`**, set cost |
221
+ | B | Tokens + catalog pricing succeeds (`isAuthoritative`, not `unknownModel`, finite cost ≥ 0) | **`priced`** (+ optional breakdown) |
222
+ | C | Tokens but no price | **`unpriced`** |
223
+ | D | No usage | omit **`costUsd`** and **`costStatus`** |
224
+
225
+ Step A always wins; explicit router **`costStatus: "unpriced"`** is never overridden by catalog.
226
+
227
+ Implemented in **`resolveCostCompletionWithAiTools`** only ( **`CostCalculator.calculateFromRecord`** via **`buildGatewayPricingRecord`** for Step B). Upstream target: **`resolveInvokeBilling`** in ai-tools — [AI_TOOLS_INVOKE_BILLING_ORCHESTRATOR_SPEC.md](./docs/upstream-reports/AI_TOOLS_INVOKE_BILLING_ORCHESTRATOR_SPEC.md).
228
+
229
+ ### `aiTools` config (aligned with funcx / generic engine contract)
230
+
231
+ | Flag | Default | Purpose |
232
+ |------|---------|---------|
233
+ | **`enabled`** | `true` | Bootstrap **`AiModelsCatalogClient`** + **`CostCalculator`** |
234
+ | **`calculateCost`** | `true` | Run post-call catalog pricing when router did not price |
235
+ | **`resolveModels`** | `true` | **`mergeConfig()`** → **`resolveInvokeModel()`** |
236
+ | **`modelsOnly`** | `true` | Reject profile shortcuts (`cheapest`, `cheap/default`, …) |
237
+ | **`bundledOnly`** | `false` | Offline bundled catalogs only |
238
+ | **`costIncludeBreakdown`** | `false` | Include prompt/completion breakdown on priced results |
239
+ | **`catalogLane`** | `"text"` (ai-tools default) | Catalog lane for resolution + cost lookup (`text`, `image`, …) |
240
+ | **`cacheTtlMs`** | ai-tools default (24h) | In-memory catalog cache TTL |
241
+
242
+ - **No Catalox / Firestore** — catalogs come from ai-tools open-assets JSON (optional **`bundledOnly`**).
243
+
244
+ Gateway exports the model orchestrator from `@x12i/ai-tools` ≥ **2.5.0** (`resolveInvokeModel`, …) — see [AI_TOOLS_INVOKE_MODEL_RESOLUTION_ORCHESTRATOR_SPEC.md](./docs/upstream-reports/AI_TOOLS_INVOKE_MODEL_RESOLUTION_ORCHESTRATOR_SPEC.md).
245
+
246
+ Gateway billing helpers (exported for tests/integrators): `resolveCostCompletionWithAiTools`, `buildGatewayPricingRecord`, `catalogPricingSucceeded`, `buildTraceUsageSummary`, `enrichTraceAttemptsWithBilling`.
222
247
 
223
248
  ---
224
249
 
225
- ## Activity tracking (@x12i/activix 7.2)
250
+ ## Activity tracking (@x12i/activix 8.x)
226
251
 
227
252
  When tracking is enabled and no custom tracker is supplied, the gateway constructs Activix with fixed collection names (see `src/config/activity-tracking-config.ts`):
228
253
 
@@ -241,7 +266,7 @@ When tracking is enabled and no custom tracker is supplied, the gateway construc
241
266
  - `outer.cost`: Activix cost shape (`usd`, `tokens`, `provider`, `model`, `details`)
242
267
  - `response.metadata`: same billing slice as returned to callers
243
268
 
244
- When **`aiTools.calculateCost`** is on and you do not pass `activityTracker`, Activix **`autoCost`** is enabled with **`overwriteOuterCost: false`** so gateway-computed cost wins.
269
+ Gateway resolves billing **before** `completeRecord` and sets **`outer.cost`** from that slice. Activix **`autoCost`** is **not** used on the default activity manager (no second pricing path).
245
270
 
246
271
  Mongo env: `MONGO_URI` + `MONGO_LOGS_DB` or `MONGO_DB`.
247
272
 
@@ -249,10 +274,20 @@ Mongo env: `MONGO_URI` + `MONGO_LOGS_DB` or `MONGO_DB`.
249
274
 
250
275
  ## Response metadata and cost
251
276
 
252
- On every successful **`invoke()`**:
277
+ On every successful **`invoke()`** and **`invokeChat()`**:
278
+
279
+ - **`metadata.provider`**, **`modelUsed`**, **`maxTokensRequested`**, **`effectiveModelConfig`** (invoke only)
280
+ - **`metadata.tokens`**, **`costStatus`**, **`costUsd`**, optional **`costBreakdown`**; **`cost`** mirrors **`costUsd`** when priced
281
+
282
+ ### Client rules (ai-skills, graph-engine, etc.)
283
+
284
+ | `metadata.costStatus` | Meaning | Client action |
285
+ |------------------------|---------|---------------|
286
+ | **`priced`** | Gateway resolved a billable USD amount | Use **`metadata.costUsd`** (or **`cost`**) |
287
+ | **`unpriced`** | Tokens recorded; no authoritative price | Do **not** call ai-tools or re-price |
288
+ | *(absent)* | No token usage | No billing signal |
253
289
 
254
- - **`metadata.provider`**, **`modelUsed`**, **`maxTokensRequested`**, **`effectiveModelConfig`**
255
- - **`metadata.tokens`**, **`costStatus`**, **`costUsd`** when usage exists and pricing applies
290
+ Do **not** add a direct **`@x12i/ai-tools`** dependency for post-call cost. For Activix rows you write yourself, use **`normalizeToActivixCostShape`** (re-exported from `@x12i/activix`) from **`costUsd`** + **`metadata.tokens`**.
256
291
 
257
292
  Full contract: [AI Gateway invoke execution metadata](./docs/AI_GATEWAY_INVOKE_EXECUTION_METADATA.md).
258
293
 
@@ -2,7 +2,7 @@
2
2
  * @x12i/ai-tools invoke client bootstrap for the gateway.
3
3
  * Model resolution orchestration lives in ai-tools ≥ 2.5.0 (`resolveInvokeModel`).
4
4
  */
5
- import { getAiToolsInvokeClient, resetAiToolsInvokeClientForTests as resetAiToolsInvokeClientForTestsUpstream, mapResolutionToRouterConfig, buildInvokeModelResolverOptions, } from '@x12i/ai-tools';
5
+ import { getAiToolsInvokeClient, resetAiToolsInvokeClientForTests as resetAiToolsInvokeClientForTestsUpstream, mapResolutionToRouterConfig, buildInvokeModelResolverOptions, CostCalculator, } from '@x12i/ai-tools';
6
6
  import { gatewayLogDebug, withActivityIdentity } from './gateway-log-meta.js';
7
7
  import { resolvePreferOpenRouter } from './openrouter-routing.js';
8
8
  export { resolveInvokeModel, applyOpenRouterInvokePolicy, buildInvokeModelResolverOptions, enrichModelResolutionError, mapResolutionToRouterConfig, ModelProfileUnroutableError, ModelProfileInputRejectedError, MODEL_PROFILE_UNROUTABLE, getAiToolsInvokeClient, resetAiToolsInvokeClientForTests as resetAiToolsInvokeClientForTestsUpstream, createAiToolsInvokeClient, } from '@x12i/ai-tools';
@@ -13,7 +13,22 @@ function invokeClientOptions(config) {
13
13
  cacheTtlMs: config.aiTools?.cacheTtlMs,
14
14
  ...(config.aiTools?.bundledOnly ? { bundledOnly: true } : {}),
15
15
  ...(config.aiTools?.costIncludeBreakdown ? { costIncludeBreakdown: true } : {}),
16
- cacheKey: `${config.aiTools?.cacheTtlMs ?? ''}:${config.aiTools?.costIncludeBreakdown ?? ''}:${config.aiTools?.bundledOnly ?? ''}`,
16
+ cacheKey: `${config.aiTools?.cacheTtlMs ?? ''}:${config.aiTools?.costIncludeBreakdown ?? ''}:${config.aiTools?.bundledOnly ?? ''}:${config.aiTools?.catalogLane ?? ''}`,
17
+ };
18
+ }
19
+ function withCatalogLaneCalculator(client, config) {
20
+ const lane = config.aiTools?.catalogLane;
21
+ if (!lane)
22
+ return client;
23
+ return {
24
+ ...client,
25
+ calculator: new CostCalculator(client.catalog, {
26
+ ...(config.aiTools?.costIncludeBreakdown ? { includeBreakdown: true } : {}),
27
+ resolverOptions: buildInvokeModelResolverOptions({
28
+ routingEnv: client.routingEnv,
29
+ catalogLane: lane
30
+ })
31
+ })
17
32
  };
18
33
  }
19
34
  /** @deprecated Use buildInvokeModelResolverOptions */
@@ -53,6 +68,7 @@ export async function getAiToolsClient(config, logger) {
53
68
  logger.debug('ai-tools catalog client ready', {
54
69
  debugKind: gatewayLogDebug.state,
55
70
  });
71
+ return withCatalogLaneCalculator(client, config);
56
72
  }
57
73
  return client;
58
74
  }
@@ -176,17 +176,8 @@ export function initializeGatewayComponents(config) {
176
176
  enableActivityTracking: config.enableActivityTracking ?? true,
177
177
  customTracker: config.activityTracker,
178
178
  logger,
179
- ...(config.activityTracker
180
- ? {}
181
- : {
182
- autoCost: config.aiTools?.enabled === false || config.aiTools?.calculateCost === false
183
- ? false
184
- : {
185
- enabled: true,
186
- overwriteOuterCost: false,
187
- ...(config.aiTools?.bundledOnly ? { bundledOnly: true } : {})
188
- }
189
- })
179
+ // Billing is resolved in gateway before logSuccess; Activix gets outer.cost from that slice only.
180
+ ...(config.activityTracker ? {} : { autoCost: false })
190
181
  });
191
182
  const templateRendering = mergeTemplateRenderOptions(defaultTemplateRendering, config.templateRendering);
192
183
  const messageBuilderConfig = {
@@ -72,8 +72,8 @@ export declare function hasNonZeroTokenUsage(tokens: {
72
72
  total: number;
73
73
  }): boolean;
74
74
  /**
75
- * Gateway fallback when the router does not set `metadata.costStatus`.
76
- * Prefer {@link resolveCostCompletionForActivity} at invoke boundaries.
75
+ * Step A/C/D cost slice when the router omits explicit `metadata.costStatus`.
76
+ * Prefer {@link resolveCostCompletionWithAiTools} at invoke boundaries.
77
77
  */
78
78
  export declare function resolveActivityCostCompletion(tokens: {
79
79
  prompt: number;
@@ -81,8 +81,7 @@ export declare function resolveActivityCostCompletion(tokens: {
81
81
  total: number;
82
82
  }, costUsd: number | undefined): ResolvedActivityCost;
83
83
  /**
84
- * Activity cost slice for Activix: router `metadata.costStatus` / cost wins when present;
85
- * otherwise gateway applies the G8 fallback (usage + no price → `unpriced`).
84
+ * Step A router passthrough + Step C when the router omits `metadata.costStatus`.
86
85
  */
87
86
  export declare function resolveCostCompletionForActivity(routerResponse: unknown, tokens: {
88
87
  prompt: number;
@@ -94,7 +93,23 @@ export type ResolveCostCompletionOptions = {
94
93
  calculator?: CostCalculator | null;
95
94
  calculateCost?: boolean;
96
95
  };
97
- /** Record shape for {@link CostCalculator.calculateFromRecord} (router + merged config + usage). */
96
+ /** Optional cache/reasoning token fields for catalog pricing records. */
97
+ export type InvokeUsageExtras = {
98
+ cached?: number;
99
+ cacheWrite?: number;
100
+ reasoning?: number;
101
+ };
102
+ /**
103
+ * Best-effort cache/reasoning token counts from router usage buckets
104
+ * (for {@link buildGatewayPricingRecord} / ai-tools {@link CostCalculator.calculateFromRecord}).
105
+ */
106
+ export declare function extractUsageExtrasFromRouterResponse(routerResponse: unknown): InvokeUsageExtras;
107
+ /**
108
+ * Whether ai-tools catalog pricing is authoritative enough for Step B (`priced`).
109
+ * Matches the generic engine contract: authoritative catalog hit with finite cost ≥ 0.
110
+ */
111
+ export declare function catalogPricingSucceeded(result: AiCostResult): boolean;
112
+ /** Record shape for {@link CostCalculator.calculateFromRecord} (shared engine contract). */
98
113
  export declare function buildGatewayPricingRecord(routerResponse: unknown, tokens: {
99
114
  prompt: number;
100
115
  completion: number;
@@ -102,7 +117,8 @@ export declare function buildGatewayPricingRecord(routerResponse: unknown, token
102
117
  }, mergedConfig?: unknown): Record<string, unknown>;
103
118
  export declare function mapAiCostResultToResolvedActivityCost(base: ResolvedActivityCost, result: AiCostResult): ResolvedActivityCost;
104
119
  /**
105
- * Router cost passthrough, then optional @x12i/ai-tools catalog pricing when still unpriced.
120
+ * Post-invoke billing (Steps A→D): router cost, then catalog via ai-tools when still unpriced.
121
+ * Single entry point for `invoke()` / `invokeChat()` and trace enrichment.
106
122
  */
107
123
  export declare function resolveCostCompletionWithAiTools(routerResponse: unknown, tokens: {
108
124
  prompt: number;
@@ -108,6 +108,8 @@ export async function mergeConfig(request, config, logger, mergeOptions) {
108
108
  defaultProvider: config.defaultEngine,
109
109
  resolveModels: true,
110
110
  modelsOnly: config.aiTools?.modelsOnly !== false,
111
+ ...(config.aiTools?.catalogLane ? { catalogLane: config.aiTools.catalogLane } : {}),
112
+ ...(config.aiTools?.bundledOnly ? { bundledOnly: true } : {}),
111
113
  });
112
114
  merged.provider = resolved.router.provider;
113
115
  merged.model = resolved.router.model;
@@ -338,8 +340,8 @@ function pickRouterCostStatus(routerResponse) {
338
340
  return status === 'priced' || status === 'unpriced' ? status : undefined;
339
341
  }
340
342
  /**
341
- * Gateway fallback when the router does not set `metadata.costStatus`.
342
- * Prefer {@link resolveCostCompletionForActivity} at invoke boundaries.
343
+ * Step A/C/D cost slice when the router omits explicit `metadata.costStatus`.
344
+ * Prefer {@link resolveCostCompletionWithAiTools} at invoke boundaries.
343
345
  */
344
346
  export function resolveActivityCostCompletion(tokens, costUsd) {
345
347
  if (typeof costUsd === 'number' && Number.isFinite(costUsd)) {
@@ -351,8 +353,7 @@ export function resolveActivityCostCompletion(tokens, costUsd) {
351
353
  return {};
352
354
  }
353
355
  /**
354
- * Activity cost slice for Activix: router `metadata.costStatus` / cost wins when present;
355
- * otherwise gateway applies the G8 fallback (usage + no price → `unpriced`).
356
+ * Step A router passthrough + Step C when the router omits `metadata.costStatus`.
356
357
  */
357
358
  export function resolveCostCompletionForActivity(routerResponse, tokens) {
358
359
  const routerStatus = pickRouterCostStatus(routerResponse);
@@ -368,42 +369,119 @@ export function resolveCostCompletionForActivity(routerResponse, tokens) {
368
369
  }
369
370
  return resolveActivityCostCompletion(tokens, costUsd);
370
371
  }
371
- /** Record shape for {@link CostCalculator.calculateFromRecord} (router + merged config + usage). */
372
+ /**
373
+ * Best-effort cache/reasoning token counts from router usage buckets
374
+ * (for {@link buildGatewayPricingRecord} / ai-tools {@link CostCalculator.calculateFromRecord}).
375
+ */
376
+ export function extractUsageExtrasFromRouterResponse(routerResponse) {
377
+ if (routerResponse == null || typeof routerResponse !== 'object')
378
+ return {};
379
+ const r = routerResponse;
380
+ const roots = [r.usage];
381
+ const meta = r.metadata != null && typeof r.metadata === 'object'
382
+ ? r.metadata
383
+ : undefined;
384
+ if (meta) {
385
+ roots.push(meta.usage, meta.tokens);
386
+ }
387
+ const raw = r.rawResponse ?? r.raw;
388
+ if (raw != null && typeof raw === 'object') {
389
+ roots.push(raw.usage);
390
+ }
391
+ const extras = {};
392
+ for (const bucket of roots) {
393
+ if (bucket == null || typeof bucket !== 'object')
394
+ continue;
395
+ const u = bucket;
396
+ const cached = firstFiniteNumber(u.cached, u.cached_tokens, u.cachedTokens, u.cache_read_tokens, u.cacheReadTokens);
397
+ const cacheWrite = firstFiniteNumber(u.cacheWrite, u.cache_write_tokens, u.cacheWriteTokens);
398
+ const reasoning = firstFiniteNumber(u.reasoning, u.reasoning_tokens, u.reasoningTokens);
399
+ if (cached !== undefined && extras.cached === undefined)
400
+ extras.cached = cached;
401
+ if (cacheWrite !== undefined && extras.cacheWrite === undefined)
402
+ extras.cacheWrite = cacheWrite;
403
+ if (reasoning !== undefined && extras.reasoning === undefined)
404
+ extras.reasoning = reasoning;
405
+ }
406
+ return extras;
407
+ }
408
+ /**
409
+ * Whether ai-tools catalog pricing is authoritative enough for Step B (`priced`).
410
+ * Matches the generic engine contract: authoritative catalog hit with finite cost ≥ 0.
411
+ */
412
+ export function catalogPricingSucceeded(result) {
413
+ if (result.unknownModel)
414
+ return false;
415
+ if (!result.isAuthoritative)
416
+ return false;
417
+ if (result.source === 'estimate-fallback' || result.source === 'local')
418
+ return false;
419
+ if (typeof result.cost !== 'number' || !Number.isFinite(result.cost) || result.cost < 0) {
420
+ return false;
421
+ }
422
+ return true;
423
+ }
424
+ /** Record shape for {@link CostCalculator.calculateFromRecord} (shared engine contract). */
372
425
  export function buildGatewayPricingRecord(routerResponse, tokens, mergedConfig) {
373
- const base = routerResponse != null && typeof routerResponse === 'object'
374
- ? { ...routerResponse }
375
- : {};
376
- const meta = base.metadata != null && typeof base.metadata === 'object'
377
- ? { ...base.metadata }
378
- : {};
379
426
  const routing = pickInvokeRoutingMetadataSlice(routerResponse, mergedConfig);
427
+ const cfg = mergedConfig != null && typeof mergedConfig === 'object'
428
+ ? mergedConfig
429
+ : {};
430
+ const requestModel = typeof cfg.model === 'string'
431
+ ? cfg.model
432
+ : typeof routing.modelUsed === 'string'
433
+ ? routing.modelUsed
434
+ : undefined;
435
+ const modelUsed = routing.modelUsed ?? requestModel;
436
+ const provider = routing.provider ??
437
+ (typeof cfg.provider === 'string' ? cfg.provider : undefined) ??
438
+ 'openrouter';
439
+ const usageExtras = extractUsageExtrasFromRouterResponse(routerResponse);
440
+ const tokenSlice = {
441
+ prompt: tokens.prompt,
442
+ completion: tokens.completion,
443
+ total: tokens.total,
444
+ ...usageExtras
445
+ };
380
446
  return {
381
- ...base,
447
+ model: modelUsed ?? requestModel ?? '',
448
+ ...(requestModel && modelUsed && requestModel !== modelUsed
449
+ ? { modelAlias: requestModel }
450
+ : {}),
451
+ ...(modelUsed ? { modelUsed, usedModel: modelUsed } : {}),
452
+ provider,
453
+ ...(provider || routing.region
454
+ ? {
455
+ routing: {
456
+ provider,
457
+ ...(routing.region ? { region: routing.region } : {})
458
+ }
459
+ }
460
+ : {}),
382
461
  usage: {
383
- promptTokens: tokens.prompt,
384
- completionTokens: tokens.completion,
385
- totalTokens: tokens.total
462
+ prompt_tokens: tokens.prompt,
463
+ completion_tokens: tokens.completion,
464
+ total_tokens: tokens.total,
465
+ ...(usageExtras.cached !== undefined ? { cachedTokensPrompt: usageExtras.cached } : {}),
466
+ ...(usageExtras.cached !== undefined ? { cachedTokensTotal: usageExtras.cached } : {})
386
467
  },
387
- tokens,
468
+ promptTokens: tokens.prompt,
469
+ completionTokens: tokens.completion,
470
+ totalTokens: tokens.total,
471
+ tokens: tokenSlice,
388
472
  metadata: {
389
- ...meta,
390
- tokens,
391
- ...(routing.provider ? { provider: routing.provider } : {}),
392
- ...(routing.modelUsed
393
- ? { modelUsed: routing.modelUsed, model: routing.modelUsed }
394
- : {})
473
+ provider,
474
+ ...(modelUsed ? { modelUsed, model: modelUsed } : {}),
475
+ ...(routing.maxTokensRequested !== undefined
476
+ ? { maxTokensRequested: routing.maxTokensRequested }
477
+ : {}),
478
+ tokens: tokenSlice
395
479
  },
396
480
  ...(mergedConfig != null ? { config: mergedConfig } : {})
397
481
  };
398
482
  }
399
483
  export function mapAiCostResultToResolvedActivityCost(base, result) {
400
- if (result.unknownModel) {
401
- return base.costStatus ? base : { ...base, costStatus: 'unpriced' };
402
- }
403
- if (typeof result.cost !== 'number' || !Number.isFinite(result.cost)) {
404
- return base;
405
- }
406
- if (!result.isAuthoritative && result.source === 'estimate-fallback') {
484
+ if (!catalogPricingSucceeded(result)) {
407
485
  return base.costStatus ? base : { ...base, costStatus: 'unpriced' };
408
486
  }
409
487
  return {
@@ -413,54 +491,36 @@ export function mapAiCostResultToResolvedActivityCost(base, result) {
413
491
  };
414
492
  }
415
493
  /**
416
- * Router cost passthrough, then optional @x12i/ai-tools catalog pricing when still unpriced.
494
+ * Step C/D: token usage without billing signal `unpriced`; no usage → omit status.
495
+ */
496
+ function finalizeInvokeBillingCost(billing, tokens) {
497
+ if (!billing.costStatus && hasNonZeroTokenUsage(tokens)) {
498
+ return { ...billing, costStatus: 'unpriced' };
499
+ }
500
+ return billing;
501
+ }
502
+ /**
503
+ * Post-invoke billing (Steps A→D): router cost, then catalog via ai-tools when still unpriced.
504
+ * Single entry point for `invoke()` / `invokeChat()` and trace enrichment.
417
505
  */
418
506
  export async function resolveCostCompletionWithAiTools(routerResponse, tokens, options) {
419
507
  const routerStatus = pickRouterCostStatus(routerResponse);
420
- const base = resolveCostCompletionForActivity(routerResponse, tokens);
421
- if (base.costStatus === 'priced') {
422
- return base;
423
- }
424
- if (routerStatus === 'unpriced') {
425
- return base;
426
- }
427
- if (options?.calculateCost === false || !options?.calculator) {
428
- return base;
429
- }
430
- if (!hasNonZeroTokenUsage(tokens)) {
431
- return base;
432
- }
433
- try {
434
- const record = buildGatewayPricingRecord(routerResponse, tokens, options.mergedConfig);
435
- const result = await options.calculator.calculateFromRecord(record);
436
- return mapAiCostResultToResolvedActivityCost(base, result);
437
- }
438
- catch {
439
- const routing = pickInvokeRoutingMetadataSlice(routerResponse, options.mergedConfig);
440
- const cfg = options.mergedConfig != null && typeof options.mergedConfig === 'object'
441
- ? options.mergedConfig
442
- : {};
443
- const provider = routing.provider ?? cfg.provider;
444
- const modelUsed = routing.modelUsed ?? cfg.model;
445
- if (!provider || !modelUsed) {
446
- return base;
447
- }
508
+ let billing = resolveCostCompletionForActivity(routerResponse, tokens);
509
+ if (billing.costStatus !== 'priced' &&
510
+ routerStatus !== 'unpriced' &&
511
+ options?.calculateCost !== false &&
512
+ options?.calculator &&
513
+ hasNonZeroTokenUsage(tokens)) {
448
514
  try {
449
- const result = await options.calculator.calculate({
450
- tokens: {
451
- prompt: tokens.prompt,
452
- completion: tokens.completion,
453
- total: tokens.total
454
- },
455
- provider,
456
- usedModel: modelUsed
457
- });
458
- return mapAiCostResultToResolvedActivityCost(base, result);
515
+ const record = buildGatewayPricingRecord(routerResponse, tokens, options.mergedConfig);
516
+ const result = await options.calculator.calculateFromRecord(record);
517
+ billing = mapAiCostResultToResolvedActivityCost(billing, result);
459
518
  }
460
519
  catch {
461
- return base;
520
+ // Step B unavailable — Step C applies below.
462
521
  }
463
522
  }
523
+ return finalizeInvokeBillingCost(billing, tokens);
464
524
  }
465
525
  function applyBillingToTraceAttempt(attempt, billing) {
466
526
  if (billing.costStatus === 'priced' || billing.costStatus === 'unpriced') {
package/dist/gateway.js CHANGED
@@ -11,7 +11,7 @@ import { resolveRetryConfig } from './gateway-defaults.js';
11
11
  import { buildMessages } from './message-builder.js';
12
12
  import { extractJsonFromFlexMd } from './flex-md-loader.js';
13
13
  import { enrichParsedContentForOutputContract, resolveOutputContractFieldKeys } from './output-contract-normalizer.js';
14
- import { attachGatewayInvokeRejectionMetadata, buildGatewayFallbackAttemptsFromTrace, buildInvokeRejectionMetadata, capActivityFullResponsePayload, formatFallbackExhaustionMessage, logResolvedModelRouting, mapGatewayFallbackAttemptsToRouter, hasNonZeroTokenUsage, DEFAULT_ACTIVITY_FULL_RESPONSE_MAX_CHARS, extractCostUsdFromRouterResponse, extractTokenUsageFromRouterResponse, mergeConfig, pickEffectiveModelConfigForMetadata, pickInvokeRoutingMetadataSlice, pickTraceMergedRouterConfig, resolveCostCompletionWithAiTools, buildTraceUsageSummary, enrichTraceAttemptsWithBilling, tryExtractRouterLikePayloadFromErrorChain } from './gateway-utils.js';
14
+ import { attachGatewayInvokeRejectionMetadata, buildGatewayFallbackAttemptsFromTrace, buildInvokeRejectionMetadata, capActivityFullResponsePayload, formatFallbackExhaustionMessage, logResolvedModelRouting, mapGatewayFallbackAttemptsToRouter, DEFAULT_ACTIVITY_FULL_RESPONSE_MAX_CHARS, extractCostUsdFromRouterResponse, extractTokenUsageFromRouterResponse, mergeConfig, pickEffectiveModelConfigForMetadata, pickInvokeRoutingMetadataSlice, pickTraceMergedRouterConfig, resolveCostCompletionWithAiTools, buildTraceUsageSummary, enrichTraceAttemptsWithBilling, tryExtractRouterLikePayloadFromErrorChain } from './gateway-utils.js';
15
15
  import { getAiToolsClient } from './ai-tools-client.js';
16
16
  import { autoRegisterProviders } from './gateway-provider-auto-register.js';
17
17
  import { setGatewayLastJobId, setGatewayRuntimeClients } from './runtime-objects.js';
@@ -135,7 +135,7 @@ export class AIGateway {
135
135
  });
136
136
  const metaChat = response?.metadata || {};
137
137
  const tokensChat = extractTokenUsageFromRouterResponse(response);
138
- const costCompletionChat = await resolveCostCompletionWithAiTools(response, tokensChat, {
138
+ let costCompletionChat = await resolveCostCompletionWithAiTools(response, tokensChat, {
139
139
  mergedConfig,
140
140
  calculator: aiTools?.calculator ?? null,
141
141
  calculateCost: this.config.aiTools?.calculateCost
@@ -614,9 +614,6 @@ export class AIGateway {
614
614
  calculator: aiTools?.calculator ?? null,
615
615
  calculateCost: this.config.aiTools?.calculateCost
616
616
  });
617
- if (!costCompletion.costStatus && hasNonZeroTokenUsage(tokens)) {
618
- costCompletion = { ...costCompletion, costStatus: 'unpriced' };
619
- }
620
617
  const routerMetaForCost = routerResponse?.metadata || {};
621
618
  const routingMetadataSlice = pickInvokeRoutingMetadataSlice(routerResponse, mergedConfig);
622
619
  const effectiveModelConfig = pickEffectiveModelConfigForMetadata(mergedConfig);
package/dist/index.d.ts CHANGED
@@ -17,7 +17,7 @@ export { AIGateway } from './gateway.js';
17
17
  export { InstructionNotFoundError, InstructionBackendError, ModelRequiredError, MaxTokensRequiredError } from './instruction-errors.js';
18
18
  export { autoRegisterProviders } from './gateway-provider-auto-register.js';
19
19
  export type { GatewayConfig, ProviderModelRef, ModelConfig, RetryConfig, ChatRequest, AIInvokeRequest, AIRequest, GatewayActionType, GatewayInvokeRejectionMetadata, GatewayFallbackAttempt, GatewayTraceRequestIds, GatewayTraceAttempt, GatewayTraceUsageSummary, GatewayTraceMergedConfig, EnhancedLLMResponse, InstructionMetadata, ValidationRule, TemplateRenderOptions, SmartInputConfig, SmartInputRenderOptions } from './types.js';
20
- export { attachGatewayInvokeRejectionMetadata, buildInvokeRejectionMetadata, tryExtractRouterLikePayloadFromErrorChain, tryExtractFallbackAttemptsFromErrorChain, pickRequestIdsFromRouterLike, resolveActivityCostCompletion, resolveCostCompletionForActivity, resolveCostCompletionWithAiTools, buildGatewayPricingRecord, mapAiCostResultToResolvedActivityCost, buildTraceUsageSummary, enrichTraceAttemptsWithBilling, hasNonZeroTokenUsage, MODEL_PROFILE_UNROUTABLE, ModelProfileUnroutableError, ModelProfileInputRejectedError, buildGatewayFallbackAttemptsFromTrace, formatFallbackExhaustionMessage, logResolvedModelRouting, mapGatewayFallbackAttemptsToRouter } from './gateway-utils.js';
20
+ export { attachGatewayInvokeRejectionMetadata, buildInvokeRejectionMetadata, tryExtractRouterLikePayloadFromErrorChain, tryExtractFallbackAttemptsFromErrorChain, pickRequestIdsFromRouterLike, resolveActivityCostCompletion, resolveCostCompletionForActivity, resolveCostCompletionWithAiTools, buildGatewayPricingRecord, mapAiCostResultToResolvedActivityCost, catalogPricingSucceeded, extractUsageExtrasFromRouterResponse, buildTraceUsageSummary, enrichTraceAttemptsWithBilling, hasNonZeroTokenUsage, MODEL_PROFILE_UNROUTABLE, ModelProfileUnroutableError, ModelProfileInputRejectedError, buildGatewayFallbackAttemptsFromTrace, formatFallbackExhaustionMessage, logResolvedModelRouting, mapGatewayFallbackAttemptsToRouter } from './gateway-utils.js';
21
21
  export { getGatewayOperationalMode, isProdGatewayMode, parseModelProviderSpec } from './gateway-mode.js';
22
22
  export type { GatewayOperationalMode } from './gateway-mode.js';
23
23
  export { DEFAULT_ACTIVITY_FULL_RESPONSE_MAX_CHARS, GATEWAY_DEFAULT_FREQUENCY_PENALTY, GATEWAY_DEFAULT_PRESENCE_PENALTY, GATEWAY_DEFAULT_RETRY, GATEWAY_DEFAULT_TEMPERATURE, GATEWAY_DEFAULT_TOP_P, resolveRetryConfig } from './gateway-defaults.js';
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ export * from '@x12i/ai-providers-router';
17
17
  export { AIGateway } from './gateway.js';
18
18
  export { InstructionNotFoundError, InstructionBackendError, ModelRequiredError, MaxTokensRequiredError } from './instruction-errors.js';
19
19
  export { autoRegisterProviders } from './gateway-provider-auto-register.js';
20
- export { attachGatewayInvokeRejectionMetadata, buildInvokeRejectionMetadata, tryExtractRouterLikePayloadFromErrorChain, tryExtractFallbackAttemptsFromErrorChain, pickRequestIdsFromRouterLike, resolveActivityCostCompletion, resolveCostCompletionForActivity, resolveCostCompletionWithAiTools, buildGatewayPricingRecord, mapAiCostResultToResolvedActivityCost, buildTraceUsageSummary, enrichTraceAttemptsWithBilling, hasNonZeroTokenUsage, MODEL_PROFILE_UNROUTABLE, ModelProfileUnroutableError, ModelProfileInputRejectedError, buildGatewayFallbackAttemptsFromTrace, formatFallbackExhaustionMessage, logResolvedModelRouting, mapGatewayFallbackAttemptsToRouter } from './gateway-utils.js';
20
+ export { attachGatewayInvokeRejectionMetadata, buildInvokeRejectionMetadata, tryExtractRouterLikePayloadFromErrorChain, tryExtractFallbackAttemptsFromErrorChain, pickRequestIdsFromRouterLike, resolveActivityCostCompletion, resolveCostCompletionForActivity, resolveCostCompletionWithAiTools, buildGatewayPricingRecord, mapAiCostResultToResolvedActivityCost, catalogPricingSucceeded, extractUsageExtrasFromRouterResponse, buildTraceUsageSummary, enrichTraceAttemptsWithBilling, hasNonZeroTokenUsage, MODEL_PROFILE_UNROUTABLE, ModelProfileUnroutableError, ModelProfileInputRejectedError, buildGatewayFallbackAttemptsFromTrace, formatFallbackExhaustionMessage, logResolvedModelRouting, mapGatewayFallbackAttemptsToRouter } from './gateway-utils.js';
21
21
  export { getGatewayOperationalMode, isProdGatewayMode, parseModelProviderSpec } from './gateway-mode.js';
22
22
  export { DEFAULT_ACTIVITY_FULL_RESPONSE_MAX_CHARS, GATEWAY_DEFAULT_FREQUENCY_PENALTY, GATEWAY_DEFAULT_PRESENCE_PENALTY, GATEWAY_DEFAULT_RETRY, GATEWAY_DEFAULT_TEMPERATURE, GATEWAY_DEFAULT_TOP_P, resolveRetryConfig } from './gateway-defaults.js';
23
23
  export { contractSpecToFieldKeys, enrichParsedContentForOutputContract, resolveOutputContractFieldKeys } from './output-contract-normalizer.js';
package/dist/types.d.ts CHANGED
@@ -9,6 +9,7 @@ type AIModel = string;
9
9
  export type UsageTier = string;
10
10
  import type { Activix } from '@x12i/activix';
11
11
  import type { SmartInputConfig, SmartInputRenderOptions, TemplateRenderOptions } from '@x12i/rendrix';
12
+ import type { ProfileCatalogLane } from '@x12i/ai-profiles';
12
13
  import type { Logxer, PackageLogLevelsConfig } from '@x12i/logxer';
13
14
  /**
14
15
  * Diagnostics options for opt-in authoritative tracing.
@@ -415,6 +416,11 @@ export interface GatewayConfig extends Omit<RouterConfig, 'defaultEngine' | 'log
415
416
  cacheTtlMs?: number;
416
417
  /** Use bundled catalog JSON only (offline / tests). */
417
418
  bundledOnly?: boolean;
419
+ /**
420
+ * Catalog lane for model resolution and cost lookup (`text`, `image`, …).
421
+ * @default `"text"` in ai-tools when omitted.
422
+ */
423
+ catalogLane?: ProfileCatalogLane;
418
424
  /** @default true */
419
425
  resolveModels?: boolean;
420
426
  /**
@@ -424,6 +430,7 @@ export interface GatewayConfig extends Omit<RouterConfig, 'defaultEngine' | 'log
424
430
  modelsOnly?: boolean;
425
431
  /** @default true */
426
432
  calculateCost?: boolean;
433
+ /** @default false — when true, priced results may include prompt/completion breakdown. */
427
434
  costIncludeBreakdown?: boolean;
428
435
  };
429
436
  /**
@@ -2,7 +2,7 @@
2
2
  * @x12i/ai-tools invoke client bootstrap for the gateway.
3
3
  * Model resolution orchestration lives in ai-tools ≥ 2.5.0 (`resolveInvokeModel`).
4
4
  */
5
- import { getAiToolsInvokeClient, resetAiToolsInvokeClientForTests as resetAiToolsInvokeClientForTestsUpstream, mapResolutionToRouterConfig, buildInvokeModelResolverOptions, } from '@x12i/ai-tools';
5
+ import { getAiToolsInvokeClient, resetAiToolsInvokeClientForTests as resetAiToolsInvokeClientForTestsUpstream, mapResolutionToRouterConfig, buildInvokeModelResolverOptions, CostCalculator, } from '@x12i/ai-tools';
6
6
  import { gatewayLogDebug, withActivityIdentity } from './gateway-log-meta.js';
7
7
  import { resolvePreferOpenRouter } from './openrouter-routing.js';
8
8
  export { resolveInvokeModel, applyOpenRouterInvokePolicy, buildInvokeModelResolverOptions, enrichModelResolutionError, mapResolutionToRouterConfig, ModelProfileUnroutableError, ModelProfileInputRejectedError, MODEL_PROFILE_UNROUTABLE, getAiToolsInvokeClient, resetAiToolsInvokeClientForTests as resetAiToolsInvokeClientForTestsUpstream, createAiToolsInvokeClient, } from '@x12i/ai-tools';
@@ -13,7 +13,22 @@ function invokeClientOptions(config) {
13
13
  cacheTtlMs: config.aiTools?.cacheTtlMs,
14
14
  ...(config.aiTools?.bundledOnly ? { bundledOnly: true } : {}),
15
15
  ...(config.aiTools?.costIncludeBreakdown ? { costIncludeBreakdown: true } : {}),
16
- cacheKey: `${config.aiTools?.cacheTtlMs ?? ''}:${config.aiTools?.costIncludeBreakdown ?? ''}:${config.aiTools?.bundledOnly ?? ''}`,
16
+ cacheKey: `${config.aiTools?.cacheTtlMs ?? ''}:${config.aiTools?.costIncludeBreakdown ?? ''}:${config.aiTools?.bundledOnly ?? ''}:${config.aiTools?.catalogLane ?? ''}`,
17
+ };
18
+ }
19
+ function withCatalogLaneCalculator(client, config) {
20
+ const lane = config.aiTools?.catalogLane;
21
+ if (!lane)
22
+ return client;
23
+ return {
24
+ ...client,
25
+ calculator: new CostCalculator(client.catalog, {
26
+ ...(config.aiTools?.costIncludeBreakdown ? { includeBreakdown: true } : {}),
27
+ resolverOptions: buildInvokeModelResolverOptions({
28
+ routingEnv: client.routingEnv,
29
+ catalogLane: lane
30
+ })
31
+ })
17
32
  };
18
33
  }
19
34
  /** @deprecated Use buildInvokeModelResolverOptions */
@@ -53,6 +68,7 @@ export async function getAiToolsClient(config, logger) {
53
68
  logger.debug('ai-tools catalog client ready', {
54
69
  debugKind: gatewayLogDebug.state,
55
70
  });
71
+ return withCatalogLaneCalculator(client, config);
56
72
  }
57
73
  return client;
58
74
  }
@@ -176,17 +176,8 @@ export function initializeGatewayComponents(config) {
176
176
  enableActivityTracking: config.enableActivityTracking ?? true,
177
177
  customTracker: config.activityTracker,
178
178
  logger,
179
- ...(config.activityTracker
180
- ? {}
181
- : {
182
- autoCost: config.aiTools?.enabled === false || config.aiTools?.calculateCost === false
183
- ? false
184
- : {
185
- enabled: true,
186
- overwriteOuterCost: false,
187
- ...(config.aiTools?.bundledOnly ? { bundledOnly: true } : {})
188
- }
189
- })
179
+ // Billing is resolved in gateway before logSuccess; Activix gets outer.cost from that slice only.
180
+ ...(config.activityTracker ? {} : { autoCost: false })
190
181
  });
191
182
  const templateRendering = mergeTemplateRenderOptions(defaultTemplateRendering, config.templateRendering);
192
183
  const messageBuilderConfig = {
@@ -108,6 +108,8 @@ export async function mergeConfig(request, config, logger, mergeOptions) {
108
108
  defaultProvider: config.defaultEngine,
109
109
  resolveModels: true,
110
110
  modelsOnly: config.aiTools?.modelsOnly !== false,
111
+ ...(config.aiTools?.catalogLane ? { catalogLane: config.aiTools.catalogLane } : {}),
112
+ ...(config.aiTools?.bundledOnly ? { bundledOnly: true } : {}),
111
113
  });
112
114
  merged.provider = resolved.router.provider;
113
115
  merged.model = resolved.router.model;
@@ -338,8 +340,8 @@ function pickRouterCostStatus(routerResponse) {
338
340
  return status === 'priced' || status === 'unpriced' ? status : undefined;
339
341
  }
340
342
  /**
341
- * Gateway fallback when the router does not set `metadata.costStatus`.
342
- * Prefer {@link resolveCostCompletionForActivity} at invoke boundaries.
343
+ * Step A/C/D cost slice when the router omits explicit `metadata.costStatus`.
344
+ * Prefer {@link resolveCostCompletionWithAiTools} at invoke boundaries.
343
345
  */
344
346
  export function resolveActivityCostCompletion(tokens, costUsd) {
345
347
  if (typeof costUsd === 'number' && Number.isFinite(costUsd)) {
@@ -351,8 +353,7 @@ export function resolveActivityCostCompletion(tokens, costUsd) {
351
353
  return {};
352
354
  }
353
355
  /**
354
- * Activity cost slice for Activix: router `metadata.costStatus` / cost wins when present;
355
- * otherwise gateway applies the G8 fallback (usage + no price → `unpriced`).
356
+ * Step A router passthrough + Step C when the router omits `metadata.costStatus`.
356
357
  */
357
358
  export function resolveCostCompletionForActivity(routerResponse, tokens) {
358
359
  const routerStatus = pickRouterCostStatus(routerResponse);
@@ -368,42 +369,119 @@ export function resolveCostCompletionForActivity(routerResponse, tokens) {
368
369
  }
369
370
  return resolveActivityCostCompletion(tokens, costUsd);
370
371
  }
371
- /** Record shape for {@link CostCalculator.calculateFromRecord} (router + merged config + usage). */
372
+ /**
373
+ * Best-effort cache/reasoning token counts from router usage buckets
374
+ * (for {@link buildGatewayPricingRecord} / ai-tools {@link CostCalculator.calculateFromRecord}).
375
+ */
376
+ export function extractUsageExtrasFromRouterResponse(routerResponse) {
377
+ if (routerResponse == null || typeof routerResponse !== 'object')
378
+ return {};
379
+ const r = routerResponse;
380
+ const roots = [r.usage];
381
+ const meta = r.metadata != null && typeof r.metadata === 'object'
382
+ ? r.metadata
383
+ : undefined;
384
+ if (meta) {
385
+ roots.push(meta.usage, meta.tokens);
386
+ }
387
+ const raw = r.rawResponse ?? r.raw;
388
+ if (raw != null && typeof raw === 'object') {
389
+ roots.push(raw.usage);
390
+ }
391
+ const extras = {};
392
+ for (const bucket of roots) {
393
+ if (bucket == null || typeof bucket !== 'object')
394
+ continue;
395
+ const u = bucket;
396
+ const cached = firstFiniteNumber(u.cached, u.cached_tokens, u.cachedTokens, u.cache_read_tokens, u.cacheReadTokens);
397
+ const cacheWrite = firstFiniteNumber(u.cacheWrite, u.cache_write_tokens, u.cacheWriteTokens);
398
+ const reasoning = firstFiniteNumber(u.reasoning, u.reasoning_tokens, u.reasoningTokens);
399
+ if (cached !== undefined && extras.cached === undefined)
400
+ extras.cached = cached;
401
+ if (cacheWrite !== undefined && extras.cacheWrite === undefined)
402
+ extras.cacheWrite = cacheWrite;
403
+ if (reasoning !== undefined && extras.reasoning === undefined)
404
+ extras.reasoning = reasoning;
405
+ }
406
+ return extras;
407
+ }
408
+ /**
409
+ * Whether ai-tools catalog pricing is authoritative enough for Step B (`priced`).
410
+ * Matches the generic engine contract: authoritative catalog hit with finite cost ≥ 0.
411
+ */
412
+ export function catalogPricingSucceeded(result) {
413
+ if (result.unknownModel)
414
+ return false;
415
+ if (!result.isAuthoritative)
416
+ return false;
417
+ if (result.source === 'estimate-fallback' || result.source === 'local')
418
+ return false;
419
+ if (typeof result.cost !== 'number' || !Number.isFinite(result.cost) || result.cost < 0) {
420
+ return false;
421
+ }
422
+ return true;
423
+ }
424
+ /** Record shape for {@link CostCalculator.calculateFromRecord} (shared engine contract). */
372
425
  export function buildGatewayPricingRecord(routerResponse, tokens, mergedConfig) {
373
- const base = routerResponse != null && typeof routerResponse === 'object'
374
- ? { ...routerResponse }
375
- : {};
376
- const meta = base.metadata != null && typeof base.metadata === 'object'
377
- ? { ...base.metadata }
378
- : {};
379
426
  const routing = pickInvokeRoutingMetadataSlice(routerResponse, mergedConfig);
427
+ const cfg = mergedConfig != null && typeof mergedConfig === 'object'
428
+ ? mergedConfig
429
+ : {};
430
+ const requestModel = typeof cfg.model === 'string'
431
+ ? cfg.model
432
+ : typeof routing.modelUsed === 'string'
433
+ ? routing.modelUsed
434
+ : undefined;
435
+ const modelUsed = routing.modelUsed ?? requestModel;
436
+ const provider = routing.provider ??
437
+ (typeof cfg.provider === 'string' ? cfg.provider : undefined) ??
438
+ 'openrouter';
439
+ const usageExtras = extractUsageExtrasFromRouterResponse(routerResponse);
440
+ const tokenSlice = {
441
+ prompt: tokens.prompt,
442
+ completion: tokens.completion,
443
+ total: tokens.total,
444
+ ...usageExtras
445
+ };
380
446
  return {
381
- ...base,
447
+ model: modelUsed ?? requestModel ?? '',
448
+ ...(requestModel && modelUsed && requestModel !== modelUsed
449
+ ? { modelAlias: requestModel }
450
+ : {}),
451
+ ...(modelUsed ? { modelUsed, usedModel: modelUsed } : {}),
452
+ provider,
453
+ ...(provider || routing.region
454
+ ? {
455
+ routing: {
456
+ provider,
457
+ ...(routing.region ? { region: routing.region } : {})
458
+ }
459
+ }
460
+ : {}),
382
461
  usage: {
383
- promptTokens: tokens.prompt,
384
- completionTokens: tokens.completion,
385
- totalTokens: tokens.total
462
+ prompt_tokens: tokens.prompt,
463
+ completion_tokens: tokens.completion,
464
+ total_tokens: tokens.total,
465
+ ...(usageExtras.cached !== undefined ? { cachedTokensPrompt: usageExtras.cached } : {}),
466
+ ...(usageExtras.cached !== undefined ? { cachedTokensTotal: usageExtras.cached } : {})
386
467
  },
387
- tokens,
468
+ promptTokens: tokens.prompt,
469
+ completionTokens: tokens.completion,
470
+ totalTokens: tokens.total,
471
+ tokens: tokenSlice,
388
472
  metadata: {
389
- ...meta,
390
- tokens,
391
- ...(routing.provider ? { provider: routing.provider } : {}),
392
- ...(routing.modelUsed
393
- ? { modelUsed: routing.modelUsed, model: routing.modelUsed }
394
- : {})
473
+ provider,
474
+ ...(modelUsed ? { modelUsed, model: modelUsed } : {}),
475
+ ...(routing.maxTokensRequested !== undefined
476
+ ? { maxTokensRequested: routing.maxTokensRequested }
477
+ : {}),
478
+ tokens: tokenSlice
395
479
  },
396
480
  ...(mergedConfig != null ? { config: mergedConfig } : {})
397
481
  };
398
482
  }
399
483
  export function mapAiCostResultToResolvedActivityCost(base, result) {
400
- if (result.unknownModel) {
401
- return base.costStatus ? base : { ...base, costStatus: 'unpriced' };
402
- }
403
- if (typeof result.cost !== 'number' || !Number.isFinite(result.cost)) {
404
- return base;
405
- }
406
- if (!result.isAuthoritative && result.source === 'estimate-fallback') {
484
+ if (!catalogPricingSucceeded(result)) {
407
485
  return base.costStatus ? base : { ...base, costStatus: 'unpriced' };
408
486
  }
409
487
  return {
@@ -413,54 +491,36 @@ export function mapAiCostResultToResolvedActivityCost(base, result) {
413
491
  };
414
492
  }
415
493
  /**
416
- * Router cost passthrough, then optional @x12i/ai-tools catalog pricing when still unpriced.
494
+ * Step C/D: token usage without billing signal `unpriced`; no usage → omit status.
495
+ */
496
+ function finalizeInvokeBillingCost(billing, tokens) {
497
+ if (!billing.costStatus && hasNonZeroTokenUsage(tokens)) {
498
+ return { ...billing, costStatus: 'unpriced' };
499
+ }
500
+ return billing;
501
+ }
502
+ /**
503
+ * Post-invoke billing (Steps A→D): router cost, then catalog via ai-tools when still unpriced.
504
+ * Single entry point for `invoke()` / `invokeChat()` and trace enrichment.
417
505
  */
418
506
  export async function resolveCostCompletionWithAiTools(routerResponse, tokens, options) {
419
507
  const routerStatus = pickRouterCostStatus(routerResponse);
420
- const base = resolveCostCompletionForActivity(routerResponse, tokens);
421
- if (base.costStatus === 'priced') {
422
- return base;
423
- }
424
- if (routerStatus === 'unpriced') {
425
- return base;
426
- }
427
- if (options?.calculateCost === false || !options?.calculator) {
428
- return base;
429
- }
430
- if (!hasNonZeroTokenUsage(tokens)) {
431
- return base;
432
- }
433
- try {
434
- const record = buildGatewayPricingRecord(routerResponse, tokens, options.mergedConfig);
435
- const result = await options.calculator.calculateFromRecord(record);
436
- return mapAiCostResultToResolvedActivityCost(base, result);
437
- }
438
- catch {
439
- const routing = pickInvokeRoutingMetadataSlice(routerResponse, options.mergedConfig);
440
- const cfg = options.mergedConfig != null && typeof options.mergedConfig === 'object'
441
- ? options.mergedConfig
442
- : {};
443
- const provider = routing.provider ?? cfg.provider;
444
- const modelUsed = routing.modelUsed ?? cfg.model;
445
- if (!provider || !modelUsed) {
446
- return base;
447
- }
508
+ let billing = resolveCostCompletionForActivity(routerResponse, tokens);
509
+ if (billing.costStatus !== 'priced' &&
510
+ routerStatus !== 'unpriced' &&
511
+ options?.calculateCost !== false &&
512
+ options?.calculator &&
513
+ hasNonZeroTokenUsage(tokens)) {
448
514
  try {
449
- const result = await options.calculator.calculate({
450
- tokens: {
451
- prompt: tokens.prompt,
452
- completion: tokens.completion,
453
- total: tokens.total
454
- },
455
- provider,
456
- usedModel: modelUsed
457
- });
458
- return mapAiCostResultToResolvedActivityCost(base, result);
515
+ const record = buildGatewayPricingRecord(routerResponse, tokens, options.mergedConfig);
516
+ const result = await options.calculator.calculateFromRecord(record);
517
+ billing = mapAiCostResultToResolvedActivityCost(billing, result);
459
518
  }
460
519
  catch {
461
- return base;
520
+ // Step B unavailable — Step C applies below.
462
521
  }
463
522
  }
523
+ return finalizeInvokeBillingCost(billing, tokens);
464
524
  }
465
525
  function applyBillingToTraceAttempt(attempt, billing) {
466
526
  if (billing.costStatus === 'priced' || billing.costStatus === 'unpriced') {
@@ -72,8 +72,8 @@ export declare function hasNonZeroTokenUsage(tokens: {
72
72
  total: number;
73
73
  }): boolean;
74
74
  /**
75
- * Gateway fallback when the router does not set `metadata.costStatus`.
76
- * Prefer {@link resolveCostCompletionForActivity} at invoke boundaries.
75
+ * Step A/C/D cost slice when the router omits explicit `metadata.costStatus`.
76
+ * Prefer {@link resolveCostCompletionWithAiTools} at invoke boundaries.
77
77
  */
78
78
  export declare function resolveActivityCostCompletion(tokens: {
79
79
  prompt: number;
@@ -81,8 +81,7 @@ export declare function resolveActivityCostCompletion(tokens: {
81
81
  total: number;
82
82
  }, costUsd: number | undefined): ResolvedActivityCost;
83
83
  /**
84
- * Activity cost slice for Activix: router `metadata.costStatus` / cost wins when present;
85
- * otherwise gateway applies the G8 fallback (usage + no price → `unpriced`).
84
+ * Step A router passthrough + Step C when the router omits `metadata.costStatus`.
86
85
  */
87
86
  export declare function resolveCostCompletionForActivity(routerResponse: unknown, tokens: {
88
87
  prompt: number;
@@ -94,7 +93,23 @@ export type ResolveCostCompletionOptions = {
94
93
  calculator?: CostCalculator | null;
95
94
  calculateCost?: boolean;
96
95
  };
97
- /** Record shape for {@link CostCalculator.calculateFromRecord} (router + merged config + usage). */
96
+ /** Optional cache/reasoning token fields for catalog pricing records. */
97
+ export type InvokeUsageExtras = {
98
+ cached?: number;
99
+ cacheWrite?: number;
100
+ reasoning?: number;
101
+ };
102
+ /**
103
+ * Best-effort cache/reasoning token counts from router usage buckets
104
+ * (for {@link buildGatewayPricingRecord} / ai-tools {@link CostCalculator.calculateFromRecord}).
105
+ */
106
+ export declare function extractUsageExtrasFromRouterResponse(routerResponse: unknown): InvokeUsageExtras;
107
+ /**
108
+ * Whether ai-tools catalog pricing is authoritative enough for Step B (`priced`).
109
+ * Matches the generic engine contract: authoritative catalog hit with finite cost ≥ 0.
110
+ */
111
+ export declare function catalogPricingSucceeded(result: AiCostResult): boolean;
112
+ /** Record shape for {@link CostCalculator.calculateFromRecord} (shared engine contract). */
98
113
  export declare function buildGatewayPricingRecord(routerResponse: unknown, tokens: {
99
114
  prompt: number;
100
115
  completion: number;
@@ -102,7 +117,8 @@ export declare function buildGatewayPricingRecord(routerResponse: unknown, token
102
117
  }, mergedConfig?: unknown): Record<string, unknown>;
103
118
  export declare function mapAiCostResultToResolvedActivityCost(base: ResolvedActivityCost, result: AiCostResult): ResolvedActivityCost;
104
119
  /**
105
- * Router cost passthrough, then optional @x12i/ai-tools catalog pricing when still unpriced.
120
+ * Post-invoke billing (Steps A→D): router cost, then catalog via ai-tools when still unpriced.
121
+ * Single entry point for `invoke()` / `invokeChat()` and trace enrichment.
106
122
  */
107
123
  export declare function resolveCostCompletionWithAiTools(routerResponse: unknown, tokens: {
108
124
  prompt: number;
@@ -11,7 +11,7 @@ import { resolveRetryConfig } from './gateway-defaults.js';
11
11
  import { buildMessages } from './message-builder.js';
12
12
  import { extractJsonFromFlexMd } from './flex-md-loader.js';
13
13
  import { enrichParsedContentForOutputContract, resolveOutputContractFieldKeys } from './output-contract-normalizer.js';
14
- import { attachGatewayInvokeRejectionMetadata, buildGatewayFallbackAttemptsFromTrace, buildInvokeRejectionMetadata, capActivityFullResponsePayload, formatFallbackExhaustionMessage, logResolvedModelRouting, mapGatewayFallbackAttemptsToRouter, hasNonZeroTokenUsage, DEFAULT_ACTIVITY_FULL_RESPONSE_MAX_CHARS, extractCostUsdFromRouterResponse, extractTokenUsageFromRouterResponse, mergeConfig, pickEffectiveModelConfigForMetadata, pickInvokeRoutingMetadataSlice, pickTraceMergedRouterConfig, resolveCostCompletionWithAiTools, buildTraceUsageSummary, enrichTraceAttemptsWithBilling, tryExtractRouterLikePayloadFromErrorChain } from './gateway-utils.js';
14
+ import { attachGatewayInvokeRejectionMetadata, buildGatewayFallbackAttemptsFromTrace, buildInvokeRejectionMetadata, capActivityFullResponsePayload, formatFallbackExhaustionMessage, logResolvedModelRouting, mapGatewayFallbackAttemptsToRouter, DEFAULT_ACTIVITY_FULL_RESPONSE_MAX_CHARS, extractCostUsdFromRouterResponse, extractTokenUsageFromRouterResponse, mergeConfig, pickEffectiveModelConfigForMetadata, pickInvokeRoutingMetadataSlice, pickTraceMergedRouterConfig, resolveCostCompletionWithAiTools, buildTraceUsageSummary, enrichTraceAttemptsWithBilling, tryExtractRouterLikePayloadFromErrorChain } from './gateway-utils.js';
15
15
  import { getAiToolsClient } from './ai-tools-client.js';
16
16
  import { autoRegisterProviders } from './gateway-provider-auto-register.js';
17
17
  import { setGatewayLastJobId, setGatewayRuntimeClients } from './runtime-objects.js';
@@ -135,7 +135,7 @@ export class AIGateway {
135
135
  });
136
136
  const metaChat = response?.metadata || {};
137
137
  const tokensChat = extractTokenUsageFromRouterResponse(response);
138
- const costCompletionChat = await resolveCostCompletionWithAiTools(response, tokensChat, {
138
+ let costCompletionChat = await resolveCostCompletionWithAiTools(response, tokensChat, {
139
139
  mergedConfig,
140
140
  calculator: aiTools?.calculator ?? null,
141
141
  calculateCost: this.config.aiTools?.calculateCost
@@ -614,9 +614,6 @@ export class AIGateway {
614
614
  calculator: aiTools?.calculator ?? null,
615
615
  calculateCost: this.config.aiTools?.calculateCost
616
616
  });
617
- if (!costCompletion.costStatus && hasNonZeroTokenUsage(tokens)) {
618
- costCompletion = { ...costCompletion, costStatus: 'unpriced' };
619
- }
620
617
  const routerMetaForCost = routerResponse?.metadata || {};
621
618
  const routingMetadataSlice = pickInvokeRoutingMetadataSlice(routerResponse, mergedConfig);
622
619
  const effectiveModelConfig = pickEffectiveModelConfigForMetadata(mergedConfig);
@@ -17,7 +17,7 @@ export * from '@x12i/ai-providers-router';
17
17
  export { AIGateway } from './gateway.js';
18
18
  export { InstructionNotFoundError, InstructionBackendError, ModelRequiredError, MaxTokensRequiredError } from './instruction-errors.js';
19
19
  export { autoRegisterProviders } from './gateway-provider-auto-register.js';
20
- export { attachGatewayInvokeRejectionMetadata, buildInvokeRejectionMetadata, tryExtractRouterLikePayloadFromErrorChain, tryExtractFallbackAttemptsFromErrorChain, pickRequestIdsFromRouterLike, resolveActivityCostCompletion, resolveCostCompletionForActivity, resolveCostCompletionWithAiTools, buildGatewayPricingRecord, mapAiCostResultToResolvedActivityCost, buildTraceUsageSummary, enrichTraceAttemptsWithBilling, hasNonZeroTokenUsage, MODEL_PROFILE_UNROUTABLE, ModelProfileUnroutableError, ModelProfileInputRejectedError, buildGatewayFallbackAttemptsFromTrace, formatFallbackExhaustionMessage, logResolvedModelRouting, mapGatewayFallbackAttemptsToRouter } from './gateway-utils.js';
20
+ export { attachGatewayInvokeRejectionMetadata, buildInvokeRejectionMetadata, tryExtractRouterLikePayloadFromErrorChain, tryExtractFallbackAttemptsFromErrorChain, pickRequestIdsFromRouterLike, resolveActivityCostCompletion, resolveCostCompletionForActivity, resolveCostCompletionWithAiTools, buildGatewayPricingRecord, mapAiCostResultToResolvedActivityCost, catalogPricingSucceeded, extractUsageExtrasFromRouterResponse, buildTraceUsageSummary, enrichTraceAttemptsWithBilling, hasNonZeroTokenUsage, MODEL_PROFILE_UNROUTABLE, ModelProfileUnroutableError, ModelProfileInputRejectedError, buildGatewayFallbackAttemptsFromTrace, formatFallbackExhaustionMessage, logResolvedModelRouting, mapGatewayFallbackAttemptsToRouter } from './gateway-utils.js';
21
21
  export { getGatewayOperationalMode, isProdGatewayMode, parseModelProviderSpec } from './gateway-mode.js';
22
22
  export { DEFAULT_ACTIVITY_FULL_RESPONSE_MAX_CHARS, GATEWAY_DEFAULT_FREQUENCY_PENALTY, GATEWAY_DEFAULT_PRESENCE_PENALTY, GATEWAY_DEFAULT_RETRY, GATEWAY_DEFAULT_TEMPERATURE, GATEWAY_DEFAULT_TOP_P, resolveRetryConfig } from './gateway-defaults.js';
23
23
  export { contractSpecToFieldKeys, enrichParsedContentForOutputContract, resolveOutputContractFieldKeys } from './output-contract-normalizer.js';
@@ -17,7 +17,7 @@ export { AIGateway } from './gateway.js';
17
17
  export { InstructionNotFoundError, InstructionBackendError, ModelRequiredError, MaxTokensRequiredError } from './instruction-errors.js';
18
18
  export { autoRegisterProviders } from './gateway-provider-auto-register.js';
19
19
  export type { GatewayConfig, ProviderModelRef, ModelConfig, RetryConfig, ChatRequest, AIInvokeRequest, AIRequest, GatewayActionType, GatewayInvokeRejectionMetadata, GatewayFallbackAttempt, GatewayTraceRequestIds, GatewayTraceAttempt, GatewayTraceUsageSummary, GatewayTraceMergedConfig, EnhancedLLMResponse, InstructionMetadata, ValidationRule, TemplateRenderOptions, SmartInputConfig, SmartInputRenderOptions } from './types.js';
20
- export { attachGatewayInvokeRejectionMetadata, buildInvokeRejectionMetadata, tryExtractRouterLikePayloadFromErrorChain, tryExtractFallbackAttemptsFromErrorChain, pickRequestIdsFromRouterLike, resolveActivityCostCompletion, resolveCostCompletionForActivity, resolveCostCompletionWithAiTools, buildGatewayPricingRecord, mapAiCostResultToResolvedActivityCost, buildTraceUsageSummary, enrichTraceAttemptsWithBilling, hasNonZeroTokenUsage, MODEL_PROFILE_UNROUTABLE, ModelProfileUnroutableError, ModelProfileInputRejectedError, buildGatewayFallbackAttemptsFromTrace, formatFallbackExhaustionMessage, logResolvedModelRouting, mapGatewayFallbackAttemptsToRouter } from './gateway-utils.js';
20
+ export { attachGatewayInvokeRejectionMetadata, buildInvokeRejectionMetadata, tryExtractRouterLikePayloadFromErrorChain, tryExtractFallbackAttemptsFromErrorChain, pickRequestIdsFromRouterLike, resolveActivityCostCompletion, resolveCostCompletionForActivity, resolveCostCompletionWithAiTools, buildGatewayPricingRecord, mapAiCostResultToResolvedActivityCost, catalogPricingSucceeded, extractUsageExtrasFromRouterResponse, buildTraceUsageSummary, enrichTraceAttemptsWithBilling, hasNonZeroTokenUsage, MODEL_PROFILE_UNROUTABLE, ModelProfileUnroutableError, ModelProfileInputRejectedError, buildGatewayFallbackAttemptsFromTrace, formatFallbackExhaustionMessage, logResolvedModelRouting, mapGatewayFallbackAttemptsToRouter } from './gateway-utils.js';
21
21
  export { getGatewayOperationalMode, isProdGatewayMode, parseModelProviderSpec } from './gateway-mode.js';
22
22
  export type { GatewayOperationalMode } from './gateway-mode.js';
23
23
  export { DEFAULT_ACTIVITY_FULL_RESPONSE_MAX_CHARS, GATEWAY_DEFAULT_FREQUENCY_PENALTY, GATEWAY_DEFAULT_PRESENCE_PENALTY, GATEWAY_DEFAULT_RETRY, GATEWAY_DEFAULT_TEMPERATURE, GATEWAY_DEFAULT_TOP_P, resolveRetryConfig } from './gateway-defaults.js';
@@ -9,6 +9,7 @@ type AIModel = string;
9
9
  export type UsageTier = string;
10
10
  import type { Activix } from '@x12i/activix';
11
11
  import type { SmartInputConfig, SmartInputRenderOptions, TemplateRenderOptions } from '@x12i/rendrix';
12
+ import type { ProfileCatalogLane } from '@x12i/ai-profiles';
12
13
  import type { Logxer, PackageLogLevelsConfig } from '@x12i/logxer';
13
14
  /**
14
15
  * Diagnostics options for opt-in authoritative tracing.
@@ -415,6 +416,11 @@ export interface GatewayConfig extends Omit<RouterConfig, 'defaultEngine' | 'log
415
416
  cacheTtlMs?: number;
416
417
  /** Use bundled catalog JSON only (offline / tests). */
417
418
  bundledOnly?: boolean;
419
+ /**
420
+ * Catalog lane for model resolution and cost lookup (`text`, `image`, …).
421
+ * @default `"text"` in ai-tools when omitted.
422
+ */
423
+ catalogLane?: ProfileCatalogLane;
418
424
  /** @default true */
419
425
  resolveModels?: boolean;
420
426
  /**
@@ -424,6 +430,7 @@ export interface GatewayConfig extends Omit<RouterConfig, 'defaultEngine' | 'log
424
430
  modelsOnly?: boolean;
425
431
  /** @default true */
426
432
  calculateCost?: boolean;
433
+ /** @default false — when true, priced results may include prompt/completion breakdown. */
427
434
  costIncludeBreakdown?: boolean;
428
435
  };
429
436
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x12i/ai-gateway",
3
- "version": "10.0.4",
3
+ "version": "10.0.6",
4
4
  "description": "AI Gateway - Unified interface for LLM provider routing and management",
5
5
  "type": "module",
6
6
  "exports": {