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,116 @@
1
+ /**
2
+ * Credentials Middleware
3
+ * Resolves and validates the active account credentials,
4
+ * auto-refreshing tokens when they are expired or expiring soon.
5
+ */
6
+
7
+ import {
8
+ getActiveAccount,
9
+ refreshAccountToken,
10
+ isTokenExpiredOrExpiringSoon,
11
+ loadAccounts
12
+ } from '../account-manager.js';
13
+ import { logger } from '../utils/logger.js';
14
+
15
+ /**
16
+ * Resolves the active account credentials, refreshing the token if needed.
17
+ * Returns null if no valid account is available.
18
+ *
19
+ * @returns {Promise<{accessToken: string, accountId: string, email: string}|null>}
20
+ */
21
+ export async function getCredentialsOrError() {
22
+ const account = getActiveAccount();
23
+
24
+ if (!account) {
25
+ logger.info('No active account found');
26
+ return null;
27
+ }
28
+
29
+ if (!account.accessToken || !account.accountId) {
30
+ logger.info(`Account ${account.email} missing token or accountId`);
31
+ return null;
32
+ }
33
+
34
+ if (isTokenExpiredOrExpiringSoon(account)) {
35
+ logger.info(`Token expired/expiring soon for ${account.email}, refreshing...`);
36
+ const result = await refreshAccountToken(account.email);
37
+
38
+ if (!result.success) {
39
+ logger.error(`Failed to refresh token: ${result.message}`);
40
+ return null;
41
+ }
42
+
43
+ const refreshedAccount = getActiveAccount();
44
+ if (!refreshedAccount) {
45
+ logger.error('Failed to get refreshed account');
46
+ return null;
47
+ }
48
+
49
+ logger.info(`Using refreshed token for ${refreshedAccount.email}`);
50
+ return {
51
+ accessToken: refreshedAccount.accessToken,
52
+ accountId: refreshedAccount.accountId,
53
+ email: refreshedAccount.email
54
+ };
55
+ }
56
+
57
+ return {
58
+ accessToken: account.accessToken,
59
+ accountId: account.accountId,
60
+ email: account.email
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Get credentials for a specific account by email.
66
+ * @param {string} email
67
+ * @returns {Promise<{accessToken: string, accountId: string, email: string}|null>}
68
+ */
69
+ export async function getCredentialsForAccount(email) {
70
+ const data = loadAccounts();
71
+ const account = data.accounts.find(a => a.email === email);
72
+
73
+ if (!account) {
74
+ return null;
75
+ }
76
+
77
+ if (!account.accessToken || !account.accountId) {
78
+ return null;
79
+ }
80
+
81
+ if (isTokenExpiredOrExpiringSoon(account)) {
82
+ const result = await refreshAccountToken(account.email);
83
+ if (!result.success) {
84
+ return null;
85
+ }
86
+ const refreshedData = loadAccounts();
87
+ const refreshedAccount = refreshedData.accounts.find(a => a.email === email);
88
+ if (!refreshedAccount) return null;
89
+
90
+ return {
91
+ accessToken: refreshedAccount.accessToken,
92
+ accountId: refreshedAccount.accountId,
93
+ email: refreshedAccount.email
94
+ };
95
+ }
96
+
97
+ return {
98
+ accessToken: account.accessToken,
99
+ accountId: account.accountId,
100
+ email: account.email
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Sends a 401 authentication error response.
106
+ * @param {import('express').Response} res
107
+ * @param {string} [message]
108
+ */
109
+ export function sendAuthError(res, message = 'No active account with valid credentials. Add an account via /accounts/add') {
110
+ return res.status(401).json({
111
+ type: 'error',
112
+ error: { type: 'authentication_error', message }
113
+ });
114
+ }
115
+
116
+ export default { getCredentialsOrError, getCredentialsForAccount, sendAuthError };
@@ -0,0 +1,130 @@
1
+ /**
2
+ * SSE Helpers
3
+ * Shared utilities for Server-Sent Events streaming and error responses.
4
+ */
5
+
6
+ import { resolveModelRouting } from '../model-mapper.js';
7
+ import { formatSSEEvent } from '../response-streamer.js';
8
+ import { logger } from '../utils/logger.js';
9
+
10
+ /**
11
+ * Sets the standard SSE response headers and flushes them.
12
+ * @param {import('express').Response} res
13
+ */
14
+ export function initSSEResponse(res) {
15
+ res.setHeader('Content-Type', 'text/event-stream');
16
+ res.setHeader('Cache-Control', 'no-cache');
17
+ res.setHeader('Connection', 'keep-alive');
18
+ res.setHeader('X-Accel-Buffering', 'no');
19
+ res.flushHeaders();
20
+ }
21
+
22
+ /**
23
+ * Streams an async generator of Anthropic-format SSE events to the response.
24
+ * Writes [DONE] and ends the response when the generator is exhausted.
25
+ *
26
+ * @param {import('express').Response} res
27
+ * @param {AsyncIterable<object>} eventStream
28
+ */
29
+ export async function pipeSSEStream(res, eventStream) {
30
+ let usage = null;
31
+
32
+ for await (const event of eventStream) {
33
+ if (event?.data?.type === 'message_delta' && event.data.usage) {
34
+ usage = event.data.usage;
35
+ }
36
+ res.write(formatSSEEvent(event));
37
+ }
38
+ res.write('data: [DONE]\n\n');
39
+ res.end();
40
+ return usage;
41
+ }
42
+
43
+ /**
44
+ * Sends a structured Anthropic-style error JSON response.
45
+ * If headers have already been sent (mid-stream), writes an SSE error event instead.
46
+ *
47
+ * @param {import('express').Response} res
48
+ * @param {Error} error
49
+ * @param {string} model
50
+ * @param {number} startTime
51
+ */
52
+ export function handleStreamError(res, error, model, startTime) {
53
+ const duration = Date.now() - startTime;
54
+ const status = getErrorStatus(error);
55
+ const errorType = getAnthropicErrorType(error, status);
56
+ const loggedModel = typeof model === 'string' && model.toLowerCase().startsWith('claude-')
57
+ ? resolveModelRouting(model).upstreamModel
58
+ : model;
59
+ logger.response(status, { model: loggedModel, error: error.message, duration });
60
+
61
+ if (res.headersSent) {
62
+ res.write(
63
+ `event: error\ndata: ${JSON.stringify({
64
+ type: 'error',
65
+ error: { type: errorType, message: getClientErrorMessage(error) }
66
+ })}\n\n`
67
+ );
68
+ res.end();
69
+ return;
70
+ }
71
+
72
+ if (error.message.includes('AUTH_EXPIRED')) {
73
+ return res.status(401).json({
74
+ type: 'error',
75
+ error: { type: 'authentication_error', message: 'Token expired. Please refresh or re-authenticate.' }
76
+ });
77
+ }
78
+
79
+ if (error.message.startsWith('RATE_LIMITED:')) {
80
+ const parts = error.message.split(':');
81
+ const resetMs = parseInt(parts[1], 10);
82
+ const errorText = parts.slice(2).join(':') || error.message;
83
+
84
+ return res.status(429).json({
85
+ type: 'error',
86
+ error: {
87
+ type: 'rate_limit_error',
88
+ message: errorText,
89
+ resetMs: resetMs,
90
+ resetSeconds: Math.round(resetMs / 1000)
91
+ }
92
+ });
93
+ }
94
+
95
+ if (error.message.includes('RESOURCE_EXHAUSTED')) {
96
+ return res.status(429).json({
97
+ type: 'error',
98
+ error: { type: 'rate_limit_error', message: error.message }
99
+ });
100
+ }
101
+
102
+ res.status(status).json({
103
+ type: 'error',
104
+ error: { type: errorType, message: getClientErrorMessage(error) }
105
+ });
106
+ }
107
+
108
+ function getErrorStatus(error) {
109
+ if (Number.isInteger(error?.status) && error.status >= 400 && error.status <= 599) {
110
+ return error.status;
111
+ }
112
+ if (error?.message?.includes('AUTH_EXPIRED')) return 401;
113
+ if (error?.message?.startsWith('RATE_LIMITED:') || error?.message?.includes('RESOURCE_EXHAUSTED')) return 429;
114
+ return 500;
115
+ }
116
+
117
+ function getAnthropicErrorType(error, status) {
118
+ if (error?.anthropicErrorType) return error.anthropicErrorType;
119
+ if (status === 401) return 'authentication_error';
120
+ if (status === 429) return 'rate_limit_error';
121
+ if (status === 400) return 'invalid_request_error';
122
+ if (status === 403) return 'permission_error';
123
+ return 'api_error';
124
+ }
125
+
126
+ function getClientErrorMessage(error) {
127
+ return error?.message || 'Internal server error';
128
+ }
129
+
130
+ export default { initSSEResponse, pipeSSEStream, handleStreamError };
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Model API for ChatGPT Codex
3
+ * Handles model listing and quota retrieval from ChatGPT backend API.
4
+ */
5
+
6
+ const CHATGPT_API_BASE = 'https://chatgpt.com/backend-api';
7
+ const CLIENT_VERSION = '0.100.0';
8
+
9
+ const MODEL_CACHE = {
10
+ models: null,
11
+ lastFetched: 0,
12
+ ttlMs: 5 * 60 * 1000
13
+ };
14
+
15
+ export async function fetchModels(accessToken, accountId) {
16
+ const now = Date.now();
17
+
18
+ if (MODEL_CACHE.models && (now - MODEL_CACHE.lastFetched) < MODEL_CACHE.ttlMs) {
19
+ return MODEL_CACHE.models;
20
+ }
21
+
22
+ const url = `${CHATGPT_API_BASE}/codex/models?client_version=${CLIENT_VERSION}`;
23
+
24
+ const response = await fetch(url, {
25
+ method: 'GET',
26
+ headers: {
27
+ 'Authorization': `Bearer ${accessToken}`,
28
+ 'ChatGPT-Account-ID': accountId,
29
+ 'Accept': 'application/json'
30
+ }
31
+ });
32
+
33
+ if (!response.ok) {
34
+ const errorText = await response.text();
35
+ throw new Error(`Failed to fetch models: ${response.status} - ${errorText}`);
36
+ }
37
+
38
+ const data = await response.json();
39
+
40
+ const models = (data.models || []).map(m => ({
41
+ id: m.slug,
42
+ name: m.display_name || m.slug,
43
+ description: m.description || '',
44
+ defaultReasoningLevel: m.default_reasoning_level || 'medium',
45
+ supportedReasoningLevels: m.supported_reasoning_levels || [],
46
+ supportedInApi: m.supported_in_api || false,
47
+ visibility: m.visibility || 'list'
48
+ }));
49
+
50
+ MODEL_CACHE.models = models;
51
+ MODEL_CACHE.lastFetched = now;
52
+
53
+ return models;
54
+ }
55
+
56
+ export async function fetchUsage(accessToken, accountId) {
57
+ const url = `${CHATGPT_API_BASE}/wham/usage`;
58
+
59
+ const response = await fetch(url, {
60
+ method: 'GET',
61
+ headers: {
62
+ 'Authorization': `Bearer ${accessToken}`,
63
+ 'ChatGPT-Account-ID': accountId,
64
+ 'Accept': 'application/json'
65
+ }
66
+ });
67
+
68
+ if (!response.ok) {
69
+ const errorText = await response.text();
70
+ throw new Error(`Failed to fetch usage: ${response.status} - ${errorText}`);
71
+ }
72
+
73
+ const data = await response.json();
74
+
75
+ const primaryWindow = data.rate_limit?.primary_window || {};
76
+ const usedPercentRaw = Number(primaryWindow?.used_percent);
77
+ const usedPercent = Number.isFinite(usedPercentRaw) ? usedPercentRaw : 0;
78
+
79
+ const limitWindowSecondsRaw = Number(primaryWindow?.limit_window_seconds);
80
+ const limitWindowSeconds = Number.isFinite(limitWindowSecondsRaw) ? limitWindowSecondsRaw : null;
81
+
82
+ const resetAfterSecondsRaw = Number(primaryWindow?.reset_after_seconds);
83
+ const resetAfterSeconds = Number.isFinite(resetAfterSecondsRaw) ? resetAfterSecondsRaw : null;
84
+
85
+ const resetAtEpoch = Number(primaryWindow?.reset_at);
86
+ const resetAt = Number.isFinite(resetAtEpoch) ? new Date(resetAtEpoch * 1000).toISOString() : null;
87
+
88
+ return {
89
+ totalTokenUsage: usedPercent,
90
+ limit: 100,
91
+ remaining: 100 - usedPercent,
92
+ percentage: usedPercent,
93
+ resetAt: resetAt,
94
+ resetAfterSeconds: resetAfterSeconds,
95
+ limitWindowSeconds: limitWindowSeconds,
96
+ planType: data.plan_type || null,
97
+ limitReached: data.rate_limit?.limit_reached || false,
98
+ allowed: data.rate_limit?.allowed ?? true,
99
+ raw: data
100
+ };
101
+ }
102
+
103
+ export async function fetchAccountCheck(accessToken, accountId) {
104
+ const url = `${CHATGPT_API_BASE}/wham/accounts/check`;
105
+
106
+ const response = await fetch(url, {
107
+ method: 'GET',
108
+ headers: {
109
+ 'Authorization': `Bearer ${accessToken}`,
110
+ 'ChatGPT-Account-ID': accountId,
111
+ 'Accept': 'application/json'
112
+ }
113
+ });
114
+
115
+ if (!response.ok) {
116
+ const errorText = await response.text();
117
+ throw new Error(`Failed to fetch account check: ${response.status} - ${errorText}`);
118
+ }
119
+
120
+ return await response.json();
121
+ }
122
+
123
+ export async function getAccountQuota(accessToken, accountId) {
124
+ try {
125
+ const [usage, accountCheck] = await Promise.allSettled([
126
+ fetchUsage(accessToken, accountId),
127
+ fetchAccountCheck(accessToken, accountId)
128
+ ]);
129
+
130
+ const quotaInfo = {
131
+ usage: usage.status === 'fulfilled' ? usage.value : null,
132
+ account: accountCheck.status === 'fulfilled' ? accountCheck.value : null,
133
+ fetchedAt: new Date().toISOString()
134
+ };
135
+
136
+ if (usage.status === 'rejected') {
137
+ quotaInfo.usageError = usage.reason?.message || 'Unknown error';
138
+ }
139
+
140
+ if (accountCheck.status === 'rejected') {
141
+ quotaInfo.accountError = accountCheck.reason?.message || 'Unknown error';
142
+ }
143
+
144
+ return quotaInfo;
145
+ } catch (error) {
146
+ return {
147
+ usage: null,
148
+ account: null,
149
+ error: error.message,
150
+ fetchedAt: new Date().toISOString()
151
+ };
152
+ }
153
+ }
154
+
155
+ export async function getModelsAndQuota(accessToken, accountId) {
156
+ try {
157
+ const [models, quota] = await Promise.all([
158
+ fetchModels(accessToken, accountId),
159
+ getAccountQuota(accessToken, accountId)
160
+ ]);
161
+
162
+ return {
163
+ models,
164
+ quota,
165
+ success: true
166
+ };
167
+ } catch (error) {
168
+ return {
169
+ models: null,
170
+ quota: null,
171
+ error: error.message,
172
+ success: false
173
+ };
174
+ }
175
+ }
176
+
177
+ export function clearModelCache() {
178
+ MODEL_CACHE.models = null;
179
+ MODEL_CACHE.lastFetched = 0;
180
+ }
181
+
182
+ export default {
183
+ fetchModels,
184
+ fetchUsage,
185
+ fetchAccountCheck,
186
+ getAccountQuota,
187
+ getModelsAndQuota,
188
+ clearModelCache
189
+ };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Model Mapper
3
+ * Maps Anthropic/Claude model names to upstream OpenAI model identifiers.
4
+ */
5
+
6
+ import { getServerSettings } from './server-settings.js';
7
+
8
+ const DEFAULT_CODEX_MODEL = 'gpt-5.5';
9
+
10
+ const DIRECT_GPT_MODEL_RE = /^gpt-[a-z0-9][a-z0-9._-]*(?::[a-z0-9._-]+)?$/i;
11
+
12
+ function getDefaultCodexModel(settings = getServerSettings()) {
13
+ return settings.defaultCodexModel || DEFAULT_CODEX_MODEL;
14
+ }
15
+
16
+ function lookupModelMap(model, modelMap) {
17
+ if (!model || !modelMap) return null;
18
+
19
+ if (modelMap[model]) {
20
+ return modelMap[model];
21
+ }
22
+
23
+ const modelLower = model.toLowerCase();
24
+
25
+ for (const [key, value] of Object.entries(modelMap)) {
26
+ if (key.toLowerCase() === modelLower) {
27
+ return value;
28
+ }
29
+ }
30
+
31
+ return null;
32
+ }
33
+
34
+ function resolveFamilyAlias(alias, modelMap, fallback) {
35
+ return lookupModelMap(alias, modelMap) || fallback;
36
+ }
37
+
38
+ export function mapClaudeModel(model) {
39
+ const settings = getServerSettings();
40
+ const modelMap = settings.modelMap || {};
41
+ const fallback = getDefaultCodexModel(settings);
42
+ const requestedModel = model || fallback;
43
+ const modelLower = requestedModel.toLowerCase();
44
+
45
+ // 1. User-defined full keys take precedence.
46
+ const exactMappedModel = lookupModelMap(requestedModel, modelMap);
47
+ if (exactMappedModel) {
48
+ return exactMappedModel;
49
+ }
50
+
51
+ // 2. Pass through `gpt-*` directly to prevent `gpt-5.5` from being mapped to `gpt-5`.
52
+ if (DIRECT_GPT_MODEL_RE.test(modelLower)) {
53
+ return requestedModel;
54
+ }
55
+
56
+ // 3. Claude full model names follow the family-based UI configuration.
57
+ if (modelLower.startsWith('claude-')) {
58
+ if (modelLower.includes('opus')) {
59
+ return resolveFamilyAlias('opus', modelMap, fallback);
60
+ }
61
+
62
+ if (modelLower.includes('sonnet')) {
63
+ return resolveFamilyAlias('sonnet', modelMap, fallback);
64
+ }
65
+
66
+ if (modelLower.includes('haiku')) {
67
+ return resolveFamilyAlias('haiku', modelMap, fallback);
68
+ }
69
+ }
70
+
71
+ return fallback;
72
+ }
73
+
74
+ /**
75
+ * Resolves all model routing info from a requested model name.
76
+ * @param {string} requestedModel
77
+ * @returns {{ mappedModel: string, upstreamModel: string }}
78
+ */
79
+ export function resolveModelRouting(requestedModel) {
80
+ const settings = getServerSettings();
81
+ const mappedModel = mapClaudeModel(requestedModel || getDefaultCodexModel(settings));
82
+ return { mappedModel, upstreamModel: mappedModel };
83
+ }
84
+
85
+ export default {
86
+ mapClaudeModel,
87
+ resolveModelRouting
88
+ };