@jsonstudio/llms 0.6.97 → 0.6.123
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/conversion/compat/profiles/chat-glm.json +15 -15
- package/dist/conversion/compat/profiles/chat-iflow.json +34 -34
- package/dist/conversion/compat/profiles/chat-lmstudio.json +35 -35
- package/dist/conversion/compat/profiles/chat-qwen.json +16 -16
- package/dist/conversion/compat/profiles/responses-c4m.json +42 -42
- package/dist/conversion/hub/pipeline/stages/resp_outbound/resp_outbound_stage1_client_remap/index.js +12 -0
- package/dist/conversion/shared/responses-conversation-store.js +26 -3
- package/dist/router/virtual-router/classifier.js +42 -8
- package/dist/router/virtual-router/engine.d.ts +27 -0
- package/dist/router/virtual-router/engine.js +395 -17
- package/dist/router/virtual-router/features.js +183 -21
- package/dist/router/virtual-router/types.d.ts +4 -0
- package/dist/test-output/virtual-router/results.json +1 -0
- package/dist/test-output/virtual-router/summary.json +12 -0
- package/package.json +1 -1
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
2
|
+
"id": "chat:glm",
|
|
3
|
+
"protocol": "openai-chat",
|
|
4
|
+
"direction": "request",
|
|
5
|
+
"mappings": [
|
|
6
|
+
{
|
|
7
|
+
"action": "rename",
|
|
8
|
+
"from": "response_format",
|
|
9
|
+
"to": "metadata.generation.response_format"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"action": "remove",
|
|
13
|
+
"path": "metadata.clientModelId"
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"filters": []
|
|
17
17
|
}
|
|
@@ -1,36 +1,36 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
2
|
+
"id": "chat:iflow",
|
|
3
|
+
"protocol": "openai-chat",
|
|
4
|
+
"request": {
|
|
5
|
+
"mappings": [
|
|
6
|
+
{
|
|
7
|
+
"action": "remove",
|
|
8
|
+
"path": "metadata.toolCallIdStyle"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"action": "remove",
|
|
12
|
+
"path": "metadata.clientModelId"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"action": "remove",
|
|
16
|
+
"path": "metadata.providerHint"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"response": {
|
|
21
|
+
"mappings": [
|
|
22
|
+
{
|
|
23
|
+
"action": "rename",
|
|
24
|
+
"from": "created_at",
|
|
25
|
+
"to": "created"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"action": "convert_responses_output_to_choices"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"action": "stringify",
|
|
32
|
+
"path": "choices[*].message.tool_calls[*].function.arguments"
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
36
|
}
|
|
@@ -1,37 +1,37 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
2
|
+
"id": "chat:lmstudio",
|
|
3
|
+
"protocol": "openai-chat",
|
|
4
|
+
"request": {
|
|
5
|
+
"mappings": [
|
|
6
|
+
{
|
|
7
|
+
"action": "normalize_tool_choice",
|
|
8
|
+
"path": "tool_choice",
|
|
9
|
+
"objectReplacement": "required"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
"response": {
|
|
14
|
+
"mappings": [
|
|
15
|
+
{
|
|
16
|
+
"action": "set_default",
|
|
17
|
+
"path": "object",
|
|
18
|
+
"value": "chat.completion"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"action": "set_default",
|
|
22
|
+
"path": "id",
|
|
23
|
+
"valueSource": "chat_completion_id"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"action": "set_default",
|
|
27
|
+
"path": "created",
|
|
28
|
+
"valueSource": "timestamp_seconds"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"action": "set_default",
|
|
32
|
+
"path": "model",
|
|
33
|
+
"value": "unknown"
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
37
|
}
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
2
|
+
"id": "chat:qwen",
|
|
3
|
+
"protocol": "openai-chat",
|
|
4
|
+
"request": {
|
|
5
|
+
"mappings": [
|
|
6
|
+
{
|
|
7
|
+
"action": "parse_json",
|
|
8
|
+
"path": "messages[*].tool_calls[*].function.arguments",
|
|
9
|
+
"fallback": {}
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"action": "stringify",
|
|
13
|
+
"path": "messages[*].tool_calls[*].function.arguments",
|
|
14
|
+
"fallback": {}
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
18
|
}
|
|
@@ -1,45 +1,45 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
]
|
|
34
|
-
}
|
|
2
|
+
"id": "responses:c4m",
|
|
3
|
+
"protocol": "openai-responses",
|
|
4
|
+
"request": {
|
|
5
|
+
"mappings": [
|
|
6
|
+
{
|
|
7
|
+
"action": "remove",
|
|
8
|
+
"path": "max_tokens"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"action": "remove",
|
|
12
|
+
"path": "maxTokens"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"action": "remove",
|
|
16
|
+
"path": "max_output_tokens"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"action": "remove",
|
|
20
|
+
"path": "maxOutputTokens"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"action": "inject_instruction",
|
|
24
|
+
"sourcePath": "instructions",
|
|
25
|
+
"targetPath": "input",
|
|
26
|
+
"role": "system",
|
|
27
|
+
"contentType": "input_text",
|
|
28
|
+
"stripHtml": true,
|
|
29
|
+
"maxLengthEnv": [
|
|
30
|
+
"ROUTECODEX_C4M_INSTRUCTIONS_MAX",
|
|
31
|
+
"RCC_C4M_INSTRUCTIONS_MAX",
|
|
32
|
+
"ROUTECODEX_COMPAT_INSTRUCTIONS_MAX"
|
|
35
33
|
]
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
"response": {
|
|
38
|
+
"filters": [
|
|
39
|
+
{
|
|
40
|
+
"action": "rate_limit_text",
|
|
41
|
+
"needle": "The Codex-For.ME service is available, but you have reached the request limit"
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
45
|
}
|
package/dist/conversion/hub/pipeline/stages/resp_outbound/resp_outbound_stage1_client_remap/index.js
CHANGED
|
@@ -62,4 +62,16 @@ function mergeOriginalResponsesPayload(payload, adapterContext) {
|
|
|
62
62
|
if (rawStatus === 'requires_action') {
|
|
63
63
|
payload.status = 'requires_action';
|
|
64
64
|
}
|
|
65
|
+
// 如果桥接后的 payload 没有 usage,而原始 Responses 载荷带有 usage,则回填原始 usage,
|
|
66
|
+
// 确保 token usage 不在工具/桥接路径中丢失。
|
|
67
|
+
const payloadUsage = payload.usage;
|
|
68
|
+
const rawUsage = raw.usage;
|
|
69
|
+
if ((payloadUsage == null || typeof payloadUsage !== 'object') && rawUsage && typeof rawUsage === 'object') {
|
|
70
|
+
try {
|
|
71
|
+
payload.usage = JSON.parse(JSON.stringify(rawUsage));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
payload.usage = rawUsage;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
65
77
|
}
|
|
@@ -205,16 +205,29 @@ class ResponsesConversationStore {
|
|
|
205
205
|
}
|
|
206
206
|
resumeConversation(responseId, submitPayload, options) {
|
|
207
207
|
if (typeof responseId !== 'string' || !responseId.trim()) {
|
|
208
|
-
|
|
208
|
+
raiseResumeError('Responses conversation requires valid response_id', {
|
|
209
|
+
code: 'RESPONSES_RESUME_MISSING_ID',
|
|
210
|
+
status: 422,
|
|
211
|
+
origin: 'client'
|
|
212
|
+
});
|
|
209
213
|
}
|
|
210
214
|
this.prune();
|
|
211
215
|
const entry = this.responseIndex.get(responseId);
|
|
212
216
|
if (!entry) {
|
|
213
|
-
|
|
217
|
+
raiseResumeError('Responses conversation expired or not found', {
|
|
218
|
+
code: 'RESPONSES_RESUME_NOT_FOUND',
|
|
219
|
+
status: 500,
|
|
220
|
+
origin: 'server',
|
|
221
|
+
details: { responseId }
|
|
222
|
+
});
|
|
214
223
|
}
|
|
215
224
|
const toolOutputs = Array.isArray(submitPayload.tool_outputs) ? submitPayload.tool_outputs : [];
|
|
216
225
|
if (!toolOutputs.length) {
|
|
217
|
-
|
|
226
|
+
raiseResumeError('tool_outputs array is required when submitting Responses tool results', {
|
|
227
|
+
code: 'RESPONSES_RESUME_MISSING_OUTPUTS',
|
|
228
|
+
status: 422,
|
|
229
|
+
origin: 'client'
|
|
230
|
+
});
|
|
218
231
|
}
|
|
219
232
|
const mergedInput = coerceInputArray(entry.input);
|
|
220
233
|
const normalizedOutputs = normalizeSubmittedToolOutputs(toolOutputs);
|
|
@@ -281,6 +294,16 @@ class ResponsesConversationStore {
|
|
|
281
294
|
}
|
|
282
295
|
const store = new ResponsesConversationStore();
|
|
283
296
|
const RESPONSES_DEBUG = (process.env.ROUTECODEX_RESPONSES_DEBUG || '').trim() === '1';
|
|
297
|
+
function raiseResumeError(message, options) {
|
|
298
|
+
const err = new Error(message);
|
|
299
|
+
err.code = options?.code ?? 'RESPONSES_RESUME_ERROR';
|
|
300
|
+
err.status = options?.status;
|
|
301
|
+
err.origin = options?.origin;
|
|
302
|
+
if (options?.details) {
|
|
303
|
+
err.details = options.details;
|
|
304
|
+
}
|
|
305
|
+
throw err;
|
|
306
|
+
}
|
|
284
307
|
export function captureResponsesRequestContext(args) {
|
|
285
308
|
try {
|
|
286
309
|
if (RESPONSES_DEBUG) {
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import { DEFAULT_ROUTE, ROUTE_PRIORITY } from './types.js';
|
|
2
2
|
const DEFAULT_LONG_CONTEXT_THRESHOLD = 180000;
|
|
3
|
+
const WEBSEARCH_HINT_KEYWORDS = [
|
|
4
|
+
'web search',
|
|
5
|
+
'search the web',
|
|
6
|
+
'search online',
|
|
7
|
+
'internet search',
|
|
8
|
+
'search internet',
|
|
9
|
+
'google it',
|
|
10
|
+
'bing it',
|
|
11
|
+
'网络搜索',
|
|
12
|
+
'上网搜索',
|
|
13
|
+
'查一下网络',
|
|
14
|
+
'搜一下网络'
|
|
15
|
+
];
|
|
3
16
|
export class RoutingClassifier {
|
|
4
17
|
config;
|
|
5
18
|
constructor(config) {
|
|
@@ -11,13 +24,28 @@ export class RoutingClassifier {
|
|
|
11
24
|
}
|
|
12
25
|
classify(features) {
|
|
13
26
|
const lastToolCategory = features.lastAssistantToolCategory;
|
|
27
|
+
const toolCategories = features.assistantToolCategories ?? [];
|
|
28
|
+
const hasSearchToolCall = toolCategories.includes('search');
|
|
29
|
+
const hasWriteToolCall = toolCategories.includes('write');
|
|
30
|
+
const hasReadToolCall = toolCategories.includes('read');
|
|
31
|
+
const hasOtherToolCall = toolCategories.includes('other');
|
|
32
|
+
const hasToolCall = toolCategories.length > 0;
|
|
14
33
|
const reachedLongContext = features.estimatedTokens >= (this.config.longContextThresholdTokens ?? DEFAULT_LONG_CONTEXT_THRESHOLD);
|
|
15
34
|
const thinkingKeywordHit = features.hasThinkingKeyword ||
|
|
16
35
|
containsKeywords(features.userTextSample, this.config.thinkingKeywords ?? []);
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
36
|
+
const routeHint = typeof features.metadata?.routeHint === 'string'
|
|
37
|
+
? features.metadata.routeHint.trim().toLowerCase()
|
|
38
|
+
: undefined;
|
|
39
|
+
const websearchKeywordHit = containsKeywords(features.userTextSample, WEBSEARCH_HINT_KEYWORDS);
|
|
40
|
+
const codingContinuation = hasWriteToolCall || lastToolCategory === 'write';
|
|
41
|
+
const thinkingContinuation = hasReadToolCall || lastToolCategory === 'read';
|
|
42
|
+
const searchContinuation = features.assistantCalledWebSearchTool === true;
|
|
43
|
+
const toolsContinuation = hasOtherToolCall ||
|
|
44
|
+
searchContinuation ||
|
|
45
|
+
(hasToolCall && !hasSearchToolCall && !hasWriteToolCall && !hasReadToolCall);
|
|
46
|
+
const toolContinuationReason = hasOtherToolCall
|
|
47
|
+
? formatToolContinuationReason(features.lastAssistantToolName, features.lastAssistantToolDetail)
|
|
48
|
+
: 'tools:tool-call-detected';
|
|
21
49
|
const evaluationMap = {
|
|
22
50
|
vision: {
|
|
23
51
|
triggered: features.hasVisionTool && features.hasImageAttachment,
|
|
@@ -28,8 +56,8 @@ export class RoutingClassifier {
|
|
|
28
56
|
reason: 'longcontext:token-threshold'
|
|
29
57
|
},
|
|
30
58
|
websearch: {
|
|
31
|
-
triggered:
|
|
32
|
-
reason:
|
|
59
|
+
triggered: routeHint === 'websearch' || websearchKeywordHit,
|
|
60
|
+
reason: routeHint === 'websearch' ? 'websearch:route-hint' : 'websearch:keywords'
|
|
33
61
|
},
|
|
34
62
|
coding: {
|
|
35
63
|
triggered: codingContinuation,
|
|
@@ -40,8 +68,8 @@ export class RoutingClassifier {
|
|
|
40
68
|
reason: thinkingContinuation ? 'thinking:last-tool-read' : 'thinking:keywords'
|
|
41
69
|
},
|
|
42
70
|
tools: {
|
|
43
|
-
triggered: toolsContinuation
|
|
44
|
-
reason:
|
|
71
|
+
triggered: toolsContinuation,
|
|
72
|
+
reason: toolContinuationReason
|
|
45
73
|
},
|
|
46
74
|
background: {
|
|
47
75
|
triggered: containsKeywords(features.userTextSample, this.config.backgroundKeywords ?? []),
|
|
@@ -100,3 +128,9 @@ function containsKeywords(text, keywords) {
|
|
|
100
128
|
const normalized = text.toLowerCase();
|
|
101
129
|
return keywords.some((keyword) => normalized.includes(keyword));
|
|
102
130
|
}
|
|
131
|
+
function formatToolContinuationReason(toolName, toolDetail) {
|
|
132
|
+
const trimmedName = toolName?.trim() || 'tool';
|
|
133
|
+
const trimmedDetail = toolDetail?.trim();
|
|
134
|
+
const detailText = trimmedDetail ? `${trimmedName}: ${trimmedDetail}` : trimmedName;
|
|
135
|
+
return `tools:last-tool-other(${detailText})`;
|
|
136
|
+
}
|
|
@@ -9,6 +9,9 @@ export declare class VirtualRouterEngine {
|
|
|
9
9
|
private routeStats;
|
|
10
10
|
private readonly debug;
|
|
11
11
|
private healthConfig;
|
|
12
|
+
private stickyPlans;
|
|
13
|
+
private selectionHistory;
|
|
14
|
+
private providerErrorStreaks;
|
|
12
15
|
initialize(config: VirtualRouterConfig): void;
|
|
13
16
|
route(request: StandardizedRequest | ProcessedRequest, metadata: RouterMetadataInput): {
|
|
14
17
|
target: TargetMetadata;
|
|
@@ -25,6 +28,20 @@ export declare class VirtualRouterEngine {
|
|
|
25
28
|
}>;
|
|
26
29
|
health: import("./types.js").ProviderHealthState[];
|
|
27
30
|
};
|
|
31
|
+
private consumeSticky;
|
|
32
|
+
private selectStickyTarget;
|
|
33
|
+
private buildStickyClassification;
|
|
34
|
+
private recordSelectionSnapshot;
|
|
35
|
+
private buildStickyPlan;
|
|
36
|
+
private storeStickyPlan;
|
|
37
|
+
private resolveStickyDescriptor;
|
|
38
|
+
private maybeForceStickyFromHistory;
|
|
39
|
+
private shouldForceApplyPatchSticky;
|
|
40
|
+
private extractPreviousRequestId;
|
|
41
|
+
private pruneStickyPlans;
|
|
42
|
+
private buildErrorSignature;
|
|
43
|
+
private bumpProviderErrorStreak;
|
|
44
|
+
private resetProviderErrorStreak;
|
|
28
45
|
private validateConfig;
|
|
29
46
|
private selectProvider;
|
|
30
47
|
private incrementRouteStat;
|
|
@@ -33,6 +50,16 @@ export declare class VirtualRouterEngine {
|
|
|
33
50
|
private mapProviderError;
|
|
34
51
|
private deriveReason;
|
|
35
52
|
private buildRouteCandidates;
|
|
53
|
+
private ensureConfiguredClassification;
|
|
54
|
+
private normalizeCandidateList;
|
|
55
|
+
private normalizeRouteName;
|
|
56
|
+
private isRouteConfigured;
|
|
36
57
|
private sortByPriority;
|
|
37
58
|
private routeWeight;
|
|
59
|
+
private buildHitReason;
|
|
60
|
+
private formatToolIdentifier;
|
|
61
|
+
private decorateReason;
|
|
62
|
+
private buildVirtualRouterHitLog;
|
|
63
|
+
private colorizeVirtualRouterLog;
|
|
64
|
+
private shouldColorVirtualRouterLogs;
|
|
38
65
|
}
|
|
@@ -4,6 +4,11 @@ import { RouteLoadBalancer } from './load-balancer.js';
|
|
|
4
4
|
import { RoutingClassifier } from './classifier.js';
|
|
5
5
|
import { buildRoutingFeatures } from './features.js';
|
|
6
6
|
import { DEFAULT_ROUTE, ROUTE_PRIORITY, VirtualRouterError, VirtualRouterErrorCode } from './types.js';
|
|
7
|
+
const VIRTUAL_ROUTER_HIT_COLOR = '\x1b[38;5;208m';
|
|
8
|
+
const ANSI_RESET = '\x1b[0m';
|
|
9
|
+
const STICKY_PLAN_TTL_MS = 30 * 60 * 1000;
|
|
10
|
+
const ERROR_STREAK_TTL_MS = 10 * 60 * 1000;
|
|
11
|
+
const ERROR_STREAK_THRESHOLD = 4;
|
|
7
12
|
export class VirtualRouterEngine {
|
|
8
13
|
routing = {};
|
|
9
14
|
providerRegistry = new ProviderRegistry();
|
|
@@ -13,6 +18,9 @@ export class VirtualRouterEngine {
|
|
|
13
18
|
routeStats = new Map();
|
|
14
19
|
debug = console; // thin hook; host may monkey-patch for colored logging
|
|
15
20
|
healthConfig = null;
|
|
21
|
+
stickyPlans = new Map();
|
|
22
|
+
selectionHistory = new Map();
|
|
23
|
+
providerErrorStreaks = new Map();
|
|
16
24
|
initialize(config) {
|
|
17
25
|
this.validateConfig(config);
|
|
18
26
|
this.routing = config.routing;
|
|
@@ -29,14 +37,42 @@ export class VirtualRouterEngine {
|
|
|
29
37
|
}
|
|
30
38
|
route(request, metadata) {
|
|
31
39
|
const features = buildRoutingFeatures(request, metadata);
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
let stickyActivation = this.consumeSticky(metadata, features);
|
|
41
|
+
let classification = null;
|
|
42
|
+
let selection = null;
|
|
43
|
+
if (stickyActivation) {
|
|
44
|
+
selection = this.selectStickyTarget(stickyActivation, metadata);
|
|
45
|
+
if (selection) {
|
|
46
|
+
classification = this.buildStickyClassification(stickyActivation);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
stickyActivation = null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (!selection || !classification) {
|
|
53
|
+
classification = this.classifier.classify(features);
|
|
54
|
+
classification = this.ensureConfiguredClassification(classification);
|
|
55
|
+
const routeName = classification.routeName || DEFAULT_ROUTE;
|
|
56
|
+
selection = this.selectProvider(routeName, metadata, classification);
|
|
57
|
+
}
|
|
58
|
+
if (!selection || !classification) {
|
|
59
|
+
throw new VirtualRouterError('Virtual router failed to select provider', VirtualRouterErrorCode.ROUTE_NOT_FOUND);
|
|
60
|
+
}
|
|
35
61
|
const target = this.providerRegistry.buildTarget(selection.providerKey);
|
|
36
62
|
this.healthManager.recordSuccess(selection.providerKey);
|
|
63
|
+
this.resetProviderErrorStreak(selection.providerKey);
|
|
37
64
|
this.incrementRouteStat(selection.routeUsed, selection.providerKey);
|
|
38
|
-
|
|
39
|
-
|
|
65
|
+
const targetModel = typeof target.modelId === 'string' ? target.modelId : '';
|
|
66
|
+
this.recordSelectionSnapshot(metadata.requestId, selection.routeUsed, selection.providerKey, targetModel);
|
|
67
|
+
if (!stickyActivation || stickyActivation.mode === 'forced') {
|
|
68
|
+
const nextPlan = this.buildStickyPlan(features, selection, target);
|
|
69
|
+
this.storeStickyPlan(metadata.requestId, nextPlan);
|
|
70
|
+
}
|
|
71
|
+
const phase = stickyActivation ? 'execution' : 'hit';
|
|
72
|
+
const hitReason = this.buildHitReason(selection.routeUsed, classification, features, stickyActivation || undefined, phase);
|
|
73
|
+
const hitLog = this.buildVirtualRouterHitLog(selection.routeUsed, selection.providerKey, targetModel, hitReason, stickyActivation || undefined, phase);
|
|
74
|
+
this.debug?.log?.(hitLog);
|
|
75
|
+
const didFallback = selection.routeUsed !== (classification.routeName || DEFAULT_ROUTE) || classification.fallback;
|
|
40
76
|
return {
|
|
41
77
|
target,
|
|
42
78
|
decision: {
|
|
@@ -93,6 +129,199 @@ export class VirtualRouterEngine {
|
|
|
93
129
|
health: this.healthManager.getSnapshot()
|
|
94
130
|
};
|
|
95
131
|
}
|
|
132
|
+
consumeSticky(metadata, features) {
|
|
133
|
+
const prevId = this.extractPreviousRequestId(metadata);
|
|
134
|
+
if (!prevId) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
this.pruneStickyPlans();
|
|
138
|
+
const planned = this.stickyPlans.get(prevId);
|
|
139
|
+
if (planned) {
|
|
140
|
+
this.stickyPlans.delete(prevId);
|
|
141
|
+
const activation = {
|
|
142
|
+
...planned,
|
|
143
|
+
sourceRequestId: prevId,
|
|
144
|
+
mode: 'planned'
|
|
145
|
+
};
|
|
146
|
+
if (planned.remainingRounds > 1 && metadata.requestId) {
|
|
147
|
+
this.stickyPlans.set(metadata.requestId, {
|
|
148
|
+
...planned,
|
|
149
|
+
remainingRounds: planned.remainingRounds - 1,
|
|
150
|
+
createdAt: Date.now()
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return activation;
|
|
154
|
+
}
|
|
155
|
+
return this.maybeForceStickyFromHistory(prevId, features);
|
|
156
|
+
}
|
|
157
|
+
selectStickyTarget(sticky, metadata) {
|
|
158
|
+
if (sticky.strategy === 'target' && sticky.providerKey) {
|
|
159
|
+
if (!this.healthManager.isAvailable(sticky.providerKey)) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
const pool = this.routing[sticky.routeName] ?? [];
|
|
163
|
+
return { providerKey: sticky.providerKey, routeUsed: sticky.routeName, pool };
|
|
164
|
+
}
|
|
165
|
+
const pool = this.routing[sticky.routeName];
|
|
166
|
+
if (!Array.isArray(pool) || pool.length === 0) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
const stub = {
|
|
170
|
+
routeName: sticky.routeName,
|
|
171
|
+
confidence: 1,
|
|
172
|
+
reasoning: `sticky:${sticky.reason}`,
|
|
173
|
+
fallback: false,
|
|
174
|
+
candidates: [sticky.routeName]
|
|
175
|
+
};
|
|
176
|
+
return this.selectProvider(sticky.routeName, metadata, stub);
|
|
177
|
+
}
|
|
178
|
+
buildStickyClassification(sticky) {
|
|
179
|
+
return {
|
|
180
|
+
routeName: sticky.routeName,
|
|
181
|
+
confidence: 1,
|
|
182
|
+
reasoning: `sticky:${sticky.reason}`,
|
|
183
|
+
fallback: false,
|
|
184
|
+
candidates: [sticky.routeName]
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
recordSelectionSnapshot(requestId, routeName, providerKey, modelId) {
|
|
188
|
+
if (!requestId || !providerKey) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
this.selectionHistory.set(requestId, {
|
|
192
|
+
routeName,
|
|
193
|
+
providerKey,
|
|
194
|
+
modelId,
|
|
195
|
+
createdAt: Date.now()
|
|
196
|
+
});
|
|
197
|
+
this.pruneStickyPlans();
|
|
198
|
+
}
|
|
199
|
+
buildStickyPlan(features, selection, target) {
|
|
200
|
+
const descriptor = this.resolveStickyDescriptor(selection.routeUsed, features);
|
|
201
|
+
if (!descriptor || descriptor.rounds <= 0) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const plan = {
|
|
205
|
+
routeName: selection.routeUsed,
|
|
206
|
+
strategy: descriptor.strategy,
|
|
207
|
+
providerKey: descriptor.strategy === 'target' ? selection.providerKey : undefined,
|
|
208
|
+
modelId: descriptor.strategy === 'target' ? target.modelId : undefined,
|
|
209
|
+
remainingRounds: descriptor.rounds,
|
|
210
|
+
totalRounds: descriptor.rounds,
|
|
211
|
+
reason: descriptor.reason,
|
|
212
|
+
createdAt: Date.now()
|
|
213
|
+
};
|
|
214
|
+
return plan;
|
|
215
|
+
}
|
|
216
|
+
storeStickyPlan(requestId, plan) {
|
|
217
|
+
if (!requestId) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
this.pruneStickyPlans();
|
|
221
|
+
if (plan && plan.remainingRounds > 0) {
|
|
222
|
+
this.stickyPlans.set(requestId, plan);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
this.stickyPlans.delete(requestId);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
resolveStickyDescriptor(routeName, features) {
|
|
229
|
+
if (this.shouldForceApplyPatchSticky(features)) {
|
|
230
|
+
return { strategy: 'target', rounds: 1, reason: 'apply_patch' };
|
|
231
|
+
}
|
|
232
|
+
if (routeName === 'coding' || routeName === 'thinking') {
|
|
233
|
+
return { strategy: 'pool', rounds: 3, reason: routeName };
|
|
234
|
+
}
|
|
235
|
+
if (routeName === 'tools') {
|
|
236
|
+
return { strategy: 'pool', rounds: 0, reason: routeName };
|
|
237
|
+
}
|
|
238
|
+
if (routeName === DEFAULT_ROUTE || !routeName) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
return { strategy: 'pool', rounds: 1, reason: routeName };
|
|
242
|
+
}
|
|
243
|
+
maybeForceStickyFromHistory(prevId, features) {
|
|
244
|
+
if (!this.shouldForceApplyPatchSticky(features)) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
const snapshot = this.selectionHistory.get(prevId);
|
|
248
|
+
if (!snapshot) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
if (!this.healthManager.isAvailable(snapshot.providerKey)) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
routeName: snapshot.routeName,
|
|
256
|
+
providerKey: snapshot.providerKey,
|
|
257
|
+
modelId: snapshot.modelId,
|
|
258
|
+
strategy: 'target',
|
|
259
|
+
remainingRounds: 0,
|
|
260
|
+
totalRounds: 1,
|
|
261
|
+
reason: 'apply_patch',
|
|
262
|
+
createdAt: Date.now(),
|
|
263
|
+
sourceRequestId: prevId,
|
|
264
|
+
mode: 'forced'
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
shouldForceApplyPatchSticky(features) {
|
|
268
|
+
const name = (features.lastAssistantToolName || '').toLowerCase();
|
|
269
|
+
if (name === 'apply_patch') {
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
const detail = (features.lastAssistantToolDetail || '').toLowerCase();
|
|
273
|
+
if (detail.includes('apply_patch')) {
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
extractPreviousRequestId(metadata) {
|
|
279
|
+
const resume = metadata.responsesResume;
|
|
280
|
+
if (resume && typeof resume.previousRequestId === 'string' && resume.previousRequestId.trim()) {
|
|
281
|
+
return resume.previousRequestId.trim();
|
|
282
|
+
}
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
pruneStickyPlans() {
|
|
286
|
+
const cutoff = Date.now() - STICKY_PLAN_TTL_MS;
|
|
287
|
+
for (const [key, plan] of this.stickyPlans.entries()) {
|
|
288
|
+
if (!plan || plan.createdAt < cutoff) {
|
|
289
|
+
this.stickyPlans.delete(key);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
for (const [key, snapshot] of this.selectionHistory.entries()) {
|
|
293
|
+
if (!snapshot || snapshot.createdAt < cutoff) {
|
|
294
|
+
this.selectionHistory.delete(key);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
buildErrorSignature(code, statusCode, message) {
|
|
299
|
+
const normalizedMessage = typeof message === 'string'
|
|
300
|
+
? message.trim().toLowerCase().replace(/\s+/g, ' ').slice(0, 120)
|
|
301
|
+
: '';
|
|
302
|
+
const codeToken = code?.toUpperCase() || 'ERR_UNKNOWN';
|
|
303
|
+
const statusToken = typeof statusCode === 'number' ? String(statusCode) : 'NA';
|
|
304
|
+
return `${statusToken}|${codeToken}|${normalizedMessage}`;
|
|
305
|
+
}
|
|
306
|
+
bumpProviderErrorStreak(providerKey, signature) {
|
|
307
|
+
if (!providerKey || !signature) {
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
const now = Date.now();
|
|
311
|
+
const entry = this.providerErrorStreaks.get(providerKey);
|
|
312
|
+
if (!entry || entry.signature !== signature || now - entry.lastAt > ERROR_STREAK_TTL_MS) {
|
|
313
|
+
this.providerErrorStreaks.set(providerKey, { signature, count: 1, lastAt: now });
|
|
314
|
+
return 1;
|
|
315
|
+
}
|
|
316
|
+
const next = { signature, count: entry.count + 1, lastAt: now };
|
|
317
|
+
this.providerErrorStreaks.set(providerKey, next);
|
|
318
|
+
return next.count;
|
|
319
|
+
}
|
|
320
|
+
resetProviderErrorStreak(providerKey) {
|
|
321
|
+
if (providerKey) {
|
|
322
|
+
this.providerErrorStreaks.delete(providerKey);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
96
325
|
validateConfig(config) {
|
|
97
326
|
if (!config.routing || typeof config.routing !== 'object') {
|
|
98
327
|
throw new VirtualRouterError('routing configuration is required', VirtualRouterErrorCode.CONFIG_ERROR);
|
|
@@ -120,7 +349,8 @@ export class VirtualRouterEngine {
|
|
|
120
349
|
}
|
|
121
350
|
}
|
|
122
351
|
selectProvider(requestedRoute, metadata, classification) {
|
|
123
|
-
const
|
|
352
|
+
const normalizedRoute = this.normalizeRouteName(requestedRoute);
|
|
353
|
+
const candidates = this.buildRouteCandidates(normalizedRoute, classification.candidates);
|
|
124
354
|
const stickyKey = this.resolveStickyKey(metadata);
|
|
125
355
|
const attempted = [];
|
|
126
356
|
for (const routeName of candidates) {
|
|
@@ -140,7 +370,8 @@ export class VirtualRouterEngine {
|
|
|
140
370
|
}
|
|
141
371
|
attempted.push(routeName);
|
|
142
372
|
}
|
|
143
|
-
|
|
373
|
+
const failureRoute = attempted.length ? attempted[attempted.length - 1] : requestedRoute;
|
|
374
|
+
throw new VirtualRouterError(`All providers unavailable for route ${failureRoute}`, VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, { routeName: failureRoute, attempted });
|
|
144
375
|
}
|
|
145
376
|
incrementRouteStat(routeName, providerKey) {
|
|
146
377
|
if (!this.routeStats.has(routeName)) {
|
|
@@ -179,22 +410,23 @@ export class VirtualRouterEngine {
|
|
|
179
410
|
const code = event.code?.toUpperCase() ?? 'ERR_UNKNOWN';
|
|
180
411
|
const stage = event.stage?.toLowerCase() ?? 'unknown';
|
|
181
412
|
const recoverable = event.recoverable === true;
|
|
182
|
-
//
|
|
413
|
+
// 默认策略:只有显式可恢复的错误才视为非致命;其余一律按致命处理。
|
|
414
|
+
// 注意:provider 层已经对 429 做了「连续 4 次升级为不可恢复」的判断,这里不再把所有 429 强行当作可恢复。
|
|
183
415
|
let fatal = !recoverable;
|
|
184
416
|
let reason = this.deriveReason(code, stage, statusCode);
|
|
185
417
|
let cooldownOverrideMs;
|
|
186
|
-
//
|
|
187
|
-
if (statusCode ===
|
|
188
|
-
fatal = false;
|
|
189
|
-
cooldownOverrideMs = Math.max(30_000, this.providerHealthConfig().cooldownMs);
|
|
190
|
-
reason = 'rate_limit';
|
|
191
|
-
// 401 / 402 / 500 / 524 以及所有未被标记为可恢复的错误一律视为不可恢复
|
|
192
|
-
}
|
|
193
|
-
else if (statusCode === 401 || statusCode === 402 || statusCode === 403 || code.includes('AUTH')) {
|
|
418
|
+
// 401 / 402 / 500 / 524 以及所有未被标记为可恢复的错误一律视为不可恢复
|
|
419
|
+
if (statusCode === 401 || statusCode === 402 || statusCode === 403 || code.includes('AUTH')) {
|
|
194
420
|
fatal = true;
|
|
195
421
|
cooldownOverrideMs = Math.max(10 * 60_000, this.providerHealthConfig().fatalCooldownMs ?? 10 * 60_000);
|
|
196
422
|
reason = 'auth';
|
|
197
423
|
}
|
|
424
|
+
else if (statusCode === 429 && !recoverable) {
|
|
425
|
+
// 连续 429 已在 provider 层被升级为不可恢复:这里按致命限流处理(长冷却,等同熔断)
|
|
426
|
+
fatal = true;
|
|
427
|
+
cooldownOverrideMs = Math.max(10 * 60_000, this.providerHealthConfig().fatalCooldownMs ?? 10 * 60_000);
|
|
428
|
+
reason = 'rate_limit';
|
|
429
|
+
}
|
|
198
430
|
else if (statusCode && statusCode >= 500) {
|
|
199
431
|
fatal = true;
|
|
200
432
|
cooldownOverrideMs = Math.max(5 * 60_000, this.providerHealthConfig().fatalCooldownMs ?? 5 * 60_000);
|
|
@@ -205,6 +437,13 @@ export class VirtualRouterEngine {
|
|
|
205
437
|
cooldownOverrideMs = Math.max(10 * 60_000, this.providerHealthConfig().fatalCooldownMs ?? 10 * 60_000);
|
|
206
438
|
reason = 'compatibility';
|
|
207
439
|
}
|
|
440
|
+
const signature = this.buildErrorSignature(code, statusCode, event.message);
|
|
441
|
+
const streak = this.bumpProviderErrorStreak(providerKey, signature);
|
|
442
|
+
if (streak >= ERROR_STREAK_THRESHOLD) {
|
|
443
|
+
fatal = true;
|
|
444
|
+
reason = reason === 'unknown' ? 'repeated_error' : `${reason}|repeated`;
|
|
445
|
+
cooldownOverrideMs = Math.max(this.providerHealthConfig().fatalCooldownMs ?? 5 * 60_000, 5 * 60_000);
|
|
446
|
+
}
|
|
208
447
|
return {
|
|
209
448
|
providerKey,
|
|
210
449
|
routeName,
|
|
@@ -213,7 +452,8 @@ export class VirtualRouterEngine {
|
|
|
213
452
|
statusCode,
|
|
214
453
|
errorCode: code,
|
|
215
454
|
retryable: recoverable,
|
|
216
|
-
affectsHealth
|
|
455
|
+
// 是否影响健康由 provider 层决定;这里仅在 event.affectsHealth !== false 时才计入健康状态
|
|
456
|
+
affectsHealth: event.affectsHealth !== false,
|
|
217
457
|
cooldownOverrideMs,
|
|
218
458
|
metadata: {
|
|
219
459
|
...event.runtime,
|
|
@@ -267,6 +507,59 @@ export class VirtualRouterEngine {
|
|
|
267
507
|
}
|
|
268
508
|
return filtered.length ? filtered : [DEFAULT_ROUTE];
|
|
269
509
|
}
|
|
510
|
+
ensureConfiguredClassification(classification) {
|
|
511
|
+
const normalizedRoute = this.normalizeRouteName(classification.routeName);
|
|
512
|
+
const normalizedCandidates = this.normalizeCandidateList(normalizedRoute, classification.candidates);
|
|
513
|
+
const fallback = normalizedRoute === DEFAULT_ROUTE ? true : classification.fallback;
|
|
514
|
+
return {
|
|
515
|
+
...classification,
|
|
516
|
+
routeName: normalizedRoute,
|
|
517
|
+
fallback,
|
|
518
|
+
candidates: normalizedCandidates
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
normalizeCandidateList(primaryRoute, rawCandidates) {
|
|
522
|
+
const base = rawCandidates && rawCandidates.length ? rawCandidates : [primaryRoute];
|
|
523
|
+
const deduped = [];
|
|
524
|
+
for (const routeName of base) {
|
|
525
|
+
if (!routeName) {
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
if (!this.isRouteConfigured(routeName)) {
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
if (!deduped.includes(routeName)) {
|
|
532
|
+
deduped.push(routeName);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (!deduped.includes(primaryRoute) && this.isRouteConfigured(primaryRoute)) {
|
|
536
|
+
deduped.push(primaryRoute);
|
|
537
|
+
}
|
|
538
|
+
if (!deduped.includes(DEFAULT_ROUTE) && this.isRouteConfigured(DEFAULT_ROUTE)) {
|
|
539
|
+
deduped.push(DEFAULT_ROUTE);
|
|
540
|
+
}
|
|
541
|
+
if (!deduped.length && this.isRouteConfigured(DEFAULT_ROUTE)) {
|
|
542
|
+
deduped.push(DEFAULT_ROUTE);
|
|
543
|
+
}
|
|
544
|
+
return this.sortByPriority(deduped);
|
|
545
|
+
}
|
|
546
|
+
normalizeRouteName(routeName) {
|
|
547
|
+
if (routeName && this.isRouteConfigured(routeName)) {
|
|
548
|
+
return routeName;
|
|
549
|
+
}
|
|
550
|
+
if (this.isRouteConfigured(DEFAULT_ROUTE)) {
|
|
551
|
+
return DEFAULT_ROUTE;
|
|
552
|
+
}
|
|
553
|
+
const firstConfigured = Object.keys(this.routing).find((key) => this.isRouteConfigured(key));
|
|
554
|
+
return firstConfigured || DEFAULT_ROUTE;
|
|
555
|
+
}
|
|
556
|
+
isRouteConfigured(routeName) {
|
|
557
|
+
if (!routeName) {
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
const pool = this.routing[routeName];
|
|
561
|
+
return Array.isArray(pool) && pool.length > 0;
|
|
562
|
+
}
|
|
270
563
|
sortByPriority(routeNames) {
|
|
271
564
|
return [...routeNames].sort((a, b) => this.routeWeight(a) - this.routeWeight(b));
|
|
272
565
|
}
|
|
@@ -274,4 +567,89 @@ export class VirtualRouterEngine {
|
|
|
274
567
|
const idx = ROUTE_PRIORITY.indexOf(routeName);
|
|
275
568
|
return idx >= 0 ? idx : ROUTE_PRIORITY.length;
|
|
276
569
|
}
|
|
570
|
+
buildHitReason(routeUsed, classification, features, sticky, phase) {
|
|
571
|
+
const reasoning = classification.reasoning || '';
|
|
572
|
+
const primary = reasoning.split('|')[0] || '';
|
|
573
|
+
const lastToolName = features.lastAssistantToolName;
|
|
574
|
+
const lastToolDetail = features.lastAssistantToolDetail;
|
|
575
|
+
if (routeUsed === 'tools') {
|
|
576
|
+
const decoratedTools = lastToolName ? this.formatToolIdentifier(lastToolName, lastToolDetail) : null;
|
|
577
|
+
const base = primary
|
|
578
|
+
? decoratedTools
|
|
579
|
+
? `${primary}(${decoratedTools})`
|
|
580
|
+
: primary
|
|
581
|
+
: decoratedTools
|
|
582
|
+
? `tools(${decoratedTools})`
|
|
583
|
+
: 'tools';
|
|
584
|
+
return this.decorateReason(base, sticky, phase);
|
|
585
|
+
}
|
|
586
|
+
if (routeUsed === 'thinking') {
|
|
587
|
+
return this.decorateReason(primary || 'thinking', sticky, phase);
|
|
588
|
+
}
|
|
589
|
+
if (routeUsed === DEFAULT_ROUTE && classification.fallback) {
|
|
590
|
+
return this.decorateReason(primary || 'fallback:default', sticky, phase);
|
|
591
|
+
}
|
|
592
|
+
if (primary) {
|
|
593
|
+
return this.decorateReason(primary, sticky, phase);
|
|
594
|
+
}
|
|
595
|
+
return this.decorateReason(routeUsed ? `route:${routeUsed}` : 'route:unknown', sticky, phase);
|
|
596
|
+
}
|
|
597
|
+
formatToolIdentifier(name, detail) {
|
|
598
|
+
if (!detail) {
|
|
599
|
+
return name;
|
|
600
|
+
}
|
|
601
|
+
return `${name}:${detail}`;
|
|
602
|
+
}
|
|
603
|
+
decorateReason(base, sticky, phase) {
|
|
604
|
+
let result = base;
|
|
605
|
+
if (sticky) {
|
|
606
|
+
result = `${result}|sticky:${sticky.reason}`;
|
|
607
|
+
}
|
|
608
|
+
if (phase === 'execution') {
|
|
609
|
+
result = `${result}|phase=execution`;
|
|
610
|
+
}
|
|
611
|
+
return result;
|
|
612
|
+
}
|
|
613
|
+
buildVirtualRouterHitLog(route, providerKey, modelId, reason, sticky, phase) {
|
|
614
|
+
const parts = ['[virtual-router-hit]', route, providerKey];
|
|
615
|
+
if (modelId) {
|
|
616
|
+
parts.push(modelId);
|
|
617
|
+
}
|
|
618
|
+
if (reason) {
|
|
619
|
+
parts.push(`reason=${reason}`);
|
|
620
|
+
}
|
|
621
|
+
parts.push(`phase=${phase}`);
|
|
622
|
+
if (sticky) {
|
|
623
|
+
const total = Math.max(1, sticky.totalRounds || sticky.remainingRounds || 1);
|
|
624
|
+
const consumed = Math.max(1, Math.min(total, total - sticky.remainingRounds));
|
|
625
|
+
const descriptor = `${sticky.strategy}:${sticky.reason}[${consumed}/${total};${sticky.mode}]`;
|
|
626
|
+
parts.push(`sticky=${descriptor}`);
|
|
627
|
+
}
|
|
628
|
+
const message = parts.filter((segment) => typeof segment === 'string' && segment.length > 0).join(' ');
|
|
629
|
+
return this.colorizeVirtualRouterLog(message);
|
|
630
|
+
}
|
|
631
|
+
colorizeVirtualRouterLog(message) {
|
|
632
|
+
if (!this.shouldColorVirtualRouterLogs()) {
|
|
633
|
+
return message;
|
|
634
|
+
}
|
|
635
|
+
return `${VIRTUAL_ROUTER_HIT_COLOR}${message}${ANSI_RESET}`;
|
|
636
|
+
}
|
|
637
|
+
shouldColorVirtualRouterLogs() {
|
|
638
|
+
if (typeof process === 'undefined') {
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
641
|
+
const noColor = String(process.env.NO_COLOR ?? process.env.RCC_NO_COLOR ?? '').toLowerCase();
|
|
642
|
+
if (noColor === '1' || noColor === 'true') {
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
const forceColor = String(process.env.FORCE_COLOR ?? '').trim();
|
|
646
|
+
if (forceColor === '0') {
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
const stdout = process.stdout;
|
|
650
|
+
if (stdout && stdout.isTTY === false) {
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
277
655
|
}
|
|
@@ -43,6 +43,8 @@ const WRITE_TOOL_KEYWORDS = ['write', 'patch', 'modify', 'edit', 'create', 'upda
|
|
|
43
43
|
const SEARCH_TOOL_KEYWORDS = ['search', 'websearch', 'web_fetch', 'webfetch', 'web-request', 'web_request', 'internet'];
|
|
44
44
|
const SHELL_TOOL_NAMES = new Set(['shell_command', 'shell', 'bash']);
|
|
45
45
|
const SHELL_HEREDOC_PATTERN = /<<\s*['"]?[a-z0-9_-]+/i;
|
|
46
|
+
const COMMAND_DETAIL_MAX_LENGTH = 80;
|
|
47
|
+
const TOOL_CATEGORY_PRIORITY = ['search', 'write', 'read', 'other'];
|
|
46
48
|
const SHELL_WRITE_PATTERNS = [
|
|
47
49
|
'apply_patch',
|
|
48
50
|
'sed -i',
|
|
@@ -134,7 +136,7 @@ export function buildRoutingFeatures(request, metadata) {
|
|
|
134
136
|
const hasCodingTool = detectCodingTool(request);
|
|
135
137
|
const hasWebTool = detectWebTool(request);
|
|
136
138
|
const hasThinkingKeyword = hasThinking || detectExtendedThinkingKeyword(normalizedUserText);
|
|
137
|
-
const
|
|
139
|
+
const assistantToolSummary = summarizeAssistantToolUsage(assistantMessages);
|
|
138
140
|
return {
|
|
139
141
|
requestId: metadata.requestId,
|
|
140
142
|
model: request.model,
|
|
@@ -149,8 +151,11 @@ export function buildRoutingFeatures(request, metadata) {
|
|
|
149
151
|
hasCodingTool,
|
|
150
152
|
hasThinkingKeyword,
|
|
151
153
|
estimatedTokens,
|
|
152
|
-
lastAssistantToolCategory:
|
|
153
|
-
lastAssistantToolName:
|
|
154
|
+
lastAssistantToolCategory: assistantToolSummary.primary?.category,
|
|
155
|
+
lastAssistantToolName: assistantToolSummary.primary?.name,
|
|
156
|
+
lastAssistantToolDetail: assistantToolSummary.primary?.detail,
|
|
157
|
+
assistantToolCategories: assistantToolSummary.categories,
|
|
158
|
+
assistantCalledWebSearchTool: assistantToolSummary.usedWebSearchTool,
|
|
154
159
|
metadata: {
|
|
155
160
|
...metadata
|
|
156
161
|
}
|
|
@@ -294,30 +299,73 @@ function extractToolDescription(tool) {
|
|
|
294
299
|
}
|
|
295
300
|
return '';
|
|
296
301
|
}
|
|
297
|
-
function
|
|
302
|
+
function summarizeAssistantToolUsage(messages) {
|
|
298
303
|
for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
|
|
299
304
|
const msg = messages[idx];
|
|
300
305
|
if (!msg || !Array.isArray(msg.tool_calls) || msg.tool_calls.length === 0) {
|
|
301
306
|
continue;
|
|
302
307
|
}
|
|
303
|
-
|
|
308
|
+
const classifications = [];
|
|
309
|
+
let usedWebSearchTool = false;
|
|
304
310
|
for (const call of msg.tool_calls) {
|
|
305
311
|
const classification = classifyToolCall(call);
|
|
306
|
-
if (
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
312
|
-
if (classification.category !== 'other') {
|
|
313
|
-
return classification;
|
|
312
|
+
if (classification) {
|
|
313
|
+
classifications.push(classification);
|
|
314
|
+
if (classification.category === 'search' && isWebSearchToolInvocation(classification)) {
|
|
315
|
+
usedWebSearchTool = true;
|
|
316
|
+
}
|
|
314
317
|
}
|
|
315
318
|
}
|
|
316
|
-
if (
|
|
317
|
-
|
|
319
|
+
if (!classifications.length) {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const categorySet = new Set();
|
|
323
|
+
for (const classification of classifications) {
|
|
324
|
+
categorySet.add(classification.category);
|
|
325
|
+
}
|
|
326
|
+
const categories = orderToolCategories(Array.from(categorySet));
|
|
327
|
+
const primary = classifications.find((classification) => classification.category !== 'other') ?? classifications[0];
|
|
328
|
+
return {
|
|
329
|
+
categories,
|
|
330
|
+
primary,
|
|
331
|
+
usedWebSearchTool
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
return { categories: [], usedWebSearchTool: false };
|
|
335
|
+
}
|
|
336
|
+
function orderToolCategories(categories) {
|
|
337
|
+
const ordered = [];
|
|
338
|
+
for (const category of TOOL_CATEGORY_PRIORITY) {
|
|
339
|
+
if (categories.includes(category)) {
|
|
340
|
+
ordered.push(category);
|
|
318
341
|
}
|
|
319
342
|
}
|
|
320
|
-
|
|
343
|
+
for (const category of categories) {
|
|
344
|
+
if (!ordered.includes(category)) {
|
|
345
|
+
ordered.push(category);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return ordered;
|
|
349
|
+
}
|
|
350
|
+
function isWebSearchToolName(name) {
|
|
351
|
+
const normalized = name.toLowerCase();
|
|
352
|
+
if (SEARCH_TOOL_EXACT.has(normalized)) {
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
return WEB_TOOL_KEYWORDS.some((keyword) => normalized.includes(keyword.toLowerCase()));
|
|
356
|
+
}
|
|
357
|
+
function isWebSearchToolInvocation(classification) {
|
|
358
|
+
if (!classification) {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
if (isWebSearchToolName(classification.name)) {
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
if (classification.detail) {
|
|
365
|
+
const detail = classification.detail.toLowerCase();
|
|
366
|
+
return WEB_TOOL_KEYWORDS.some((keyword) => detail.includes(keyword.toLowerCase()));
|
|
367
|
+
}
|
|
368
|
+
return false;
|
|
321
369
|
}
|
|
322
370
|
function classifyToolCall(call) {
|
|
323
371
|
if (!call || typeof call !== 'object') {
|
|
@@ -330,14 +378,19 @@ function classifyToolCall(call) {
|
|
|
330
378
|
return undefined;
|
|
331
379
|
}
|
|
332
380
|
const argsObject = parseToolArguments(call?.function?.arguments);
|
|
333
|
-
const commandText = extractCommandText(argsObject);
|
|
381
|
+
const commandText = extractCommandText(argsObject).trim();
|
|
382
|
+
const commandDetail = summarizeCommandDetail(commandText);
|
|
334
383
|
const nameCategory = categorizeToolName(functionName);
|
|
335
384
|
if (nameCategory === 'write' || nameCategory === 'read' || nameCategory === 'search') {
|
|
336
385
|
return { category: nameCategory, name: functionName };
|
|
337
386
|
}
|
|
338
387
|
if (SHELL_TOOL_NAMES.has(functionName)) {
|
|
339
388
|
const shellCategory = classifyShellCommand(commandText);
|
|
340
|
-
return {
|
|
389
|
+
return {
|
|
390
|
+
category: shellCategory,
|
|
391
|
+
name: functionName,
|
|
392
|
+
detail: commandDetail
|
|
393
|
+
};
|
|
341
394
|
}
|
|
342
395
|
if (commandText) {
|
|
343
396
|
const derivedCategory = classifyShellCommand(commandText);
|
|
@@ -380,7 +433,12 @@ function extractCommandText(args) {
|
|
|
380
433
|
return args;
|
|
381
434
|
}
|
|
382
435
|
if (Array.isArray(args)) {
|
|
383
|
-
|
|
436
|
+
const tokens = collectCommandTokens(args);
|
|
437
|
+
if (tokens.length) {
|
|
438
|
+
return tokens.join(' ');
|
|
439
|
+
}
|
|
440
|
+
const derived = extractFirstStringValue(args);
|
|
441
|
+
return derived ?? '';
|
|
384
442
|
}
|
|
385
443
|
if (typeof args === 'object') {
|
|
386
444
|
const record = args;
|
|
@@ -391,7 +449,20 @@ function extractCommandText(args) {
|
|
|
391
449
|
return command;
|
|
392
450
|
}
|
|
393
451
|
if (Array.isArray(command)) {
|
|
394
|
-
|
|
452
|
+
const tokens = collectCommandTokens(command);
|
|
453
|
+
if (tokens.length) {
|
|
454
|
+
return tokens.join(' ');
|
|
455
|
+
}
|
|
456
|
+
const derivedCommand = extractFirstStringValue(command);
|
|
457
|
+
if (derivedCommand) {
|
|
458
|
+
return derivedCommand;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (command && typeof command === 'object') {
|
|
462
|
+
const derivedCommand = extractFirstStringValue(command);
|
|
463
|
+
if (derivedCommand) {
|
|
464
|
+
return derivedCommand;
|
|
465
|
+
}
|
|
395
466
|
}
|
|
396
467
|
if (typeof input === 'string') {
|
|
397
468
|
return input;
|
|
@@ -400,7 +471,30 @@ function extractCommandText(args) {
|
|
|
400
471
|
return nestedArgs;
|
|
401
472
|
}
|
|
402
473
|
if (Array.isArray(nestedArgs)) {
|
|
403
|
-
|
|
474
|
+
const tokens = collectCommandTokens(nestedArgs);
|
|
475
|
+
if (tokens.length) {
|
|
476
|
+
return tokens.join(' ');
|
|
477
|
+
}
|
|
478
|
+
const derivedArgs = extractFirstStringValue(nestedArgs);
|
|
479
|
+
if (derivedArgs) {
|
|
480
|
+
return derivedArgs;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (nestedArgs && typeof nestedArgs === 'object') {
|
|
484
|
+
const derivedArgs = extractFirstStringValue(nestedArgs);
|
|
485
|
+
if (derivedArgs) {
|
|
486
|
+
return derivedArgs;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const fallback = extractFirstStringValue(record);
|
|
490
|
+
if (fallback) {
|
|
491
|
+
return fallback;
|
|
492
|
+
}
|
|
493
|
+
try {
|
|
494
|
+
return JSON.stringify(record);
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
return '';
|
|
404
498
|
}
|
|
405
499
|
}
|
|
406
500
|
return '';
|
|
@@ -489,3 +583,71 @@ function trimEnclosingQuotes(value) {
|
|
|
489
583
|
}
|
|
490
584
|
return value;
|
|
491
585
|
}
|
|
586
|
+
function summarizeCommandDetail(command) {
|
|
587
|
+
if (!command) {
|
|
588
|
+
return undefined;
|
|
589
|
+
}
|
|
590
|
+
const [firstSegment] = splitCommandSegments(command);
|
|
591
|
+
const candidate = firstSegment ?? command;
|
|
592
|
+
const normalized = candidate.replace(/\s+/g, ' ').trim();
|
|
593
|
+
if (!normalized) {
|
|
594
|
+
return undefined;
|
|
595
|
+
}
|
|
596
|
+
if (normalized.length > COMMAND_DETAIL_MAX_LENGTH) {
|
|
597
|
+
return `${normalized.slice(0, COMMAND_DETAIL_MAX_LENGTH - 1)}…`;
|
|
598
|
+
}
|
|
599
|
+
return normalized;
|
|
600
|
+
}
|
|
601
|
+
function collectCommandTokens(values) {
|
|
602
|
+
const tokens = [];
|
|
603
|
+
for (const value of values) {
|
|
604
|
+
if (typeof value === 'string') {
|
|
605
|
+
const trimmed = value.trim();
|
|
606
|
+
if (trimmed) {
|
|
607
|
+
tokens.push(trimmed);
|
|
608
|
+
}
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
if (Array.isArray(value)) {
|
|
612
|
+
const nested = collectCommandTokens(value);
|
|
613
|
+
if (nested.length) {
|
|
614
|
+
tokens.push(nested.join(' '));
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (value && typeof value === 'object') {
|
|
619
|
+
const extracted = extractFirstStringValue(value);
|
|
620
|
+
if (extracted) {
|
|
621
|
+
tokens.push(extracted.trim());
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return tokens.filter(Boolean).slice(0, 16);
|
|
626
|
+
}
|
|
627
|
+
function extractFirstStringValue(value) {
|
|
628
|
+
if (!value) {
|
|
629
|
+
return undefined;
|
|
630
|
+
}
|
|
631
|
+
if (typeof value === 'string') {
|
|
632
|
+
const trimmed = value.trim();
|
|
633
|
+
return trimmed || undefined;
|
|
634
|
+
}
|
|
635
|
+
if (Array.isArray(value)) {
|
|
636
|
+
for (const item of value) {
|
|
637
|
+
const extracted = extractFirstStringValue(item);
|
|
638
|
+
if (extracted) {
|
|
639
|
+
return extracted;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return undefined;
|
|
643
|
+
}
|
|
644
|
+
if (typeof value === 'object') {
|
|
645
|
+
for (const candidate of Object.values(value)) {
|
|
646
|
+
const extracted = extractFirstStringValue(candidate);
|
|
647
|
+
if (extracted) {
|
|
648
|
+
return extracted;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return undefined;
|
|
653
|
+
}
|
|
@@ -118,6 +118,9 @@ export interface RoutingFeatures {
|
|
|
118
118
|
estimatedTokens: number;
|
|
119
119
|
lastAssistantToolCategory?: 'read' | 'write' | 'search' | 'other';
|
|
120
120
|
lastAssistantToolName?: string;
|
|
121
|
+
lastAssistantToolDetail?: string;
|
|
122
|
+
assistantToolCategories?: Array<'read' | 'write' | 'search' | 'other'>;
|
|
123
|
+
assistantCalledWebSearchTool?: boolean;
|
|
121
124
|
metadata: RouterMetadataInput;
|
|
122
125
|
}
|
|
123
126
|
export interface ClassificationResult {
|
|
@@ -210,6 +213,7 @@ export interface ProviderErrorEvent {
|
|
|
210
213
|
stage: string;
|
|
211
214
|
status?: number;
|
|
212
215
|
recoverable?: boolean;
|
|
216
|
+
affectsHealth?: boolean;
|
|
213
217
|
runtime: ProviderErrorRuntimeMetadata;
|
|
214
218
|
timestamp: number;
|
|
215
219
|
details?: Record<string, unknown>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"samplesRoot": "/Users/fanzhang/.routecodex/codex-samples",
|
|
3
|
+
"configPath": "/Users/fanzhang/Documents/github/sharedmodule/llmswitch-core/test/virtual-router/virtual-router.config.json",
|
|
4
|
+
"stats": {
|
|
5
|
+
"totalSamples": 0,
|
|
6
|
+
"processed": 0,
|
|
7
|
+
"routes": {},
|
|
8
|
+
"providers": {},
|
|
9
|
+
"errors": [],
|
|
10
|
+
"scenarios": {}
|
|
11
|
+
}
|
|
12
|
+
}
|