claude-codex-proxy 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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +227 -0
  3. package/bin/cli.js +113 -0
  4. package/docs/ACCOUNTS.md +202 -0
  5. package/docs/API.md +244 -0
  6. package/docs/ARCHITECTURE.md +119 -0
  7. package/docs/CLAUDE_INTEGRATION.md +163 -0
  8. package/docs/OAUTH.md +83 -0
  9. package/docs/OPENCLAW.md +33 -0
  10. package/docs/legal.md +9 -0
  11. package/images/demo-screenshot.png +0 -0
  12. package/images/f757093f-507b-4453-994e-f8275f8b07a9.png +0 -0
  13. package/package.json +56 -0
  14. package/public/css/style.css +791 -0
  15. package/public/index.html +838 -0
  16. package/public/js/app.js +619 -0
  17. package/src/account-manager.js +526 -0
  18. package/src/account-rotation/index.js +93 -0
  19. package/src/account-rotation/rate-limits.js +293 -0
  20. package/src/account-rotation/strategies/base-strategy.js +48 -0
  21. package/src/account-rotation/strategies/index.js +31 -0
  22. package/src/account-rotation/strategies/round-robin-strategy.js +42 -0
  23. package/src/account-rotation/strategies/sticky-strategy.js +97 -0
  24. package/src/claude-config.js +154 -0
  25. package/src/cli/accounts.js +551 -0
  26. package/src/direct-api.js +214 -0
  27. package/src/format-converter.js +563 -0
  28. package/src/index.js +45 -0
  29. package/src/middleware/credentials.js +116 -0
  30. package/src/middleware/sse.js +130 -0
  31. package/src/model-api.js +189 -0
  32. package/src/model-mapper.js +88 -0
  33. package/src/oauth.js +623 -0
  34. package/src/response-streamer.js +469 -0
  35. package/src/routes/accounts-route.js +296 -0
  36. package/src/routes/api-routes.js +102 -0
  37. package/src/routes/chat-route.js +239 -0
  38. package/src/routes/claude-config-route.js +114 -0
  39. package/src/routes/logs-route.js +43 -0
  40. package/src/routes/messages-route.js +164 -0
  41. package/src/routes/models-route.js +115 -0
  42. package/src/routes/settings-route.js +154 -0
  43. package/src/server-settings.js +70 -0
  44. package/src/server.js +57 -0
  45. package/src/signature-cache.js +106 -0
  46. package/src/thinking-utils.js +312 -0
  47. package/src/utils/logger.js +170 -0
