@jsonstudio/llms 0.6.795 → 0.6.938

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 (184) hide show
  1. package/dist/bridge/routecodex-adapter.d.ts +74 -0
  2. package/dist/config-unified/enhanced-path-resolver.d.ts +5 -0
  3. package/dist/config-unified/unified-config.d.ts +26 -0
  4. package/dist/conversion/codec-registry.d.ts +10 -0
  5. package/dist/conversion/codecs/gemini-openai-codec.d.ts +16 -0
  6. package/dist/conversion/codecs/openai-openai-codec.d.ts +12 -0
  7. package/dist/conversion/codecs/responses-openai-codec.d.ts +12 -0
  8. package/dist/conversion/compat/profiles/chat-gemini.json +12 -0
  9. package/dist/conversion/config/config-manager.d.ts +212 -0
  10. package/dist/conversion/hub/config/types.d.ts +26 -0
  11. package/dist/conversion/hub/core/detour-registry.d.ts +9 -0
  12. package/dist/conversion/hub/core/hub-context.d.ts +21 -0
  13. package/dist/conversion/hub/core/index.d.ts +3 -0
  14. package/dist/conversion/hub/core/stage-driver.d.ts +30 -0
  15. package/dist/conversion/hub/format-adapters/anthropic-format-adapter.d.ts +16 -0
  16. package/dist/conversion/hub/format-adapters/chat-format-adapter.d.ts +17 -0
  17. package/dist/conversion/hub/format-adapters/gemini-format-adapter.d.ts +16 -0
  18. package/dist/conversion/hub/format-adapters/index.d.ts +21 -0
  19. package/dist/conversion/hub/hub-feature.d.ts +1 -0
  20. package/dist/conversion/hub/node-support.d.ts +19 -0
  21. package/dist/conversion/hub/pipeline/compat/compat-pipeline-executor.js +11 -0
  22. package/dist/conversion/hub/pipeline/compat/compat-types.d.ts +3 -0
  23. package/dist/conversion/hub/pipeline/hub-pipeline.d.ts +7 -0
  24. package/dist/conversion/hub/pipeline/hub-pipeline.js +71 -14
  25. package/dist/conversion/hub/pipeline/stages/req_process/req_process_stage1_tool_governance/index.js +4 -0
  26. package/dist/conversion/hub/pipeline/stages/resp_outbound/resp_outbound_stage1_client_remap/index.js +23 -1
  27. package/dist/conversion/hub/pipelines/inbound.d.ts +22 -0
  28. package/dist/conversion/hub/pipelines/outbound.d.ts +22 -0
  29. package/dist/conversion/hub/policy/policy-engine.d.ts +46 -0
  30. package/dist/conversion/hub/policy/policy-engine.js +176 -0
  31. package/dist/conversion/hub/policy/protocol-spec.d.ts +50 -0
  32. package/dist/conversion/hub/policy/protocol-spec.js +105 -0
  33. package/dist/conversion/hub/process/chat-process.d.ts +32 -0
  34. package/dist/conversion/hub/registry.d.ts +28 -0
  35. package/dist/conversion/hub/response/chat-response-utils.d.ts +6 -0
  36. package/dist/conversion/hub/response/provider-response.js +31 -0
  37. package/dist/conversion/hub/semantic-mappers/gemini-mapper.d.ts +7 -0
  38. package/dist/conversion/hub/semantic-mappers/gemini-mapper.js +87 -1
  39. package/dist/conversion/hub/semantic-mappers/index.d.ts +4 -0
  40. package/dist/conversion/hub/semantic-mappers/responses-mapper.d.ts +21 -0
  41. package/dist/conversion/hub/standardized-bridge.d.ts +12 -0
  42. package/dist/conversion/hub/types/chat-schema.d.ts +112 -0
  43. package/dist/conversion/hub/types/errors.d.ts +5 -0
  44. package/dist/conversion/hub/types/format-envelope.d.ts +7 -0
  45. package/dist/conversion/hub/types/index.d.ts +6 -0
  46. package/dist/conversion/hub/types/json.d.ts +9 -0
  47. package/dist/conversion/hub/types/node.d.ts +31 -0
  48. package/dist/conversion/responses/responses-openai-bridge.js +263 -10
  49. package/dist/conversion/schema-validator.d.ts +7 -0
  50. package/dist/conversion/shared/args-mapping.d.ts +18 -0
  51. package/dist/conversion/shared/chat-request-filters.d.ts +9 -0
  52. package/dist/conversion/shared/errors.d.ts +1 -1
  53. package/dist/conversion/shared/gemini-tool-utils.js +61 -0
  54. package/dist/conversion/shared/jsonish.d.ts +3 -0
  55. package/dist/conversion/shared/mcp-injection.d.ts +2 -0
  56. package/dist/conversion/shared/media.d.ts +1 -0
  57. package/dist/conversion/shared/openai-message-normalize.d.ts +1 -0
  58. package/dist/conversion/shared/payload-budget.d.ts +13 -0
  59. package/dist/conversion/shared/reasoning-mapping.d.ts +5 -0
  60. package/dist/conversion/shared/responses-request-adapter.d.ts +1 -28
  61. package/dist/conversion/shared/responses-request-adapter.js +1 -430
  62. package/dist/conversion/shared/snapshot-hooks.js +112 -4
  63. package/dist/conversion/shared/tool-governor.js +8 -2
  64. package/dist/conversion/shared/tool-harvester.d.ts +31 -0
  65. package/dist/conversion/shared/tool-mapping.js +10 -29
  66. package/dist/conversion/types.d.ts +33 -0
  67. package/dist/filters/builtin/add-fields-filter.d.ts +8 -0
  68. package/dist/filters/builtin/blacklist-filter.d.ts +8 -0
  69. package/dist/filters/builtin/whitelist-filter.d.ts +8 -0
  70. package/dist/filters/engine.d.ts +16 -0
  71. package/dist/filters/special/request-tool-choice-policy.d.ts +11 -0
  72. package/dist/filters/special/response-finish-invariants.d.ts +11 -0
  73. package/dist/filters/special/response-openai-to-responses-bridge.d.ts +13 -0
  74. package/dist/filters/special/response-tool-arguments-blacklist.d.ts +12 -0
  75. package/dist/filters/special/response-tool-arguments-schema-converge.d.ts +13 -0
  76. package/dist/filters/special/response-tool-arguments-stringify.d.ts +9 -0
  77. package/dist/filters/special/response-tool-arguments-whitelist.d.ts +11 -0
  78. package/dist/filters/special/tool-filter-hooks.d.ts +19 -0
  79. package/dist/filters/special/tool-post-constraints.d.ts +31 -0
  80. package/dist/filters/types.d.ts +68 -0
  81. package/dist/filters/utils/fieldmap-loader.d.ts +2 -0
  82. package/dist/filters/utils/snapshot-writer.d.ts +10 -0
  83. package/dist/guidance/index.d.ts +3 -0
  84. package/dist/guidance/index.js +78 -83
  85. package/dist/http/sse-response.d.ts +22 -0
  86. package/dist/router/virtual-router/bootstrap.d.ts +6 -0
  87. package/dist/router/virtual-router/bootstrap.js +49 -5
  88. package/dist/router/virtual-router/classifier.d.ts +10 -0
  89. package/dist/router/virtual-router/engine-selection.js +147 -15
  90. package/dist/router/virtual-router/engine.js +177 -31
  91. package/dist/router/virtual-router/error-center.d.ts +10 -0
  92. package/dist/router/virtual-router/features.d.ts +3 -0
  93. package/dist/router/virtual-router/routing-instructions.d.ts +23 -1
  94. package/dist/router/virtual-router/routing-instructions.js +120 -30
  95. package/dist/router/virtual-router/types.d.ts +11 -0
  96. package/dist/servertool/engine.js +189 -16
  97. package/dist/servertool/handlers/apply-patch-guard.js +269 -0
  98. package/dist/servertool/handlers/exec-command-guard.js +558 -0
  99. package/dist/servertool/handlers/followup-message-trimmer.d.ts +16 -0
  100. package/dist/servertool/handlers/followup-message-trimmer.js +198 -0
  101. package/dist/servertool/handlers/followup-request-builder.d.ts +17 -0
  102. package/dist/servertool/handlers/followup-request-builder.js +122 -0
  103. package/dist/servertool/handlers/gemini-empty-reply-continue.js +252 -51
  104. package/dist/servertool/handlers/iflow-model-error-retry.js +12 -22
  105. package/dist/servertool/handlers/stop-message-auto.js +237 -75
  106. package/dist/servertool/handlers/vision.js +15 -27
  107. package/dist/servertool/handlers/web-search.js +17 -43
  108. package/dist/servertool/server-side-tools.d.ts +3 -0
  109. package/dist/servertool/server-side-tools.js +3 -0
  110. package/dist/sse/json-to-sse/anthropic-json-to-sse-converter.d.ts +2 -1
  111. package/dist/sse/json-to-sse/chat-json-to-sse-converter.d.ts +80 -0
  112. package/dist/sse/json-to-sse/event-generators/chat.d.ts +55 -0
  113. package/dist/sse/json-to-sse/event-generators/responses.d.ts +99 -0
  114. package/dist/sse/json-to-sse/gemini-json-to-sse-converter.d.ts +2 -1
  115. package/dist/sse/json-to-sse/responses-json-to-sse-converter.d.ts +80 -0
  116. package/dist/sse/json-to-sse/sequencers/anthropic-sequencer.d.ts +1 -1
  117. package/dist/sse/json-to-sse/sequencers/chat-sequencer.d.ts +2 -2
  118. package/dist/sse/json-to-sse/sequencers/gemini-sequencer.d.ts +1 -1
  119. package/dist/sse/json-to-sse/sequencers/responses-sequencer.d.ts +40 -0
  120. package/dist/sse/shared/chat-serializer.d.ts +4 -0
  121. package/dist/sse/shared/constants.d.ts +272 -0
  122. package/dist/sse/shared/serializers/anthropic-event-serializer.d.ts +1 -1
  123. package/dist/sse/shared/serializers/base-serializer.d.ts +158 -0
  124. package/dist/sse/shared/serializers/chat-event-serializer.d.ts +82 -0
  125. package/dist/sse/shared/serializers/gemini-event-serializer.d.ts +1 -1
  126. package/dist/sse/shared/serializers/index.d.ts +2 -1
  127. package/dist/sse/shared/serializers/responses-event-serializer.d.ts +123 -0
  128. package/dist/sse/shared/serializers/types.d.ts +51 -0
  129. package/dist/sse/shared/utils.d.ts +254 -0
  130. package/dist/sse/shared/writer.d.ts +2 -2
  131. package/dist/sse/sse-to-json/anthropic-sse-to-json-converter.d.ts +1 -1
  132. package/dist/sse/sse-to-json/builders/anthropic-response-builder.d.ts +1 -1
  133. package/dist/sse/sse-to-json/builders/response-builder.d.ts +1 -1
  134. package/dist/sse/sse-to-json/chat-sse-to-json-converter.d.ts +2 -1
  135. package/dist/sse/sse-to-json/gemini-sse-to-json-converter.d.ts +2 -1
  136. package/dist/sse/sse-to-json/parsers/sse-parser.d.ts +73 -0
  137. package/dist/sse/sse-to-json/responses-sse-to-json-converter.d.ts +1 -1
  138. package/dist/sse/types/chat-types.d.ts +1 -1
  139. package/dist/sse/types/responses-types.d.ts +1 -1
  140. package/dist/tools/apply-patch/execution-capturer.d.ts +13 -0
  141. package/dist/tools/apply-patch/execution-capturer.js +158 -0
  142. package/dist/tools/apply-patch/regression-capturer.d.ts +1 -0
  143. package/dist/tools/apply-patch/regression-capturer.js +5 -4
  144. package/dist/tools/apply-patch/structured.js +109 -13
  145. package/dist/tools/apply-patch/validator.js +261 -17
  146. package/dist/tools/tool-registry.d.ts +8 -0
  147. package/dist/tools/tool-registry.js +2 -1
  148. package/package.json +4 -4
  149. package/dist/conversion/compat/actions/apply-patch-format-fixer.js +0 -233
  150. package/dist/conversion/config/compat-profiles.json +0 -38
  151. package/dist/conversion/hub/response/server-side-tools.d.ts +0 -26
  152. package/dist/conversion/hub/response/server-side-tools.js +0 -383
  153. package/dist/conversion/shared/bridge-conversation-store.d.ts +0 -41
  154. package/dist/conversion/shared/bridge-conversation-store.js +0 -279
  155. package/dist/conversion/shared/bridge-request-adapter.d.ts +0 -28
  156. package/dist/conversion/shared/bridge-request-adapter.js +0 -430
  157. package/dist/conversion/shared/responses-id-utils.js +0 -42
  158. package/dist/conversion/shared/responses-instructions.js +0 -113
  159. package/dist/conversion/shared/responses-message-utils.d.ts +0 -15
  160. package/dist/conversion/shared/responses-message-utils.js +0 -206
  161. package/dist/conversion/shared/responses-metadata.js +0 -1
  162. package/dist/conversion/shared/responses-output-utils.d.ts +0 -7
  163. package/dist/conversion/shared/responses-output-utils.js +0 -108
  164. package/dist/conversion/shared/responses-types.d.ts +0 -33
  165. package/dist/conversion/shared/tool-normalizers.d.ts +0 -4
  166. package/dist/conversion/shared/tool-normalizers.js +0 -84
  167. package/dist/filters/special/request-streaming-to-nonstreaming.d.ts +0 -13
  168. package/dist/filters/special/request-streaming-to-nonstreaming.js +0 -39
  169. package/dist/filters/special/response-apply-patch-toon-decode.d.ts +0 -23
  170. package/dist/filters/special/response-apply-patch-toon-decode.js +0 -460
  171. package/dist/filters/special/response-tool-arguments-toon-decode.d.ts +0 -10
  172. package/dist/filters/special/response-tool-arguments-toon-decode.js +0 -154
  173. package/dist/servertool/flow-types.d.ts +0 -40
  174. package/dist/servertool/flow-types.js +0 -1
  175. package/dist/servertool/orchestration-types.d.ts +0 -33
  176. package/dist/servertool/orchestration-types.js +0 -1
  177. package/dist/servertool/vision-tool.d.ts +0 -2
  178. package/dist/servertool/vision-tool.js +0 -185
  179. package/dist/tools/patch-args-normalizer.d.ts +0 -15
  180. package/dist/tools/patch-args-normalizer.js +0 -472
  181. package/dist/utils/toon.d.ts +0 -4
  182. package/dist/utils/toon.js +0 -75
  183. /package/dist/{conversion/compat/actions/apply-patch-format-fixer.d.ts → servertool/handlers/apply-patch-guard.d.ts} +0 -0
  184. /package/dist/{conversion/shared/responses-types.js → servertool/handlers/exec-command-guard.d.ts} +0 -0
