@jsonstudio/llms 0.6.633 → 0.6.749

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.
Files changed (64) hide show
  1. package/dist/conversion/codecs/anthropic-openai-codec.js +0 -5
  2. package/dist/conversion/codecs/openai-openai-codec.js +0 -6
  3. package/dist/conversion/codecs/responses-openai-codec.js +1 -7
  4. package/dist/conversion/hub/node-support.js +5 -4
  5. package/dist/conversion/hub/pipeline/hub-pipeline.d.ts +14 -1
  6. package/dist/conversion/hub/pipeline/hub-pipeline.js +82 -18
  7. package/dist/conversion/hub/pipeline/session-identifiers.js +132 -2
  8. package/dist/conversion/hub/pipeline/stages/req_inbound/req_inbound_stage3_context_capture/index.js +23 -19
  9. package/dist/conversion/hub/pipeline/stages/resp_inbound/resp_inbound_stage1_sse_decode/index.js +47 -0
  10. package/dist/conversion/hub/pipeline/stages/resp_process/resp_process_stage1_tool_governance/index.js +4 -2
  11. package/dist/conversion/hub/process/chat-process.js +2 -0
  12. package/dist/conversion/hub/response/provider-response.js +6 -1
  13. package/dist/conversion/hub/snapshot-recorder.js +8 -1
  14. package/dist/conversion/pipeline/codecs/v2/shared/openai-chat-helpers.js +0 -7
  15. package/dist/conversion/responses/responses-openai-bridge.js +47 -7
  16. package/dist/conversion/shared/compaction-detect.d.ts +2 -0
  17. package/dist/conversion/shared/compaction-detect.js +53 -0
  18. package/dist/conversion/shared/errors.d.ts +1 -1
  19. package/dist/conversion/shared/reasoning-tool-normalizer.js +7 -0
  20. package/dist/conversion/shared/snapshot-hooks.d.ts +2 -0
  21. package/dist/conversion/shared/snapshot-hooks.js +180 -4
  22. package/dist/conversion/shared/snapshot-utils.d.ts +4 -0
  23. package/dist/conversion/shared/snapshot-utils.js +4 -0
  24. package/dist/conversion/shared/tool-filter-pipeline.js +3 -9
  25. package/dist/conversion/shared/tool-governor.d.ts +2 -0
  26. package/dist/conversion/shared/tool-governor.js +101 -13
  27. package/dist/conversion/shared/tool-harvester.js +42 -2
  28. package/dist/filters/index.d.ts +0 -2
  29. package/dist/filters/index.js +0 -2
  30. package/dist/filters/special/request-tools-normalize.d.ts +11 -0
  31. package/dist/filters/special/request-tools-normalize.js +13 -50
  32. package/dist/filters/special/response-apply-patch-toon-decode.js +403 -82
  33. package/dist/filters/special/response-tool-arguments-toon-decode.js +6 -75
  34. package/dist/filters/utils/snapshot-writer.js +42 -4
  35. package/dist/guidance/index.js +8 -2
  36. package/dist/router/virtual-router/bootstrap.js +68 -4
  37. package/dist/router/virtual-router/engine-health.js +0 -4
  38. package/dist/router/virtual-router/engine-selection.d.ts +8 -1
  39. package/dist/router/virtual-router/engine-selection.js +168 -9
  40. package/dist/router/virtual-router/engine.d.ts +6 -1
  41. package/dist/router/virtual-router/engine.js +263 -14
  42. package/dist/router/virtual-router/load-balancer.d.ts +18 -0
  43. package/dist/router/virtual-router/load-balancer.js +3 -2
  44. package/dist/router/virtual-router/routing-instructions.d.ts +6 -0
  45. package/dist/router/virtual-router/routing-instructions.js +18 -3
  46. package/dist/router/virtual-router/sticky-session-store.d.ts +1 -0
  47. package/dist/router/virtual-router/sticky-session-store.js +36 -0
  48. package/dist/router/virtual-router/types.d.ts +29 -0
  49. package/dist/servertool/engine.js +335 -9
  50. package/dist/servertool/handlers/compaction-detect.d.ts +1 -0
  51. package/dist/servertool/handlers/compaction-detect.js +1 -0
  52. package/dist/servertool/handlers/gemini-empty-reply-continue.js +29 -5
  53. package/dist/servertool/handlers/iflow-model-error-retry.js +17 -0
  54. package/dist/servertool/handlers/stop-message-auto.js +199 -19
  55. package/dist/servertool/server-side-tools.d.ts +0 -1
  56. package/dist/servertool/server-side-tools.js +0 -1
  57. package/dist/servertool/types.d.ts +1 -0
  58. package/dist/tools/apply-patch-structured.js +52 -15
  59. package/dist/tools/tool-registry.js +537 -15
  60. package/dist/utils/toon.d.ts +4 -0
  61. package/dist/utils/toon.js +75 -0
  62. package/package.json +4 -2
  63. package/dist/test-output/virtual-router/results.json +0 -1
  64. package/dist/test-output/virtual-router/summary.json +0 -12
@@ -80,11 +80,6 @@ export class AnthropicOpenAIConversionCodec {
80
80
  };
81
81
  const engine = new FilterEngine();
82
82
  engine.registerFilter(new ResponseToolTextCanonicalizeFilter());
