@jsonstudio/llms 0.6.375 → 0.6.467

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 (37) hide show
  1. package/dist/conversion/codecs/gemini-openai-codec.js +15 -1
  2. package/dist/conversion/compat/actions/iflow-web-search.d.ts +18 -0
  3. package/dist/conversion/compat/actions/iflow-web-search.js +87 -0
  4. package/dist/conversion/compat/profiles/chat-glm.json +4 -0
  5. package/dist/conversion/compat/profiles/chat-iflow.json +5 -1
  6. package/dist/conversion/hub/pipeline/compat/compat-pipeline-executor.js +6 -0
  7. package/dist/conversion/hub/pipeline/compat/compat-types.d.ts +2 -0
  8. package/dist/conversion/hub/pipeline/hub-pipeline.js +5 -1
  9. package/dist/conversion/hub/pipeline/session-identifiers.d.ts +9 -0
  10. package/dist/conversion/hub/pipeline/session-identifiers.js +76 -0
  11. package/dist/conversion/hub/pipeline/stages/resp_inbound/resp_inbound_stage1_sse_decode/index.js +31 -2
  12. package/dist/conversion/hub/process/chat-process.js +89 -25
  13. package/dist/conversion/responses/responses-openai-bridge.js +75 -4
  14. package/dist/conversion/shared/anthropic-message-utils.js +41 -6
  15. package/dist/conversion/shared/errors.d.ts +20 -0
  16. package/dist/conversion/shared/errors.js +28 -0
  17. package/dist/conversion/shared/responses-conversation-store.js +30 -3
  18. package/dist/conversion/shared/responses-output-builder.js +68 -6
  19. package/dist/filters/special/request-toolcalls-stringify.d.ts +13 -0
  20. package/dist/filters/special/request-toolcalls-stringify.js +103 -3
  21. package/dist/filters/special/response-tool-text-canonicalize.d.ts +16 -0
  22. package/dist/filters/special/response-tool-text-canonicalize.js +27 -3
  23. package/dist/router/virtual-router/classifier.js +4 -2
  24. package/dist/router/virtual-router/engine.d.ts +30 -0
  25. package/dist/router/virtual-router/engine.js +600 -42
  26. package/dist/router/virtual-router/provider-registry.d.ts +15 -0
  27. package/dist/router/virtual-router/provider-registry.js +40 -0
  28. package/dist/router/virtual-router/routing-instructions.d.ts +34 -0
  29. package/dist/router/virtual-router/routing-instructions.js +383 -0
  30. package/dist/router/virtual-router/sticky-session-store.d.ts +3 -0
  31. package/dist/router/virtual-router/sticky-session-store.js +110 -0
  32. package/dist/router/virtual-router/tool-signals.js +0 -22
  33. package/dist/router/virtual-router/types.d.ts +35 -0
  34. package/dist/servertool/engine.js +42 -1
  35. package/dist/servertool/handlers/web-search.js +157 -4
  36. package/dist/servertool/types.d.ts +6 -0
  37. package/package.json +1 -1
@@ -3,6 +3,7 @@ import { resolveBridgePolicy, resolvePolicyActions } from './bridge-policies.js'
3
3
  import { normalizeChatMessageContent } from './chat-output-normalizer.js';
4
4
  import { mapBridgeToolsToChat, mapChatToolsToBridge } from './tool-mapping.js';
5
5
  import { jsonClone } from '../hub/types/json.js';
6
+ import { ProviderProtocolError } from './errors.js';
6
7
  function isObject(v) {
7
8
  return !!v && typeof v === 'object' && !Array.isArray(v);
8
9
  }
@@ -111,20 +112,39 @@ function extractToolResultSegment(entry) {
111
112
  }
112
113
  return String(entry);
113
114
  }
