@agimon-ai/model-proxy-mcp 0.2.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.
- package/LICENSE +52 -0
- package/README.md +251 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +630 -0
- package/dist/index.d.mts +465 -0
- package/dist/index.mjs +3 -0
- package/dist/stdio-C23n_O3v.mjs +4041 -0
- package/package.json +57 -0
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { c as DEFAULT_HTTP_PORT, i as GatewayService, l as DEFAULT_SERVICE_NAME, n as createServer, o as ProfileStore, r as createHttpServer, s as consoleLogger, t as StdioTransportHandler } from "./stdio-C23n_O3v.mjs";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import path, { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
import { execFile, spawn } from "node:child_process";
|
|
8
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
9
|
+
import fs$1 from "node:fs/promises";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
import { serve } from "@hono/node-server";
|
|
13
|
+
|
|
14
|
+
//#region src/services/HttpServerHealthCheck.ts
|
|
15
|
+
var HttpServerHealthCheck = class {
|
|
16
|
+
async check(port) {
|
|
17
|
+
try {
|
|
18
|
+
const response = await fetch(`http://127.0.0.1:${port}/health`);
|
|
19
|
+
if (!response.ok) return {
|
|
20
|
+
healthy: false,
|
|
21
|
+
error: `Health check failed with status ${response.status}`
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
healthy: true,
|
|
25
|
+
serviceName: (await response.json()).service
|
|
26
|
+
};
|
|
27
|
+
} catch (error) {
|
|
28
|
+
return {
|
|
29
|
+
healthy: false,
|
|
30
|
+
error: error instanceof Error ? error.message : String(error)
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
//#endregion
|
|
37
|
+
//#region src/services/HttpServerManager.ts
|
|
38
|
+
const WORKSPACE_MARKERS = [
|
|
39
|
+
"pnpm-workspace.yaml",
|
|
40
|
+
"nx.json",
|
|
41
|
+
".git"
|
|
42
|
+
];
|
|
43
|
+
const PORT_SCAN_RANGE = 25;
|
|
44
|
+
const SHELL_BINARY = "sh";
|
|
45
|
+
const LSOF_COMMAND_PREFIX = "lsof -n -P -sTCP:LISTEN";
|
|
46
|
+
const HEALTH_CHECK_STABILIZE_MS = 5e3;
|
|
47
|
+
const HEALTH_CHECK_POLL_INTERVAL_MS = 250;
|
|
48
|
+
const PROCESS_SHUTDOWN_WAIT_MS = 1e3;
|
|
49
|
+
const SIGTERM_SIGNAL = "SIGTERM";
|
|
50
|
+
const SIGKILL_SIGNAL = "SIGKILL";
|
|
51
|
+
const STATE_FILE_PREFIX = "model-proxy-mcp-http";
|
|
52
|
+
const STATE_FILE_ENCODING = "utf8";
|
|
53
|
+
const execFileAsync = promisify(execFile);
|
|
54
|
+
function resolveWorkspaceRoot(startPath = process.cwd()) {
|
|
55
|
+
let currentDir = path.resolve(startPath);
|
|
56
|
+
while (true) {
|
|
57
|
+
for (const marker of WORKSPACE_MARKERS) if (existsSync(path.join(currentDir, marker))) return currentDir;
|
|
58
|
+
const parentDir = path.dirname(currentDir);
|
|
59
|
+
if (parentDir === currentDir) return process.cwd();
|
|
60
|
+
currentDir = parentDir;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
var HttpServerManager = class {
|
|
64
|
+
repositoryPath = resolveWorkspaceRoot(process.cwd());
|
|
65
|
+
serviceName = DEFAULT_SERVICE_NAME;
|
|
66
|
+
stateFilePath = path.join(os.tmpdir(), `${STATE_FILE_PREFIX}-${Buffer.from(resolveWorkspaceRoot(process.cwd())).toString("hex")}.json`);
|
|
67
|
+
constructor(healthCheck = new HttpServerHealthCheck()) {
|
|
68
|
+
this.healthCheck = healthCheck;
|
|
69
|
+
}
|
|
70
|
+
createEmptyStatus(overrides = {}) {
|
|
71
|
+
return {
|
|
72
|
+
running: false,
|
|
73
|
+
scope: "default",
|
|
74
|
+
activeProfileId: null,
|
|
75
|
+
auth: {
|
|
76
|
+
configured: false,
|
|
77
|
+
authFilePath: ""
|
|
78
|
+
},
|
|
79
|
+
profiles: [],
|
|
80
|
+
slotModels: {},
|
|
81
|
+
...overrides
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
async getRegistration() {
|
|
85
|
+
try {
|
|
86
|
+
const rawState = await fs$1.readFile(this.stateFilePath, STATE_FILE_ENCODING);
|
|
87
|
+
const parsed = JSON.parse(rawState);
|
|
88
|
+
return parsed.port ? parsed : null;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error.code === "ENOENT") return null;
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async releaseService(pid) {
|
|
95
|
+
const registration = await this.getRegistration();
|
|
96
|
+
if (pid && registration?.pid && registration.pid !== pid) return;
|
|
97
|
+
await fs$1.rm(this.stateFilePath, { force: true });
|
|
98
|
+
}
|
|
99
|
+
async listListeningProcesses(port) {
|
|
100
|
+
try {
|
|
101
|
+
const { stdout } = await execFileAsync(SHELL_BINARY, ["-lc", `${LSOF_COMMAND_PREFIX} -iTCP:${port} || true`], { encoding: "utf8" });
|
|
102
|
+
return stdout.split("\n").slice(1).map((line) => line.trim()).filter(Boolean).map((line) => line.split(/\s+/)).map((parts) => ({
|
|
103
|
+
pid: Number.parseInt(parts[1] || "", 10),
|
|
104
|
+
port
|
|
105
|
+
})).filter((listener) => Number.isInteger(listener.pid) && listener.pid > 0);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async findHealthyServicePort(startPort) {
|
|
111
|
+
for (let offset = 0; offset <= PORT_SCAN_RANGE; offset += 1) {
|
|
112
|
+
const candidatePort = startPort + offset;
|
|
113
|
+
const health = await this.healthCheck.check(candidatePort);
|
|
114
|
+
if (health.healthy && health.serviceName === this.serviceName) return candidatePort;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
async clearPortListeners(port, preservePid) {
|
|
119
|
+
const listeners = await this.listListeningProcesses(port);
|
|
120
|
+
for (const listener of listeners) {
|
|
121
|
+
if (preservePid && listener.pid === preservePid) continue;
|
|
122
|
+
await this.killProcess(listener.pid);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async registerService(port, pid) {
|
|
126
|
+
await fs$1.writeFile(this.stateFilePath, JSON.stringify({
|
|
127
|
+
port,
|
|
128
|
+
pid
|
|
129
|
+
}), STATE_FILE_ENCODING);
|
|
130
|
+
}
|
|
131
|
+
async findAvailablePort(startPort) {
|
|
132
|
+
const minimumPort = Math.min(DEFAULT_HTTP_PORT, startPort);
|
|
133
|
+
const maximumPort = Math.max(DEFAULT_HTTP_PORT + 1e3, startPort);
|
|
134
|
+
for (let candidatePort = startPort; candidatePort <= maximumPort; candidatePort += 1) if ((await this.listListeningProcesses(candidatePort)).length === 0) return candidatePort;
|
|
135
|
+
for (let candidatePort = minimumPort; candidatePort < startPort; candidatePort += 1) if ((await this.listListeningProcesses(candidatePort)).length === 0) return candidatePort;
|
|
136
|
+
throw new Error(`No available port found from ${startPort}`);
|
|
137
|
+
}
|
|
138
|
+
async fileExists(filePath) {
|
|
139
|
+
try {
|
|
140
|
+
await fs$1.access(filePath);
|
|
141
|
+
return true;
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async startHttpServer(port) {
|
|
147
|
+
const distCliPath = path.join(this.repositoryPath, "packages", "mcp", "model-proxy-mcp", "dist", "cli.mjs");
|
|
148
|
+
const srcCliPath = path.join(this.repositoryPath, "packages", "mcp", "model-proxy-mcp", "src", "cli.ts");
|
|
149
|
+
const command = "node";
|
|
150
|
+
let args;
|
|
151
|
+
if (await this.fileExists(distCliPath)) args = [
|
|
152
|
+
distCliPath,
|
|
153
|
+
"http-serve",
|
|
154
|
+
"--port",
|
|
155
|
+
String(port)
|
|
156
|
+
];
|
|
157
|
+
else if (await this.fileExists(srcCliPath)) args = [
|
|
158
|
+
"--loader",
|
|
159
|
+
"ts-node/esm",
|
|
160
|
+
srcCliPath,
|
|
161
|
+
"http-serve",
|
|
162
|
+
"--port",
|
|
163
|
+
String(port)
|
|
164
|
+
];
|
|
165
|
+
else throw new Error("Cannot find model-proxy-mcp CLI");
|
|
166
|
+
const child = spawn(command, args, {
|
|
167
|
+
detached: true,
|
|
168
|
+
stdio: "ignore",
|
|
169
|
+
env: {
|
|
170
|
+
...process.env,
|
|
171
|
+
NODE_ENV: process.env.NODE_ENV || "development"
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
child.unref();
|
|
175
|
+
if (!child.pid) throw new Error("Failed to spawn HTTP server");
|
|
176
|
+
return child.pid;
|
|
177
|
+
}
|
|
178
|
+
async killProcess(pid) {
|
|
179
|
+
try {
|
|
180
|
+
process.kill(pid, SIGTERM_SIGNAL);
|
|
181
|
+
await new Promise((resolve) => setTimeout(resolve, PROCESS_SHUTDOWN_WAIT_MS));
|
|
182
|
+
try {
|
|
183
|
+
process.kill(pid, 0);
|
|
184
|
+
process.kill(pid, SIGKILL_SIGNAL);
|
|
185
|
+
} catch {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async waitForHealthy(port) {
|
|
193
|
+
const deadline = Date.now() + HEALTH_CHECK_STABILIZE_MS;
|
|
194
|
+
let lastError = "Server failed health check after startup";
|
|
195
|
+
while (Date.now() < deadline) {
|
|
196
|
+
const health = await this.healthCheck.check(port);
|
|
197
|
+
if (health.healthy && health.serviceName === this.serviceName) return { healthy: true };
|
|
198
|
+
lastError = health.error || `Unexpected service on port ${port}`;
|
|
199
|
+
await new Promise((resolve) => setTimeout(resolve, HEALTH_CHECK_POLL_INTERVAL_MS));
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
healthy: false,
|
|
203
|
+
error: lastError
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
async ensureRunning(port = DEFAULT_HTTP_PORT) {
|
|
207
|
+
try {
|
|
208
|
+
const healthyPort = await this.findHealthyServicePort(port);
|
|
209
|
+
if (healthyPort !== null) {
|
|
210
|
+
const healthyPid = (await this.listListeningProcesses(healthyPort))[0]?.pid;
|
|
211
|
+
await this.registerService(healthyPort, healthyPid ?? process.pid);
|
|
212
|
+
return this.createEmptyStatus({
|
|
213
|
+
running: true,
|
|
214
|
+
port: healthyPort,
|
|
215
|
+
pid: healthyPid
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
const existing = await this.getRegistration();
|
|
219
|
+
if (existing) {
|
|
220
|
+
const health$1 = await this.healthCheck.check(existing.port);
|
|
221
|
+
if (health$1.healthy && health$1.serviceName === this.serviceName) return this.createEmptyStatus({
|
|
222
|
+
running: true,
|
|
223
|
+
port: existing.port,
|
|
224
|
+
pid: existing.pid
|
|
225
|
+
});
|
|
226
|
+
if (existing.pid) await this.killProcess(existing.pid);
|
|
227
|
+
await this.clearPortListeners(existing.port, existing.pid);
|
|
228
|
+
await this.releaseService(existing.pid);
|
|
229
|
+
}
|
|
230
|
+
await this.clearPortListeners(port);
|
|
231
|
+
const availablePort = await this.findAvailablePort(port);
|
|
232
|
+
const pid = await this.startHttpServer(availablePort);
|
|
233
|
+
const health = await this.waitForHealthy(availablePort);
|
|
234
|
+
if (!health.healthy) {
|
|
235
|
+
await this.killProcess(pid);
|
|
236
|
+
await this.clearPortListeners(availablePort, pid);
|
|
237
|
+
await this.releaseService(pid);
|
|
238
|
+
return this.createEmptyStatus({ error: health.error || "Server failed health check after startup" });
|
|
239
|
+
}
|
|
240
|
+
await this.registerService(availablePort, pid);
|
|
241
|
+
return this.createEmptyStatus({
|
|
242
|
+
running: true,
|
|
243
|
+
port: availablePort,
|
|
244
|
+
pid
|
|
245
|
+
});
|
|
246
|
+
} catch (error) {
|
|
247
|
+
return this.createEmptyStatus({ error: error instanceof Error ? error.message : String(error) });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
async stop() {
|
|
251
|
+
const registration = await this.getRegistration();
|
|
252
|
+
let stopped = false;
|
|
253
|
+
if (registration?.pid) {
|
|
254
|
+
await this.killProcess(registration.pid);
|
|
255
|
+
stopped = true;
|
|
256
|
+
}
|
|
257
|
+
if (registration) {
|
|
258
|
+
await this.clearPortListeners(registration.port, registration.pid);
|
|
259
|
+
await this.releaseService(registration.pid);
|
|
260
|
+
}
|
|
261
|
+
const healthyPort = await this.findHealthyServicePort(DEFAULT_HTTP_PORT);
|
|
262
|
+
if (healthyPort !== null) {
|
|
263
|
+
const listeners = await this.listListeningProcesses(healthyPort);
|
|
264
|
+
for (const listener of listeners) {
|
|
265
|
+
await this.killProcess(listener.pid);
|
|
266
|
+
stopped = true;
|
|
267
|
+
}
|
|
268
|
+
await this.releaseService();
|
|
269
|
+
}
|
|
270
|
+
return stopped;
|
|
271
|
+
}
|
|
272
|
+
async getStatus() {
|
|
273
|
+
const registration = await this.getRegistration();
|
|
274
|
+
if (registration) {
|
|
275
|
+
if ((await this.healthCheck.check(registration.port)).healthy) return this.createEmptyStatus({
|
|
276
|
+
running: true,
|
|
277
|
+
port: registration.port,
|
|
278
|
+
pid: registration.pid
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
const healthyPort = await this.findHealthyServicePort(DEFAULT_HTTP_PORT);
|
|
282
|
+
if (healthyPort !== null) {
|
|
283
|
+
const pid = (await this.listListeningProcesses(healthyPort))[0]?.pid;
|
|
284
|
+
if (pid) await this.registerService(healthyPort, pid);
|
|
285
|
+
return this.createEmptyStatus({
|
|
286
|
+
running: true,
|
|
287
|
+
port: healthyPort,
|
|
288
|
+
pid
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return this.createEmptyStatus({ error: registration ? "Registered server is unhealthy" : "No HTTP server registered" });
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
//#endregion
|
|
296
|
+
//#region src/commands/claude.ts
|
|
297
|
+
const CLAUDE_BINARY = "claude";
|
|
298
|
+
const DEFAULT_SCOPE$2 = "default";
|
|
299
|
+
const SESSIONS_DIRECTORY_NAME = ".claude-sessions";
|
|
300
|
+
const RANDOM_SCOPE_PREFIX = "scope";
|
|
301
|
+
const RANDOM_SCOPE_BYTES = 3;
|
|
302
|
+
const TEXT_ENCODING = "utf8";
|
|
303
|
+
const STDIO_INHERIT = "inherit";
|
|
304
|
+
const DECIMAL_RADIX = 10;
|
|
305
|
+
const LOG_PREFIX = "[model-proxy-mcp]";
|
|
306
|
+
const RECOVERY_MESSAGE = `Recovery: verify HOME, proxy port, and that \`${CLAUDE_BINARY}\` is on PATH.`;
|
|
307
|
+
const ARG_RESUME = "--resume";
|
|
308
|
+
const ARG_SESSION_ID = "--session-id";
|
|
309
|
+
const ARG_SKIP_PERMISSIONS = "--dangerously-skip-permissions";
|
|
310
|
+
const MODEL_ALIAS_OPUS = "ccproxy-opus";
|
|
311
|
+
const MODEL_ALIAS_SONNET = "ccproxy-sonnet";
|
|
312
|
+
const MODEL_ALIAS_HAIKU = "ccproxy-haiku";
|
|
313
|
+
const MODEL_ALIAS_SUBAGENT = "ccproxy-subagent";
|
|
314
|
+
const DEFAULT_SCOPE_SEED_FILE = "model-proxy-mcp.yaml";
|
|
315
|
+
const MODEL_PROXY_SCOPE_ENV$1 = "MODEL_PROXY_MCP_SCOPE";
|
|
316
|
+
const MODEL_PROXY_SLOT_ENV = "MODEL_PROXY_MCP_SLOT";
|
|
317
|
+
const DEFAULT_MODEL_PROXY_SLOT = "default";
|
|
318
|
+
var ClaudeCommandError = class extends Error {
|
|
319
|
+
constructor(message, code, options) {
|
|
320
|
+
super(message, options);
|
|
321
|
+
this.code = code;
|
|
322
|
+
this.name = "ClaudeCommandError";
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
var ClaudeCommandConfigError = class extends ClaudeCommandError {
|
|
326
|
+
constructor(message, options) {
|
|
327
|
+
super(message, "CLAUDE_COMMAND_CONFIG_ERROR", options);
|
|
328
|
+
this.name = "ClaudeCommandConfigError";
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
var ClaudeCommandValidationError = class extends ClaudeCommandError {
|
|
332
|
+
constructor(message, options) {
|
|
333
|
+
super(message, "CLAUDE_COMMAND_VALIDATION_ERROR", options);
|
|
334
|
+
this.name = "ClaudeCommandValidationError";
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
var ClaudeCommandLaunchError = class extends ClaudeCommandError {
|
|
338
|
+
constructor(message, options) {
|
|
339
|
+
super(message, "CLAUDE_COMMAND_LAUNCH_ERROR", options);
|
|
340
|
+
this.name = "ClaudeCommandLaunchError";
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
function sanitizeScope(scope) {
|
|
344
|
+
return scope.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || DEFAULT_SCOPE$2;
|
|
345
|
+
}
|
|
346
|
+
function generateScopeName() {
|
|
347
|
+
return `${RANDOM_SCOPE_PREFIX}-${randomBytes(RANDOM_SCOPE_BYTES).toString("hex")}`;
|
|
348
|
+
}
|
|
349
|
+
function buildClaudeEnvironment(port, scope, baseEnvironment = process.env) {
|
|
350
|
+
return {
|
|
351
|
+
...baseEnvironment,
|
|
352
|
+
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/scopes/${scope}`,
|
|
353
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL: baseEnvironment.ANTHROPIC_DEFAULT_OPUS_MODEL || MODEL_ALIAS_OPUS,
|
|
354
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: baseEnvironment.ANTHROPIC_DEFAULT_SONNET_MODEL || MODEL_ALIAS_SONNET,
|
|
355
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: baseEnvironment.ANTHROPIC_DEFAULT_HAIKU_MODEL || MODEL_ALIAS_HAIKU,
|
|
356
|
+
CLAUDE_CODE_SUBAGENT_MODEL: baseEnvironment.CLAUDE_CODE_SUBAGENT_MODEL || MODEL_ALIAS_SUBAGENT,
|
|
357
|
+
MODEL_PROXY_MCP_SCOPE: baseEnvironment[MODEL_PROXY_SCOPE_ENV$1] || scope,
|
|
358
|
+
MODEL_PROXY_MCP_SLOT: baseEnvironment[MODEL_PROXY_SLOT_ENV] || DEFAULT_MODEL_PROXY_SLOT
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
async function resolveScopeSeedConfigPath(configFile, workingDirectory = process.cwd()) {
|
|
362
|
+
const candidate = configFile ? path.resolve(workingDirectory, configFile) : path.join(workingDirectory, DEFAULT_SCOPE_SEED_FILE);
|
|
363
|
+
try {
|
|
364
|
+
if (!(await fs$1.stat(candidate)).isFile()) {
|
|
365
|
+
if (configFile) throw new ClaudeCommandConfigError(`Scope seed config path is not a file: ${candidate}`);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
return candidate;
|
|
369
|
+
} catch (error) {
|
|
370
|
+
if (error.code === "ENOENT") {
|
|
371
|
+
if (configFile) throw new ClaudeCommandConfigError(`Scope seed config file not found: ${candidate}`, { cause: error });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (error instanceof ClaudeCommandConfigError) throw error;
|
|
375
|
+
throw new ClaudeCommandConfigError(`Failed to read scope seed config file: ${candidate}`, { cause: error });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
function getSessionsDirectory() {
|
|
379
|
+
const homeDirectory = process.env.HOME || process.env.USERPROFILE;
|
|
380
|
+
if (!homeDirectory) throw new ClaudeCommandConfigError("HOME or USERPROFILE is not set");
|
|
381
|
+
return path.join(homeDirectory, SESSIONS_DIRECTORY_NAME);
|
|
382
|
+
}
|
|
383
|
+
async function ensureSessionsDirectory() {
|
|
384
|
+
const sessionsDirectory = getSessionsDirectory();
|
|
385
|
+
await fs$1.mkdir(sessionsDirectory, { recursive: true });
|
|
386
|
+
return sessionsDirectory;
|
|
387
|
+
}
|
|
388
|
+
async function readSessionId(sessionFile) {
|
|
389
|
+
try {
|
|
390
|
+
return (await fs$1.readFile(sessionFile, TEXT_ENCODING)).trim() || null;
|
|
391
|
+
} catch (error) {
|
|
392
|
+
if (error.code === "ENOENT") return null;
|
|
393
|
+
throw new ClaudeCommandConfigError(`Failed to read Claude session file: ${sessionFile}`, { cause: error });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
async function resolveSessionId(scope, clearSession) {
|
|
397
|
+
const sessionsDirectory = await ensureSessionsDirectory();
|
|
398
|
+
const sessionFile = path.join(sessionsDirectory, scope);
|
|
399
|
+
if (clearSession) try {
|
|
400
|
+
await fs$1.rm(sessionFile, { force: true });
|
|
401
|
+
} catch (error) {
|
|
402
|
+
throw new ClaudeCommandConfigError(`Failed to clear Claude session file: ${sessionFile}`, { cause: error });
|
|
403
|
+
}
|
|
404
|
+
const existingSessionId = await readSessionId(sessionFile);
|
|
405
|
+
if (existingSessionId) return {
|
|
406
|
+
sessionId: existingSessionId,
|
|
407
|
+
resume: true
|
|
408
|
+
};
|
|
409
|
+
const sessionId = randomUUID().toLowerCase();
|
|
410
|
+
try {
|
|
411
|
+
await fs$1.writeFile(sessionFile, `${sessionId}\n`, TEXT_ENCODING);
|
|
412
|
+
} catch (error) {
|
|
413
|
+
throw new ClaudeCommandConfigError(`Failed to write Claude session file: ${sessionFile}`, { cause: error });
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
sessionId,
|
|
417
|
+
resume: false
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
async function launchClaude(scope, port, clearSession, claudeArgs, configFile) {
|
|
421
|
+
const scopeSeedConfigPath = await resolveScopeSeedConfigPath(configFile);
|
|
422
|
+
await new ProfileStore().ensureConfig(scope, scopeSeedConfigPath);
|
|
423
|
+
const status = await new HttpServerManager().ensureRunning(port);
|
|
424
|
+
if (!status.running || !status.port) throw new ClaudeCommandLaunchError(status.error || "Failed to start model proxy HTTP server");
|
|
425
|
+
const { sessionId, resume } = await resolveSessionId(scope, clearSession);
|
|
426
|
+
const env = buildClaudeEnvironment(status.port, scope);
|
|
427
|
+
const args = [
|
|
428
|
+
ARG_SKIP_PERMISSIONS,
|
|
429
|
+
...resume ? [ARG_RESUME, sessionId] : [ARG_SESSION_ID, sessionId],
|
|
430
|
+
...claudeArgs
|
|
431
|
+
];
|
|
432
|
+
console.log(`${LOG_PREFIX} Scope: ${scope}`);
|
|
433
|
+
console.log(`${LOG_PREFIX} Proxy: ${env.ANTHROPIC_BASE_URL}`);
|
|
434
|
+
console.log(`${LOG_PREFIX} Session: ${sessionId}${resume ? " (resume)" : " (new)"}`);
|
|
435
|
+
if (scopeSeedConfigPath) console.log(`${LOG_PREFIX} Scope config seed: ${scopeSeedConfigPath}`);
|
|
436
|
+
return new Promise((resolve, reject) => {
|
|
437
|
+
const child = spawn(CLAUDE_BINARY, args, {
|
|
438
|
+
stdio: STDIO_INHERIT,
|
|
439
|
+
env
|
|
440
|
+
});
|
|
441
|
+
child.on("error", (error) => {
|
|
442
|
+
reject(new ClaudeCommandLaunchError(`Failed to launch ${CLAUDE_BINARY}`, { cause: error }));
|
|
443
|
+
});
|
|
444
|
+
child.on("exit", (code, signal) => {
|
|
445
|
+
if (signal) {
|
|
446
|
+
reject(new ClaudeCommandLaunchError(`${CLAUDE_BINARY} exited with signal ${signal}`));
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
resolve(code ?? 0);
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
const claudeCommand = new Command("claude").description("Launch Claude Code through the model proxy").option("-s, --scope <scope>", "Proxy scope to use").option("-p, --port <port>", "Preferred HTTP port for the proxy", String(DEFAULT_HTTP_PORT)).option("-c, --config-file <path>", "Seed a new scope from the specified scope config file").option("--config <path>", "Alias for --config-file").option("--clear-session", "Start with a fresh Claude session for the selected scope", false).allowUnknownOption(true).allowExcessArguments(true).argument("[claudeArgs...]").action(async (claudeArgs, options) => {
|
|
454
|
+
try {
|
|
455
|
+
const resolvedScope = sanitizeScope(options.scope || generateScopeName());
|
|
456
|
+
const port = Number.parseInt(options.port, DECIMAL_RADIX);
|
|
457
|
+
if (!Number.isInteger(port) || port <= 0) throw new ClaudeCommandValidationError(`Invalid port: ${options.port}`);
|
|
458
|
+
const exitCode = await launchClaude(resolvedScope, port, options.clearSession, claudeArgs, options.configFile ?? options.config);
|
|
459
|
+
process.exit(exitCode);
|
|
460
|
+
} catch (error) {
|
|
461
|
+
const commandError = error instanceof ClaudeCommandError ? error : new ClaudeCommandLaunchError("Failed to launch Claude", { cause: error });
|
|
462
|
+
console.error(`${LOG_PREFIX} ${commandError.code}: ${commandError.message}`);
|
|
463
|
+
console.error(`${LOG_PREFIX} ${RECOVERY_MESSAGE}`);
|
|
464
|
+
if (commandError.cause) console.error(`${LOG_PREFIX} Cause:`, commandError.cause);
|
|
465
|
+
process.exit(1);
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
//#endregion
|
|
470
|
+
//#region src/commands/http-serve.ts
|
|
471
|
+
const LOCAL_HOST = "127.0.0.1";
|
|
472
|
+
const httpServeCommand = new Command("http-serve").description("Start the Claude-compatible HTTP model proxy server").option("-p, --port <port>", "Port to listen on", String(DEFAULT_HTTP_PORT)).action(async (options) => {
|
|
473
|
+
try {
|
|
474
|
+
const port = Number.parseInt(options.port, 10);
|
|
475
|
+
if (!Number.isInteger(port) || port <= 0) throw new Error(`Invalid port: ${options.port}`);
|
|
476
|
+
const gatewayService = new GatewayService();
|
|
477
|
+
await gatewayService.ensureConfig();
|
|
478
|
+
const server = serve({
|
|
479
|
+
fetch: createHttpServer(gatewayService).fetch,
|
|
480
|
+
port,
|
|
481
|
+
hostname: LOCAL_HOST
|
|
482
|
+
});
|
|
483
|
+
console.log(`model-proxy-mcp listening on http://${LOCAL_HOST}:${port}`);
|
|
484
|
+
console.log(`Claude base URL: http://${LOCAL_HOST}:${port}`);
|
|
485
|
+
const shutdown = (signal) => {
|
|
486
|
+
console.log(`\n${signal} received. Shutting down...`);
|
|
487
|
+
server.close();
|
|
488
|
+
process.exit(0);
|
|
489
|
+
};
|
|
490
|
+
process.once("SIGINT", () => shutdown("SIGINT"));
|
|
491
|
+
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
|
492
|
+
} catch (error) {
|
|
493
|
+
console.error("Failed to start HTTP server:", error);
|
|
494
|
+
process.exit(1);
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
//#endregion
|
|
499
|
+
//#region src/commands/mcp-serve.ts
|
|
500
|
+
const DEFAULT_SCOPE$1 = "default";
|
|
501
|
+
const MODEL_PROXY_SCOPE_ENV = "MODEL_PROXY_MCP_SCOPE";
|
|
502
|
+
const mcpServeCommand = new Command("mcp-serve").description("Start MCP server with stdio transport").option("--cleanup", "Stop HTTP server on shutdown", false).option("-p, --port <port>", "Port for HTTP server", String(DEFAULT_HTTP_PORT)).action(async (options) => {
|
|
503
|
+
try {
|
|
504
|
+
const port = Number.parseInt(options.port, 10);
|
|
505
|
+
if (!Number.isInteger(port) || port <= 0) throw new Error(`Invalid port: ${options.port}`);
|
|
506
|
+
const gatewayService = new GatewayService();
|
|
507
|
+
await gatewayService.ensureConfig(process.env[MODEL_PROXY_SCOPE_ENV] || DEFAULT_SCOPE$1);
|
|
508
|
+
const manager = new HttpServerManager();
|
|
509
|
+
const httpStatus = await manager.ensureRunning(port);
|
|
510
|
+
if (!httpStatus.running) console.error(`Warning: HTTP server failed to start: ${httpStatus.error}`);
|
|
511
|
+
const handler = new StdioTransportHandler(createServer(gatewayService));
|
|
512
|
+
const shutdown = async (signal) => {
|
|
513
|
+
console.error(`\nReceived ${signal}, shutting down gracefully...`);
|
|
514
|
+
await handler.stop();
|
|
515
|
+
if (options.cleanup && httpStatus.running) await manager.stop();
|
|
516
|
+
process.exit(0);
|
|
517
|
+
};
|
|
518
|
+
process.once("SIGINT", () => void shutdown("SIGINT").catch((error) => {
|
|
519
|
+
console.error("Failed to shut down after SIGINT:", error);
|
|
520
|
+
process.exit(1);
|
|
521
|
+
}));
|
|
522
|
+
process.once("SIGTERM", () => void shutdown("SIGTERM").catch((error) => {
|
|
523
|
+
console.error("Failed to shut down after SIGTERM:", error);
|
|
524
|
+
process.exit(1);
|
|
525
|
+
}));
|
|
526
|
+
await handler.start();
|
|
527
|
+
} catch (error) {
|
|
528
|
+
console.error("Failed to start MCP server:", error);
|
|
529
|
+
process.exit(1);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
//#endregion
|
|
534
|
+
//#region src/commands/start.ts
|
|
535
|
+
const startCommand = new Command("start").description("Start HTTP and/or MCP server for the model proxy").option("--mcp-only", "Start only the MCP server", false).option("--http-only", "Start only the HTTP server", false).option("-p, --port <port>", "Port for HTTP server", String(DEFAULT_HTTP_PORT)).action(async (options) => {
|
|
536
|
+
try {
|
|
537
|
+
const httpServerManager = new HttpServerManager();
|
|
538
|
+
const port = Number.parseInt(options.port, 10);
|
|
539
|
+
const startHttp = !options.mcpOnly;
|
|
540
|
+
const startMcp = !options.httpOnly;
|
|
541
|
+
if (startHttp) {
|
|
542
|
+
const status = await httpServerManager.ensureRunning(port);
|
|
543
|
+
if (!status.running) {
|
|
544
|
+
console.error(`HTTP server failed to start: ${status.error}`);
|
|
545
|
+
process.exit(1);
|
|
546
|
+
}
|
|
547
|
+
console.log(`HTTP server running on http://127.0.0.1:${status.port}`);
|
|
548
|
+
}
|
|
549
|
+
if (startMcp) {
|
|
550
|
+
const handler = new StdioTransportHandler(createServer());
|
|
551
|
+
await handler.start();
|
|
552
|
+
const shutdown = async (signal) => {
|
|
553
|
+
console.error(`\n${signal} received. Shutting down...`);
|
|
554
|
+
await handler.stop();
|
|
555
|
+
process.exit(0);
|
|
556
|
+
};
|
|
557
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
558
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
559
|
+
} else process.exit(0);
|
|
560
|
+
} catch (error) {
|
|
561
|
+
console.error("Failed to start services:", error);
|
|
562
|
+
process.exit(1);
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
//#endregion
|
|
567
|
+
//#region src/commands/status.ts
|
|
568
|
+
const DEFAULT_SCOPE = "default";
|
|
569
|
+
const STATUS_COMMAND_NAME = "status";
|
|
570
|
+
const STATUS_COMMAND_DESCRIPTION = "Show proxy server and profile status";
|
|
571
|
+
const STATUS_ERROR_MESSAGE = "Failed to read status";
|
|
572
|
+
var StatusCommandError = class extends Error {
|
|
573
|
+
code = "STATUS_CHECK_FAILED";
|
|
574
|
+
constructor(cause) {
|
|
575
|
+
super(STATUS_ERROR_MESSAGE, { cause: cause instanceof Error ? cause : new Error(String(cause)) });
|
|
576
|
+
this.name = "StatusCommandError";
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
const statusCommand = new Command(STATUS_COMMAND_NAME).description(STATUS_COMMAND_DESCRIPTION).option("-s, --scope <scope>", "Configuration scope", DEFAULT_SCOPE).action(async (options) => {
|
|
580
|
+
try {
|
|
581
|
+
const serverStatus = await new HttpServerManager().getStatus();
|
|
582
|
+
const status = await new GatewayService().getStatus(options.scope, serverStatus.port, serverStatus.pid);
|
|
583
|
+
status.running = serverStatus.running;
|
|
584
|
+
status.error = serverStatus.error;
|
|
585
|
+
consoleLogger.info(JSON.stringify(status, null, 2));
|
|
586
|
+
process.exit(0);
|
|
587
|
+
} catch (error) {
|
|
588
|
+
const statusError = new StatusCommandError(error);
|
|
589
|
+
consoleLogger.error(STATUS_ERROR_MESSAGE, {
|
|
590
|
+
command: STATUS_COMMAND_NAME,
|
|
591
|
+
code: statusError.code,
|
|
592
|
+
scope: options.scope,
|
|
593
|
+
cause: statusError.cause
|
|
594
|
+
});
|
|
595
|
+
process.exit(1);
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
//#endregion
|
|
600
|
+
//#region src/commands/stop.ts
|
|
601
|
+
const stopCommand = new Command("stop").description("Stop the background HTTP server").action(async () => {
|
|
602
|
+
try {
|
|
603
|
+
const stopped = await new HttpServerManager().stop();
|
|
604
|
+
console.log(stopped ? "HTTP server stopped" : "No HTTP server running");
|
|
605
|
+
process.exit(0);
|
|
606
|
+
} catch (error) {
|
|
607
|
+
console.error("Failed to stop HTTP server:", error);
|
|
608
|
+
process.exit(1);
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
//#endregion
|
|
613
|
+
//#region src/cli.ts
|
|
614
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
615
|
+
const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
|
|
616
|
+
async function main() {
|
|
617
|
+
const program = new Command();
|
|
618
|
+
program.name("model-proxy-mcp").description("Claude-compatible model proxy MCP").version(packageJson.version);
|
|
619
|
+
program.addCommand(startCommand);
|
|
620
|
+
program.addCommand(stopCommand);
|
|
621
|
+
program.addCommand(statusCommand);
|
|
622
|
+
program.addCommand(claudeCommand);
|
|
623
|
+
program.addCommand(httpServeCommand);
|
|
624
|
+
program.addCommand(mcpServeCommand);
|
|
625
|
+
await program.parseAsync(process.argv);
|
|
626
|
+
}
|
|
627
|
+
main();
|
|
628
|
+
|
|
629
|
+
//#endregion
|
|
630
|
+
export { };
|