mslxdff 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/README.md +83 -0
- package/bin/mslxdfree.js +78 -0
- package/package.json +29 -0
- package/src/daemon.js +63 -0
- package/src/models.js +92 -0
- package/src/reasoning.js +33 -0
- package/src/routes.js +125 -0
- package/src/server.js +27 -0
- package/src/state.js +39 -0
- package/src/upstream.js +68 -0
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# mslxdfree
|
|
2
|
+
|
|
3
|
+
Standalone **OpenCode Free** proxy — an OpenAI-compatible `/v1` gateway that forwards requests to the free zen gateway at `opencode.ai` (no account required) and exposes only the free models.
|
|
4
|
+
|
|
5
|
+
Zero runtime dependencies: Node ≥ 20, built-in `node:http`, `node:crypto`, `node:test`.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- `POST /v1/chat/completions` — forwards your OpenAI-format request to the upstream (with reasoning-content injection for thinking-mode DeepSeek/Kimi models), streams SSE back chunk-by-chunk, or passes through JSON for non-streaming calls.
|
|
10
|
+
- `GET /v1/models` — the ~7 free models (`*-free` plus `big-pickle`), filtered from the full upstream list, cached for 10 minutes.
|
|
11
|
+
- `GET /health` — public liveness check.
|
|
12
|
+
|
|
13
|
+
## Install & run
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
npm install # no deps actually fetched; just links the bin
|
|
17
|
+
mslxdfree # or: node bin/mslxdfree.js
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
First run generates a bearer token, writes it to the state file, and prints it:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
mslxdfree listening on http://localhost:8080
|
|
24
|
+
auth token: 9b5de021e914...
|
|
25
|
+
endpoint: http://localhost:8080/v1
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Daemon (background, stays resident)
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
mslxdfree -d # start detached background daemon
|
|
32
|
+
mslxdfree -stop # stop it
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Logs go to `~/.config/mslxdfree/daemon.log`, the daemon pid to `daemon.pid` (both overridable via `MSLXDFREE_DAEMON_DIR`). The daemon keeps running after your shell exits.
|
|
36
|
+
|
|
37
|
+
### Token
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
mslxdfree -showtoken # print the current token (creates one on first use)
|
|
41
|
+
mslxdfree -refresh-token # rotate it (prints the new token, does not start the server)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Client configuration
|
|
45
|
+
|
|
46
|
+
Point any OpenAI-compatible client at the endpoint with the token:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
Endpoint: http://localhost:8080/v1
|
|
50
|
+
API Key: <the bearer token> (sent as Authorization: Bearer <token>)
|
|
51
|
+
Model: oc/deepseek-v4-flash-free (the oc/ prefix is optional)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
<x-model list>
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
$ curl -H "Authorization: Bearer <token>" http://localhost:8080/v1/models
|
|
58
|
+
{"object":"list","data":[{"id":"big-pickle",...},{"id":"deepseek-v4-flash-free",...}, ...]}
|
|
59
|
+
</x-model list>
|
|
60
|
+
|
|
61
|
+
## Environment variables
|
|
62
|
+
|
|
63
|
+
| Variable | Default | Purpose |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `PORT` | `8080` | listen port |
|
|
66
|
+
| `MSLXDFREE_STATE_FILE` | `~/.config/mslxdfree/state.json` | token state file (mode 0600) |
|
|
67
|
+
| `MSLXDFREE_DAEMON_DIR` | `~/.config/mslxdfree` | daemon pid + log directory |
|
|
68
|
+
| `UPSTREAM_BASE_URL` | `https://opencode.ai` | upstream base |
|
|
69
|
+
| `UPSTREAM_AUTH_TOKEN` | `public` | upstream `Authorization: Bearer <…>` value |
|
|
70
|
+
| `UPSTREAM_CONNECT_TIMEOUT_MS` | `30000` | upstream connect timeout |
|
|
71
|
+
| `LOG_LEVEL` | `info` | (reserved) |
|
|
72
|
+
|
|
73
|
+
## Clients
|
|
74
|
+
|
|
75
|
+
Point your OpenAI client at `http://<host>:8080/v1` (or the equivalent config seen above). Works for streaming and non-streaming chat completions.
|
|
76
|
+
|
|
77
|
+
## Development
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
npm test # node --test, no network access
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The reference implementation is 9Router v0.5.45 (`/root/9router`); see `CLAUDE.md`, `CONTEXT.md`, and `docs/adr/` for the contract and the decisions behind it.
|
package/bin/mslxdfree.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { startServer } from "../src/server.js";
|
|
3
|
+
import { createRouter } from "../src/routes.js";
|
|
4
|
+
import { createUpstreamClient } from "../src/upstream.js";
|
|
5
|
+
import { createModelsService } from "../src/models.js";
|
|
6
|
+
import { loadToken, refreshToken } from "../src/state.js";
|
|
7
|
+
import { startDaemon, stopDaemon, writePid, pidFile, logFile } from "../src/daemon.js";
|
|
8
|
+
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
|
|
11
|
+
if (args.includes("-refresh-token") || args.includes("--refresh-token")) {
|
|
12
|
+
const token = await refreshToken();
|
|
13
|
+
console.log(token);
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (args.includes("-showtoken") || args.includes("--showtoken")) {
|
|
18
|
+
const { token } = await loadToken();
|
|
19
|
+
console.log(token);
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (args.includes("-stop") || args.includes("--stop")) {
|
|
24
|
+
const { stopped, pid, reason } = stopDaemon();
|
|
25
|
+
if (stopped) {
|
|
26
|
+
console.log(`mslxdfree daemon stopped (pid ${pid})`);
|
|
27
|
+
} else {
|
|
28
|
+
console.log(`mslxdfree daemon not running${reason ? ` (${reason})` : ""}`);
|
|
29
|
+
}
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (args.includes("-d") || args.includes("--daemon")) {
|
|
34
|
+
if (!process.env.MSLXDFREE_DAEMON) {
|
|
35
|
+
// foreground: spawn the detached background instance, then wait for health
|
|
36
|
+
const spawnedPid = startDaemon(args.filter((a) => a !== "-d" && a !== "--daemon"));
|
|
37
|
+
await waitForHealth(4000);
|
|
38
|
+
console.log(`mslxdfree daemon started (pid ${spawnedPid})`);
|
|
39
|
+
console.log(`log: ${logFile()}`);
|
|
40
|
+
console.log(`pid: ${pidFile()}`);
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
// we ARE the daemon; stdout/stderr already point at the log file via startDaemon stdio
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { token, created } = await loadToken();
|
|
47
|
+
const upstream = createUpstreamClient({});
|
|
48
|
+
const baseUrl = process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
|
|
49
|
+
const models = createModelsService({ baseUrl, headers: upstream.headers });
|
|
50
|
+
|
|
51
|
+
const router = createRouter({ token, upstream, models });
|
|
52
|
+
const srv = startServer({ router });
|
|
53
|
+
|
|
54
|
+
await srv.ready();
|
|
55
|
+
if (process.env.MSLXDFREE_DAEMON) {
|
|
56
|
+
writePid(process.pid);
|
|
57
|
+
}
|
|
58
|
+
const addr = srv.server.address();
|
|
59
|
+
const host = addr.address === "0.0.0.0" || addr.address === "::" ? "localhost" : addr.address;
|
|
60
|
+
console.log(`mslxdfree listening on http://${host}:${addr.port}`);
|
|
61
|
+
if (created) {
|
|
62
|
+
console.log(`auth token: ${token}`);
|
|
63
|
+
}
|
|
64
|
+
console.log(`endpoint: http://${host}:${addr.port}/v1`);
|
|
65
|
+
|
|
66
|
+
async function waitForHealth(timeoutMs) {
|
|
67
|
+
const start = Date.now();
|
|
68
|
+
const port = Number(process.env.PORT) || 8080;
|
|
69
|
+
while (Date.now() - start < timeoutMs) {
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`);
|
|
72
|
+
if (res.ok) return;
|
|
73
|
+
} catch {
|
|
74
|
+
// not up yet
|
|
75
|
+
}
|
|
76
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
77
|
+
}
|
|
78
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mslxdff",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Standalone OpenCode Free proxy — OpenAI-compatible /v1 gateway to opencode.ai zen (free models)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mslxdfree": "bin/mslxdfree.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/mslxdfree.js",
|
|
11
|
+
"test": "node --test test/"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin/",
|
|
18
|
+
"src/",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"keywords": [
|
|
22
|
+
"opencode",
|
|
23
|
+
"openai",
|
|
24
|
+
"proxy",
|
|
25
|
+
"free",
|
|
26
|
+
"zen"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT"
|
|
29
|
+
}
|
package/src/daemon.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, openSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
export function daemonDir() {
|
|
8
|
+
return process.env.MSLXDFREE_DAEMON_DIR || join(os.homedir(), ".config", "mslxdfree");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function pidFile() {
|
|
12
|
+
return join(daemonDir(), "daemon.pid");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function logFile() {
|
|
16
|
+
return join(daemonDir(), "daemon.log");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function startDaemon(args = []) {
|
|
20
|
+
const here = fileURLToPath(import.meta.url);
|
|
21
|
+
const entry = here.endsWith("bin/mslxdfree.js")
|
|
22
|
+
? here
|
|
23
|
+
: join(dirname(here), "..", "bin", "mslxdfree.js");
|
|
24
|
+
const dir = daemonDir();
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
26
|
+
const logFd = openSync(logFile(), "a", 0o600);
|
|
27
|
+
const child = spawn(process.execPath, [entry, ...args, "--daemon"], {
|
|
28
|
+
detached: true,
|
|
29
|
+
stdio: ["ignore", logFd, logFd],
|
|
30
|
+
env: { ...process.env, MSLXDFREE_DAEMON: "1" },
|
|
31
|
+
});
|
|
32
|
+
child.unref();
|
|
33
|
+
return child.pid;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function writePid(pid) {
|
|
37
|
+
const dir = daemonDir();
|
|
38
|
+
mkdirSync(dir, { recursive: true });
|
|
39
|
+
writeFileSync(pidFile(), String(pid), { mode: 0o600 });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function readPid() {
|
|
43
|
+
if (!existsSync(pidFile())) return null;
|
|
44
|
+
const raw = readFileSync(pidFile(), "utf8").trim();
|
|
45
|
+
const n = Number(raw);
|
|
46
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function stopDaemon() {
|
|
50
|
+
const pid = readPid();
|
|
51
|
+
if (!pid) return { stopped: false, reason: "no pid file" };
|
|
52
|
+
try {
|
|
53
|
+
process.kill(pid, "SIGTERM");
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err.code !== "ESRCH") throw err;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
unlinkSync(pidFile());
|
|
59
|
+
} catch {
|
|
60
|
+
// already gone
|
|
61
|
+
}
|
|
62
|
+
return { stopped: true, pid };
|
|
63
|
+
}
|
package/src/models.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const KNOWN_FREE_OPENCODE_MODELS = ["big-pickle"];
|
|
2
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
3
|
+
|
|
4
|
+
export function isFreeModel(id) {
|
|
5
|
+
return (typeof id === "string" && id.endsWith("-free")) ||
|
|
6
|
+
KNOWN_FREE_OPENCODE_MODELS.includes(id);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function filterFreeModels(list) {
|
|
10
|
+
const seen = new Set();
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const m of list || []) {
|
|
13
|
+
if (!(m && m.id)) continue;
|
|
14
|
+
if (!isFreeModel(m.id)) continue;
|
|
15
|
+
if (seen.has(m.id)) continue;
|
|
16
|
+
seen.add(m.id);
|
|
17
|
+
out.push(m);
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createModelsService({ baseUrl, headers, ttlMs = CACHE_TTL_MS } = {}) {
|
|
23
|
+
let cache = null;
|
|
24
|
+
let fetchedAt = 0;
|
|
25
|
+
let inflight = null;
|
|
26
|
+
|
|
27
|
+
async function get() {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
if (cache && now - fetchedAt < ttlMs) return cache;
|
|
30
|
+
if (inflight) return inflight;
|
|
31
|
+
|
|
32
|
+
inflight = (async () => {
|
|
33
|
+
try {
|
|
34
|
+
const data = await fetchUpstreamModels({ baseUrl, headers });
|
|
35
|
+
cache = data;
|
|
36
|
+
fetchedAt = Date.now();
|
|
37
|
+
return data;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
// serve stale on failure if we have it, else rethrow
|
|
40
|
+
if (cache) return cache;
|
|
41
|
+
throw err;
|
|
42
|
+
} finally {
|
|
43
|
+
inflight = null;
|
|
44
|
+
}
|
|
45
|
+
})();
|
|
46
|
+
return inflight;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { get };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function fetchUpstreamModels({ baseUrl, headers, connectTimeoutMs = 30_000 }) {
|
|
53
|
+
const url = `${baseUrl}/zen/v1/models`;
|
|
54
|
+
for (let attempt = 0; ; attempt++) {
|
|
55
|
+
const res = await attemptFetch(url, headers, connectTimeoutMs);
|
|
56
|
+
if (res instanceof Error) {
|
|
57
|
+
if (attempt < NETWORK_RETRIES) continue;
|
|
58
|
+
throw res;
|
|
59
|
+
}
|
|
60
|
+
if (isRetryable(res.status) && attempt < STATUS_RETRIES) {
|
|
61
|
+
await sleep(2000);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!res.ok) throw new Error(`models fetch failed: HTTP ${res.status}`);
|
|
65
|
+
const json = await res.json().catch(() => ({}));
|
|
66
|
+
const raw = Array.isArray(json) ? json : json.data ?? json.models ?? [];
|
|
67
|
+
return { object: "list", data: filterFreeModels(raw) };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function attemptFetch(url, headers, connectTimeoutMs) {
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
const timer = setTimeout(() => controller.abort(), connectTimeoutMs);
|
|
74
|
+
try {
|
|
75
|
+
return await fetch(url, { headers, signal: controller.signal });
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return err;
|
|
78
|
+
} finally {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isRetryable(status) {
|
|
84
|
+
return status === 429 || status === 502 || status === 503 || status === 504;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sleep(ms) {
|
|
88
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const NETWORK_RETRIES = 2;
|
|
92
|
+
const STATUS_RETRIES = 2;
|
package/src/reasoning.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const PLACEHOLDER = " ";
|
|
2
|
+
|
|
3
|
+
const MODEL_RULES = [
|
|
4
|
+
{ match: (m) => /^kimi-/i.test(m || ""), scope: "toolCalls" },
|
|
5
|
+
{ match: (m) => /deepseek/i.test(m || ""), scope: "all" },
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
export function normalizeModel(model) {
|
|
9
|
+
return model.startsWith("oc/") ? model.slice(3) : model;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function shouldInject(message, scope) {
|
|
13
|
+
if (message?.role !== "assistant") return false;
|
|
14
|
+
const rc = message.reasoning_content;
|
|
15
|
+
if (typeof rc === "string" && rc.length > 0) return false;
|
|
16
|
+
if (scope === "toolCalls") {
|
|
17
|
+
return Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function applyRule(body, rule) {
|
|
23
|
+
if (!rule || !body?.messages) return body;
|
|
24
|
+
const messages = body.messages.map((m) =>
|
|
25
|
+
shouldInject(m, rule.scope) ? { ...m, reasoning_content: PLACEHOLDER } : m
|
|
26
|
+
);
|
|
27
|
+
return { ...body, messages };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function injectReasoningContent(model, body) {
|
|
31
|
+
const rule = MODEL_RULES.find((r) => r.match(model));
|
|
32
|
+
return applyRule(body, rule);
|
|
33
|
+
}
|
package/src/routes.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { timingSafeEqual, createHash } from "node:crypto";
|
|
2
|
+
import { injectReasoningContent, normalizeModel } from "./reasoning.js";
|
|
3
|
+
|
|
4
|
+
export const errMsg = (err) => String(err?.message || err);
|
|
5
|
+
|
|
6
|
+
export function createRouter({ token, upstream, models }) {
|
|
7
|
+
return async function router(req, res) {
|
|
8
|
+
const method = req.method || "GET";
|
|
9
|
+
const path = (req.url || "").split("?")[0];
|
|
10
|
+
|
|
11
|
+
const route = ROUTES.find((r) => r.method === method && r.path === path);
|
|
12
|
+
if (!route) return notFound(res);
|
|
13
|
+
|
|
14
|
+
if (route.requiresAuth && !authorized(req, token)) {
|
|
15
|
+
res.statusCode = 401;
|
|
16
|
+
res.setHeader("WWW-Authenticate", "Bearer");
|
|
17
|
+
return json(res, 401, { error: "Unauthorized" });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
await route.handler({ req, res, upstream, models });
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function authorized(req, token) {
|
|
25
|
+
const header = req.headers["authorization"] || "";
|
|
26
|
+
const match = /^Bearer (.+)$/.exec(header);
|
|
27
|
+
if (!match) return false;
|
|
28
|
+
const digests = (s) => createHash("sha256").update(s).digest();
|
|
29
|
+
return timingSafeEqual(digests(match[1]), digests(token));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function json(res, status, body) {
|
|
33
|
+
res.statusCode = status;
|
|
34
|
+
res.setHeader("Content-Type", "application/json");
|
|
35
|
+
res.end(JSON.stringify(body));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function notFound(res) {
|
|
39
|
+
return json(res, 404, { error: "Not Found" });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readBody(req) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
let data = "";
|
|
45
|
+
req.on("data", (c) => (data += c));
|
|
46
|
+
req.on("end", () => {
|
|
47
|
+
try {
|
|
48
|
+
resolve(data ? JSON.parse(data) : {});
|
|
49
|
+
} catch (err) {
|
|
50
|
+
reject(err);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
req.on("error", reject);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const ROUTES = [
|
|
58
|
+
{
|
|
59
|
+
method: "GET",
|
|
60
|
+
path: "/health",
|
|
61
|
+
handler: ({ res }) => json(res, 200, { status: "ok" }),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
method: "POST",
|
|
65
|
+
path: "/v1/chat/completions",
|
|
66
|
+
requiresAuth: true,
|
|
67
|
+
handler: async ({ req, res, upstream }) => {
|
|
68
|
+
let body;
|
|
69
|
+
try {
|
|
70
|
+
body = await readBody(req);
|
|
71
|
+
} catch {
|
|
72
|
+
return json(res, 400, { error: "Invalid JSON body" });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const model = normalizeModel(body.model || "");
|
|
76
|
+
const forwarded = { ...injectReasoningContent(model, body), model };
|
|
77
|
+
let upRes;
|
|
78
|
+
try {
|
|
79
|
+
upRes = await upstream.chat(forwarded);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
return json(res, 502, { error: errMsg(err) });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const contentType = upRes.headers.get("content-type") || "";
|
|
85
|
+
const isStream = Boolean(body.stream) || contentType.includes("text/event-stream");
|
|
86
|
+
res.statusCode = upRes.status;
|
|
87
|
+
|
|
88
|
+
if (isStream) {
|
|
89
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
90
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
91
|
+
res.setHeader("Connection", "keep-alive");
|
|
92
|
+
if (upRes.body) {
|
|
93
|
+
for await (const chunk of upRes.body) {
|
|
94
|
+
res.write(chunk);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
res.end();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const text = await upRes.text();
|
|
102
|
+
try {
|
|
103
|
+
json(res, upRes.status, JSON.parse(text));
|
|
104
|
+
} catch {
|
|
105
|
+
res.statusCode = upRes.status;
|
|
106
|
+
res.setHeader("Content-Type", contentType || "text/plain");
|
|
107
|
+
res.end(text);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
method: "GET",
|
|
113
|
+
path: "/v1/models",
|
|
114
|
+
requiresAuth: true,
|
|
115
|
+
handler: async ({ res, models }) => {
|
|
116
|
+
if (!models) return json(res, 501, { error: "Models service not configured" });
|
|
117
|
+
try {
|
|
118
|
+
const data = await models.get();
|
|
119
|
+
json(res, 200, data);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
json(res, 502, { error: errMsg(err) });
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
];
|
package/src/server.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createServer as httpCreateServer } from "node:http";
|
|
2
|
+
|
|
3
|
+
export function startServer({ router }, port = Number(process.env.PORT) || 8080) {
|
|
4
|
+
const server = httpCreateServer((req, res) => {
|
|
5
|
+
router(req, res).catch((err) => {
|
|
6
|
+
res.statusCode = 500;
|
|
7
|
+
res.setHeader("Content-Type", "application/json");
|
|
8
|
+
res.end(JSON.stringify({ error: String(err?.message || err) }));
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const ready = () =>
|
|
13
|
+
new Promise((resolve, reject) => {
|
|
14
|
+
server.on("error", reject);
|
|
15
|
+
server.listen(port, resolve);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const close = () =>
|
|
19
|
+
new Promise((resolve) => {
|
|
20
|
+
server.close(resolve);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
process.on("SIGINT", close);
|
|
24
|
+
process.on("SIGTERM", close);
|
|
25
|
+
|
|
26
|
+
return { server, ready, close };
|
|
27
|
+
}
|
package/src/state.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
|
|
6
|
+
export function defaultStateFile() {
|
|
7
|
+
return process.env.MSLXDFREE_STATE_FILE ||
|
|
8
|
+
join(os.homedir(), ".config", "mslxdfree", "state.json");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function generateToken() {
|
|
12
|
+
return randomBytes(32).toString("hex");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function loadToken({ file = defaultStateFile() } = {}) {
|
|
16
|
+
try {
|
|
17
|
+
const saved = JSON.parse(readFileSync(file, "utf8"));
|
|
18
|
+
if (typeof saved.token === "string" && saved.token.length > 0) {
|
|
19
|
+
return { token: saved.token, created: false };
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
// missing or unreadable → generate fresh below
|
|
23
|
+
}
|
|
24
|
+
return { token: writeToken(file), created: true };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function refreshToken({ file = defaultStateFile() } = {}) {
|
|
28
|
+
return writeToken(file);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeToken(file) {
|
|
32
|
+
const state = {
|
|
33
|
+
token: generateToken(),
|
|
34
|
+
createdAt: new Date().toISOString(),
|
|
35
|
+
};
|
|
36
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
37
|
+
writeFileSync(file, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
38
|
+
return state.token;
|
|
39
|
+
}
|
package/src/upstream.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export function createUpstreamClient({
|
|
2
|
+
baseUrl = process.env.UPSTREAM_BASE_URL || "https://opencode.ai",
|
|
3
|
+
authToken = process.env.UPSTREAM_AUTH_TOKEN || "public",
|
|
4
|
+
connectTimeoutMs = Number(process.env.UPSTREAM_CONNECT_TIMEOUT_MS) || 30_000,
|
|
5
|
+
retry = {
|
|
6
|
+
network: { attempts: 2, delayMs: 1000 },
|
|
7
|
+
429: { attempts: 2, delayMs: 2000 },
|
|
8
|
+
502: { attempts: 2, delayMs: 2000 },
|
|
9
|
+
503: { attempts: 2, delayMs: 2000 },
|
|
10
|
+
504: { attempts: 2, delayMs: 3000 },
|
|
11
|
+
},
|
|
12
|
+
fetchImpl = fetch,
|
|
13
|
+
} = {}) {
|
|
14
|
+
const headers = {
|
|
15
|
+
"Content-Type": "application/json",
|
|
16
|
+
"Authorization": `Bearer ${authToken}`,
|
|
17
|
+
"x-opencode-client": "desktop",
|
|
18
|
+
"Accept": "text/event-stream",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
async function chat(body) {
|
|
22
|
+
const url = `${baseUrl}/zen/v1/chat/completions`;
|
|
23
|
+
for (let attempt = 0; ; attempt++) {
|
|
24
|
+
const result = await attemptOnce(url, body);
|
|
25
|
+
if (result instanceof Error) {
|
|
26
|
+
const entry = retry?.network;
|
|
27
|
+
if (entry && attempt < entry.attempts) {
|
|
28
|
+
await sleep(entry.delayMs);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
throw result;
|
|
32
|
+
}
|
|
33
|
+
const entry = retry?.[result.status];
|
|
34
|
+
if (entry && attempt < entry.attempts) {
|
|
35
|
+
await sleep(entry.delayMs);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function attemptOnce(url, body) {
|
|
43
|
+
const controller = new AbortController();
|
|
44
|
+
const timer = setTimeout(() =>
|
|
45
|
+
controller.abort(new Error(`upstream timed out after ${connectTimeoutMs}ms`)),
|
|
46
|
+
connectTimeoutMs
|
|
47
|
+
);
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetchImpl(url, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers,
|
|
52
|
+
body: JSON.stringify(body),
|
|
53
|
+
signal: controller.signal,
|
|
54
|
+
});
|
|
55
|
+
return res;
|
|
56
|
+
} catch (err) {
|
|
57
|
+
return err;
|
|
58
|
+
} finally {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { chat, headers };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sleep(ms) {
|
|
67
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
68
|
+
}
|