@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.
- package/dist/conversion/hub/pipeline/hub-pipeline.d.ts +1 -0
- package/dist/conversion/hub/pipeline/hub-pipeline.js +34 -1
- package/dist/conversion/hub/pipeline/stages/resp_outbound/resp_outbound_stage1_client_remap/index.js +34 -0
- package/dist/conversion/hub/process/chat-process.js +4 -1
- package/dist/conversion/hub/response/provider-response.js +8 -0
- package/dist/conversion/shared/tool-filter-pipeline.d.ts +21 -0
- package/dist/conversion/shared/tool-filter-pipeline.js +76 -2
- package/dist/conversion/shared/tool-governor.d.ts +21 -0
- package/dist/conversion/shared/tool-governor.js +116 -1
- package/dist/guidance/index.js +6 -2
- package/dist/router/virtual-router/engine.d.ts +39 -0
- package/dist/router/virtual-router/engine.js +52 -11
- package/dist/router/virtual-router/types.d.ts +6 -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
|
@@ -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;
|
package/dist/conversion/hub/pipeline/stages/resp_outbound/resp_outbound_stage1_client_remap/index.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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;
|
package/dist/guidance/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
169
|
-
|
|
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
|
|
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 @@
|
|
|
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
|
+
}
|