83
- try {
84
- const { ResponseToolArgumentsToonDecodeFilter } = await import('../../filters/index.js');
85
- engine.registerFilter(new ResponseToolArgumentsToonDecodeFilter());
86
- }
87
- catch { /* optional */ }
88
83
  engine.registerFilter(new ResponseToolArgumentsStringifyFilter());
89
84
  engine.registerFilter(new ResponseFinishInvariantsFilter());
90
85
  let staged = await engine.run('response_pre', dto.data, resCtxBase);
@@ -88,12 +88,6 @@ export class OpenAIOpenAIConversionCodec {
88
88
  const engine = new FilterEngine();
89
89
  // Response-side filters (idempotent w.r.t existing logic)
90
90
  engine.registerFilter(new ResponseToolTextCanonicalizeFilter()); // response_pre
91
- try {
92
- const { ResponseToolArgumentsToonDecodeFilter, ResponseApplyPatchToonDecodeFilter } = await import('../../filters/index.js');
93
- engine.registerFilter(new ResponseToolArgumentsToonDecodeFilter()); // response_pre, runs before stringify
94
- engine.registerFilter(new ResponseApplyPatchToonDecodeFilter()); // response_pre, runs before stringify
95
- }
96
- catch { /* optional */ }
97
91
  engine.registerFilter(new ResponseToolArgumentsStringifyFilter()); // response_post
98
92
  engine.registerFilter(new ResponseFinishInvariantsFilter()); // response_post
99
93
  // Load field map config (if present) and register transforms
@@ -147,14 +147,8 @@ export class ResponsesOpenAIConversionCodec {
147
147
  debug: { emit: () => { } }
148
148
  };
149
149
  const engine = new FilterEngine();
150
- // Response-side filters:文本标准化 → TOON decode(可选)→ apply_patch 结构化补丁规范化 → shell/basics → finish_reason 不变式
150
+ // Response-side filters:文本标准化 → apply_patch 结构化补丁规范化 → shell/basics → finish_reason 不变式
151
151
  engine.registerFilter(new ResponseToolTextCanonicalizeFilter()); // response_pre
152
- try {
153
- const { ResponseToolArgumentsToonDecodeFilter, ResponseApplyPatchToonDecodeFilter } = await import('../../filters/index.js');
154
- engine.registerFilter(new ResponseToolArgumentsToonDecodeFilter()); // response_pre, runs before stringify
155
- engine.registerFilter(new ResponseApplyPatchToonDecodeFilter()); // response_pre, runs before stringify
156
- }
157
- catch { /* optional */ }
158
152
  engine.registerFilter(new ResponseToolArgumentsStringifyFilter()); // response_post
159
153
  engine.registerFilter(new ResponseFinishInvariantsFilter()); // response_post
160
154
  let staged = await engine.run('response_pre', dto.data, resCtxBase);
@@ -34,8 +34,9 @@ export async function runHubInboundConversion(options) {
34
34
  }
35
35
  export async function runHubOutboundConversion(options) {
36
36
  const adapterContext = deriveAdapterContext(options.nodeContext, options.protocol);
37
- const providerEndpoint = resolveEndpointForProtocol(adapterContext.providerProtocol);
38
- const stageRecorder = maybeCreateStageRecorder(adapterContext, options.nodeContext.request.endpoint, providerEndpoint);
37
+ // Snapshots must be grouped by the client-facing entry endpoint, not by the provider protocol.
38
+ // A /v1/responses request routed to an anthropic upstream is still a responses *entry* request.
39
+ const stageRecorder = maybeCreateStageRecorder(adapterContext, options.nodeContext.request.endpoint);
39
40
  const { outbound } = createProtocolPlans(options.protocol);
40
41
  const chatEnvelope = standardizedToChatEnvelope(options.request, { adapterContext });
41
42
  const payload = await runOutboundPipeline({
@@ -46,12 +47,12 @@ export async function runHubOutboundConversion(options) {
46
47
  });
47
48
  return payload;
48
49
  }
49
- function maybeCreateStageRecorder(adapterContext, defaultEndpoint, override) {
50
+ function maybeCreateStageRecorder(adapterContext, defaultEndpoint) {
50
51
  const flag = process.env.ROUTECODEX_HUB_SNAPSHOTS;
51
52
  if (flag && flag.trim() === '0') {
52
53
  return undefined;
53
54
  }
54
- const endpoint = override || defaultEndpoint || adapterContext.entryEndpoint || '/v1/chat/completions';
55
+ const endpoint = defaultEndpoint || adapterContext.entryEndpoint || '/v1/chat/completions';
55
56
  return createSnapshotRecorder(adapterContext, endpoint);
56
57
  }
57
58
  function resolveEndpointForProtocol(protocol) {
@@ -1,7 +1,7 @@
1
1
  import { Readable } from 'node:stream';
2
2
  import type { StandardizedRequest, ProcessedRequest } from '../types/standardized.js';
3
3
  import type { JsonObject } from '../types/json.js';
4
- import type { VirtualRouterConfig, RoutingDecision, RoutingDiagnostics, TargetMetadata, VirtualRouterHealthStore } from '../../../router/virtual-router/types.js';
4
+ import type { VirtualRouterConfig, RoutingDecision, RoutingDiagnostics, TargetMetadata, VirtualRouterHealthStore, ProviderQuotaView } from '../../../router/virtual-router/types.js';
5
5
  import { type HubProcessNodeResult } from '../process/chat-process.js';
6
6
  export interface HubPipelineConfig {
7
7
  virtualRouter: VirtualRouterConfig;
@@ -10,6 +10,19 @@ export interface HubPipelineConfig {
10
10
  * 当提供时,VirtualRouterEngine 将在初始化时恢复上一次快照,并在 cooldown/熔断变化时调用 persistSnapshot。
11
11
  */
12
12
  healthStore?: VirtualRouterHealthStore;
13
+ /**
14
+ * 可选:路由状态存储,用于持久化 sticky routing / stopMessage 等指令状态。
15
+ */
16
+ routingStateStore?: {
17
+ loadSync(key: string): unknown;
18
+ saveAsync(key: string, state: unknown): void;
19
+ };
20
+ /**
21
+ * 可选:配额视图。若提供,VirtualRouterEngine 将在路由过程中参考
22
+ * provider 的 quota 状态(inPool/priorityTier/cooldownUntil/blacklistUntil)
23
+ * 过滤目标并按优先级分层调度。
24
+ */
25
+ quotaView?: ProviderQuotaView;
13
26
  }
14
27
  export interface HubPipelineRequestMetadata extends Record<string, unknown> {
15
28
  entryEndpoint?: string;
@@ -24,6 +24,7 @@ import { runReqOutboundStage2FormatBuild } from './stages/req_outbound/req_outbo
24
24
  import { runReqOutboundStage3Compat } from './stages/req_outbound/req_outbound_stage3_compat/index.js';
25
25
  import { extractSessionIdentifiersFromMetadata } from './session-identifiers.js';
26
26
  import { computeRequestTokens } from '../../../router/virtual-router/token-estimator.js';
27
+ import { isCompactionRequest } from '../../shared/compaction-detect.js';
27
28
  export class HubPipeline {
28
29
  routerEngine;
29
30
  config;
@@ -31,7 +32,9 @@ export class HubPipeline {
31
32
  constructor(config) {
32
33
  this.config = config;
33
34
  this.routerEngine = new VirtualRouterEngine({
34
- healthStore: config.healthStore
35
+ healthStore: config.healthStore,
36
+ routingStateStore: config.routingStateStore,
37
+ quotaView: config.quotaView
35
38
  });
36
39
  this.routerEngine.initialize(config.virtualRouter);
37
40
  try {
@@ -58,9 +61,13 @@ export class HubPipeline {
58
61
  async executeRequestStagePipeline(normalized, hooks) {
59
62
  const formatAdapter = hooks.createFormatAdapter();
60
63
  const semanticMapper = hooks.createSemanticMapper();
64
+ const rawRequest = this.asJsonObject(normalized.payload);
65
+ if (isCompactionRequest(rawRequest)) {
66
+ normalized.metadata = normalized.metadata || {};
67
+ normalized.metadata.compactionRequest = true;
68
+ }
61
69
  const inboundAdapterContext = this.buildAdapterContext(normalized);
62
70
  const inboundRecorder = this.maybeCreateStageRecorder(inboundAdapterContext, normalized.entryEndpoint);
63
- const rawRequest = this.asJsonObject(normalized.payload);
64
71
  const inboundStart = Date.now();
65
72
  const formatEnvelope = await runReqInboundStage1FormatParse({
66
73
  rawRequest,
@@ -143,6 +150,15 @@ export class HubPipeline {
143
150
  const serverToolRequired = stdMetadata?.webSearchEnabled === true ||
144
151
  stdMetadata?.serverToolRequired === true;
145
152
  const sessionIdentifiers = extractSessionIdentifiersFromMetadata(normalized.metadata);
153
+ // 将从 metadata / clientHeaders 中解析出的会话标识同步回 normalized.metadata,
154
+ // 便于后续 AdapterContext(响应侧 servertool)也能访问到相同的 sessionId /
155
+ // conversationId,用于 sticky-session 相关逻辑(例如 stopMessage)。
156
+ if (sessionIdentifiers.sessionId && normalized.metadata && typeof normalized.metadata === 'object') {
157
+ normalized.metadata.sessionId = sessionIdentifiers.sessionId;
158
+ }
159
+ if (sessionIdentifiers.conversationId && normalized.metadata && typeof normalized.metadata === 'object') {
160
+ normalized.metadata.conversationId = sessionIdentifiers.conversationId;
161
+ }
146
162
  const metadataInput = {
147
163
  requestId: normalized.id,
148
164
  entryEndpoint: normalized.entryEndpoint,
@@ -164,6 +180,10 @@ export class HubPipeline {
164
180
  normalizedMetadata: normalized.metadata,
165
181
  stageRecorder: inboundRecorder
166
182
  });
183
+ const stopMessageState = this.routerEngine.getStopMessageState(metadataInput);
184
+ if (stopMessageState && normalized.metadata && typeof normalized.metadata === 'object') {
185
+ normalized.metadata.stopMessageState = stopMessageState;
186
+ }
167
187
  // Emit virtual router hit log for debugging (orange [virtual-router] ...)
168
188
  try {
169
189
  const routeName = routing.decision?.routeName;
@@ -193,8 +213,10 @@ export class HubPipeline {
193
213
  const outboundFormatAdapter = protocolSwitch ? outboundHooks.createFormatAdapter() : formatAdapter;
194
214
  const outboundContextMetadataKey = protocolSwitch ? outboundHooks.contextMetadataKey : hooks.contextMetadataKey;
195
215
  const outboundContextSnapshot = protocolSwitch ? undefined : contextSnapshot;
196
- const outboundEndpoint = resolveEndpointForProviderProtocol(outboundAdapterContext.providerProtocol);
197
- const outboundRecorder = this.maybeCreateStageRecorder(outboundAdapterContext, outboundEndpoint);
216
+ // Snapshots must be grouped by entry endpoint (client-facing protocol), not by provider protocol.
217
+ // Otherwise one request would be split across multiple folders (e.g. openai-responses + anthropic-messages),
218
+ // which breaks codex-samples correlation.
219
+ const outboundRecorder = this.maybeCreateStageRecorder(outboundAdapterContext, normalized.entryEndpoint);
198
220
  const outboundStart = Date.now();
199
221
  let providerPayload;
200
222
  const outboundStage1 = await runReqOutboundStage1SemanticMap({
@@ -239,22 +261,25 @@ export class HubPipeline {
239
261
  // route 将 processMode 标记为 passthrough,我们仍然需要保留一次规范化后的
240
262
  // Chat 请求快照,供 stopMessage / gemini_empty_reply_continue 等被动触发型
241
263
  // servertool 在响应阶段使用。
242
- let capturedChatRequest;
243
- try {
244
- capturedChatRequest = JSON.parse(JSON.stringify({
245
- model: workingRequest.model,
246
- messages: workingRequest.messages,
247
- tools: workingRequest.tools,
248
- parameters: workingRequest.parameters
249
- }));
250
- }
251
- catch {
252
- capturedChatRequest = undefined;
253
- }
264
+ //
265
+ // 之前这里通过 JSON.stringify/parse 做深拷贝,但在部分 Responses/Gemini
266
+ // 场景下,workingRequest 上携带的 metadata 可能包含无法安全序列化的字段,
267
+ // 导致克隆过程抛错、capturedChatRequest 被静默丢弃,从而让响应侧的
268
+ // stop_message_auto 等 ServerTool 无法获取上一跳的 Chat 请求。
269
+ //
270
+ // 对于 capturedChatRequest,我们只需要一个“可读快照”,不会在后续流程中
271
+ // 对其做就地修改,因此可以直接使用浅拷贝结构,避免序列化失败导致整段
272
+ // 逻辑失效。
273
+ const capturedChatRequest = {
274
+ model: workingRequest.model,
275
+ messages: Array.isArray(workingRequest.messages) ? [...workingRequest.messages] : workingRequest.messages,
276
+ tools: workingRequest.tools,
277
+ parameters: workingRequest.parameters
278
+ };
254
279
  const metadata = {
255
280
  ...normalized.metadata,
256
281
  ...(hasImageAttachment ? { hasImageAttachment: true } : {}),
257
- ...(capturedChatRequest ? { capturedChatRequest } : {}),
282
+ capturedChatRequest,
258
283
  entryEndpoint: normalized.entryEndpoint,
259
284
  providerProtocol: outboundProtocol,
260
285
  stream: normalized.stream,
@@ -369,6 +394,18 @@ export class HubPipeline {
369
394
  streamingHint,
370
395
  toolCallIdStyle
371
396
  };
397
+ const clientRequestId = typeof metadata.clientRequestId === 'string'
398
+ ? metadata.clientRequestId.trim()
399
+ : '';
400
+ if (clientRequestId) {
401
+ adapterContext.clientRequestId = clientRequestId;
402
+ }
403
+ const groupRequestId = typeof metadata.groupRequestId === 'string'
404
+ ? metadata.groupRequestId.trim()
405
+ : '';
406
+ if (groupRequestId) {
407
+ adapterContext.groupRequestId = groupRequestId;
408
+ }
372
409
  if (typeof metadata.originalModelId === 'string') {
373
410
  adapterContext.originalModelId = metadata.originalModelId;
374
411
  }
@@ -379,7 +416,8 @@ export class HubPipeline {
379
416
  adapterContext.modelId = metadata.assignedModelId;
380
417
  }
381
418
  // 将 serverToolFollowup 等 ServerTool 相关标记从 normalized.metadata 透传到 AdapterContext,
382
- // 便于响应侧的 convertProviderResponse 正确识别“二跳/内部跳转”并跳过 servertool 编排。
419
+ // 便于响应侧的 convertProviderResponse / ServerTool handler 识别“二跳/内部跳转”,
420
+ // 再由具体 handler 决定是否在 followup 响应中继续生效(例如 stopMessage 多轮续写)。
383
421
  if (Object.prototype.hasOwnProperty.call(metadata, 'serverToolFollowup')) {
384
422
  adapterContext.serverToolFollowup = metadata
385
423
  .serverToolFollowup;
@@ -396,6 +434,28 @@ export class HubPipeline {
396
434
  if (conversationId) {
397
435
  adapterContext.conversationId = conversationId;
398
436
  }
437
+ const stopMessageState = metadata.stopMessageState;
438
+ if (stopMessageState && typeof stopMessageState === 'object') {
439
+ adapterContext.stopMessageState = stopMessageState;
440
+ }
441
+ const compactionRequest = metadata.compactionRequest;
442
+ if (compactionRequest === true ||
443
+ (typeof compactionRequest === 'string' && compactionRequest.trim().toLowerCase() === 'true')) {
444
+ adapterContext.compactionRequest = true;
445
+ }
446
+ const clientConnectionState = metadata.clientConnectionState;
447
+ if (clientConnectionState && typeof clientConnectionState === 'object' && !Array.isArray(clientConnectionState)) {
448
+ const stateRecord = clientConnectionState;
449
+ adapterContext.clientConnectionState = clientConnectionState;
450
+ if (typeof stateRecord.disconnected === 'boolean') {
451
+ adapterContext.clientDisconnected = stateRecord.disconnected;
452
+ }
453
+ }
454
+ const clientDisconnectedRaw = metadata.clientDisconnected;
455
+ if (clientDisconnectedRaw === true ||
456
+ (typeof clientDisconnectedRaw === 'string' && clientDisconnectedRaw.trim().toLowerCase() === 'true')) {
457
+ adapterContext.clientDisconnected = true;
458
+ }
399
459
  const responsesResume = metadata.responsesResume &&
400
460
  typeof metadata.responsesResume === 'object'
401
461
  ? metadata.responsesResume
@@ -403,6 +463,10 @@ export class HubPipeline {
403
463
  if (responsesResume) {
404
464
  adapterContext.responsesResume = responsesResume;
405
465
  }
466
+ const serverToolLoopState = metadata.serverToolLoopState;
467
+ if (serverToolLoopState && typeof serverToolLoopState === 'object' && !Array.isArray(serverToolLoopState)) {
468
+ adapterContext.serverToolLoopState = serverToolLoopState;
469
+ }
406
470
  // 透传 gemini_empty_reply_continue 的重试计数,便于在多次空回复后终止自动续写。
407
471
  const emptyReplyCount = metadata.geminiEmptyReplyCount;
408
472
  if (typeof emptyReplyCount === 'number' && Number.isFinite(emptyReplyCount)) {
@@ -2,9 +2,9 @@ export function extractSessionIdentifiersFromMetadata(metadata) {
2
2
  const directSession = normalizeIdentifier(metadata?.sessionId);
3
3
  const directConversation = normalizeIdentifier(metadata?.conversationId);
4
4
  const headers = coerceClientHeaders(metadata?.clientHeaders);
5
- const sessionId = directSession ||
5
+ let sessionId = directSession ||
6
6
  (headers ? pickHeader(headers, ['session_id', 'session-id', 'x-session-id', 'anthropic-session-id']) : undefined);
7
- const conversationId = directConversation ||
7
+ let conversationId = directConversation ||
8
8
  (headers
9
9
  ? pickHeader(headers, [
10
10
  'conversation_id',
@@ -14,6 +14,11 @@ export function extractSessionIdentifiersFromMetadata(metadata) {
14
14
  'openai-conversation-id'
15
15
  ])
16
16
  : undefined);
17
+ if (!sessionId || !conversationId) {
18
+ const fallback = deriveIdentifiersFromRawPayload(metadata);
19
+ sessionId = sessionId || fallback.sessionId;
20
+ conversationId = conversationId || fallback.conversationId || fallback.sessionId;
21
+ }
17
22
  return {
18
23
  ...(sessionId ? { sessionId } : {}),
19
24
  ...(conversationId ? { conversationId } : {})
@@ -74,3 +79,128 @@ function normalizeIdentifier(value) {
74
79
  const trimmed = value.trim();
75
80
  return trimmed || undefined;
76
81
  }
82
+ const SESSION_TOKEN_REGEX = /session[_:\-\s]?([0-9a-f]{8,}(?:-[0-9a-f]{4,}){0,5})/i;
83
+ const CONVERSATION_TOKEN_REGEX = /conversation[_:\-\s]?([0-9a-f]{8,}(?:-[0-9a-f]{4,}){0,5})/i;
84
+ const SESSION_FIELD_KEYS = ['sessionid', 'session_id', 'session-id', 'anthropic-session-id'];
85
+ const CONVERSATION_FIELD_KEYS = ['conversationid', 'conversation_id', 'conversation-id', 'anthropic-conversation-id', 'openai-conversation-id'];
86
+ function deriveIdentifiersFromRawPayload(metadata) {
87
+ if (!metadata || typeof metadata !== 'object') {
88
+ return {};
89
+ }
90
+ const raw = metadata.__raw_request_body;
91
+ const targets = [];
92
+ let rawUserMetadataSession;
93
+ if (raw !== undefined) {
94
+ targets.push(raw);
95
+ if (typeof raw === 'object' && raw !== null) {
96
+ const rawRecord = raw;
97
+ rawUserMetadataSession = extractSessionIdFromUserMetadata(rawRecord);
98
+ if (rawRecord.metadata) {
99
+ targets.push(rawRecord.metadata);
100
+ }
101
+ if (rawRecord.rawText) {
102
+ targets.push(rawRecord.rawText);
103
+ }
104
+ if (rawRecord.events) {
105
+ targets.push(rawRecord.events);
106
+ }
107
+ }
108
+ }
109
+ let sessionId;
110
+ let conversationId;
111
+ if (rawUserMetadataSession) {
112
+ sessionId = rawUserMetadataSession;
113
+ conversationId = rawUserMetadataSession;
114
+ }
115
+ for (const candidate of targets) {
116
+ if (!sessionId) {
117
+ sessionId = findIdentifier(candidate, SESSION_FIELD_KEYS, SESSION_TOKEN_REGEX);
118
+ }
119
+ if (!conversationId) {
120
+ conversationId = findIdentifier(candidate, CONVERSATION_FIELD_KEYS, CONVERSATION_TOKEN_REGEX);
121
+ }
122
+ if (sessionId && conversationId) {
123
+ break;
124
+ }
125
+ }
126
+ if (!sessionId && conversationId) {
127
+ sessionId = conversationId;
128
+ }
129
+ else if (sessionId && !conversationId) {
130
+ conversationId = sessionId;
131
+ }
132
+ return {
133
+ ...(sessionId ? { sessionId } : {}),
134
+ ...(conversationId ? { conversationId } : {})
135
+ };
136
+ }
137
+ function extractSessionIdFromUserMetadata(raw) {
138
+ const metadataNode = raw.metadata;
139
+ if (!metadataNode || typeof metadataNode !== 'object') {
140
+ return undefined;
141
+ }
142
+ const userId = metadataNode.user_id;
143
+ if (typeof userId !== 'string') {
144
+ return undefined;
145
+ }
146
+ const trimmed = userId.trim();
147
+ if (!trimmed) {
148
+ return undefined;
149
+ }
150
+ const match = trimmed.match(SESSION_TOKEN_REGEX);
151
+ if (match && match[1]) {
152
+ return match[1];
153
+ }
154
+ return undefined;
155
+ }
156
+ function findIdentifier(source, preferredKeys, regex) {
157
+ if (source === undefined || source === null) {
158
+ return undefined;
159
+ }
160
+ if (typeof source === 'string') {
161
+ const trimmed = source.trim();
162
+ if (!trimmed) {
163
+ return undefined;
164
+ }
165
+ const match = trimmed.match(regex);
166
+ if (match && match[1]) {
167
+ return match[1];
168
+ }
169
+ if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
170
+ try {
171
+ const parsed = JSON.parse(trimmed);
172
+ return findIdentifier(parsed, preferredKeys, regex);
173
+ }
174
+ catch {
175
+ return undefined;
176
+ }
177
+ }
178
+ return undefined;
179
+ }
180
+ if (Array.isArray(source)) {
181
+ for (const entry of source) {
182
+ const candidate = findIdentifier(entry, preferredKeys, regex);
183
+ if (candidate) {
184
+ return candidate;
185
+ }
186
+ }
187
+ return undefined;
188
+ }
189
+ if (typeof source === 'object') {
190
+ const record = source;
191
+ for (const [key, value] of Object.entries(record)) {
192
+ const lowered = typeof key === 'string' ? key.toLowerCase() : '';
193
+ if (preferredKeys.includes(lowered)) {
194
+ const normalized = normalizeIdentifier(value);
195
+ if (normalized) {
196
+ return normalized;
197
+ }
198
+ }
199
+ const fromNested = findIdentifier(value, preferredKeys, regex);
200
+ if (fromNested) {
201
+ return fromNested;
202
+ }
203
+ }
204
+ }
205
+ return undefined;
206
+ }
@@ -1,6 +1,26 @@
1
1
  import { captureResponsesContext, buildChatRequestFromResponses } from '../../../../../responses/responses-openai-bridge.js';
2
2
  import { recordStage } from '../../../stages/utils.js';
3
3
  export async function runReqInboundStage3ContextCapture(options) {
4
+ // 对由 server-side 工具触发的二跳/内部跳转请求(例如 stopMessage followup),
5
+ // 跳过工具输出扫描与 apply_patch 诊断日志,避免在内部流中重复放大客户端已收到的
6
+ // 工具错误信息。此类请求的工具治理在 chat-process 阶段完成,这里仅保留最小快照。
7
+ try {
8
+ const ctx = options.adapterContext;
9
+ const followupFlag = ctx?.serverToolFollowup;
10
+ const isFollowup = followupFlag === true ||
11
+ (typeof followupFlag === 'string' && followupFlag.trim().toLowerCase() === 'true');
12
+ if (isFollowup) {
13
+ const snapshot = {
14
+ providerProtocol: options.adapterContext.providerProtocol ?? 'unknown',
15
+ tool_outputs: []
16
+ };
17
+ recordStage(options.stageRecorder, 'req_inbound_stage3_context_capture', snapshot);
18
+ return snapshot;
19
+ }
20
+ }
21
+ catch {
22
+ // best-effort: 若检测 serverToolFollowup 失败,则继续走通用路径
23
+ }
4
24
  let context;
5
25
  if (options.captureContext) {
6
26
  try {
@@ -104,23 +124,6 @@ function collectToolOutputs(payload) {
104
124
  if (!id) {
105
125
  return;
106
126
  }
107
- // 针对 apply_patch 工具的失败结果做醒目日志,便于监控
108
- try {
109
- const name = typeof entry.name === 'string' ? entry.name.trim() : undefined;
110
- const output = typeof entry.output === 'string' ? entry.output : undefined;
111
- if (name === 'apply_patch' && output) {
112
- const lower = output.toLowerCase();
113
- if (lower.includes('apply_patch verification failed') ||
114
- lower.includes('failed to parse function arguments')) {
115
- const firstLine = output.split('\n')[0] ?? output;
116
- // eslint-disable-next-line no-console
117
- console.error(`\x1b[31m[apply_patch][tool_error] tool_call_id=${id} ${firstLine}\x1b[0m`);
118
- }
119
- }
120
- }
121
- catch {
122
- // logging best-effort
123
- }
124
127
  if (seen.has(id)) {
125
128
  return;
126
129
  }
@@ -254,6 +257,7 @@ function readResponsesInputToolOutputs(payload) {
254
257
  })
255
258
  .filter((entry) => Boolean(entry));
256
259
  }
260
+ const loggedApplyPatchErrorIds = new Set();
257
261
  function normalizeToolOutputEntry(entry) {
258
262
  if (!entry || typeof entry !== 'object') {
259
263
  return undefined;
@@ -299,10 +303,10 @@ function buildApplyPatchDiagnostics(output) {
299
303
  return undefined;
300
304
  }
301
305
  if (output.includes('missing field `input`')) {
302
- return '\n\n[RouteCodex precheck] apply_patch 参数解析失败:缺少字段 "input"。当前 RouteCodex 期望 { input, patch } { toon: \"<*** Begin Patch ... *** End Patch>\" } 形态。请将统一 diff 文本同时填入 \"patch\" 和 \"input\",或改用 toon 形态。';
306
+ return '\n\n[RouteCodex precheck] apply_patch 参数解析失败:缺少字段 "input"。当前 RouteCodex 期望 { input, patch } 形态,并且两个字段都应包含完整统一 diff 文本。';
303
307
  }
304
308
  if (output.includes('invalid type: map, expected a string')) {
305
- return '\n\n[RouteCodex precheck] apply_patch 参数类型错误:检测到 JSON 对象(map),但客户端期望字符串。请先对参数做 JSON.stringify 再写入 arguments,或直接提供 { patch: \"<统一 diff>\" } / { toon: \"<*** Begin Patch ... *** End Patch>\" }。';
309
+ return '\n\n[RouteCodex precheck] apply_patch 参数类型错误:检测到 JSON 对象(map),但客户端期望字符串。请先对参数做 JSON.stringify 再写入 arguments,或直接提供 { patch: \"<统一 diff>\" } 形式。';
306
310
  }
307
311
  return undefined;
308
312
  }
@@ -1,6 +1,31 @@
1
1
  import { defaultSseCodecRegistry } from '../../../../../../sse/index.js';
2
2
  import { recordStage } from '../../../stages/utils.js';
3
3
  import { ProviderProtocolError } from '../../../../../shared/errors.js';
4
+ function extractSseWrapperError(payload) {
5
+ return findSseWrapperError(payload, 2);
6
+ }
7
+ function findSseWrapperError(record, depth) {
8
+ if (!record || typeof record !== 'object' || depth < 0) {
9
+ return undefined;
10
+ }
11
+ const mode = record.mode;
12
+ const errVal = record.error;
13
+ if (mode === 'sse' && typeof errVal === 'string' && errVal.trim()) {
14
+ return errVal.trim();
15
+ }
16
+ const nestedKeys = ['body', 'data', 'payload', 'response'];
17
+ for (const key of nestedKeys) {
18
+ const nested = record[key];
19
+ if (!nested || typeof nested !== 'object' || Array.isArray(nested)) {
20
+ continue;
21
+ }
22
+ const found = findSseWrapperError(nested, depth - 1);
23
+ if (found) {
24
+ return found;
25
+ }
26
+ }
27
+ return undefined;
28
+ }
4
29
  function resolveProviderType(protocol) {
5
30
  if (protocol === 'openai-chat')
6
31
  return 'openai';
@@ -13,7 +38,29 @@ function resolveProviderType(protocol) {
13
38
  return undefined;
14
39
  }
15
40
  export async function runRespInboundStage1SseDecode(options) {
41
+ const wrapperError = extractSseWrapperError(options.payload);
16
42
  const stream = extractSseStream(options.payload);
43
+ // 某些 mock-provider / 捕获样本在 SSE 连接被异常终止时会携带 error 标记,
44
+ // 即使仍保留 __sse_responses 流,也应视为上游异常并终止。
45
+ if (wrapperError) {
46
+ recordStage(options.stageRecorder, 'resp_inbound_stage1_sse_decode', {
47
+ streamDetected: Boolean(stream),
48
+ decoded: false,
49
+ protocol: options.providerProtocol,
50
+ reason: 'sse_wrapper_error',
51
+ error: wrapperError
52
+ });
53
+ throw new ProviderProtocolError(`[resp_inbound_stage1_sse_decode] Upstream SSE terminated: ${wrapperError}`, {
54
+ code: 'SSE_DECODE_ERROR',
55
+ protocol: options.providerProtocol,
56
+ providerType: resolveProviderType(options.providerProtocol),
57
+ details: {
58
+ phase: 'resp_inbound_stage1_sse_decode',
59
+ requestId: options.adapterContext.requestId,
60
+ message: wrapperError
61
+ }
62
+ });
63
+ }
17
64
  if (!stream) {
18
65
  recordStage(options.stageRecorder, 'resp_inbound_stage1_sse_decode', {
19
66
  streamDetected: false
@@ -1,4 +1,5 @@
1
1
  import { runChatResponseToolFilters } from '../../../../../shared/tool-filter-pipeline.js';
2
+ import { normalizeApplyPatchToolCallsOnResponse } from '../../../../../shared/tool-governor.js';
2
3
  import { ToolGovernanceEngine } from '../../../../tool-governance/index.js';
3
4
  import { recordStage } from '../../../stages/utils.js';
4
5
  const toolGovernanceEngine = new ToolGovernanceEngine();
@@ -8,11 +9,12 @@ export async function runRespProcessStage1ToolGovernance(options) {
8
9
  requestId: options.requestId,
9
10
  profile: 'openai-chat'
10
11
  });
11
- const { payload: governed, summary } = toolGovernanceEngine.governResponse(filtered, options.clientProtocol);
12
+ const patched = normalizeApplyPatchToolCallsOnResponse(filtered);
13
+ const { payload: governed, summary } = toolGovernanceEngine.governResponse(patched, options.clientProtocol);
12
14
  recordStage(options.stageRecorder, 'resp_process_stage1_tool_governance', {
13
15
  summary,
14
16
  applied: summary?.applied,
15
- filteredPayload: filtered,
17
+ filteredPayload: patched,
16
18
  governedPayload: governed
17
19
  });
18
20
  return { governedPayload: governed };
@@ -1,6 +1,7 @@
1
1
  import { runChatRequestToolFilters } from '../../shared/tool-filter-pipeline.js';
2
2
  import { ToolGovernanceEngine } from '../tool-governance/index.js';
3
3
  import { ensureApplyPatchSchema } from '../../shared/tool-mapping.js';
4
+ import { normalizeApplyPatchToolCallsOnRequest } from '../../shared/tool-governor.js';
4
5
  import { isJsonObject } from '../types/json.js';
5
6
  const toolGovernanceEngine = new ToolGovernanceEngine();
6
7
  export async function runHubChatProcess(options) {
@@ -79,6 +80,7 @@ async function applyRequestToolGovernance(request, context) {
79
80
  ...merged,
80
81
  messages: stripHistoricalImageAttachments(merged.messages)
81
82
  };
83
+ merged = normalizeApplyPatchToolCallsOnRequest(merged);
82
84
  if (containsImageAttachment(merged.messages)) {
83
85
  if (!merged.metadata) {
84
86
  merged.metadata = {
@@ -85,7 +85,12 @@ function resolveChatReasoningMode(_entryEndpoint) {
85
85
  export async function convertProviderResponse(options) {
86
86
  const clientProtocol = resolveClientProtocol(options.entryEndpoint);
87
87
  const hasServerToolSupport = Boolean(options.providerInvoker) || Boolean(options.reenterPipeline);
88
- const skipServerTools = isServerToolFollowup(options.context) || !hasServerToolSupport;
88
+ // 是否跳过 ServerTool 编排:
89
+ // - 仅在当前 Provider 完全不支持 ServerTool(没有 invoker/reenterPipeline)时跳过;
90
+ // - 对于 serverToolFollowup=true 的二/三跳请求,也允许再次进入 ServerTool 流程,
91
+ // 由具体 handler(例如 gemini_empty_reply_continue / iflow_model_error_retry 等)
92
+ // 自行通过 serverToolFollowup 标记决定是否生效。
93
+ const skipServerTools = !hasServerToolSupport;
89
94
  // 对于由 server-side 工具触发的内部跳转(二跳/三跳),统一禁用 SSE 聚合输出,
90
95
  // 始终返回完整的 ChatCompletion JSON,便于在 llms 内部直接解析,而不是拿到
91
96
  // __sse_responses 可读流。
@@ -5,9 +5,16 @@ export class SnapshotStageRecorder {
5
5
  writer;
6
6
  constructor(options) {
7
7
  this.options = options;
8
+ const contextAny = options.context;
8
9
  this.writer = createSnapshotWriter({
9
10
  requestId: options.context.requestId,
10
- endpoint: options.endpoint
11
+ endpoint: options.endpoint,
12
+ providerKey: typeof options.context.providerId === 'string' ? options.context.providerId : undefined,
13
+ groupRequestId: typeof contextAny.clientRequestId === 'string'
14
+ ? contextAny.clientRequestId
15
+ : typeof contextAny.groupRequestId === 'string'
16
+ ? contextAny.groupRequestId
17
+ : undefined
11
18
  });
12
19
  }
13
20
  record(stage, payload) {
@@ -43,13 +43,6 @@ export async function canonicalizeOpenAIChatResponse(payload, context, options)
43
43
  };
44
44
  const engine = new FilterEngine();
45
45
  engine.registerFilter(new ResponseToolTextCanonicalizeFilter());
46
- try {
47
- const { ResponseToolArgumentsToonDecodeFilter } = await import('../../../../../filters/index.js');
48
- engine.registerFilter(new ResponseToolArgumentsToonDecodeFilter());
49
- }
50
- catch {
51
- // optional decode filter
52
- }
53
46
  engine.registerFilter(new ResponseToolArgumentsStringifyFilter());
54
47
  engine.registerFilter(new ResponseFinishInvariantsFilter());
55
48
  const stage1 = await engine.run('response_pre', dto.data, filterContext);