@waibiwaibig/all-api 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.
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/package.json +26 -0
- package/src/cli.mjs +668 -0
- package/test/extract.test.mjs +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 waibiwaibig
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# all-api
|
|
2
|
+
|
|
3
|
+
Tiny OpenAI-compatible gateway for local coding agents.
|
|
4
|
+
|
|
5
|
+
It turns local `codex`, `claude`, OpenClaw, and Hermes endpoints into a small
|
|
6
|
+
OpenAI-style `base_url + api_key + model` service.
|
|
7
|
+
|
|
8
|
+
Implemented surface:
|
|
9
|
+
|
|
10
|
+
- `GET /v1/models`
|
|
11
|
+
- `POST /v1/chat/completions`
|
|
12
|
+
|
|
13
|
+
## Use
|
|
14
|
+
|
|
15
|
+
From GitHub:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
git clone https://github.com/waibiwaibig/all-api.git
|
|
19
|
+
cd all-api
|
|
20
|
+
npm install
|
|
21
|
+
npm link
|
|
22
|
+
all-api setup
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
From npm:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npm install -g @waibiwaibig/all-api
|
|
29
|
+
all-api setup
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`setup` asks for the workspace directory, creates an API key, starts the server,
|
|
33
|
+
and prints:
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
Base URL:
|
|
37
|
+
http://127.0.0.1:4011/v1
|
|
38
|
+
|
|
39
|
+
API key:
|
|
40
|
+
sk-allapi-admin-...
|
|
41
|
+
|
|
42
|
+
Models:
|
|
43
|
+
codex-local
|
|
44
|
+
claude-code
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Call it:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
curl http://127.0.0.1:4011/v1/chat/completions \
|
|
51
|
+
-H "Authorization: Bearer sk-allapi-admin-..." \
|
|
52
|
+
-H "Content-Type: application/json" \
|
|
53
|
+
-d '{
|
|
54
|
+
"model": "codex-local",
|
|
55
|
+
"messages": [{"role": "user", "content": "Reply with exactly: ok"}]
|
|
56
|
+
}'
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Create another key:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
all-api key create --models claude-code
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Notes
|
|
66
|
+
|
|
67
|
+
- No runtime dependencies.
|
|
68
|
+
- No Docker or database.
|
|
69
|
+
- Each request starts a fresh agent process.
|
|
70
|
+
- Stores API key hashes, not raw keys.
|
|
71
|
+
- Codex runs with `--sandbox read-only`.
|
|
72
|
+
- Claude runs with `--permission-mode plan`.
|
|
73
|
+
- Binds to `127.0.0.1` by default.
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@waibiwaibig/all-api",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tiny OpenAI-compatible gateway for local coding agents.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/waibiwaibig/all-api.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/waibiwaibig/all-api/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/waibiwaibig/all-api#readme",
|
|
15
|
+
"bin": {
|
|
16
|
+
"all-api": "src/cli.mjs"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"start": "node ./src/cli.mjs up",
|
|
20
|
+
"detect": "node ./src/cli.mjs detect",
|
|
21
|
+
"test": "node --test"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import readline from "node:readline/promises";
|
|
8
|
+
import http from "node:http";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_DIR = join(homedir(), ".all-api");
|
|
12
|
+
const DEFAULT_CONFIG = join(DEFAULT_DIR, "config.json");
|
|
13
|
+
const DEFAULT_PORT = 4011;
|
|
14
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
15
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
function usage() {
|
|
18
|
+
console.log(`all-api
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
all-api init [--config FILE] [--workspace DIR]
|
|
22
|
+
all-api setup [--config FILE] [--workspace DIR] [--host HOST] [--port PORT] [--yes]
|
|
23
|
+
all-api up [--config FILE] [--host HOST] [--port PORT]
|
|
24
|
+
all-api detect
|
|
25
|
+
all-api key create [--config FILE] [--models MODEL,MODEL]
|
|
26
|
+
|
|
27
|
+
Examples:
|
|
28
|
+
all-api init --workspace /path/to/repo
|
|
29
|
+
all-api setup
|
|
30
|
+
all-api up
|
|
31
|
+
all-api key create --models codex-local,claude-code
|
|
32
|
+
`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function argValue(args, name, fallback = undefined) {
|
|
36
|
+
const i = args.indexOf(name);
|
|
37
|
+
if (i === -1) return fallback;
|
|
38
|
+
return args[i + 1] ?? fallback;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hasArg(args, name) {
|
|
42
|
+
return args.includes(name);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ensureDir(path) {
|
|
46
|
+
mkdirSync(path, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function randomKey(prefix = "sk-allapi") {
|
|
50
|
+
return `${prefix}-${randomBytes(24).toString("base64url")}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sha256(value) {
|
|
54
|
+
return createHash("sha256").update(value).digest("hex");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function saveConfig(configPath, config) {
|
|
58
|
+
ensureDir(dirname(configPath));
|
|
59
|
+
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function loadConfig(configPath) {
|
|
63
|
+
if (!existsSync(configPath)) return null;
|
|
64
|
+
return JSON.parse(readFileSync(configPath, "utf8"));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function commandExists(command) {
|
|
68
|
+
return new Promise((resolveExists) => {
|
|
69
|
+
const child = spawn("sh", ["-lc", `command -v ${shellQuote(command)}`], {
|
|
70
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
71
|
+
});
|
|
72
|
+
child.on("exit", (code) => resolveExists(code === 0));
|
|
73
|
+
child.on("error", () => resolveExists(false));
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function shellQuote(value) {
|
|
78
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function run(command, args, options = {}) {
|
|
82
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
83
|
+
return new Promise((resolveRun) => {
|
|
84
|
+
const child = spawn(command, args, {
|
|
85
|
+
cwd: options.cwd,
|
|
86
|
+
env: { ...process.env, ...(options.env ?? {}) },
|
|
87
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
88
|
+
detached: true,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
let stdout = "";
|
|
92
|
+
let stderr = "";
|
|
93
|
+
let timedOut = false;
|
|
94
|
+
const timer = setTimeout(() => {
|
|
95
|
+
timedOut = true;
|
|
96
|
+
terminateChild(child, "SIGTERM");
|
|
97
|
+
setTimeout(() => terminateChild(child, "SIGKILL"), 2000).unref();
|
|
98
|
+
}, timeoutMs);
|
|
99
|
+
|
|
100
|
+
child.stdout.setEncoding("utf8");
|
|
101
|
+
child.stderr.setEncoding("utf8");
|
|
102
|
+
child.stdout.on("data", (chunk) => {
|
|
103
|
+
stdout += chunk;
|
|
104
|
+
});
|
|
105
|
+
child.stderr.on("data", (chunk) => {
|
|
106
|
+
stderr += chunk;
|
|
107
|
+
});
|
|
108
|
+
child.on("error", (error) => {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
resolveRun({ ok: false, code: null, stdout, stderr: String(error), timedOut });
|
|
111
|
+
});
|
|
112
|
+
child.on("exit", (code) => {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
resolveRun({ ok: code === 0, code, stdout, stderr, timedOut });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (options.input) child.stdin.end(options.input);
|
|
118
|
+
else child.stdin.end();
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function terminateChild(child, signal) {
|
|
123
|
+
if (!child.pid || child.exitCode !== null) return;
|
|
124
|
+
try {
|
|
125
|
+
process.kill(-child.pid, signal);
|
|
126
|
+
} catch {
|
|
127
|
+
try {
|
|
128
|
+
child.kill(signal);
|
|
129
|
+
} catch {
|
|
130
|
+
// Process already exited.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function detectAdapters() {
|
|
136
|
+
const [codex, claude] = await Promise.all([commandExists("codex"), commandExists("claude")]);
|
|
137
|
+
const openclaw = await probeOpenAIEndpoint("http://127.0.0.1:18789/v1");
|
|
138
|
+
const hermes = await probeOpenAIEndpoint("http://127.0.0.1:8642/v1");
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
codex: { available: codex, command: "codex" },
|
|
142
|
+
claude: { available: claude, command: "claude" },
|
|
143
|
+
openclaw: { available: openclaw, baseUrl: "http://127.0.0.1:18789/v1" },
|
|
144
|
+
hermes: { available: hermes, baseUrl: "http://127.0.0.1:8642/v1" },
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function probeOpenAIEndpoint(baseUrl) {
|
|
149
|
+
return new Promise((resolveProbe) => {
|
|
150
|
+
const req = http.request(`${baseUrl}/models`, { method: "GET", timeout: 800 }, (res) => {
|
|
151
|
+
res.resume();
|
|
152
|
+
resolveProbe(res.statusCode >= 200 && res.statusCode < 500);
|
|
153
|
+
});
|
|
154
|
+
req.on("error", () => resolveProbe(false));
|
|
155
|
+
req.on("timeout", () => {
|
|
156
|
+
req.destroy();
|
|
157
|
+
resolveProbe(false);
|
|
158
|
+
});
|
|
159
|
+
req.end();
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function buildInitialConfig(args) {
|
|
164
|
+
const detected = await detectAdapters();
|
|
165
|
+
const workspace = resolve(argValue(args, "--workspace", process.cwd()));
|
|
166
|
+
const models = [];
|
|
167
|
+
|
|
168
|
+
if (detected.codex.available) {
|
|
169
|
+
models.push({
|
|
170
|
+
id: "codex-local",
|
|
171
|
+
type: "codex",
|
|
172
|
+
command: "codex",
|
|
173
|
+
workspace,
|
|
174
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
175
|
+
enabled: true,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (detected.claude.available) {
|
|
180
|
+
models.push({
|
|
181
|
+
id: "claude-code",
|
|
182
|
+
type: "claude",
|
|
183
|
+
command: "claude",
|
|
184
|
+
workspace,
|
|
185
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
186
|
+
enabled: true,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (detected.openclaw.available) {
|
|
191
|
+
models.push({
|
|
192
|
+
id: "openclaw",
|
|
193
|
+
type: "openai-compatible",
|
|
194
|
+
baseUrl: detected.openclaw.baseUrl,
|
|
195
|
+
apiKeyEnv: "OPENCLAW_GATEWAY_TOKEN",
|
|
196
|
+
upstreamModel: "openclaw/default",
|
|
197
|
+
enabled: true,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (detected.hermes.available) {
|
|
202
|
+
models.push({
|
|
203
|
+
id: "hermes",
|
|
204
|
+
type: "openai-compatible",
|
|
205
|
+
baseUrl: detected.hermes.baseUrl,
|
|
206
|
+
apiKeyEnv: "HERMES_API_SERVER_KEY",
|
|
207
|
+
upstreamModel: "hermes-agent",
|
|
208
|
+
enabled: true,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const adminKey = randomKey("sk-allapi-admin");
|
|
213
|
+
return {
|
|
214
|
+
host: "127.0.0.1",
|
|
215
|
+
port: DEFAULT_PORT,
|
|
216
|
+
keys: [
|
|
217
|
+
{
|
|
218
|
+
name: "admin",
|
|
219
|
+
keyHash: sha256(adminKey),
|
|
220
|
+
models: ["*"],
|
|
221
|
+
createdAt: new Date().toISOString(),
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
models,
|
|
225
|
+
printedKeys: { admin: adminKey },
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function cmdInit(args) {
|
|
230
|
+
const configPath = resolve(argValue(args, "--config", DEFAULT_CONFIG));
|
|
231
|
+
if (existsSync(configPath) && !hasArg(args, "--force")) {
|
|
232
|
+
console.error(`Config already exists: ${configPath}`);
|
|
233
|
+
console.error("Use --force to overwrite.");
|
|
234
|
+
process.exitCode = 1;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const config = await buildInitialConfig(args);
|
|
239
|
+
const adminKey = config.printedKeys.admin;
|
|
240
|
+
delete config.printedKeys;
|
|
241
|
+
saveConfig(configPath, config);
|
|
242
|
+
|
|
243
|
+
console.log(`Created ${configPath}`);
|
|
244
|
+
printEndpoint(config, adminKey);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function cmdSetup(args) {
|
|
248
|
+
const configPath = resolve(argValue(args, "--config", DEFAULT_CONFIG));
|
|
249
|
+
const nonInteractive = hasArg(args, "--yes") || hasArg(args, "-y") || !process.stdin.isTTY;
|
|
250
|
+
let config = loadConfig(configPath);
|
|
251
|
+
let generatedKey = null;
|
|
252
|
+
|
|
253
|
+
if (config && !nonInteractive) {
|
|
254
|
+
const keep = await ask(`Use existing config at ${configPath}?`, "Y");
|
|
255
|
+
if (!/^n/i.test(keep)) {
|
|
256
|
+
config.host = argValue(args, "--host", config.host ?? "127.0.0.1");
|
|
257
|
+
config.port = Number(argValue(args, "--port", config.port ?? DEFAULT_PORT));
|
|
258
|
+
startServer(config);
|
|
259
|
+
printEndpoint(config, null);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
config = null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!config) {
|
|
266
|
+
const defaults = {
|
|
267
|
+
workspace: resolve(argValue(args, "--workspace", process.cwd())),
|
|
268
|
+
host: argValue(args, "--host", "127.0.0.1"),
|
|
269
|
+
port: String(argValue(args, "--port", DEFAULT_PORT)),
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const workspace = nonInteractive
|
|
273
|
+
? defaults.workspace
|
|
274
|
+
: resolve(await ask("Workspace directory", defaults.workspace));
|
|
275
|
+
const host = nonInteractive ? defaults.host : await ask("Host", defaults.host);
|
|
276
|
+
const port = Number(nonInteractive ? defaults.port : await ask("Port", defaults.port));
|
|
277
|
+
|
|
278
|
+
config = await buildInitialConfig(["--workspace", workspace]);
|
|
279
|
+
generatedKey = config.printedKeys.admin;
|
|
280
|
+
delete config.printedKeys;
|
|
281
|
+
config.host = host;
|
|
282
|
+
config.port = port;
|
|
283
|
+
saveConfig(configPath, config);
|
|
284
|
+
console.log(`Created ${configPath}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
startServer(config);
|
|
288
|
+
printEndpoint(config, generatedKey);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function ask(question, defaultValue) {
|
|
292
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
293
|
+
try {
|
|
294
|
+
const answer = await rl.question(`${question} [${defaultValue}]: `);
|
|
295
|
+
return answer.trim() || defaultValue;
|
|
296
|
+
} finally {
|
|
297
|
+
rl.close();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function cmdDetect() {
|
|
302
|
+
const detected = await detectAdapters();
|
|
303
|
+
console.log(JSON.stringify(detected, null, 2));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function cmdKeyCreate(args) {
|
|
307
|
+
const configPath = resolve(argValue(args, "--config", DEFAULT_CONFIG));
|
|
308
|
+
const config = loadConfig(configPath);
|
|
309
|
+
if (!config) {
|
|
310
|
+
console.error(`Missing config: ${configPath}`);
|
|
311
|
+
process.exitCode = 1;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const models = argValue(args, "--models", "*").split(",").map((m) => m.trim()).filter(Boolean);
|
|
316
|
+
const key = randomKey();
|
|
317
|
+
config.keys.push({
|
|
318
|
+
name: argValue(args, "--name", `key-${config.keys.length + 1}`),
|
|
319
|
+
keyHash: sha256(key),
|
|
320
|
+
models,
|
|
321
|
+
createdAt: new Date().toISOString(),
|
|
322
|
+
});
|
|
323
|
+
saveConfig(configPath, config);
|
|
324
|
+
console.log(key);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function cmdUp(args) {
|
|
328
|
+
const configPath = resolve(argValue(args, "--config", DEFAULT_CONFIG));
|
|
329
|
+
let config = loadConfig(configPath);
|
|
330
|
+
let generatedKey = null;
|
|
331
|
+
if (!config) {
|
|
332
|
+
config = await buildInitialConfig(args);
|
|
333
|
+
generatedKey = config.printedKeys.admin;
|
|
334
|
+
delete config.printedKeys;
|
|
335
|
+
saveConfig(configPath, config);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
config.host = argValue(args, "--host", config.host ?? "127.0.0.1");
|
|
339
|
+
config.port = Number(argValue(args, "--port", config.port ?? DEFAULT_PORT));
|
|
340
|
+
startServer(config);
|
|
341
|
+
printEndpoint(config, generatedKey);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function printEndpoint(config, key) {
|
|
345
|
+
const hostForPrint = config.host === "0.0.0.0" ? "localhost" : config.host;
|
|
346
|
+
console.log("");
|
|
347
|
+
console.log("OpenAI-compatible endpoint:");
|
|
348
|
+
console.log(` http://${hostForPrint}:${config.port}/v1`);
|
|
349
|
+
if (key) {
|
|
350
|
+
console.log("");
|
|
351
|
+
console.log("API key:");
|
|
352
|
+
console.log(` ${key}`);
|
|
353
|
+
}
|
|
354
|
+
console.log("");
|
|
355
|
+
console.log("Models:");
|
|
356
|
+
for (const model of config.models.filter((m) => m.enabled)) {
|
|
357
|
+
console.log(` ${model.id}`);
|
|
358
|
+
}
|
|
359
|
+
console.log("");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function startServer(config) {
|
|
363
|
+
const server = http.createServer(async (req, res) => {
|
|
364
|
+
try {
|
|
365
|
+
await route(req, res, config);
|
|
366
|
+
} catch (error) {
|
|
367
|
+
sendJson(res, 500, {
|
|
368
|
+
error: {
|
|
369
|
+
message: error?.message ?? "Internal server error",
|
|
370
|
+
type: "server_error",
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
server.listen(config.port, config.host, () => {});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function route(req, res, config) {
|
|
380
|
+
const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);
|
|
381
|
+
|
|
382
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
383
|
+
sendJson(res, 200, { ok: true });
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const auth = authenticate(req, config);
|
|
388
|
+
if (!auth.ok) {
|
|
389
|
+
sendJson(res, 401, { error: { message: "Invalid API key", type: "authentication_error" } });
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
394
|
+
sendJson(res, 200, {
|
|
395
|
+
object: "list",
|
|
396
|
+
data: allowedModels(config, auth.key).map((model) => ({
|
|
397
|
+
id: model.id,
|
|
398
|
+
object: "model",
|
|
399
|
+
created: 0,
|
|
400
|
+
owned_by: "all-api",
|
|
401
|
+
})),
|
|
402
|
+
});
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
|
|
407
|
+
const body = await readJsonBody(req);
|
|
408
|
+
const model = config.models.find((m) => m.enabled && m.id === body.model);
|
|
409
|
+
if (!model) {
|
|
410
|
+
sendJson(res, 404, { error: { message: `Unknown model: ${body.model}`, type: "invalid_request_error" } });
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (!keyAllowsModel(auth.key, model.id)) {
|
|
414
|
+
sendJson(res, 403, { error: { message: `API key cannot access model: ${model.id}`, type: "permission_error" } });
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const result = await complete(model, body);
|
|
419
|
+
sendJson(res, 200, result);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
sendJson(res, 404, { error: { message: "Not found", type: "invalid_request_error" } });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function authenticate(req, config) {
|
|
427
|
+
const header = req.headers.authorization ?? "";
|
|
428
|
+
const match = /^Bearer\s+(.+)$/i.exec(header);
|
|
429
|
+
if (!match) return { ok: false };
|
|
430
|
+
const hash = sha256(match[1]);
|
|
431
|
+
const found = config.keys.find((key) => safeEqual(key.keyHash, hash));
|
|
432
|
+
if (!found) return { ok: false };
|
|
433
|
+
return { ok: true, key: found };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function safeEqual(a, b) {
|
|
437
|
+
const left = Buffer.from(String(a));
|
|
438
|
+
const right = Buffer.from(String(b));
|
|
439
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function allowedModels(config, key) {
|
|
443
|
+
return config.models.filter((model) => model.enabled && keyAllowsModel(key, model.id));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function keyAllowsModel(key, modelId) {
|
|
447
|
+
return key.models.includes("*") || key.models.includes(modelId);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function readJsonBody(req) {
|
|
451
|
+
return new Promise((resolveBody, rejectBody) => {
|
|
452
|
+
let size = 0;
|
|
453
|
+
let data = "";
|
|
454
|
+
req.setEncoding("utf8");
|
|
455
|
+
req.on("data", (chunk) => {
|
|
456
|
+
size += Buffer.byteLength(chunk);
|
|
457
|
+
if (size > MAX_BODY_BYTES) {
|
|
458
|
+
rejectBody(new Error("Request body too large"));
|
|
459
|
+
req.destroy();
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
data += chunk;
|
|
463
|
+
});
|
|
464
|
+
req.on("end", () => {
|
|
465
|
+
try {
|
|
466
|
+
resolveBody(data ? JSON.parse(data) : {});
|
|
467
|
+
} catch {
|
|
468
|
+
rejectBody(new Error("Invalid JSON body"));
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
req.on("error", rejectBody);
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function complete(model, body) {
|
|
476
|
+
if (model.type === "codex") return completeWithCodex(model, body);
|
|
477
|
+
if (model.type === "claude") return completeWithClaude(model, body);
|
|
478
|
+
if (model.type === "openai-compatible") return completeWithOpenAICompatible(model, body);
|
|
479
|
+
throw new Error(`Unsupported model type: ${model.type}`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function completeWithCodex(model, body) {
|
|
483
|
+
const prompt = messagesToPrompt(body.messages);
|
|
484
|
+
const result = await run(model.command ?? "codex", [
|
|
485
|
+
"exec",
|
|
486
|
+
"--json",
|
|
487
|
+
"--ephemeral",
|
|
488
|
+
"--skip-git-repo-check",
|
|
489
|
+
"--sandbox",
|
|
490
|
+
"read-only",
|
|
491
|
+
"--cd",
|
|
492
|
+
model.workspace ?? process.cwd(),
|
|
493
|
+
prompt,
|
|
494
|
+
], { timeoutMs: model.timeoutMs });
|
|
495
|
+
|
|
496
|
+
if (!result.ok) throw new Error(result.stderr || `codex exited with code ${result.code}`);
|
|
497
|
+
return chatCompletion(body.model, extractCodexText(result.stdout));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function completeWithClaude(model, body) {
|
|
501
|
+
const prompt = messagesToPrompt(body.messages);
|
|
502
|
+
const result = await run(model.command ?? "claude", [
|
|
503
|
+
"-p",
|
|
504
|
+
prompt,
|
|
505
|
+
"--output-format",
|
|
506
|
+
"json",
|
|
507
|
+
"--no-session-persistence",
|
|
508
|
+
"--permission-mode",
|
|
509
|
+
"plan",
|
|
510
|
+
], {
|
|
511
|
+
cwd: model.workspace ?? process.cwd(),
|
|
512
|
+
timeoutMs: model.timeoutMs,
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
if (!result.ok) throw new Error(result.stderr || `claude exited with code ${result.code}`);
|
|
516
|
+
return chatCompletion(body.model, extractClaudeText(result.stdout));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function completeWithOpenAICompatible(model, body) {
|
|
520
|
+
const url = new URL(`${model.baseUrl.replace(/\/$/, "")}/chat/completions`);
|
|
521
|
+
const upstreamBody = {
|
|
522
|
+
...body,
|
|
523
|
+
model: model.upstreamModel ?? body.model,
|
|
524
|
+
};
|
|
525
|
+
delete upstreamBody.user;
|
|
526
|
+
|
|
527
|
+
const headers = {
|
|
528
|
+
"content-type": "application/json",
|
|
529
|
+
};
|
|
530
|
+
const apiKey = model.apiKeyEnv ? process.env[model.apiKeyEnv] : model.apiKey;
|
|
531
|
+
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
|
|
532
|
+
|
|
533
|
+
const response = await fetch(url, {
|
|
534
|
+
method: "POST",
|
|
535
|
+
headers,
|
|
536
|
+
body: JSON.stringify(upstreamBody),
|
|
537
|
+
});
|
|
538
|
+
const text = await response.text();
|
|
539
|
+
if (!response.ok) throw new Error(text || `upstream returned ${response.status}`);
|
|
540
|
+
return JSON.parse(text);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function messagesToPrompt(messages = []) {
|
|
544
|
+
return messages.map((message) => {
|
|
545
|
+
const role = message.role ?? "user";
|
|
546
|
+
const content = normalizeContent(message.content);
|
|
547
|
+
return `${role.toUpperCase()}:\n${content}`;
|
|
548
|
+
}).join("\n\n");
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function normalizeContent(content) {
|
|
552
|
+
if (typeof content === "string") return content;
|
|
553
|
+
if (Array.isArray(content)) {
|
|
554
|
+
return content.map((part) => {
|
|
555
|
+
if (typeof part === "string") return part;
|
|
556
|
+
if (part?.type === "text") return part.text ?? "";
|
|
557
|
+
return JSON.stringify(part);
|
|
558
|
+
}).join("\n");
|
|
559
|
+
}
|
|
560
|
+
return content == null ? "" : JSON.stringify(content);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function extractCodexText(stdout) {
|
|
564
|
+
let lastText = "";
|
|
565
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
566
|
+
if (!line.trim()) continue;
|
|
567
|
+
try {
|
|
568
|
+
const event = JSON.parse(line);
|
|
569
|
+
const candidate = findText(event);
|
|
570
|
+
if (candidate) lastText = candidate;
|
|
571
|
+
} catch {
|
|
572
|
+
lastText = line;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return lastText || stdout.trim();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function extractClaudeText(stdout) {
|
|
579
|
+
try {
|
|
580
|
+
const parsed = JSON.parse(stdout);
|
|
581
|
+
return parsed.result ?? parsed.response ?? parsed.content ?? findText(parsed) ?? stdout.trim();
|
|
582
|
+
} catch {
|
|
583
|
+
return stdout.trim();
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function findText(value) {
|
|
588
|
+
if (!value || typeof value !== "object") return "";
|
|
589
|
+
if (typeof value.text === "string") return value.text;
|
|
590
|
+
if (typeof value.content === "string") return value.content;
|
|
591
|
+
if (typeof value.message === "string") return value.message;
|
|
592
|
+
if (Array.isArray(value.content)) {
|
|
593
|
+
const text = value.content.map(findText).filter(Boolean).join("\n");
|
|
594
|
+
if (text) return text;
|
|
595
|
+
}
|
|
596
|
+
for (const item of Object.values(value)) {
|
|
597
|
+
const text = Array.isArray(item)
|
|
598
|
+
? item.map(findText).filter(Boolean).join("\n")
|
|
599
|
+
: findText(item);
|
|
600
|
+
if (text) return text;
|
|
601
|
+
}
|
|
602
|
+
return "";
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function chatCompletion(model, content) {
|
|
606
|
+
return {
|
|
607
|
+
id: `chatcmpl-${randomBytes(12).toString("hex")}`,
|
|
608
|
+
object: "chat.completion",
|
|
609
|
+
created: Math.floor(Date.now() / 1000),
|
|
610
|
+
model,
|
|
611
|
+
choices: [
|
|
612
|
+
{
|
|
613
|
+
index: 0,
|
|
614
|
+
message: {
|
|
615
|
+
role: "assistant",
|
|
616
|
+
content,
|
|
617
|
+
},
|
|
618
|
+
finish_reason: "stop",
|
|
619
|
+
},
|
|
620
|
+
],
|
|
621
|
+
usage: {
|
|
622
|
+
prompt_tokens: 0,
|
|
623
|
+
completion_tokens: 0,
|
|
624
|
+
total_tokens: 0,
|
|
625
|
+
},
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function sendJson(res, status, value) {
|
|
630
|
+
const body = JSON.stringify(value);
|
|
631
|
+
res.writeHead(status, {
|
|
632
|
+
"content-type": "application/json; charset=utf-8",
|
|
633
|
+
"content-length": Buffer.byteLength(body),
|
|
634
|
+
});
|
|
635
|
+
res.end(body);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export {
|
|
639
|
+
chatCompletion,
|
|
640
|
+
extractClaudeText,
|
|
641
|
+
extractCodexText,
|
|
642
|
+
messagesToPrompt,
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
async function main(args = process.argv.slice(2)) {
|
|
646
|
+
const command = args[0];
|
|
647
|
+
|
|
648
|
+
if (!command || hasArg(args, "--help") || hasArg(args, "-h")) {
|
|
649
|
+
usage();
|
|
650
|
+
} else if (command === "init") {
|
|
651
|
+
await cmdInit(args.slice(1));
|
|
652
|
+
} else if (command === "setup") {
|
|
653
|
+
await cmdSetup(args.slice(1));
|
|
654
|
+
} else if (command === "up") {
|
|
655
|
+
await cmdUp(args.slice(1));
|
|
656
|
+
} else if (command === "detect") {
|
|
657
|
+
await cmdDetect();
|
|
658
|
+
} else if (command === "key" && args[1] === "create") {
|
|
659
|
+
await cmdKeyCreate(args.slice(2));
|
|
660
|
+
} else {
|
|
661
|
+
usage();
|
|
662
|
+
process.exitCode = 1;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (process.argv[1] && realpathSync(resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
|
|
667
|
+
await main();
|
|
668
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
chatCompletion,
|
|
5
|
+
extractClaudeText,
|
|
6
|
+
extractCodexText,
|
|
7
|
+
messagesToPrompt,
|
|
8
|
+
} from "../src/cli.mjs";
|
|
9
|
+
|
|
10
|
+
test("messagesToPrompt keeps roles and text content", () => {
|
|
11
|
+
assert.equal(
|
|
12
|
+
messagesToPrompt([
|
|
13
|
+
{ role: "system", content: "Be terse." },
|
|
14
|
+
{ role: "user", content: [{ type: "text", text: "Hello" }] },
|
|
15
|
+
]),
|
|
16
|
+
"SYSTEM:\nBe terse.\n\nUSER:\nHello",
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("extractClaudeText reads json result", () => {
|
|
21
|
+
assert.equal(extractClaudeText(JSON.stringify({ result: "done" })), "done");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("extractCodexText reads last json text event", () => {
|
|
25
|
+
const stdout = [
|
|
26
|
+
JSON.stringify({ type: "start" }),
|
|
27
|
+
JSON.stringify({ item: { content: [{ type: "text", text: "final answer" }] } }),
|
|
28
|
+
].join("\n");
|
|
29
|
+
assert.equal(extractCodexText(stdout), "final answer");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("chatCompletion returns OpenAI-compatible shape", () => {
|
|
33
|
+
const response = chatCompletion("codex-local", "hello");
|
|
34
|
+
assert.equal(response.object, "chat.completion");
|
|
35
|
+
assert.equal(response.model, "codex-local");
|
|
36
|
+
assert.equal(response.choices[0].message.role, "assistant");
|
|
37
|
+
assert.equal(response.choices[0].message.content, "hello");
|
|
38
|
+
});
|