@@ -29,12 +29,32 @@ export function bootstrapVirtualRouterConfig(input) {
29
29
  }
30
30
  const webSearch = normalizeWebSearch(section.webSearch, routingSource);
31
31
  validateWebSearchRouting(webSearch, routingSource);
32
- const { runtimeEntries, aliasIndex } = buildProviderRuntimeEntries(providersSource);
32
+ const execCommandGuard = normalizeExecCommandGuard(section.execCommandGuard);
33
+ const { runtimeEntries, aliasIndex, modelIndex } = buildProviderRuntimeEntries(providersSource);
33
34
  const { routing, targetKeys } = expandRoutingTable(routingSource, aliasIndex);
34
35
  if (!routing.default || routing.default.length === 0) {
35
36
  throw new VirtualRouterError('Virtual Router default route must contain at least one provider target', VirtualRouterErrorCode.CONFIG_ERROR);
36
37
  }
37
- const { profiles: providerProfiles, targetRuntime } = buildProviderProfiles(targetKeys, runtimeEntries);
38
+ // Build provider profiles for:
39
+ // - routing targets (targetKeys), and
40
+ // - all declared provider models across all auth aliases (modelIndex × aliasIndex),
41
+ // so that "direct model" and "prefer model" routing can bypass route pools when needed.
42
+ const expandedTargetKeys = new Set(targetKeys);
43
+ for (const [providerId, aliases] of aliasIndex.entries()) {
44
+ const models = modelIndex.get(providerId) ?? [];
45
+ if (!models.length || !aliases.length) {
46
+ continue;
47
+ }
48
+ for (const alias of aliases) {
49
+ const runtimeKey = buildRuntimeKey(providerId, alias);
50
+ for (const modelId of models) {
51
+ if (modelId && typeof modelId === 'string') {
52
+ expandedTargetKeys.add(`${runtimeKey}.${modelId}`);
53
+ }
54
+ }
55
+ }
56
+ }
57
+ const { profiles: providerProfiles, targetRuntime } = buildProviderProfiles(expandedTargetKeys, runtimeEntries);
38
58
  const classifier = normalizeClassifier(section.classifier);
