@x12i/ai-gateway 9.6.0 → 9.6.1

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.
@@ -155,6 +155,35 @@ export declare function pickEffectiveModelConfigFromInvokeRequest(request: Pick<
155
155
  */
156
156
  export declare function tryExtractRouterLikePayloadFromErrorChain(error: unknown, maxDepth?: number): unknown;
157
157
  export declare function pickRequestIdsFromRouterLike(gatewayAiRequestId: string | undefined, routerLike: unknown): GatewayTraceRequestIds | undefined;
158
+ /** Error code hint when a bundled profile name cannot be routed to a catalog target. */
159
+ export declare const MODEL_PROFILE_UNROUTABLE = "MODEL_PROFILE_UNROUTABLE";
160
+ export declare class ModelProfileUnroutableError extends Error {
161
+ readonly profileAlias: string;
162
+ readonly provider: string | undefined;
163
+ readonly code = "MODEL_PROFILE_UNROUTABLE";
164
+ constructor(profileAlias: string, provider: string | undefined, cause?: unknown);
165
+ }
166
+ type ModelResolutionCandidate = {
167
+ provider: string;
168
+ model: string;
169
+ };
170
+ /**
171
+ * Build rejection-metadata fallback attempts from trace-mode {@link GatewayTraceAttempt}s.
172
+ */
173
+ export declare function buildGatewayFallbackAttemptsFromTrace(traceAttempts: GatewayTraceAttempt[], candidates: ModelResolutionCandidate[], lastError?: Error): GatewayFallbackAttempt[];
174
+ /** Human-readable exhaustion message for trace fallback chains and rejection logs. */
175
+ export declare function formatFallbackExhaustionMessage(attempts: GatewayFallbackAttempt[], candidates: ModelResolutionCandidate[]): string;
176
+ export declare function mapGatewayFallbackAttemptsToRouter(attempts: GatewayFallbackAttempt[]): Array<{
177
+ provider: string;
178
+ model?: string;
179
+ httpStatus?: number;
180
+ error: Error;
181
+ responsePreview?: string;
182
+ }>;
183
+ /**
184
+ * Log profile alias vs OpenRouter model id actually sent to the router after catalog resolution.
185
+ */
186
+ export declare function logResolvedModelRouting(logger: Logxer, request: ChatRequest, mergedConfig: ChatRequest['config']): void;
158
187
  /**
159
188
  * Walk `error` and `error.cause` for {@link FallbackExhaustedError.attempts}.
160
189
  */
@@ -4,7 +4,9 @@
4
4
  */
5
5
  import * as crypto from 'crypto';
6
6
  import { FallbackExhaustedError } from '@x12i/ai-providers-router';
7
- import { ModelResolutionError } from '@x12i/ai-tools';
7
+ import { ModelResolutionError, isKnownProfileOrShortcut } from '@x12i/ai-tools';
8
+ import { extractHttpStatusCode } from './gateway-retry.js';
9
+ import { gatewayLogDebug, withActivityIdentity } from './gateway-log-meta.js';
8
10
  import { getPreParsedInstructions } from './gateway-instructions.js';
9
11
  import { getModelMaxTokensFromFlexMd } from './flex-md-loader.js';
10
12
  import { applyModelResolution } from './ai-tools-client.js';
@@ -208,7 +210,7 @@ export async function mergeConfig(request, config, logger, mergeOptions) {
208
210
  await substituteGatewayDefaultModelAndResolve(merged, request, config, logger, mergeOptions, 'model_resolution_failed', { provider: originalProvider, model: originalModel });
209
211
  }
210
212
  else {
211
- throw new ModelResolutionError({ provider: merged.provider, model: explicitModel }, resolution);
213
+ throw buildModelResolutionFailureError(explicitModel, merged.provider, resolution);
212
214
  }
213
215
  }
214
216
  catch (error) {
@@ -822,6 +824,122 @@ export function pickRequestIdsFromRouterLike(gatewayAiRequestId, routerLike) {
822
824
  }
823
825
  return out;
824
826
  }
