@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.
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/bin/cli.js +118 -0
- package/docs/ACCOUNTS.md +202 -0
- package/docs/API.md +289 -0
- package/docs/ARCHITECTURE.md +129 -0
- package/docs/CLAUDE_INTEGRATION.md +163 -0
- package/docs/OAUTH.md +85 -0
- package/docs/OPENCLAW.md +34 -0
- package/docs/legal.md +11 -0
- package/images/dashboard-screenshot.png +0 -0
- package/images/demo-screenshot.png +0 -0
- package/images/f757093f-507b-4453-994e-f8275f8b07a9.png +0 -0
- package/package.json +61 -0
- package/public/css/style.css +1502 -0
- package/public/index.html +827 -0
- package/public/js/app.js +601 -0
- package/src/account-manager.js +528 -0
- package/src/account-rotation/index.js +93 -0
- package/src/account-rotation/rate-limits.js +293 -0
- package/src/account-rotation/strategies/base-strategy.js +48 -0
- package/src/account-rotation/strategies/index.js +31 -0
- package/src/account-rotation/strategies/round-robin-strategy.js +42 -0
- package/src/account-rotation/strategies/sticky-strategy.js +97 -0
- package/src/claude-config.js +153 -0
- package/src/cli/accounts.js +557 -0
- package/src/direct-api.js +164 -0
- package/src/format-converter.js +420 -0
- package/src/index.js +46 -0
- package/src/kilo-api.js +68 -0
- package/src/kilo-format-converter.js +285 -0
- package/src/kilo-models.js +103 -0
- package/src/kilo-streamer.js +243 -0
- package/src/middleware/credentials.js +116 -0
- package/src/middleware/sse.js +96 -0
- package/src/model-api.js +189 -0
- package/src/model-mapper.js +157 -0
- package/src/oauth.js +666 -0
- package/src/response-streamer.js +409 -0
- package/src/routes/accounts-route.js +332 -0
- package/src/routes/api-routes.js +98 -0
- package/src/routes/chat-route.js +229 -0
- package/src/routes/claude-config-route.js +121 -0
- package/src/routes/logs-route.js +43 -0
- package/src/routes/messages-route.js +203 -0
- package/src/routes/models-route.js +119 -0
- package/src/routes/settings-route.js +143 -0
- package/src/security.js +142 -0
- package/src/server-settings.js +56 -0
- package/src/server.js +58 -0
- package/src/signature-cache.js +106 -0
- package/src/thinking-utils.js +312 -0
- package/src/utils/logger.js +156 -0
package/src/security.js
ADDED
|
@@ -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
|
+
};
|