115
+ function resolveProtocolErrorCode(context) {
116
+ const ctx = context.toLowerCase();
117
+ return ctx.includes('tool') ? 'TOOL_PROTOCOL_ERROR' : 'MALFORMED_REQUEST';
118
+ }
114
119
  function requireTrimmedString(value, context) {
115
120
  if (typeof value !== 'string') {
116
- throw new Error(`Anthropic bridge constraint violated: ${context} must be a string`);
121
+ throw new ProviderProtocolError(`Anthropic bridge constraint violated: ${context} must be a string`, {
122
+ code: resolveProtocolErrorCode(context),
123
+ protocol: 'anthropic-messages',
124
+ providerType: 'anthropic',
125
+ details: { context, actualType: typeof value }
126
+ });
117
127
  }
118
128
  const trimmed = value.trim();
119
129
  if (!trimmed.length) {
120
- throw new Error(`Anthropic bridge constraint violated: ${context} must not be empty`);
130
+ throw new ProviderProtocolError(`Anthropic bridge constraint violated: ${context} must not be empty`, {
131
+ code: resolveProtocolErrorCode(context),
132
+ protocol: 'anthropic-messages',
133
+ providerType: 'anthropic',
134
+ details: { context }
135
+ });
121
136
  }
122
137
  return trimmed;
123
138
  }
124
139
  function requireSystemText(block, context) {
125
140
  const text = flattenAnthropicText(block).trim();
126
141
  if (!text) {
127
- throw new Error(`Anthropic bridge constraint violated: ${context} must contain text`);
142
+ throw new ProviderProtocolError(`Anthropic bridge constraint violated: ${context} must contain text`, {
143
+ code: resolveProtocolErrorCode(context),
144
+ protocol: 'anthropic-messages',
145
+ providerType: 'anthropic',
146
+ details: { context }
147
+ });
128
148
  }
129
149
  return text;
130
150
  }
@@ -710,7 +730,12 @@ export function buildAnthropicRequestFromOpenAIChat(chatReq) {
710
730
  pushSystemBlock(requireSystemText(val, 'top-level system'));
711
731
  return;
712
732
  }
713
- throw new Error('Anthropic bridge constraint violated: unsupported system payload type');
733
+ throw new ProviderProtocolError('Anthropic bridge constraint violated: unsupported system payload type', {
734
+ code: 'MALFORMED_REQUEST',
735
+ protocol: 'anthropic-messages',
736
+ providerType: 'anthropic',
737
+ details: { context: 'top-level system', actualType: typeof val }
738
+ });
714
739
  };
715
740
  ingestSystem(sys);
716
741
  }
