@h1d3rone/claude-proxy 0.1.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.
@@ -0,0 +1,271 @@
1
+ const express = require("express");
2
+ const fs = require("fs/promises");
3
+ const { DEFAULT_CLIENT_API_KEY } = require("../config");
4
+ const { ensureDir, writeText } = require("../utils");
5
+ const {
6
+ convertClaudeToOpenAI,
7
+ convertOpenAIToClaudeResponse,
8
+ mapClaudeModelToOpenAI,
9
+ streamOpenAiToClaude
10
+ } = require("./converters");
11
+
12
+ function resolveChatCompletionsUrl(baseUrl) {
13
+ const upstreamUrl = new URL(String(baseUrl));
14
+ const normalizedPath = upstreamUrl.pathname.replace(/\/+$/, "");
15
+
16
+ upstreamUrl.pathname =
17
+ !normalizedPath || normalizedPath === "/"
18
+ ? "/v1/chat/completions"
19
+ : `${normalizedPath}/chat/completions`;
20
+
21
+ return upstreamUrl.toString();
22
+ }
23
+
24
+ function extractClientApiKey(request) {
25
+ const xApiKey = request.headers["x-api-key"];
26
+ if (xApiKey) {
27
+ return String(xApiKey);
28
+ }
29
+
30
+ const auth = request.headers.authorization;
31
+ if (auth && auth.startsWith("Bearer ")) {
32
+ return auth.slice("Bearer ".length);
33
+ }
34
+
35
+ return null;
36
+ }
37
+
38
+ function validateClientApiKey(request, config) {
39
+ if (!config.client_api_key) {
40
+ return true;
41
+ }
42
+ return extractClientApiKey(request) === config.client_api_key;
43
+ }
44
+
45
+ async function parseUpstreamError(response) {
46
+ const text = await response.text();
47
+ try {
48
+ const json = JSON.parse(text);
49
+ return json.error?.message || json.message || text;
50
+ } catch {
51
+ return text || `Upstream request failed with status ${response.status}`;
52
+ }
53
+ }
54
+
55
+ function createUpstreamHeaders(config) {
56
+ return {
57
+ "Content-Type": "application/json",
58
+ Authorization: `Bearer ${config.api_key}`
59
+ };
60
+ }
61
+
62
+ function estimateInputTokens(messageRequest) {
63
+ let totalChars = 0;
64
+ if (typeof messageRequest.system === "string") {
65
+ totalChars += messageRequest.system.length;
66
+ } else if (Array.isArray(messageRequest.system)) {
67
+ totalChars += messageRequest.system
68
+ .filter((block) => block && typeof block.text === "string")
69
+ .reduce((sum, block) => sum + block.text.length, 0);
70
+ }
71
+
72
+ for (const message of messageRequest.messages || []) {
73
+ if (typeof message.content === "string") {
74
+ totalChars += message.content.length;
75
+ continue;
76
+ }
77
+
78
+ if (Array.isArray(message.content)) {
79
+ for (const block of message.content) {
80
+ if (block && typeof block.text === "string") {
81
+ totalChars += block.text.length;
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ return Math.max(1, Math.floor(totalChars / 4));
88
+ }
89
+
90
+ async function startServer(config, host) {
91
+ const app = express();
92
+ app.use(express.json({ limit: "100mb" }));
93
+ const baseUrl = host.base_url || config.base_url;
94
+ const apiKey = host.api_key || config.api_key;
95
+ const clientApiKey = host.client_api_key || DEFAULT_CLIENT_API_KEY;
96
+ const smallModel = host.small_model || config.small_model;
97
+
98
+ app.get("/", async (request, response) => {
99
+ response.json({
100
+ message: "Claude-to-OpenAI API Proxy (Node rewrite)",
101
+ status: "running",
102
+ upstream_base_url: baseUrl,
103
+ listen: `${config.server_host}:${config.server_port}`,
104
+ host: host.name
105
+ });
106
+ });
107
+
108
+ app.get("/health", async (request, response) => {
109
+ response.json({
110
+ status: "healthy",
111
+ upstream_base_url: baseUrl,
112
+ api_key_configured: Boolean(apiKey),
113
+ client_api_key_configured: Boolean(clientApiKey),
114
+ host: host.name
115
+ });
116
+ });
117
+
118
+ app.get("/test-connection", async (request, response) => {
119
+ try {
120
+ const upstreamResponse = await fetch(resolveChatCompletionsUrl(baseUrl), {
121
+ method: "POST",
122
+ headers: createUpstreamHeaders({ api_key: apiKey }),
123
+ body: JSON.stringify({
124
+ model: smallModel,
125
+ messages: [{ role: "user", content: "Hello" }],
126
+ max_tokens: 5
127
+ })
128
+ });
129
+
130
+ if (!upstreamResponse.ok) {
131
+ throw new Error(await parseUpstreamError(upstreamResponse));
132
+ }
133
+
134
+ const payload = await upstreamResponse.json();
135
+ response.json({
136
+ status: "success",
137
+ model_used: smallModel,
138
+ response_id: payload.id || null
139
+ });
140
+ } catch (error) {
141
+ response.status(503).json({
142
+ status: "failed",
143
+ message: error.message
144
+ });
145
+ }
146
+ });
147
+
148
+ app.post("/v1/messages/count_tokens", async (request, response) => {
149
+ response.json({
150
+ input_tokens: estimateInputTokens(request.body || {})
151
+ });
152
+ });
153
+
154
+ app.post("/v1/messages", async (request, response) => {
155
+ if (!validateClientApiKey(request, { client_api_key: clientApiKey })) {
156
+ response.status(401).json({
157
+ type: "error",
158
+ error: {
159
+ type: "authentication_error",
160
+ message: "Invalid Claude client API key."
161
+ }
162
+ });
163
+ return;
164
+ }
165
+
166
+ let openaiRequest;
167
+ try {
168
+ openaiRequest = convertClaudeToOpenAI(request.body, {
169
+ big_model: host.big_model || config.big_model,
170
+ middle_model: host.middle_model || config.middle_model,
171
+ small_model: smallModel
172
+ });
173
+ } catch (error) {
174
+ response.status(400).json({
175
+ type: "error",
176
+ error: {
177
+ type: "invalid_request_error",
178
+ message: error.message
179
+ }
180
+ });
181
+ return;
182
+ }
183
+
184
+ const abortController = new AbortController();
185
+ request.on("aborted", () => abortController.abort());
186
+ response.on("close", () => {
187
+ if (!response.writableEnded) {
188
+ abortController.abort();
189
+ }
190
+ });
191
+
192
+ try {
193
+ const upstreamResponse = await fetch(resolveChatCompletionsUrl(baseUrl), {
194
+ method: "POST",
195
+ headers: createUpstreamHeaders({ api_key: apiKey }),
196
+ body: JSON.stringify(openaiRequest),
197
+ signal: abortController.signal
198
+ });
199
+
200
+ if (!upstreamResponse.ok) {
201
+ response.status(upstreamResponse.status).json({
202
+ type: "error",
203
+ error: {
204
+ type: "api_error",
205
+ message: await parseUpstreamError(upstreamResponse)
206
+ }
207
+ });
208
+ return;
209
+ }
210
+
211
+ if (openaiRequest.stream) {
212
+ response.status(200);
213
+ response.setHeader("Content-Type", "text/event-stream");
214
+ response.setHeader("Cache-Control", "no-cache");
215
+ response.setHeader("Connection", "keep-alive");
216
+ response.setHeader("Access-Control-Allow-Origin", "*");
217
+ response.setHeader("Access-Control-Allow-Headers", "*");
218
+ response.flushHeaders();
219
+ await streamOpenAiToClaude(upstreamResponse, response, request.body, abortController);
220
+ response.end();
221
+ return;
222
+ }
223
+
224
+ const upstreamJson = await upstreamResponse.json();
225
+ const claudeResponse = convertOpenAIToClaudeResponse(upstreamJson, request.body);
226
+ response.json(claudeResponse);
227
+ } catch (error) {
228
+ const status = error.name === "AbortError" ? 499 : 500;
229
+ response.status(status).json({
230
+ type: "error",
231
+ error: {
232
+ type: "api_error",
233
+ message: error.message
234
+ }
235
+ });
236
+ }
237
+ });
238
+
239
+ await ensureDir(host.state_dir);
240
+ await ensureDir(host.logs_dir);
241
+
242
+ return new Promise((resolve, reject) => {
243
+ const server = app.listen(config.server_port, config.server_host, async () => {
244
+ await writeText(host.pid_file, `${process.pid}\n`);
245
+ resolve(server);
246
+ });
247
+
248
+ server.on("error", async (error) => {
249
+ try {
250
+ await fs.rm(host.pid_file, { force: true });
251
+ } catch {}
252
+ reject(error);
253
+ });
254
+
255
+ const cleanup = async () => {
256
+ try {
257
+ await fs.rm(host.pid_file, { force: true });
258
+ } catch {}
259
+ process.exit(0);
260
+ };
261
+
262
+ process.on("SIGINT", cleanup);
263
+ process.on("SIGTERM", cleanup);
264
+ });
265
+ }
266
+
267
+ module.exports = {
268
+ mapClaudeModelToOpenAI,
269
+ resolveChatCompletionsUrl,
270
+ startServer
271
+ };
@@ -0,0 +1,302 @@
1
+ const fs = require("fs/promises");
2
+ const path = require("path");
3
+ const toml = require("@iarna/toml");
4
+ const {
5
+ backupFile,
6
+ copyFile,
7
+ ensureDir,
8
+ pathExists,
9
+ readJson,
10
+ writeJson
11
+ } = require("../utils");
12
+ const { cleanClaudeSettings, patchClaudeSettings } = require("./host-manager");
13
+
14
+ function getEmptyManagedState() {
15
+ return {
16
+ version: 1,
17
+ files: {}
18
+ };
19
+ }
20
+
21
+ function reportProgress(options, event) {
22
+ if (typeof options?.onProgress === "function") {
23
+ options.onProgress(event);
24
+ }
25
+ }
26
+
27
+ async function runManagedStep(options, label, action) {
28
+ reportProgress(options, { label, status: "started" });
29
+ try {
30
+ const result = await action();
31
+ reportProgress(options, { label, status: "completed" });
32
+ return result;
33
+ } catch (error) {
34
+ reportProgress(options, { label, status: "failed", error });
35
+ throw error;
36
+ }
37
+ }
38
+
39
+ async function readToml(targetPath, fallback = {}) {
40
+ try {
41
+ const raw = await fs.readFile(targetPath, "utf8");
42
+ return toml.parse(raw);
43
+ } catch (error) {
44
+ if (error.code === "ENOENT") {
45
+ return fallback;
46
+ }
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ async function writeToml(targetPath, value) {
52
+ await ensureDir(path.dirname(targetPath));
53
+ await fs.writeFile(targetPath, toml.stringify(value), "utf8");
54
+ }
55
+
56
+ async function loadManagedState(host) {
57
+ const state = await readJson(host.managed_state_file, getEmptyManagedState());
58
+ return {
59
+ version: 1,
60
+ files: {},
61
+ ...state
62
+ };
63
+ }
64
+
65
+ async function ensureManagedBackup(host, state, key, targetPath) {
66
+ if (state.files[key]) {
67
+ return state;
68
+ }
69
+
70
+ const existed = await pathExists(targetPath);
71
+ const backupPath = existed ? await backupFile(targetPath, host.backups_dir) : null;
72
+ state.files[key] = {
73
+ existed,
74
+ target_path: targetPath,
75
+ backup_path: backupPath
76
+ };
77
+ return state;
78
+ }
79
+
80
+ function getCodexProviderName(document, preferredProvider) {
81
+ const providers =
82
+ document.model_providers && typeof document.model_providers === "object"
83
+ ? document.model_providers
84
+ : null;
85
+
86
+ if (providers && preferredProvider && providers[preferredProvider]) {
87
+ return preferredProvider;
88
+ }
89
+
90
+ if (providers && document.model_provider && providers[document.model_provider]) {
91
+ return document.model_provider;
92
+ }
93
+
94
+ if (!providers) {
95
+ return null;
96
+ }
97
+
98
+ return Object.keys(providers)[0] || null;
99
+ }
100
+
101
+ function patchCodexConfigDocument(document, host, config = {}) {
102
+ const patched = { ...document };
103
+ const providerName = getCodexProviderName(
104
+ patched,
105
+ host.codex_provider || config.codex_provider || null
106
+ );
107
+ const baseUrl = host.base_url || config.base_url;
108
+
109
+ if (providerName) {
110
+ patched.model_providers = patched.model_providers || {};
111
+ patched.model_providers[providerName] = {
112
+ ...(patched.model_providers[providerName] || {}),
113
+ base_url: baseUrl
114
+ };
115
+ return patched;
116
+ }
117
+
118
+ patched.base_url = baseUrl;
119
+ return patched;
120
+ }
121
+
122
+ async function patchCodexConfig(config, host, state) {
123
+ await ensureManagedBackup(host, state, "codex_config", host.codex_config_path);
124
+ const document = await readToml(host.codex_config_path, {});
125
+ await writeToml(host.codex_config_path, patchCodexConfigDocument(document, host, config));
126
+ }
127
+
128
+ async function patchCodexAuth(config, host, state) {
129
+ await ensureManagedBackup(host, state, "codex_auth", host.codex_auth_path);
130
+ const auth = await readJson(host.codex_auth_path, {});
131
+ auth.OPENAI_API_KEY = host.api_key || config.api_key;
132
+ await writeJson(host.codex_auth_path, auth);
133
+ }
134
+
135
+ function getManagedApplyEntries(host, config, state) {
136
+ return {
137
+ codex_config: {
138
+ label: `Update Codex config (${host.codex_config_path})`,
139
+ action: () => patchCodexConfig(config, host, state)
140
+ },
141
+ codex_auth: {
142
+ label: `Update Codex auth (${host.codex_auth_path})`,
143
+ action: () => patchCodexAuth(config, host, state)
144
+ },
145
+ claude_settings: {
146
+ label: `Update Claude settings (${host.settings_path})`,
147
+ action: async () => {
148
+ await ensureManagedBackup(host, state, "claude_settings", host.settings_path);
149
+ await patchClaudeSettings(config, host, { configPath: config.__configPath });
150
+ }
151
+ }
152
+ };
153
+ }
154
+
155
+ async function applyManagedEntries(config, host, selectedKeys, options = {}) {
156
+ await ensureDir(host.backups_dir);
157
+ await ensureDir(host.state_dir);
158
+
159
+ const state = await loadManagedState(host);
160
+ const entries = getManagedApplyEntries(host, config, state);
161
+
162
+ for (const key of selectedKeys) {
163
+ const entry = entries[key];
164
+ if (!entry) {
165
+ continue;
166
+ }
167
+
168
+ await runManagedStep(options, entry.label, entry.action);
169
+ }
170
+
171
+ await runManagedStep(options, `Write managed state (${host.managed_state_file})`, async () => {
172
+ await writeJson(host.managed_state_file, state);
173
+ });
174
+ }
175
+
176
+ async function applyClaudeManagedHostConfig(config, host, options = {}) {
177
+ return applyManagedEntries(config, host, ["claude_settings"], options);
178
+ }
179
+
180
+ async function applyOpenAIManagedHostConfig(config, host, options = {}) {
181
+ return applyManagedEntries(config, host, ["codex_config", "codex_auth"], options);
182
+ }
183
+
184
+ async function applyManagedHostConfig(config, host, options = {}) {
185
+ return applyManagedEntries(config, host, ["codex_config", "codex_auth", "claude_settings"], options);
186
+ }
187
+
188
+ async function restoreManagedFile(host, entry) {
189
+ await backupFile(entry.target_path, host.backups_dir);
190
+
191
+ if (entry.existed && entry.backup_path && (await pathExists(entry.backup_path))) {
192
+ await copyFile(entry.backup_path, entry.target_path);
193
+ return;
194
+ }
195
+
196
+ await fs.rm(entry.target_path, { force: true });
197
+ }
198
+
199
+ function getManagedEntries(host) {
200
+ return {
201
+ claude_settings: {
202
+ key: "claude_settings",
203
+ label: "Restore Claude settings",
204
+ targetPath: host.settings_path,
205
+ fallbackLabel: "Clean Claude settings",
206
+ fallback: (config) => cleanClaudeSettings(config, host)
207
+ },
208
+ codex_config: {
209
+ key: "codex_config",
210
+ label: "Restore Codex config",
211
+ targetPath: host.codex_config_path
212
+ },
213
+ codex_auth: {
214
+ key: "codex_auth",
215
+ label: "Restore Codex auth",
216
+ targetPath: host.codex_auth_path
217
+ }
218
+ };
219
+ }
220
+
221
+ async function finalizeManagedState(host, state, options = {}) {
222
+ if (Object.keys(state.files || {}).length === 0) {
223
+ await runManagedStep(options, `Remove managed state (${host.managed_state_file})`, async () => {
224
+ await fs.rm(host.managed_state_file, { force: true });
225
+ });
226
+ return;
227
+ }
228
+
229
+ await runManagedStep(options, `Write managed state (${host.managed_state_file})`, async () => {
230
+ await writeJson(host.managed_state_file, state);
231
+ });
232
+ }
233
+
234
+ async function cleanManagedEntries(config, host, selectedKeys, options = {}) {
235
+ const state = await loadManagedState(host);
236
+ const fileEntries = state.files || {};
237
+ const managedEntries = getManagedEntries(host);
238
+ let changed = false;
239
+
240
+ for (const key of selectedKeys) {
241
+ const descriptor = managedEntries[key];
242
+ if (!descriptor) {
243
+ continue;
244
+ }
245
+
246
+ const entry = fileEntries[key];
247
+ if (entry) {
248
+ await runManagedStep(options, `${descriptor.label} (${descriptor.targetPath})`, async () => {
249
+ await restoreManagedFile(host, entry);
250
+ });
251
+ delete state.files[key];
252
+ changed = true;
253
+ continue;
254
+ }
255
+
256
+ if (descriptor.fallback) {
257
+ await runManagedStep(
258
+ options,
259
+ `${descriptor.fallbackLabel} (${descriptor.targetPath})`,
260
+ async () => {
261
+ await descriptor.fallback(config);
262
+ }
263
+ );
264
+ changed = true;
265
+ continue;
266
+ }
267
+
268
+ reportProgress(options, {
269
+ label: `${descriptor.label} (${descriptor.targetPath})`,
270
+ status: "skipped"
271
+ });
272
+ }
273
+
274
+ if (!changed) {
275
+ return false;
276
+ }
277
+
278
+ await finalizeManagedState(host, state, options);
279
+ return true;
280
+ }
281
+
282
+ async function cleanClaudeManagedHostConfig(config, host, options = {}) {
283
+ return cleanManagedEntries(config, host, ["claude_settings"], options);
284
+ }
285
+
286
+ async function cleanOpenAIManagedHostConfig(config, host, options = {}) {
287
+ return cleanManagedEntries(config, host, ["codex_config", "codex_auth"], options);
288
+ }
289
+
290
+ async function cleanManagedHostConfig(config, host, options = {}) {
291
+ return cleanManagedEntries(config, host, ["claude_settings", "codex_config", "codex_auth"], options);
292
+ }
293
+
294
+ module.exports = {
295
+ applyClaudeManagedHostConfig,
296
+ applyManagedHostConfig,
297
+ applyOpenAIManagedHostConfig,
298
+ cleanClaudeManagedHostConfig,
299
+ cleanManagedHostConfig,
300
+ cleanOpenAIManagedHostConfig,
301
+ patchCodexConfigDocument
302
+ };