commons-proxy 2.0.0
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/LICENSE +21 -0
- package/README.md +757 -0
- package/bin/cli.js +146 -0
- package/package.json +97 -0
- package/public/Complaint Details.pdf +0 -0
- package/public/Cyber Crime Portal.pdf +0 -0
- package/public/app.js +229 -0
- package/public/css/src/input.css +523 -0
- package/public/css/style.css +1 -0
- package/public/favicon.png +0 -0
- package/public/index.html +549 -0
- package/public/js/components/account-manager.js +356 -0
- package/public/js/components/add-account-modal.js +414 -0
- package/public/js/components/claude-config.js +420 -0
- package/public/js/components/dashboard/charts.js +605 -0
- package/public/js/components/dashboard/filters.js +362 -0
- package/public/js/components/dashboard/stats.js +110 -0
- package/public/js/components/dashboard.js +236 -0
- package/public/js/components/logs-viewer.js +100 -0
- package/public/js/components/models.js +36 -0
- package/public/js/components/server-config.js +349 -0
- package/public/js/config/constants.js +102 -0
- package/public/js/data-store.js +375 -0
- package/public/js/settings-store.js +58 -0
- package/public/js/store.js +99 -0
- package/public/js/translations/en.js +367 -0
- package/public/js/translations/id.js +412 -0
- package/public/js/translations/pt.js +308 -0
- package/public/js/translations/tr.js +358 -0
- package/public/js/translations/zh.js +373 -0
- package/public/js/utils/account-actions.js +189 -0
- package/public/js/utils/error-handler.js +96 -0
- package/public/js/utils/model-config.js +42 -0
- package/public/js/utils/ui-logger.js +143 -0
- package/public/js/utils/validators.js +77 -0
- package/public/js/utils.js +69 -0
- package/public/proxy-server-64.png +0 -0
- package/public/views/accounts.html +361 -0
- package/public/views/dashboard.html +484 -0
- package/public/views/logs.html +97 -0
- package/public/views/models.html +331 -0
- package/public/views/settings.html +1327 -0
- package/src/account-manager/credentials.js +378 -0
- package/src/account-manager/index.js +462 -0
- package/src/account-manager/onboarding.js +112 -0
- package/src/account-manager/rate-limits.js +369 -0
- package/src/account-manager/storage.js +160 -0
- package/src/account-manager/strategies/base-strategy.js +109 -0
- package/src/account-manager/strategies/hybrid-strategy.js +339 -0
- package/src/account-manager/strategies/index.js +79 -0
- package/src/account-manager/strategies/round-robin-strategy.js +76 -0
- package/src/account-manager/strategies/sticky-strategy.js +138 -0
- package/src/account-manager/strategies/trackers/health-tracker.js +162 -0
- package/src/account-manager/strategies/trackers/index.js +9 -0
- package/src/account-manager/strategies/trackers/quota-tracker.js +120 -0
- package/src/account-manager/strategies/trackers/token-bucket-tracker.js +155 -0
- package/src/auth/database.js +169 -0
- package/src/auth/oauth.js +548 -0
- package/src/auth/token-extractor.js +117 -0
- package/src/cli/accounts.js +648 -0
- package/src/cloudcode/index.js +29 -0
- package/src/cloudcode/message-handler.js +510 -0
- package/src/cloudcode/model-api.js +248 -0
- package/src/cloudcode/rate-limit-parser.js +235 -0
- package/src/cloudcode/request-builder.js +93 -0
- package/src/cloudcode/session-manager.js +47 -0
- package/src/cloudcode/sse-parser.js +121 -0
- package/src/cloudcode/sse-streamer.js +293 -0
- package/src/cloudcode/streaming-handler.js +615 -0
- package/src/config.js +125 -0
- package/src/constants.js +407 -0
- package/src/errors.js +242 -0
- package/src/fallback-config.js +29 -0
- package/src/format/content-converter.js +193 -0
- package/src/format/index.js +20 -0
- package/src/format/request-converter.js +255 -0
- package/src/format/response-converter.js +120 -0
- package/src/format/schema-sanitizer.js +673 -0
- package/src/format/signature-cache.js +88 -0
- package/src/format/thinking-utils.js +648 -0
- package/src/index.js +148 -0
- package/src/modules/usage-stats.js +205 -0
- package/src/providers/anthropic-provider.js +258 -0
- package/src/providers/base-provider.js +157 -0
- package/src/providers/cloudcode.js +94 -0
- package/src/providers/copilot.js +399 -0
- package/src/providers/github-provider.js +287 -0
- package/src/providers/google-provider.js +192 -0
- package/src/providers/index.js +211 -0
- package/src/providers/openai-compatible.js +265 -0
- package/src/providers/openai-provider.js +271 -0
- package/src/providers/openrouter-provider.js +325 -0
- package/src/providers/setup.js +83 -0
- package/src/server.js +870 -0
- package/src/utils/claude-config.js +245 -0
- package/src/utils/helpers.js +51 -0
- package/src/utils/logger.js +142 -0
- package/src/utils/native-module-helper.js +162 -0
- package/src/webui/index.js +1134 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thinking Block Utilities
|
|
3
|
+
* Handles thinking block processing, validation, and filtering
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { MIN_SIGNATURE_LENGTH } from '../constants.js';
|
|
7
|
+
import { getCachedSignatureFamily } from './signature-cache.js';
|
|
8
|
+
import { logger } from '../utils/logger.js';
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Cache Control Cleaning (Issue #189)
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Remove cache_control fields from all content blocks in messages.
|
|
16
|
+
* This is a critical fix for Issue #189 where Claude Code CLI sends cache_control
|
|
17
|
+
* fields that the Cloud Code API rejects with "Extra inputs are not permitted".
|
|
18
|
+
*
|
|
19
|
+
* Inspired by Antigravity-Manager's clean_cache_control_from_messages() approach,
|
|
20
|
+
* this function proactively strips cache_control from ALL block types at the
|
|
21
|
+
* entry point of the conversion pipeline.
|
|
22
|
+
*
|
|
23
|
+
* @param {Array<Object>} messages - Array of messages in Anthropic format
|
|
24
|
+
* @returns {Array<Object>} Messages with cache_control fields removed
|
|
25
|
+
*/
|
|
26
|
+
export function cleanCacheControl(messages) {
|
|
27
|
+
if (!Array.isArray(messages)) return messages;
|
|
28
|
+
|
|
29
|
+
let removedCount = 0;
|
|
30
|
+
|
|
31
|
+
const cleaned = messages.map(message => {
|
|
32
|
+
if (!message || typeof message !== 'object') return message;
|
|
33
|
+
|
|
34
|
+
// Handle string content (no cache_control possible)
|
|
35
|
+
if (typeof message.content === 'string') return message;
|
|
36
|
+
|
|
37
|
+
// Handle array content
|
|
38
|
+
if (!Array.isArray(message.content)) return message;
|
|
39
|
+
|
|
40
|
+
const cleanedContent = message.content.map(block => {
|
|
41
|
+
if (!block || typeof block !== 'object') return block;
|
|
42
|
+
|
|
43
|
+
// Check if cache_control exists before destructuring
|
|
44
|
+
if (block.cache_control === undefined) return block;
|
|
45
|
+
|
|
46
|
+
// Create a shallow copy without cache_control
|
|
47
|
+
const { cache_control, ...cleanBlock } = block;
|
|
48
|
+
removedCount++;
|
|
49
|
+
|
|
50
|
+
return cleanBlock;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
...message,
|
|
55
|
+
content: cleanedContent
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (removedCount > 0) {
|
|
60
|
+
logger.debug(`[ThinkingUtils] Removed cache_control from ${removedCount} block(s)`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return cleaned;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check if a part is a thinking block
|
|
68
|
+
* @param {Object} part - Content part to check
|
|
69
|
+
* @returns {boolean} True if the part is a thinking block
|
|
70
|
+
*/
|
|
71
|
+
function isThinkingPart(part) {
|
|
72
|
+
return part.type === 'thinking' ||
|
|
73
|
+
part.type === 'redacted_thinking' ||
|
|
74
|
+
part.thinking !== undefined ||
|
|
75
|
+
part.thought === true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check if a thinking part has a valid signature (>= MIN_SIGNATURE_LENGTH chars)
|
|
80
|
+
*/
|
|
81
|
+
function hasValidSignature(part) {
|
|
82
|
+
const signature = part.thought === true ? part.thoughtSignature : part.signature;
|
|
83
|
+
return typeof signature === 'string' && signature.length >= MIN_SIGNATURE_LENGTH;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check if conversation history contains Gemini-style messages.
|
|
88
|
+
* Gemini puts thoughtSignature on tool_use blocks, Claude puts signature on thinking blocks.
|
|
89
|
+
* @param {Array<Object>} messages - Array of messages
|
|
90
|
+
* @returns {boolean} True if any tool_use has thoughtSignature (Gemini pattern)
|
|
91
|
+
*/
|
|
92
|
+
export function hasGeminiHistory(messages) {
|
|
93
|
+
return messages.some(msg =>
|
|
94
|
+
Array.isArray(msg.content) &&
|
|
95
|
+
msg.content.some(block =>
|
|
96
|
+
block.type === 'tool_use' && block.thoughtSignature !== undefined
|
|
97
|
+
)
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Check if conversation has unsigned thinking blocks that will be dropped.
|
|
103
|
+
* These cause "Expected thinking but found text" errors.
|
|
104
|
+
* @param {Array<Object>} messages - Array of messages
|
|
105
|
+
* @returns {boolean} True if any assistant message has unsigned thinking blocks
|
|
106
|
+
*/
|
|
107
|
+
export function hasUnsignedThinkingBlocks(messages) {
|
|
108
|
+
return messages.some(msg => {
|
|
109
|
+
if (msg.role !== 'assistant' && msg.role !== 'model') return false;
|
|
110
|
+
if (!Array.isArray(msg.content)) return false;
|
|
111
|
+
return msg.content.some(block =>
|
|
112
|
+
isThinkingPart(block) && !hasValidSignature(block)
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Sanitize a thinking part by keeping only allowed fields
|
|
119
|
+
*/
|
|
120
|
+
function sanitizeThinkingPart(part) {
|
|
121
|
+
// Gemini-style thought blocks: { thought: true, text, thoughtSignature }
|
|
122
|
+
if (part.thought === true) {
|
|
123
|
+
const sanitized = { thought: true };
|
|
124
|
+
if (part.text !== undefined) sanitized.text = part.text;
|
|
125
|
+
if (part.thoughtSignature !== undefined) sanitized.thoughtSignature = part.thoughtSignature;
|
|
126
|
+
return sanitized;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Anthropic-style thinking blocks: { type: "thinking", thinking, signature }
|
|
130
|
+
if (part.type === 'thinking' || part.thinking !== undefined) {
|
|
131
|
+
const sanitized = { type: 'thinking' };
|
|
132
|
+
if (part.thinking !== undefined) sanitized.thinking = part.thinking;
|
|
133
|
+
if (part.signature !== undefined) sanitized.signature = part.signature;
|
|
134
|
+
return sanitized;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return part;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Sanitize a thinking block by removing extra fields like cache_control.
|
|
142
|
+
* Only keeps: type, thinking, signature (for thinking) or type, data (for redacted_thinking)
|
|
143
|
+
*/
|
|
144
|
+
function sanitizeAnthropicThinkingBlock(block) {
|
|
145
|
+
if (!block) return block;
|
|
146
|
+
|
|
147
|
+
if (block.type === 'thinking') {
|
|
148
|
+
const sanitized = { type: 'thinking' };
|
|
149
|
+
if (block.thinking !== undefined) sanitized.thinking = block.thinking;
|
|
150
|
+
if (block.signature !== undefined) sanitized.signature = block.signature;
|
|
151
|
+
return sanitized;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (block.type === 'redacted_thinking') {
|
|
155
|
+
const sanitized = { type: 'redacted_thinking' };
|
|
156
|
+
if (block.data !== undefined) sanitized.data = block.data;
|
|
157
|
+
return sanitized;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return block;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Sanitize a text block by removing extra fields like cache_control.
|
|
165
|
+
* Only keeps: type, text
|
|
166
|
+
* @param {Object} block - Text block to sanitize
|
|
167
|
+
* @returns {Object} Sanitized text block
|
|
168
|
+
*/
|
|
169
|
+
function sanitizeTextBlock(block) {
|
|
170
|
+
if (!block || block.type !== 'text') return block;
|
|
171
|
+
|
|
172
|
+
const sanitized = { type: 'text' };
|
|
173
|
+
if (block.text !== undefined) sanitized.text = block.text;
|
|
174
|
+
return sanitized;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Sanitize a tool_use block by removing extra fields like cache_control.
|
|
179
|
+
* Only keeps: type, id, name, input, thoughtSignature (for Gemini)
|
|
180
|
+
* @param {Object} block - Tool_use block to sanitize
|
|
181
|
+
* @returns {Object} Sanitized tool_use block
|
|
182
|
+
*/
|
|
183
|
+
function sanitizeToolUseBlock(block) {
|
|
184
|
+
if (!block || block.type !== 'tool_use') return block;
|
|
185
|
+
|
|
186
|
+
const sanitized = { type: 'tool_use' };
|
|
187
|
+
if (block.id !== undefined) sanitized.id = block.id;
|
|
188
|
+
if (block.name !== undefined) sanitized.name = block.name;
|
|
189
|
+
if (block.input !== undefined) sanitized.input = block.input;
|
|
190
|
+
// Preserve thoughtSignature for Gemini models
|
|
191
|
+
if (block.thoughtSignature !== undefined) sanitized.thoughtSignature = block.thoughtSignature;
|
|
192
|
+
return sanitized;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Filter content array, keeping only thinking blocks with valid signatures.
|
|
197
|
+
*/
|
|
198
|
+
function filterContentArray(contentArray) {
|
|
199
|
+
const filtered = [];
|
|
200
|
+
|
|
201
|
+
for (const item of contentArray) {
|
|
202
|
+
if (!item || typeof item !== 'object') {
|
|
203
|
+
filtered.push(item);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!isThinkingPart(item)) {
|
|
208
|
+
filtered.push(item);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Keep items with valid signatures
|
|
213
|
+
if (hasValidSignature(item)) {
|
|
214
|
+
filtered.push(sanitizeThinkingPart(item));
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Drop unsigned thinking blocks
|
|
219
|
+
logger.debug('[ThinkingUtils] Dropping unsigned thinking block');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return filtered;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Filter unsigned thinking blocks from contents (Gemini format)
|
|
227
|
+
*
|
|
228
|
+
* @param {Array<{role: string, parts: Array}>} contents - Array of content objects in Gemini format
|
|
229
|
+
* @returns {Array<{role: string, parts: Array}>} Filtered contents with unsigned thinking blocks removed
|
|
230
|
+
*/
|
|
231
|
+
export function filterUnsignedThinkingBlocks(contents) {
|
|
232
|
+
return contents.map(content => {
|
|
233
|
+
if (!content || typeof content !== 'object') return content;
|
|
234
|
+
|
|
235
|
+
if (Array.isArray(content.parts)) {
|
|
236
|
+
return { ...content, parts: filterContentArray(content.parts) };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return content;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Remove trailing unsigned thinking blocks from assistant messages.
|
|
245
|
+
* Claude/Gemini APIs require that assistant messages don't end with unsigned thinking blocks.
|
|
246
|
+
*
|
|
247
|
+
* @param {Array<Object>} content - Array of content blocks
|
|
248
|
+
* @returns {Array<Object>} Content array with trailing unsigned thinking blocks removed
|
|
249
|
+
*/
|
|
250
|
+
export function removeTrailingThinkingBlocks(content) {
|
|
251
|
+
if (!Array.isArray(content)) return content;
|
|
252
|
+
if (content.length === 0) return content;
|
|
253
|
+
|
|
254
|
+
// Work backwards from the end, removing thinking blocks
|
|
255
|
+
let endIndex = content.length;
|
|
256
|
+
for (let i = content.length - 1; i >= 0; i--) {
|
|
257
|
+
const block = content[i];
|
|
258
|
+
if (!block || typeof block !== 'object') break;
|
|
259
|
+
|
|
260
|
+
// Check if it's a thinking block (any format)
|
|
261
|
+
const isThinking = isThinkingPart(block);
|
|
262
|
+
|
|
263
|
+
if (isThinking) {
|
|
264
|
+
// Check if it has a valid signature
|
|
265
|
+
if (!hasValidSignature(block)) {
|
|
266
|
+
endIndex = i;
|
|
267
|
+
} else {
|
|
268
|
+
break; // Stop at signed thinking block
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
break; // Stop at first non-thinking block
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (endIndex < content.length) {
|
|
276
|
+
logger.debug('[ThinkingUtils] Removed', content.length - endIndex, 'trailing unsigned thinking blocks');
|
|
277
|
+
return content.slice(0, endIndex);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return content;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Filter thinking blocks: keep only those with valid signatures.
|
|
285
|
+
* Blocks without signatures are dropped (API requires signatures).
|
|
286
|
+
* Also sanitizes blocks to remove extra fields like cache_control.
|
|
287
|
+
*
|
|
288
|
+
* @param {Array<Object>} content - Array of content blocks
|
|
289
|
+
* @returns {Array<Object>} Filtered content with only valid signed thinking blocks
|
|
290
|
+
*/
|
|
291
|
+
export function restoreThinkingSignatures(content) {
|
|
292
|
+
if (!Array.isArray(content)) return content;
|
|
293
|
+
|
|
294
|
+
const originalLength = content.length;
|
|
295
|
+
const filtered = [];
|
|
296
|
+
|
|
297
|
+
for (const block of content) {
|
|
298
|
+
if (!block || block.type !== 'thinking') {
|
|
299
|
+
filtered.push(block);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Keep blocks with valid signatures (>= MIN_SIGNATURE_LENGTH chars), sanitized
|
|
304
|
+
if (block.signature && block.signature.length >= MIN_SIGNATURE_LENGTH) {
|
|
305
|
+
filtered.push(sanitizeAnthropicThinkingBlock(block));
|
|
306
|
+
}
|
|
307
|
+
// Unsigned thinking blocks are dropped
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (filtered.length < originalLength) {
|
|
311
|
+
logger.debug(`[ThinkingUtils] Dropped ${originalLength - filtered.length} unsigned thinking block(s)`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return filtered;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Reorder content so that:
|
|
319
|
+
* 1. Thinking blocks come first (required when thinking is enabled)
|
|
320
|
+
* 2. Text blocks come in the middle (filtering out empty/useless ones)
|
|
321
|
+
* 3. Tool_use blocks come at the end (required before tool_result)
|
|
322
|
+
*
|
|
323
|
+
* @param {Array<Object>} content - Array of content blocks
|
|
324
|
+
* @returns {Array<Object>} Reordered content array
|
|
325
|
+
*/
|
|
326
|
+
export function reorderAssistantContent(content) {
|
|
327
|
+
if (!Array.isArray(content)) return content;
|
|
328
|
+
|
|
329
|
+
// Even for single-element arrays, we need to sanitize thinking blocks
|
|
330
|
+
if (content.length === 1) {
|
|
331
|
+
const block = content[0];
|
|
332
|
+
if (block && (block.type === 'thinking' || block.type === 'redacted_thinking')) {
|
|
333
|
+
return [sanitizeAnthropicThinkingBlock(block)];
|
|
334
|
+
}
|
|
335
|
+
return content;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const thinkingBlocks = [];
|
|
339
|
+
const textBlocks = [];
|
|
340
|
+
const toolUseBlocks = [];
|
|
341
|
+
let droppedEmptyBlocks = 0;
|
|
342
|
+
|
|
343
|
+
for (const block of content) {
|
|
344
|
+
if (!block) continue;
|
|
345
|
+
|
|
346
|
+
if (block.type === 'thinking' || block.type === 'redacted_thinking') {
|
|
347
|
+
// Sanitize thinking blocks to remove cache_control and other extra fields
|
|
348
|
+
thinkingBlocks.push(sanitizeAnthropicThinkingBlock(block));
|
|
349
|
+
} else if (block.type === 'tool_use') {
|
|
350
|
+
// Sanitize tool_use blocks to remove cache_control and other extra fields
|
|
351
|
+
toolUseBlocks.push(sanitizeToolUseBlock(block));
|
|
352
|
+
} else if (block.type === 'text') {
|
|
353
|
+
// Only keep text blocks with meaningful content
|
|
354
|
+
if (block.text && block.text.trim().length > 0) {
|
|
355
|
+
// Sanitize text blocks to remove cache_control and other extra fields
|
|
356
|
+
textBlocks.push(sanitizeTextBlock(block));
|
|
357
|
+
} else {
|
|
358
|
+
droppedEmptyBlocks++;
|
|
359
|
+
}
|
|
360
|
+
} else {
|
|
361
|
+
// Other block types go in the text position
|
|
362
|
+
textBlocks.push(block);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (droppedEmptyBlocks > 0) {
|
|
367
|
+
logger.debug(`[ThinkingUtils] Dropped ${droppedEmptyBlocks} empty text block(s)`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const reordered = [...thinkingBlocks, ...textBlocks, ...toolUseBlocks];
|
|
371
|
+
|
|
372
|
+
// Log only if actual reordering happened (not just filtering)
|
|
373
|
+
if (reordered.length === content.length) {
|
|
374
|
+
const originalOrder = content.map(b => b?.type || 'unknown').join(',');
|
|
375
|
+
const newOrder = reordered.map(b => b?.type || 'unknown').join(',');
|
|
376
|
+
if (originalOrder !== newOrder) {
|
|
377
|
+
logger.debug('[ThinkingUtils] Reordered assistant content');
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return reordered;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ============================================================================
|
|
385
|
+
// Thinking Recovery Functions
|
|
386
|
+
// ============================================================================
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Check if a message has any VALID (signed) thinking blocks.
|
|
390
|
+
* Only counts thinking blocks that have valid signatures, not unsigned ones
|
|
391
|
+
* that will be dropped later.
|
|
392
|
+
*
|
|
393
|
+
* @param {Object} message - Message to check
|
|
394
|
+
* @returns {boolean} True if message has valid signed thinking blocks
|
|
395
|
+
*/
|
|
396
|
+
function messageHasValidThinking(message) {
|
|
397
|
+
const content = message.content || message.parts || [];
|
|
398
|
+
if (!Array.isArray(content)) return false;
|
|
399
|
+
return content.some(block => {
|
|
400
|
+
if (!isThinkingPart(block)) return false;
|
|
401
|
+
// Check for valid signature (Anthropic style)
|
|
402
|
+
if (block.signature && block.signature.length >= MIN_SIGNATURE_LENGTH) return true;
|
|
403
|
+
// Check for thoughtSignature (Gemini style on functionCall)
|
|
404
|
+
if (block.thoughtSignature && block.thoughtSignature.length >= MIN_SIGNATURE_LENGTH) return true;
|
|
405
|
+
return false;
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Check if a message has tool_use blocks
|
|
411
|
+
* @param {Object} message - Message to check
|
|
412
|
+
* @returns {boolean} True if message has tool_use blocks
|
|
413
|
+
*/
|
|
414
|
+
function messageHasToolUse(message) {
|
|
415
|
+
const content = message.content || message.parts || [];
|
|
416
|
+
if (!Array.isArray(content)) return false;
|
|
417
|
+
return content.some(block =>
|
|
418
|
+
block.type === 'tool_use' || block.functionCall
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Check if a message has tool_result blocks
|
|
424
|
+
* @param {Object} message - Message to check
|
|
425
|
+
* @returns {boolean} True if message has tool_result blocks
|
|
426
|
+
*/
|
|
427
|
+
function messageHasToolResult(message) {
|
|
428
|
+
const content = message.content || message.parts || [];
|
|
429
|
+
if (!Array.isArray(content)) return false;
|
|
430
|
+
return content.some(block =>
|
|
431
|
+
block.type === 'tool_result' || block.functionResponse
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Check if message is a plain user text message (not tool_result)
|
|
437
|
+
* @param {Object} message - Message to check
|
|
438
|
+
* @returns {boolean} True if message is plain user text
|
|
439
|
+
*/
|
|
440
|
+
function isPlainUserMessage(message) {
|
|
441
|
+
if (message.role !== 'user') return false;
|
|
442
|
+
const content = message.content || message.parts || [];
|
|
443
|
+
if (!Array.isArray(content)) return typeof content === 'string';
|
|
444
|
+
// Check if it has tool_result blocks
|
|
445
|
+
return !content.some(block =>
|
|
446
|
+
block.type === 'tool_result' || block.functionResponse
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Analyze conversation state to detect if we're in a corrupted state.
|
|
452
|
+
* This includes:
|
|
453
|
+
* 1. Tool loop: assistant has tool_use followed by tool_results (normal flow)
|
|
454
|
+
* 2. Interrupted tool: assistant has tool_use followed by plain user message (interrupted)
|
|
455
|
+
*
|
|
456
|
+
* @param {Array<Object>} messages - Array of messages
|
|
457
|
+
* @returns {Object} State object with inToolLoop, interruptedTool, turnHasThinking, etc.
|
|
458
|
+
*/
|
|
459
|
+
function analyzeConversationState(messages) {
|
|
460
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
461
|
+
return { inToolLoop: false, interruptedTool: false, turnHasThinking: false, toolResultCount: 0 };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Find the last assistant message
|
|
465
|
+
let lastAssistantIdx = -1;
|
|
466
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
467
|
+
if (messages[i].role === 'assistant' || messages[i].role === 'model') {
|
|
468
|
+
lastAssistantIdx = i;
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (lastAssistantIdx === -1) {
|
|
474
|
+
return { inToolLoop: false, interruptedTool: false, turnHasThinking: false, toolResultCount: 0 };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const lastAssistant = messages[lastAssistantIdx];
|
|
478
|
+
const hasToolUse = messageHasToolUse(lastAssistant);
|
|
479
|
+
const hasThinking = messageHasValidThinking(lastAssistant);
|
|
480
|
+
|
|
481
|
+
// Count trailing tool results after the assistant message
|
|
482
|
+
let toolResultCount = 0;
|
|
483
|
+
let hasPlainUserMessageAfter = false;
|
|
484
|
+
for (let i = lastAssistantIdx + 1; i < messages.length; i++) {
|
|
485
|
+
if (messageHasToolResult(messages[i])) {
|
|
486
|
+
toolResultCount++;
|
|
487
|
+
}
|
|
488
|
+
if (isPlainUserMessage(messages[i])) {
|
|
489
|
+
hasPlainUserMessageAfter = true;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// We're in a tool loop if: assistant has tool_use AND there are tool_results after
|
|
494
|
+
const inToolLoop = hasToolUse && toolResultCount > 0;
|
|
495
|
+
|
|
496
|
+
// We have an interrupted tool if: assistant has tool_use, NO tool_results,
|
|
497
|
+
// but there IS a plain user message after (user interrupted and sent new message)
|
|
498
|
+
const interruptedTool = hasToolUse && toolResultCount === 0 && hasPlainUserMessageAfter;
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
inToolLoop,
|
|
502
|
+
interruptedTool,
|
|
503
|
+
turnHasThinking: hasThinking,
|
|
504
|
+
toolResultCount,
|
|
505
|
+
lastAssistantIdx
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Check if conversation needs thinking recovery.
|
|
511
|
+
*
|
|
512
|
+
* Recovery is only needed when:
|
|
513
|
+
* 1. We're in a tool loop or have an interrupted tool, AND
|
|
514
|
+
* 2. No valid thinking blocks exist in the current turn
|
|
515
|
+
*
|
|
516
|
+
* Cross-model signature compatibility is handled by stripInvalidThinkingBlocks
|
|
517
|
+
* during recovery (not here).
|
|
518
|
+
*
|
|
519
|
+
* @param {Array<Object>} messages - Array of messages
|
|
520
|
+
* @returns {boolean} True if thinking recovery is needed
|
|
521
|
+
*/
|
|
522
|
+
export function needsThinkingRecovery(messages) {
|
|
523
|
+
const state = analyzeConversationState(messages);
|
|
524
|
+
|
|
525
|
+
// Recovery is only needed in tool loops or interrupted tools
|
|
526
|
+
if (!state.inToolLoop && !state.interruptedTool) return false;
|
|
527
|
+
|
|
528
|
+
// Need recovery if no valid thinking blocks exist
|
|
529
|
+
return !state.turnHasThinking;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Strip invalid or incompatible thinking blocks from messages.
|
|
534
|
+
* Used before injecting synthetic messages for recovery.
|
|
535
|
+
* Keeps valid thinking blocks to preserve context from previous turns.
|
|
536
|
+
*
|
|
537
|
+
* @param {Array<Object>} messages - Array of messages
|
|
538
|
+
* @param {string} targetFamily - Target model family ('claude' or 'gemini')
|
|
539
|
+
* @returns {Array<Object>} Messages with invalid thinking blocks removed
|
|
540
|
+
*/
|
|
541
|
+
function stripInvalidThinkingBlocks(messages, targetFamily = null) {
|
|
542
|
+
let strippedCount = 0;
|
|
543
|
+
|
|
544
|
+
const result = messages.map(msg => {
|
|
545
|
+
const content = msg.content || msg.parts;
|
|
546
|
+
if (!Array.isArray(content)) return msg;
|
|
547
|
+
|
|
548
|
+
const filtered = content.filter(block => {
|
|
549
|
+
// Keep non-thinking blocks
|
|
550
|
+
if (!isThinkingPart(block)) return true;
|
|
551
|
+
|
|
552
|
+
// Check generic validity (has signature of sufficient length)
|
|
553
|
+
if (!hasValidSignature(block)) {
|
|
554
|
+
strippedCount++;
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Check family compatibility only for Gemini targets
|
|
559
|
+
// Claude can validate its own signatures, so we don't drop for Claude
|
|
560
|
+
if (targetFamily === 'gemini') {
|
|
561
|
+
const signature = block.thought === true ? block.thoughtSignature : block.signature;
|
|
562
|
+
const signatureFamily = getCachedSignatureFamily(signature);
|
|
563
|
+
|
|
564
|
+
// For Gemini: drop unknown or mismatched signatures
|
|
565
|
+
if (!signatureFamily || signatureFamily !== targetFamily) {
|
|
566
|
+
strippedCount++;
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return true;
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
// Use '.' instead of '' because claude models reject empty text parts
|
|
575
|
+
if (msg.content) {
|
|
576
|
+
return { ...msg, content: filtered.length > 0 ? filtered : [{ type: 'text', text: '.' }] };
|
|
577
|
+
} else if (msg.parts) {
|
|
578
|
+
return { ...msg, parts: filtered.length > 0 ? filtered : [{ text: '.' }] };
|
|
579
|
+
}
|
|
580
|
+
return msg;
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
if (strippedCount > 0) {
|
|
584
|
+
logger.debug(`[ThinkingUtils] Stripped ${strippedCount} invalid/incompatible thinking block(s)`);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return result;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Close tool loop by injecting synthetic messages.
|
|
592
|
+
* This allows the model to start a fresh turn when thinking is corrupted.
|
|
593
|
+
*
|
|
594
|
+
* When thinking blocks are stripped (no valid signatures) and we're in the
|
|
595
|
+
* middle of a tool loop OR have an interrupted tool, the conversation is in
|
|
596
|
+
* a corrupted state. This function injects synthetic messages to close the
|
|
597
|
+
* loop and allow the model to continue.
|
|
598
|
+
*
|
|
599
|
+
* @param {Array<Object>} messages - Array of messages
|
|
600
|
+
* @param {string} targetFamily - Target model family ('claude' or 'gemini')
|
|
601
|
+
* @returns {Array<Object>} Modified messages with synthetic messages injected
|
|
602
|
+
*/
|
|
603
|
+
export function closeToolLoopForThinking(messages, targetFamily = null) {
|
|
604
|
+
const state = analyzeConversationState(messages);
|
|
605
|
+
|
|
606
|
+
// Handle neither tool loop nor interrupted tool
|
|
607
|
+
if (!state.inToolLoop && !state.interruptedTool) return messages;
|
|
608
|
+
|
|
609
|
+
// Strip only invalid/incompatible thinking blocks (keep valid ones)
|
|
610
|
+
let modified = stripInvalidThinkingBlocks(messages, targetFamily);
|
|
611
|
+
|
|
612
|
+
if (state.interruptedTool) {
|
|
613
|
+
// For interrupted tools: just strip thinking and add a synthetic assistant message
|
|
614
|
+
// to acknowledge the interruption before the user's new message
|
|
615
|
+
|
|
616
|
+
// Find where to insert the synthetic message (before the plain user message)
|
|
617
|
+
const insertIdx = state.lastAssistantIdx + 1;
|
|
618
|
+
|
|
619
|
+
// Insert synthetic assistant message acknowledging interruption
|
|
620
|
+
modified.splice(insertIdx, 0, {
|
|
621
|
+
role: 'assistant',
|
|
622
|
+
content: [{ type: 'text', text: '[Tool call was interrupted.]' }]
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
logger.debug('[ThinkingUtils] Applied thinking recovery for interrupted tool');
|
|
626
|
+
} else if (state.inToolLoop) {
|
|
627
|
+
// For tool loops: add synthetic messages to close the loop
|
|
628
|
+
const syntheticText = state.toolResultCount === 1
|
|
629
|
+
? '[Tool execution completed.]'
|
|
630
|
+
: `[${state.toolResultCount} tool executions completed.]`;
|
|
631
|
+
|
|
632
|
+
// Inject synthetic model message to complete the turn
|
|
633
|
+
modified.push({
|
|
634
|
+
role: 'assistant',
|
|
635
|
+
content: [{ type: 'text', text: syntheticText }]
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
// Inject synthetic user message to start fresh
|
|
639
|
+
modified.push({
|
|
640
|
+
role: 'user',
|
|
641
|
+
content: [{ type: 'text', text: '[Continue]' }]
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
logger.debug('[ThinkingUtils] Applied thinking recovery for tool loop');
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return modified;
|
|
648
|
+
}
|