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