@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.
- package/dist/bridge/routecodex-adapter.d.ts +74 -0
- package/dist/config-unified/enhanced-path-resolver.d.ts +5 -0
- package/dist/config-unified/unified-config.d.ts +26 -0
- package/dist/conversion/codec-registry.d.ts +10 -0
- package/dist/conversion/codecs/gemini-openai-codec.d.ts +16 -0
- package/dist/conversion/codecs/openai-openai-codec.d.ts +12 -0
- package/dist/conversion/codecs/responses-openai-codec.d.ts +12 -0
- package/dist/conversion/compat/profiles/chat-gemini.json +12 -0
- package/dist/conversion/config/config-manager.d.ts +212 -0
- package/dist/conversion/hub/config/types.d.ts +26 -0
- package/dist/conversion/hub/core/detour-registry.d.ts +9 -0
- package/dist/conversion/hub/core/hub-context.d.ts +21 -0
- package/dist/conversion/hub/core/index.d.ts +3 -0
- package/dist/conversion/hub/core/stage-driver.d.ts +30 -0
- package/dist/conversion/hub/format-adapters/anthropic-format-adapter.d.ts +16 -0
- package/dist/conversion/hub/format-adapters/chat-format-adapter.d.ts +17 -0
- package/dist/conversion/hub/format-adapters/gemini-format-adapter.d.ts +16 -0
- package/dist/conversion/hub/format-adapters/index.d.ts +21 -0
- package/dist/conversion/hub/hub-feature.d.ts +1 -0
- package/dist/conversion/hub/node-support.d.ts +19 -0
- package/dist/conversion/hub/pipeline/compat/compat-pipeline-executor.js +11 -0
- package/dist/conversion/hub/pipeline/compat/compat-types.d.ts +3 -0
- package/dist/conversion/hub/pipeline/hub-pipeline.d.ts +7 -0
- package/dist/conversion/hub/pipeline/hub-pipeline.js +113 -17
- package/dist/conversion/hub/pipeline/stages/req_inbound/req_inbound_stage3_context_capture/index.js +6 -3
- package/dist/conversion/hub/pipeline/stages/req_process/req_process_stage1_tool_governance/index.js +4 -0
- package/dist/conversion/hub/pipeline/stages/resp_outbound/resp_outbound_stage1_client_remap/index.js +23 -1
- package/dist/conversion/hub/pipelines/inbound.d.ts +22 -0
- package/dist/conversion/hub/pipelines/outbound.d.ts +22 -0
- package/dist/conversion/hub/policy/policy-engine.d.ts +46 -0
- package/dist/conversion/hub/policy/policy-engine.js +176 -0
- package/dist/conversion/hub/policy/protocol-spec.d.ts +50 -0
- package/dist/conversion/hub/policy/protocol-spec.js +105 -0
- package/dist/conversion/hub/process/chat-process.d.ts +32 -0
- package/dist/conversion/hub/registry.d.ts +28 -0
- package/dist/conversion/hub/response/chat-response-utils.d.ts +6 -0
- package/dist/conversion/hub/response/provider-response.js +31 -0
- package/dist/conversion/hub/semantic-mappers/chat-mapper.js +32 -1
- package/dist/conversion/hub/semantic-mappers/gemini-mapper.d.ts +7 -0
- package/dist/conversion/hub/semantic-mappers/gemini-mapper.js +96 -1
- package/dist/conversion/hub/semantic-mappers/index.d.ts +4 -0
- package/dist/conversion/hub/semantic-mappers/responses-mapper.d.ts +21 -0
- package/dist/conversion/hub/standardized-bridge.d.ts +12 -0
- package/dist/conversion/hub/types/chat-schema.d.ts +112 -0
- package/dist/conversion/hub/types/errors.d.ts +5 -0
- package/dist/conversion/hub/types/format-envelope.d.ts +7 -0
- package/dist/conversion/hub/types/index.d.ts +6 -0
- package/dist/conversion/hub/types/json.d.ts +9 -0
- package/dist/conversion/hub/types/node.d.ts +31 -0
- package/dist/conversion/responses/responses-openai-bridge.js +263 -10
- package/dist/conversion/schema-validator.d.ts +7 -0
- package/dist/conversion/shared/args-mapping.d.ts +18 -0
- package/dist/conversion/shared/chat-request-filters.d.ts +9 -0
- package/dist/conversion/shared/errors.d.ts +1 -1
- package/dist/conversion/shared/gemini-tool-utils.js +105 -1
- package/dist/conversion/shared/jsonish.d.ts +3 -0
- package/dist/conversion/shared/mcp-injection.d.ts +2 -0
- package/dist/conversion/shared/media.d.ts +1 -0
- package/dist/conversion/shared/openai-message-normalize.d.ts +1 -0
- package/dist/conversion/shared/payload-budget.d.ts +13 -0
- package/dist/conversion/shared/reasoning-mapping.d.ts +5 -0
- package/dist/conversion/shared/responses-request-adapter.d.ts +1 -28
- package/dist/conversion/shared/responses-request-adapter.js +1 -430
- package/dist/conversion/shared/snapshot-hooks.js +58 -3
- package/dist/conversion/shared/tool-governor.js +8 -2
- package/dist/conversion/shared/tool-harvester.d.ts +31 -0
- package/dist/conversion/shared/tool-mapping.js +10 -29
- package/dist/conversion/types.d.ts +33 -0
- package/dist/filters/builtin/add-fields-filter.d.ts +8 -0
- package/dist/filters/builtin/blacklist-filter.d.ts +8 -0
- package/dist/filters/builtin/whitelist-filter.d.ts +8 -0
- package/dist/filters/engine.d.ts +16 -0
- package/dist/filters/special/request-tool-choice-policy.d.ts +11 -0
- package/dist/filters/special/response-finish-invariants.d.ts +11 -0
- package/dist/filters/special/response-openai-to-responses-bridge.d.ts +13 -0
- package/dist/filters/special/response-tool-arguments-blacklist.d.ts +12 -0
- package/dist/filters/special/response-tool-arguments-schema-converge.d.ts +13 -0
- package/dist/filters/special/response-tool-arguments-stringify.d.ts +9 -0
- package/dist/filters/special/response-tool-arguments-whitelist.d.ts +11 -0
- package/dist/filters/special/tool-filter-hooks.d.ts +19 -0
- package/dist/filters/special/tool-post-constraints.d.ts +31 -0
- package/dist/filters/types.d.ts +68 -0
- package/dist/filters/utils/fieldmap-loader.d.ts +2 -0
- package/dist/filters/utils/snapshot-writer.d.ts +10 -0
- package/dist/guidance/index.d.ts +3 -0
- package/dist/guidance/index.js +78 -83
- package/dist/http/sse-response.d.ts +22 -0
- package/dist/router/virtual-router/bootstrap.d.ts +6 -0
- package/dist/router/virtual-router/bootstrap.js +49 -5
- package/dist/router/virtual-router/classifier.d.ts +10 -0
- package/dist/router/virtual-router/engine-selection.js +98 -11
- package/dist/router/virtual-router/engine.js +177 -31
- package/dist/router/virtual-router/error-center.d.ts +10 -0
- package/dist/router/virtual-router/features.d.ts +3 -0
- package/dist/router/virtual-router/routing-instructions.d.ts +23 -1
- package/dist/router/virtual-router/routing-instructions.js +120 -30
- package/dist/router/virtual-router/types.d.ts +11 -0
- package/dist/servertool/engine.js +192 -17
- package/dist/servertool/handlers/apply-patch-guard.js +269 -0
- package/dist/servertool/handlers/exec-command-guard.js +558 -0
- package/dist/servertool/handlers/followup-message-trimmer.d.ts +16 -0
- package/dist/servertool/handlers/followup-message-trimmer.js +198 -0
- package/dist/servertool/handlers/followup-request-builder.d.ts +17 -0
- package/dist/servertool/handlers/followup-request-builder.js +122 -0
- package/dist/servertool/handlers/gemini-empty-reply-continue.js +252 -51
- package/dist/servertool/handlers/iflow-model-error-retry.js +12 -22
- package/dist/servertool/handlers/stop-message-auto.js +237 -75
- package/dist/servertool/handlers/vision.js +15 -27
- package/dist/servertool/handlers/web-search.js +17 -43
- package/dist/servertool/server-side-tools.d.ts +3 -0
- package/dist/servertool/server-side-tools.js +3 -0
- package/dist/sse/json-to-sse/anthropic-json-to-sse-converter.d.ts +2 -1
- package/dist/sse/json-to-sse/chat-json-to-sse-converter.d.ts +80 -0
- package/dist/sse/json-to-sse/event-generators/chat.d.ts +55 -0
- package/dist/sse/json-to-sse/event-generators/responses.d.ts +99 -0
- package/dist/sse/json-to-sse/gemini-json-to-sse-converter.d.ts +2 -1
- package/dist/sse/json-to-sse/responses-json-to-sse-converter.d.ts +80 -0
- package/dist/sse/json-to-sse/sequencers/anthropic-sequencer.d.ts +1 -1
- package/dist/sse/json-to-sse/sequencers/chat-sequencer.d.ts +2 -2
- package/dist/sse/json-to-sse/sequencers/gemini-sequencer.d.ts +1 -1
- package/dist/sse/json-to-sse/sequencers/responses-sequencer.d.ts +40 -0
- package/dist/sse/shared/chat-serializer.d.ts +4 -0
- package/dist/sse/shared/constants.d.ts +272 -0
- package/dist/sse/shared/serializers/anthropic-event-serializer.d.ts +1 -1
- package/dist/sse/shared/serializers/base-serializer.d.ts +158 -0
- package/dist/sse/shared/serializers/chat-event-serializer.d.ts +82 -0
- package/dist/sse/shared/serializers/gemini-event-serializer.d.ts +1 -1
- package/dist/sse/shared/serializers/index.d.ts +2 -1
- package/dist/sse/shared/serializers/responses-event-serializer.d.ts +123 -0
- package/dist/sse/shared/serializers/types.d.ts +51 -0
- package/dist/sse/shared/utils.d.ts +254 -0
- package/dist/sse/shared/writer.d.ts +2 -2
- package/dist/sse/sse-to-json/anthropic-sse-to-json-converter.d.ts +1 -1
- package/dist/sse/sse-to-json/builders/anthropic-response-builder.d.ts +1 -1
- package/dist/sse/sse-to-json/builders/response-builder.d.ts +1 -1
- package/dist/sse/sse-to-json/chat-sse-to-json-converter.d.ts +2 -1
- package/dist/sse/sse-to-json/gemini-sse-to-json-converter.d.ts +2 -1
- package/dist/sse/sse-to-json/parsers/sse-parser.d.ts +73 -0
- package/dist/sse/sse-to-json/responses-sse-to-json-converter.d.ts +1 -1
- package/dist/sse/types/chat-types.d.ts +1 -1
- package/dist/sse/types/responses-types.d.ts +1 -1
- package/dist/tools/apply-patch/execution-capturer.d.ts +13 -0
- package/dist/tools/apply-patch/execution-capturer.js +158 -0
- package/dist/tools/apply-patch/regression-capturer.d.ts +1 -0
- package/dist/tools/apply-patch/regression-capturer.js +5 -4
- package/dist/tools/apply-patch/structured.js +109 -13
- package/dist/tools/apply-patch/validator.js +112 -18
- package/dist/tools/tool-registry.d.ts +8 -0
- package/dist/tools/tool-registry.js +2 -1
- package/package.json +4 -4
- package/dist/conversion/compat/actions/apply-patch-format-fixer.js +0 -233
- package/dist/conversion/config/compat-profiles.json +0 -38
- package/dist/conversion/hub/pipeline/context-limit.d.ts +0 -13
- package/dist/conversion/hub/pipeline/context-limit.js +0 -55
- package/dist/conversion/hub/response/server-side-tools.d.ts +0 -26
- package/dist/conversion/hub/response/server-side-tools.js +0 -383
- package/dist/conversion/shared/bridge-conversation-store.d.ts +0 -41
- package/dist/conversion/shared/bridge-conversation-store.js +0 -279
- package/dist/conversion/shared/bridge-request-adapter.d.ts +0 -28
- package/dist/conversion/shared/bridge-request-adapter.js +0 -430
- package/dist/conversion/shared/responses-id-utils.js +0 -42
- package/dist/conversion/shared/responses-instructions.js +0 -113
- package/dist/conversion/shared/responses-message-utils.d.ts +0 -15
- package/dist/conversion/shared/responses-message-utils.js +0 -206
- package/dist/conversion/shared/responses-metadata.js +0 -1
- package/dist/conversion/shared/responses-output-utils.d.ts +0 -7
- package/dist/conversion/shared/responses-output-utils.js +0 -108
- package/dist/conversion/shared/responses-types.d.ts +0 -33
- package/dist/conversion/shared/tool-normalizers.d.ts +0 -4
- package/dist/conversion/shared/tool-normalizers.js +0 -84
- package/dist/filters/special/request-streaming-to-nonstreaming.d.ts +0 -13
- package/dist/filters/special/request-streaming-to-nonstreaming.js +0 -39
- package/dist/filters/special/response-apply-patch-toon-decode.d.ts +0 -23
- package/dist/filters/special/response-apply-patch-toon-decode.js +0 -460
- package/dist/filters/special/response-tool-arguments-toon-decode.d.ts +0 -10
- package/dist/filters/special/response-tool-arguments-toon-decode.js +0 -154
- package/dist/servertool/flow-types.d.ts +0 -40
- package/dist/servertool/flow-types.js +0 -1
- package/dist/servertool/orchestration-types.d.ts +0 -33
- package/dist/servertool/orchestration-types.js +0 -1
- package/dist/servertool/vision-tool.d.ts +0 -2
- package/dist/servertool/vision-tool.js +0 -185
- package/dist/tools/patch-args-normalizer.d.ts +0 -15
- package/dist/tools/patch-args-normalizer.js +0 -472
- package/dist/utils/toon.d.ts +0 -4
- package/dist/utils/toon.js +0 -75
- /package/dist/{conversion/compat/actions/apply-patch-format-fixer.d.ts → servertool/handlers/apply-patch-guard.d.ts} +0 -0
- /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
|
-
//
|
|
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
|
|
194
|
+
// - "!provider.model" / "!provider[alias].model" / "!provider.2":prefer 语义;
|
|
195
|
+
// model 可用时只命中该 model(忽略路由),不可用则自动清除并回退到正常路由命中。
|
|
157
196
|
//
|
|
158
|
-
// 这样可以在不破坏既有 "!glm,openai"
|
|
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: '
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (!
|
|
221
|
-
|
|
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 (
|
|
224
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
if (
|
|
232
|
-
return { provider,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
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: '
|
|
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;
|