39
59
  const loadBalancing = section.loadBalancing ?? DEFAULT_LOAD_BALANCING;
40
60
  const health = section.health ?? DEFAULT_HEALTH;
@@ -46,7 +66,8 @@ export function bootstrapVirtualRouterConfig(input) {
46
66
  loadBalancing,
47
67
  health,
48
68
  contextRouting,
49
- ...(webSearch ? { webSearch } : {})
69
+ ...(webSearch ? { webSearch } : {}),
70
+ ...(execCommandGuard ? { execCommandGuard } : {})
50
71
  };
51
72
  return {
52
73
  config,
@@ -68,13 +89,17 @@ function extractVirtualRouterSection(input) {
68
89
  const health = normalizeHealth(section.health ?? root.health);
69
90
  const contextRouting = normalizeContextRouting(section.contextRouting ?? root.contextRouting);
70
91
  const webSearch = section.webSearch ?? root.webSearch;
71
- return { providers, routing, classifier, loadBalancing, health, contextRouting, webSearch };
92
+ const execCommandGuard = section.execCommandGuard ?? root.execCommandGuard;
93
+ return { providers, routing, classifier, loadBalancing, health, contextRouting, webSearch, execCommandGuard };
72
94
  }
73
95
  function buildProviderRuntimeEntries(providers) {
74
96
  const runtimeEntries = {};
75
97
  const aliasIndex = new Map();
98
+ const modelIndex = new Map();
76
99
  for (const [providerId, providerRaw] of Object.entries(providers)) {
77
100
  const normalizedProvider = normalizeProvider(providerId, providerRaw);
101
+ const modelsNode = asRecord(providerRaw?.models);
102
+ modelIndex.set(providerId, modelsNode ? Object.keys(modelsNode).filter(Boolean) : []);
78
103
  const authEntries = extractProviderAuthEntries(providerId, providerRaw);
79
104
  if (!authEntries.length) {
80
105
  throw new VirtualRouterError(`Provider ${providerId} requires at least one auth entry`, VirtualRouterErrorCode.CONFIG_ERROR);
@@ -126,7 +151,7 @@ function buildProviderRuntimeEntries(providers) {
126
151
  };
127
152
  }
128
153
  }
129
- return { runtimeEntries, aliasIndex };
154
+ return { runtimeEntries, aliasIndex, modelIndex };
130
155
  }
131
156
  function expandRoutingTable(routingSource, aliasIndex) {
132
157
  const routing = {};
@@ -690,6 +715,25 @@ function normalizeWebSearch(input, routingSource) {
690
715
  ...(force ? { force } : {})
691
716
  };
692
717
  }
718
+ function normalizeExecCommandGuard(input) {
719
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
720
+ return undefined;
721
+ }
722
+ const record = input;
723
+ const enabledRaw = record.enabled;
724
+ const enabled = enabledRaw === true ||
725
+ (typeof enabledRaw === 'string' && enabledRaw.trim().toLowerCase() === 'true') ||
726
+ (typeof enabledRaw === 'number' && enabledRaw === 1);
727
+ if (!enabled) {
728
+ return undefined;
729
+ }
730
+ const policyFileRaw = record.policyFile ?? record?.policy_file;
731
+ const policyFile = typeof policyFileRaw === 'string' && policyFileRaw.trim().length ? policyFileRaw.trim() : undefined;
732
+ return {
733
+ enabled: true,
734
+ ...(policyFile ? { policyFile } : {})
735
+ };
736
+ }
693
737
  function extractProviderAuthEntries(providerId, raw) {
694
738
  const provider = asRecord(raw);
695
739
  const auth = asRecord(provider.auth);
@@ -0,0 +1,10 @@
1
+ import type { ClassificationResult, RoutingFeatures, VirtualRouterClassifierConfig } from './types.js';
2
+ export declare class RoutingClassifier {
3
+ private readonly config;
4
+ constructor(config: VirtualRouterClassifierConfig);
5
+ classify(features: RoutingFeatures): ClassificationResult;
6
+ private buildResult;
7
+ private ensureDefaultCandidate;
8
+ private orderRoutes;
9
+ private routeWeight;
10
+ }
@@ -76,13 +76,41 @@ export function selectProviderImpl(requestedRoute, metadata, classification, fea
76
76
  }
77
77
  }
