@jsonstudio/llms 0.6.74 → 0.6.104

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.
@@ -44,6 +44,7 @@ export interface HubPipelineResult {
44
44
  export declare class HubPipeline {
45
45
  private readonly routerEngine;
46
46
  private config;
47
+ private unsubscribeProviderErrors?;
47
48
  constructor(config: HubPipelineConfig);
48
49
  updateVirtualRouterConfig(nextConfig: VirtualRouterConfig): void;
49
50
  private executeRequestStagePipeline;
@@ -1,5 +1,6 @@
1
1
  import { Readable } from 'node:stream';
2
2
  import { VirtualRouterEngine } from '../../../router/virtual-router/engine.js';
3
+ import { providerErrorCenter } from '../../../router/virtual-router/error-center.js';
3
4
  import { defaultSseCodecRegistry } from '../../../sse/index.js';
4
5
  import { ResponsesFormatAdapter } from '../format-adapters/responses-format-adapter.js';
5
6
  import { ResponsesSemanticMapper } from '../semantic-mappers/responses-mapper.js';
@@ -22,10 +23,24 @@ import { runReqOutboundStage3Compat } from './stages/req_outbound/req_outbound_s
22
23
  export class HubPipeline {
23
24
  routerEngine;
24
25
  config;
26
+ unsubscribeProviderErrors;
25
27
  constructor(config) {
26
28
  this.config = config;
27
29
  this.routerEngine = new VirtualRouterEngine();
28
30
  this.routerEngine.initialize(config.virtualRouter);
31
+ try {
32
+ this.unsubscribeProviderErrors = providerErrorCenter.subscribe((event) => {
33
+ try {
34
+ this.routerEngine.handleProviderError(event);
35
+ }
36
+ catch {
37
+ // ignore subscriber errors
38
+ }
39
+ });
40
+ }
41
+ catch {
42
+ this.unsubscribeProviderErrors = undefined;
43
+ }
29
44
  }
30
45
  updateVirtualRouterConfig(nextConfig) {
31
46
  if (!nextConfig || typeof nextConfig !== 'object') {
@@ -92,6 +107,10 @@ export class HubPipeline {
92
107
  }
93
108
  }
94
109
  const workingRequest = processedRequest ?? standardizedRequest;
110
+ const normalizedMeta = normalized.metadata;
111
+ const responsesResume = normalizedMeta && typeof normalizedMeta.responsesResume === 'object'
112
+ ? normalizedMeta.responsesResume
113
+ : undefined;
95
114
  const metadataInput = {
96
115
  requestId: normalized.id,
97
116
  entryEndpoint: normalized.entryEndpoint,
@@ -100,7 +119,8 @@ export class HubPipeline {
100
119
  direction: normalized.direction,
101
120
  providerProtocol: normalized.providerProtocol,
102
121
  routeHint: normalized.routeHint,
103
- stage: normalized.stage
122
+ stage: normalized.stage,
123
+ responsesResume: responsesResume
104
124
  };
105
125
  const routing = runReqProcessStage2RouteSelect({
106
126
  routerEngine: this.routerEngine,
@@ -109,6 +129,19 @@ export class HubPipeline {
109
129
  normalizedMetadata: normalized.metadata,
110
130
  stageRecorder: inboundRecorder
111
131
  });
132
+ // Emit virtual router hit log for debugging (orange [virtual-router] ...)
133
+ try {
134
+ const routeName = routing.decision?.routeName;
135
+ const providerKey = routing.target?.providerKey;
136
+ const modelId = workingRequest.model;
137
+ const logger = (normalized.metadata && normalized.metadata.logger);
138
+ if (logger && typeof logger.logVirtualRouterHit === 'function' && routeName && providerKey) {
139
+ logger.logVirtualRouterHit(routeName, providerKey, typeof modelId === 'string' ? modelId : undefined);
140
+ }
141
+ }
142
+ catch {
143
+ // logging must not break routing
144
+ }
112
145
  const outboundAdapterContext = this.buildAdapterContext(normalized, routing.target);
113
146
  if (routing.target?.compatibilityProfile) {
114
147
  outboundAdapterContext.compatibilityProfile = routing.target.compatibilityProfile;
@@ -15,6 +15,7 @@ export function runRespOutboundStage1ClientRemap(options) {
15
15
  clientPayload = buildResponsesPayloadFromChat(options.payload, {
16
16
  requestId: options.requestId
17
17
  });
18
+ mergeOriginalResponsesPayload(clientPayload, options.adapterContext);
18
19
  }
19
20
  recordStage(options.stageRecorder, 'resp_outbound_stage1_client_remap', clientPayload);
20
21
  return clientPayload;
@@ -41,3 +42,36 @@ function resolveAliasMapFromContext(adapterContext) {
41
42
  }
42
43
  return Object.keys(map).length ? map : undefined;
43
44
  }
45
+ function mergeOriginalResponsesPayload(payload, adapterContext) {
46
+ if (!adapterContext) {
47
+ return;
48
+ }
49
+ const raw = adapterContext.__raw_responses_payload;
50
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
51
+ return;
52
+ }
53
+ try {
54
+ if (payload.required_action == null && raw.required_action != null) {
55
+ payload.required_action = JSON.parse(JSON.stringify(raw.required_action));
56
+ }
57
+ }
58
+ catch {
59
+ /* ignore clone errors */
60
+ }
61
+ const rawStatus = typeof raw.status === 'string' ? raw.status : undefined;
62
+ if (rawStatus === 'requires_action') {
63
+ payload.status = 'requires_action';
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
+ }
77
+ }
@@ -44,7 +44,10 @@ async function applyRequestToolGovernance(request, context) {
44
44
  model: request.model,
45
45
  profile: providerProtocol,
46
46
  stream: inboundStreamIntent,
47
- toolFilterHints: metadataToolHints
47
+ toolFilterHints: metadataToolHints,
48
+ rawPayload: context.metadata?.__raw_request_body && typeof context.metadata.__raw_request_body === 'object'
49
+ ? context.metadata.__raw_request_body
50
+ : undefined
48
51
  });
49
52
  const governed = normalizeRecord(governedPayload);
50
53
  const providerStreamIntent = typeof governed.stream === 'boolean' ? governed.stream : undefined;
@@ -117,6 +117,14 @@ export async function convertProviderResponse(options) {
117
117
  catch {
118
118
  // ignore conversation capture errors
119
119
  }
120
+ if (formatEnvelope.payload && typeof formatEnvelope.payload === 'object') {
121
+ try {
122
+ options.context.__raw_responses_payload = JSON.parse(JSON.stringify(formatEnvelope.payload));
123
+ }
124
+ catch {
125
+ /* best-effort clone */
126
+ }
127
+ }
120
128
  }
121
129
  formatEnvelope.payload = runRespInboundStageCompatResponse({
122
130
  payload: formatEnvelope.payload,
@@ -0,0 +1,21 @@
1
+ import { ToolFilterHints } from '../../filters/index.js';
2
+ interface RequestFilterOptions {
3
+ entryEndpoint?: string;
4
+ requestId?: string;
5
+ model?: string;
6
+ profile?: string;
7
+ stream?: boolean;
8
+ toolFilterHints?: ToolFilterHints;
9
+ /**
10
+ * Optional raw payload snapshot for local tool governance (e.g. view_image exposure).
11
+ */
12
+ rawPayload?: Record<string, unknown>;
13
+ }
14
+ interface ResponseFilterOptions {
15
+ entryEndpoint?: string;
16
+ requestId?: string;
17
+ profile?: string;
18
+ }
19
+ export declare function runChatRequestToolFilters(chatRequest: any, options?: RequestFilterOptions): Promise<any>;
20
+ export declare function runChatResponseToolFilters(chatJson: any, options?: ResponseFilterOptions): Promise<any>;
21
+ export {};
@@ -45,7 +45,8 @@ export async function runChatRequestToolFilters(chatRequest, options = {}) {
45
45
  return;
46
46
  snapshot(stage, payload);
47
47
  };
48
- recordStage('req_process_tool_filters_input', chatRequest);
48
+ const preFiltered = applyLocalToolGovernance(chatRequest, options.rawPayload);
49
+ recordStage('req_process_tool_filters_input', preFiltered);
49
50
  const engine = new FilterEngine();
50
51
  const registeredStages = new Set();
51
52
  const register = (filter) => {
@@ -90,7 +91,7 @@ export async function runChatRequestToolFilters(chatRequest, options = {}) {
90
91
  // optional; keep prior behavior when filter not available
91
92
  }
92
93
  assertStageCoverage('request', registeredStages, REQUEST_FILTER_STAGES);
93
- let staged = chatRequest;
94
+ let staged = preFiltered;
94
95
  for (const stage of REQUEST_FILTER_STAGES) {
95
96
  staged = await engine.run(stage, staged, reqCtxBase);
96
97
  recordStage(`req_process_tool_filters_${stage}`, staged);
@@ -98,6 +99,79 @@ export async function runChatRequestToolFilters(chatRequest, options = {}) {
98
99
  recordStage('req_process_tool_filters_output', staged);
99
100
  return staged;
100
101
  }
102
+ function applyLocalToolGovernance(chatRequest, rawPayload) {
103
+ if (!chatRequest || typeof chatRequest !== 'object') {
104
+ return chatRequest;
105
+ }
106
+ const messages = Array.isArray(chatRequest.messages) ? chatRequest.messages : undefined;
107
+ const tools = Array.isArray(chatRequest.tools) ? chatRequest.tools : undefined;
108
+ if (!tools || !tools.length) {
109
+ return chatRequest;
110
+ }
111
+ const hasImageHint = detectImageHint(messages, rawPayload);
112
+ if (hasImageHint) {
113
+ return chatRequest;
114
+ }
115
+ const filteredTools = tools.filter((tool) => {
116
+ if (!tool || typeof tool !== 'object')
117
+ return false;
118
+ const fn = tool.function;
119
+ if (!fn || typeof fn !== 'object')
120
+ return true;
121
+ const name = fn.name;
122
+ if (typeof name !== 'string')
123
+ return true;
124
+ return name.trim() !== 'view_image';
125
+ });
126
+ if (filteredTools.length === tools.length) {
127
+ return chatRequest;
128
+ }
129
+ return {
130
+ ...chatRequest,
131
+ tools: filteredTools
132
+ };
133
+ }
134
+ function detectImageHint(messages, rawPayload) {
135
+ const candidates = [];
136
+ const collect = (value) => {
137
+ if (typeof value === 'string' && value) {
138
+ candidates.push(value);
139
+ }
140
+ };
141
+ if (Array.isArray(messages)) {
142
+ for (const msg of messages) {
143
+ if (msg && typeof msg === 'object') {
144
+ const text = msg.content;
145
+ if (typeof text === 'string') {
146
+ collect(text);
147
+ }
148
+ else if (Array.isArray(text)) {
149
+ for (const part of text) {
150
+ if (part && typeof part === 'object') {
151
+ collect(part.text);
152
+ }
153
+ }
154
+ }
155
+ }
156
+ }
157
+ }
158
+ if (rawPayload && typeof rawPayload === 'object') {
159
+ collect(rawPayload.content);
160
+ }
161
+ if (!candidates.length) {
162
+ return false;
163
+ }
164
+ const patterns = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'];
165
+ for (const text of candidates) {
166
+ const lower = text.toLowerCase();
167
+ for (const ext of patterns) {
168
+ if (lower.includes(ext)) {
169
+ return true;
170
+ }
171
+ }
172
+ }
173
+ return false;
174
+ }
101
175
  export async function runChatResponseToolFilters(chatJson, options = {}) {
102
176
  const resCtxBase = {
103
177
  requestId: options.requestId ?? `req_${Date.now()}`,
@@ -0,0 +1,21 @@
1
+ type Unknown = Record<string, unknown>;
2
+ export interface ToolGovernanceOptions {
3
+ injectGuidance?: boolean;
4
+ snapshot?: {
5
+ enabled?: boolean;
6
+ endpoint?: string;
7
+ requestId?: string;
8
+ baseDir?: string;
9
+ };
10
+ }
11
+ export declare function processChatRequestTools(request: Unknown, opts?: ToolGovernanceOptions): Unknown;
12
+ export declare function processChatResponseTools(resp: Unknown): Unknown;
13
+ export interface GovernContext extends ToolGovernanceOptions {
14
+ phase: 'request' | 'response';
15
+ endpoint?: 'chat' | 'responses' | 'messages';
16
+ stream?: boolean;
17
+ produceRequiredAction?: boolean;
18
+ requestId?: string;
19
+ }
20
+ export declare function governTools(payload: Unknown, ctx: GovernContext): Unknown;
21
+ export {};
@@ -40,6 +40,114 @@ function tryWriteSnapshot(options, stage, data) {
40
40
  * - Inject/Refine system tool guidance (idempotent)
41
41
  * - Canonicalize textual tool markup to tool_calls; set content=null when applicable
42
42
  */
43
+ const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg)(?:[?#].*)?$/i;
44
+ function hasImageReference(messages) {
45
+ if (!Array.isArray(messages))
46
+ return false;
47
+ for (const entry of messages) {
48
+ if (!entry || typeof entry !== 'object')
49
+ continue;
50
+ const content = entry.content;
51
+ if (!content)
52
+ continue;
53
+ if (Array.isArray(content)) {
54
+ if (content.some((part) => isImagePart(part)))
55
+ return true;
56
+ }
57
+ else if (isObject(content)) {
58
+ if (isImagePart(content))
59
+ return true;
60
+ }
61
+ else if (typeof content === 'string') {
62
+ if (stringHasImageLink(content))
63
+ return true;
64
+ }
65
+ }
66
+ return false;
67
+ }
68
+ function hasInputImage(entries) {
69
+ if (!Array.isArray(entries))
70
+ return false;
71
+ for (const entry of entries) {
72
+ if (!entry || typeof entry !== 'object')
73
+ continue;
74
+ const type = String(entry.type || '').toLowerCase();
75
+ if (type.includes('image'))
76
+ return true;
77
+ const content = entry.content;
78
+ if (!content)
79
+ continue;
80
+ if (Array.isArray(content)) {
81
+ if (content.some((part) => isImagePart(part)))
82
+ return true;
83
+ }
84
+ else if (isObject(content)) {
85
+ if (isImagePart(content))
86
+ return true;
87
+ }
88
+ }
89
+ return false;
90
+ }
91
+ function attachmentsHaveImage(payload) {
92
+ const attachments = payload?.attachments;
93
+ if (!Array.isArray(attachments))
94
+ return false;
95
+ for (const attachment of attachments) {
96
+ if (!attachment || typeof attachment !== 'object')
97
+ continue;
98
+ const mime = typeof attachment.mime === 'string' ? attachment.mime.toLowerCase() : '';
99
+ if (mime.startsWith('image/'))
100
+ return true;
101
+ const name = typeof attachment.name === 'string' ? attachment.name : '';
102
+ if (IMAGE_EXT_RE.test(name))
103
+ return true;
104
+ const url = typeof attachment.url === 'string' ? attachment.url : '';
105
+ if (stringHasImageLink(url))
106
+ return true;
107
+ }
108
+ return false;
109
+ }
110
+ function stringHasImageLink(value) {
111
+ if (!value)
112
+ return false;
113
+ if (value.includes('cid:'))
114
+ return true;
115
+ if (IMAGE_EXT_RE.test(value))
116
+ return true;
117
+ const lowered = value.toLowerCase();
118
+ return lowered.includes('image://');
119
+ }
120
+ function isImagePart(part) {
121
+ if (!part || typeof part !== 'object')
122
+ return false;
123
+ const type = String(part.type || '').toLowerCase();
124
+ if (type.includes('image'))
125
+ return true;
126
+ const imageUrl = part.image_url || part.imageUrl;
127
+ if (typeof imageUrl === 'string')
128
+ return true;
129
+ if (isObject(imageUrl) && typeof imageUrl.url === 'string')
130
+ return true;
131
+ const url = part.url;
132
+ if (typeof url === 'string' && stringHasImageLink(url))
133
+ return true;
134
+ return false;
135
+ }
136
+ function shouldExposeViewImage(payload) {
137
+ if (hasImageReference(payload?.messages))
138
+ return true;
139
+ if (hasInputImage(payload?.input))
140
+ return true;
141
+ if (attachmentsHaveImage(payload))
142
+ return true;
143
+ return false;
144
+ }
145
+ function isViewImageTool(tool) {
146
+ if (!tool || typeof tool !== 'object')
147
+ return false;
148
+ const name = String(tool.name || tool?.function?.name || '').toLowerCase();
149
+ return name === 'view_image';
150
+ }
43
151
  export function processChatRequestTools(request, opts) {
44
152
  const options = { ...(opts || {}) };
45
153
  if (!isObject(request))
@@ -48,8 +156,15 @@ export function processChatRequestTools(request, opts) {
48
156
  // tools 形状最小修复:为缺失 function.parameters 的工具补一个空对象,避免上游
49
157
  // Responses/OpenAI 校验 422(外部错误必须暴露,但这里属于规范化入口)。
50
158
  try {
51
- const tools = Array.isArray(out?.tools) ? out.tools : [];
159
+ let tools = Array.isArray(out?.tools) ? out.tools : [];
52
160
  if (tools.length > 0) {
161
+ if (!shouldExposeViewImage(out)) {
162
+ const filtered = tools.filter((tool) => !isViewImageTool(tool));
163
+ if (filtered.length !== tools.length) {
164
+ tools = filtered;
165
+ out.tools = tools;
166
+ }
167
+ }
53
168
  for (const t of tools) {
54
169
  if (!t || typeof t !== 'object')
55
170
  continue;
@@ -63,6 +63,8 @@ function augmentApplyPatch(fn) {
63
63
  const guidance = [
64
64
  marker,
65
65
  'Edit files by applying a unified diff patch. Return ONLY the patch text with *** Begin Patch/*** End Patch blocks.',
66
+ 'Paths resolve relative to the active workspace root. Use forward-slash paths (e.g., packages/foo/file.ts) and switch to absolute paths only if you truly need to edit outside the workspace.',
67
+ '路径一律相对于当前工作区根目录解析;请写 packages/foo/... 这样的相对路径,跨工作区时再使用绝对路径。',
66
68
  'Example:',
67
69
  '*** Begin Patch',
68
70
  '*** Update File: path/to/file.ts',
@@ -181,7 +183,9 @@ export function augmentAnthropicTools(tools) {
181
183
  const marker = '[Codex ApplyPatch Guidance]';
182
184
  const guidance = [
183
185
  marker,
184
- 'Use unified diff patch with *** Begin Patch/End Patch. Return only the patch text.'
186
+ 'Use unified diff patch with *** Begin Patch/End Patch. Return only the patch text.',
187
+ 'All file paths must stay relative to the workspace root; never emit leading \'/\' or drive letters.',
188
+ '所有文件路径都必须相对当前工作区根目录,禁止输出以 / 或盘符开头的绝对路径。'
185
189
  ].join('\n');
186
190
  copy.description = appendOnce(desc, guidance, marker);
187
191
  }
@@ -216,7 +220,7 @@ export function buildSystemToolGuidance() {
216
220
  lines.push(bullet('function.arguments must be a single JSON string. / arguments 必须是单个 JSON 字符串。'));
217
221
  lines.push(bullet('shell: Place ALL intent into the command argv array only; do not invent extra keys. / shell 所有意图写入 command 数组,不要添加额外键名。'));
218
222
  lines.push(bullet('File writes are FORBIDDEN via shell (no redirection, no here-doc, no sed -i, no ed -s, no tee). Use apply_patch ONLY. / 通过 shell 写文件一律禁止(不得使用重定向、heredoc、sed -i、ed -s、tee);必须使用 apply_patch。'));
219
- lines.push(bullet('apply_patch: Provide a unified diff patch with *** Begin Patch/*** End Patch only. / 仅输出统一 diff 补丁。'));
223
+ lines.push(bullet('apply_patch: Provide a unified diff patch with *** Begin Patch/*** End Patch only, and keep file paths relative to the workspace (no leading / or drive letters). / 仅输出统一 diff 补丁,且文件路径必须是相对路径(禁止以 / 或盘符开头)。'));
220
224
  lines.push(bullet('apply_patch example / 示例:\n*** Begin Patch\n*** Update File: path/to/file.ts\n@@\n- old line\n+ new line\n*** End Patch'));
221
225
  lines.push(bullet('update_plan: Keep exactly one step in_progress; others pending/completed. / 仅一个 in_progress 步骤。'));
222
226
  lines.push(bullet('view_image: Path must be an image file (.png .jpg .jpeg .gif .webp .bmp .svg). / 仅图片路径。'));
@@ -0,0 +1,39 @@
1
+ import { type RoutingDecision, type RoutingDiagnostics, type RouterMetadataInput, type VirtualRouterConfig, type TargetMetadata, type ProviderFailureEvent, type ProviderErrorEvent } from './types.js';
2
+ import type { ProcessedRequest, StandardizedRequest } from '../../conversion/hub/types/standardized.js';
3
+ export declare class VirtualRouterEngine {
4
+ private routing;
5
+ private readonly providerRegistry;
6
+ private readonly healthManager;
7
+ private loadBalancer;
8
+ private classifier;
9
+ private routeStats;
10
+ private readonly debug;
11
+ private healthConfig;
12
+ initialize(config: VirtualRouterConfig): void;
13
+ route(request: StandardizedRequest | ProcessedRequest, metadata: RouterMetadataInput): {
14
+ target: TargetMetadata;
15
+ decision: RoutingDecision;
16
+ diagnostics: RoutingDiagnostics;
17
+ };
18
+ handleProviderFailure(event: ProviderFailureEvent): void;
19
+ handleProviderError(event: ProviderErrorEvent): void;
20
+ getStatus(): {
21
+ routes: Record<string, {
22
+ providers: string[];
23
+ hits: number;
24
+ lastUsedProvider?: string;
25
+ }>;
26
+ health: import("./types.js").ProviderHealthState[];
27
+ };
28
+ private validateConfig;
29
+ private selectProvider;
30
+ private incrementRouteStat;
31
+ private providerHealthConfig;
32
+ private resolveStickyKey;
33
+ private mapProviderError;
34
+ private deriveReason;
35
+ private buildRouteCandidates;
36
+ private sortByPriority;
37
+ private routeWeight;
38
+ private buildHitReason;
39
+ }
@@ -11,11 +11,14 @@ export class VirtualRouterEngine {
11
11
  loadBalancer = new RouteLoadBalancer();
12
12
  classifier = new RoutingClassifier({});
13
13
  routeStats = new Map();
14
+ debug = console; // thin hook; host may monkey-patch for colored logging
15
+ healthConfig = null;
14
16
  initialize(config) {
15
17
  this.validateConfig(config);
16
18
  this.routing = config.routing;
17
19
  this.providerRegistry.load(config.providers);
18
20
  this.healthManager.configure(config.health);
21
+ this.healthConfig = config.health ?? null;
19
22
  this.healthManager.registerProviders(Object.keys(config.providers));
20
23
  this.loadBalancer = new RouteLoadBalancer(config.loadBalancing);
21
24
  this.classifier = new RoutingClassifier(config.classifier);
@@ -32,6 +35,8 @@ export class VirtualRouterEngine {
32
35
  const target = this.providerRegistry.buildTarget(selection.providerKey);
33
36
  this.healthManager.recordSuccess(selection.providerKey);
34
37
  this.incrementRouteStat(selection.routeUsed, selection.providerKey);
38
+ const hitReason = this.buildHitReason(selection.routeUsed, classification, features);
39
+ this.debug?.log?.('[virtual-router-hit]', selection.routeUsed, selection.providerKey, target.modelId || '', hitReason ? `reason=${hitReason}` : '');
35
40
  const didFallback = selection.routeUsed !== routeName || classification.fallback;
36
41
  return {
37
42
  target,
@@ -117,7 +122,7 @@ export class VirtualRouterEngine {
117
122
  }
118
123
  selectProvider(requestedRoute, metadata, classification) {
119
124
  const candidates = this.buildRouteCandidates(requestedRoute, classification.candidates);
120
- const stickyKey = metadata.requestId;
125
+ const stickyKey = this.resolveStickyKey(metadata);
121
126
  const attempted = [];
122
127
  for (const routeName of candidates) {
123
128
  const pool = this.routing[routeName];
@@ -147,7 +152,20 @@ export class VirtualRouterEngine {
147
152
  stats.hits += 1;
148
153
  stats.lastProvider = providerKey;
149
154
  }
155
+ providerHealthConfig() {
156
+ return this.healthManager.getConfig();
157
+ }
158
+ resolveStickyKey(metadata) {
159
+ const resume = metadata.responsesResume;
160
+ if (resume && typeof resume.previousRequestId === 'string' && resume.previousRequestId.trim()) {
161
+ return resume.previousRequestId.trim();
162
+ }
163
+ return metadata.requestId;
164
+ }
150
165
  mapProviderError(event) {
166
+ // NOTE: mapProviderError is the only place where VirtualRouter translates providerErrorCenter
167
+ // events into health signals. Classification is intentionally coarse; upstream providers
168
+ // are expected to set event.recoverable explicitly when they know an error is safe to retry.
151
169
  if (!event || !event.runtime) {
152
170
  return null;
153
171
  }
@@ -162,19 +180,23 @@ export class VirtualRouterEngine {
162
180
  const code = event.code?.toUpperCase() ?? 'ERR_UNKNOWN';
163
181
  const stage = event.stage?.toLowerCase() ?? 'unknown';
164
182
  const recoverable = event.recoverable === true;
183
+ // 默认策略:只有显式可恢复的错误才视为非致命;其余一律按致命处理。
184
+ // 注意:provider 层已经对 429 做了「连续 4 次升级为不可恢复」的判断,这里不再把所有 429 强行当作可恢复。
165
185
  let fatal = !recoverable;
166
186
  let reason = this.deriveReason(code, stage, statusCode);
167
187
  let cooldownOverrideMs;
168
- if (statusCode === 429 || code.includes('429') || recoverable) {
169
- fatal = false;
170
- cooldownOverrideMs = Math.max(30_000, this.providerHealthConfig().cooldownMs);
171
- reason = 'rate_limit';
172
- }
173
- else if (statusCode === 401 || statusCode === 403 || code.includes('AUTH')) {
188
+ // 401 / 402 / 500 / 524 以及所有未被标记为可恢复的错误一律视为不可恢复
189
+ if (statusCode === 401 || statusCode === 402 || statusCode === 403 || code.includes('AUTH')) {
174
190
  fatal = true;
175
191
  cooldownOverrideMs = Math.max(10 * 60_000, this.providerHealthConfig().fatalCooldownMs ?? 10 * 60_000);
176
192
  reason = 'auth';
177
193
  }
194
+ else if (statusCode === 429 && !recoverable) {
195
+ // 连续 429 已在 provider 层被升级为不可恢复:这里按致命限流处理(长冷却,等同熔断)
196
+ fatal = true;
197
+ cooldownOverrideMs = Math.max(10 * 60_000, this.providerHealthConfig().fatalCooldownMs ?? 10 * 60_000);
198
+ reason = 'rate_limit';
199
+ }
178
200
  else if (statusCode && statusCode >= 500) {
179
201
  fatal = true;
180
202
  cooldownOverrideMs = Math.max(5 * 60_000, this.providerHealthConfig().fatalCooldownMs ?? 5 * 60_000);
@@ -193,7 +215,8 @@ export class VirtualRouterEngine {
193
215
  statusCode,
194
216
  errorCode: code,
195
217
  retryable: recoverable,
196
- affectsHealth: true,
218
+ // 是否影响健康由 provider 层决定;这里仅在 event.affectsHealth !== false 时才计入健康状态
219
+ affectsHealth: event.affectsHealth !== false,
197
220
  cooldownOverrideMs,
198
221
  metadata: {
199
222
  ...event.runtime,
@@ -221,9 +244,6 @@ export class VirtualRouterEngine {
221
244
  return 'client_error';
222
245
  return 'unknown';
223
246
  }
224
- providerHealthConfig() {
225
- return this.healthManager.getConfig();
226
- }
227
247
  buildRouteCandidates(requestedRoute, classificationCandidates) {
228
248
  const normalized = requestedRoute || DEFAULT_ROUTE;
229
249
  const baseList = classificationCandidates && classificationCandidates.length
@@ -257,4 +277,25 @@ export class VirtualRouterEngine {
257
277
  const idx = ROUTE_PRIORITY.indexOf(routeName);
258
278
  return idx >= 0 ? idx : ROUTE_PRIORITY.length;
259
279
  }
280
+ buildHitReason(routeUsed, classification, features) {
281
+ const reasoning = classification.reasoning || '';
282
+ const primary = reasoning.split('|')[0] || '';
283
+ const lastToolName = features.lastAssistantToolName;
284
+ if (routeUsed === 'tools') {
285
+ if (lastToolName) {
286
+ return primary ? `${primary}(${lastToolName})` : `tools(${lastToolName})`;
287
+ }
288
+ return primary || 'tools';
289
+ }
290
+ if (routeUsed === 'thinking') {
291
+ return primary || 'thinking';
292
+ }
293
+ if (routeUsed === DEFAULT_ROUTE && classification.fallback) {
294
+ return primary || 'fallback:default';
295
+ }
296
+ if (primary) {
297
+ return primary;
298
+ }
299
+ return routeUsed ? `route:${routeUsed}` : 'route:unknown';
300
+ }
260
301
  }
@@ -96,6 +96,11 @@ export interface RouterMetadataInput {
96
96
  providerProtocol?: string;
97
97
  stage?: 'inbound' | 'outbound' | 'response';
98
98
  routeHint?: string;
99
+ responsesResume?: {
100
+ previousRequestId?: string;
101
+ restoredFromResponseId?: string;
102
+ [key: string]: unknown;
103
+ };
99
104
  }
100
105
  export interface RoutingFeatures {
101
106
  requestId: string;
@@ -205,6 +210,7 @@ export interface ProviderErrorEvent {
205
210
  stage: string;
206
211
  status?: number;
207
212
  recoverable?: boolean;
213
+ affectsHealth?: boolean;
208
214
  runtime: ProviderErrorRuntimeMetadata;
209
215
  timestamp: number;
210
216
  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.074",
3
+ "version": "0.6.104",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",