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.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +757 -0
  3. package/bin/cli.js +146 -0
  4. package/package.json +97 -0
  5. package/public/Complaint Details.pdf +0 -0
  6. package/public/Cyber Crime Portal.pdf +0 -0
  7. package/public/app.js +229 -0
  8. package/public/css/src/input.css +523 -0
  9. package/public/css/style.css +1 -0
  10. package/public/favicon.png +0 -0
  11. package/public/index.html +549 -0
  12. package/public/js/components/account-manager.js +356 -0
  13. package/public/js/components/add-account-modal.js +414 -0
  14. package/public/js/components/claude-config.js +420 -0
  15. package/public/js/components/dashboard/charts.js +605 -0
  16. package/public/js/components/dashboard/filters.js +362 -0
  17. package/public/js/components/dashboard/stats.js +110 -0
  18. package/public/js/components/dashboard.js +236 -0
  19. package/public/js/components/logs-viewer.js +100 -0
  20. package/public/js/components/models.js +36 -0
  21. package/public/js/components/server-config.js +349 -0
  22. package/public/js/config/constants.js +102 -0
  23. package/public/js/data-store.js +375 -0
  24. package/public/js/settings-store.js +58 -0
  25. package/public/js/store.js +99 -0
  26. package/public/js/translations/en.js +367 -0
  27. package/public/js/translations/id.js +412 -0
  28. package/public/js/translations/pt.js +308 -0
  29. package/public/js/translations/tr.js +358 -0
  30. package/public/js/translations/zh.js +373 -0
  31. package/public/js/utils/account-actions.js +189 -0
  32. package/public/js/utils/error-handler.js +96 -0
  33. package/public/js/utils/model-config.js +42 -0
  34. package/public/js/utils/ui-logger.js +143 -0
  35. package/public/js/utils/validators.js +77 -0
  36. package/public/js/utils.js +69 -0
  37. package/public/proxy-server-64.png +0 -0
  38. package/public/views/accounts.html +361 -0
  39. package/public/views/dashboard.html +484 -0
  40. package/public/views/logs.html +97 -0
  41. package/public/views/models.html +331 -0
  42. package/public/views/settings.html +1327 -0
  43. package/src/account-manager/credentials.js +378 -0
  44. package/src/account-manager/index.js +462 -0
  45. package/src/account-manager/onboarding.js +112 -0
  46. package/src/account-manager/rate-limits.js +369 -0
  47. package/src/account-manager/storage.js +160 -0
  48. package/src/account-manager/strategies/base-strategy.js +109 -0
  49. package/src/account-manager/strategies/hybrid-strategy.js +339 -0
  50. package/src/account-manager/strategies/index.js +79 -0
  51. package/src/account-manager/strategies/round-robin-strategy.js +76 -0
  52. package/src/account-manager/strategies/sticky-strategy.js +138 -0
  53. package/src/account-manager/strategies/trackers/health-tracker.js +162 -0
  54. package/src/account-manager/strategies/trackers/index.js +9 -0
  55. package/src/account-manager/strategies/trackers/quota-tracker.js +120 -0
  56. package/src/account-manager/strategies/trackers/token-bucket-tracker.js +155 -0
  57. package/src/auth/database.js +169 -0
  58. package/src/auth/oauth.js +548 -0
  59. package/src/auth/token-extractor.js +117 -0
  60. package/src/cli/accounts.js +648 -0
  61. package/src/cloudcode/index.js +29 -0
  62. package/src/cloudcode/message-handler.js +510 -0
  63. package/src/cloudcode/model-api.js +248 -0
  64. package/src/cloudcode/rate-limit-parser.js +235 -0
  65. package/src/cloudcode/request-builder.js +93 -0
  66. package/src/cloudcode/session-manager.js +47 -0
  67. package/src/cloudcode/sse-parser.js +121 -0
  68. package/src/cloudcode/sse-streamer.js +293 -0
  69. package/src/cloudcode/streaming-handler.js +615 -0
  70. package/src/config.js +125 -0
  71. package/src/constants.js +407 -0
  72. package/src/errors.js +242 -0
  73. package/src/fallback-config.js +29 -0
  74. package/src/format/content-converter.js +193 -0
  75. package/src/format/index.js +20 -0
  76. package/src/format/request-converter.js +255 -0
  77. package/src/format/response-converter.js +120 -0
  78. package/src/format/schema-sanitizer.js +673 -0
  79. package/src/format/signature-cache.js +88 -0
  80. package/src/format/thinking-utils.js +648 -0
  81. package/src/index.js +148 -0
  82. package/src/modules/usage-stats.js +205 -0
  83. package/src/providers/anthropic-provider.js +258 -0
  84. package/src/providers/base-provider.js +157 -0
  85. package/src/providers/cloudcode.js +94 -0
  86. package/src/providers/copilot.js +399 -0
  87. package/src/providers/github-provider.js +287 -0
  88. package/src/providers/google-provider.js +192 -0
  89. package/src/providers/index.js +211 -0
  90. package/src/providers/openai-compatible.js +265 -0
  91. package/src/providers/openai-provider.js +271 -0
  92. package/src/providers/openrouter-provider.js +325 -0
  93. package/src/providers/setup.js +83 -0
  94. package/src/server.js +870 -0
  95. package/src/utils/claude-config.js +245 -0
  96. package/src/utils/helpers.js +51 -0
  97. package/src/utils/logger.js +142 -0
  98. package/src/utils/native-module-helper.js +162 -0
  99. 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
+ }