@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.
@@ -1,17 +1,17 @@
1
1
  {
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": []
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
- "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
- }
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
- "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
- }
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
- "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
- }
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
- "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"
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
- "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
- }
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
  }
@@ -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
- throw new Error('Responses conversation requires valid response_id');
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
- throw new Error('Responses conversation expired or not found');
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
- throw new Error('tool_outputs array is required when submitting Responses tool results');
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 codingContinuation = lastToolCategory === 'write';
18
- const thinkingContinuation = lastToolCategory === 'read';
19
- const searchContinuation = lastToolCategory === 'search';
20
- const toolsContinuation = lastToolCategory === 'other';
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: features.hasWebTool || searchContinuation,
32
- reason: searchContinuation ? 'websearch:last-tool-search' : 'websearch:web-tools-detected'
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 || features.hasTools || features.hasToolCallResponses,
44
- reason: toolsContinuation ? 'tools:last-tool-other' : 'tools:tool-request-detected'
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
- const classification = this.classifier.classify(features);
33
- const routeName = classification.routeName || DEFAULT_ROUTE;
34
- const selection = this.selectProvider(routeName, metadata, classification);
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
- this.debug?.log?.('[virtual-router-hit]', selection.routeUsed, selection.providerKey, target.modelId || '');
39
- const didFallback = selection.routeUsed !== routeName || classification.fallback;
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 candidates = this.buildRouteCandidates(requestedRoute, classification.candidates);
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
- throw new VirtualRouterError(`All providers unavailable for route ${requestedRoute}`, VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, { routeName: requestedRoute, attempted });
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
- // 400 / 429 作为明确可恢复池:走限流通道,不做长期拉黑
187
- if (statusCode === 429 || code.includes('429') || statusCode === 400 || code.includes('400')) {
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: true,
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 lastAssistantTool = detectLastAssistantToolCategory(assistantMessages);
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: lastAssistantTool?.category,
153
- lastAssistantToolName: lastAssistantTool?.name,
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 detectLastAssistantToolCategory(messages) {
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
- let fallback;
308
+ const classifications = [];
309
+ let usedWebSearchTool = false;
304
310
  for (const call of msg.tool_calls) {
305
311
  const classification = classifyToolCall(call);
306
- if (!classification) {
307
- continue;
308
- }
309
- if (!fallback) {
310
- fallback = classification;
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 (fallback) {
317
- return fallback;
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
- return undefined;
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 { category: shellCategory, name: functionName };
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
- return args.map((item) => (typeof item === 'string' ? item : '')).filter(Boolean).join(' ');
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
- return command.map((item) => (typeof item === 'string' ? item : '')).filter(Boolean).join(' ');
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
- return nestedArgs.map((item) => (typeof item === 'string' ? item : '')).filter(Boolean).join(' ');
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,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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsonstudio/llms",
3
- "version": "0.6.097",
3
+ "version": "0.6.123",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",