@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
@@ -0,0 +1,10 @@
1
+ import type { ProviderErrorEvent } from './types.js';
2
+ type ProviderErrorListener = (event: ProviderErrorEvent) => void;
3
+ export declare class ProviderErrorCenter {
4
+ private readonly listeners;
5
+ subscribe(listener: ProviderErrorListener): () => void;
6
+ emit(event: ProviderErrorEvent): ProviderErrorEvent;
7
+ private normalize;
8
+ }
9
+ export declare const providerErrorCenter: ProviderErrorCenter;
10
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { ProcessedRequest, StandardizedRequest } from '../../conversion/hub/types/standardized.js';
2
+ import type { RouterMetadataInput, RoutingFeatures } from './types.js';
3
+ export declare function buildRoutingFeatures(request: StandardizedRequest | ProcessedRequest, metadata: RouterMetadataInput): RoutingFeatures;
@@ -1,6 +1,6 @@
1
1
  import type { StandardizedMessage } from '../../conversion/hub/types/standardized.js';
2
2
  export interface RoutingInstruction {
3
- type: 'force' | 'sticky' | 'disable' | 'enable' | 'clear' | 'allow' | 'stopMessageSet' | 'stopMessageClear';
3
+ type: 'force' | 'sticky' | 'prefer' | 'disable' | 'enable' | 'clear' | 'allow' | 'stopMessageSet' | 'stopMessageClear';
4
4
  provider?: string;
5
5
  keyAlias?: string;
6
6
  keyIndex?: number;
@@ -24,6 +24,13 @@ export interface RoutingInstructionState {
24
24
  model?: string;
25
25
  pathLength?: number;
26
26
  };
27
+ preferTarget?: {
28
+ provider?: string;
29
+ keyAlias?: string;
30
+ keyIndex?: number;
31
+ model?: string;
32
+ pathLength?: number;
33
+ };
27
34
  allowedProviders: Set<string>;
28
35
  disabledProviders: Set<string>;
29
36
  disabledKeys: Map<string, Set<string | number>>;
@@ -41,6 +48,21 @@ export interface RoutingInstructionState {
41
48
  stopMessageLastUsedAt?: number;
42
49
  }
43
50
  export declare function parseRoutingInstructions(messages: StandardizedMessage[]): RoutingInstruction[];
51
+ /**
52
+ * 解析并预处理路由指令,优先处理 clear 指令,确保新指令能够覆盖旧状态。
53
+ * 返回清理后的指令列表,移除冗余的 stopMessageSet 指令。
54
+ */
55
+ export declare function parseAndPreprocessRoutingInstructions(messages: StandardizedMessage[]): RoutingInstruction[];
56
+ /**
57
+ * 提取 clear 指令(如果存在)。用于在路由选择前优先执行清理操作。
58
+ * @returns 是否存在 clear 指令
59
+ */
60
+ export declare function extractClearInstruction(messages: StandardizedMessage[]): boolean;
61
+ /**
62
+ * 提取 stopMessageClear 指令(如果存在)。
63
+ * @returns 是否存在 stopMessageClear 指令
64
+ */
65
+ export declare function extractStopMessageClearInstruction(messages: StandardizedMessage[]): boolean;
44
66
  export declare function applyRoutingInstructions(instructions: RoutingInstruction[], currentState: RoutingInstructionState): RoutingInstructionState;
45
67
  export declare function cleanMessagesFromRoutingInstructions(messages: StandardizedMessage[]): StandardizedMessage[];
46
68
  export declare function serializeRoutingInstructionState(state: RoutingInstructionState): Record<string, unknown>;
@@ -2,8 +2,8 @@ import { extractMessageText } from './message-utils.js';
2
2
  export function parseRoutingInstructions(messages) {
3
3
  const instructions = [];
4
4
  // 从最新一条携带路由指令标记(<** ... **>)的 user 消息中解析指令,
5
- // 而不是简单地取“最后一条 user 消息”。这样可以在服务重启后,通过完整
6
- // 会话历史恢复 sticky/黑名单状态,同时保持“最后一次指令生效”的语义。
5
+ // 而不是简单地取"最后一条 user 消息"。这样可以在服务重启后,通过完整
6
+ // 会话历史恢复 sticky/黑名单状态,同时保持"最后一次指令生效"的语义。
7
7
  let sanitized = null;
8
8
  for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
9
9
  const message = messages[idx];
@@ -44,6 +44,44 @@ export function parseRoutingInstructions(messages) {
44
44
  }
45
45
  return instructions;
46
46
  }
47
+ /**
48
+ * 解析并预处理路由指令,优先处理 clear 指令,确保新指令能够覆盖旧状态。
49
+ * 返回清理后的指令列表,移除冗余的 stopMessageSet 指令。
50
+ */
51
+ export function parseAndPreprocessRoutingInstructions(messages) {
52
+ const rawInstructions = parseRoutingInstructions(messages);
53
+ if (rawInstructions.length === 0) {
54
+ return [];
55
+ }
56
+ // 检查是否有 clear 指令
57
+ const hasClear = rawInstructions.some(inst => inst.type === 'clear');
58
+ if (!hasClear) {
59
+ return rawInstructions;
60
+ }
61
+ // 如果有 clear 指令,clear 之后的指令才有效,clear 之前的指令被清除
62
+ const clearIndex = rawInstructions.findIndex(inst => inst.type === 'clear');
63
+ const effectiveInstructions = rawInstructions.slice(clearIndex + 1);
64
+ // 移除 clear 后冗余的 stopMessageSet 指令(如果与原指令相同)
65
+ // 这里的逻辑会在 applyRoutingInstructions 中处理,
66
+ // 所以我们只需要返回 clear 之后的指令即可
67
+ return effectiveInstructions;
68
+ }
69
+ /**
70
+ * 提取 clear 指令(如果存在)。用于在路由选择前优先执行清理操作。
71
+ * @returns 是否存在 clear 指令
72
+ */
73
+ export function extractClearInstruction(messages) {
74
+ const instructions = parseRoutingInstructions(messages);
75
+ return instructions.some((inst) => inst.type === 'clear');
76
+ }
77
+ /**
78
+ * 提取 stopMessageClear 指令(如果存在)。
79
+ * @returns 是否存在 stopMessageClear 指令
80
+ */
81
+ export function extractStopMessageClearInstruction(messages) {
82
+ const instructions = parseRoutingInstructions(messages);
83
+ return instructions.some((inst) => inst.type === 'stopMessageClear');
84
+ }
47
85
  function expandInstructionSegments(instruction) {
48
86
  const trimmed = instruction.trim();
49
87
  if (!trimmed) {
@@ -153,9 +191,10 @@ function parseSingleInstruction(instruction) {
153
191
  }
154
192
  // 约定:
155
193
  // - "!providerA,providerB":允许列表(whitelist),用于快速限制可用 provider 集合;
156
- // - "!provider.model" / "!provider.alias.model" / "!provider.2":sticky 语义,按 provider / alias / model 过滤当前路由池。
194
+ // - "!provider.model" / "!provider[alias].model" / "!provider.2":prefer 语义;
195
+ // model 可用时只命中该 model(忽略路由),不可用则自动清除并回退到正常路由命中。
157
196
  //
158
- // 这样可以在不破坏既有 "!glm,openai" 语义的前提下,引入基于模型 / alias 的 sticky 行为。
197
+ // 这样可以在不破坏既有 "!glm,openai" 语义的前提下,引入基于模型的优先命中行为。
159
198
  if (!target.includes('.')) {
160
199
  if (parsed.provider) {
161
200
  return { type: 'allow', provider: parsed.provider, pathLength: parsed.pathLength };
@@ -163,7 +202,7 @@ function parseSingleInstruction(instruction) {
163
202
  return null;
164
203
  }
165
204
  const normalized = normalizeStickyOrForceTarget(parsed);
166
- return { type: 'sticky', ...normalized };
205
+ return { type: 'prefer', ...normalized };
167
206
  }
168
207
  else if (instruction.startsWith('#')) {
169
208
  const target = instruction.substring(1).trim();
@@ -202,38 +241,71 @@ function parseTarget(target) {
202
241
  if (!target) {
203
242
  return null;
204
243
  }
205
- const parts = target.split('.');
206
- const pathLength = parts.length;
207
- if (parts.length === 0) {
208
- return null;
209
- }
210
- const provider = parts[0];
211
- if (!provider || !isValidIdentifier(provider)) {
212
- return null;
213
- }
214
- if (parts.length === 1) {
215
- return { provider, pathLength };
216
- }
217
- if (parts.length === 2) {
218
- const second = parts[1];
219
- const keyIndex = parseInt(second, 10);
220
- if (!isNaN(keyIndex) && keyIndex > 0) {
221
- return { provider, keyIndex, pathLength };
244
+ // Accept "provider[alias].model" (as printed in virtual-router-hit logs) to avoid users
245
+ // needing to translate bracket notation back to dot notation manually.
246
+ // With the alias disambiguated, allow dots in model ids (e.g. gpt-5.2) without ambiguity.
247
+ const bracketMatch = target.match(/^([a-zA-Z0-9_-]+)\[([a-zA-Z0-9_-]*)\](?:\.(.+))?$/);
248
+ if (bracketMatch) {
249
+ const provider = bracketMatch[1];
250
+ const keyAliasRaw = bracketMatch[2];
251
+ const keyAlias = typeof keyAliasRaw === 'string' ? keyAliasRaw.trim() : '';
252
+ const model = typeof bracketMatch[3] === 'string' ? bracketMatch[3].trim() : '';
253
+ if (!provider || !isValidIdentifier(provider)) {
254
+ return null;
255
+ }
256
+ // Allow omitting the alias: "provider[].model" means "provider.model across all aliases".
257
+ // This also enables disambiguating model ids that contain dots, without requiring the user
258
+ // to specify the alias.
259
+ if (!keyAlias) {
260
+ if (!model) {
261
+ return { provider, pathLength: 1 };
262
+ }
263
+ if (!/^[a-zA-Z0-9_.-]+$/.test(model)) {
264
+ return null;
265
+ }
266
+ return { provider, model, pathLength: 2 };
267
+ }
268
+ if (!isValidIdentifier(keyAlias)) {
269
+ return null;
222
270
  }
223
- if (isValidIdentifier(second)) {
224
- return { provider, model: second, keyAlias: second, pathLength };
271
+ if (!model) {
272
+ // Treat as explicit alias selection. Use pathLength=3 so engine resolves keyAlias directly.
273
+ return { provider, keyAlias, pathLength: 3 };
225
274
  }
275
+ // Model ids may contain dots (e.g. gpt-5.2); allow them here because alias is already explicit.
276
+ if (!/^[a-zA-Z0-9_.-]+$/.test(model)) {
277
+ return null;
278
+ }
279
+ return { provider, keyAlias, model, pathLength: 3 };
280
+ }
281
+ // Dot syntax: align with config parsing used by routing entries.
282
+ // - "provider.modelId" -> modelId may contain dots; it always means model across all aliases.
283
+ // - Key/alias selection must use bracket syntax: "provider[alias]" or "provider[alias].modelId".
284
+ const firstDot = target.indexOf('.');
285
+ if (firstDot < 0) {
286
+ const provider = target.trim();
287
+ if (!provider || !isValidIdentifier(provider)) {
288
+ return null;
289
+ }
290
+ return { provider, pathLength: 1 };
291
+ }
292
+ const provider = target.slice(0, firstDot).trim();
293
+ const remainder = target.slice(firstDot + 1).trim();
294
+ if (!provider || !isValidIdentifier(provider) || !remainder) {
226
295
  return null;
227
296
  }
228
- if (parts.length === 3) {
229
- const keyAlias = parts[1];
230
- const model = parts[2];
231
- if (isValidIdentifier(keyAlias) && isValidIdentifier(model)) {
232
- return { provider, keyAlias, model, pathLength };
297
+ // Support "provider.2" key-index notation (only when remainder is a plain integer).
298
+ if (/^\d+$/.test(remainder)) {
299
+ const keyIndex = Number.parseInt(remainder, 10);
300
+ if (Number.isFinite(keyIndex) && keyIndex > 0) {
301
+ return { provider, keyIndex, pathLength: 2 };
233
302
  }
303
+ }
304
+ // Treat everything after the first dot as the model id, allowing dots.
305
+ if (!/^[a-zA-Z0-9_.-]+$/.test(remainder)) {
234
306
  return null;
235
307
  }
236
- return null;
308
+ return { provider, model: remainder, pathLength: 2 };
237
309
  }
238
310
  function normalizeStickyOrForceTarget(target) {
239
311
  if (target &&
@@ -269,6 +341,7 @@ export function applyRoutingInstructions(instructions, currentState) {
269
341
  const newState = {
270
342
  forcedTarget: currentState.forcedTarget ? { ...currentState.forcedTarget } : undefined,
271
343
  stickyTarget: currentState.stickyTarget ? { ...currentState.stickyTarget } : undefined,
344
+ preferTarget: currentState.preferTarget ? { ...currentState.preferTarget } : undefined,
272
345
  allowedProviders: new Set(currentState.allowedProviders),
273
346
  disabledProviders: new Set(currentState.disabledProviders),
274
347
  disabledKeys: new Map(Array.from(currentState.disabledKeys.entries()).map(([k, v]) => [k, new Set(v)])),
@@ -304,6 +377,17 @@ export function applyRoutingInstructions(instructions, currentState) {
304
377
  };
305
378
  newState.forcedTarget = undefined;
306
379
  break;
380
+ case 'prefer':
381
+ newState.preferTarget = {
382
+ provider: instruction.provider,
383
+ keyAlias: instruction.keyAlias,
384
+ keyIndex: instruction.keyIndex,
385
+ model: instruction.model,
386
+ pathLength: instruction.pathLength
387
+ };
388
+ newState.forcedTarget = undefined;
389
+ newState.stickyTarget = undefined;
390
+ break;
307
391
  case 'allow':
308
392
  if (!allowReset) {
309
393
  newState.allowedProviders.clear();
@@ -385,6 +469,7 @@ export function applyRoutingInstructions(instructions, currentState) {
385
469
  case 'clear':
386
470
  newState.forcedTarget = undefined;
387
471
  newState.stickyTarget = undefined;
472
+ newState.preferTarget = undefined;
388
473
  newState.allowedProviders.clear();
389
474
  newState.disabledProviders.clear();
390
475
  newState.disabledKeys.clear();
@@ -452,6 +537,7 @@ export function serializeRoutingInstructionState(state) {
452
537
  return {
453
538
  forcedTarget: state.forcedTarget,
454
539
  stickyTarget: state.stickyTarget,
540
+ preferTarget: state.preferTarget,
455
541
  allowedProviders: Array.from(state.allowedProviders),
456
542
  disabledProviders: Array.from(state.disabledProviders),
457
543
  disabledKeys: Array.from(state.disabledKeys.entries()).map(([provider, keys]) => ({
@@ -486,6 +572,7 @@ export function deserializeRoutingInstructionState(data) {
486
572
  const state = {
487
573
  forcedTarget: undefined,
488
574
  stickyTarget: undefined,
575
+ preferTarget: undefined,
489
576
  allowedProviders: new Set(),
490
577
  disabledProviders: new Set(),
491
578
  disabledKeys: new Map(),
@@ -500,6 +587,9 @@ export function deserializeRoutingInstructionState(data) {
500
587
  if (data.stickyTarget && typeof data.stickyTarget === 'object') {
501
588
  state.stickyTarget = data.stickyTarget;
502
589
  }
590
+ if (data.preferTarget && typeof data.preferTarget === 'object') {
591
+ state.preferTarget = data.preferTarget;
592
+ }
503
593
  if (Array.isArray(data.allowedProviders)) {
504
594
  state.allowedProviders = new Set(data.allowedProviders);
505
595
  }
@@ -127,6 +127,15 @@ export interface VirtualRouterWebSearchConfig {
127
127
  */
128
128
  force?: boolean;
129
129
  }
130
+ export interface VirtualRouterExecCommandGuardConfig {
131
+ enabled: boolean;
132
+ /**
133
+ * Optional JSON policy file path for additional deny rules.
134
+ * When enabled=true but policyFile is missing/empty/unreadable,
135
+ * llmswitch-core will still apply baseline "must-deny" rules.
136
+ */
137
+ policyFile?: string;
138
+ }
130
139
  export interface VirtualRouterConfig {
131
140
  routing: RoutingPools;
132
141
  providers: Record<string, ProviderProfile>;
@@ -135,6 +144,7 @@ export interface VirtualRouterConfig {
135
144
  health?: ProviderHealthConfig;
136
145
  contextRouting?: VirtualRouterContextRoutingConfig;
137
146
  webSearch?: VirtualRouterWebSearchConfig;
147
+ execCommandGuard?: VirtualRouterExecCommandGuardConfig;
138
148
  }
139
149
  export interface VirtualRouterContextRoutingConfig {
140
150
  warnRatio: number;
@@ -150,6 +160,7 @@ export interface VirtualRouterBootstrapInput extends Record<string, unknown> {
150
160
  health?: ProviderHealthConfig;
151
161
  contextRouting?: VirtualRouterContextRoutingConfig;
152
162
  webSearch?: VirtualRouterWebSearchConfig | Record<string, unknown>;
163
+ execCommandGuard?: VirtualRouterExecCommandGuardConfig | Record<string, unknown>;
153
164
  }
154
165
  export type ProviderRuntimeMap = Record<string, ProviderRuntimeProfile>;
155
166
  export interface VirtualRouterBootstrapResult {
@@ -58,6 +58,101 @@ function createServerToolTimeoutError(options) {
58
58
  err.status = 504;
59
59
  return err;
60
60
  }
61
+ function coerceFollowupPayloadStream(payload, stream) {
62
+ if (!payload || typeof payload !== 'object') {
63
+ return payload;
64
+ }
65
+ // ServerTool followup requests must be non-streaming to keep parsing deterministic and avoid
66
+ // provider-side SSE wrappers leaking into internal reenter calls.
67
+ if (stream === false) {
68
+ payload.stream = false;
69
+ }
70
+ return payload;
71
+ }
72
+ function hasNonEmptyText(value) {
73
+ if (typeof value === 'string') {
74
+ return value.trim().length > 0;
75
+ }
76
+ if (Array.isArray(value)) {
77
+ return value.some((entry) => hasNonEmptyText(entry));
78
+ }
79
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
80
+ const record = value;
81
+ if (hasNonEmptyText(record.text))
82
+ return true;
83
+ if (hasNonEmptyText(record.output_text))
84
+ return true;
85
+ if (hasNonEmptyText(record.content))
86
+ return true;
87
+ }
88
+ return false;
89
+ }
90
+ function isEmptyClientResponsePayload(payload) {
91
+ if (!payload || typeof payload !== 'object') {
92
+ return true;
93
+ }
94
+ // If upstream returned an explicit error shape, treat as non-empty (caller should surface it).
95
+ if (Object.prototype.hasOwnProperty.call(payload, 'error')) {
96
+ return false;
97
+ }
98
+ // OpenAI Responses: requires_action (function_call output) is a meaningful response and must not be
99
+ // treated as "empty". Some auto-followup servertools (stop_message_flow / gemini_empty_reply_continue)
100
+ // previously misclassified this as empty because there is no output_text/content yet.
101
+ const requiredAction = payload.required_action;
102
+ if (requiredAction && typeof requiredAction === 'object') {
103
+ return false;
104
+ }
105
+ const outputForResponses = Array.isArray(payload.output) ? payload.output : [];
106
+ if (outputForResponses.length > 0) {
107
+ for (const item of outputForResponses) {
108
+ if (!item || typeof item !== 'object' || Array.isArray(item))
109
+ continue;
110
+ const type = typeof item.type === 'string' ? String(item.type).trim().toLowerCase() : '';
111
+ if (type === 'function_call' || type === 'tool_call' || type === 'tool_use' || type.includes('tool')) {
112
+ return false;
113
+ }
114
+ }
115
+ }
116
+ const choices = Array.isArray(payload.choices) ? payload.choices : [];
117
+ if (choices.length > 0) {
118
+ const first = choices[0] && typeof choices[0] === 'object' && !Array.isArray(choices[0]) ? choices[0] : null;
119
+ const message = first && typeof first.message === 'object' && first.message !== null && !Array.isArray(first.message)
120
+ ? first.message
121
+ : null;
122
+ if (!message) {
123
+ return true;
124
+ }
125
+ const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
126
+ if (toolCalls.length > 0) {
127
+ return false;
128
+ }
129
+ // Support common chat shapes: content string/array, reasoning_content, etc.
130
+ if (hasNonEmptyText(message.content))
131
+ return false;
132
+ if (hasNonEmptyText(message.reasoning_content))
133
+ return false;
134
+ if (hasNonEmptyText(message.reasoning))
135
+ return false;
136
+ return true;
137
+ }
138
+ const output = Array.isArray(payload.output) ? payload.output : [];
139
+ if (output.length > 0) {
140
+ for (const item of output) {
141
+ if (!item || typeof item !== 'object' || Array.isArray(item))
142
+ continue;
143
+ const content = item.content;
144
+ if (hasNonEmptyText(content)) {
145
+ return false;
146
+ }
147
+ if (hasNonEmptyText(item.text))
148
+ return false;
149
+ if (hasNonEmptyText(item.output_text))
150
+ return false;
151
+ }
152
+ return true;
153
+ }
154
+ return true;
155
+ }
61
156
  export async function runServerToolOrchestration(options) {
62
157
  const serverToolTimeoutMs = resolveServerToolTimeoutMs();
63
158
  const followupTimeoutMs = resolveServerToolFollowupTimeoutMs(serverToolTimeoutMs);
@@ -89,11 +184,13 @@ export async function runServerToolOrchestration(options) {
89
184
  };
90
185
  }
91
186
  const isStopMessageFlow = engineResult.execution.flowId === 'stop_message_flow';
187
+ const isGeminiEmptyReplyContinue = engineResult.execution.flowId === 'gemini_empty_reply_continue';
92
188
  const stopMessageSource = isStopMessageFlow ? getStopMessageSource(options.adapterContext) : undefined;
93
189
  const isAutoStopMessage = isStopMessageFlow && stopMessageSource !== 'explicit';
94
190
  const isErrorAutoFlow = engineResult.execution.flowId === 'iflow_model_error_retry';
95
191
  const applyAutoLimit = isAutoStopMessage || isErrorAutoFlow;
96
- const routeHint = resolveRouteHint(options.adapterContext, engineResult.execution.flowId);
192
+ // ServerTool followups must not inherit or inject any routeHint; always route fresh.
193
+ const preserveRouteHint = false;
97
194
  const loopState = buildServerToolLoopState(options.adapterContext, engineResult.execution.flowId, engineResult.execution.followup.payload);
98
195
  if (applyAutoLimit && loopState && typeof loopState.repeatCount === 'number' && loopState.repeatCount >= 3) {
99
196
  return {
@@ -118,23 +215,35 @@ export async function runServerToolOrchestration(options) {
118
215
  ...(loopState ? { serverToolLoopState: loopState } : {}),
119
216
  ...(engineResult.execution.followup.metadata ?? {})
120
217
  };
121
- if (routeHint && typeof metadata.routeHint !== 'string') {
122
- metadata.routeHint = routeHint;
123
- }
124
- const maxAttempts = isStopMessageFlow ? 2 : 1;
218
+ // Enforce unified followup contract:
219
+ // - clear any inherited routeHint
220
+ // - do not inherit sticky target
221
+ // - record original entry endpoint for downstream formatting/debug
222
+ metadata.preserveRouteHint = preserveRouteHint;
223
+ // Use empty string (falsy) to avoid VirtualRouter calling `.trim()` on non-string values.
224
+ metadata.routeHint = '';
225
+ metadata.disableStickyRoutes = true;
226
+ metadata.serverToolOriginalEntryEndpoint =
227
+ (typeof options.entryEndpoint === 'string' && options.entryEndpoint.trim().length
228
+ ? options.entryEndpoint
229
+ : followupEntryEndpoint);
230
+ const retryEmptyFollowupOnce = isStopMessageFlow || isGeminiEmptyReplyContinue;
231
+ const maxAttempts = retryEmptyFollowupOnce ? 2 : 1;
125
232
  const followupRequestId = buildFollowupRequestId(options.requestId, engineResult.execution.followup.requestIdSuffix);
233
+ const followupPayload = coerceFollowupPayloadStream(engineResult.execution.followup.payload, metadata.stream === true);
126
234
  let followup;
127
235
  let lastError;
128
- let reservation = null;
236
+ // stopMessage 是一种“状态型” servertool:一旦触发,我们需要尽量避免因 followup 失败而把状态留在可继续触发的位置,
237
+ // 否则会出现下一轮仍然自动触发 → 再次失败 → 客户端永远 502 的死循环。
238
+ //
239
+ // 因此这里对 stop_message_flow 做一次性 reservation,并在最终判定 followup 为空时清理 stopMessage 状态。
240
+ const stopMessageReservation = isStopMessageFlow ? reserveStopMessageUsage(options.adapterContext) : null;
129
241
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
130
242
  try {
131
- if (isStopMessageFlow) {
132
- reservation = reserveStopMessageUsage(options.adapterContext);
133
- }
134
243
  followup = await withTimeout(options.reenterPipeline({
135
244
  entryEndpoint: followupEntryEndpoint,
136
245
  requestId: followupRequestId,
137
- body: engineResult.execution.followup.payload,
246
+ body: followupPayload,
138
247
  metadata
139
248
  }), followupTimeoutMs, () => createServerToolTimeoutError({
140
249
  requestId: options.requestId,
@@ -144,14 +253,25 @@ export async function runServerToolOrchestration(options) {
144
253
  attempt,
145
254
  maxAttempts
146
255
  }));
256
+ // Treat empty followup as failure for auto followup flows:
257
+ // - retry once (maxAttempts=2)
258
+ // - if still empty, surface as HTTP error so client can retry.
259
+ if (retryEmptyFollowupOnce) {
260
+ const body = followup && followup.body && typeof followup.body === 'object'
261
+ ? followup.body
262
+ : undefined;
263
+ if (body && isEmptyClientResponsePayload(body)) {
264
+ followup = undefined;
265
+ lastError = new Error('SERVERTOOL_EMPTY_FOLLOWUP');
266
+ if (attempt < maxAttempts) {
267
+ continue;
268
+ }
269
+ }
270
+ }
147
271
  lastError = undefined;
148
272
  break;
149
273
  }
150
274
  catch (error) {
151
- if (reservation) {
152
- rollbackStopMessageUsage(reservation);
153
- reservation = null;
154
- }
155
275
  if (isServerToolTimeoutError(error)) {
156
276
  throw error;
157
277
  }
@@ -176,15 +296,27 @@ export async function runServerToolOrchestration(options) {
176
296
  const followupBody = followup && followup.body && typeof followup.body === 'object'
177
297
  ? followup.body
178
298
  : undefined;
179
- if (isStopMessageFlow && !followupBody) {
299
+ if (retryEmptyFollowupOnce && (!followupBody || isEmptyClientResponsePayload(followupBody))) {
300
+ if (isStopMessageFlow) {
301
+ // 对 stopMessage:避免把 empty followup 升级为 502,直接清理 stopMessage 状态并返回原始响应。
302
+ // 这样客户端至少能拿到本轮输出,且 stopMessage 不会在后续请求里继续触发导致“永远 502”。
303
+ disableStopMessageAfterFailedFollowup(options.adapterContext, stopMessageReservation);
304
+ return {
305
+ chat: engineResult.finalChatResponse,
306
+ executed: true,
307
+ flowId: engineResult.execution.flowId
308
+ };
309
+ }
180
310
  const wrapped = new ProviderProtocolError(`[servertool] Followup returned empty response for flow ${engineResult.execution.flowId ?? 'unknown'}`, {
181
- code: 'SERVERTOOL_FOLLOWUP_FAILED',
311
+ code: 'SERVERTOOL_EMPTY_FOLLOWUP',
312
+ category: 'EXTERNAL_ERROR',
182
313
  details: {
183
314
  flowId: engineResult.execution.flowId,
184
315
  requestId: options.requestId,
185
316
  error: lastError instanceof Error ? lastError.message : undefined
186
317
  }
187
318
  });
319
+ wrapped.status = 502;
188
320
  wrapped.cause = lastError;
189
321
  throw wrapped;
190
322
  }
@@ -244,6 +376,47 @@ function reserveStopMessageUsage(adapterContext) {
244
376
  function rollbackStopMessageUsage(reservation) {
245
377
  saveRoutingInstructionStateSync(reservation.stickyKey, reservation.previousState);
246
378
  }
379
+ function disableStopMessageAfterFailedFollowup(adapterContext, reservation) {
380
+ try {
381
+ const key = reservation && typeof reservation.stickyKey === 'string' && reservation.stickyKey.trim()
382
+ ? reservation.stickyKey.trim()
383
+ : resolveStickyKeyFromAdapterContext(adapterContext);
384
+ if (!key) {
385
+ return;
386
+ }
387
+ const state = loadRoutingInstructionStateSync(key);
388
+ if (!state) {
389
+ return;
390
+ }
391
+ state.stopMessageText = undefined;
392
+ state.stopMessageMaxRepeats = undefined;
393
+ state.stopMessageUsed = undefined;
394
+ state.stopMessageUpdatedAt = undefined;
395
+ state.stopMessageLastUsedAt = undefined;
396
+ saveRoutingInstructionStateSync(key, state);
397
+ }
398
+ catch {
399
+ // best-effort: do not crash the request due to state cleanup failures
400
+ }
401
+ }
402
+ function resolveStickyKeyFromAdapterContext(adapterContext) {
403
+ if (!adapterContext || typeof adapterContext !== 'object') {
404
+ return undefined;
405
+ }
406
+ const sessionId = typeof adapterContext.sessionId === 'string'
407
+ ? adapterContext.sessionId.trim()
408
+ : '';
409
+ const conversationId = typeof adapterContext.conversationId === 'string'
410
+ ? adapterContext.conversationId.trim()
411
+ : '';
412
+ if (sessionId) {
413
+ return `session:${sessionId}`;
414
+ }
415
+ if (conversationId) {
416
+ return `conversation:${conversationId}`;
417
+ }
418
+ return undefined;
419
+ }
247
420
  function cloneRoutingInstructionState(state) {
248
421
  if (!state) {
249
422
  return null;