827
+ /** Error code hint when a bundled profile name cannot be routed to a catalog target. */
828
+ export const MODEL_PROFILE_UNROUTABLE = 'MODEL_PROFILE_UNROUTABLE';
829
+ export class ModelProfileUnroutableError extends Error {
830
+ profileAlias;
831
+ provider;
832
+ code = MODEL_PROFILE_UNROUTABLE;
833
+ constructor(profileAlias, provider, cause) {
834
+ super(`${MODEL_PROFILE_UNROUTABLE}: profile "${profileAlias}" is retired or has no routable catalog target` +
835
+ (provider ? ` (provider: "${provider}")` : '') +
836
+ '. Update @x12i/ai-profiles or choose another profile alias.');
837
+ this.profileAlias = profileAlias;
838
+ this.provider = provider;
839
+ this.name = 'ModelProfileUnroutableError';
840
+ if (cause !== undefined) {
841
+ this.cause = cause;
842
+ }
843
+ }
844
+ }
845
+ function buildModelResolutionFailureError(explicitModel, provider, resolution) {
846
+ const base = new ModelResolutionError({ provider, model: explicitModel }, resolution);
847
+ if (isKnownProfileOrShortcut(explicitModel)) {
848
+ return new ModelProfileUnroutableError(explicitModel, provider, base);
849
+ }
850
+ return base;
851
+ }
852
+ /**
853
+ * Build rejection-metadata fallback attempts from trace-mode {@link GatewayTraceAttempt}s.
854
+ */
855
+ export function buildGatewayFallbackAttemptsFromTrace(traceAttempts, candidates, lastError) {
856
+ const lastFailedByIndex = new Map();
857
+ for (const attempt of traceAttempts) {
858
+ if (!attempt.ok) {
859
+ lastFailedByIndex.set(attempt.routing.fallbackIndex, attempt);
860
+ }
861
+ }
862
+ return candidates.map((candidate, index) => {
863
+ const failed = lastFailedByIndex.get(index);
864
+ const errMsg = failed?.error?.message ??
865
+ (index === candidates.length - 1 && lastError ? lastError.message : 'invoke failed');
866
+ const httpStatus = extractHttpStatusCode(new Error(errMsg));
867
+ let responsePreview;
868
+ const raw = failed?.rawProviderPayload;
869
+ if (raw !== undefined) {
870
+ try {
871
+ const rawStr = typeof raw === 'string' ? raw : JSON.stringify(raw);
872
+ responsePreview = rawStr.length <= 500 ? rawStr : rawStr.slice(0, 500) + '…';
873
+ }
874
+ catch {
875
+ responsePreview = '[Unserializable]';
876
+ }
877
+ }
878
+ return {
879
+ provider: candidate.provider,
880
+ model: candidate.model,
881
+ ...(httpStatus !== undefined ? { httpStatus } : {}),
882
+ error: errMsg,
883
+ ...(responsePreview !== undefined ? { responsePreview } : {})
884
+ };
885
+ });
886
+ }
887
+ /** Human-readable exhaustion message for trace fallback chains and rejection logs. */
888
+ export function formatFallbackExhaustionMessage(attempts, candidates) {
889
+ const providersTried = [...new Set(candidates.map((c) => c.provider))];
890
+ const providerNote = providersTried.length > 1
891
+ ? `; providers tried: ${providersTried.join(' → ')}`
892
+ : providersTried.length === 1
893
+ ? `; provider: ${providersTried[0]}`
894
+ : '';
895
+ const detail = attempts
896
+ .map((a) => {
897
+ const model = a.model ? `${a.provider}/${a.model}` : a.provider;
898
+ const status = a.httpStatus !== undefined ? ` HTTP ${a.httpStatus}` : '';
899
+ const preview = a.responsePreview ? ` body=${a.responsePreview}` : '';
900
+ return `[${model}${status}] ${a.error}${preview}`;
901
+ })
902
+ .join('; ');
903
+ const last = attempts[attempts.length - 1];
904
+ const lastBody = last?.responsePreview && !detail.includes(last.responsePreview)
905
+ ? ` Last response preview: ${last.responsePreview}`
906
+ : '';
907
+ return (`All fallback candidates failed (${candidates.length} tried${providerNote}). ` +
908
+ `Attempts: ${detail || 'no attempt details recorded'}.${lastBody}`);
909
+ }
910
+ export function mapGatewayFallbackAttemptsToRouter(attempts) {
911
+ return attempts.map((a) => ({
912
+ provider: a.provider,
913
+ model: a.model,
914
+ httpStatus: a.httpStatus,
915
+ error: new Error(a.error),
916
+ responsePreview: a.responsePreview
917
+ }));
918
+ }
919
+ /**
920
+ * Log profile alias vs OpenRouter model id actually sent to the router after catalog resolution.
921
+ */
922
+ export function logResolvedModelRouting(logger, request, mergedConfig) {
923
+ const res = request._modelResolution;
924
+ if (!res?.modelId && res?.originalModel === undefined) {
925
+ return;
926
+ }
927
+ const profileAlias = res.originalModel ?? mergedConfig?.model;
928
+ const invokedModelId = res.modelId ?? mergedConfig?.model;
929
+ const provider = mergedConfig?.provider;
930
+ const openRouterPath = res.routedViaOpenRouter === true || provider === 'openrouter';
931
+ if (!openRouterPath) {
932
+ return;
933
+ }
934
+ logger.info('OpenRouter routing: profile alias resolved to model id for invoke', withActivityIdentity(request.identity, {
935
+ profileAlias,
936
+ invokedOpenRouterModelId: invokedModelId,
937
+ provider,
938
+ routedViaOpenRouter: res.routedViaOpenRouter,
939
+ resolvedVia: res.resolvedVia,
940
+ debugKind: gatewayLogDebug.trace
941
+ }));
942
+ }
825
943
  function mapRouterFallbackAttempts(attempts) {
826
944
  return attempts.map((attempt) => ({
827
945
  provider: String(attempt.provider),
package/dist/gateway.js CHANGED
@@ -3,13 +3,14 @@
3
3
  *
4
4
  * Simplified AI Gateway - Clean proxy implementation
5
5
  */
6
+ import { FallbackExhaustedError } from '@x12i/ai-providers-router';
6
7
  import { validateChatRequest, validateAIRequest } from './gateway-validation.js';
7
8
  import { ensureGatewayRequestIdentity } from './activity-manager.js';
8
9
  import { initializeGatewayComponents } from './gateway-config.js';
9
10
  import { buildMessages } from './message-builder.js';
10
11
  import { extractJsonFromFlexMd, getModelMaxTokensFromFlexMd } from './flex-md-loader.js';
11
12
  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, resolveCostCompletionWithAiTools, buildOptimixerActualUsage, buildTraceUsageSummary, enrichTraceAttemptsWithBilling, isMaxTokensExplicitlySet, tryExtractRouterLikePayloadFromErrorChain } from './gateway-utils.js';
13
+ import { attachGatewayInvokeRejectionMetadata, buildGatewayFallbackAttemptsFromTrace, buildInvokeRejectionMetadata, capActivityFullResponsePayload, formatFallbackExhaustionMessage, logResolvedModelRouting, mapGatewayFallbackAttemptsToRouter, hasNonZeroTokenUsage, DEFAULT_ACTIVITY_FULL_RESPONSE_MAX_CHARS, extractCostUsdFromRouterResponse, extractTokenUsageFromRouterResponse, mergeConfig, pickEffectiveModelConfigForMetadata, pickInvokeRoutingMetadataSlice, pickTraceMergedRouterConfig, resolveCostCompletionWithAiTools, buildOptimixerActualUsage, buildTraceUsageSummary, enrichTraceAttemptsWithBilling, isMaxTokensExplicitlySet, tryExtractRouterLikePayloadFromErrorChain } from './gateway-utils.js';
13
14
  import { getAiToolsClient } from './ai-tools-client.js';
14
15
  import { autoRegisterProviders } from './gateway-provider-auto-register.js';
15
16
  import { setGatewayLastJobId, setGatewayRuntimeClients } from './runtime-objects.js';
@@ -277,6 +278,7 @@ export class AIGateway {
277
278
  catalog: aiTools?.catalog ?? null
278
279
  });
279
280
  request._mergedRouterConfig = mergedConfig;
281
+ logResolvedModelRouting(this.logger, request, mergedConfig);
280
282
  const diagnosticsMode = request.diagnostics?.mode;
281
283
  const traceEnabled = diagnosticsMode === 'trace';
282
284
  const includeRawProviderPayload = request.diagnostics?.includeRawProviderPayload === true;
@@ -468,7 +470,20 @@ export class AIGateway {
468
470
  }
469
471
  }
470
472
  if (!response) {
471
- throw lastError ?? new Error('All fallback candidates failed');
473
+ const fallbackAttempts = buildGatewayFallbackAttemptsFromTrace(traceAttempts, deduped, lastError);
474
+ const providersTried = [...new Set(deduped.map((c) => c.provider))];
475
+ this.logger.error('Trace fallback chain exhausted', withActivityIdentity(request.identity, {
476
+ providersTried,
477
+ candidates: deduped,
478
+ fallbackAttempts,
479
+ debugKind: gatewayLogDebug.anomaly
480
+ }));
481
+ const exhausted = new FallbackExhaustedError(mapGatewayFallbackAttemptsToRouter(fallbackAttempts));
482
+ exhausted.message = formatFallbackExhaustionMessage(fallbackAttempts, deduped);
483
+ if (lastError) {
484
+ exhausted.cause = lastError;
485
+ }
486
+ throw exhausted;
472
487
  }
473
488
  // Summary counts + final request ids.
474
489
  traceRetryCount = traceAttempts.filter(a => a.routing.retryIndex > 0).length;
@@ -565,11 +580,14 @@ export class AIGateway {
565
580
  tokens = second;
566
581
  }
567
582
  }
