@pikoloo/codex-proxy 1.0.6

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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +199 -0
  3. package/bin/cli.js +118 -0
  4. package/docs/ACCOUNTS.md +202 -0
  5. package/docs/API.md +289 -0
  6. package/docs/ARCHITECTURE.md +129 -0
  7. package/docs/CLAUDE_INTEGRATION.md +163 -0
  8. package/docs/OAUTH.md +85 -0
  9. package/docs/OPENCLAW.md +34 -0
  10. package/docs/legal.md +11 -0
  11. package/images/dashboard-screenshot.png +0 -0
  12. package/images/demo-screenshot.png +0 -0
  13. package/images/f757093f-507b-4453-994e-f8275f8b07a9.png +0 -0
  14. package/package.json +61 -0
  15. package/public/css/style.css +1502 -0
  16. package/public/index.html +827 -0
  17. package/public/js/app.js +601 -0
  18. package/src/account-manager.js +528 -0
  19. package/src/account-rotation/index.js +93 -0
  20. package/src/account-rotation/rate-limits.js +293 -0
  21. package/src/account-rotation/strategies/base-strategy.js +48 -0
  22. package/src/account-rotation/strategies/index.js +31 -0
  23. package/src/account-rotation/strategies/round-robin-strategy.js +42 -0
  24. package/src/account-rotation/strategies/sticky-strategy.js +97 -0
  25. package/src/claude-config.js +153 -0
  26. package/src/cli/accounts.js +557 -0
  27. package/src/direct-api.js +164 -0
  28. package/src/format-converter.js +420 -0
  29. package/src/index.js +46 -0
  30. package/src/kilo-api.js +68 -0
  31. package/src/kilo-format-converter.js +285 -0
  32. package/src/kilo-models.js +103 -0
  33. package/src/kilo-streamer.js +243 -0
  34. package/src/middleware/credentials.js +116 -0
  35. package/src/middleware/sse.js +96 -0
  36. package/src/model-api.js +189 -0
  37. package/src/model-mapper.js +157 -0
  38. package/src/oauth.js +666 -0
  39. package/src/response-streamer.js +409 -0
  40. package/src/routes/accounts-route.js +332 -0
  41. package/src/routes/api-routes.js +98 -0
  42. package/src/routes/chat-route.js +229 -0
  43. package/src/routes/claude-config-route.js +121 -0
  44. package/src/routes/logs-route.js +43 -0
  45. package/src/routes/messages-route.js +203 -0
  46. package/src/routes/models-route.js +119 -0
  47. package/src/routes/settings-route.js +143 -0
  48. package/src/security.js +142 -0
  49. package/src/server-settings.js +56 -0
  50. package/src/server.js +58 -0
  51. package/src/signature-cache.js +106 -0
  52. package/src/thinking-utils.js +312 -0
  53. package/src/utils/logger.js +156 -0