78
78
  if (forcedKeySet.size > 0) {
79
- const candidates = buildRouteCandidates(requestedRoute, classification.candidates, features, deps.routing, deps.providerRegistry);
79
+ const candidates = extendRouteCandidatesForState(buildRouteCandidates(requestedRoute, classification.candidates, features, deps.routing, deps.providerRegistry), state, deps.routing);
80
80
  const filteredCandidates = filterCandidatesByRoutingState(candidates, state, deps.routing, deps.providerRegistry);
81
81
  if (filteredCandidates.length === 0) {
82
- throw new VirtualRouterError('No available providers after applying routing instructions', VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, {
82
+ const allowedProviders = Array.from(state.allowedProviders);
83
+ const disabledProviders = Array.from(state.disabledProviders);
84
+ const providersInRouting = new Set();
85
+ for (const pools of Object.values(deps.routing)) {
86
+ if (!Array.isArray(pools))
87
+ continue;
88
+ for (const pool of pools) {
89
+ if (!pool || !Array.isArray(pool.targets))
90
+ continue;
91
+ for (const key of pool.targets) {
92
+ if (typeof key !== 'string' || !key)
93
+ continue;
94
+ const providerId = extractProviderId(key);
95
+ if (providerId) {
96
+ providersInRouting.add(providerId);
97
+ }
98
+ }
99
+ }
100
+ }
101
+ const missingAllowedProviders = allowedProviders.length > 0 ? allowedProviders.filter((provider) => !providersInRouting.has(provider)) : [];
102
+ const hint = (() => {
103
+ if (missingAllowedProviders.length > 0) {
104
+ return `Allowed providers not present in routing pools: ${missingAllowedProviders.join(', ')}`;
105
+ }
106
+ return 'Routing instructions excluded all route candidates';
107
+ })();
108
+ throw new VirtualRouterError(`No available providers after applying routing instructions (${hint}). ` +
109
+ `Tip: remove/adjust <**...**> routing instructions (or use <**clear**>), or add providers/models to routing.`, VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, {
83
110
  requestedRoute,
84
- allowedProviders: Array.from(state.allowedProviders),
85
- disabledProviders: Array.from(state.disabledProviders)
111
+ allowedProviders,
112
+ disabledProviders,
113
+ missingAllowedProviders
86
114
  });
87
115
  }
88
116
  return selectFromCandidates(filteredCandidates, metadata, classification, features, state, deps, {
@@ -98,18 +126,65 @@ export function selectProviderImpl(requestedRoute, metadata, classification, fea
98
126
  }
99
127
  }
100
128
  const candidates = buildRouteCandidates(requestedRoute, classification.candidates, features, deps.routing, deps.providerRegistry);
101
- const filteredCandidates = filterCandidatesByRoutingState(candidates, state, deps.routing, deps.providerRegistry);
129
+ const expandedCandidates = extendRouteCandidatesForState(candidates, state, deps.routing);
130
+ const filteredCandidates = filterCandidatesByRoutingState(expandedCandidates, state, deps.routing, deps.providerRegistry);
102
131
  if (filteredCandidates.length === 0) {
103
- throw new VirtualRouterError('No available providers after applying routing instructions', VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, {
132
+ const allowedProviders = Array.from(state.allowedProviders);
133
+ const disabledProviders = Array.from(state.disabledProviders);
134
+ const providersInRouting = new Set();
135
+ for (const pools of Object.values(deps.routing)) {
136
+ if (!Array.isArray(pools))
137
+ continue;
138
+ for (const pool of pools) {
139
+ if (!pool || !Array.isArray(pool.targets))
140
+ continue;
141
+ for (const key of pool.targets) {
142
+ if (typeof key !== 'string' || !key)
143
+ continue;
144
+ const providerId = extractProviderId(key);
145
+ if (providerId) {
146
+ providersInRouting.add(providerId);
147
+ }
148
+ }
149
+ }
150
+ }
151
+ const missingAllowedProviders = allowedProviders.length > 0 ? allowedProviders.filter((provider) => !providersInRouting.has(provider)) : [];
152
+ const hint = (() => {
153
+ if (missingAllowedProviders.length > 0) {
154
+ return `Allowed providers not present in routing pools: ${missingAllowedProviders.join(', ')}`;
155
+ }
156
+ return 'Routing instructions excluded all route candidates';
157
+ })();
158
+ throw new VirtualRouterError(`No available providers after applying routing instructions (${hint}). ` +
159
+ `Tip: remove/adjust <**...**> routing instructions (or use <**clear**>), or add providers/models to routing.`, VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, {
104
160
  requestedRoute,
105
- allowedProviders: Array.from(state.allowedProviders),
106
- disabledProviders: Array.from(state.disabledProviders)
161
+ allowedProviders,
162
+ disabledProviders,
163
+ missingAllowedProviders
107
164
  });
108
165
  }
109
166
  return selectFromCandidates(filteredCandidates, metadata, classification, features, state, deps, {
110
167
  allowAliasRotation
111
168
  });
112
169
  }
170
+ function extendRouteCandidatesForState(candidates, state, routing) {
171
+ // When provider allowlists are active (e.g. "<**!glm**>"), routing should not be bounded by
172
+ // classifier candidates only. Otherwise, a perfectly valid provider that exists in config
173
+ // (e.g. in a backup/default pool) can become unreachable and cause PROVIDER_NOT_AVAILABLE.
174
+ //
175
+ // We keep original ordering, then append all known routes (by priority) as a fallback search space.
176
+ if (!state.allowedProviders || state.allowedProviders.size === 0) {
177
+ return candidates;
178
+ }
179
+ const allRoutes = sortByPriority(Object.keys(routing).filter((routeName) => routeName && routeHasTargets(routing[routeName])));
180
+ const expanded = Array.isArray(candidates) ? [...candidates] : [];
181
+ for (const routeName of allRoutes) {
182
+ if (!expanded.includes(routeName)) {
183
+ expanded.push(routeName);
184
+ }
185
+ }
186
+ return expanded;
187
+ }
113
188
  export function selectDirectProviderModel(providerId, modelId, metadata, features, activeState, deps) {
114
189
  const normalizedProvider = typeof providerId === 'string' ? providerId.trim() : '';
115
190
  const normalizedModel = typeof modelId === 'string' ? modelId.trim() : '';
@@ -220,7 +295,11 @@ function trySelectFromTier(routeName, tier, stickyKey, estimatedTokens, features
220
295
  targets = targets.filter((key) => !excludedKeys.has(key));
221
296
  }
222
297
  if (targets.length > 0) {
223
- targets = targets.filter((key) => !deps.isProviderCoolingDown(key));
298
+ const cooled = targets.filter((key) => !deps.isProviderCoolingDown(key));
299
+ // 单 provider 兜底:当一个 tier 只有一个候选 key 时,不因 cooldown 造成路由池为空。
300
+ if (cooled.length > 0 || targets.length !== 1) {
301
+ targets = cooled;
302
+ }
224
303
  }
225
304
  if (allowedProviders && allowedProviders.size > 0) {
226
305
  targets = targets.filter((key) => {
@@ -331,14 +410,22 @@ function trySelectFromTier(routeName, tier, stickyKey, estimatedTokens, features
331
410
  const selectWithQuota = (candidates) => {
332
411
  if (!quotaView) {
333
412
  if (tier.mode === 'priority') {
334
- return selectFirstAvailable(candidates);
413
+ const selected = selectFirstAvailable(candidates);
414
+ if (!selected && candidates.length === 1) {
415
+ return candidates[0];
416
+ }
417
+ return selected;
335
418
  }
336
- return deps.loadBalancer.select({
419
+ const selected = deps.loadBalancer.select({
337
420
  routeName: `${routeName}:${tier.id}`,
338
421
  candidates,
339
422
  stickyKey: options.allowAliasRotation ? undefined : stickyKey,
340
423
  availabilityCheck: (key) => deps.healthManager.isAvailable(key)
341
424
  }, tier.mode === 'round-robin' ? 'round-robin' : undefined);
425
+ if (!selected && candidates.length === 1) {
426
+ return candidates[0];
427
+ }
428
+ return selected;
342
429
  }
343
430
  const buckets = new Map();
344
431
  for (const key of candidates) {
@@ -389,6 +476,33 @@ function trySelectFromTier(routeName, tier, stickyKey, estimatedTokens, features
389
476
  }
390
477
  }
391
478
  }
479
+ // default 路由永不因 quota gating 而“空池”:
480
+ // 当 quotaView 过滤后没有任何可用候选时,默认路由允许忽略 quotaView,
481
+ // 继续按健康/负载均衡选择一个 providerKey(但不覆盖 forced/required 约束)。
482
+ const quotaBypassAllowed = routeName === DEFAULT_ROUTE && (!requiredProviderKeys || requiredProviderKeys.size === 0);
483
+ if (quotaBypassAllowed) {
484
+ if (tier.mode === 'priority') {
485
+ const selected = selectFirstAvailable(candidates);
486
+ if (selected) {
487
+ return selected;
488
+ }
489
+ }
490
+ else {
491
+ const selected = deps.loadBalancer.select({
492
+ routeName: `${routeName}:${tier.id}:quota-bypass`,
493
+ candidates,
494
+ stickyKey: options.allowAliasRotation ? undefined : stickyKey,
495
+ availabilityCheck: (key) => deps.healthManager.isAvailable(key)
496
+ }, tier.mode === 'round-robin' ? 'round-robin' : undefined);
497
+ if (selected) {
498
+ return selected;
499
+ }
500
+ }
501
+ }
502
+ // 单 provider 兜底:当只剩一个候选 key 时,不因 quota/blacklist/cooldown 或健康状态过滤导致无 provider。
503
+ if (candidates.length === 1) {
504
+ return candidates[0];
505
+ }
392
506
  return null;
393
507
  };
394
508
  for (const candidatePool of prioritizedPools) {
@@ -416,10 +530,13 @@ export function selectFromStickyPool(stickyKeySet, metadata, features, state, de
416
530
  ]));
417
531
  const disabledModels = new Map(Array.from(state.disabledModels.entries()).map(([provider, models]) => [provider, new Set(models)]));
418
532
  let candidates = Array.from(stickyKeySet).filter((key) => !deps.isProviderCoolingDown(key));
533
+ if (!candidates.length && stickyKeySet.size === 1) {
534
+ candidates = Array.from(stickyKeySet);
535
+ }
419
536
  const quotaView = deps.quotaView;
420
537
  const now = quotaView ? Date.now() : 0;
421
538
  if (quotaView) {
422
- candidates = candidates.filter((key) => {
539
+ const filtered = candidates.filter((key) => {
423
540
  const entry = quotaView(key);
424
541
  if (!entry) {
425
542
  return true;
@@ -435,6 +552,9 @@ export function selectFromStickyPool(stickyKeySet, metadata, features, state, de
435
552
  }
436
553
  return true;
437
554
  });
555
+ if (filtered.length > 0 || candidates.length !== 1) {
556
+ candidates = filtered;
557
+ }
438
558
  }
439
559
  if (allowedProviders.size > 0) {
440
560
  candidates = candidates.filter((key) => {
@@ -777,9 +897,21 @@ function resolveInstructionTarget(target, providerRegistry) {
777
897
  const alias = typeof target.keyAlias === 'string' ? target.keyAlias.trim() : '';
778
898
  const aliasExplicit = alias.length > 0 && target.pathLength === 3;
779
899
  if (aliasExplicit) {
780
- const runtimeKey = providerRegistry.resolveRuntimeKeyByAlias(providerId, alias);
781
- if (runtimeKey) {
782
- return { mode: 'exact', keys: [runtimeKey] };
900
+ const prefix = `${providerId}.${alias}.`;
901
+ const aliasKeys = providerKeys.filter((key) => key.startsWith(prefix));
902
+ if (aliasKeys.length > 0) {
903
+ if (target.model && target.model.trim()) {
904
+ const normalizedModel = target.model.trim();
905
+ const matching = aliasKeys.filter((key) => getProviderModelId(key, providerRegistry) === normalizedModel);
906
+ if (matching.length > 0) {
907
+ // Prefer exact to keep sticky pool deterministic when only one key matches.
908
+ if (matching.length === 1) {
909
+ return { mode: 'exact', keys: [matching[0]] };
910
+ }
911
+ return { mode: 'filter', keys: matching };
912
+ }
913
+ }
914
+ return { mode: 'filter', keys: aliasKeys };
783
915
  }
784
916
  }
785
917
  if (typeof target.keyIndex === 'number' && target.keyIndex > 0) {
@@ -9,7 +9,7 @@ import { getStatsCenter } from '../../telemetry/stats-center.js';
9
9
  import { parseRoutingInstructions, applyRoutingInstructions, cleanMessagesFromRoutingInstructions } from './routing-instructions.js';
10
10
  import { loadRoutingInstructionStateSync, saveRoutingInstructionStateAsync } from './sticky-session-store.js';
11
11
  import { buildHitReason, formatVirtualRouterHit } from './engine-logging.js';
12
- import { selectDirectProviderModel, selectProviderImpl } from './engine-selection.js';
12
+ import { selectDirectProviderModel, selectFromStickyPool, selectProviderImpl } from './engine-selection.js';
13
13
  import { applyQuotaDepletedImpl, applyQuotaRecoveryImpl, applySeriesCooldownImpl, handleProviderFailureImpl, mapProviderErrorImpl } from './engine-health.js';
14
14
  export class VirtualRouterEngine {
15
15
  routing = {};
@@ -105,7 +105,11 @@ export class VirtualRouterEngine {
105
105
  }
106
106
  route(request, metadata) {
107
107
  const stickyKey = this.resolveStickyKey(metadata);
108
- const baseState = this.getRoutingInstructionState(stickyKey);
108
+ const sessionScope = this.resolveSessionScope(metadata);
109
+ // Routing instructions should be session/conversation-scoped when available (including /v1/responses),
110
+ // while auto-sticky for Responses remains request-chain scoped via resolveStickyKey().
111
+ const stateKey = sessionScope || stickyKey || 'default';
112
+ const baseState = this.getRoutingInstructionState(stateKey);
109
113
  let routingState = baseState;
110
114
  const metadataInstructions = this.buildMetadataInstructions(metadata);
111
115
  if (metadataInstructions.length > 0) {
@@ -114,13 +118,13 @@ export class VirtualRouterEngine {
114
118
  const disableStickyRoutes = metadata &&
115
119
  typeof metadata === 'object' &&
116
120
  metadata.disableStickyRoutes === true;
117
- if (disableStickyRoutes && routingState.stickyTarget) {
121
+ if (disableStickyRoutes && (routingState.stickyTarget || routingState.preferTarget)) {
118
122
  routingState = {
119
123
  ...routingState,
120
- stickyTarget: undefined
124
+ stickyTarget: undefined,
125
+ preferTarget: undefined
121
126
  };
122
127
  }
123
- const sessionScope = this.resolveSessionScope(metadata);
124
128
  if (sessionScope) {
125
129
  const sessionState = this.getRoutingInstructionState(sessionScope);
126
130
  if (typeof sessionState.stopMessageText === 'string' ||
@@ -163,9 +167,8 @@ export class VirtualRouterEngine {
163
167
  }
164
168
  if (instructions.length > 0) {
165
169
  routingState = applyRoutingInstructions(instructions, routingState);
166
- const effectiveKey = stickyKey || 'default';
167
- this.routingInstructionState.set(effectiveKey, routingState);
168
- this.persistRoutingInstructionState(effectiveKey, routingState);
170
+ this.routingInstructionState.set(stateKey, routingState);
171
+ this.persistRoutingInstructionState(stateKey, routingState);
169
172
  // 对 stopMessage 指令补充一份基于 session/conversation 的持久化状态,
170
173
  // 便于 server-side 工具通过 session:*/conversation:* scope 读取到相同配置。
171
174
  if (sessionScope) {
@@ -236,12 +239,52 @@ export class VirtualRouterEngine {
236
239
  routingState.stopMessageLastUsedAt = sessionState.stopMessageLastUsedAt;
237
240
  }
238
241
  }
239
- const routingMode = this.resolveRoutingMode([...metadataInstructions, ...instructions], routingState);
242
+ // Guardrail: if a session is restricted to providers that do not exist in any routing pools,
243
+ // we must not hard-fail the request loop. Auto-clear the allowlist and fall back to normal routing.
244
+ if (routingState.allowedProviders.size > 0) {
245
+ const providersInRouting = new Set();
246
+ for (const pools of Object.values(this.routing)) {
247
+ if (!Array.isArray(pools))
248
+ continue;
249
+ for (const pool of pools) {
250
+ if (!pool || !Array.isArray(pool.targets))
251
+ continue;
252
+ for (const key of pool.targets) {
253
+ if (typeof key !== 'string' || !key)
254
+ continue;
255
+ const providerId = this.extractProviderId(key);
256
+ if (providerId) {
257
+ providersInRouting.add(providerId);
258
+ }
259
+ }
260
+ }
261
+ }
262
+ const allowed = Array.from(routingState.allowedProviders);
263
+ const hasIntersection = allowed.some((provider) => providersInRouting.has(provider));
264
+ if (!hasIntersection) {
265
+ routingState = {
266
+ ...routingState,
267
+ allowedProviders: new Set()
268
+ };
269
+ this.routingInstructionState.set(stateKey, routingState);
270
+ this.persistRoutingInstructionState(stateKey, routingState);
271
+ }
272
+ }
240
273
  const features = buildRoutingFeatures(request, metadata);
241
274
  const directProviderModel = this.parseDirectProviderModel(request?.model);
242
275
  let classification;
243
276
  let requestedRoute;
244
277
  let selection;
278
+ const selectionDeps = {
279
+ routing: this.routing,
280
+ providerRegistry: this.providerRegistry,
281
+ healthManager: this.healthManager,
282
+ contextAdvisor: this.contextAdvisor,
283
+ loadBalancer: this.loadBalancer,
284
+ isProviderCoolingDown: (key) => this.isProviderCoolingDown(key),
285
+ resolveStickyKey: (m) => this.resolveStickyKey(m),
286
+ quotaView: this.quotaView
287
+ };
245
288
  if (directProviderModel) {
246
289
  const providerKeys = this.providerRegistry.listProviderKeys(directProviderModel.providerId);
247
290
  let hasModel = false;
@@ -268,33 +311,129 @@ export class VirtualRouterEngine {
268
311
  candidates: ['direct']
269
312
  };
270
313
  requestedRoute = 'direct';
271
- const directSelection = selectDirectProviderModel(directProviderModel.providerId, directProviderModel.modelId, metadata, features, routingState, {
272
- routing: this.routing,
273
- providerRegistry: this.providerRegistry,
274
- healthManager: this.healthManager,
275
- contextAdvisor: this.contextAdvisor,
276
- loadBalancer: this.loadBalancer,
277
- isProviderCoolingDown: (key) => this.isProviderCoolingDown(key),
278
- resolveStickyKey: (m) => this.resolveStickyKey(m),
279
- quotaView: this.quotaView
280
- });
314
+ const directSelection = selectDirectProviderModel(directProviderModel.providerId, directProviderModel.modelId, metadata, features, routingState, selectionDeps);
281
315
  if (!directSelection) {
282
316
  throw new VirtualRouterError(`All providers unavailable for model ${directProviderModel.providerId}.${directProviderModel.modelId}`, VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, { providerId: directProviderModel.providerId, modelId: directProviderModel.modelId });
283
317
  }
284
318
  selection = directSelection;
285
319
  }
286
320
  else {
287
- classification = metadata.routeHint && metadata.routeHint.trim()
288
- ? {
289
- routeName: metadata.routeHint.trim(),
290
- confidence: 1,
291
- reasoning: `route_hint:${metadata.routeHint.trim()}`,
292
- fallback: false,
293
- candidates: [metadata.routeHint.trim()]
321
+ // Prefer target (from "<**!provider.model**>") is evaluated before routing classification.
322
+ const preferTarget = routingState.preferTarget;
323
+ if (preferTarget && typeof preferTarget.provider === 'string' && preferTarget.provider.trim()) {
324
+ const providerId = preferTarget.provider.trim();
325
+ const keyAlias = typeof preferTarget.keyAlias === 'string' ? preferTarget.keyAlias.trim() : '';
326
+ const modelId = typeof preferTarget.model === 'string' ? preferTarget.model.trim() : '';
327
+ const keyIndex = typeof preferTarget.keyIndex === 'number' && Number.isFinite(preferTarget.keyIndex)
328
+ ? Math.floor(preferTarget.keyIndex)
329
+ : undefined;
330
+ const candidateKeys = [];
331
+ if (keyIndex !== undefined && keyIndex > 0) {
332
+ const runtimeKey = this.providerRegistry.resolveRuntimeKeyByIndex(providerId, keyIndex);
333
+ if (runtimeKey) {
334
+ candidateKeys.push(runtimeKey);
335
+ }
294
336
  }
295
- : this.classifier.classify(features);
296
- requestedRoute = this.normalizeRouteAlias(classification.routeName || DEFAULT_ROUTE);
297
- selection = this.selectProvider(requestedRoute, metadata, classification, features, routingState);
337
+ else if (modelId) {
338
+ const allKeys = this.providerRegistry.listProviderKeys(providerId);
339
+ for (const key of allKeys) {
340
+ if (keyAlias) {
341
+ const prefix = `${providerId}.${keyAlias}.`;
342
+ if (!key.startsWith(prefix)) {
343
+ continue;
344
+ }
345
+ }
346
+ try {
347
+ const profile = this.providerRegistry.get(key);
348
+ if (profile?.modelId === modelId) {
349
+ candidateKeys.push(key);
350
+ }
351
+ }
352
+ catch {
353
+ continue;
354
+ }
355
+ }
356
+ }
357
+ const allowAliasRotation = !keyAlias && keyIndex === undefined;
358
+ const eligibleKeys = (() => {
359
+ if (candidateKeys.length === 0) {
360
+ return [];
361
+ }
362
+ const quotaView = selectionDeps.quotaView;
363
+ const now = quotaView ? Date.now() : 0;
364
+ return candidateKeys.filter((key) => {
365
+ if (this.isProviderCoolingDown(key)) {
366
+ return false;
367
+ }
368
+ if (!this.healthManager.isAvailable(key)) {
369
+ return false;
370
+ }
371
+ if (!quotaView) {
372
+ return true;
373
+ }
374
+ const entry = quotaView(key);
375
+ if (!entry) {
376
+ return true;
377
+ }
378
+ if (!entry.inPool) {
379
+ return false;
380
+ }
381
+ if (entry.cooldownUntil && entry.cooldownUntil > now) {
382
+ return false;
383
+ }
384
+ if (entry.blacklistUntil && entry.blacklistUntil > now) {
385
+ return false;
386
+ }
387
+ return true;
388
+ });
389
+ })();
390
+ const preferSelection = eligibleKeys.length > 0
391
+ ? selectFromStickyPool(new Set(eligibleKeys), metadata, features, routingState, selectionDeps, {
392
+ allowAliasRotation
393
+ })
394
+ : null;
395
+ if (preferSelection) {
396
+ classification = {
397
+ routeName: 'prefer',
398
+ confidence: 1,
399
+ reasoning: keyIndex !== undefined ? `prefer_key:${providerId}.${keyIndex}` : `prefer_model:${providerId}.${modelId}`,
400
+ fallback: false,
401
+ candidates: ['prefer']
402
+ };
403
+ requestedRoute = 'prefer';
404
+ selection = {
405
+ ...preferSelection,
406
+ routeUsed: 'prefer',
407
+ poolId: 'prefer-primary'
408
+ };
409
+ }
410
+ else if (routingState.preferTarget) {
411
+ // Auto-clear only when the target becomes invalid or blocked by explicit routing instructions.
412
+ // Do NOT clear for temporary unavailability (e.g. 429 cooldown, quota cooldown, transient health).
413
+ const shouldAutoClear = candidateKeys.length === 0 || eligibleKeys.length > 0;
414
+ if (shouldAutoClear) {
415
+ routingState = {
416
+ ...routingState,
417
+ preferTarget: undefined
418
+ };
419
+ this.routingInstructionState.set(stateKey, routingState);
420
+ this.persistRoutingInstructionState(stateKey, routingState);
421
+ }
422
+ }
423
+ }
424
+ if (!selection) {
425
+ classification = metadata.routeHint && metadata.routeHint.trim()
426
+ ? {
427
+ routeName: metadata.routeHint.trim(),
428
+ confidence: 1,
429
+ reasoning: `route_hint:${metadata.routeHint.trim()}`,
430
+ fallback: false,
431
+ candidates: [metadata.routeHint.trim()]
432
+ }
433
+ : this.classifier.classify(features);
434
+ requestedRoute = this.normalizeRouteAlias(classification.routeName || DEFAULT_ROUTE);
435
+ selection = this.selectProvider(requestedRoute, metadata, classification, features, routingState);
436
+ }
298
437
  }
299
438
  const baseTarget = this.providerRegistry.buildTarget(selection.providerKey);
300
439
  const forceVision = this.routeHasForceFlag('vision');
@@ -304,6 +443,7 @@ export class VirtualRouterEngine {
304
443
  ...(forceVision ? { forceVision: true } : {})
305
444
  };
306
445
  this.incrementRouteStat(selection.routeUsed, selection.providerKey);
446
+ const routingMode = this.resolveRoutingMode([...metadataInstructions, ...instructions], routingState);
307
447
  try {
308
448
  this.statsCenter.recordVirtualRouterHit({
309
449
  requestId: metadata.requestId,
@@ -577,7 +717,7 @@ export class VirtualRouterEngine {
577
717
  // 能在 VirtualRouter 日志中实时反映出来。
578
718
  if (existing && (key.startsWith('session:') || key.startsWith('conversation:'))) {
579
719
  try {
580
- const persisted = loadRoutingInstructionStateSync(key);
720
+ const persisted = this.routingStateStore.loadSync(key);
581
721
  if (persisted) {
582
722
  // 以持久化状态为准(包括清空后的 undefined),避免 stopMessage 状态“卡死”在内存中。
583
723
  existing.stopMessageText = persisted.stopMessageText;
@@ -603,7 +743,7 @@ export class VirtualRouterEngine {
603
743
  let initial = null;
604
744
  // 仅对 session:/conversation: 作用域的 key 尝试从磁盘恢复持久化状态
605
745
  if (key.startsWith('session:') || key.startsWith('conversation:')) {
606
- initial = loadRoutingInstructionStateSync(key);
746
+ initial = this.routingStateStore.loadSync(key);
607
747
  }
608
748
  if (!initial) {
609
749
  initial = {
@@ -661,6 +801,7 @@ export class VirtualRouterEngine {
661
801
  const hasForce = instructions.some((inst) => inst.type === 'force');
662
802
  const hasAllow = instructions.some((inst) => inst.type === 'allow');
663
803
  const hasClear = instructions.some((inst) => inst.type === 'clear');
804
+ const hasPrefer = instructions.some((inst) => inst.type === 'prefer');
664
805
  if (hasClear) {
665
806
  return 'none';
666
807
  }
@@ -670,6 +811,9 @@ export class VirtualRouterEngine {
670
811
  if (hasForce || state.forcedTarget) {
671
812
  return 'force';
672
813
  }
814
+ if (hasPrefer || state.preferTarget) {
815
+ return 'sticky';
816
+ }
673
817
  if (state.stickyTarget) {
674
818
  return 'sticky';
675
819
  }
@@ -1114,6 +1258,7 @@ export class VirtualRouterEngine {
1114
1258
  }
1115
1259
  const noForced = !state.forcedTarget;
1116
1260
  const noSticky = !state.stickyTarget;
1261
+ const noPrefer = !state.preferTarget;
1117
1262
  const noAllowed = state.allowedProviders.size === 0;
1118
1263
  const noDisabledProviders = state.disabledProviders.size === 0;
1119
1264
  const noDisabledKeys = state.disabledKeys.size === 0;
@@ -1125,6 +1270,7 @@ export class VirtualRouterEngine {
1125
1270
  (typeof state.stopMessageLastUsedAt !== 'number' || !Number.isFinite(state.stopMessageLastUsedAt));
1126
1271
  return (noForced &&
1127
1272
  noSticky &&
1273
+ noPrefer &&
1128
1274
  noAllowed &&
1129
1275
  noDisabledProviders &&
1130
1276
  noDisabledKeys &&