@x12i/ai-gateway 9.2.0 → 9.3.0

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.
Files changed (69) hide show
  1. package/README.md +45 -0
  2. package/dist/ai-tools-client.d.ts +20 -0
  3. package/dist/ai-tools-client.js +91 -0
  4. package/dist/gateway-config.d.ts +2 -0
  5. package/dist/gateway-config.js +2 -1
  6. package/dist/gateway-mode.d.ts +40 -0
  7. package/dist/gateway-mode.js +75 -0
  8. package/dist/gateway-utils.d.ts +28 -1
  9. package/dist/gateway-utils.js +137 -12
  10. package/dist/gateway.d.ts +3 -0
  11. package/dist/gateway.js +29 -5
  12. package/dist/index.d.ts +3 -1
  13. package/dist/index.js +2 -1
  14. package/dist/types.d.ts +21 -0
  15. package/dist-cjs/activity-manager.cjs +14 -19
  16. package/dist-cjs/ai-tools-client.cjs +91 -0
  17. package/dist-cjs/ai-tools-client.d.ts +20 -0
  18. package/dist-cjs/config/activity-tracking-config.cjs +1 -4
  19. package/dist-cjs/content-normalizer/content-normalizer.cjs +3 -8
  20. package/dist-cjs/content-normalizer/index.cjs +1 -7
  21. package/dist-cjs/content-normalizer/types.cjs +1 -2
  22. package/dist-cjs/flex-md-loader.cjs +20 -67
  23. package/dist-cjs/gateway-config.cjs +25 -63
  24. package/dist-cjs/gateway-config.d.ts +2 -0
  25. package/dist-cjs/gateway-conversion.cjs +10 -48
  26. package/dist-cjs/gateway-instructions.cjs +5 -10
  27. package/dist-cjs/gateway-log-meta.cjs +9 -14
  28. package/dist-cjs/gateway-memory.cjs +2 -6
  29. package/dist-cjs/gateway-messages.cjs +3 -6
  30. package/dist-cjs/gateway-meta.cjs +1 -4
  31. package/dist-cjs/gateway-mode.cjs +75 -0
  32. package/dist-cjs/gateway-mode.d.ts +40 -0
  33. package/dist-cjs/gateway-provider-auto-register.cjs +2 -38
  34. package/dist-cjs/gateway-provider.cjs +10 -22
  35. package/dist-cjs/gateway-rate-limiter-constants.cjs +2 -5
  36. package/dist-cjs/gateway-rate-limiter.cjs +5 -9
  37. package/dist-cjs/gateway-retry.cjs +6 -14
  38. package/dist-cjs/gateway-utils.cjs +160 -89
  39. package/dist-cjs/gateway-utils.d.ts +28 -1
  40. package/dist-cjs/gateway-validation.cjs +2 -6
  41. package/dist-cjs/gateway.cjs +86 -66
  42. package/dist-cjs/gateway.d.ts +3 -0
  43. package/dist-cjs/index.cjs +22 -98
  44. package/dist-cjs/index.d.ts +3 -1
  45. package/dist-cjs/instruction-errors.cjs +2 -7
  46. package/dist-cjs/instruction-optimizer.cjs +4 -10
  47. package/dist-cjs/instructions-parser.cjs +5 -10
  48. package/dist-cjs/logger-factory.cjs +3 -6
  49. package/dist-cjs/memory-path-resolution.cjs +8 -18
  50. package/dist-cjs/message-builder.cjs +11 -47
  51. package/dist-cjs/object-types-library-integration.cjs +3 -8
  52. package/dist-cjs/object-types-library.cjs +5 -10
  53. package/dist-cjs/output-auditor.cjs +1 -4
  54. package/dist-cjs/output-contract-normalizer.cjs +9 -14
  55. package/dist-cjs/request-report-generator.cjs +1 -4
  56. package/dist-cjs/response-analyzer/format-type-detector.cjs +1 -5
  57. package/dist-cjs/response-analyzer/index.cjs +3 -9
  58. package/dist-cjs/response-analyzer/object-type-detector.cjs +1 -5
  59. package/dist-cjs/response-analyzer/response-analyzer.cjs +6 -10
  60. package/dist-cjs/response-analyzer/types.cjs +1 -2
  61. package/dist-cjs/response-fallback-fixer.cjs +1 -4
  62. package/dist-cjs/runtime-objects.cjs +7 -13
  63. package/dist-cjs/template-parser.cjs +5 -42
  64. package/dist-cjs/template-render-merge.cjs +2 -6
  65. package/dist-cjs/troubleshooting-helper.cjs +13 -28
  66. package/dist-cjs/types.cjs +1 -2
  67. package/dist-cjs/types.d.ts +21 -0
  68. package/dist-cjs/usage-tracker.cjs +3 -7
  69. package/package.json +11 -5
package/README.md CHANGED
@@ -368,6 +368,51 @@ The gateway only exposes official queryable clients. It exposes `activixClient`
368
368
 
369
369
  See [Runtime Objects Observability Methodology](./docs/RUNTIME_OBJECTS_OBSERVABILITY.md) for the reusable package-level contract.