@@ -0,0 +1,142 @@
1
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
2
+ const CONTROL_PATH_PREFIXES = [
3
+ '/accounts',
4
+ '/settings',
5
+ '/claude/config',
6
+ '/api/logs'
7
+ ];
8
+ const SAFE_FETCH_SITES = new Set(['same-origin', 'same-site', 'none']);
9
+ const SENSITIVE_KEY_RE = /(api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|id[_-]?token|secret|password)/i;
10
+
11
+ export function isLoopbackAddress(address) {
12
+ if (!address || typeof address !== 'string') return false;
13
+ const normalized = address.replace(/^\[|\]$/g, '');
14
+ return normalized === '::1' ||
15
+ normalized === '127.0.0.1' ||
16
+ normalized.startsWith('127.') ||
17
+ normalized.startsWith('::ffff:127.');
18
+ }
19
+
20
+ export function isLoopbackHostname(hostname) {
21
+ if (!hostname || typeof hostname !== 'string') return false;
22
+ return LOOPBACK_HOSTS.has(hostname.toLowerCase());
23
+ }
24
+
25
+ export function isControlPath(path = '') {
26
+ return CONTROL_PATH_PREFIXES.some(prefix => path === prefix || path.startsWith(`${prefix}/`));
27
+ }
28
+
29
+ export function isStateChangingMethod(method = 'GET') {
30
+ return !['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase());
31
+ }
32
+
33
+ export function evaluateRequestAccess(req, options = {}) {
34
+ const path = req.path || req.url || '';
35
+ const method = req.method || 'GET';
36
+
37
+ if (!isControlPath(path)) {
38
+ return { allowed: true };
39
+ }
40
+
41
+ const headers = req.headers || {};
42
+ if (isStateChangingMethod(method)) {
43
+ const fetchSite = String(headers['sec-fetch-site'] || '').toLowerCase();
44
+ if (fetchSite && !SAFE_FETCH_SITES.has(fetchSite)) {
45
+ return { allowed: false, status: 403, reason: 'Cross-site control-plane request blocked' };
46
+ }
47
+
48
+ const origin = headers.origin;
49
+ if (origin && Array.isArray(options.allowedOrigins) && !options.allowedOrigins.includes(origin)) {
50
+ return { allowed: false, status: 403, reason: 'Origin is not allowed for control-plane request' };
51
+ }
52
+ }
53
+
54
+ const remoteAddress = req.socket?.remoteAddress || req.ip;
55
+ if (isLoopbackAddress(remoteAddress)) {
56
+ return { allowed: true };
57
+ }
58
+
59
+ const adminToken = options.adminToken || process.env.CODEX_CLAUDE_PROXY_ADMIN_TOKEN;
60
+ const suppliedToken = headers['x-codex-proxy-admin-token'];
61
+ if (adminToken && suppliedToken === adminToken) {
62
+ return { allowed: true };
63
+ }
64
+
65
+ return { allowed: false, status: 403, reason: 'Remote control-plane request blocked' };
66
+ }
67
+
68
+ export function securityMiddleware(options = {}) {
69
+ return (req, res, next) => {
70
+ const result = evaluateRequestAccess(req, options);
71
+ if (result.allowed) {
72
+ next();
73
+ return;
74
+ }
75
+ res.status(result.status || 403).json({ success: false, error: result.reason || 'Forbidden' });
76
+ };
77
+ }
78
+
79
+ export function redactSensitiveConfig(value) {
80
+ if (Array.isArray(value)) {
81
+ return value.map(redactSensitiveConfig);
82
+ }
83
+
84
+ if (!value || typeof value !== 'object') {
85
+ return value;
86
+ }
87
+
88
+ const result = {};
89
+ for (const [key, nestedValue] of Object.entries(value)) {
90
+ result[key] = SENSITIVE_KEY_RE.test(key) ? '[redacted]' : redactSensitiveConfig(nestedValue);
91
+ }
92
+ return result;
93
+ }
94
+
95
+ export function isAllowedApiEndpoint(apiUrl, options = {}) {
96
+ let parsed;
97
+ try {
98
+ parsed = new URL(apiUrl);
99
+ } catch {
100
+ return false;
101
+ }
102
+
103
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
104
+ return false;
105
+ }
106
+
107
+ if (isLoopbackHostname(parsed.hostname)) {
108
+ return true;
109
+ }
110
+
111
+ const allowExternal = options.allowExternal ?? process.env.CODEX_CLAUDE_PROXY_ALLOW_EXTERNAL_ENDPOINTS === 'true';
112
+ return allowExternal === true;
113
+ }
114
+
115
+ export function buildAllowedOrigins(port, host = '127.0.0.1') {
116
+ const origins = new Set([
117
+ `http://localhost:${port}`,
118
+ `http://127.0.0.1:${port}`
119
+ ]);
120
+
121
+ if (Number(port) === 80) {
122
+ origins.add('http://localhost');
123
+ origins.add('http://127.0.0.1');
124
+ }
125
+
126
+ if (host && !['0.0.0.0', '::'].includes(host)) {
127
+ origins.add(`http://${host}:${port}`);
128
+ }
129
+
130
+ return [...origins];
131
+ }
132
+
133
+ export default {
134
+ buildAllowedOrigins,
135
+ evaluateRequestAccess,
136
+ isAllowedApiEndpoint,
137
+ isControlPath,
138
+ isLoopbackAddress,
139
+ isLoopbackHostname,
140
+ redactSensitiveConfig,
141
+ securityMiddleware
142
+ };
@@ -0,0 +1,56 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { CONFIG_DIR } from './account-manager.js';
4
+
5
+ const SETTINGS_FILE = join(CONFIG_DIR, 'settings.json');
6
+ const MULTI_ACCOUNT_ROTATION_ENV = 'CODEX_CLAUDE_PROXY_ENABLE_MULTI_ACCOUNT_ROTATION';
7
+
8
+ const DEFAULT_SETTINGS = {
9
+ haikuKiloModel: 'minimax/minimax-m2.5:free',
10
+ accountStrategy: 'sticky'
11
+ };
12
+
13
+ function ensureConfigDir() {
14
+ if (!existsSync(CONFIG_DIR)) {
15
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
16
+ }
17
+ }
18
+
19
+ export function getServerSettings() {
20
+ ensureConfigDir();
21
+
22
+ if (!existsSync(SETTINGS_FILE)) {
23
+ return { ...DEFAULT_SETTINGS };
24
+ }
25
+
26
+ try {
27
+ const data = JSON.parse(readFileSync(SETTINGS_FILE, 'utf8'));
28
+ return { ...DEFAULT_SETTINGS, ...data };
29
+ } catch (error) {
30
+ console.error('[ServerSettings] Failed to read settings:', error.message);
31
+ return { ...DEFAULT_SETTINGS };
32
+ }
33
+ }
34
+
35
+ export function setServerSettings(patch = {}) {
36
+ const current = getServerSettings();
37
+ const next = { ...current, ...patch };
38
+
39
+ ensureConfigDir();
40
+ writeFileSync(SETTINGS_FILE, JSON.stringify(next, null, 2), { mode: 0o600 });
41
+ return next;
42
+ }
43
+
44
+ export function isMultiAccountRotationEnabled(env = process.env) {
45
+ return env[MULTI_ACCOUNT_ROTATION_ENV] === 'true';
46
+ }
47
+
48
+ export { SETTINGS_FILE, MULTI_ACCOUNT_ROTATION_ENV };
49
+
50
+ export default {
51
+ getServerSettings,
52
+ setServerSettings,
53
+ isMultiAccountRotationEnabled,
54
+ MULTI_ACCOUNT_ROTATION_ENV,
55
+ SETTINGS_FILE
56
+ };
package/src/server.js ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Server bootstrap
3
+ * Creates the Express app, middleware, and registers API routes.
4
+ */
5
+
6
+ import express from 'express';
7
+ import cors from 'cors';
8
+
9
+ import { ensureAccountsPersist, startAutoRefresh } from './account-manager.js';
10
+ import { registerApiRoutes } from './routes/api-routes.js';
11
+ import { buildAllowedOrigins, securityMiddleware } from './security.js';
12
+
13
+ export const DEFAULT_HOST = '127.0.0.1';
14
+
15
+ export function createServer({ port, host = DEFAULT_HOST }) {
16
+ ensureAccountsPersist();
17
+ startAutoRefresh();
18
+
19
+ const app = express();
20
+ app.disable('x-powered-by');
21
+
22
+ // High-level request logging
23
+ app.use((req, res, next) => {
24
+ const start = Date.now();
25
+ res.on('finish', () => {
26
+ const duration = Date.now() - start;
27
+ const msg = `[${req.method}] ${req.originalUrl} ${res.statusCode} (${duration}ms)`;
28
+ if (res.statusCode >= 400) {
29
+ console.log(`\x1b[31m${msg}\x1b[0m`); // Red for error
30
+ } else if (req.originalUrl !== '/health') { // Skip health check logs to reduce noise
31
+ console.log(`\x1b[36m${msg}\x1b[0m`); // Cyan for success
32
+ }
33
+ });
34
+ next();
35
+ });
36
+
37
+ const allowedOrigins = buildAllowedOrigins(port, host);
38
+
39
+ app.use(cors({
40
+ origin: allowedOrigins,
41
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
42
+ allowedHeaders: ['Content-Type', 'Authorization', 'X-Codex-Proxy-Admin-Token'],
43
+ credentials: false
44
+ }));
45
+ app.use(securityMiddleware({ allowedOrigins }));
46
+ app.use(express.json({ limit: '10mb' }));
47
+
48
+ registerApiRoutes(app, { port });
49
+
50
+ return app;
51
+ }
52
+
53
+ export function startServer({ port, host = process.env.HOST || DEFAULT_HOST }) {
54
+ const app = createServer({ port, host });
55
+ return app.listen(port, host);
56
+ }
57
+
58
+ export default { createServer, startServer };
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Signature Cache
3
+ * In-memory cache for thinking signatures
4
+ *
5
+ * Claude Code strips non-standard fields. This cache stores signatures by tool_use_id
6
+ * so they can be restored in subsequent requests.
7
+ *
8
+ * Also caches thinking block signatures with model family for cross-model
9
+ * compatibility checking.
10
+ */
11
+
12
+ // Default cache TTL: 2 hours
13
+ const SIGNATURE_CACHE_TTL_MS = 2 * 60 * 60 * 1000;
14
+
15
+ // Minimum valid signature length
16
+ const MIN_SIGNATURE_LENGTH = 50;
17
+
18
+ const signatureCache = new Map();
19
+ const thinkingSignatureCache = new Map();
20
+
21
+ /**
22
+ * Store a signature for a tool_use_id
23
+ * @param {string} toolUseId - The tool use ID
24
+ * @param {string} signature - The thoughtSignature to cache
25
+ */
26
+ export function cacheSignature(toolUseId, signature) {
27
+ if (!toolUseId || !signature) return;
28
+ signatureCache.set(toolUseId, {
29
+ signature,
30
+ timestamp: Date.now()
31
+ });
32
+ }
33
+
34
+ /**
35
+ * Get a cached signature for a tool_use_id
36
+ * @param {string} toolUseId - The tool use ID
37
+ * @returns {string|null} The cached signature or null if not found/expired
38
+ */
39
+ export function getCachedSignature(toolUseId) {
40
+ if (!toolUseId) return null;
41
+ const entry = signatureCache.get(toolUseId);
42
+ if (!entry) return null;
43
+
44
+ // Check TTL
45
+ if (Date.now() - entry.timestamp > SIGNATURE_CACHE_TTL_MS) {
46
+ signatureCache.delete(toolUseId);
47
+ return null;
48
+ }
49
+
50
+ return entry.signature;
51
+ }
52
+
53
+ /**
54
+ * Cache a thinking block signature with its model family
55
+ * @param {string} signature - The thinking signature to cache
56
+ * @param {string} modelFamily - The model family ('claude' or 'gemini' or 'openai')
57
+ */
58
+ export function cacheThinkingSignature(signature, modelFamily) {
59
+ if (!signature || signature.length < MIN_SIGNATURE_LENGTH) return;
60
+ thinkingSignatureCache.set(signature, {
61
+ modelFamily,
62
+ timestamp: Date.now()
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Get the cached model family for a thinking signature
68
+ * @param {string} signature - The signature to look up
69
+ * @returns {string|null} 'claude', 'gemini', 'openai', or null if not found/expired
70
+ */
71
+ export function getCachedSignatureFamily(signature) {
72
+ if (!signature) return null;
73
+ const entry = thinkingSignatureCache.get(signature);
74
+ if (!entry) return null;
75
+
76
+ // Check TTL
77
+ if (Date.now() - entry.timestamp > SIGNATURE_CACHE_TTL_MS) {
78
+ thinkingSignatureCache.delete(signature);
79
+ return null;
80
+ }
81
+
82
+ return entry.modelFamily;
83
+ }
84
+
85
+ /**
86
+ * Clear all entries from the thinking signature cache.
87
+ * Used for testing cold cache scenarios.
88
+ */
89
+ export function clearThinkingSignatureCache() {
90
+ thinkingSignatureCache.clear();
91
+ signatureCache.clear();
92
+ }
93
+
94
+ export const SIGNATURE_CONSTANTS = {
95
+ MIN_SIGNATURE_LENGTH,
96
+ SIGNATURE_CACHE_TTL_MS
97
+ };
98
+
99
+ export default {
100
+ cacheSignature,
101
+ getCachedSignature,
102
+ cacheThinkingSignature,
103
+ getCachedSignatureFamily,
104
+ clearThinkingSignatureCache,
105
+ SIGNATURE_CONSTANTS
106
+ };
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Thinking Block Utilities
3
+ * Handles thinking block processing, validation, and filtering
4
+ */
5
+
6
+ import { getCachedSignatureFamily, SIGNATURE_CONSTANTS } from './signature-cache.js';
7
+
8
+ const { MIN_SIGNATURE_LENGTH } = SIGNATURE_CONSTANTS;
9
+
10
+ // ============================================================================
11
+ // Cache Control Cleaning (Critical for Claude Code compatibility)
12
+ // ============================================================================
13
+
14
+ /**
15
+ * Remove cache_control fields from all content blocks in messages.
16
+ * This is critical - Claude Code CLI sends cache_control fields that the API
17
+ * rejects with "Extra inputs are not permitted".
18
+ *
19
+ * @param {Array<Object>} messages - Array of messages in Anthropic format
20
+ * @returns {Array<Object>} Messages with cache_control fields removed
21
+ */
22
+ export function cleanCacheControl(messages) {
23
+ if (!Array.isArray(messages)) return messages;
24
+
25
+ let removedCount = 0;
26
+
27
+ const cleaned = messages.map(message => {
28
+ if (!message || typeof message !== 'object') return message;
29
+
30
+ // Handle string content (no cache_control possible)
31
+ if (typeof message.content === 'string') return message;
32
+
33
+ // Handle array content
34
+ if (!Array.isArray(message.content)) return message;
35
+
36
+ const cleanedContent = message.content.map(block => {
37
+ if (!block || typeof block !== 'object') return block;
38
+
39
+ // Check if cache_control exists before destructuring
40
+ if (block.cache_control === undefined) return block;
41
+
42
+ // Create a shallow copy without cache_control
43
+ const { cache_control, ...cleanBlock } = block;
44
+ removedCount++;
45
+
46
+ return cleanBlock;
47
+ });
48
+
49
+ return {
50
+ ...message,
51
+ content: cleanedContent
52
+ };
53
+ });
54
+
55
+ if (removedCount > 0) {
56
+ // Debug only - cache_control removal is expected behavior
57
+ // console.debug(`[ThinkingUtils] Removed cache_control from ${removedCount} block(s)`);
58
+ }
59
+
60
+ return cleaned;
61
+ }
62
+
63
+ // ============================================================================
64
+ // Thinking Block Detection and Validation
65
+ // ============================================================================
66
+
67
+ /**
68
+ * Check if a part is a thinking block
69
+ * @param {Object} part - Content part to check
70
+ * @returns {boolean} True if the part is a thinking block
71
+ */
72
+ function isThinkingPart(part) {
73
+ return part.type === 'thinking' ||
74
+ part.type === 'redacted_thinking' ||
75
+ part.thinking !== undefined ||
76
+ part.thought === true;
77
+ }
78
+
79
+ /**
80
+ * Check if a thinking part has a valid signature (>= MIN_SIGNATURE_LENGTH chars)
81
+ */
82
+ function hasValidSignature(part) {
83
+ const signature = part.thought === true ? part.thoughtSignature : part.signature;
84
+ return typeof signature === 'string' && signature.length >= MIN_SIGNATURE_LENGTH;
85
+ }
86
+
87
+ /**
88
+ * Check if conversation has unsigned thinking blocks that will be dropped.
89
+ * These cause "Expected thinking but found text" errors.
90
+ * @param {Array<Object>} messages - Array of messages
91
+ * @returns {boolean} True if any assistant message has unsigned thinking blocks
92
+ */
93
+ export function hasUnsignedThinkingBlocks(messages) {
94
+ return messages.some(msg => {
95
+ if (msg.role !== 'assistant' && msg.role !== 'model') return false;
96
+ if (!Array.isArray(msg.content)) return false;
97
+ return msg.content.some(block =>
98
+ isThinkingPart(block) && !hasValidSignature(block)
99
+ );
100
+ });
101
+ }
102
+
103
+ // ============================================================================
104
+ // Thinking Block Sanitization
105
+ // ============================================================================
106
+
107
+ /**
108
+ * Sanitize a thinking block by removing extra fields like cache_control.
109
+ * Only keeps: type, thinking, signature (for thinking) or type, data (for redacted_thinking)
110
+ */
111
+ function sanitizeAnthropicThinkingBlock(block) {
112
+ if (!block) return block;
113
+
114
+ if (block.type === 'thinking') {
115
+ const sanitized = { type: 'thinking' };
116
+ if (block.thinking !== undefined) sanitized.thinking = block.thinking;
117
+ if (block.signature !== undefined) sanitized.signature = block.signature;
118
+ return sanitized;
119
+ }
120
+
121
+ if (block.type === 'redacted_thinking') {
122
+ const sanitized = { type: 'redacted_thinking' };
123
+ if (block.data !== undefined) sanitized.data = block.data;
124
+ return sanitized;
125
+ }
126
+
127
+ return block;
128
+ }
129
+
130
+ /**
131
+ * Sanitize a text block by removing extra fields like cache_control.
132
+ * Only keeps: type, text
133
+ */
134
+ function sanitizeTextBlock(block) {
135
+ if (!block || block.type !== 'text') return block;
136
+
137
+ const sanitized = { type: 'text' };
138
+ if (block.text !== undefined) sanitized.text = block.text;
139
+ return sanitized;
140
+ }
141
+
142
+ /**
143
+ * Sanitize a tool_use block by removing extra fields like cache_control.
144
+ * Only keeps: type, id, name, input, thoughtSignature
145
+ */
146
+ function sanitizeToolUseBlock(block) {
147
+ if (!block || block.type !== 'tool_use') return block;
148
+
149
+ const sanitized = { type: 'tool_use' };
150
+ if (block.id !== undefined) sanitized.id = block.id;
151
+ if (block.name !== undefined) sanitized.name = block.name;
152
+ if (block.input !== undefined) sanitized.input = block.input;
153
+ if (block.thoughtSignature !== undefined) sanitized.thoughtSignature = block.thoughtSignature;
154
+ return sanitized;
155
+ }
156
+
157
+ // ============================================================================
158
+ // Thinking Block Processing
159
+ // ============================================================================
160
+
161
+ /**
162
+ * Remove trailing unsigned thinking blocks from assistant messages.
163
+ * APIs require that assistant messages don't end with unsigned thinking blocks.
164
+ *
165
+ * @param {Array<Object>} content - Array of content blocks
166
+ * @returns {Array<Object>} Content array with trailing unsigned thinking blocks removed
167
+ */
168
+ export function removeTrailingThinkingBlocks(content) {
169
+ if (!Array.isArray(content)) return content;
170
+ if (content.length === 0) return content;
171
+
172
+ // Work backwards from the end, removing thinking blocks
173
+ let endIndex = content.length;
174
+ for (let i = content.length - 1; i >= 0; i--) {
175
+ const block = content[i];
176
+ if (!block || typeof block !== 'object') break;
177
+
178
+ const isThinking = isThinkingPart(block);
179
+
180
+ if (isThinking) {
181
+ if (!hasValidSignature(block)) {
182
+ endIndex = i;
183
+ } else {
184
+ break; // Stop at signed thinking block
185
+ }
186
+ } else {
187
+ break; // Stop at first non-thinking block
188
+ }
189
+ }
190
+
191
+ if (endIndex < content.length) {
192
+ console.log(`[ThinkingUtils] Removed ${content.length - endIndex} trailing unsigned thinking blocks`);
193
+ return content.slice(0, endIndex);
194
+ }
195
+
196
+ return content;
197
+ }
198
+
199
+ /**
200
+ * Filter thinking blocks: keep only those with valid signatures.
201
+ * Blocks without signatures are dropped (API requires signatures).
202
+ * Also sanitizes blocks to remove extra fields like cache_control.
203
+ *
204
+ * @param {Array<Object>} content - Array of content blocks
205
+ * @returns {Array<Object>} Filtered content with only valid signed thinking blocks
206
+ */
207
+ export function restoreThinkingSignatures(content) {
208
+ if (!Array.isArray(content)) return content;
209
+
210
+ const originalLength = content.length;
211
+ const filtered = [];
212
+
213
+ for (const block of content) {
214
+ if (!block || block.type !== 'thinking') {
215
+ filtered.push(block);
216
+ continue;
217
+ }
218
+
219
+ // Keep blocks with valid signatures, sanitized
220
+ if (block.signature && block.signature.length >= MIN_SIGNATURE_LENGTH) {
221
+ filtered.push(sanitizeAnthropicThinkingBlock(block));
222
+ }
223
+ // Unsigned thinking blocks are dropped - there's no way to restore them
224
+ // as thinking signatures are cached by signature itself (for family tracking)
225
+ }
226
+
227
+ if (filtered.length < originalLength) {
228
+ console.log(`[ThinkingUtils] Dropped ${originalLength - filtered.length} unsigned thinking block(s)`);
229
+ }
230
+
231
+ return filtered;
232
+ }
233
+
234
+ /**
235
+ * Reorder content so that:
236
+ * 1. Thinking blocks come first (required when thinking is enabled)
237
+ * 2. Text blocks come in the middle (filtering out empty/useless ones)
238
+ * 3. Tool_use blocks come at the end (required before tool_result)
239
+ *
240
+ * @param {Array<Object>} content - Array of content blocks
241
+ * @returns {Array<Object>} Reordered content array
242
+ */
243
+ export function reorderAssistantContent(content) {
244
+ if (!Array.isArray(content)) return content;
245
+
246
+ // Even for single-element arrays, we need to sanitize thinking blocks
247
+ if (content.length === 1) {
248
+ const block = content[0];
249
+ if (block && (block.type === 'thinking' || block.type === 'redacted_thinking')) {
250
+ return [sanitizeAnthropicThinkingBlock(block)];
251
+ }
252
+ return content;
253
+ }
254
+
255
+ const thinkingBlocks = [];
256
+ const textBlocks = [];
257
+ const toolUseBlocks = [];
258
+ let droppedEmptyBlocks = 0;
259
+
260
+ for (const block of content) {
261
+ if (!block) continue;
262
+
263
+ if (block.type === 'thinking' || block.type === 'redacted_thinking') {
264
+ thinkingBlocks.push(sanitizeAnthropicThinkingBlock(block));
265
+ } else if (block.type === 'tool_use') {
266
+ toolUseBlocks.push(sanitizeToolUseBlock(block));
267
+ } else if (block.type === 'text') {
268
+ // Only keep text blocks with meaningful content
269
+ if (block.text && block.text.trim().length > 0) {
270
+ textBlocks.push(sanitizeTextBlock(block));
271
+ } else {
272
+ droppedEmptyBlocks++;
273
+ }
274
+ } else {
275
+ textBlocks.push(block);
276
+ }
277
+ }
278
+
279
+ if (droppedEmptyBlocks > 0) {
280
+ console.log(`[ThinkingUtils] Dropped ${droppedEmptyBlocks} empty text block(s)`);
281
+ }
282
+
283
+ return [...thinkingBlocks, ...textBlocks, ...toolUseBlocks];
284
+ }
285
+
286
+ /**
287
+ * Process assistant message content:
288
+ * 1. Restore thinking signatures from cache
289
+ * 2. Remove trailing unsigned thinking blocks
290
+ * 3. Reorder content (thinking first, then text, then tool_use)
291
+ *
292
+ * @param {Array<Object>} content - Content array from assistant message
293
+ * @returns {Array<Object>} Processed content
294
+ */
295
+ export function processAssistantContent(content) {
296
+ if (!Array.isArray(content)) return content;
297
+
298
+ let processed = restoreThinkingSignatures(content);
299
+ processed = removeTrailingThinkingBlocks(processed);
300
+ processed = reorderAssistantContent(processed);
301
+
302
+ return processed;
303
+ }
304
+
305
+ export default {
306
+ cleanCacheControl,
307
+ hasUnsignedThinkingBlocks,
308
+ removeTrailingThinkingBlocks,
309
+ restoreThinkingSignatures,
310
+ reorderAssistantContent,
311
+ processAssistantContent
312
+ };