@@ -0,0 +1,563 @@
1
+ /**
2
+ * Format Converter
3
+ * Converts between Anthropic Messages API and OpenAI Responses API format
4
+ */
5
+
6
+ import crypto from 'crypto';
7
+ import { cleanCacheControl, processAssistantContent, hasUnsignedThinkingBlocks } from './thinking-utils.js';
8
+ import { getCachedSignature, cacheSignature, cacheThinkingSignature, SIGNATURE_CONSTANTS } from './signature-cache.js';
9
+
10
+ const { MIN_SIGNATURE_LENGTH } = SIGNATURE_CONSTANTS;
11
+
12
+ /**
13
+ * Convert Anthropic tool ID to OpenAI fc_ format
14
+ * Deterministic: strips toolu_/call_ prefix, adds fc_ prefix
15
+ * @param {string} anthropicId - Original Anthropic tool ID (e.g., toolu_abc123)
16
+ * @returns {string} OpenAI fc_ format ID (e.g., fc_abc123)
17
+ */
18
+ function toOpenAIToolId(anthropicId) {
19
+ if (!anthropicId) return `fc_${crypto.randomBytes(12).toString('hex')}`;
20
+ if (anthropicId.startsWith('fc_')) return anthropicId;
21
+
22
+ // Strip known prefixes and add fc_ prefix
23
+ const baseId = anthropicId.replace(/^(call_|toolu_)/, '');
24
+ return `fc_${baseId}`;
25
+ }
26
+
27
+ /**
28
+ * Convert OpenAI fc_ ID back to Anthropic toolu_ format
29
+ * Deterministic: strips fc_ prefix, adds toolu_ prefix
30
+ * This is the inverse of toOpenAIToolId
31
+ * @param {string} openAIId - OpenAI fc_ format ID (e.g., fc_abc123)
32
+ * @returns {string} Anthropic toolu_ format ID (e.g., toolu_abc123)
33
+ */
34
+ function toAnthropicToolId(openAIId) {
35
+ if (!openAIId) return `toolu_${crypto.randomBytes(12).toString('hex')}`;
36
+ if (openAIId.startsWith('toolu_')) return openAIId;
37
+
38
+ // Strip fc_ prefix and add toolu_ prefix
39
+ const baseId = openAIId.replace(/^fc_/, '');
40
+ return `toolu_${baseId}`;
41
+ }
42
+
43
+ function extractSystemPrompt(system) {
44
+ if (!system) {
45
+ return undefined;
46
+ }
47
+
48
+ if (typeof system === 'string') {
49
+ return system;
50
+ }
51
+
52
+ if (Array.isArray(system)) {
53
+ const textParts = system
54
+ .filter(block => block.type === 'text')
55
+ .map(block => block.text);
56
+ return textParts.join('\n\n') || undefined;
57
+ }
58
+
59
+ return undefined;
60
+ }
61
+
62
+ function convertAnthropicImageToResponsesInputImage(block) {
63
+ const source = block?.source;
64
+ if (!source) return null;
65
+
66
+ if (source.type === 'base64' && source.data) {
67
+ const mediaType = source.media_type || 'image/png';
68
+ return {
69
+ type: 'input_image',
70
+ image_url: `data:${mediaType};base64,${source.data}`
71
+ };
72
+ }
73
+
74
+ if (source.type === 'url' && source.url) {
75
+ return {
76
+ type: 'input_image',
77
+ image_url: source.url
78
+ };
79
+ }
80
+
81
+ return null;
82
+ }
83
+
84
+ function convertToolResultContentToResponsesOutput(block) {
85
+ if (typeof block.content === 'string') {
86
+ return block.content;
87
+ }
88
+
89
+ if (Array.isArray(block.content)) {
90
+ const output = [];
91
+
92
+ for (const item of block.content) {
93
+ if (item.type === 'text') {
94
+ output.push({
95
+ type: 'input_text',
96
+ text: item.text || ''
97
+ });
98
+ } else if (item.type === 'image') {
99
+ const image = convertAnthropicImageToResponsesInputImage(item);
100
+ if (image) output.push(image);
101
+ }
102
+ }
103
+
104
+ if (output.length === 0) return '';
105
+
106
+ // Preserve legacy behavior for text-only tool results.
107
+ if (output.length === 1 && output[0].type === 'input_text') {
108
+ return output[0].text;
109
+ }
110
+
111
+ return output;
112
+ }
113
+
114
+ if (block.content && typeof block.content === 'object') {
115
+ return JSON.stringify(block.content);
116
+ }
117
+
118
+ return '';
119
+ }
120
+
121
+ function prefixToolErrorOutput(output) {
122
+ if (typeof output === 'string') {
123
+ return `Error: ${output}`;
124
+ }
125
+
126
+ if (Array.isArray(output)) {
127
+ return [
128
+ { type: 'input_text', text: 'Error:' },
129
+ ...output
130
+ ];
131
+ }
132
+
133
+ return output;
134
+ }
135
+
136
+ /**
137
+ * Convert Anthropic Messages API request to OpenAI Responses API format
138
+ */
139
+ export function convertAnthropicToResponsesAPI(anthropicRequest) {
140
+ const { model, messages, system, tools, tool_choice } = anthropicRequest;
141
+
142
+ // [CRITICAL] Clean cache_control from all messages FIRST
143
+ // Claude Code CLI sends cache_control fields that the API rejects
144
+ const cleanedMessages = cleanCacheControl(messages || []);
145
+
146
+ const instructions = extractSystemPrompt(system);
147
+
148
+ const request = {
149
+ model: model || 'gpt-5.2-codex',
150
+ input: convertMessagesToInput(cleanedMessages),
151
+ tools: tools ? convertAnthropicToolsToOpenAI(tools) : [],
152
+ tool_choice: tool_choice || 'auto',
153
+ parallel_tool_calls: true,
154
+ store: false,
155
+ stream: true,
156
+ include: []
157
+ };
158
+
159
+ if (instructions) {
160
+ request.instructions = instructions;
161
+ } else {
162
+ request.instructions = '';
163
+ }
164
+
165
+ return request;
166
+ }
167
+
168
+ /**
169
+ * Convert Anthropic messages to OpenAI Responses API input format
170
+ */
171
+ function convertMessagesToInput(messages) {
172
+ if (!Array.isArray(messages)) {
173
+ return [];
174
+ }
175
+
176
+ const input = [];
177
+
178
+ for (const msg of messages) {
179
+ if (msg.role === 'user') {
180
+ const { textParts, toolResults } = convertUserContent(msg.content);
181
+
182
+ if (textParts.length > 0) {
183
+ // API accepts: string OR array of {type: 'input_text', text: '...'}
184
+ const content = textParts.length === 1
185
+ ? textParts[0] // Use string for single text
186
+ : textParts.map(text => ({ type: 'input_text', text }));
187
+ input.push({
188
+ type: 'message',
189
+ role: 'user',
190
+ content
191
+ });
192
+ }
193
+
194
+ for (const result of toolResults) {
195
+ input.push(result);
196
+ }
197
+ } else if (msg.role === 'assistant') {
198
+ // Process assistant content: restore signatures, reorder, sanitize
199
+ let msgContent = msg.content;
200
+ if (Array.isArray(msgContent)) {
201
+ msgContent = processAssistantContent(msgContent);
202
+ }
203
+
204
+ const { textParts, toolCalls } = convertAssistantContentToOpenAI(msgContent);
205
+
206
+ if (textParts.length > 0) {
207
+ // API accepts: string OR array of {type: 'output_text', text: '...'}
208
+ const content = textParts.length === 1
209
+ ? textParts[0] // Use string for single text
210
+ : textParts.map(text => ({ type: 'output_text', text }));
211
+ input.push({
212
+ type: 'message',
213
+ role: 'assistant',
214
+ content
215
+ });
216
+ }
217
+
218
+ for (const call of toolCalls) {
219
+ input.push(call);
220
+ }
221
+ }
222
+ }
223
+
224
+ return input;
225
+ }
226
+
227
+ /**
228
+ * Convert user content, separating text and tool results
229
+ */
230
+ function convertUserContent(content) {
231
+ const textParts = [];
232
+ const toolResults = [];
233
+
234
+ if (typeof content === 'string') {
235
+ textParts.push(content);
236
+ } else if (Array.isArray(content)) {
237
+ for (const block of content) {
238
+ if (block.type === 'text') {
239
+ textParts.push(block.text);
240
+ } else if (block.type === 'tool_result') {
241
+ const outputContent = convertToolResultContentToResponsesOutput(block);
242
+
243
+ // Convert to OpenAI fc_ format
244
+ const callId = toOpenAIToolId(block.tool_use_id);
245
+
246
+ toolResults.push({
247
+ type: 'function_call_output',
248
+ call_id: callId,
249
+ output: block.is_error ? prefixToolErrorOutput(outputContent) : outputContent
250
+ });
251
+ }
252
+ }
253
+ }
254
+
255
+ return { textParts, toolResults };
256
+ }
257
+
258
+ /**
259
+ * Convert Anthropic assistant content to OpenAI format
260
+ */
261
+ function convertAssistantContentToOpenAI(content) {
262
+ const textParts = [];
263
+ const toolCalls = [];
264
+
265
+ if (typeof content === 'string') {
266
+ textParts.push(content);
267
+ } else if (Array.isArray(content)) {
268
+ for (const block of content) {
269
+ if (block.type === 'text') {
270
+ textParts.push(block.text);
271
+ } else if (block.type === 'thinking') {
272
+ // Handle thinking blocks - they may have signatures we need to cache
273
+ if (block.signature && block.signature.length >= MIN_SIGNATURE_LENGTH) {
274
+ cacheThinkingSignature(block.signature, 'openai');
275
+ }
276
+ // For now, we don't include thinking in the output
277
+ // The API will regenerate thinking as needed
278
+ } else if (block.type === 'tool_use') {
279
+ // Convert to OpenAI fc_ format while preserving mapping
280
+ const openAIId = toOpenAIToolId(block.id);
281
+
282
+ // Restore thoughtSignature from cache if missing (Claude Code strips it)
283
+ let thoughtSignature = block.thoughtSignature;
284
+ if (!thoughtSignature && block.id) {
285
+ thoughtSignature = getCachedSignature(block.id);
286
+ if (thoughtSignature) {
287
+ console.log(`[FormatConverter] Restored signature from cache for tool: ${block.id}`);
288
+ }
289
+ }
290
+
291
+ // Cache the signature for future restoration (keyed by original ID)
292
+ if (thoughtSignature && thoughtSignature.length >= MIN_SIGNATURE_LENGTH) {
293
+ cacheSignature(block.id, thoughtSignature);
294
+ }
295
+
296
+ toolCalls.push({
297
+ type: 'function_call',
298
+ id: openAIId,
299
+ call_id: openAIId,
300
+ name: block.name,
301
+ arguments: typeof block.input === 'string'
302
+ ? block.input
303
+ : JSON.stringify(block.input)
304
+ });
305
+ }
306
+ }
307
+ }
308
+
309
+ return { textParts, toolCalls };
310
+ }
311
+
312
+ /**
313
+ * Convert Anthropic tools to OpenAI function format
314
+ */
315
+ function convertAnthropicToolsToOpenAI(tools) {
316
+ if (!Array.isArray(tools)) {
317
+ return [];
318
+ }
319
+
320
+ return tools.map(tool => ({
321
+ type: 'function',
322
+ name: tool.name,
323
+ description: tool.description || '',
324
+ parameters: sanitizeSchema(tool.input_schema || { type: 'object' })
325
+ }));
326
+ }
327
+
328
+ function sanitizeAnthropicToolInput(name, input) {
329
+ if (name !== 'Read') return input;
330
+
331
+ if (
332
+ input &&
333
+ typeof input === 'object' &&
334
+ !Array.isArray(input) &&
335
+ input.pages === ''
336
+ ) {
337
+ const { pages, ...rest } = input;
338
+ return rest;
339
+ }
340
+
341
+ return input;
342
+ }
343
+
344
+ function sanitizeAnthropicToolInputJson(name, raw) {
345
+ if (name !== 'Read' || !raw) return raw;
346
+
347
+ try {
348
+ const input = JSON.parse(raw);
349
+ return JSON.stringify(sanitizeAnthropicToolInput(name, input));
350
+ } catch {
351
+ return raw;
352
+ }
353
+ }
354
+
355
+ function flattenStringEnumUnion(variants) {
356
+ if (!Array.isArray(variants)) return null;
357
+
358
+ const values = [];
359
+
360
+ for (const variant of variants) {
361
+ if (!variant || typeof variant !== 'object') return null;
362
+
363
+ if (typeof variant.const === 'string') {
364
+ values.push(variant.const);
365
+ continue;
366
+ }
367
+
368
+ if (
369
+ Array.isArray(variant.enum) &&
370
+ variant.enum.every(value => typeof value === 'string')
371
+ ) {
372
+ values.push(...variant.enum);
373
+ continue;
374
+ }
375
+
376
+ return null;
377
+ }
378
+
379
+ return [...new Set(values)];
380
+ }
381
+
382
+ function sanitizeSchema(schema) {
383
+ if (typeof schema !== 'object' || schema === null) {
384
+ return { type: 'object' };
385
+ }
386
+
387
+ const unionVariants = schema.anyOf || schema.oneOf;
388
+ const flattenedEnum = flattenStringEnumUnion(unionVariants);
389
+ if (flattenedEnum) {
390
+ return {
391
+ type: 'string',
392
+ enum: flattenedEnum,
393
+ ...(schema.description ? { description: schema.description } : {}),
394
+ ...(schema.title ? { title: schema.title } : {})
395
+ };
396
+ }
397
+
398
+ const result = {};
399
+
400
+ for (const [key, value] of Object.entries(schema)) {
401
+ if (key === 'const') {
402
+ result.enum = [value];
403
+ continue;
404
+ }
405
+
406
+ if (['allOf', 'anyOf', 'oneOf'].includes(key) && Array.isArray(value)) {
407
+ result[key] = value.map(item => sanitizeSchema(item));
408
+ continue;
409
+ }
410
+
411
+ if ([
412
+ '$schema', '$id', '$ref', '$defs', '$comment',
413
+ 'additionalItems', 'definitions', 'examples',
414
+ 'pattern', 'format',
415
+ 'minItems', 'maxItems', 'minimum', 'maximum',
416
+ 'exclusiveMinimum', 'exclusiveMaximum',
417
+ 'not'
418
+ ].includes(key)) {
419
+ continue;
420
+ }
421
+
422
+ if (key === 'additionalProperties' && typeof value === 'boolean') {
423
+ continue;
424
+ }
425
+
426
+ if (key === 'type' && Array.isArray(value)) {
427
+ const nonNullTypes = value.filter(t => t !== 'null');
428
+ result.type = nonNullTypes.length > 0 ? nonNullTypes[0] : 'string';
429
+ continue;
430
+ }
431
+
432
+ if (key === 'properties' && value && typeof value === 'object') {
433
+ result.properties = {};
434
+ for (const [propKey, propValue] of Object.entries(value)) {
435
+ result.properties[propKey] = sanitizeSchema(propValue);
436
+ }
437
+ continue;
438
+ }
439
+
440
+ if (key === 'items') {
441
+ if (Array.isArray(value)) {
442
+ result.items = value.map(item => sanitizeSchema(item));
443
+ } else if (typeof value === 'object') {
444
+ result.items = sanitizeSchema(value);
445
+ } else {
446
+ result.items = value;
447
+ }
448
+ continue;
449
+ }
450
+
451
+ if (key === 'required' && Array.isArray(value)) {
452
+ result.required = value;
453
+ continue;
454
+ }
455
+
456
+ if (key === 'enum' && Array.isArray(value)) {
457
+ result.enum = value;
458
+ continue;
459
+ }
460
+
461
+ if (['type', 'description', 'title'].includes(key)) {
462
+ result[key] = value;
463
+ }
464
+ }
465
+
466
+ const hasSchemaShape = result.enum || result.allOf || result.anyOf || result.oneOf;
467
+
468
+ if (!result.type && !hasSchemaShape) {
469
+ result.type = 'object';
470
+ }
471
+
472
+ if (result.type === 'object' && !result.properties) {
473
+ result.properties = {};
474
+ }
475
+
476
+ return result;
477
+ }
478
+
479
+ /**
480
+ * Convert OpenAI Responses API output to Anthropic content blocks
481
+ */
482
+ export function convertOutputToAnthropic(output) {
483
+ if (!Array.isArray(output)) {
484
+ return [{ type: 'text', text: '' }];
485
+ }
486
+
487
+ const content = [];
488
+
489
+ for (const item of output) {
490
+ if (item.type === 'message') {
491
+ for (const part of item.content || []) {
492
+ if (part.type === 'output_text') {
493
+ content.push({ type: 'text', text: part.text });
494
+ }
495
+ }
496
+ } else if (item.type === 'function_call') {
497
+ let input = {};
498
+ try {
499
+ input = typeof item.arguments === 'string'
500
+ ? JSON.parse(item.arguments)
501
+ : item.arguments || {};
502
+ } catch (e) {
503
+ input = {};
504
+ }
505
+
506
+ // Convert OpenAI fc_ ID back to original Anthropic ID
507
+ const openAIId = item.call_id || item.id;
508
+ const toolId = toAnthropicToolId(openAIId);
509
+
510
+ const toolUseBlock = {
511
+ type: 'tool_use',
512
+ id: toolId,
513
+ name: item.name,
514
+ input: sanitizeAnthropicToolInput(item.name, input)
515
+ };
516
+
517
+ // Cache signature if present (keyed by original Anthropic ID)
518
+ if (item.signature && item.signature.length >= MIN_SIGNATURE_LENGTH) {
519
+ toolUseBlock.thoughtSignature = item.signature;
520
+ cacheSignature(toolId, item.signature);
521
+ }
522
+
523
+ content.push(toolUseBlock);
524
+ } else if (item.type === 'reasoning') {
525
+ const signature = item.signature || '';
526
+
527
+ // Cache thinking signature
528
+ if (signature && signature.length >= MIN_SIGNATURE_LENGTH) {
529
+ cacheThinkingSignature(signature, 'openai');
530
+ }
531
+
532
+ content.push({
533
+ type: 'thinking',
534
+ thinking: item.text || item.content || '',
535
+ signature: signature
536
+ });
537
+ }
538
+ }
539
+
540
+ return content.length > 0 ? content : [{ type: 'text', text: '' }];
541
+ }
542
+
543
+ /**
544
+ * Generate Anthropic message ID
545
+ */
546
+ export function generateMessageId() {
547
+ return `msg_${crypto.randomBytes(16).toString('hex')}`;
548
+ }
549
+
550
+ export {
551
+ sanitizeAnthropicToolInput,
552
+ sanitizeAnthropicToolInputJson,
553
+ toOpenAIToolId,
554
+ toAnthropicToolId
555
+ };
556
+
557
+ export default {
558
+ convertAnthropicToResponsesAPI,
559
+ convertOutputToAnthropic,
560
+ generateMessageId,
561
+ toOpenAIToolId,
562
+ toAnthropicToolId
563
+ };
package/src/index.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Codex Claude Proxy
3
+ * Entry point
4
+ */
5
+
6
+ import { startServer } from './server.js';
7
+ import { logger } from './utils/logger.js';
8
+ import { getStatus, ACCOUNTS_FILE } from './account-manager.js';
9
+
10
+ const PORT = Number(process.env.PORT || 8081);
11
+
12
+ startServer({ port: PORT });
13
+
14
+ console.log(`
15
+ ╔══════════════════════════════════════════════════════════════╗
16
+ ║ Codex Claude Proxy v1.0.5 ║
17
+ ║ (Direct API Mode) ║
18
+ ╠══════════════════════════════════════════════════════════════╣
19
+ ║ Server: http://localhost:${PORT} ║
20
+ ║ WebUI: http://localhost:${PORT} ║
21
+ ║ Health: http://localhost:${PORT}/health ║
22
+ ║ Accounts: http://localhost:${PORT}/accounts ║
23
+ ║ Logs: http://localhost:${PORT}/api/logs/stream ║
24
+ ╠══════════════════════════════════════════════════════════════╣
25
+ ║ Features: ║
26
+ ║ ✓ Native tool calling support ║
27
+ ║ ✓ Real-time streaming ║
28
+ ║ ✓ Multi-account management ║
29
+ ║ ✓ OpenAI & Anthropic API compatibility ║
30
+ ╠══════════════════════════════════════════════════════════════╣
31
+ ║ Support: ║
32
+ ║ ★ Give it a star on GitHub! ║
33
+ ║ https://github.com/Ayush-Kotlin-Dev/codex-claude-proxy ║
34
+ ╚══════════════════════════════════════════════════════════════╝
35
+ `);
36
+
37
+ const status = getStatus();
38
+ logger.info(`Accounts: ${status.total} total, Active: ${status.active || 'None'}`);
39
+
40
+ if (status.total === 0) {
41
+ logger.warn(`No accounts configured. Open http://localhost:${PORT} to add one.`);
42
+ }
43
+
44
+ // Expose config path in logs for convenience
45
+ logger.info(`Accounts config: ${ACCOUNTS_FILE}`);