@@ -730,7 +755,12 @@ export function buildAnthropicRequestFromOpenAIChat(chatReq) {
730
755
  const text = collectText(contentNode).trim();
731
756
  if (role === 'system') {
732
757
  if (!text) {
733
- throw new Error('Anthropic bridge constraint violated: Chat system message must contain text');
758
+ throw new ProviderProtocolError('Anthropic bridge constraint violated: Chat system message must contain text', {
759
+ code: 'MALFORMED_REQUEST',
760
+ protocol: 'anthropic-messages',
761
+ providerType: 'anthropic',
762
+ details: { context: 'chat.system', original: contentNode }
763
+ });
734
764
  }
735
765
  pushSystemBlock(text);
736
766
  continue;
@@ -738,7 +768,12 @@ export function buildAnthropicRequestFromOpenAIChat(chatReq) {
738
768
  if (role === 'tool') {
739
769
  const toolCallId = requireTrimmedString(m.tool_call_id ?? m.call_id ?? m.tool_use_id ?? m.id, 'tool_result.tool_call_id');
740
770
  if (!knownToolCallIds.has(toolCallId)) {
741
- throw new Error(`Anthropic bridge constraint violated: tool result ${toolCallId} has no matching tool call`);
771
+ throw new ProviderProtocolError(`Anthropic bridge constraint violated: tool result ${toolCallId} has no matching tool call`, {
772
+ code: 'TOOL_PROTOCOL_ERROR',
773
+ protocol: 'anthropic-messages',
774
+ providerType: 'anthropic',
775
+ details: { toolCallId }
776
+ });
742
777
  }
743
778
  const block = {
744
779
  type: 'tool_result',
@@ -0,0 +1,20 @@
1
+ export type ProviderProtocolErrorCode = 'TOOL_PROTOCOL_ERROR' | 'SSE_DECODE_ERROR' | 'MALFORMED_RESPONSE' | 'MALFORMED_REQUEST';
2
+ export type ProviderErrorCategory = 'EXTERNAL_ERROR' | 'TOOL_ERROR' | 'INTERNAL_ERROR';
3
+ export interface ProviderProtocolErrorOptions {
4
+ code: ProviderProtocolErrorCode;
5
+ protocol?: string;
6
+ providerType?: string;
7
+ /**
8
+ * 粗粒度错误类别;若未显式指定,将基于 code 自动推导。
9
+ */
10
+ category?: ProviderErrorCategory;
11
+ details?: Record<string, unknown>;
12
+ }
13
+ export declare class ProviderProtocolError extends Error {
14
+ readonly code: ProviderProtocolErrorCode;
15
+ readonly protocol?: string;
16
+ readonly providerType?: string;
17
+ readonly category: ProviderErrorCategory;
18
+ readonly details?: Record<string, unknown>;
19
+ constructor(message: string, options: ProviderProtocolErrorOptions);
20
+ }
@@ -0,0 +1,28 @@
1
+ function inferCategoryFromCode(code) {
2
+ switch (code) {
3
+ case 'TOOL_PROTOCOL_ERROR':
4
+ return 'TOOL_ERROR';
5
+ case 'SSE_DECODE_ERROR':
6
+ case 'MALFORMED_RESPONSE':
7
+ case 'MALFORMED_REQUEST':
8
+ default:
9
+ // 默认视为外部协议/载荷问题;真正的内部错误由上层使用 INTERNAL_ERROR 显式标记。
10
+ return 'EXTERNAL_ERROR';
11
+ }
12
+ }
13
+ export class ProviderProtocolError extends Error {
14
+ code;
15
+ protocol;
16
+ providerType;
17
+ category;
18
+ details;
19
+ constructor(message, options) {
20
+ super(message);
21
+ this.name = 'ProviderProtocolError';
22
+ this.code = options.code;
23
+ this.protocol = options.protocol;
24
+ this.providerType = options.providerType;
25
+ this.category = options.category ?? inferCategoryFromCode(options.code);
26
+ this.details = options.details;
27
+ }
28
+ }
@@ -1,3 +1,4 @@
1
+ import { ProviderProtocolError } from './errors.js';
1
2
  const TTL_MS = 1000 * 60 * 30; // 30min
2
3
  function cloneDeep(value) {
3
4
  try {
@@ -205,16 +206,42 @@ class ResponsesConversationStore {
205
206
  }
206
207
  resumeConversation(responseId, submitPayload, options) {
207
208
  if (typeof responseId !== 'string' || !responseId.trim()) {
208
- throw new Error('Responses conversation requires valid response_id');
209
+ throw new ProviderProtocolError('Responses conversation requires valid response_id', {
210
+ code: 'MALFORMED_REQUEST',
211
+ protocol: 'openai-responses',
212
+ providerType: 'responses',
213
+ details: {
214
+ context: 'responses-conversation-store.resumeConversation',
215
+ reason: 'missing_or_empty_response_id'
216
+ }
217
+ });
209
218
  }
210
219
  this.prune();
211
220
  const entry = this.responseIndex.get(responseId);
212
221
  if (!entry) {
213
- throw new Error('Responses conversation expired or not found');
222
+ throw new ProviderProtocolError('Responses conversation expired or not found', {
223
+ code: 'MALFORMED_REQUEST',
224
+ protocol: 'openai-responses',
225
+ providerType: 'responses',
226
+ details: {
227
+ context: 'responses-conversation-store.resumeConversation',
228
+ reason: 'expired_or_unknown_response_id',
229
+ responseId
230
+ }
231
+ });
214
232
  }
215
233
  const toolOutputs = Array.isArray(submitPayload.tool_outputs) ? submitPayload.tool_outputs : [];
216
234
  if (!toolOutputs.length) {
217
- throw new Error('tool_outputs array is required when submitting Responses tool results');
235
+ throw new ProviderProtocolError('tool_outputs array is required when submitting Responses tool results', {
236
+ code: 'MALFORMED_REQUEST',
237
+ protocol: 'openai-responses',
238
+ providerType: 'responses',
239
+ details: {
240
+ context: 'responses-conversation-store.resumeConversation',
241
+ reason: 'missing_tool_outputs',
242
+ responseId
243
+ }
244
+ });
218
245
  }
219
246
  const mergedInput = coerceInputArray(entry.input);
220
247
  const normalizedOutputs = normalizeSubmittedToolOutputs(toolOutputs);
@@ -177,6 +177,47 @@ function normalizeUsage(usageRaw) {
177
177
  }
178
178
  return usageRaw;
179
179
  }
180
+ function extractApplyPatchArguments(rawArgs) {
181
+ // Upstream Responses providers may wrap apply_patch arguments in a JSON object
182
+ // (e.g. { patch: '*** Begin Patch...', input: '...' }). For Codex, the tool
183
+ // expects a FREEFORM patch string obeying the unified diff grammar. Here we
184
+ // best-effort extract such a patch string when available.
185
+ const tryExtractFromObject = (obj) => {
186
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj))
187
+ return null;
188
+ const record = obj;
189
+ const candidates = ['patch', 'input'];
190
+ for (const key of candidates) {
191
+ const value = record[key];
192
+ if (typeof value !== 'string')
193
+ continue;
194
+ const trimmed = value.trimStart();
195
+ if (trimmed.startsWith('*** Begin Patch')) {
196
+ return trimmed;
197
+ }
198
+ }
199
+ return null;
200
+ };
201
+ if (typeof rawArgs === 'string') {
202
+ const trimmed = rawArgs.trimStart();
203
+ if (trimmed.startsWith('*** Begin Patch')) {
204
+ return trimmed;
205
+ }
206
+ try {
207
+ const parsed = JSON.parse(rawArgs);
208
+ const fromObject = tryExtractFromObject(parsed);
209
+ if (fromObject) {
210
+ return fromObject;
211
+ }
212
+ }
213
+ catch {
214
+ // non-JSON string that is not a patch header; leave to caller
215
+ }
216
+ return null;
217
+ }
218
+ const fromObject = tryExtractFromObject(rawArgs);
219
+ return fromObject;
220
+ }
180
221
  function buildFunctionCallOutput(call, allocateOutputId, sanitizeFunctionName, baseCount, offset) {
181
222
  try {
182
223
  const fn = call?.function || {};
@@ -187,16 +228,37 @@ function buildFunctionCallOutput(call, allocateOutputId, sanitizeFunctionName, b
187
228
  if (!sanitized || sanitized.toLowerCase() === 'tool')
188
229
  return null;
189
230
  const rawArgs = fn?.arguments ?? call.arguments ?? {};
190
- const argsStr = typeof rawArgs === 'string'
191
- ? rawArgs
192
- : (() => {
231
+ let argsStr;
232
+ if (sanitized === 'apply_patch') {
233
+ const patch = extractApplyPatchArguments(rawArgs);
234
+ if (patch != null) {
235
+ argsStr = patch;
236
+ }
237
+ else if (typeof rawArgs === 'string') {
238
+ argsStr = rawArgs;
239
+ }
240
+ else {
193
241
  try {
194
- return JSON.stringify(rawArgs ?? {});
242
+ argsStr = JSON.stringify(rawArgs ?? {});
195
243
  }
196
244
  catch {
197
- return '{}';
245
+ argsStr = '{}';
198
246
  }
199
- })();
247
+ }
248
+ }
249
+ else {
250
+ argsStr =
251
+ typeof rawArgs === 'string'
252
+ ? rawArgs
253
+ : (() => {
254
+ try {
255
+ return JSON.stringify(rawArgs ?? {});
256
+ }
257
+ catch {
258
+ return '{}';
259
+ }
260
+ })();
261
+ }
200
262
  const originalCallId = typeof call.id === 'string' && call.id.trim().length
201
263
  ? String(call.id)
202
264
  : (typeof call.call_id === 'string' && call.call_id.trim().length ? String(call.call_id) : undefined);
@@ -0,0 +1,13 @@
1
+ import type { Filter, FilterContext, FilterResult, JsonObject } from '../types.js';
2
+ /**
3
+ * Ensure assistant.tool_calls[].function.arguments is a JSON string containing valid JSON.
4
+ * - If arguments is not a string, JSON.stringify it.
5
+ * - If arguments is a string but not parseable as JSON, wrap it into a JSON object so the
6
+ * provider always receives syntactically valid JSON (e.g. {"input": "<raw>"}).
7
+ * Also set assistant.content=null when tool_calls exist (request-side invariant).
8
+ */
9
+ export declare class RequestToolCallsStringifyFilter implements Filter<JsonObject> {
10
+ readonly name = "request_toolcalls_stringify";
11
+ readonly stage: FilterContext['stage'];
12
+ apply(input: JsonObject): FilterResult<JsonObject>;
13
+ }
@@ -1,5 +1,8 @@
1
1
  /**
2
- * Ensure assistant.tool_calls[].function.arguments is a JSON string.
2
+ * Ensure assistant.tool_calls[].function.arguments is a JSON string containing valid JSON.
3
+ * - If arguments is not a string, JSON.stringify it.
4
+ * - If arguments is a string but not parseable as JSON, wrap it into a JSON object so the
5
+ * provider always receives syntactically valid JSON (e.g. {"input": "<raw>"}).
3
6
  * Also set assistant.content=null when tool_calls exist (request-side invariant).
4
7
  */
5
8
  export class RequestToolCallsStringifyFilter {
@@ -18,14 +21,111 @@ export class RequestToolCallsStringifyFilter {
18
21
  if (!tc || typeof tc !== 'object')
19
22
  continue;
20
23
  const fn = tc.function || {};
21
- if (fn && typeof fn === 'object' && fn.arguments !== undefined && typeof fn.arguments !== 'string') {
24
+ if (!fn || typeof fn !== 'object')
25
+ continue;
26
+ const currentArgs = fn.arguments;
27
+ const fnName = typeof fn.name === 'string' ? fn.name.trim() : '';
28
+ // Case 1: non-string arguments → stringify directly
29
+ if (currentArgs !== undefined && typeof currentArgs !== 'string') {
22
30
  try {
23
- fn.arguments = JSON.stringify(fn.arguments ?? {});
31
+ fn.arguments = JSON.stringify(currentArgs ?? {});
24
32
  }
25
33
  catch {
26
34
  fn.arguments = '{}';
27
35
  }
28
36
  tc.function = fn;
37
+ continue;
38
+ }
39
+ // Case 2: string arguments → ensure it is valid JSON
40
+ if (typeof currentArgs === 'string') {
41
+ const trimmed = currentArgs.trim();
42
+ if (trimmed.length === 0) {
43
+ fn.arguments = '{}';
44
+ tc.function = fn;
45
+ continue;
46
+ }
47
+ let parsedOk = false;
48
+ let parsedValue = undefined;
49
+ try {
50
+ parsedValue = JSON.parse(trimmed);
51
+ parsedOk = true;
52
+ }
53
+ catch {
54
+ parsedOk = false;
55
+ }
56
+ if (!parsedOk) {
57
+ // Wrap raw string into a JSON object to keep payload syntactically valid.
58
+ // For shell, align with GLM/统一工具治理约定,优先映射到 { command },
59
+ // 其余模型仍使用 { input } 形式。
60
+ try {
61
+ if (fnName === 'shell') {
62
+ fn.arguments = JSON.stringify({ command: currentArgs });
63
+ }
64
+ else if (fnName === 'apply_patch') {
65
+ fn.arguments = JSON.stringify({ patch: currentArgs });
66
+ }
67
+ else {
68
+ fn.arguments = JSON.stringify({ input: currentArgs });
69
+ }
70
+ }
71
+ catch {
72
+ fn.arguments = '{}';
73
+ }
74
+ tc.function = fn;
75
+ continue;
76
+ }
77
+ // 已经是合法 JSON 的场景下,仅对特定工具做形状修复。
78
+ if (parsedOk && fnName === 'apply_patch') {
79
+ try {
80
+ let obj = parsedValue;
81
+ // 1) 若整体是字符串,则视为补丁文本
82
+ if (typeof obj === 'string') {
83
+ obj = { patch: obj };
84
+ }
85
+ if (obj && typeof obj === 'object') {
86
+ const container = obj;
87
+ const rawPatch = container.patch;
88
+ const rawInput = container.input;
89
+ // 2) 若 patch 是形如 JSON 的字符串,尝试解包 {"input": "..."} 或 {"patch": "..."}
90
+ if (typeof rawPatch === 'string') {
91
+ const ptrim = rawPatch.trim();
92
+ if (ptrim.startsWith('{') && ptrim.endsWith('}')) {
93
+ try {
94
+ const inner = JSON.parse(ptrim);
95
+ if (typeof inner.patch === 'string') {
96
+ container.patch = inner.patch;
97
+ }
98
+ else if (typeof inner.input === 'string') {
99
+ container.patch = inner.input;
100
+ }
101
+ else {
102
+ container.patch = ptrim;
103
+ }
104
+ }
105
+ catch {
106
+ container.patch = rawPatch;
107
+ }
108
+ }
109
+ }
110
+ else if (rawPatch === undefined && typeof rawInput === 'string') {
111
+ // 3) 若只有 input 字段,则复制一份到 patch,避免双层包装
112
+ container.patch = rawInput;
113
+ }
114
+ fn.arguments = JSON.stringify(container);
115
+ tc.function = fn;
116
+ continue;
117
+ }
118
+ }
119
+ catch {
120
+ // 回退到原始字符串形状
121
+ fn.arguments = trimmed;
122
+ tc.function = fn;
123
+ continue;
124
+ }
125
+ }
126
+ // 其它合法 JSON 场景保持原样
127
+ fn.arguments = trimmed;
128
+ tc.function = fn;
29
129
  }
30
130
  }
31
131
  }
@@ -0,0 +1,16 @@
1
+ import type { Filter, FilterContext, FilterResult, JsonObject } from '../types.js';
2
+ /**
3
+ * Canonicalize assistant textual tool markup into tool_calls (Chat path).
4
+ *
5
+ * 行为分两步:
6
+ * 1. 使用 text-markup-normalizer 将纯文本中的工具标记(含 apply_patch)提升为 tool_calls;
7
+ * 2. 再通过 canonicalizeChatResponseTools 统一 tool_calls 形态(content=null、arguments 为字符串等)。
8
+ *
9
+ * 这样可以保证:无论上游 provider 是哪种协议,只要在文本中输出符合规范的 apply_patch / shell 等片段,
10
+ * 在响应侧都会被折叠为标准的 tool_calls,供后续工具治理与客户端透明消费。
11
+ */
12
+ export declare class ResponseToolTextCanonicalizeFilter implements Filter<JsonObject> {
13
+ readonly name = "response_tool_text_canonicalize";
14
+ readonly stage: FilterContext['stage'];
15
+ apply(input: JsonObject): FilterResult<JsonObject>;
16
+ }
@@ -1,16 +1,40 @@
1
1
  /**
2
2
  * Canonicalize assistant textual tool markup into tool_calls (Chat path).
3
- * Wrapper around existing canonicalizer to fit the Filter pipeline.
3
+ *
4
+ * 行为分两步:
5
+ * 1. 使用 text-markup-normalizer 将纯文本中的工具标记(含 apply_patch)提升为 tool_calls;
6
+ * 2. 再通过 canonicalizeChatResponseTools 统一 tool_calls 形态(content=null、arguments 为字符串等)。
7
+ *
8
+ * 这样可以保证:无论上游 provider 是哪种协议,只要在文本中输出符合规范的 apply_patch / shell 等片段,
9
+ * 在响应侧都会被折叠为标准的 tool_calls,供后续工具治理与客户端透明消费。
4
10
  */
5
11
  export class ResponseToolTextCanonicalizeFilter {
6
12
  name = 'response_tool_text_canonicalize';
7
13
  stage = 'response_pre';
8
14
  apply(input) {
9
15
  try {
10
- // Defer to existing canonicalizer for behavior parity
11
16
  // eslint-disable-next-line @typescript-eslint/no-var-requires
12
17
  const { canonicalizeChatResponseTools } = require('../../conversion/shared/tool-canonicalizer.js');
13
- const out = canonicalizeChatResponseTools(input);
18
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
19
+ const { normalizeAssistantTextToToolCalls } = require('../../conversion/shared/text-markup-normalizer.js');
20
+ // 先在文本层面抽取工具调用(apply_patch / shell / MCP 等)
21
+ let working = input && typeof input === 'object' ? JSON.parse(JSON.stringify(input)) : input;
22
+ try {
23
+ const choices = Array.isArray(working?.choices) ? working.choices : [];
24
+ for (const ch of choices) {
25
+ if (!ch || typeof ch !== 'object')
26
+ continue;
27
+ const msg = ch.message;
28
+ if (msg && typeof msg === 'object') {
29
+ ch.message = normalizeAssistantTextToToolCalls(msg);
30
+ }
31
+ }
32
+ working.choices = choices;
33
+ }
34
+ catch {
35
+ // best-effort:文本解析失败时保留原始 payload
36
+ }
37
+ const out = canonicalizeChatResponseTools(working);
14
38
  return { ok: true, data: out };
15
39
  }
16
40
  catch {
@@ -17,8 +17,6 @@ export class RoutingClassifier {
17
17
  const thinkingContinuation = lastToolCategory === 'read';
18
18
  const searchContinuation = lastToolCategory === 'search';
19
19
  const toolsContinuation = lastToolCategory === 'other';
20
- // 用户输入优先级最高(仅次于 vision),确保每次新的用户输入都走 thinking 路由进行工具检查
21
- // thinking_continuation 用于区分"工具轮次中的 read 类调用"与"用户新输入"
22
20
  const evaluationMap = {
23
21
  vision: {
24
22
  triggered: features.hasImageAttachment,
@@ -40,6 +38,10 @@ export class RoutingClassifier {
40
38
  triggered: thinkingContinuation,
41
39
  reason: 'thinking:last-tool-read'
42
40
  },
41
+ search: {
42
+ triggered: searchContinuation,
43
+ reason: 'search:last-tool-search'
44
+ },
43
45
  tools: {
44
46
  triggered: toolsContinuation || features.hasTools || features.hasToolCallResponses,
45
47
  reason: toolsContinuation ? 'tools:last-tool-other' : 'tools:tool-request-detected'
@@ -13,6 +13,7 @@ export declare class VirtualRouterEngine {
13
13
  private healthConfig;
14
14
  private readonly statsCenter;
15
15
  private webSearchForce;
16
+ private routingInstructionState;
16
17
  initialize(config: VirtualRouterConfig): void;
17
18
  route(request: StandardizedRequest | ProcessedRequest, metadata: RouterMetadataInput): {
18
19
  target: TargetMetadata;
@@ -43,6 +44,33 @@ export declare class VirtualRouterEngine {
43
44
  private buildContextCandidatePools;
44
45
  private describeAttempt;
45
46
  private resolveStickyKey;
47
+ private resolveSessionScope;
48
+ private getRoutingInstructionState;
49
+ private buildMetadataInstructions;
50
+ private parseMetadataDisableDescriptor;
51
+ private resolveRoutingMode;
52
+ private resolveInstructionTarget;
53
+ private filterCandidatesByRoutingState;
54
+ private selectFromCandidates;
55
+ private extractProviderId;
56
+ /**
57
+ * 在已有候选路由集合上,筛选出真正挂载了 sticky 池内 providerKey 的路由,
58
+ * 并按 ROUTE_PRIORITY 进行排序;同时显式排除 tools 路由,保证一旦进入
59
+ * sticky 模式,就不会再命中独立的 tools 池(例如 glm/qwen 工具模型)。
60
+ * 若候选集合中完全没有挂载 sticky key 的路由,则尝试在 default 路由上兜底。
61
+ */
62
+ private buildStickyRouteCandidatesFromFiltered;
63
+ /**
64
+ * 在 sticky 模式下,仅在 sticky 池内选择 Provider:
65
+ * - stickyKeySet 表示已经解析并通过健康检查的 providerKey 集合;
66
+ * - 不再依赖 routing[*].targets 中是否挂载这些 key,避免「未初始化路由池」导致 sticky 池为空;
67
+ * - 仍然尊重 allowed/disabledProviders、disabledKeys、disabledModels 以及上下文长度。
68
+ */
69
+ private selectFromStickyPool;
70
+ private extractKeyAlias;
71
+ private normalizeAliasDescriptor;
72
+ private extractKeyIndex;
73
+ private getProviderModelId;
46
74
  private mapProviderError;
47
75
  private deriveReason;
48
76
  private buildRouteCandidates;
@@ -56,6 +84,8 @@ export declare class VirtualRouterEngine {
56
84
  private sortRoutePools;
57
85
  private flattenPoolTargets;
58
86
  private buildHitReason;
87
+ private isRoutingStateEmpty;
88
+ private persistRoutingInstructionState;
59
89
  private decorateWithDetail;
60
90
  private formatVirtualRouterHit;
61
91
  private resolveRouteColor;