@jsonstudio/llms 0.6.802 → 0.6.954

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 (188) 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 +113 -17
  25. package/dist/conversion/hub/pipeline/stages/req_inbound/req_inbound_stage3_context_capture/index.js +6 -3
  26. package/dist/conversion/hub/pipeline/stages/req_process/req_process_stage1_tool_governance/index.js +4 -0
  27. package/dist/conversion/hub/pipeline/stages/resp_outbound/resp_outbound_stage1_client_remap/index.js +23 -1
  28. package/dist/conversion/hub/pipelines/inbound.d.ts +22 -0
  29. package/dist/conversion/hub/pipelines/outbound.d.ts +22 -0
  30. package/dist/conversion/hub/policy/policy-engine.d.ts +46 -0
  31. package/dist/conversion/hub/policy/policy-engine.js +176 -0
  32. package/dist/conversion/hub/policy/protocol-spec.d.ts +50 -0
  33. package/dist/conversion/hub/policy/protocol-spec.js +105 -0
  34. package/dist/conversion/hub/process/chat-process.d.ts +32 -0
  35. package/dist/conversion/hub/registry.d.ts +28 -0
  36. package/dist/conversion/hub/response/chat-response-utils.d.ts +6 -0
  37. package/dist/conversion/hub/response/provider-response.js +31 -0
  38. package/dist/conversion/hub/semantic-mappers/chat-mapper.js +32 -1
  39. package/dist/conversion/hub/semantic-mappers/gemini-mapper.d.ts +7 -0
  40. package/dist/conversion/hub/semantic-mappers/gemini-mapper.js +96 -1
  41. package/dist/conversion/hub/semantic-mappers/index.d.ts +4 -0
  42. package/dist/conversion/hub/semantic-mappers/responses-mapper.d.ts +21 -0
  43. package/dist/conversion/hub/standardized-bridge.d.ts +12 -0
  44. package/dist/conversion/hub/types/chat-schema.d.ts +112 -0
  45. package/dist/conversion/hub/types/errors.d.ts +5 -0
  46. package/dist/conversion/hub/types/format-envelope.d.ts +7 -0
  47. package/dist/conversion/hub/types/index.d.ts +6 -0
  48. package/dist/conversion/hub/types/json.d.ts +9 -0
  49. package/dist/conversion/hub/types/node.d.ts +31 -0
  50. package/dist/conversion/responses/responses-openai-bridge.js +263 -10
  51. package/dist/conversion/schema-validator.d.ts +7 -0
  52. package/dist/conversion/shared/args-mapping.d.ts +18 -0
  53. package/dist/conversion/shared/chat-request-filters.d.ts +9 -0
  54. package/dist/conversion/shared/errors.d.ts +1 -1
  55. package/dist/conversion/shared/gemini-tool-utils.js +105 -1
  56. package/dist/conversion/shared/jsonish.d.ts +3 -0
  57. package/dist/conversion/shared/mcp-injection.d.ts +2 -0
  58. package/dist/conversion/shared/media.d.ts +1 -0
  59. package/dist/conversion/shared/openai-message-normalize.d.ts +1 -0
  60. package/dist/conversion/shared/payload-budget.d.ts +13 -0
  61. package/dist/conversion/shared/reasoning-mapping.d.ts +5 -0
  62. package/dist/conversion/shared/responses-request-adapter.d.ts +1 -28
  63. package/dist/conversion/shared/responses-request-adapter.js +1 -430
  64. package/dist/conversion/shared/snapshot-hooks.js +58 -3
  65. package/dist/conversion/shared/tool-governor.js +8 -2
  66. package/dist/conversion/shared/tool-harvester.d.ts +31 -0
  67. package/dist/conversion/shared/tool-mapping.js +10 -29
  68. package/dist/conversion/types.d.ts +33 -0
  69. package/dist/filters/builtin/add-fields-filter.d.ts +8 -0
  70. package/dist/filters/builtin/blacklist-filter.d.ts +8 -0
  71. package/dist/filters/builtin/whitelist-filter.d.ts +8 -0
  72. package/dist/filters/engine.d.ts +16 -0
  73. package/dist/filters/special/request-tool-choice-policy.d.ts +11 -0
  74. package/dist/filters/special/response-finish-invariants.d.ts +11 -0
  75. package/dist/filters/special/response-openai-to-responses-bridge.d.ts +13 -0
  76. package/dist/filters/special/response-tool-arguments-blacklist.d.ts +12 -0
  77. package/dist/filters/special/response-tool-arguments-schema-converge.d.ts +13 -0
  78. package/dist/filters/special/response-tool-arguments-stringify.d.ts +9 -0
  79. package/dist/filters/special/response-tool-arguments-whitelist.d.ts +11 -0
  80. package/dist/filters/special/tool-filter-hooks.d.ts +19 -0
  81. package/dist/filters/special/tool-post-constraints.d.ts +31 -0
  82. package/dist/filters/types.d.ts +68 -0
  83. package/dist/filters/utils/fieldmap-loader.d.ts +2 -0
  84. package/dist/filters/utils/snapshot-writer.d.ts +10 -0
  85. package/dist/guidance/index.d.ts +3 -0
  86. package/dist/guidance/index.js +78 -83
  87. package/dist/http/sse-response.d.ts +22 -0
  88. package/dist/router/virtual-router/bootstrap.d.ts +6 -0
  89. package/dist/router/virtual-router/bootstrap.js +49 -5
  90. package/dist/router/virtual-router/classifier.d.ts +10 -0
  91. package/dist/router/virtual-router/engine-selection.js +98 -11
  92. package/dist/router/virtual-router/engine.js +177 -31
  93. package/dist/router/virtual-router/error-center.d.ts +10 -0
  94. package/dist/router/virtual-router/features.d.ts +3 -0
  95. package/dist/router/virtual-router/routing-instructions.d.ts +23 -1
  96. package/dist/router/virtual-router/routing-instructions.js +120 -30
  97. package/dist/router/virtual-router/types.d.ts +11 -0
  98. package/dist/servertool/engine.js +192 -17
  99. package/dist/servertool/handlers/apply-patch-guard.js +269 -0
  100. package/dist/servertool/handlers/exec-command-guard.js +558 -0
  101. package/dist/servertool/handlers/followup-message-trimmer.d.ts +16 -0
  102. package/dist/servertool/handlers/followup-message-trimmer.js +198 -0
  103. package/dist/servertool/handlers/followup-request-builder.d.ts +17 -0
  104. package/dist/servertool/handlers/followup-request-builder.js +122 -0
  105. package/dist/servertool/handlers/gemini-empty-reply-continue.js +252 -51
  106. package/dist/servertool/handlers/iflow-model-error-retry.js +12 -22
  107. package/dist/servertool/handlers/stop-message-auto.js +237 -75
  108. package/dist/servertool/handlers/vision.js +15 -27
  109. package/dist/servertool/handlers/web-search.js +17 -43
  110. package/dist/servertool/server-side-tools.d.ts +3 -0
  111. package/dist/servertool/server-side-tools.js +3 -0
  112. package/dist/sse/json-to-sse/anthropic-json-to-sse-converter.d.ts +2 -1
  113. package/dist/sse/json-to-sse/chat-json-to-sse-converter.d.ts +80 -0
  114. package/dist/sse/json-to-sse/event-generators/chat.d.ts +55 -0
  115. package/dist/sse/json-to-sse/event-generators/responses.d.ts +99 -0
  116. package/dist/sse/json-to-sse/gemini-json-to-sse-converter.d.ts +2 -1
  117. package/dist/sse/json-to-sse/responses-json-to-sse-converter.d.ts +80 -0
  118. package/dist/sse/json-to-sse/sequencers/anthropic-sequencer.d.ts +1 -1
  119. package/dist/sse/json-to-sse/sequencers/chat-sequencer.d.ts +2 -2
  120. package/dist/sse/json-to-sse/sequencers/gemini-sequencer.d.ts +1 -1
  121. package/dist/sse/json-to-sse/sequencers/responses-sequencer.d.ts +40 -0
  122. package/dist/sse/shared/chat-serializer.d.ts +4 -0
  123. package/dist/sse/shared/constants.d.ts +272 -0
  124. package/dist/sse/shared/serializers/anthropic-event-serializer.d.ts +1 -1
  125. package/dist/sse/shared/serializers/base-serializer.d.ts +158 -0
  126. package/dist/sse/shared/serializers/chat-event-serializer.d.ts +82 -0
  127. package/dist/sse/shared/serializers/gemini-event-serializer.d.ts +1 -1
  128. package/dist/sse/shared/serializers/index.d.ts +2 -1
  129. package/dist/sse/shared/serializers/responses-event-serializer.d.ts +123 -0
  130. package/dist/sse/shared/serializers/types.d.ts +51 -0
  131. package/dist/sse/shared/utils.d.ts +254 -0
  132. package/dist/sse/shared/writer.d.ts +2 -2
  133. package/dist/sse/sse-to-json/anthropic-sse-to-json-converter.d.ts +1 -1
  134. package/dist/sse/sse-to-json/builders/anthropic-response-builder.d.ts +1 -1
  135. package/dist/sse/sse-to-json/builders/response-builder.d.ts +1 -1
  136. package/dist/sse/sse-to-json/chat-sse-to-json-converter.d.ts +2 -1
  137. package/dist/sse/sse-to-json/gemini-sse-to-json-converter.d.ts +2 -1
  138. package/dist/sse/sse-to-json/parsers/sse-parser.d.ts +73 -0
  139. package/dist/sse/sse-to-json/responses-sse-to-json-converter.d.ts +1 -1
  140. package/dist/sse/types/chat-types.d.ts +1 -1
  141. package/dist/sse/types/responses-types.d.ts +1 -1
  142. package/dist/tools/apply-patch/execution-capturer.d.ts +13 -0
  143. package/dist/tools/apply-patch/execution-capturer.js +158 -0
  144. package/dist/tools/apply-patch/regression-capturer.d.ts +1 -0
  145. package/dist/tools/apply-patch/regression-capturer.js +5 -4
  146. package/dist/tools/apply-patch/structured.js +109 -13
  147. package/dist/tools/apply-patch/validator.js +112 -18
  148. package/dist/tools/tool-registry.d.ts +8 -0
  149. package/dist/tools/tool-registry.js +2 -1
  150. package/package.json +4 -4
  151. package/dist/conversion/compat/actions/apply-patch-format-fixer.js +0 -233
  152. package/dist/conversion/config/compat-profiles.json +0 -38
  153. package/dist/conversion/hub/pipeline/context-limit.d.ts +0 -13
  154. package/dist/conversion/hub/pipeline/context-limit.js +0 -55
  155. package/dist/conversion/hub/response/server-side-tools.d.ts +0 -26
  156. package/dist/conversion/hub/response/server-side-tools.js +0 -383
  157. package/dist/conversion/shared/bridge-conversation-store.d.ts +0 -41
  158. package/dist/conversion/shared/bridge-conversation-store.js +0 -279
  159. package/dist/conversion/shared/bridge-request-adapter.d.ts +0 -28
  160. package/dist/conversion/shared/bridge-request-adapter.js +0 -430
  161. package/dist/conversion/shared/responses-id-utils.js +0 -42
  162. package/dist/conversion/shared/responses-instructions.js +0 -113
  163. package/dist/conversion/shared/responses-message-utils.d.ts +0 -15
  164. package/dist/conversion/shared/responses-message-utils.js +0 -206
  165. package/dist/conversion/shared/responses-metadata.js +0 -1
  166. package/dist/conversion/shared/responses-output-utils.d.ts +0 -7
  167. package/dist/conversion/shared/responses-output-utils.js +0 -108
  168. package/dist/conversion/shared/responses-types.d.ts +0 -33
  169. package/dist/conversion/shared/tool-normalizers.d.ts +0 -4
  170. package/dist/conversion/shared/tool-normalizers.js +0 -84
  171. package/dist/filters/special/request-streaming-to-nonstreaming.d.ts +0 -13
  172. package/dist/filters/special/request-streaming-to-nonstreaming.js +0 -39
  173. package/dist/filters/special/response-apply-patch-toon-decode.d.ts +0 -23
  174. package/dist/filters/special/response-apply-patch-toon-decode.js +0 -460
  175. package/dist/filters/special/response-tool-arguments-toon-decode.d.ts +0 -10
  176. package/dist/filters/special/response-tool-arguments-toon-decode.js +0 -154
  177. package/dist/servertool/flow-types.d.ts +0 -40
  178. package/dist/servertool/flow-types.js +0 -1
  179. package/dist/servertool/orchestration-types.d.ts +0 -33
  180. package/dist/servertool/orchestration-types.js +0 -1
  181. package/dist/servertool/vision-tool.d.ts +0 -2
  182. package/dist/servertool/vision-tool.js +0 -185
  183. package/dist/tools/patch-args-normalizer.d.ts +0 -15
  184. package/dist/tools/patch-args-normalizer.js +0 -472
  185. package/dist/utils/toon.d.ts +0 -4
  186. package/dist/utils/toon.js +0 -75
  187. /package/dist/{conversion/compat/actions/apply-patch-format-fixer.d.ts → servertool/handlers/apply-patch-guard.d.ts} +0 -0
  188. /package/dist/{conversion/shared/responses-types.js → servertool/handlers/exec-command-guard.d.ts} +0 -0