370
370
 
371
+ ### Model catalog resolution and defaults (`@x12i/ai-tools`)
372
+
373
+ Before each invoke, the gateway can normalize caller `config.model` / `modelConfig` via the **ai-models** Catalox catalog (`@x12i/ai-tools`). After invoke, when the router leaves cost **unpriced**, the gateway may compute USD from the same catalog.
374
+
375
+ **Environment variables:**
376
+
377
+ | Variable | Purpose |
378
+ |----------|---------|
379
+ | `AI_GATEWAY_DEFAULT_MODEL` | Default model when none is provided, or when resolution fails in **`mode=prod`**. Supports `provider/model` (e.g. `openrouter/openai/gpt-5-nano`) or a bare model id. |
380
+ | `mode` / `MODE` | `prod` — unresolved models fall back to the default chain (with **Logxer `warn`**). `dev` / `debug` / omitted — unresolved models throw **`ModelResolutionError`**. |
381
+
382
+ **Default model priority** (prod fallback only): `AI_GATEWAY_DEFAULT_MODEL` → `src/defaults/model-config.json` `defaultModel` → code constant `gpt-5-nano`.
383
+
384
+ **Logxer warnings** on default substitution include structured fields: `reason` (`no_model_provided`, `model_resolution_failed`, `ai_tools_unavailable`), `defaultSource` (`env`, `model-config.json`, `code`), `originalModel`, `defaultModel`, and `mode`.
385
+
386
+ Catalox/Firebase credentials are required for catalog bootstrap (same as `@x12i/ai-tools` — see that package’s README). Disable with `aiTools: { enabled: false }` on `GatewayConfig`, or inject `aiTools.catalox` for tests.
387
+
388
+ **GatewayConfig (optional overrides):**
389
+
390
+ ```typescript
391
+ const gateway = new AIGateway({
392
+ mode: 'prod', // or 'dev' | 'debug' — overrides process.env.mode
393
+ aiTools: {
394
+ enabled: true,
395
+ resolveModels: true,
396
+ calculateCost: true,
397
+ costIncludeBreakdown: false,
398
+ cacheTtlMs: 60_000,
399
+ // catalox: injectedCataloxInstance,
400
+ },
401
+ });
402
+ ```
403
+
404
+ **Tests before release:**
405
+
406
+ ```bash
407
+ npm run build
408
+ npm test # integration (tsx)
409
+ npm run test:ai-tools # unit: mode, defaults, cost helper
410
+ npm run test:live # LIVE: catalog + invoke (needs .env + Firebase + LLM key)
411
+ npm run test:real:comprehensive # optional: compiled real router matrix + npm test
412
+ ```
413
+
414
+ See [`.env.example`](./.env.example) for `AI_GATEWAY_DEFAULT_MODEL`, `mode`, provider keys, and Firebase/Catalox variables.
415
+
371
416
  **Recommended (auto-configured from environment variables):**
372
417
 
