@jsonstudio/llms 0.6.1643 → 0.6.1739
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/compat/actions/harvest-tool-calls-from-text.d.ts +10 -0
- package/dist/conversion/compat/actions/harvest-tool-calls-from-text.js +121 -0
- package/dist/conversion/compat/actions/iflow-kimi-cli-defaults.d.ts +10 -0
- package/dist/conversion/compat/actions/iflow-kimi-cli-defaults.js +80 -0
- package/dist/conversion/compat/actions/iflow-kimi-history-media-placeholder.d.ts +7 -0
- package/dist/conversion/compat/actions/iflow-kimi-history-media-placeholder.js +161 -0
- package/dist/conversion/compat/actions/iflow-kimi-thinking-reasoning-fill.d.ts +12 -0
- package/dist/conversion/compat/actions/iflow-kimi-thinking-reasoning-fill.js +67 -0
- package/dist/conversion/compat/actions/iflow-response-body-unwrap.d.ts +9 -0
- package/dist/conversion/compat/actions/iflow-response-body-unwrap.js +140 -0
- package/dist/conversion/compat/actions/lmstudio-responses-fc-ids.d.ts +10 -0
- package/dist/conversion/compat/actions/lmstudio-responses-fc-ids.js +59 -0
- package/dist/conversion/compat/actions/lmstudio-responses-input-stringify.d.ts +14 -0
- package/dist/conversion/compat/actions/lmstudio-responses-input-stringify.js +125 -0
- package/dist/conversion/compat/actions/normalize-tool-call-ids.d.ts +11 -0
- package/dist/conversion/compat/actions/normalize-tool-call-ids.js +140 -0
- package/dist/conversion/compat/actions/strip-orphan-function-calls-tag.d.ts +2 -0
- package/dist/conversion/compat/actions/strip-orphan-function-calls-tag.js +152 -0
- package/dist/conversion/compat/antigravity-session-signature.d.ts +1 -1
- package/dist/conversion/compat/antigravity-session-signature.js +5 -4
- package/dist/conversion/compat/profiles/chat-iflow.json +6 -0
- package/dist/conversion/compat/profiles/chat-lmstudio.json +7 -1
- package/dist/conversion/hub/operation-table/operation-table-runner.js +1 -1
- package/dist/conversion/hub/operation-table/semantic-mappers/gemini-mapper.js +19 -2
- package/dist/conversion/hub/pipeline/compat/compat-pipeline-executor.js +101 -5
- package/dist/conversion/hub/pipeline/compat/compat-profile-resolver.d.ts +2 -0
- package/dist/conversion/hub/pipeline/compat/compat-profile-resolver.js +63 -0
- package/dist/conversion/hub/pipeline/compat/compat-types.d.ts +18 -0
- package/dist/conversion/hub/pipeline/hub-pipeline.js +1 -1
- package/dist/conversion/hub/pipeline/stages/req_outbound/req_outbound_stage1_semantic_map/index.js +8 -5
- package/dist/conversion/hub/pipeline/stages/req_outbound/req_outbound_stage3_compat/index.js +5 -1
- package/dist/conversion/hub/pipeline/stages/resp_inbound/resp_inbound_stage1_sse_decode/index.js +113 -0
- package/dist/conversion/hub/pipeline/target-utils.js +3 -0
- package/dist/conversion/hub/response/provider-response.js +27 -1
- package/dist/conversion/responses/responses-openai-bridge.js +32 -6
- package/dist/conversion/shared/anthropic-message-utils.js +20 -5
- package/dist/conversion/shared/bridge-id-utils.d.ts +2 -0
- package/dist/conversion/shared/bridge-id-utils.js +52 -15
- package/dist/conversion/shared/responses-conversation-store.js +40 -5
- package/dist/conversion/shared/responses-output-builder.js +23 -7
- package/dist/conversion/shared/responses-tool-utils.d.ts +1 -0
- package/dist/conversion/shared/responses-tool-utils.js +30 -13
- package/dist/conversion/shared/text-markup-normalizer.d.ts +1 -0
- package/dist/conversion/shared/text-markup-normalizer.js +269 -1
- package/dist/router/virtual-router/bootstrap.js +31 -7
- package/dist/router/virtual-router/classifier.js +1 -1
- package/dist/router/virtual-router/engine/antigravity/alias-lease.d.ts +33 -0
- package/dist/router/virtual-router/engine/antigravity/alias-lease.js +247 -0
- package/dist/router/virtual-router/engine/health/index.d.ts +23 -0
- package/dist/router/virtual-router/engine/health/index.js +720 -0
- package/dist/router/virtual-router/engine/provider-key/parse.d.ts +6 -0
- package/dist/router/virtual-router/engine/provider-key/parse.js +43 -0
- package/dist/router/virtual-router/engine/routing-pools/index.d.ts +13 -0
- package/dist/router/virtual-router/engine/routing-pools/index.js +225 -0
- package/dist/router/virtual-router/engine/routing-state/keys.d.ts +3 -0
- package/dist/router/virtual-router/engine/routing-state/keys.js +30 -0
- package/dist/router/virtual-router/engine/routing-state/metadata.d.ts +6 -0
- package/dist/router/virtual-router/engine/routing-state/metadata.js +132 -0
- package/dist/router/virtual-router/engine/routing-state/store.d.ts +11 -0
- package/dist/router/virtual-router/engine/routing-state/store.js +107 -0
- package/dist/router/virtual-router/engine-health.d.ts +1 -23
- package/dist/router/virtual-router/engine-health.js +1 -720
- package/dist/router/virtual-router/engine-selection/route-utils.js +57 -0
- package/dist/router/virtual-router/engine-selection/tier-selection-select.js +8 -48
- package/dist/router/virtual-router/engine-selection/tier-selection.js +34 -17
- package/dist/router/virtual-router/engine-selection.d.ts +1 -13
- package/dist/router/virtual-router/engine-selection.js +1 -225
- package/dist/router/virtual-router/engine.d.ts +2 -23
- package/dist/router/virtual-router/engine.js +130 -603
- package/dist/router/virtual-router/message-utils.js +15 -5
- package/dist/servertool/engine.js +4 -4
- package/dist/servertool/handlers/followup-request-builder.js +46 -0
- package/dist/servertool/handlers/gemini-empty-reply-continue.js +48 -47
- package/dist/servertool/handlers/stop-message-auto.js +64 -7
- package/dist/servertool/handlers/vision.js +10 -0
- package/dist/servertool/types.d.ts +3 -0
- package/dist/sse/sse-to-json/builders/response-builder.js +6 -0
- package/dist/sse/sse-to-json/chat-sse-to-json-converter.js +32 -2
- package/dist/sse/sse-to-json/parsers/sse-parser.js +34 -0
- package/dist/sse/sse-to-json/responses-sse-to-json-converter.d.ts +1 -0
- package/dist/sse/sse-to-json/responses-sse-to-json-converter.js +33 -1
- package/dist/tools/apply-patch/args-normalizer/default-actions.d.ts +2 -0
- package/dist/tools/apply-patch/args-normalizer/default-actions.js +12 -0
- package/dist/tools/apply-patch/args-normalizer/extract-patch.d.ts +2 -0
- package/dist/tools/apply-patch/args-normalizer/extract-patch.js +15 -0
- package/dist/tools/apply-patch/args-normalizer/index.d.ts +2 -0
- package/dist/tools/apply-patch/args-normalizer/index.js +164 -0
- package/dist/tools/apply-patch/args-normalizer/structured-builders.d.ts +7 -0
- package/dist/tools/apply-patch/args-normalizer/structured-builders.js +85 -0
- package/dist/tools/apply-patch/args-normalizer/types.d.ts +54 -0
- package/dist/tools/apply-patch/args-normalizer/types.js +1 -0
- package/dist/tools/apply-patch/patch-text/looks-like-patch.js +1 -0
- package/dist/tools/apply-patch/patch-text/normalize.js +104 -5
- package/dist/tools/apply-patch/structured/coercion.js +28 -4
- package/dist/tools/apply-patch/validator.js +7 -146
- package/package.json +1 -1
|
@@ -59,8 +59,8 @@ export function detectExtendedThinkingKeyword(text) {
|
|
|
59
59
|
export function detectImageAttachment(message) {
|
|
60
60
|
if (!message)
|
|
61
61
|
return false;
|
|
62
|
-
// 仅基于标准 Chat
|
|
63
|
-
// - content 为数组时查找 { type: 'image' | 'image_url' | 'input_image', ... } 块;
|
|
62
|
+
// 仅基于标准 Chat 语义判断是否携带视觉媒体(图片/视频):
|
|
63
|
+
// - content 为数组时查找 { type: 'image' | 'image_url' | 'input_image' | 'video' | 'video_url' | 'input_video', ... } 块;
|
|
64
64
|
// - 不再依赖 metadata.attachments,也不再用纯文本关键字或剪贴板标记作为信号。
|
|
65
65
|
if (Array.isArray(message.content)) {
|
|
66
66
|
for (const part of message.content) {
|
|
@@ -69,19 +69,29 @@ export function detectImageAttachment(message) {
|
|
|
69
69
|
}
|
|
70
70
|
const record = part;
|
|
71
71
|
const typeValue = typeof record.type === 'string' ? record.type.toLowerCase() : '';
|
|
72
|
-
// For chat/standardized content,
|
|
72
|
+
// For chat/standardized content, media may appear as:
|
|
73
73
|
// - { type: "image_url", image_url: { url } }
|
|
74
74
|
// - { type: "image", uri: "...", data: "...", url: "..." }
|
|
75
75
|
// - { type: "input_image", image_url: "data:..." }
|
|
76
|
-
//
|
|
76
|
+
// - { type: "video_url", video_url: { url } }
|
|
77
|
+
// - { type: "video", uri: "...", data: "...", url: "..." }
|
|
78
|
+
// - { type: "input_video", video_url: "data:..." }
|
|
79
|
+
// Treat any non-empty URL/URI/data on a media-* block as a signal.
|
|
77
80
|
let imageCandidate = '';
|
|
78
81
|
if (typeof record.image_url === 'string') {
|
|
79
82
|
imageCandidate = record.image_url ?? '';
|
|
80
83
|
}
|
|
84
|
+
else if (typeof record.video_url === 'string') {
|
|
85
|
+
imageCandidate = record.video_url ?? '';
|
|
86
|
+
}
|
|
81
87
|
else if (record.image_url &&
|
|
82
88
|
typeof record.image_url?.url === 'string') {
|
|
83
89
|
imageCandidate = record.image_url?.url ?? '';
|
|
84
90
|
}
|
|
91
|
+
else if (record.video_url &&
|
|
92
|
+
typeof record.video_url?.url === 'string') {
|
|
93
|
+
imageCandidate = record.video_url?.url ?? '';
|
|
94
|
+
}
|
|
85
95
|
else if (typeof record.url === 'string') {
|
|
86
96
|
imageCandidate = record.url ?? '';
|
|
87
97
|
}
|
|
@@ -91,7 +101,7 @@ export function detectImageAttachment(message) {
|
|
|
91
101
|
else if (typeof record.data === 'string') {
|
|
92
102
|
imageCandidate = record.data ?? '';
|
|
93
103
|
}
|
|
94
|
-
if (typeValue.includes('image') && imageCandidate.trim().length > 0) {
|
|
104
|
+
if ((typeValue.includes('image') || typeValue.includes('video')) && imageCandidate.trim().length > 0) {
|
|
95
105
|
return true;
|
|
96
106
|
}
|
|
97
107
|
}
|
|
@@ -101,7 +101,7 @@ function isEmptyClientResponsePayload(payload) {
|
|
|
101
101
|
return false;
|
|
102
102
|
}
|
|
103
103
|
// OpenAI Responses: requires_action (function_call output) is a meaningful response and must not be
|
|
104
|
-
// treated as "empty". Some auto-followup servertools (stop_message_flow /
|
|
104
|
+
// treated as "empty". Some auto-followup servertools (stop_message_flow / empty_reply_continue)
|
|
105
105
|
// previously misclassified this as empty because there is no output_text/content yet.
|
|
106
106
|
const requiredAction = payload.required_action;
|
|
107
107
|
if (requiredAction && typeof requiredAction === 'object') {
|
|
@@ -337,13 +337,13 @@ export async function runServerToolOrchestration(options) {
|
|
|
337
337
|
};
|
|
338
338
|
}
|
|
339
339
|
const isStopMessageFlow = engineResult.execution.flowId === 'stop_message_flow';
|
|
340
|
-
const
|
|
340
|
+
const isEmptyReplyContinue = engineResult.execution.flowId === 'empty_reply_continue';
|
|
341
341
|
const isApplyPatchGuard = engineResult.execution.flowId === 'apply_patch_guard';
|
|
342
342
|
const isExecCommandGuard = engineResult.execution.flowId === 'exec_command_guard';
|
|
343
343
|
const stopMessageSource = isStopMessageFlow ? getStopMessageSource(options.adapterContext) : undefined;
|
|
344
344
|
const isAutoStopMessage = isStopMessageFlow && stopMessageSource !== 'explicit';
|
|
345
345
|
const isErrorAutoFlow = engineResult.execution.flowId === 'iflow_model_error_retry';
|
|
346
|
-
const applyAutoLimit = isAutoStopMessage || isErrorAutoFlow ||
|
|
346
|
+
const applyAutoLimit = isAutoStopMessage || isErrorAutoFlow || isEmptyReplyContinue || isApplyPatchGuard || isExecCommandGuard;
|
|
347
347
|
// ServerTool followups must not inherit or inject any routeHint; always route fresh.
|
|
348
348
|
const preserveRouteHint = false;
|
|
349
349
|
const followupPlan = engineResult.execution.followup;
|
|
@@ -432,7 +432,7 @@ export async function runServerToolOrchestration(options) {
|
|
|
432
432
|
metadata.__shadowCompareForcedProviderKey = providerKey;
|
|
433
433
|
}
|
|
434
434
|
}
|
|
435
|
-
const retryEmptyFollowupOnce = isStopMessageFlow ||
|
|
435
|
+
const retryEmptyFollowupOnce = isStopMessageFlow || isEmptyReplyContinue;
|
|
436
436
|
const maxAttempts = retryEmptyFollowupOnce ? 2 : 1;
|
|
437
437
|
const followupRequestId = buildFollowupRequestId(options.requestId, engineResult.execution.followup.requestIdSuffix);
|
|
438
438
|
let followupPayload = coerceFollowupPayloadStream(followupPayloadRaw, metadata.stream === true);
|
|
@@ -249,6 +249,45 @@ function injectSystemTextIntoMessages(source, text) {
|
|
|
249
249
|
messages.splice(insertAt, 0, sys);
|
|
250
250
|
return messages;
|
|
251
251
|
}
|
|
252
|
+
function compactToolContentValue(value, maxChars) {
|
|
253
|
+
const text = typeof value === 'string'
|
|
254
|
+
? value
|
|
255
|
+
: (() => {
|
|
256
|
+
try {
|
|
257
|
+
return JSON.stringify(value ?? '');
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return String(value ?? '');
|
|
261
|
+
}
|
|
262
|
+
})();
|
|
263
|
+
if (text.length <= maxChars) {
|
|
264
|
+
return text;
|
|
265
|
+
}
|
|
266
|
+
const keepHead = Math.max(24, Math.floor(maxChars * 0.45));
|
|
267
|
+
const keepTail = Math.max(24, Math.floor(maxChars * 0.35));
|
|
268
|
+
const omitted = Math.max(0, text.length - keepHead - keepTail);
|
|
269
|
+
const head = text.slice(0, keepHead);
|
|
270
|
+
const tail = text.slice(text.length - keepTail);
|
|
271
|
+
return head + '\n...[tool_output_compacted omitted=' + String(omitted) + ']...\n' + tail;
|
|
272
|
+
}
|
|
273
|
+
function compactToolContentInMessages(source, options) {
|
|
274
|
+
const maxChars = Number.isFinite(options.maxChars) ? Math.max(64, Math.floor(options.maxChars)) : 1200;
|
|
275
|
+
const messages = Array.isArray(source) ? cloneJson(source) : [];
|
|
276
|
+
for (const message of messages) {
|
|
277
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const role = typeof message.role === 'string'
|
|
281
|
+
? String(message.role).trim().toLowerCase()
|
|
282
|
+
: '';
|
|
283
|
+
if (role !== 'tool') {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const content = message.content;
|
|
287
|
+
message.content = compactToolContentValue(content, maxChars);
|
|
288
|
+
}
|
|
289
|
+
return messages;
|
|
290
|
+
}
|
|
252
291
|
function buildStandardFollowupTools() {
|
|
253
292
|
// Keep this list minimal and stable. Used only as a best-effort fallback when a followup hop
|
|
254
293
|
// would otherwise have no tools at all (which can cause tool-based clients to "break" mid-session).
|
|
@@ -372,6 +411,13 @@ export function buildServerToolFollowupChatPayloadFromInjection(args) {
|
|
|
372
411
|
messages = trimOpenAiMessagesForFollowup(messages, { maxNonSystemMessages });
|
|
373
412
|
continue;
|
|
374
413
|
}
|
|
414
|
+
if (op.op === 'compact_tool_content') {
|
|
415
|
+
const maxChars = typeof op.maxChars === 'number'
|
|
416
|
+
? Math.max(64, Math.floor(op.maxChars))
|
|
417
|
+
: 1200;
|
|
418
|
+
messages = compactToolContentInMessages(messages, { maxChars });
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
375
421
|
if (op.op === 'append_assistant_message') {
|
|
376
422
|
const required = op.required !== false;
|
|
377
423
|
const msg = extractAssistantMessageFromChatLike(args.chatResponse);
|
|
@@ -2,13 +2,13 @@ import { registerServerToolHandler } from '../registry.js';
|
|
|
2
2
|
import { isCompactionRequest } from './compaction-detect.js';
|
|
3
3
|
import { extractCapturedChatSeed } from './followup-request-builder.js';
|
|
4
4
|
import { ensureRuntimeMetadata, readRuntimeMetadata } from '../../conversion/shared/runtime-metadata.js';
|
|
5
|
-
const FLOW_ID = '
|
|
5
|
+
const FLOW_ID = 'empty_reply_continue';
|
|
6
|
+
const MAX_TOOL_HINTS = 24;
|
|
6
7
|
const handler = async (ctx) => {
|
|
7
8
|
if (!ctx.capabilities.reenterPipeline) {
|
|
8
9
|
return null;
|
|
9
10
|
}
|
|
10
11
|
// 避免在 followup 请求里再次触发,防止循环。
|
|
11
|
-
const adapterRecord = ctx.adapterContext;
|
|
12
12
|
const rt = readRuntimeMetadata(ctx.adapterContext);
|
|
13
13
|
const followupRaw = rt?.serverToolFollowup;
|
|
14
14
|
if (followupRaw === true || (typeof followupRaw === 'string' && followupRaw.trim().toLowerCase() === 'true')) {
|
|
@@ -17,22 +17,11 @@ const handler = async (ctx) => {
|
|
|
17
17
|
if (hasCompactionFlag(rt)) {
|
|
18
18
|
return null;
|
|
19
19
|
}
|
|
20
|
-
//
|
|
21
|
-
if (ctx.providerProtocol !== 'gemini-chat') {
|
|
22
|
-
return null;
|
|
23
|
-
}
|
|
20
|
+
// 通用空回复续跑:只要是 /v1/responses 链路且满足空回复判定,不区分 provider family / protocol。
|
|
24
21
|
const entryEndpoint = (ctx.entryEndpoint || '').toLowerCase();
|
|
25
22
|
if (!entryEndpoint.includes('/v1/responses')) {
|
|
26
23
|
return null;
|
|
27
24
|
}
|
|
28
|
-
const providerKey = typeof adapterRecord.providerKey === 'string' && adapterRecord.providerKey.trim()
|
|
29
|
-
? adapterRecord.providerKey.trim().toLowerCase()
|
|
30
|
-
: '';
|
|
31
|
-
const isAntigravity = providerKey.startsWith('antigravity.');
|
|
32
|
-
const isGeminiCli = providerKey.startsWith('gemini-cli.');
|
|
33
|
-
if (!isAntigravity && !isGeminiCli) {
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
25
|
// 支持两种客户端协议形状:
|
|
37
26
|
// - OpenAI Chat: choices[0].message.content
|
|
38
27
|
// - OpenAI Responses: output/output_text/status
|
|
@@ -42,7 +31,7 @@ const handler = async (ctx) => {
|
|
|
42
31
|
return null;
|
|
43
32
|
}
|
|
44
33
|
// 统计连续空回复次数,超过上限后不再自动续写,而是返回一个可重试错误。
|
|
45
|
-
const previousCountRaw = rt?.geminiEmptyReplyCount;
|
|
34
|
+
const previousCountRaw = rt?.emptyReplyContinueCount ?? rt?.geminiEmptyReplyCount;
|
|
46
35
|
const previousCount = typeof previousCountRaw === 'number' && Number.isFinite(previousCountRaw) && previousCountRaw >= 0
|
|
47
36
|
? previousCountRaw
|
|
48
37
|
: 0;
|
|
@@ -58,6 +47,7 @@ const handler = async (ctx) => {
|
|
|
58
47
|
if (!seed) {
|
|
59
48
|
return null;
|
|
60
49
|
}
|
|
50
|
+
const continueText = buildContinueUserText(seed);
|
|
61
51
|
// 超过最多 3 次空回复:返回一个 HTTP_HANDLER_ERROR 形状的错误,交由上层错误中心处理。
|
|
62
52
|
if (nextCount > 3) {
|
|
63
53
|
const errorChat = {
|
|
@@ -65,7 +55,7 @@ const handler = async (ctx) => {
|
|
|
65
55
|
object: base.object,
|
|
66
56
|
model: base.model,
|
|
67
57
|
error: {
|
|
68
|
-
message: 'fetch failed:
|
|
58
|
+
message: 'fetch failed: empty_reply_continue exceeded max empty replies',
|
|
69
59
|
code: 'HTTP_HANDLER_ERROR',
|
|
70
60
|
type: 'servertool_empty_reply'
|
|
71
61
|
}
|
|
@@ -80,7 +70,6 @@ const handler = async (ctx) => {
|
|
|
80
70
|
})
|
|
81
71
|
};
|
|
82
72
|
}
|
|
83
|
-
const assistantMessage = extractAssistantMessageForFollowup(ctx.base);
|
|
84
73
|
return {
|
|
85
74
|
flowId: FLOW_ID,
|
|
86
75
|
finalize: async () => ({
|
|
@@ -92,13 +81,16 @@ const handler = async (ctx) => {
|
|
|
92
81
|
entryEndpoint: ctx.entryEndpoint,
|
|
93
82
|
injection: {
|
|
94
83
|
ops: [
|
|
84
|
+
{ op: 'preserve_tools' },
|
|
95
85
|
{ op: 'append_assistant_message', required: false },
|
|
96
|
-
{ op: 'append_user_text', text:
|
|
86
|
+
{ op: 'append_user_text', text: continueText }
|
|
97
87
|
]
|
|
98
88
|
},
|
|
99
89
|
metadata: (() => {
|
|
100
90
|
const meta = {};
|
|
101
91
|
const runtime = ensureRuntimeMetadata(meta);
|
|
92
|
+
runtime.emptyReplyContinueCount = nextCount;
|
|
93
|
+
// Backward compatibility for old snapshots/guards.
|
|
102
94
|
runtime.geminiEmptyReplyCount = nextCount;
|
|
103
95
|
return meta;
|
|
104
96
|
})()
|
|
@@ -107,7 +99,7 @@ const handler = async (ctx) => {
|
|
|
107
99
|
})
|
|
108
100
|
};
|
|
109
101
|
};
|
|
110
|
-
registerServerToolHandler('
|
|
102
|
+
registerServerToolHandler('empty_reply_continue', handler, { trigger: 'auto' });
|
|
111
103
|
function decideEmptyReply(base) {
|
|
112
104
|
// 1) OpenAI Chat shape
|
|
113
105
|
const choices = Array.isArray(base.choices) ? base.choices : [];
|
|
@@ -164,34 +156,6 @@ function decideEmptyReply(base) {
|
|
|
164
156
|
// 允许 output 为空或仅包含空消息:视作空回复,触发自动续写。
|
|
165
157
|
return { shouldTrigger: true };
|
|
166
158
|
}
|
|
167
|
-
function extractAssistantMessageForFollowup(chatResponse) {
|
|
168
|
-
if (!chatResponse || typeof chatResponse !== 'object' || Array.isArray(chatResponse)) {
|
|
169
|
-
return null;
|
|
170
|
-
}
|
|
171
|
-
const choices = Array.isArray(chatResponse.choices)
|
|
172
|
-
? chatResponse.choices
|
|
173
|
-
: [];
|
|
174
|
-
if (!choices.length) {
|
|
175
|
-
return null;
|
|
176
|
-
}
|
|
177
|
-
const first = choices[0];
|
|
178
|
-
if (!first || typeof first !== 'object' || Array.isArray(first)) {
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
181
|
-
const message = first.message;
|
|
182
|
-
if (!message || typeof message !== 'object' || Array.isArray(message)) {
|
|
183
|
-
return null;
|
|
184
|
-
}
|
|
185
|
-
const role = typeof message.role === 'string' ? String(message.role) : '';
|
|
186
|
-
if (role && role.toLowerCase() !== 'assistant') {
|
|
187
|
-
return null;
|
|
188
|
-
}
|
|
189
|
-
const content = message.content;
|
|
190
|
-
if (typeof content !== 'string' || !content.trim()) {
|
|
191
|
-
return null;
|
|
192
|
-
}
|
|
193
|
-
return { role: 'assistant', content: content.trim() };
|
|
194
|
-
}
|
|
195
159
|
function extractResponsesOutputText(base) {
|
|
196
160
|
const raw = base.output_text;
|
|
197
161
|
if (typeof raw === 'string') {
|
|
@@ -265,3 +229,40 @@ function hasCompactionFlag(rt) {
|
|
|
265
229
|
}
|
|
266
230
|
return false;
|
|
267
231
|
}
|
|
232
|
+
function buildContinueUserText(seed) {
|
|
233
|
+
const toolNames = collectToolNames(seed);
|
|
234
|
+
if (!toolNames.length) {
|
|
235
|
+
return '继续执行';
|
|
236
|
+
}
|
|
237
|
+
const shown = toolNames.slice(0, MAX_TOOL_HINTS);
|
|
238
|
+
const omitted = toolNames.length - shown.length;
|
|
239
|
+
const listText = shown.join(', ');
|
|
240
|
+
const tail = omitted > 0 ? `(其余 ${omitted} 个省略)` : '';
|
|
241
|
+
return `继续执行。可用工具列表:${listText}${tail}。请优先调用这些工具,不要返回空回复。`;
|
|
242
|
+
}
|
|
243
|
+
function collectToolNames(seed) {
|
|
244
|
+
const tools = Array.isArray(seed.tools) ? seed.tools : [];
|
|
245
|
+
if (!tools.length) {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
const names = [];
|
|
249
|
+
const seen = new Set();
|
|
250
|
+
for (const tool of tools) {
|
|
251
|
+
if (!tool || typeof tool !== 'object' || Array.isArray(tool))
|
|
252
|
+
continue;
|
|
253
|
+
const fnNode = tool.function;
|
|
254
|
+
const fnName = fnNode && typeof fnNode === 'object' && !Array.isArray(fnNode)
|
|
255
|
+
? normalizeToolName(fnNode.name)
|
|
256
|
+
: '';
|
|
257
|
+
const fallbackName = normalizeToolName(tool.name);
|
|
258
|
+
const resolvedName = fnName || fallbackName;
|
|
259
|
+
if (!resolvedName || seen.has(resolvedName))
|
|
260
|
+
continue;
|
|
261
|
+
seen.add(resolvedName);
|
|
262
|
+
names.push(resolvedName);
|
|
263
|
+
}
|
|
264
|
+
return names;
|
|
265
|
+
}
|
|
266
|
+
function normalizeToolName(value) {
|
|
267
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : '';
|
|
268
|
+
}
|
|
@@ -166,6 +166,20 @@ const handler = async (ctx) => {
|
|
|
166
166
|
state.stopMessageUpdatedAt = now;
|
|
167
167
|
}
|
|
168
168
|
saveRoutingInstructionStateAsync(stickyKey, state);
|
|
169
|
+
const followupProviderKey = resolveStopMessageFollowupProviderKey({ record, runtimeMetadata: rt });
|
|
170
|
+
const followupToolContentMaxChars = resolveStopMessageFollowupToolContentMaxChars({
|
|
171
|
+
providerKey: followupProviderKey,
|
|
172
|
+
model: seed.model
|
|
173
|
+
});
|
|
174
|
+
const followupOps = [];
|
|
175
|
+
if (typeof followupToolContentMaxChars === 'number' &&
|
|
176
|
+
Number.isFinite(followupToolContentMaxChars) &&
|
|
177
|
+
followupToolContentMaxChars > 0) {
|
|
178
|
+
followupOps.push({ op: 'compact_tool_content', maxChars: Math.floor(followupToolContentMaxChars) });
|
|
179
|
+
}
|
|
180
|
+
followupOps.push({ op: 'append_assistant_message', required: false });
|
|
181
|
+
followupOps.push({ op: 'ensure_standard_tools' });
|
|
182
|
+
followupOps.push({ op: 'append_user_text', text });
|
|
169
183
|
return {
|
|
170
184
|
flowId: FLOW_ID,
|
|
171
185
|
finalize: async () => ({
|
|
@@ -176,11 +190,7 @@ const handler = async (ctx) => {
|
|
|
176
190
|
requestIdSuffix: ':stop_followup',
|
|
177
191
|
entryEndpoint,
|
|
178
192
|
injection: {
|
|
179
|
-
ops:
|
|
180
|
-
{ op: 'append_assistant_message', required: false },
|
|
181
|
-
{ op: 'ensure_standard_tools' },
|
|
182
|
-
{ op: 'append_user_text', text }
|
|
183
|
-
]
|
|
193
|
+
ops: followupOps
|
|
184
194
|
},
|
|
185
195
|
metadata: (connectionState ? { clientConnectionState: connectionState } : {})
|
|
186
196
|
}
|
|
@@ -202,6 +212,53 @@ function resolveStickyKey(record) {
|
|
|
202
212
|
}
|
|
203
213
|
return undefined;
|
|
204
214
|
}
|
|
215
|
+
function toNonEmptyText(value) {
|
|
216
|
+
return typeof value === 'string' && value.trim().length ? value.trim() : '';
|
|
217
|
+
}
|
|
218
|
+
function readProviderKeyFromMetadata(value) {
|
|
219
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
220
|
+
return '';
|
|
221
|
+
}
|
|
222
|
+
const metadata = value;
|
|
223
|
+
const direct = toNonEmptyText(metadata.providerKey) ||
|
|
224
|
+
toNonEmptyText(metadata.providerId) ||
|
|
225
|
+
toNonEmptyText(metadata.targetProviderKey);
|
|
226
|
+
if (direct) {
|
|
227
|
+
return direct;
|
|
228
|
+
}
|
|
229
|
+
const target = metadata.target;
|
|
230
|
+
if (target && typeof target === 'object' && !Array.isArray(target)) {
|
|
231
|
+
const targetRecord = target;
|
|
232
|
+
return toNonEmptyText(targetRecord.providerKey) || toNonEmptyText(targetRecord.providerId);
|
|
233
|
+
}
|
|
234
|
+
return '';
|
|
235
|
+
}
|
|
236
|
+
function resolveStopMessageFollowupProviderKey(args) {
|
|
237
|
+
const direct = toNonEmptyText(args.record.providerKey) ||
|
|
238
|
+
toNonEmptyText(args.record.providerId) ||
|
|
239
|
+
readProviderKeyFromMetadata(args.record.metadata) ||
|
|
240
|
+
readProviderKeyFromMetadata(args.runtimeMetadata);
|
|
241
|
+
return direct;
|
|
242
|
+
}
|
|
243
|
+
function resolveStopMessageFollowupToolContentMaxChars(params) {
|
|
244
|
+
const raw = String(process.env.ROUTECODEX_STOPMESSAGE_FOLLOWUP_TOOL_CONTENT_MAX_CHARS || '').trim();
|
|
245
|
+
if (raw) {
|
|
246
|
+
const parsed = Number(raw);
|
|
247
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
248
|
+
return Math.max(64, Math.floor(parsed));
|
|
249
|
+
}
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
const providerKey = typeof params.providerKey === 'string' ? params.providerKey.trim().toLowerCase() : '';
|
|
253
|
+
if (providerKey.startsWith('iflow.')) {
|
|
254
|
+
return 1200;
|
|
255
|
+
}
|
|
256
|
+
const model = typeof params.model === 'string' ? params.model.trim().toLowerCase() : '';
|
|
257
|
+
if (model === 'kimi-k2.5' || model.startsWith('kimi-k2.5-')) {
|
|
258
|
+
return 1200;
|
|
259
|
+
}
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
205
262
|
function isStopFinishReason(base) {
|
|
206
263
|
if (!base || typeof base !== 'object' || Array.isArray(base)) {
|
|
207
264
|
return false;
|
|
@@ -425,7 +482,7 @@ function resolveImplicitGeminiStopMessageSnapshot(ctx, record) {
|
|
|
425
482
|
return null;
|
|
426
483
|
}
|
|
427
484
|
// 仅在“空回复”时触发隐式 stopMessage:
|
|
428
|
-
// - 这个场景由
|
|
485
|
+
// - 这个场景由 empty_reply_continue 专门处理;
|
|
429
486
|
// - stop_message_auto 里的隐式逻辑只作为兼容兜底(且默认关闭),避免对正常 stop 响应追加“继续执行”。
|
|
430
487
|
if (!isEmptyAssistantReply(ctx.base)) {
|
|
431
488
|
return null;
|
|
@@ -456,7 +513,7 @@ function isEmptyAssistantReply(base) {
|
|
|
456
513
|
const finishReason = typeof finishReasonRaw === 'string' && finishReasonRaw.trim()
|
|
457
514
|
? finishReasonRaw.trim().toLowerCase()
|
|
458
515
|
: '';
|
|
459
|
-
// 仅接受 stop:length 截断通常并非“空回复”,而是需要续写(由
|
|
516
|
+
// 仅接受 stop:length 截断通常并非“空回复”,而是需要续写(由 empty_reply_continue 负责)。
|
|
460
517
|
if (finishReason !== 'stop') {
|
|
461
518
|
return false;
|
|
462
519
|
}
|
|
@@ -103,6 +103,11 @@ function shouldRunVisionFlow(ctx) {
|
|
|
103
103
|
}
|
|
104
104
|
const providerType = typeof record.providerType === 'string' ? record.providerType.toLowerCase() : '';
|
|
105
105
|
const providerProtocol = typeof record.providerProtocol === 'string' ? record.providerProtocol.toLowerCase() : '';
|
|
106
|
+
const modelId = typeof record.modelId === 'string'
|
|
107
|
+
? record.modelId.trim().toLowerCase()
|
|
108
|
+
: typeof record.assignedModelId === 'string'
|
|
109
|
+
? record.assignedModelId.trim().toLowerCase()
|
|
110
|
+
: '';
|
|
106
111
|
const inlineMultimodal = providerType === 'gemini' ||
|
|
107
112
|
providerType === 'responses' ||
|
|
108
113
|
providerProtocol === 'gemini-chat' ||
|
|
@@ -110,6 +115,11 @@ function shouldRunVisionFlow(ctx) {
|
|
|
110
115
|
if (inlineMultimodal) {
|
|
111
116
|
return false;
|
|
112
117
|
}
|
|
118
|
+
// Kimi K2.5 supports inline multimodal natively (image_url/video_url).
|
|
119
|
+
// When the routed model is kimi-k2.5, do not trigger the legacy vision detour.
|
|
120
|
+
if (modelId === 'kimi-k2.5') {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
113
123
|
return true;
|
|
114
124
|
}
|
|
115
125
|
function getCapturedRequest(adapterContext) {
|
|
@@ -74,6 +74,9 @@ export type ServerToolFollowupInjectionOp = {
|
|
|
74
74
|
} | {
|
|
75
75
|
op: 'trim_openai_messages';
|
|
76
76
|
maxNonSystemMessages: number;
|
|
77
|
+
} | {
|
|
78
|
+
op: 'compact_tool_content';
|
|
79
|
+
maxChars: number;
|
|
77
80
|
};
|
|
78
81
|
export type ServerToolFollowupInjectionPlan = {
|
|
79
82
|
ops: ServerToolFollowupInjectionOp[];
|
|
@@ -712,6 +712,12 @@ export class ResponsesResponseBuilder {
|
|
|
712
712
|
break;
|
|
713
713
|
}
|
|
714
714
|
case 'function_call':
|
|
715
|
+
// Terminated SSE salvage may end before `function_call.done` / `output_item.done`.
|
|
716
|
+
// In that case the builder state remains `in_progress` and arguments are usually partial/empty.
|
|
717
|
+
// Do not promote such incomplete tool calls into a completed output item.
|
|
718
|
+
if (state.status !== 'completed') {
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
715
721
|
outputItem = this.buildFunctionCallItem(state);
|
|
716
722
|
break;
|
|
717
723
|
case 'reasoning':
|
|
@@ -109,16 +109,46 @@ export class ChatSseToJsonConverter {
|
|
|
109
109
|
*/
|
|
110
110
|
parseSseChunk(chunk) {
|
|
111
111
|
const lines = chunk.trim().split('\n');
|
|
112
|
-
let
|
|
112
|
+
let rawEventType;
|
|
113
113
|
let dataValue = '';
|
|
114
114
|
for (const line of lines) {
|
|
115
115
|
if (line.startsWith('event:')) {
|
|
116
|
-
|
|
116
|
+
rawEventType = line.substring(6).trim();
|
|
117
117
|
}
|
|
118
118
|
else if (line.startsWith('data:')) {
|
|
119
119
|
dataValue = line.substring(5).trim();
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
|
+
const normalizeEventType = (candidate) => {
|
|
123
|
+
if (!candidate)
|
|
124
|
+
return undefined;
|
|
125
|
+
const v = candidate.trim();
|
|
126
|
+
if (!v)
|
|
127
|
+
return undefined;
|
|
128
|
+
// OpenAI Chat Completions SSE does not include `event:` lines; we infer types elsewhere.
|
|
129
|
+
// When upstream does include `event:`, accept common aliases for compatibility.
|
|
130
|
+
if (v === 'chat_chunk' || v === 'chat_chunk'.toLowerCase())
|
|
131
|
+
return 'chat_chunk';
|
|
132
|
+
if (v === 'chat.done' || v === 'chat_done')
|
|
133
|
+
return 'chat.done';
|
|
134
|
+
if (v === 'ping' || v === 'heartbeat')
|
|
135
|
+
return 'ping';
|
|
136
|
+
if (v === 'error')
|
|
137
|
+
return 'error';
|
|
138
|
+
// Legacy aliases
|
|
139
|
+
if (v === 'chunk')
|
|
140
|
+
return 'chat_chunk';
|
|
141
|
+
if (v === 'done')
|
|
142
|
+
return 'chat.done';
|
|
143
|
+
return undefined;
|
|
144
|
+
};
|
|
145
|
+
let eventType = normalizeEventType(rawEventType);
|
|
146
|
+
if (!eventType) {
|
|
147
|
+
// OpenAI-compatible streams often omit `event:`; use `[DONE]` sentinel to mark completion.
|
|
148
|
+
if (dataValue) {
|
|
149
|
+
eventType = dataValue === '[DONE]' ? 'chat.done' : 'chat_chunk';
|
|
150
|
+
}
|
|
151
|
+
}
|
|
122
152
|
if (!eventType) {
|
|
123
153
|
throw ErrorUtils.createError('SSE event type is required', CHAT_CONVERSION_ERROR_CODES.VALIDATION_ERROR, { chunk });
|
|
124
154
|
}
|
|
@@ -144,6 +144,36 @@ function validateEventType(eventType, config) {
|
|
|
144
144
|
}
|
|
145
145
|
return config.allowedEventTypes.has(eventType);
|
|
146
146
|
}
|
|
147
|
+
function inferEventTypeFromData(rawEvent, config) {
|
|
148
|
+
// LM Studio (and some other OpenAI-compatible servers) may omit the SSE `event:` line and only
|
|
149
|
+
// send JSON payloads like: `data: {"type":"response.output_item.added", ...}`.
|
|
150
|
+
// Per SSE spec the default event type becomes "message", but for our protocol converters we
|
|
151
|
+
// need the real OpenAI event type to pass strict validation and builder logic.
|
|
152
|
+
if (rawEvent.event && rawEvent.event !== 'message') {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
if (!rawEvent.data) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const parsed = safeJsonParse(rawEvent.data);
|
|
160
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
const candidate = parsed.type;
|
|
164
|
+
if (typeof candidate !== 'string' || !candidate.trim()) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
const normalized = candidate.trim();
|
|
168
|
+
if (!validateEventType(normalized, config)) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return normalized;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
147
177
|
/**
|
|
148
178
|
* 创建基础事件对象
|
|
149
179
|
*/
|
|
@@ -279,6 +309,10 @@ export function parseSseEvent(sseText, config = DEFAULT_SSE_PARSER_CONFIG) {
|
|
|
279
309
|
result.error = 'Invalid SSE event format';
|
|
280
310
|
return result;
|
|
281
311
|
}
|
|
312
|
+
const inferred = inferEventTypeFromData(rawEvent, config);
|
|
313
|
+
if (inferred) {
|
|
314
|
+
rawEvent.event = inferred;
|
|
315
|
+
}
|
|
282
316
|
// 验证事件大小
|
|
283
317
|
if (config.enableStrictValidation && sseText.length > config.maxEventSize) {
|
|
284
318
|
result.error = `Event size ${sseText.length} exceeds maximum ${config.maxEventSize}`;
|
|
@@ -15,6 +15,7 @@ export declare class ResponsesSseToJsonConverterRefactored {
|
|
|
15
15
|
* 将SSE流转换为Responses响应
|
|
16
16
|
*/
|
|
17
17
|
convertSseToJson(sseStream: ResponsesSseEventStream, options: SseToResponsesJsonOptions): Promise<ResponsesResponse>;
|
|
18
|
+
private isTerminatedError;
|
|
18
19
|
/**
|
|
19
20
|
* 创建可读流
|
|
20
21
|
*/
|
|
@@ -31,6 +31,7 @@ export class ResponsesSseToJsonConverterRefactored {
|
|
|
31
31
|
// 1. 创建上下文
|
|
32
32
|
const context = this.createContext(options);
|
|
33
33
|
this.contexts.set(options.requestId, context);
|
|
34
|
+
let responseBuilder = null;
|
|
34
35
|
try {
|
|
35
36
|
// 2. 创建解析器
|
|
36
37
|
const parser = createSseParser({
|
|
@@ -38,7 +39,7 @@ export class ResponsesSseToJsonConverterRefactored {
|
|
|
38
39
|
enableEventRecovery: !this.config.strictMode
|
|
39
40
|
});
|
|
40
41
|
// 3. 创建响应构建器
|
|
41
|
-
|
|
42
|
+
responseBuilder = createResponseBuilder({
|
|
42
43
|
enableStrictValidation: false,
|
|
43
44
|
enableEventRecovery: !this.config.strictMode,
|
|
44
45
|
maxOutputItems: 50,
|
|
@@ -100,6 +101,25 @@ export class ResponsesSseToJsonConverterRefactored {
|
|
|
100
101
|
return result.response;
|
|
101
102
|
}
|
|
102
103
|
catch (error) {
|
|
104
|
+
// 容错:部分 OpenAI-compatible 上游(例如 LM Studio)在产出 tool_call 后会直接断开 SSE 连接,
|
|
105
|
+
// undici 会抛出 "terminated"。若此时已聚合出可用 response,则应优先返回而不是把它当作致命错误。
|
|
106
|
+
if (responseBuilder && this.isTerminatedError(error)) {
|
|
107
|
+
try {
|
|
108
|
+
const salvaged = responseBuilder.getResult();
|
|
109
|
+
if (salvaged.success && salvaged.response) {
|
|
110
|
+
context.isCompleted = true;
|
|
111
|
+
context.endTime = Date.now();
|
|
112
|
+
context.duration = context.endTime - context.startTime;
|
|
113
|
+
if (options.onCompletion) {
|
|
114
|
+
options.onCompletion(salvaged.response);
|
|
115
|
+
}
|
|
116
|
+
return salvaged.response;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// ignore salvage failure, fall through to normal error path
|
|
121
|
+
}
|
|
122
|
+
}
|
|
103
123
|
context.isCompleted = true;
|
|
104
124
|
context.endTime = Date.now();
|
|
105
125
|
context.duration = context.endTime - context.startTime;
|
|
@@ -114,6 +134,18 @@ export class ResponsesSseToJsonConverterRefactored {
|
|
|
114
134
|
this.clearContext(options.requestId);
|
|
115
135
|
}
|
|
116
136
|
}
|
|
137
|
+
isTerminatedError(error) {
|
|
138
|
+
if (!error || typeof error !== 'object')
|
|
139
|
+
return false;
|
|
140
|
+
const msg = error.message;
|
|
141
|
+
if (typeof msg !== 'string') {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
const normalized = msg.toLowerCase();
|
|
145
|
+
return (normalized.includes('terminated') ||
|
|
146
|
+
normalized.includes('upstream_stream_idle_timeout') ||
|
|
147
|
+
normalized.includes('upstream_stream_timeout'));
|
|
148
|
+
}
|
|
117
149
|
/**
|
|
118
150
|
* 创建可读流
|
|
119
151
|
*/
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const DEFAULT_APPLY_PATCH_NORMALIZE_ACTIONS = [
|
|
2
|
+
{ action: 'raw_non_json_patch' },
|
|
3
|
+
{ action: 'json_container_patch_fallback' },
|
|
4
|
+
{ action: 'record_text_fields', fields: ['patch'] },
|
|
5
|
+
{ action: 'record_conflict_patch', patchField: 'patch', fileFields: ['file', 'path', 'filepath', 'filename'] },
|
|
6
|
+
{ action: 'record_text_fields', fields: ['diff', 'patchText', 'body', 'input', 'instructions'] },
|
|
7
|
+
{ action: 'record_raw_envelope', field: '_raw', parseJson: true, maxDepth: 3 },
|
|
8
|
+
{ action: 'record_structured_payload' },
|
|
9
|
+
{ action: 'array_structured_payload' },
|
|
10
|
+
{ action: 'raw_string_patch' },
|
|
11
|
+
{ action: 'invalid_json_guard' }
|
|
12
|
+
];
|