568
- const costCompletion = await resolveCostCompletionWithAiTools(routerResponse, tokens, {
583
+ let costCompletion = await resolveCostCompletionWithAiTools(routerResponse, tokens, {
569
584
  mergedConfig,
570
585
  calculator: aiTools?.calculator ?? null,
571
586
  calculateCost: this.config.aiTools?.calculateCost
572
587
  });
588
+ if (!costCompletion.costStatus && hasNonZeroTokenUsage(tokens)) {
589
+ costCompletion = { ...costCompletion, costStatus: 'unpriced' };
590
+ }
573
591
  const routerMetaForCost = routerResponse?.metadata || {};
574
592
  const routingMetadataSlice = pickInvokeRoutingMetadataSlice(routerResponse, mergedConfig);
575
593
  const effectiveModelConfig = pickEffectiveModelConfigForMetadata(mergedConfig);
package/dist/index.d.ts CHANGED
@@ -17,7 +17,7 @@ export { AIGateway } from './gateway.js';
17
17
  export { InstructionNotFoundError, InstructionBackendError } 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 } from './gateway-utils.js';
20
+ export { attachGatewayInvokeRejectionMetadata, buildInvokeRejectionMetadata, tryExtractRouterLikePayloadFromErrorChain, tryExtractFallbackAttemptsFromErrorChain, pickRequestIdsFromRouterLike, resolveActivityCostCompletion, resolveCostCompletionForActivity, resolveCostCompletionWithAiTools, buildGatewayPricingRecord, mapAiCostResultToResolvedActivityCost, buildTraceUsageSummary, enrichTraceAttemptsWithBilling, hasNonZeroTokenUsage, MODEL_PROFILE_UNROUTABLE, ModelProfileUnroutableError, buildGatewayFallbackAttemptsFromTrace, formatFallbackExhaustionMessage, logResolvedModelRouting, mapGatewayFallbackAttemptsToRouter } from './gateway-utils.js';
21
21
  export { getGatewayOperationalMode, isProdGatewayMode, resolveGatewayDefaultModel, parseModelProviderSpec, CODE_DEFAULT_MODEL } from './gateway-mode.js';
22
22
  export type { GatewayOperationalMode, GatewayDefaultModelSource, DefaultModelSubstitutionReason, ResolvedGatewayDefault } from './gateway-mode.js';
23
23
  export type { ActivityCostStatus, ResolvedActivityCost } from './gateway-utils.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 } 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 } from './gateway-utils.js';
20
+ export { attachGatewayInvokeRejectionMetadata, buildInvokeRejectionMetadata, tryExtractRouterLikePayloadFromErrorChain, tryExtractFallbackAttemptsFromErrorChain, pickRequestIdsFromRouterLike, resolveActivityCostCompletion, resolveCostCompletionForActivity, resolveCostCompletionWithAiTools, buildGatewayPricingRecord, mapAiCostResultToResolvedActivityCost, buildTraceUsageSummary, enrichTraceAttemptsWithBilling, hasNonZeroTokenUsage, MODEL_PROFILE_UNROUTABLE, ModelProfileUnroutableError, buildGatewayFallbackAttemptsFromTrace, formatFallbackExhaustionMessage, logResolvedModelRouting, mapGatewayFallbackAttemptsToRouter } from './gateway-utils.js';
21
21
  export { getGatewayOperationalMode, isProdGatewayMode, resolveGatewayDefaultModel, parseModelProviderSpec, CODE_DEFAULT_MODEL } from './gateway-mode.js';
22
22
  export { contractSpecToFieldKeys, enrichParsedContentForOutputContract, resolveOutputContractFieldKeys } from './output-contract-normalizer.js';
23
23
  export { mergeGatewayAndRequestTemplateRenderOptions, mergeTemplateRenderOptions } from './template-render-merge.js';
@@ -1,14 +1,15 @@
1
1
  import { Optimixer } from '@x12i/optimixer';
2
2
  import { resolveActivityTrackingConfig } from './config/activity-tracking-config.js';
3
3
  import { estimateMessagesTokenSizes } from './token-estimate.js';
4
- function resolveActionTypeId(request) {
4
+ /** Optimixer bucket key: prefer taskTypeId (template), then identity actionType, else gateway default. */
5
+ function resolveTemplateId(request) {
6
+ if (request.taskTypeId && String(request.taskTypeId).trim()) {
7
+ return String(request.taskTypeId).trim();
8
+ }
5
9
  const identity = request.identity;
6
10
  if (identity?.actionType && String(identity.actionType).trim()) {
7
11
  return String(identity.actionType).trim();
8
12
  }
9
- if (request.taskTypeId && String(request.taskTypeId).trim()) {
10
- return String(request.taskTypeId).trim();
11
- }
12
13
  return 'gateway.invoke';
13
14
  }
14
15
  function toActivixRunContext(identity) {
@@ -77,15 +78,18 @@ export class OptimixerManager {
77
78
  const { request, mergedConfig, messages } = ctx;
78
79
  const { inputSize, contextSize } = estimateMessagesTokenSizes(messages);
79
80
  const acceptableRisk = this.config?.acceptableRisk ?? 'medium';
81
+ const provider = typeof mergedConfig?.provider === 'string' ? mergedConfig.provider : undefined;
82
+ const model = typeof mergedConfig?.model === 'string' ? mergedConfig.model : undefined;
80
83
  try {
81
84
  return await optimixer.predictAiMaxTokens({
82
- actionTypeId: resolveActionTypeId(request),
85
+ templateId: resolveTemplateId(request),
83
86
  inputSize,
84
87
  contextSize,
85
88
  acceptableRisk,
86
89
  runContext: toActivixRunContext(request.identity),
87
- provider: typeof mergedConfig?.provider === 'string' ? mergedConfig.provider : undefined,
88
- model: typeof mergedConfig?.model === 'string' ? mergedConfig.model : undefined
90
+ ...(provider || model
91
+ ? { modelProfile: { ...(provider ? { provider } : {}), ...(model ? { model } : {}) } }
92
+ : {})
89
93
  });
90
94
  }
91
95
  catch (error) {
@@ -4,7 +4,9 @@
4
4
  */
5
5
  import * as crypto from 'crypto';
6
6
  import { FallbackExhaustedError } from '@x12i/ai-providers-router';
7
- import { ModelResolutionError } from '@x12i/ai-tools';
7
+ import { ModelResolutionError, isKnownProfileOrShortcut } from '@x12i/ai-tools';
8
+ import { extractHttpStatusCode } from './gateway-retry.js';
9
+ import { gatewayLogDebug, withActivityIdentity } from './gateway-log-meta.js';
8
10
  import { getPreParsedInstructions } from './gateway-instructions.js';
9
11
  import { getModelMaxTokensFromFlexMd } from './flex-md-loader.js';
10
12
  import { applyModelResolution } from './ai-tools-client.js';
@@ -208,7 +210,7 @@ export async function mergeConfig(request, config, logger, mergeOptions) {
208
210
  await substituteGatewayDefaultModelAndResolve(merged, request, config, logger, mergeOptions, 'model_resolution_failed', { provider: originalProvider, model: originalModel });
209
211
  }
210
212
  else {
211
- throw new ModelResolutionError({ provider: merged.provider, model: explicitModel }, resolution);
213
+ throw buildModelResolutionFailureError(explicitModel, merged.provider, resolution);
212
214
  }
213
215
  }
214
216
  catch (error) {
@@ -822,6 +824,122 @@ export function pickRequestIdsFromRouterLike(gatewayAiRequestId, routerLike) {
822
824
  }
823
825
  return out;
824
826
  }
827
+ /** Error code hint when a bundled profile name cannot be routed to a catalog target. */
828
+ export const MODEL_PROFILE_UNROUTABLE = 'MODEL_PROFILE_UNROUTABLE';
829
+ export class ModelProfileUnroutableError extends Error {
830
+ profileAlias;
831
+ provider;
832
+ code = MODEL_PROFILE_UNROUTABLE;
833
+ constructor(profileAlias, provider, cause) {
834
+ super(`${MODEL_PROFILE_UNROUTABLE}: profile "${profileAlias}" is retired or has no routable catalog target` +
835
+ (provider ? ` (provider: "${provider}")` : '') +
836
+ '. Update @x12i/ai-profiles or choose another profile alias.');
837
+ this.profileAlias = profileAlias;
838
+ this.provider = provider;
839
+ this.name = 'ModelProfileUnroutableError';
840
+ if (cause !== undefined) {
841
+ this.cause = cause;
842
+ }
843
+ }
844
+ }
845
+ function buildModelResolutionFailureError(explicitModel, provider, resolution) {
846
+ const base = new ModelResolutionError({ provider, model: explicitModel }, resolution);
847
+ if (isKnownProfileOrShortcut(explicitModel)) {
848
+ return new ModelProfileUnroutableError(explicitModel, provider, base);
849
+ }
850
+ return base;
851
+ }
852
+ /**
853
+ * Build rejection-metadata fallback attempts from trace-mode {@link GatewayTraceAttempt}s.
854
+ */
855
+ export function buildGatewayFallbackAttemptsFromTrace(traceAttempts, candidates, lastError) {
856
+ const lastFailedByIndex = new Map();
857
+ for (const attempt of traceAttempts) {
858
+ if (!attempt.ok) {
859
+ lastFailedByIndex.set(attempt.routing.fallbackIndex, attempt);
860
+ }
861
+ }
862
+ return candidates.map((candidate, index) => {
863
+ const failed = lastFailedByIndex.get(index);
864
+ const errMsg = failed?.error?.message ??
865
+ (index === candidates.length - 1 && lastError ? lastError.message : 'invoke failed');
866
+ const httpStatus = extractHttpStatusCode(new Error(errMsg));
867
+ let responsePreview;
868
+ const raw = failed?.rawProviderPayload;
869
+ if (raw !== undefined) {
870
+ try {
871
+ const rawStr = typeof raw === 'string' ? raw : JSON.stringify(raw);
872
+ responsePreview = rawStr.length <= 500 ? rawStr : rawStr.slice(0, 500) + '…';
873
+ }
874
+ catch {
875
+ responsePreview = '[Unserializable]';
876
+ }
877
+ }
878
+ return {
879
+ provider: candidate.provider,
880
+ model: candidate.model,
881
+ ...(httpStatus !== undefined ? { httpStatus } : {}),
882
+ error: errMsg,
883
+ ...(responsePreview !== undefined ? { responsePreview } : {})
884
+ };
885
+ });
886
+ }
887
+ /** Human-readable exhaustion message for trace fallback chains and rejection logs. */
888
+ export function formatFallbackExhaustionMessage(attempts, candidates) {
889
+ const providersTried = [...new Set(candidates.map((c) => c.provider))];
890
+ const providerNote = providersTried.length > 1
891
+ ? `; providers tried: ${providersTried.join(' → ')}`
892
+ : providersTried.length === 1
893
+ ? `; provider: ${providersTried[0]}`
894
+ : '';
895
+ const detail = attempts
896
+ .map((a) => {
897
+ const model = a.model ? `${a.provider}/${a.model}` : a.provider;
898
+ const status = a.httpStatus !== undefined ? ` HTTP ${a.httpStatus}` : '';
899
+ const preview = a.responsePreview ? ` body=${a.responsePreview}` : '';
900
+ return `[${model}${status}] ${a.error}${preview}`;
901
+ })
902
+ .join('; ');
903
+ const last = attempts[attempts.length - 1];
904
+ const lastBody = last?.responsePreview && !detail.includes(last.responsePreview)
905
+ ? ` Last response preview: ${last.responsePreview}`
906
+ : '';
907
+ return (`All fallback candidates failed (${candidates.length} tried${providerNote}). ` +
908
+ `Attempts: ${detail || 'no attempt details recorded'}.${lastBody}`);
909
+ }
910
+ export function mapGatewayFallbackAttemptsToRouter(attempts) {
911
+ return attempts.map((a) => ({
912
+ provider: a.provider,
913
+ model: a.model,
914
+ httpStatus: a.httpStatus,
915
+ error: new Error(a.error),
916
+ responsePreview: a.responsePreview
917
+ }));
918
+ }
919
+ /**
920
+ * Log profile alias vs OpenRouter model id actually sent to the router after catalog resolution.
921
+ */
922
+ export function logResolvedModelRouting(logger, request, mergedConfig) {
923
+ const res = request._modelResolution;
924
+ if (!res?.modelId && res?.originalModel === undefined) {
925
+ return;
926
+ }
927
+ const profileAlias = res.originalModel ?? mergedConfig?.model;
928
+ const invokedModelId = res.modelId ?? mergedConfig?.model;
929
+ const provider = mergedConfig?.provider;
930
+ const openRouterPath = res.routedViaOpenRouter === true || provider === 'openrouter';
931
+ if (!openRouterPath) {
932
+ return;
933
+ }
934
+ logger.info('OpenRouter routing: profile alias resolved to model id for invoke', withActivityIdentity(request.identity, {
935
+ profileAlias,
936
+ invokedOpenRouterModelId: invokedModelId,
937
+ provider,
938
+ routedViaOpenRouter: res.routedViaOpenRouter,
939
+ resolvedVia: res.resolvedVia,
940
+ debugKind: gatewayLogDebug.trace
941
+ }));
942
+ }
825
943
  function mapRouterFallbackAttempts(attempts) {
826
944
  return attempts.map((attempt) => ({
827
945
  provider: String(attempt.provider),
@@ -155,6 +155,35 @@ export declare function pickEffectiveModelConfigFromInvokeRequest(request: Pick<
155
155
  */
156
156
  export declare function tryExtractRouterLikePayloadFromErrorChain(error: unknown, maxDepth?: number): unknown;
157
157
  export declare function pickRequestIdsFromRouterLike(gatewayAiRequestId: string | undefined, routerLike: unknown): GatewayTraceRequestIds | undefined;
158
+ /** Error code hint when a bundled profile name cannot be routed to a catalog target. */
159
+ export declare const MODEL_PROFILE_UNROUTABLE = "MODEL_PROFILE_UNROUTABLE";
160
+ export declare class ModelProfileUnroutableError extends Error {
161
+ readonly profileAlias: string;
162
+ readonly provider: string | undefined;
163
+ readonly code = "MODEL_PROFILE_UNROUTABLE";
164
+ constructor(profileAlias: string, provider: string | undefined, cause?: unknown);
165
+ }
166
+ type ModelResolutionCandidate = {
167
+ provider: string;
168
+ model: string;
169
+ };
170
+ /**
171
+ * Build rejection-metadata fallback attempts from trace-mode {@link GatewayTraceAttempt}s.
172
+ */
173
+ export declare function buildGatewayFallbackAttemptsFromTrace(traceAttempts: GatewayTraceAttempt[], candidates: ModelResolutionCandidate[], lastError?: Error): GatewayFallbackAttempt[];
174
+ /** Human-readable exhaustion message for trace fallback chains and rejection logs. */
175
+ export declare function formatFallbackExhaustionMessage(attempts: GatewayFallbackAttempt[], candidates: ModelResolutionCandidate[]): string;
176
+ export declare function mapGatewayFallbackAttemptsToRouter(attempts: GatewayFallbackAttempt[]): Array<{
177
+ provider: string;
178
+ model?: string;
179
+ httpStatus?: number;
180
+ error: Error;
181
+ responsePreview?: string;
182
+ }>;
183
+ /**
184
+ * Log profile alias vs OpenRouter model id actually sent to the router after catalog resolution.
185
+ */
186
+ export declare function logResolvedModelRouting(logger: Logxer, request: ChatRequest, mergedConfig: ChatRequest['config']): void;
158
187
  /**
159
188
  * Walk `error` and `error.cause` for {@link FallbackExhaustedError.attempts}.
160
189
  */
@@ -3,13 +3,14 @@
3
3
  *
4
4
  * Simplified AI Gateway - Clean proxy implementation
5
5
  */
6
+ import { FallbackExhaustedError } from '@x12i/ai-providers-router';
6
7
  import { validateChatRequest, validateAIRequest } from './gateway-validation.js';
7
8
  import { ensureGatewayRequestIdentity } from './activity-manager.js';
8
9
  import { initializeGatewayComponents } from './gateway-config.js';
9
10
  import { buildMessages } from './message-builder.js';
10
11
  import { extractJsonFromFlexMd, getModelMaxTokensFromFlexMd } from './flex-md-loader.js';
11
12
  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, resolveCostCompletionWithAiTools, buildOptimixerActualUsage, buildTraceUsageSummary, enrichTraceAttemptsWithBilling, isMaxTokensExplicitlySet, tryExtractRouterLikePayloadFromErrorChain } from './gateway-utils.js';
13
+ import { attachGatewayInvokeRejectionMetadata, buildGatewayFallbackAttemptsFromTrace, buildInvokeRejectionMetadata, capActivityFullResponsePayload, formatFallbackExhaustionMessage, logResolvedModelRouting, mapGatewayFallbackAttemptsToRouter, hasNonZeroTokenUsage, DEFAULT_ACTIVITY_FULL_RESPONSE_MAX_CHARS, extractCostUsdFromRouterResponse, extractTokenUsageFromRouterResponse, mergeConfig, pickEffectiveModelConfigForMetadata, pickInvokeRoutingMetadataSlice, pickTraceMergedRouterConfig, resolveCostCompletionWithAiTools, buildOptimixerActualUsage, buildTraceUsageSummary, enrichTraceAttemptsWithBilling, isMaxTokensExplicitlySet, tryExtractRouterLikePayloadFromErrorChain } from './gateway-utils.js';
13
14
  import { getAiToolsClient } from './ai-tools-client.js';
14
15
  import { autoRegisterProviders } from './gateway-provider-auto-register.js';
15
16
  import { setGatewayLastJobId, setGatewayRuntimeClients } from './runtime-objects.js';
@@ -277,6 +278,7 @@ export class AIGateway {
277
278
  catalog: aiTools?.catalog ?? null
278
279
  });
279
280
  request._mergedRouterConfig = mergedConfig;
281
+ logResolvedModelRouting(this.logger, request, mergedConfig);
280
282
  const diagnosticsMode = request.diagnostics?.mode;
281
283
  const traceEnabled = diagnosticsMode === 'trace';
282
284
  const includeRawProviderPayload = request.diagnostics?.includeRawProviderPayload === true;
@@ -468,7 +470,20 @@ export class AIGateway {
468
470
  }
469
471
  }
470
472
  if (!response) {
471
- throw lastError ?? new Error('All fallback candidates failed');
473
+ const fallbackAttempts = buildGatewayFallbackAttemptsFromTrace(traceAttempts, deduped, lastError);
474
+ const providersTried = [...new Set(deduped.map((c) => c.provider))];
475
+ this.logger.error('Trace fallback chain exhausted', withActivityIdentity(request.identity, {
476
+ providersTried,
477
+ candidates: deduped,
478
+ fallbackAttempts,
479
+ debugKind: gatewayLogDebug.anomaly
480
+ }));
481
+ const exhausted = new FallbackExhaustedError(mapGatewayFallbackAttemptsToRouter(fallbackAttempts));
482
+ exhausted.message = formatFallbackExhaustionMessage(fallbackAttempts, deduped);
483
+ if (lastError) {
484
+ exhausted.cause = lastError;
485
+ }
486
+ throw exhausted;
472
487
  }
473
488
  // Summary counts + final request ids.
474
489
  traceRetryCount = traceAttempts.filter(a => a.routing.retryIndex > 0).length;
@@ -565,11 +580,14 @@ export class AIGateway {
565
580
  tokens = second;
566
581
  }
567
582
  }
568
- const costCompletion = await resolveCostCompletionWithAiTools(routerResponse, tokens, {
583
+ let costCompletion = await resolveCostCompletionWithAiTools(routerResponse, tokens, {
569
584
  mergedConfig,
570
585
  calculator: aiTools?.calculator ?? null,
571
586
  calculateCost: this.config.aiTools?.calculateCost
572
587
  });
588
+ if (!costCompletion.costStatus && hasNonZeroTokenUsage(tokens)) {
589
+ costCompletion = { ...costCompletion, costStatus: 'unpriced' };
590
+ }
573
591
  const routerMetaForCost = routerResponse?.metadata || {};
574
592
  const routingMetadataSlice = pickInvokeRoutingMetadataSlice(routerResponse, mergedConfig);
575
593
  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 } 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 } from './gateway-utils.js';
20
+ export { attachGatewayInvokeRejectionMetadata, buildInvokeRejectionMetadata, tryExtractRouterLikePayloadFromErrorChain, tryExtractFallbackAttemptsFromErrorChain, pickRequestIdsFromRouterLike, resolveActivityCostCompletion, resolveCostCompletionForActivity, resolveCostCompletionWithAiTools, buildGatewayPricingRecord, mapAiCostResultToResolvedActivityCost, buildTraceUsageSummary, enrichTraceAttemptsWithBilling, hasNonZeroTokenUsage, MODEL_PROFILE_UNROUTABLE, ModelProfileUnroutableError, buildGatewayFallbackAttemptsFromTrace, formatFallbackExhaustionMessage, logResolvedModelRouting, mapGatewayFallbackAttemptsToRouter } from './gateway-utils.js';
21
21
  export { getGatewayOperationalMode, isProdGatewayMode, resolveGatewayDefaultModel, parseModelProviderSpec, CODE_DEFAULT_MODEL } from './gateway-mode.js';
22
22
  export { contractSpecToFieldKeys, enrichParsedContentForOutputContract, resolveOutputContractFieldKeys } from './output-contract-normalizer.js';
23
23
  export { mergeGatewayAndRequestTemplateRenderOptions, mergeTemplateRenderOptions } from './template-render-merge.js';
@@ -17,7 +17,7 @@ export { AIGateway } from './gateway.js';
17
17
  export { InstructionNotFoundError, InstructionBackendError } 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 } from './gateway-utils.js';
20
+ export { attachGatewayInvokeRejectionMetadata, buildInvokeRejectionMetadata, tryExtractRouterLikePayloadFromErrorChain, tryExtractFallbackAttemptsFromErrorChain, pickRequestIdsFromRouterLike, resolveActivityCostCompletion, resolveCostCompletionForActivity, resolveCostCompletionWithAiTools, buildGatewayPricingRecord, mapAiCostResultToResolvedActivityCost, buildTraceUsageSummary, enrichTraceAttemptsWithBilling, hasNonZeroTokenUsage, MODEL_PROFILE_UNROUTABLE, ModelProfileUnroutableError, buildGatewayFallbackAttemptsFromTrace, formatFallbackExhaustionMessage, logResolvedModelRouting, mapGatewayFallbackAttemptsToRouter } from './gateway-utils.js';
21
21
  export { getGatewayOperationalMode, isProdGatewayMode, resolveGatewayDefaultModel, parseModelProviderSpec, CODE_DEFAULT_MODEL } from './gateway-mode.js';
22
22
  export type { GatewayOperationalMode, GatewayDefaultModelSource, DefaultModelSubstitutionReason, ResolvedGatewayDefault } from './gateway-mode.js';
23
23
  export type { ActivityCostStatus, ResolvedActivityCost } from './gateway-utils.js';
@@ -1,14 +1,15 @@
1
1
  import { Optimixer } from '@x12i/optimixer';
2
2
  import { resolveActivityTrackingConfig } from './config/activity-tracking-config.js';
3
3
  import { estimateMessagesTokenSizes } from './token-estimate.js';
4
- function resolveActionTypeId(request) {
4
+ /** Optimixer bucket key: prefer taskTypeId (template), then identity actionType, else gateway default. */
5
+ function resolveTemplateId(request) {
6
+ if (request.taskTypeId && String(request.taskTypeId).trim()) {
7
+ return String(request.taskTypeId).trim();
8
+ }
5
9
  const identity = request.identity;
6
10
  if (identity?.actionType && String(identity.actionType).trim()) {
7
11
  return String(identity.actionType).trim();
8
12
  }
9
- if (request.taskTypeId && String(request.taskTypeId).trim()) {
10
- return String(request.taskTypeId).trim();
11
- }
12
13
  return 'gateway.invoke';
13
14
  }
14
15
  function toActivixRunContext(identity) {
@@ -77,15 +78,18 @@ export class OptimixerManager {
77
78
  const { request, mergedConfig, messages } = ctx;
78
79
  const { inputSize, contextSize } = estimateMessagesTokenSizes(messages);
79
80
  const acceptableRisk = this.config?.acceptableRisk ?? 'medium';
81
+ const provider = typeof mergedConfig?.provider === 'string' ? mergedConfig.provider : undefined;
82
+ const model = typeof mergedConfig?.model === 'string' ? mergedConfig.model : undefined;
80
83
  try {
81
84
  return await optimixer.predictAiMaxTokens({
82
- actionTypeId: resolveActionTypeId(request),
85
+ templateId: resolveTemplateId(request),
83
86
  inputSize,
84
87
  contextSize,
85
88
  acceptableRisk,
86
89
  runContext: toActivixRunContext(request.identity),
87
- provider: typeof mergedConfig?.provider === 'string' ? mergedConfig.provider : undefined,
88
- model: typeof mergedConfig?.model === 'string' ? mergedConfig.model : undefined
90
+ ...(provider || model
91
+ ? { modelProfile: { ...(provider ? { provider } : {}), ...(model ? { model } : {}) } }
92
+ : {})
89
93
  });
90
94
  }
91
95
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x12i/ai-gateway",
3
- "version": "9.6.0",
3
+ "version": "9.6.1",
4
4
  "description": "AI Gateway - Unified interface for LLM provider routing and management",
5
5
  "type": "module",
6
6
  "exports": {
@@ -41,12 +41,12 @@
41
41
  "author": "x12i",
42
42
  "license": "mit",
43
43
  "dependencies": {
44
- "@x12i/activix": "^8.0.5",
44
+ "@x12i/activix": "^8.0.7",
45
45
  "@x12i/ai-providers-router": "^4.8.5",
46
46
  "@x12i/ai-tools": "^2.0.4",
47
47
  "@x12i/flex-md": "^4.8.0",
48
48
  "@x12i/logxer": "^4.3.5",
49
- "@x12i/optimixer": "^0.1.0",
49
+ "@x12i/optimixer": "^2.0.1",
50
50
  "@x12i/rendrix": "^4.3.0"
51
51
  },
52
52
  "devDependencies": {