@@ -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,15 @@ 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';
188
+ const isApplyPatchGuard = engineResult.execution.flowId === 'apply_patch_guard';
189
+ const isExecCommandGuard = engineResult.execution.flowId === 'exec_command_guard';
92
190
  const stopMessageSource = isStopMessageFlow ? getStopMessageSource(options.adapterContext) : undefined;
93
191
  const isAutoStopMessage = isStopMessageFlow && stopMessageSource !== 'explicit';
94
192
  const isErrorAutoFlow = engineResult.execution.flowId === 'iflow_model_error_retry';
95
- const applyAutoLimit = isAutoStopMessage || isErrorAutoFlow;
96
- const routeHint = resolveRouteHint(options.adapterContext, engineResult.execution.flowId);
193
+ const applyAutoLimit = isAutoStopMessage || isErrorAutoFlow || isGeminiEmptyReplyContinue || isApplyPatchGuard || isExecCommandGuard;
194
+ // ServerTool followups must not inherit or inject any routeHint; always route fresh.
195
+ const preserveRouteHint = false;
97
196
  const loopState = buildServerToolLoopState(options.adapterContext, engineResult.execution.flowId, engineResult.execution.followup.payload);
98
197
  if (applyAutoLimit && loopState && typeof loopState.repeatCount === 'number' && loopState.repeatCount >= 3) {
99
198
  return {
@@ -118,23 +217,35 @@ export async function runServerToolOrchestration(options) {
118
217
  ...(loopState ? { serverToolLoopState: loopState } : {}),
119
218
  ...(engineResult.execution.followup.metadata ?? {})
120
219
  };
121
- if (routeHint && typeof metadata.routeHint !== 'string') {
122
- metadata.routeHint = routeHint;
123
- }
124
- const maxAttempts = isStopMessageFlow ? 2 : 1;
220
+ // Enforce unified followup contract:
221
+ // - clear any inherited routeHint
222
+ // - do not inherit sticky target
223
+ // - record original entry endpoint for downstream formatting/debug
224
+ metadata.preserveRouteHint = preserveRouteHint;
225
+ // Use empty string (falsy) to avoid VirtualRouter calling `.trim()` on non-string values.
226
+ metadata.routeHint = '';
227
+ metadata.disableStickyRoutes = true;
228
+ metadata.serverToolOriginalEntryEndpoint =
229
+ (typeof options.entryEndpoint === 'string' && options.entryEndpoint.trim().length
230
+ ? options.entryEndpoint
231
+ : followupEntryEndpoint);
232
+ const retryEmptyFollowupOnce = isStopMessageFlow || isGeminiEmptyReplyContinue;
233
+ const maxAttempts = retryEmptyFollowupOnce ? 2 : 1;
125
234
  const followupRequestId = buildFollowupRequestId(options.requestId, engineResult.execution.followup.requestIdSuffix);
235
+ const followupPayload = coerceFollowupPayloadStream(engineResult.execution.followup.payload, metadata.stream === true);
126
236
  let followup;
127
237
  let lastError;
128
- let reservation = null;
238
+ // stopMessage 是一种“状态型” servertool:一旦触发,我们需要尽量避免因 followup 失败而把状态留在可继续触发的位置,
239
+ // 否则会出现下一轮仍然自动触发 → 再次失败 → 客户端永远 502 的死循环。
240
+ //
241
+ // 因此这里对 stop_message_flow 做一次性 reservation,并在最终判定 followup 为空时清理 stopMessage 状态。
242
+ const stopMessageReservation = isStopMessageFlow ? reserveStopMessageUsage(options.adapterContext) : null;
129
243
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
130
244
  try {
131
- if (isStopMessageFlow) {
132
- reservation = reserveStopMessageUsage(options.adapterContext);
133
- }
134
245
  followup = await withTimeout(options.reenterPipeline({
135
246
  entryEndpoint: followupEntryEndpoint,
136
247
  requestId: followupRequestId,
137
- body: engineResult.execution.followup.payload,
248
+ body: followupPayload,
138
249
  metadata
139
250
  }), followupTimeoutMs, () => createServerToolTimeoutError({
140
251
  requestId: options.requestId,
@@ -144,14 +255,25 @@ export async function runServerToolOrchestration(options) {
144
255
  attempt,
145
256
  maxAttempts
146
257
  }));
258
+ // Treat empty followup as failure for auto followup flows:
259
+ // - retry once (maxAttempts=2)
260
+ // - if still empty, surface as HTTP error so client can retry.
261
+ if (retryEmptyFollowupOnce) {
262
+ const body = followup && followup.body && typeof followup.body === 'object'
263
+ ? followup.body
264
+ : undefined;
265
+ if (body && isEmptyClientResponsePayload(body)) {
266
+ followup = undefined;
267
+ lastError = new Error('SERVERTOOL_EMPTY_FOLLOWUP');
268
+ if (attempt < maxAttempts) {
269
+ continue;
270
+ }
271
+ }
272
+ }
147
273
  lastError = undefined;
148
274
  break;
149
275
  }
150
276
  catch (error) {
151
- if (reservation) {
152
- rollbackStopMessageUsage(reservation);
153
- reservation = null;
154
- }
155
277
  if (isServerToolTimeoutError(error)) {
156
278
  throw error;
157
279
  }
@@ -176,15 +298,27 @@ export async function runServerToolOrchestration(options) {
176
298
  const followupBody = followup && followup.body && typeof followup.body === 'object'
177
299
  ? followup.body
178
300
  : undefined;
179
- if (isStopMessageFlow && !followupBody) {
301
+ if (retryEmptyFollowupOnce && (!followupBody || isEmptyClientResponsePayload(followupBody))) {
302
+ if (isStopMessageFlow) {
303
+ // 对 stopMessage:避免把 empty followup 升级为 502,直接清理 stopMessage 状态并返回原始响应。
304
+ // 这样客户端至少能拿到本轮输出,且 stopMessage 不会在后续请求里继续触发导致“永远 502”。
305
+ disableStopMessageAfterFailedFollowup(options.adapterContext, stopMessageReservation);
306
+ return {
307
+ chat: engineResult.finalChatResponse,
308
+ executed: true,
309
+ flowId: engineResult.execution.flowId
310
+ };
311
+ }
180
312
  const wrapped = new ProviderProtocolError(`[servertool] Followup returned empty response for flow ${engineResult.execution.flowId ?? 'unknown'}`, {
181
- code: 'SERVERTOOL_FOLLOWUP_FAILED',
313
+ code: 'SERVERTOOL_EMPTY_FOLLOWUP',
314
+ category: 'EXTERNAL_ERROR',
182
315
  details: {
183
316
  flowId: engineResult.execution.flowId,
184
317
  requestId: options.requestId,
185
318
  error: lastError instanceof Error ? lastError.message : undefined
186
319
  }
187
320
  });
321
+ wrapped.status = 502;
188
322
  wrapped.cause = lastError;
189
323
  throw wrapped;
190
324
  }
@@ -244,6 +378,47 @@ function reserveStopMessageUsage(adapterContext) {
244
378
  function rollbackStopMessageUsage(reservation) {
245
379
  saveRoutingInstructionStateSync(reservation.stickyKey, reservation.previousState);
246
380
  }
381
+ function disableStopMessageAfterFailedFollowup(adapterContext, reservation) {
382
+ try {
383
+ const key = reservation && typeof reservation.stickyKey === 'string' && reservation.stickyKey.trim()
384
+ ? reservation.stickyKey.trim()
385
+ : resolveStickyKeyFromAdapterContext(adapterContext);
386
+ if (!key) {
387
+ return;
388
+ }
389
+ const state = loadRoutingInstructionStateSync(key);
390
+ if (!state) {
391
+ return;
392
+ }
393
+ state.stopMessageText = undefined;
394
+ state.stopMessageMaxRepeats = undefined;
395
+ state.stopMessageUsed = undefined;
396
+ state.stopMessageUpdatedAt = undefined;
397
+ state.stopMessageLastUsedAt = undefined;
398
+ saveRoutingInstructionStateSync(key, state);
399
+ }
400
+ catch {
401
+ // best-effort: do not crash the request due to state cleanup failures
402
+ }
403
+ }
404
+ function resolveStickyKeyFromAdapterContext(adapterContext) {
405
+ if (!adapterContext || typeof adapterContext !== 'object') {
406
+ return undefined;
407
+ }
408
+ const sessionId = typeof adapterContext.sessionId === 'string'
409
+ ? adapterContext.sessionId.trim()
410
+ : '';
411
+ const conversationId = typeof adapterContext.conversationId === 'string'
412
+ ? adapterContext.conversationId.trim()
413
+ : '';
414
+ if (sessionId) {
415
+ return `session:${sessionId}`;
416
+ }
417
+ if (conversationId) {
418
+ return `conversation:${conversationId}`;
419
+ }
420
+ return undefined;
421
+ }
247
422
  function cloneRoutingInstructionState(state) {
248
423
  if (!state) {
249
424
  return null;