@jsonstudio/llms 0.6.54 → 0.6.97
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/codecs/responses-openai-codec.js +16 -1
- 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/req_inbound/req_inbound_stage3_context_capture/index.js +0 -11
- package/dist/conversion/hub/pipeline/stages/resp_outbound/resp_outbound_stage1_client_remap/index.js +22 -0
- package/dist/conversion/hub/process/chat-process.js +4 -1
- package/dist/conversion/hub/response/provider-response.js +8 -0
- package/dist/conversion/responses/responses-openai-bridge.d.ts +1 -0
- package/dist/conversion/responses/responses-openai-bridge.js +71 -0
- package/dist/conversion/shared/tool-filter-pipeline.d.ts +21 -0
- package/dist/conversion/shared/tool-filter-pipeline.js +138 -22
- package/dist/conversion/shared/tool-governor.d.ts +21 -0
- package/dist/conversion/shared/tool-governor.js +116 -1
- package/dist/conversion/shared/tool-mapping.js +52 -2
- package/dist/filters/special/request-tools-normalize.js +20 -1
- package/dist/guidance/index.js +6 -2
- package/dist/router/virtual-router/engine.d.ts +38 -0
- package/dist/router/virtual-router/engine.js +23 -6
- package/dist/router/virtual-router/types.d.ts +5 -0
- package/dist/tools/tool-registry.js +4 -3
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { buildResponsesPayloadFromChat, runStandardChatRequestFilters } from '../index.js';
|
|
2
|
-
import { captureResponsesContext, buildChatRequestFromResponses } from '../responses/responses-openai-bridge.js';
|
|
2
|
+
import { captureResponsesContext, buildChatRequestFromResponses, buildResponsesRequestFromChat } from '../responses/responses-openai-bridge.js';
|
|
3
|
+
import { captureResponsesRequestContext } from '../shared/responses-conversation-store.js';
|
|
3
4
|
import { FilterEngine, ResponseToolTextCanonicalizeFilter, ResponseToolArgumentsStringifyFilter, ResponseFinishInvariantsFilter } from '../../filters/index.js';
|
|
4
5
|
// Ported from root package (no behavior change). Types relaxed.
|
|
5
6
|
export class ResponsesOpenAIConversionCodec {
|
|
@@ -83,6 +84,20 @@ export class ResponsesOpenAIConversionCodec {
|
|
|
83
84
|
endpoint: context.endpoint ?? dto.metadata?.endpoint
|
|
84
85
|
};
|
|
85
86
|
const filtered = await runStandardChatRequestFilters(chatRequest, profile, ctxForFilters);
|
|
87
|
+
try {
|
|
88
|
+
const rebuilt = buildResponsesRequestFromChat(filtered, ctx);
|
|
89
|
+
const payloadForStore = rebuilt?.request;
|
|
90
|
+
if (payloadForStore && typeof payloadForStore === 'object') {
|
|
91
|
+
captureResponsesRequestContext({
|
|
92
|
+
requestId: dto.route.requestId,
|
|
93
|
+
payload: payloadForStore,
|
|
94
|
+
context: ctx
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// best-effort capture
|
|
100
|
+
}
|
|
86
101
|
if (filtered && typeof filtered === 'object') {
|
|
87
102
|
const maybe = filtered;
|
|
88
103
|
if (maybe.max_tokens === undefined && typeof maybe.max_output_tokens === 'number') {
|
|
@@ -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/req_inbound/req_inbound_stage3_context_capture/index.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { captureResponsesContext, buildChatRequestFromResponses } from '../../../../../responses/responses-openai-bridge.js';
|
|
2
|
-
import { captureResponsesRequestContext } from '../../../../../shared/responses-conversation-store.js';
|
|
3
2
|
import { recordStage } from '../../../stages/utils.js';
|
|
4
3
|
export async function runReqInboundStage3ContextCapture(options) {
|
|
5
4
|
let context;
|
|
@@ -54,16 +53,6 @@ export function captureResponsesContextSnapshot(options) {
|
|
|
54
53
|
catch {
|
|
55
54
|
// best-effort context capture
|
|
56
55
|
}
|
|
57
|
-
try {
|
|
58
|
-
captureResponsesRequestContext({
|
|
59
|
-
requestId: options.adapterContext.requestId,
|
|
60
|
-
payload: options.rawRequest,
|
|
61
|
-
context
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
catch {
|
|
65
|
-
// ignore store capture failures
|
|
66
|
-
}
|
|
67
56
|
return context;
|
|
68
57
|
}
|
|
69
58
|
function captureChatContextSnapshot(options) {
|
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,24 @@ 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
|
+
}
|
|
@@ -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,
|
|
@@ -48,6 +48,7 @@ export declare function buildResponsesRequestFromChat(payload: Record<string, un
|
|
|
48
48
|
bridgeHistory?: BridgeInputBuildResult;
|
|
49
49
|
systemInstruction?: string;
|
|
50
50
|
}): BuildResponsesRequestResult;
|
|
51
|
+
export declare function ensureResponsesApplyPatchArguments(input?: BridgeInputItem[]): void;
|
|
51
52
|
export declare function buildResponsesPayloadFromChat(payload: unknown, context?: ResponsesRequestContext): Record<string, unknown> | unknown;
|
|
52
53
|
export declare function extractRequestIdFromResponse(response: any): string | undefined;
|
|
53
54
|
export { buildChatResponseFromResponses } from '../shared/responses-response-utils.js';
|
|
@@ -18,6 +18,7 @@ function isObject(v) {
|
|
|
18
18
|
// --- Public bridge functions ---
|
|
19
19
|
export function captureResponsesContext(payload, dto) {
|
|
20
20
|
const preservedInput = cloneBridgeEntries(payload.input);
|
|
21
|
+
ensureResponsesApplyPatchArguments(preservedInput);
|
|
21
22
|
ensureBridgeInstructions(payload);
|
|
22
23
|
const context = {
|
|
23
24
|
requestId: dto?.route?.requestId,
|
|
@@ -298,6 +299,76 @@ export function buildResponsesRequestFromChat(payload, ctx, extras) {
|
|
|
298
299
|
ensureBridgeInstructions(out);
|
|
299
300
|
return { request: out, originalSystemMessages };
|
|
300
301
|
}
|
|
302
|
+
export function ensureResponsesApplyPatchArguments(input) {
|
|
303
|
+
if (!Array.isArray(input) || !input.length) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
for (const entry of input) {
|
|
307
|
+
if (!entry || typeof entry !== 'object')
|
|
308
|
+
continue;
|
|
309
|
+
const type = typeof entry.type === 'string' ? entry.type.toLowerCase() : '';
|
|
310
|
+
if (type !== 'function_call')
|
|
311
|
+
continue;
|
|
312
|
+
const name = (typeof entry.name === 'string' && entry.name.trim()) ||
|
|
313
|
+
(entry.function && typeof entry.function === 'object' && typeof entry.function.name === 'string' && entry.function.name.trim()) ||
|
|
314
|
+
'';
|
|
315
|
+
if (name !== 'apply_patch')
|
|
316
|
+
continue;
|
|
317
|
+
let normalized;
|
|
318
|
+
try {
|
|
319
|
+
normalized = normalizeApplyPatchArguments(entry.arguments ?? entry.function?.arguments);
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// best-effort: do not fail the whole request due to a malformed historical tool call
|
|
323
|
+
normalized = undefined;
|
|
324
|
+
}
|
|
325
|
+
if (normalized === undefined) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
entry.arguments = normalized;
|
|
329
|
+
if (entry.function && typeof entry.function === 'object') {
|
|
330
|
+
entry.function.arguments = normalized;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function normalizeApplyPatchArguments(source) {
|
|
335
|
+
let parsed;
|
|
336
|
+
if (typeof source === 'string' && source.trim()) {
|
|
337
|
+
try {
|
|
338
|
+
parsed = JSON.parse(source);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
parsed = { patch: source };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
else if (source && typeof source === 'object') {
|
|
345
|
+
parsed = { ...source };
|
|
346
|
+
}
|
|
347
|
+
else if (source === undefined) {
|
|
348
|
+
parsed = {};
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
return typeof source === 'string' ? source : undefined;
|
|
352
|
+
}
|
|
353
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
354
|
+
return typeof source === 'string' ? source : undefined;
|
|
355
|
+
}
|
|
356
|
+
const patchText = typeof parsed.patch === 'string' && parsed.patch.trim().length
|
|
357
|
+
? parsed.patch
|
|
358
|
+
: typeof parsed.input === 'string' && parsed.input.trim().length
|
|
359
|
+
? parsed.input
|
|
360
|
+
: undefined;
|
|
361
|
+
if (patchText) {
|
|
362
|
+
parsed.patch = patchText;
|
|
363
|
+
parsed.input = patchText;
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
return JSON.stringify(parsed);
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return typeof source === 'string' ? source : undefined;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
301
372
|
function readToolCallIdStyleFromContext(ctx) {
|
|
302
373
|
if (!ctx) {
|
|
303
374
|
return undefined;
|
|
@@ -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 {};
|
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
import { FilterEngine } from '../../filters/index.js';
|
|
2
2
|
import { loadFieldMapConfig } from '../../filters/utils/fieldmap-loader.js';
|
|
3
3
|
import { createSnapshotWriter } from './snapshot-utils.js';
|
|
4
|
+
const REQUEST_FILTER_STAGES = [
|
|
5
|
+
'request_pre',
|
|
6
|
+
'request_map',
|
|
7
|
+
'request_post',
|
|
8
|
+
'request_finalize'
|
|
9
|
+
];
|
|
10
|
+
const RESPONSE_FILTER_STAGES = [
|
|
11
|
+
'response_pre',
|
|
12
|
+
'response_map',
|
|
13
|
+
'response_post',
|
|
14
|
+
'response_finalize'
|
|
15
|
+
];
|
|
16
|
+
function assertStageCoverage(label, registeredStages, skeletonStages) {
|
|
17
|
+
const allowed = new Set(skeletonStages);
|
|
18
|
+
const uncovered = [];
|
|
19
|
+
for (const stage of registeredStages) {
|
|
20
|
+
if (!allowed.has(stage)) {
|
|
21
|
+
uncovered.push(stage);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (uncovered.length) {
|
|
25
|
+
throw new Error(`[tool-filter-pipeline] ${label}: registered filter stage(s) not covered by skeleton: ${uncovered.join(', ')}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
4
28
|
export async function runChatRequestToolFilters(chatRequest, options = {}) {
|
|
5
29
|
const reqCtxBase = {
|
|
6
30
|
requestId: options.requestId ?? `req_${Date.now()}`,
|
|
@@ -21,24 +45,30 @@ export async function runChatRequestToolFilters(chatRequest, options = {}) {
|
|
|
21
45
|
return;
|
|
22
46
|
snapshot(stage, payload);
|
|
23
47
|
};
|
|
24
|
-
|
|
48
|
+
const preFiltered = applyLocalToolGovernance(chatRequest, options.rawPayload);
|
|
49
|
+
recordStage('req_process_tool_filters_input', preFiltered);
|
|
25
50
|
const engine = new FilterEngine();
|
|
51
|
+
const registeredStages = new Set();
|
|
52
|
+
const register = (filter) => {
|
|
53
|
+
registeredStages.add(filter.stage);
|
|
54
|
+
engine.registerFilter(filter);
|
|
55
|
+
};
|
|
26
56
|
const profile = (reqCtxBase.profile || '').toLowerCase();
|
|
27
57
|
const endpoint = (reqCtxBase.endpoint || '').toLowerCase();
|
|
28
58
|
const isAnthropic = profile === 'anthropic-messages' || endpoint.includes('/v1/messages');
|
|
29
59
|
if (!isAnthropic) {
|
|
30
60
|
try {
|
|
31
61
|
const { RequestToolListFilter } = await import('../../filters/index.js');
|
|
32
|
-
|
|
62
|
+
register(new RequestToolListFilter());
|
|
33
63
|
}
|
|
34
64
|
catch {
|
|
35
65
|
/* optional */
|
|
36
66
|
}
|
|
37
67
|
}
|
|
38
68
|
const { RequestToolCallsStringifyFilter, RequestToolChoicePolicyFilter } = await import('../../filters/index.js');
|
|
39
|
-
|
|
69
|
+
register(new RequestToolCallsStringifyFilter());
|
|
40
70
|
if (!isAnthropic) {
|
|
41
|
-
|
|
71
|
+
register(new RequestToolChoicePolicyFilter());
|
|
42
72
|
}
|
|
43
73
|
try {
|
|
44
74
|
const cfg = await loadFieldMapConfig('openai-openai.fieldmap.json');
|
|
@@ -52,15 +82,96 @@ export async function runChatRequestToolFilters(chatRequest, options = {}) {
|
|
|
52
82
|
} })());
|
|
53
83
|
}
|
|
54
84
|
catch { /* ignore */ }
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
85
|
+
try {
|
|
86
|
+
const { RequestOpenAIToolsNormalizeFilter, ToolPostConstraintsFilter } = await import('../../filters/index.js');
|
|
87
|
+
register(new RequestOpenAIToolsNormalizeFilter());
|
|
88
|
+
register(new ToolPostConstraintsFilter('request_finalize'));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// optional; keep prior behavior when filter not available
|
|
92
|
+
}
|
|
93
|
+
assertStageCoverage('request', registeredStages, REQUEST_FILTER_STAGES);
|
|
94
|
+
let staged = preFiltered;
|
|
95
|
+
for (const stage of REQUEST_FILTER_STAGES) {
|
|
96
|
+
staged = await engine.run(stage, staged, reqCtxBase);
|
|
97
|
+
recordStage(`req_process_tool_filters_${stage}`, staged);
|
|
98
|
+
}
|
|
61
99
|
recordStage('req_process_tool_filters_output', staged);
|
|
62
100
|
return staged;
|
|
63
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
|
+
}
|
|
64
175
|
export async function runChatResponseToolFilters(chatJson, options = {}) {
|
|
65
176
|
const resCtxBase = {
|
|
66
177
|
requestId: options.requestId ?? `req_${Date.now()}`,
|
|
@@ -81,20 +192,25 @@ export async function runChatResponseToolFilters(chatJson, options = {}) {
|
|
|
81
192
|
};
|
|
82
193
|
recordStage('resp_process_tool_filters_input', chatJson);
|
|
83
194
|
const engine = new FilterEngine();
|
|
195
|
+
const registeredStages = new Set();
|
|
196
|
+
const register = (filter) => {
|
|
197
|
+
registeredStages.add(filter.stage);
|
|
198
|
+
engine.registerFilter(filter);
|
|
199
|
+
};
|
|
84
200
|
const { ResponseToolTextCanonicalizeFilter, ResponseToolArgumentsStringifyFilter, ResponseFinishInvariantsFilter } = await import('../../filters/index.js');
|
|
85
|
-
|
|
201
|
+
register(new ResponseToolTextCanonicalizeFilter());
|
|
86
202
|
try {
|
|
87
203
|
const { ResponseToolArgumentsToonDecodeFilter, ResponseToolArgumentsBlacklistFilter, ResponseToolArgumentsSchemaConvergeFilter } = await import('../../filters/index.js');
|
|
88
|
-
|
|
204
|
+
register(new ResponseToolArgumentsToonDecodeFilter());
|
|
89
205
|
try {
|
|
90
|
-
|
|
206
|
+
register(new ResponseToolArgumentsSchemaConvergeFilter());
|
|
91
207
|
}
|
|
92
208
|
catch { /* optional */ }
|
|
93
|
-
|
|
209
|
+
register(new ResponseToolArgumentsBlacklistFilter());
|
|
94
210
|
}
|
|
95
211
|
catch { /* optional */ }
|
|
96
|
-
|
|
97
|
-
|
|
212
|
+
register(new ResponseToolArgumentsStringifyFilter());
|
|
213
|
+
register(new ResponseFinishInvariantsFilter());
|
|
98
214
|
try {
|
|
99
215
|
const cfg = await loadFieldMapConfig('openai-openai.fieldmap.json');
|
|
100
216
|
if (cfg)
|
|
@@ -107,12 +223,12 @@ export async function runChatResponseToolFilters(chatJson, options = {}) {
|
|
|
107
223
|
} })());
|
|
108
224
|
}
|
|
109
225
|
catch { /* ignore */ }
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
226
|
+
assertStageCoverage('response', registeredStages, RESPONSE_FILTER_STAGES);
|
|
227
|
+
let staged = chatJson;
|
|
228
|
+
for (const stage of RESPONSE_FILTER_STAGES) {
|
|
229
|
+
staged = await engine.run(stage, staged, resCtxBase);
|
|
230
|
+
recordStage(`resp_process_tool_filters_${stage}`, staged);
|
|
231
|
+
}
|
|
116
232
|
recordStage('resp_process_tool_filters_output', staged);
|
|
117
233
|
return staged;
|
|
118
234
|
}
|
|
@@ -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;
|
|
@@ -8,6 +8,56 @@ export function stringifyArgs(args) {
|
|
|
8
8
|
return String(args);
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
|
+
function isPlainObject(value) {
|
|
12
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
function clonePlainObject(value) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(JSON.stringify(value));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return { ...value };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function asSchema(value) {
|
|
23
|
+
if (isPlainObject(value)) {
|
|
24
|
+
return clonePlainObject(value);
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
function ensureApplyPatchSchema(seed) {
|
|
29
|
+
const schema = seed ? { ...seed } : {};
|
|
30
|
+
schema.type = typeof schema.type === 'string' ? schema.type : 'object';
|
|
31
|
+
const properties = isPlainObject(schema.properties) ? { ...schema.properties } : {};
|
|
32
|
+
properties.input = {
|
|
33
|
+
type: 'string',
|
|
34
|
+
description: 'Unified diff patch body between *** Begin Patch/*** End Patch.'
|
|
35
|
+
};
|
|
36
|
+
if (!properties.patch || typeof properties.patch !== 'object') {
|
|
37
|
+
properties.patch = {
|
|
38
|
+
type: 'string',
|
|
39
|
+
description: 'Alias of input for backwards compatibility.'
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
schema.properties = properties;
|
|
43
|
+
const requiredList = Array.isArray(schema.required) ? schema.required.filter((entry) => typeof entry === 'string') : [];
|
|
44
|
+
if (!requiredList.includes('input')) {
|
|
45
|
+
requiredList.push('input');
|
|
46
|
+
}
|
|
47
|
+
schema.required = requiredList;
|
|
48
|
+
if (typeof schema.additionalProperties !== 'boolean') {
|
|
49
|
+
schema.additionalProperties = false;
|
|
50
|
+
}
|
|
51
|
+
return schema;
|
|
52
|
+
}
|
|
53
|
+
function enforceBuiltinToolSchema(name, candidate) {
|
|
54
|
+
const normalizedName = typeof name === 'string' ? name.trim().toLowerCase() : '';
|
|
55
|
+
if (normalizedName === 'apply_patch') {
|
|
56
|
+
const base = asSchema(candidate);
|
|
57
|
+
return ensureApplyPatchSchema(base);
|
|
58
|
+
}
|
|
59
|
+
return asSchema(candidate);
|
|
60
|
+
}
|
|
11
61
|
const DEFAULT_SANITIZER = (value) => {
|
|
12
62
|
if (typeof value === 'string') {
|
|
13
63
|
const trimmed = value.trim();
|
|
@@ -66,7 +116,7 @@ export function bridgeToolToChatDefinition(rawTool, options) {
|
|
|
66
116
|
return null;
|
|
67
117
|
}
|
|
68
118
|
const description = resolveToolDescription(fnNode?.description ?? tool.description);
|
|
69
|
-
const parameters = resolveToolParameters(fnNode, tool);
|
|
119
|
+
const parameters = enforceBuiltinToolSchema(name, resolveToolParameters(fnNode, tool));
|
|
70
120
|
const strict = resolveToolStrict(fnNode, tool);
|
|
71
121
|
const rawType = typeof tool.type === 'string' && tool.type.trim().length ? tool.type.trim() : 'function';
|
|
72
122
|
const normalizedType = rawType.toLowerCase() === 'custom' ? 'function' : rawType;
|
|
@@ -105,7 +155,7 @@ export function chatToolToBridgeDefinition(rawTool, options) {
|
|
|
105
155
|
return null;
|
|
106
156
|
}
|
|
107
157
|
const description = resolveToolDescription(fnNode?.description);
|
|
108
|
-
const parameters = resolveToolParameters(fnNode, undefined);
|
|
158
|
+
const parameters = enforceBuiltinToolSchema(name, resolveToolParameters(fnNode, undefined));
|
|
109
159
|
const strict = resolveToolStrict(fnNode, undefined);
|
|
110
160
|
const normalizedType = typeof tool.type === 'string' && tool.type.trim().length ? tool.type.trim() : 'function';
|
|
111
161
|
const responseShape = {
|
|
@@ -73,7 +73,7 @@ export class RequestOpenAIToolsNormalizeFilter {
|
|
|
73
73
|
delete dst.function.strict;
|
|
74
74
|
}
|
|
75
75
|
catch { /* ignore */ }
|
|
76
|
-
// Switch schema for
|
|
76
|
+
// Switch schema for specific built-in tools at unified shaping point
|
|
77
77
|
try {
|
|
78
78
|
const name = String(dst.function.name || '').toLowerCase();
|
|
79
79
|
if (name === 'shell') {
|
|
@@ -101,6 +101,25 @@ export class RequestOpenAIToolsNormalizeFilter {
|
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
+
else if (name === 'apply_patch') {
|
|
105
|
+
dst.function.parameters = {
|
|
106
|
+
type: 'object',
|
|
107
|
+
properties: {
|
|
108
|
+
input: {
|
|
109
|
+
type: 'string',
|
|
110
|
+
description: 'Unified diff patch body between *** Begin Patch/*** End Patch.'
|
|
111
|
+
},
|
|
112
|
+
patch: {
|
|
113
|
+
type: 'string',
|
|
114
|
+
description: 'Alias of input for backwards compatibility.'
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
required: ['input'],
|
|
118
|
+
additionalProperties: false
|
|
119
|
+
};
|
|
120
|
+
dst.function.description =
|
|
121
|
+
'Use apply_patch to edit files. Provide the diff via the input field (*** Begin Patch ... *** End Patch).';
|
|
122
|
+
}
|
|
104
123
|
}
|
|
105
124
|
catch { /* ignore */ }
|
|
106
125
|
finalTools.push(dst);
|
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,38 @@
|
|
|
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
|
+
}
|
|
@@ -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,7 @@ 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
|
+
this.debug?.log?.('[virtual-router-hit]', selection.routeUsed, selection.providerKey, target.modelId || '');
|
|
35
39
|
const didFallback = selection.routeUsed !== routeName || classification.fallback;
|
|
36
40
|
return {
|
|
37
41
|
target,
|
|
@@ -117,7 +121,7 @@ export class VirtualRouterEngine {
|
|
|
117
121
|
}
|
|
118
122
|
selectProvider(requestedRoute, metadata, classification) {
|
|
119
123
|
const candidates = this.buildRouteCandidates(requestedRoute, classification.candidates);
|
|
120
|
-
const stickyKey = metadata
|
|
124
|
+
const stickyKey = this.resolveStickyKey(metadata);
|
|
121
125
|
const attempted = [];
|
|
122
126
|
for (const routeName of candidates) {
|
|
123
127
|
const pool = this.routing[routeName];
|
|
@@ -147,7 +151,20 @@ export class VirtualRouterEngine {
|
|
|
147
151
|
stats.hits += 1;
|
|
148
152
|
stats.lastProvider = providerKey;
|
|
149
153
|
}
|
|
154
|
+
providerHealthConfig() {
|
|
155
|
+
return this.healthManager.getConfig();
|
|
156
|
+
}
|
|
157
|
+
resolveStickyKey(metadata) {
|
|
158
|
+
const resume = metadata.responsesResume;
|
|
159
|
+
if (resume && typeof resume.previousRequestId === 'string' && resume.previousRequestId.trim()) {
|
|
160
|
+
return resume.previousRequestId.trim();
|
|
161
|
+
}
|
|
162
|
+
return metadata.requestId;
|
|
163
|
+
}
|
|
150
164
|
mapProviderError(event) {
|
|
165
|
+
// NOTE: mapProviderError is the only place where VirtualRouter translates providerErrorCenter
|
|
166
|
+
// events into health signals. Classification is intentionally coarse; upstream providers
|
|
167
|
+
// are expected to set event.recoverable explicitly when they know an error is safe to retry.
|
|
151
168
|
if (!event || !event.runtime) {
|
|
152
169
|
return null;
|
|
153
170
|
}
|
|
@@ -162,15 +179,18 @@ export class VirtualRouterEngine {
|
|
|
162
179
|
const code = event.code?.toUpperCase() ?? 'ERR_UNKNOWN';
|
|
163
180
|
const stage = event.stage?.toLowerCase() ?? 'unknown';
|
|
164
181
|
const recoverable = event.recoverable === true;
|
|
182
|
+
// 默认策略:只有显式可恢复的错误才视为非致命;其余一律按致命处理
|
|
165
183
|
let fatal = !recoverable;
|
|
166
184
|
let reason = this.deriveReason(code, stage, statusCode);
|
|
167
185
|
let cooldownOverrideMs;
|
|
168
|
-
|
|
186
|
+
// 400 / 429 作为明确可恢复池:走限流通道,不做长期拉黑
|
|
187
|
+
if (statusCode === 429 || code.includes('429') || statusCode === 400 || code.includes('400')) {
|
|
169
188
|
fatal = false;
|
|
170
189
|
cooldownOverrideMs = Math.max(30_000, this.providerHealthConfig().cooldownMs);
|
|
171
190
|
reason = 'rate_limit';
|
|
191
|
+
// 401 / 402 / 500 / 524 以及所有未被标记为可恢复的错误一律视为不可恢复
|
|
172
192
|
}
|
|
173
|
-
else if (statusCode === 401 || statusCode === 403 || code.includes('AUTH')) {
|
|
193
|
+
else if (statusCode === 401 || statusCode === 402 || statusCode === 403 || code.includes('AUTH')) {
|
|
174
194
|
fatal = true;
|
|
175
195
|
cooldownOverrideMs = Math.max(10 * 60_000, this.providerHealthConfig().fatalCooldownMs ?? 10 * 60_000);
|
|
176
196
|
reason = 'auth';
|
|
@@ -221,9 +241,6 @@ export class VirtualRouterEngine {
|
|
|
221
241
|
return 'client_error';
|
|
222
242
|
return 'unknown';
|
|
223
243
|
}
|
|
224
|
-
providerHealthConfig() {
|
|
225
|
-
return this.healthManager.getConfig();
|
|
226
|
-
}
|
|
227
244
|
buildRouteCandidates(requestedRoute, classificationCandidates) {
|
|
228
245
|
const normalized = requestedRoute || DEFAULT_ROUTE;
|
|
229
246
|
const baseList = classificationCandidates && classificationCandidates.length
|
|
@@ -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;
|
|
@@ -95,11 +95,12 @@ export function validateToolCall(name, argsString) {
|
|
|
95
95
|
const rawArgs = tryParseJson(typeof argsString === 'string' ? argsString : '{}');
|
|
96
96
|
switch (normalizedName) {
|
|
97
97
|
case 'apply_patch': {
|
|
98
|
-
const
|
|
98
|
+
const input = asString(rawArgs.input);
|
|
99
|
+
const patch = asString(rawArgs.patch) ?? input;
|
|
99
100
|
if (!patch) {
|
|
100
|
-
return { ok: false, reason: '
|
|
101
|
+
return { ok: false, reason: 'missing_input' };
|
|
101
102
|
}
|
|
102
|
-
return { ok: true, normalizedArgs: toJson({ patch }) };
|
|
103
|
+
return { ok: true, normalizedArgs: toJson({ input: patch, patch }) };
|
|
103
104
|
}
|
|
104
105
|
case 'shell': {
|
|
105
106
|
const rawCommand = rawArgs.command;
|