373
418
  ```typescript
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Lazy @x12i/ai-tools catalog + cost calculator bootstrap.
3
+ */
4
+ import { AiModelsCatalogClient, CostCalculator, type ModelResolutionSuccess } from '@x12i/ai-tools';
5
+ import type { Logxer } from '@x12i/logxer';
6
+ import type { ChatRequest, GatewayConfig } from './types.js';
7
+ export type AiToolsClientBundle = {
8
+ catalog: AiModelsCatalogClient;
9
+ calculator: CostCalculator;
10
+ };
11
+ /**
12
+ * Returns catalog + calculator, or null when disabled or bootstrap fails.
13
+ */
14
+ export declare function getAiToolsClient(config: GatewayConfig, logger: Logxer): Promise<AiToolsClientBundle | null>;
15
+ /** Reset singleton (tests). */
16
+ export declare function resetAiToolsClientForTests(): void;
17
+ /**
18
+ * Map catalog resolution to router config provider/model fields.
19
+ */
20
+ export declare function applyModelResolution(merged: NonNullable<ChatRequest['config']>, resolution: ModelResolutionSuccess, gatewayDefaultEngine?: string): void;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Lazy @x12i/ai-tools catalog + cost calculator bootstrap.
3
+ */
4
+ import { AiModelsCatalogClient, CostCalculator, ensureAiModelsCatalog } from '@x12i/ai-tools';
5
+ import { gatewayLogDebug, withActivityIdentity } from './gateway-log-meta.js';
6
+ let sharedClientPromise = null;
7
+ let sharedConfigKey;
8
+ let bootstrapFailedLogged = false;
9
+ function configKey(config) {
10
+ const injected = config.aiTools?.catalox ? 'injected' : 'env';
11
+ return `${injected}:${config.aiTools?.cacheTtlMs ?? ''}:${config.aiTools?.costIncludeBreakdown ?? ''}`;
12
+ }
13
+ /**
14
+ * Returns catalog + calculator, or null when disabled or bootstrap fails.
15
+ */
16
+ export async function getAiToolsClient(config, logger) {
17
+ if (config.aiTools?.enabled === false) {
18
+ return null;
19
+ }
20
+ const key = configKey(config);
21
+ if (sharedClientPromise && sharedConfigKey !== key) {
22
+ sharedClientPromise = null;
23
+ }
24
+ sharedConfigKey = key;
25
+ if (!sharedClientPromise) {
26
+ sharedClientPromise = bootstrapAiTools(config, logger);
27
+ }
28
+ return sharedClientPromise;
29
+ }
30
+ /** Reset singleton (tests). */
31
+ export function resetAiToolsClientForTests() {
32
+ sharedClientPromise = null;
33
+ sharedConfigKey = undefined;
34
+ bootstrapFailedLogged = false;
35
+ }
36
+ async function bootstrapAiTools(config, logger) {
37
+ try {
38
+ let catalox = config.aiTools?.catalox;
39
+ if (!catalox) {
40
+ const { createCataloxFromEnv } = await import('@x12i/catalox/firebase');
41
+ const bootstrapped = createCataloxFromEnv();
42
+ catalox = bootstrapped.catalox;
43
+ }
44
+ await ensureAiModelsCatalog(catalox);
45
+ const catalog = new AiModelsCatalogClient({
46
+ catalox,
47
+ cacheTtlMs: config.aiTools?.cacheTtlMs
48
+ });
49
+ const calculator = new CostCalculator(catalog, {
50
+ includeBreakdown: config.aiTools?.costIncludeBreakdown === true
51
+ });
52
+ logger.debug('ai-tools catalog client ready', {
53
+ debugKind: gatewayLogDebug.state
54
+ });
55
+ return { catalog, calculator };
56
+ }
57
+ catch (error) {
58
+ if (!bootstrapFailedLogged) {
59
+ bootstrapFailedLogged = true;
60
+ logger.warn('ai-tools catalog bootstrap failed; model resolution and catalog cost calculation disabled', withActivityIdentity(undefined, {
61
+ error: error instanceof Error ? error.message : String(error),
62
+ debugKind: gatewayLogDebug.anomaly
63
+ }));
64
+ }
65
+ return null;
66
+ }
67
+ }
68
+ /**
69
+ * Map catalog resolution to router config provider/model fields.
70
+ */
71
+ export function applyModelResolution(merged, resolution, gatewayDefaultEngine) {
72
+ if (resolution.routedViaOpenRouter) {
73
+ merged.provider = 'openrouter';
74
+ merged.model = resolution.modelId;
75
+ return;
76
+ }
77
+ const slash = resolution.modelId.indexOf('/');
78
+ if (slash > 0) {
79
+ merged.provider = resolution.record?.providerId ?? resolution.modelId.slice(0, slash);
80
+ merged.model = resolution.modelId.slice(slash + 1);
81
+ }
82
+ else {
83
+ merged.model = resolution.modelId;
84
+ if (resolution.record?.providerId) {
85
+ merged.provider = resolution.record.providerId;
86
+ }
87
+ }
88
+ if (!merged.provider && gatewayDefaultEngine) {
89
+ merged.provider = gatewayDefaultEngine;
90
+ }
91
+ }
@@ -19,6 +19,7 @@ export interface GatewayConfigContext {
19
19
  usageTracker: UsageTracker;
20
20
  messageBuilderConfig: MessageBuilderConfig;
21
21
  }
22
+ export type InitializedGatewayComponents = ReturnType<typeof initializeGatewayComponents>;
22
23
  /**
23
24
  * Loads configuration from JSON files (model config and instructionsBlocks).
24
25
  * Pass a {@link Logxer} instance so load diagnostics go through logxer (not console).
@@ -46,4 +47,5 @@ export declare function initializeGatewayComponents(config: GatewayConfig): {
46
47
  activityManager: ActivityManager;
47
48
  usageTracker: UsageTracker;
48
49
  messageBuilderConfig: MessageBuilderConfig;
50
+ defaultModelConfig: Record<string, unknown>;
49
51
  };
@@ -283,6 +283,7 @@ export function initializeGatewayComponents(config) {
283
283
  router,
284
284
  activityManager,
285
285
  usageTracker,
286
- messageBuilderConfig
286
+ messageBuilderConfig,
287
+ defaultModelConfig
287
288
  };
288
289
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Gateway operational mode (prod vs dev/debug) and default model resolution.
3
+ */
4
+ import type { Logxer } from '@x12i/logxer';
5
+ import type { ActivityIdentity, GatewayConfig } from './types.js';
6
+ export type GatewayOperationalMode = 'prod' | 'debug' | 'dev';
7
+ export type GatewayDefaultModelSource = 'env' | 'model-config.json' | 'code';
8
+ export type DefaultModelSubstitutionReason = 'no_model_provided' | 'model_resolution_failed' | 'ai_tools_unavailable';
9
+ export declare const CODE_DEFAULT_MODEL = "gpt-5-nano";
10
+ export type ResolvedGatewayDefault = {
11
+ model: string;
12
+ provider?: string;
13
+ source: GatewayDefaultModelSource;
14
+ };
15
+ /**
16
+ * Operational mode: `GatewayConfig.mode` overrides `process.env.mode` / `MODE`.
17
+ * Only `prod` allows silent default-model substitution; all other values are strict.
18
+ */
19
+ export declare function getGatewayOperationalMode(config?: Pick<GatewayConfig, 'mode'>): GatewayOperationalMode;
20
+ export declare function isProdGatewayMode(mode: GatewayOperationalMode): boolean;
21
+ /**
22
+ * Parse `provider/model` or bare model id (OpenRouter ids may contain multiple slashes).
23
+ */
24
+ export declare function parseModelProviderSpec(spec: string): {
25
+ provider?: string;
26
+ model: string;
27
+ };
28
+ /**
29
+ * Default model priority: AI_GATEWAY_DEFAULT_MODEL → model-config.json → code constant.
30
+ */
31
+ export declare function resolveGatewayDefaultModel(defaultModelConfig?: Record<string, unknown>, gatewayDefaultEngine?: string): ResolvedGatewayDefault;
32
+ export declare function warnDefaultModelSubstitution(logger: Logxer, identity: Partial<ActivityIdentity> | undefined, details: {
33
+ reason: DefaultModelSubstitutionReason;
34
+ mode: GatewayOperationalMode;
35
+ defaultSource: GatewayDefaultModelSource;
36
+ defaultProvider?: string;
37
+ defaultModel: string;
38
+ originalProvider?: string;
39
+ originalModel?: string;
40
+ }): void;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Gateway operational mode (prod vs dev/debug) and default model resolution.
3
+ */
4
+ import { gatewayLogDebug, withActivityIdentity } from './gateway-log-meta.js';
5
+ export const CODE_DEFAULT_MODEL = 'gpt-5-nano';
6
+ /**
7
+ * Operational mode: `GatewayConfig.mode` overrides `process.env.mode` / `MODE`.
8
+ * Only `prod` allows silent default-model substitution; all other values are strict.
9
+ */
10
+ export function getGatewayOperationalMode(config) {
11
+ if (config?.mode) {
12
+ return config.mode;
13
+ }
14
+ const raw = (process.env.mode ?? process.env.MODE ?? '').toLowerCase();
15
+ if (raw === 'prod')
16
+ return 'prod';
17
+ if (raw === 'dev')
18
+ return 'dev';
19
+ return 'debug';
20
+ }
21
+ export function isProdGatewayMode(mode) {
22
+ return mode === 'prod';
23
+ }
24
+ /**
25
+ * Parse `provider/model` or bare model id (OpenRouter ids may contain multiple slashes).
26
+ */
27
+ export function parseModelProviderSpec(spec) {
28
+ const trimmed = spec.trim();
29
+ if (!trimmed) {
30
+ return { model: CODE_DEFAULT_MODEL };
31
+ }
32
+ const slash = trimmed.indexOf('/');
33
+ if (slash === -1) {
34
+ return { model: trimmed };
35
+ }
36
+ const first = trimmed.slice(0, slash);
37
+ const rest = trimmed.slice(slash + 1);
38
+ if (rest.includes('/') && (first === 'openrouter' || first === 'open-router')) {
39
+ return { provider: 'openrouter', model: trimmed };
40
+ }
41
+ return { provider: first, model: rest };
42
+ }
43
+ /**
44
+ * Default model priority: AI_GATEWAY_DEFAULT_MODEL → model-config.json → code constant.
45
+ */
46
+ export function resolveGatewayDefaultModel(defaultModelConfig, gatewayDefaultEngine) {
47
+ const envSpec = process.env.AI_GATEWAY_DEFAULT_MODEL?.trim();
48
+ if (envSpec) {
49
+ const parsed = parseModelProviderSpec(envSpec);
50
+ return { model: parsed.model, provider: parsed.provider, source: 'env' };
51
+ }
52
+ const jsonModel = typeof defaultModelConfig?.defaultModel === 'string' ? defaultModelConfig.defaultModel : undefined;
53
+ if (jsonModel) {
54
+ const parsed = parseModelProviderSpec(jsonModel);
55
+ const jsonEngine = typeof defaultModelConfig?.defaultEngine === 'string'
56
+ ? defaultModelConfig.defaultEngine
57
+ : gatewayDefaultEngine;
58
+ return {
59
+ model: parsed.model,
60
+ provider: parsed.provider ?? jsonEngine,
61
+ source: 'model-config.json'
62
+ };
63
+ }
64
+ return {
65
+ model: CODE_DEFAULT_MODEL,
66
+ provider: gatewayDefaultEngine,
67
+ source: 'code'
68
+ };
69
+ }
70
+ export function warnDefaultModelSubstitution(logger, identity, details) {
71
+ logger.warn('Gateway substituted default model for request', withActivityIdentity(identity, {
72
+ ...details,
73
+ debugKind: gatewayLogDebug.anomaly
74
+ }));
75
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import type { AIInvokeRequest, ChatRequest, GatewayConfig, GatewayInvokeRejectionMetadata, GatewayTraceMergedConfig, GatewayTraceRequestIds, ModelConfig } from './types.js';
6
6
  import type { Logxer } from '@x12i/logxer';
7
+ import { type AiModelsCatalogClient, type CostCalculator } from '@x12i/ai-tools';
7
8
  /**
8
9
  * Generates MD5 hash of a string
9
10
  */
@@ -12,13 +13,17 @@ export declare function generateMD5Hash(text: string): string;
12
13
  * Auto-generates taskTypeId from MD5 hash of pre-parsed instructions if not provided
13
14
  */
14
15
  export declare function ensureTaskTypeId(request: ChatRequest, logger: Logxer): Promise<string>;
16
+ export type MergeConfigOptions = {
17
+ defaultModelConfig?: Record<string, unknown>;
18
+ catalog?: AiModelsCatalogClient | null;
19
+ };
15
20
  /**
16
21
  * Merges config with defaults
17
22
  * Supports using internal system action defaults (internalSkill or skillAudit) when useInternalDefaults is set
18
23
  */
19
24
  export declare function mergeConfig(request: ChatRequest & {
20
25
  useInternalDefaults?: 'skill' | 'audit';
21
- }, config: GatewayConfig, logger: Logxer): Promise<ChatRequest['config']>;
26
+ }, config: GatewayConfig, logger: Logxer, mergeOptions?: MergeConfigOptions): Promise<ChatRequest['config']>;
22
27
  /**
23
28
  * Maps provider/router usage objects to gateway token counts (`metadata.tokens`, Activix, trace attempts).
24
29
  * Handles promptTokens/inputTokens, OpenAI-style snake_case, Responses-style input/output tokens, and missing total (sum prompt+completion).
@@ -48,6 +53,15 @@ export type ActivityCostStatus = 'priced' | 'unpriced';
48
53
  export type ResolvedActivityCost = {
49
54
  cost?: number;
50
55
  costStatus?: ActivityCostStatus;
56
+ costBreakdown?: {
57
+ promptCostUsd: number;
58
+ completionCostUsd: number;
59
+ cachingCostUsd?: number;
60
+ reasoningCostUsd?: number;
61
+ audioCostUsd?: number;
62
+ imageCostUsd?: number;
63
+ requestFlatCostUsd?: number;
64
+ };
51
65
  };
52
66
  export declare function hasNonZeroTokenUsage(tokens: {
53
67
  prompt: number;
@@ -72,6 +86,19 @@ export declare function resolveCostCompletionForActivity(routerResponse: unknown
72
86
  completion: number;
73
87
  total: number;
74
88
  }): ResolvedActivityCost;
89
+ export type ResolveCostCompletionOptions = {
90
+ mergedConfig?: unknown;
91
+ calculator?: CostCalculator | null;
92
+ calculateCost?: boolean;
93
+ };
94
+ /**
95
+ * Router cost passthrough, then optional @x12i/ai-tools catalog pricing when still unpriced.
96
+ */
97
+ export declare function resolveCostCompletionWithAiTools(routerResponse: unknown, tokens: {
98
+ prompt: number;
99
+ completion: number;
100
+ total: number;
101
+ }, options?: ResolveCostCompletionOptions): Promise<ResolvedActivityCost>;
75
102
  /**
76
103
  * Stable routing facts for gateway response metadata (router metadata + merged config fallbacks).
77
104
  * Matches trace-mode resolution; intended for every successful invoke(), not only diagnostics.trace.
@@ -3,8 +3,11 @@
3
3
  * Handles utility functions
4
4
  */
5
5
  import * as crypto from 'crypto';
6
+ import { ModelResolutionError } from '@x12i/ai-tools';
6
7
  import { getPreParsedInstructions } from './gateway-instructions.js';
7
8
  import { getModelMaxTokensFromFlexMd } from './flex-md-loader.js';
9
+ import { applyModelResolution } from './ai-tools-client.js';
10
+ import { getGatewayOperationalMode, isProdGatewayMode, resolveGatewayDefaultModel, warnDefaultModelSubstitution } from './gateway-mode.js';
8
11
  /**
9
12
  * Generates MD5 hash of a string
10
13
  */
@@ -29,11 +32,34 @@ export async function ensureTaskTypeId(request, logger) {
29
32
  });
30
33
  return taskTypeId;
31
34
  }
35
+ function applyGatewayDefaultToMerged(merged, defaults, config) {
36
+ merged.model = defaults.model;
37
+ if (defaults.provider) {
38
+ merged.provider = defaults.provider;
39
+ }
40
+ else if (!merged.provider) {
41
+ merged.provider = config.defaultEngine;
42
+ }
43
+ }
44
+ async function substituteGatewayDefaultModel(merged, request, config, logger, mergeOptions, reason, original) {
45
+ const operationalMode = getGatewayOperationalMode(config);
46
+ const defaults = resolveGatewayDefaultModel(mergeOptions?.defaultModelConfig, config.defaultEngine);
47
+ warnDefaultModelSubstitution(logger, request.identity, {
48
+ reason,
49
+ mode: operationalMode,
50
+ defaultSource: defaults.source,
51
+ defaultProvider: defaults.provider ?? merged.provider,
52
+ defaultModel: defaults.model,
53
+ originalProvider: original?.provider ?? merged.provider,
54
+ originalModel: original?.model
55
+ });
56
+ applyGatewayDefaultToMerged(merged, defaults, config);
57
+ }
32
58
  /**
33
59
  * Merges config with defaults
34
60
  * Supports using internal system action defaults (internalSkill or skillAudit) when useInternalDefaults is set
35
61
  */
36
- export async function mergeConfig(request, config, logger) {
62
+ export async function mergeConfig(request, config, logger, mergeOptions) {
37
63
  const useInternalDefaults = request.useInternalDefaults;
38
64
  const internalDefaults = useInternalDefaults
39
65
  ? (useInternalDefaults === 'skill'
@@ -52,8 +78,8 @@ export async function mergeConfig(request, config, logger) {
52
78
  useInternalDefaults,
53
79
  hasInternalDefaults: !!internalDefaults
54
80
  });
55
- // Default model to "gpt-5-nano" if nothing is provided (most permissive - always works)
56
- const defaultModel = 'gpt-5-nano';
81
+ const operationalMode = getGatewayOperationalMode(config);
82
+ const resolveModels = config.aiTools?.resolveModels !== false;
57
83
  // Priority: modelConfig > request.config > internalSystemActions[useInternalDefaults] > gateway defaults
58
84
  // First, merge modelConfig into a config-like object if present
59
85
  const modelConfigAsConfig = request.modelConfig ? {
@@ -87,18 +113,67 @@ export async function mergeConfig(request, config, logger) {
87
113
  ...request.config,
88
114
  // ModelConfig overrides (highest priority) - merge only defined values
89
115
  ...(modelConfigAsConfig ? Object.fromEntries(Object.entries(modelConfigAsConfig).filter(([_, value]) => value !== undefined)) : {}),
90
- // Ensure model is set: modelConfig > request.config > internalDefaults > default
91
- model: modelConfigAsConfig?.model || request.config?.model || internalDefaults?.model || defaultModel,
116
+ // Model resolved below (catalog, default chain, or explicit pass-through)
117
+ model: modelConfigAsConfig?.model || request.config?.model || internalDefaults?.model,
92
118
  // Ensure provider is set: modelConfig > request.config > internalDefaults > gateway default
93
- // Provider is required for router to know which provider to use
94
119
  provider: modelConfigAsConfig?.provider || request.config?.provider || internalDefaults?.engine || config.defaultEngine
95
120
  };
96
- // Log if using default model
97
- if (!request.config?.model && !internalDefaults?.model) {
98
- logger.info('Using default model: gpt-5-nano (no model provided in request)', {
99
- jobId: request.identity.jobId,
100
- note: 'Default model ensures requests always work regardless of configuration'
101
- });
121
+ const explicitModel = merged.model;
122
+ const originalProvider = merged.provider;
123
+ const originalModel = explicitModel;
124
+ if (!explicitModel) {
125
+ await substituteGatewayDefaultModel(merged, request, config, logger, mergeOptions, 'no_model_provided');
126
+ }
127
+ else if (resolveModels && mergeOptions?.catalog) {
128
+ try {
129
+ const resolution = await mergeOptions.catalog.resolveModel({
130
+ provider: merged.provider,
131
+ model: explicitModel
132
+ });
133
+ if (resolution.found) {
134
+ applyModelResolution(merged, resolution, config.defaultEngine);
135
+ request._modelResolution = {
136
+ modelId: resolution.modelId,
137
+ routedViaOpenRouter: resolution.routedViaOpenRouter,
138
+ confidence: resolution.confidence,
139
+ resolvedVia: resolution.resolvedVia,
140
+ originalProvider,
141
+ originalModel
142
+ };
143
+ logger.verbose('Catalog resolved model name', {
144
+ jobId: request.identity.jobId,
145
+ originalModel,
146
+ resolvedModelId: resolution.modelId,
147
+ provider: merged.provider,
148
+ model: merged.model,
149
+ confidence: resolution.confidence,
150
+ resolvedVia: resolution.resolvedVia
151
+ });
152
+ }
153
+ else if (isProdGatewayMode(operationalMode)) {
154
+ await substituteGatewayDefaultModel(merged, request, config, logger, mergeOptions, 'model_resolution_failed', { provider: originalProvider, model: originalModel });
155
+ }
156
+ else {
157
+ throw new ModelResolutionError({ provider: merged.provider, model: explicitModel }, resolution);
158
+ }
159
+ }
160
+ catch (error) {
161
+ if (error instanceof ModelResolutionError) {
162
+ throw error;
163
+ }
164
+ if (isProdGatewayMode(operationalMode)) {
165
+ await substituteGatewayDefaultModel(merged, request, config, logger, mergeOptions, 'ai_tools_unavailable', { provider: originalProvider, model: originalModel });
166
+ }
167
+ else {
168
+ throw error;
169
+ }
170
+ }
171
+ }
172
+ else if (resolveModels && !mergeOptions?.catalog && isProdGatewayMode(operationalMode)) {
173
+ await substituteGatewayDefaultModel(merged, request, config, logger, mergeOptions, 'ai_tools_unavailable', { provider: originalProvider, model: originalModel });
174
+ }
175
+ if (!merged.model) {
176
+ await substituteGatewayDefaultModel(merged, request, config, logger, mergeOptions, 'no_model_provided');
102
177
  }
103
178
  // Auto-get maxTokens from flex-md if not explicitly set in ANY config source
104
179
  // Check all possible sources: request.config, internalDefaults, gateway config
@@ -359,6 +434,56 @@ export function resolveCostCompletionForActivity(routerResponse, tokens) {
359
434
  }
360
435
  return resolveActivityCostCompletion(tokens, costUsd);
361
436
  }
437
+ /**
438
+ * Router cost passthrough, then optional @x12i/ai-tools catalog pricing when still unpriced.
439
+ */
440
+ export async function resolveCostCompletionWithAiTools(routerResponse, tokens, options) {
441
+ const routerStatus = pickRouterCostStatus(routerResponse);
442
+ const base = resolveCostCompletionForActivity(routerResponse, tokens);
443
+ if (base.costStatus === 'priced') {
444
+ return base;
445
+ }
446
+ if (routerStatus === 'unpriced') {
447
+ return base;
448
+ }
449
+ if (options?.calculateCost === false || !options?.calculator) {
450
+ return base;
451
+ }
452
+ if (!hasNonZeroTokenUsage(tokens)) {
453
+ return base;
454
+ }
455
+ const routing = pickInvokeRoutingMetadataSlice(routerResponse, options.mergedConfig);
456
+ const cfg = options.mergedConfig != null && typeof options.mergedConfig === 'object'
457
+ ? options.mergedConfig
458
+ : {};
459
+ const provider = routing.provider ?? cfg.provider;
460
+ const modelUsed = routing.modelUsed ?? cfg.model;
461
+ if (!provider || !modelUsed) {
462
+ return base;
463
+ }
464
+ try {
465
+ const result = await options.calculator.calculate({
466
+ tokens: {
467
+ prompt: tokens.prompt,
468
+ completion: tokens.completion,
469
+ total: tokens.total
470
+ },
471
+ provider,
472
+ modelUsed
473
+ });
474
+ if (typeof result.cost === 'number' && Number.isFinite(result.cost)) {
475
+ return {
476
+ cost: result.cost,
477
+ costStatus: 'priced',
478
+ ...(result.breakdown ? { costBreakdown: result.breakdown } : {})
479
+ };
480
+ }
481
+ }
482
+ catch {
483
+ // Keep router/gateway unpriced fallback
484
+ }
485
+ return base;
486
+ }
362
487
  /**
363
488
  * Stable routing facts for gateway response metadata (router metadata + merged config fallbacks).
364
489
  * Matches trace-mode resolution; intended for every successful invoke(), not only diagnostics.trace.
package/dist/gateway.d.ts CHANGED
@@ -16,7 +16,9 @@ export declare class AIGateway {
16
16
  private logger;
17
17
  private activityManager?;
18
18
  private messageBuilderConfig?;
19
+ private defaultModelConfig;
19
20
  private _autoRegisterDone;
21
+ private _aiToolsClient;
20
22
  constructor(config?: GatewayConfig, activityManager?: ActivityManager);
21
23
  /**
22
24
  * Invoke chat request (without structured output requirements)
@@ -36,4 +38,5 @@ export declare class AIGateway {
36
38
  getLogger(): Logxer;
37
39
  getActivityManager(): ActivityManager | undefined;
38
40
  setActivityManager(activityManager: ActivityManager): void;
41
+ private getAiTools;
39
42
  }
package/dist/gateway.js CHANGED
@@ -9,7 +9,8 @@ import { initializeGatewayComponents } from './gateway-config.js';
9
9
  import { buildMessages } from './message-builder.js';
10
10
  import { extractJsonFromFlexMd } from './flex-md-loader.js';
11
11
  import { enrichParsedContentForOutputContract, resolveOutputContractFieldKeys } from './output-contract-normalizer.js';
12
- import { attachGatewayInvokeRejectionMetadata, buildInvokeRejectionMetadata, capActivityFullResponsePayload, DEFAULT_ACTIVITY_FULL_RESPONSE_MAX_CHARS, extractCostUsdFromRouterResponse, extractTokenUsageFromRouterResponse, mergeConfig, pickEffectiveModelConfigForMetadata, pickInvokeRoutingMetadataSlice, pickTraceMergedRouterConfig, resolveCostCompletionForActivity, tryExtractRouterLikePayloadFromErrorChain } from './gateway-utils.js';
12
+ import { attachGatewayInvokeRejectionMetadata, buildInvokeRejectionMetadata, capActivityFullResponsePayload, DEFAULT_ACTIVITY_FULL_RESPONSE_MAX_CHARS, extractCostUsdFromRouterResponse, extractTokenUsageFromRouterResponse, mergeConfig, pickEffectiveModelConfigForMetadata, pickInvokeRoutingMetadataSlice, pickTraceMergedRouterConfig, resolveCostCompletionWithAiTools, tryExtractRouterLikePayloadFromErrorChain } from './gateway-utils.js';
13
+ import { getAiToolsClient } from './ai-tools-client.js';
13
14
  import { autoRegisterProviders } from './gateway-provider-auto-register.js';
14
15
  import { setGatewayLastJobId, setGatewayRuntimeClients } from './runtime-objects.js';
15
16
  import { gatewayLogDebug, withActivityIdentity } from './gateway-log-meta.js';
@@ -45,7 +46,9 @@ export class AIGateway {
45
46
  logger;
46
47
  activityManager;
47
48
  messageBuilderConfig;
49
+ defaultModelConfig = {};
48
50
  _autoRegisterDone = false;
51
+ _aiToolsClient = null;
49
52
  constructor(config = {}, activityManager) {
50
53
  this.config = config;
51
54
  this.activityManager = activityManager;
@@ -54,6 +57,7 @@ export class AIGateway {
54
57
  this.router = components.router;
55
58
  this.activityManager = components.activityManager;
56
59
  this.messageBuilderConfig = components.messageBuilderConfig;
60
+ this.defaultModelConfig = components.defaultModelConfig ?? {};
57
61
  setGatewayRuntimeClients({
58
62
  activix: this.activityManager?.getTracker(),
59
63
  logger: this.logger
@@ -77,7 +81,11 @@ export class AIGateway {
77
81
  // Simple message construction
78
82
  const messages = this.buildSimpleMessages(request);
79
83
  // Merge config (modelConfig > request.config > gateway defaults)
80
- const mergedConfig = await mergeConfig(request, this.config, this.logger);
84
+ const aiTools = await this.getAiTools();
85
+ const mergedConfig = await mergeConfig(request, this.config, this.logger, {
86
+ defaultModelConfig: this.defaultModelConfig,
87
+ catalog: aiTools?.catalog ?? null
88
+ });
81
89
  // Activix start snapshot must match what the router receives (modelConfig-only callers omit request.config.model).
82
90
  request._mergedRouterConfig = mergedConfig;
83
91
  // Lazy auto-register providers from env (OPENAI_API_KEY, etc.) so consumers don't have to call init
@@ -111,7 +119,11 @@ export class AIGateway {
111
119
  });
112
120
  const metaChat = response?.metadata || {};
113
121
  const tokensChat = extractTokenUsageFromRouterResponse(response);
114
- const costCompletionChat = resolveCostCompletionForActivity(response, tokensChat);
122
+ const costCompletionChat = await resolveCostCompletionWithAiTools(response, tokensChat, {
123
+ mergedConfig,
124
+ calculator: aiTools?.calculator ?? null,
125
+ calculateCost: this.config.aiTools?.calculateCost
126
+ });
115
127
  // Create enhanced response
116
128
  const enhancedResponse = {
117
129
  content: response.content || '',
@@ -250,7 +262,11 @@ export class AIGateway {
250
262
  // Attach parsedSnapshot to request for activity tracking
251
263
  request._parsedRequest = parsedSnapshot;
252
264
  // Merge config (modelConfig > request.config > gateway defaults)
253
- const mergedConfig = await mergeConfig(request, this.config, this.logger);
265
+ const aiTools = await this.getAiTools();
266
+ const mergedConfig = await mergeConfig(request, this.config, this.logger, {
267
+ defaultModelConfig: this.defaultModelConfig,
268
+ catalog: aiTools?.catalog ?? null
269
+ });
254
270
  request._mergedRouterConfig = mergedConfig;
255
271
  const diagnosticsMode = request.diagnostics?.mode;
256
272
  const traceEnabled = diagnosticsMode === 'trace';
@@ -539,7 +555,11 @@ export class AIGateway {
539
555
  tokens = second;
540
556
  }
541
557
  }
542
- const costCompletion = resolveCostCompletionForActivity(routerResponse, tokens);
558
+ const costCompletion = await resolveCostCompletionWithAiTools(routerResponse, tokens, {
559
+ mergedConfig,
560
+ calculator: aiTools?.calculator ?? null,
561
+ calculateCost: this.config.aiTools?.calculateCost
562
+ });
543
563
  const routerMetaForCost = routerResponse?.metadata || {};
544
564
  const routingMetadataSlice = pickInvokeRoutingMetadataSlice(routerResponse, mergedConfig);
545
565
  const effectiveModelConfig = pickEffectiveModelConfigForMetadata(mergedConfig);
@@ -707,6 +727,10 @@ export class AIGateway {
707
727
  logger: this.logger
708
728
  });
709
729
  }
730
+ getAiTools() {
731
+ this._aiToolsClient ??= getAiToolsClient(this.config, this.logger);
732
+ return this._aiToolsClient;
733
+ }
710
734
  }
711
735
  function resolveRuntimeJobId(request) {
712
736
  return request.identity.jobId || request.identity.sessionId || request.aiRequestId;