mslxdff 0.1.2 → 0.1.3
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 +157 -94
- package/bin/mslxdff.js +466 -4
- package/package.json +26 -26
- package/src/auto.js +99 -0
- package/src/daemon.js +63 -63
- package/src/groups.js +189 -0
- package/src/logs.js +80 -0
- package/src/models.js +127 -91
- package/src/peers.js +83 -0
- package/src/reasoning.js +32 -32
- package/src/routes.js +309 -125
- package/src/server.js +37 -36
- package/src/state.js +119 -53
- package/src/upstream.js +67 -67
package/src/daemon.js
CHANGED
|
@@ -1,63 +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.MSLXDFF_DAEMON_DIR || join(os.homedir(), ".config", "mslxdff");
|
|
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/mslxdff.js")
|
|
22
|
-
? here
|
|
23
|
-
: join(dirname(here), "..", "bin", "mslxdff.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, MSLXDFF_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
|
-
}
|
|
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.MSLXDFF_DAEMON_DIR || join(os.homedir(), ".config", "mslxdff");
|
|
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/mslxdff.js")
|
|
22
|
+
? here
|
|
23
|
+
: join(dirname(here), "..", "bin", "mslxdff.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, MSLXDFF_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/groups.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { timingSafeEqual, createHash } from "node:crypto";
|
|
2
|
+
import { loadGroups, saveGroups, loadBans, saveBans } from "./state.js";
|
|
3
|
+
import { normalizePeerUrl } from "./peers.js";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_GROUP_SYNC_MS = 60_000;
|
|
6
|
+
export const DEFAULT_BAN_WINDOW_MS = 48 * 60 * 60 * 1000;
|
|
7
|
+
export const DEFAULT_BAN_THRESHOLD = 5;
|
|
8
|
+
const SYNC_TIMEOUT_MS = 15_000;
|
|
9
|
+
|
|
10
|
+
function digestKey(key) {
|
|
11
|
+
return createHash("sha256").update(String(key || "")).digest();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function verifyGroupKey(provided, expected) {
|
|
15
|
+
if (typeof provided !== "string" || typeof expected !== "string") return false;
|
|
16
|
+
return timingSafeEqual(digestKey(provided), digestKey(expected));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Leader side: create a group. The group name IS the password — anyone who
|
|
20
|
+
// knows the name can join, so pick something unguessable.
|
|
21
|
+
export function createGroup(name, { file, key = name } = {}) {
|
|
22
|
+
if (!name) throw new Error("group name is required");
|
|
23
|
+
const groups = loadGroups(file ? { file } : {});
|
|
24
|
+
if (groups[name]) return { name, key: groups[name].key, created: false };
|
|
25
|
+
groups[name] = { key, members: {} };
|
|
26
|
+
saveGroups(groups, file ? { file } : {});
|
|
27
|
+
return { name, key, created: true };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Leader side: add a member after verifying the join key.
|
|
31
|
+
export function addGroupMember(name, { key, memberName, url, token, file } = {}) {
|
|
32
|
+
const groups = loadGroups(file ? { file } : {});
|
|
33
|
+
const group = groups[name];
|
|
34
|
+
if (!group) throw new Error(`group "${name}" not found on this node`);
|
|
35
|
+
if (!verifyGroupKey(key, group.key)) throw new Error("invalid group key");
|
|
36
|
+
if (!url) throw new Error("member url is required");
|
|
37
|
+
const id = memberName || url;
|
|
38
|
+
group.members[id] = { url, token: token || "" };
|
|
39
|
+
saveGroups(groups, file ? { file } : {});
|
|
40
|
+
return group.members;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Leader side: upsert a member without key verification (used by the sync
|
|
44
|
+
// path after the member's bearer token has already been validated).
|
|
45
|
+
export function upsertMember(name, { memberName, url, token, file } = {}) {
|
|
46
|
+
const groups = loadGroups(file ? { file } : {});
|
|
47
|
+
const group = groups[name];
|
|
48
|
+
if (!group) return null;
|
|
49
|
+
if (!url) throw new Error("member url is required");
|
|
50
|
+
const id = memberName || url;
|
|
51
|
+
group.members[id] = { url, token: token || "" };
|
|
52
|
+
saveGroups(groups, file ? { file } : {});
|
|
53
|
+
return group.members;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Leader side: confirm a bearer token matches a registered member, then return
|
|
57
|
+
// the member list. Used by the sync path (registered members re-registering).
|
|
58
|
+
export function membersForToken(name, providedToken, { file } = {}) {
|
|
59
|
+
if (typeof providedToken !== "string" || !providedToken) return null;
|
|
60
|
+
const groups = loadGroups(file ? { file } : {});
|
|
61
|
+
const group = groups[name];
|
|
62
|
+
if (!group) return null;
|
|
63
|
+
const hit = Object.entries(group.members || {}).find(([, m]) =>
|
|
64
|
+
typeof m.token === "string" && m.token.length > 0 &&
|
|
65
|
+
timingSafeEqual(digestKey(m.token), digestKey(providedToken)));
|
|
66
|
+
return hit ? { member: hit[1], members: group.members } : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Leader side: list members (requires the join key).
|
|
70
|
+
export function listGroupMembers(name, { key, file } = {}) {
|
|
71
|
+
const groups = loadGroups(file ? { file } : {});
|
|
72
|
+
const group = groups[name];
|
|
73
|
+
if (!group) throw new Error(`group "${name}" not found on this node`);
|
|
74
|
+
if (!verifyGroupKey(key, group.key)) throw new Error("invalid group key");
|
|
75
|
+
return group.members;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function listGroups({ file } = {}) {
|
|
79
|
+
return loadGroups(file ? { file } : {});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createGroupsService({ file } = {}) {
|
|
83
|
+
const opts = (o = {}) => (file ? { ...o, file } : o);
|
|
84
|
+
return {
|
|
85
|
+
create: (name, o = {}) => createGroup(name, opts(o)),
|
|
86
|
+
addMember: (name, o = {}) => addGroupMember(name, opts(o)),
|
|
87
|
+
upsertMember: (name, o = {}) => upsertMember(name, opts(o)),
|
|
88
|
+
listMembers: (name, o = {}) => listGroupMembers(name, opts(o)),
|
|
89
|
+
membersForToken: (name, token) => membersForToken(name, token, opts()),
|
|
90
|
+
list: () => listGroups(opts()),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Re-register with the leader (join is idempotent) and return the fresh member list.
|
|
95
|
+
// No key is needed once registered: the leader verifies our bearer token.
|
|
96
|
+
export async function refreshGroupMembers(name, { leaderUrl, memberName, url, token, fetchImpl = fetch } = {}) {
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS);
|
|
99
|
+
try {
|
|
100
|
+
const res = await fetchImpl(`${leaderUrl}/v1/groups/join`, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: {
|
|
103
|
+
"Content-Type": "application/json",
|
|
104
|
+
"Authorization": `Bearer ${token}`,
|
|
105
|
+
},
|
|
106
|
+
body: JSON.stringify({ name, memberName, url, token }),
|
|
107
|
+
signal: controller.signal,
|
|
108
|
+
});
|
|
109
|
+
if (!res.ok) {
|
|
110
|
+
const text = await res.text().catch(() => "");
|
|
111
|
+
throw new Error(`group sync failed (HTTP ${res.status}): ${text}`);
|
|
112
|
+
}
|
|
113
|
+
const data = await res.json();
|
|
114
|
+
return data.members || {};
|
|
115
|
+
} finally {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Merge a member map into the local peer list, replacing the previous snapshot
|
|
121
|
+
// of this group and skipping ourselves. Used by both leaders (local groups) and
|
|
122
|
+
// members (leader-pulled members).
|
|
123
|
+
export function syncPeersFromMembers({ peers, members, myUrl, group, skipIds = [] }) {
|
|
124
|
+
const self = normalizePeerUrl(myUrl);
|
|
125
|
+
const skip = new Set(skipIds);
|
|
126
|
+
const removed = peers.removeByGroup(group);
|
|
127
|
+
let added = 0;
|
|
128
|
+
for (const [id, m] of Object.entries(members || {})) {
|
|
129
|
+
if (skip.has(id)) continue;
|
|
130
|
+
const url = normalizePeerUrl(m?.url);
|
|
131
|
+
if (!url || url === self) continue;
|
|
132
|
+
if (peers.add({ name: id, url, token: m?.token || "", group })) added++;
|
|
133
|
+
}
|
|
134
|
+
return { removed, added, total: Object.keys(members || {}).length };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---- join-failure bans (per source IP) ----
|
|
138
|
+
|
|
139
|
+
export function createBansService({ file, now = () => Date.now(), windowMs = DEFAULT_BAN_WINDOW_MS, threshold = DEFAULT_BAN_THRESHOLD } = {}) {
|
|
140
|
+
let bans = loadBans(file ? { file } : {});
|
|
141
|
+
|
|
142
|
+
function prune() {
|
|
143
|
+
let changed = false;
|
|
144
|
+
for (const [ip, b] of Object.entries(bans)) {
|
|
145
|
+
if (b.bannedAt !== undefined && now() - b.bannedAt >= windowMs) {
|
|
146
|
+
delete bans[ip];
|
|
147
|
+
changed = true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (changed) saveBans(bans, file ? { file } : {});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isBanned(ip) {
|
|
154
|
+
if (!ip) return false;
|
|
155
|
+
prune();
|
|
156
|
+
const b = bans[ip];
|
|
157
|
+
if (b?.bannedAt === undefined) return false;
|
|
158
|
+
return { bannedAt: b.bannedAt, until: b.bannedAt + windowMs };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function recordFailure(ip) {
|
|
162
|
+
if (!ip) return null;
|
|
163
|
+
prune();
|
|
164
|
+
const b = (bans[ip] = bans[ip] || { fails: 0 });
|
|
165
|
+
b.fails = (b.fails || 0) + 1;
|
|
166
|
+
if (b.fails >= threshold) {
|
|
167
|
+
b.bannedAt = now();
|
|
168
|
+
b.fails = 0;
|
|
169
|
+
}
|
|
170
|
+
saveBans(bans, file ? { file } : {});
|
|
171
|
+
return b.bannedAt !== undefined ? b : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function clear(ip) {
|
|
175
|
+
if (ip) {
|
|
176
|
+
delete bans[ip];
|
|
177
|
+
} else {
|
|
178
|
+
bans = {};
|
|
179
|
+
}
|
|
180
|
+
saveBans(bans, file ? { file } : {});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function list() {
|
|
184
|
+
prune();
|
|
185
|
+
return { ...bans };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { isBanned, recordFailure, clear, list, windowMs, threshold };
|
|
189
|
+
}
|
package/src/logs.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { appendFileSync, readFileSync, mkdirSync, existsSync, writeFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { defaultStateFile } from "./state.js";
|
|
5
|
+
|
|
6
|
+
const MAX_CALLS = 500;
|
|
7
|
+
const MAX_ERRORS = 200;
|
|
8
|
+
const MAX_BYTES = 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
export function logDir() {
|
|
11
|
+
return process.env.MSLXDFF_DAEMON_DIR || dirname(defaultStateFile());
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function callsFile() {
|
|
15
|
+
return join(logDir(), "calls.log");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function errorsFile() {
|
|
19
|
+
return join(logDir(), "errors.log");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function ensureDir(dir) {
|
|
23
|
+
mkdirSync(dir, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function trimIfOversized(file, maxBytes = MAX_BYTES) {
|
|
27
|
+
try {
|
|
28
|
+
const st = statSync(file);
|
|
29
|
+
if (st.size <= maxBytes) return;
|
|
30
|
+
const text = readFileSync(file, "utf8");
|
|
31
|
+
const lines = text.split("\n");
|
|
32
|
+
const keep = lines.slice(-100);
|
|
33
|
+
writeFileSync(file, keep.join("\n"));
|
|
34
|
+
} catch {
|
|
35
|
+
// ignore
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function appendLine(file, entry) {
|
|
40
|
+
ensureDir(dirname(file));
|
|
41
|
+
appendFileSync(file, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n");
|
|
42
|
+
trimIfOversized(file);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function appendCall(entry, { file = callsFile() } = {}) {
|
|
46
|
+
appendLine(file, entry);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function appendError(entry, { file = errorsFile() } = {}) {
|
|
50
|
+
appendLine(file, entry);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readLines(file) {
|
|
54
|
+
try {
|
|
55
|
+
if (!existsSync(file)) return [];
|
|
56
|
+
const text = readFileSync(file, "utf8");
|
|
57
|
+
return text.split("\n").filter(Boolean).map((l) => {
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(l);
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}).filter(Boolean);
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function recentCalls(n = 5, { file = callsFile() } = {}) {
|
|
70
|
+
return readLines(file).slice(-n);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function lastError({ file = errorsFile() } = {}) {
|
|
74
|
+
const lines = readLines(file);
|
|
75
|
+
return lines.length ? lines[lines.length - 1] : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function recentErrors(n = 5, { file = errorsFile() } = {}) {
|
|
79
|
+
return readLines(file).slice(-n);
|
|
80
|
+
}
|
package/src/models.js
CHANGED
|
@@ -1,92 +1,128 @@
|
|
|
1
|
-
const KNOWN_FREE_OPENCODE_MODELS = ["big-pickle"];
|
|
2
|
-
const CACHE_TTL_MS =
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
1
|
+
const KNOWN_FREE_OPENCODE_MODELS = ["big-pickle"];
|
|
2
|
+
const CACHE_TTL_MS = 2 * 60 * 60 * 1000;
|
|
3
|
+
const DEFAULT_REFRESH_MS = 2 * 60 * 60 * 1000;
|
|
4
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname } from "node:path";
|
|
6
|
+
|
|
7
|
+
export function isFreeModel(id) {
|
|
8
|
+
return (typeof id === "string" && id.endsWith("-free")) ||
|
|
9
|
+
KNOWN_FREE_OPENCODE_MODELS.includes(id);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function filterFreeModels(list) {
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const m of list || []) {
|
|
16
|
+
if (!(m && m.id)) continue;
|
|
17
|
+
if (!isFreeModel(m.id)) continue;
|
|
18
|
+
if (seen.has(m.id)) continue;
|
|
19
|
+
seen.add(m.id);
|
|
20
|
+
out.push(m);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createModelsService({ baseUrl, headers, ttlMs = CACHE_TTL_MS, refreshMs = DEFAULT_REFRESH_MS, cacheFile } = {}) {
|
|
26
|
+
let cache = null;
|
|
27
|
+
let fetchedAt = 0;
|
|
28
|
+
let inflight = null;
|
|
29
|
+
let timer = null;
|
|
30
|
+
|
|
31
|
+
async function load() {
|
|
32
|
+
const data = await fetchUpstreamModels({ baseUrl, headers });
|
|
33
|
+
cache = data;
|
|
34
|
+
fetchedAt = Date.now();
|
|
35
|
+
if (cacheFile) persistModels(data, cacheFile);
|
|
36
|
+
return data;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function get() {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
if (cache && now - fetchedAt < ttlMs) return cache;
|
|
42
|
+
if (inflight) return inflight;
|
|
43
|
+
|
|
44
|
+
inflight = (async () => {
|
|
45
|
+
try {
|
|
46
|
+
return await load();
|
|
47
|
+
} catch (err) {
|
|
48
|
+
// serve stale on failure if we have it, else rethrow
|
|
49
|
+
if (cache) return cache;
|
|
50
|
+
throw err;
|
|
51
|
+
} finally {
|
|
52
|
+
inflight = null;
|
|
53
|
+
}
|
|
54
|
+
})();
|
|
55
|
+
return inflight;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function startAutoRefresh(intervalMs = refreshMs) {
|
|
59
|
+
if (timer) return stopAutoRefresh;
|
|
60
|
+
timer = setInterval(() => {
|
|
61
|
+
void load().catch(() => {
|
|
62
|
+
// keep serving stale cache on background refresh failure
|
|
63
|
+
});
|
|
64
|
+
}, intervalMs);
|
|
65
|
+
timer.unref?.();
|
|
66
|
+
return stopAutoRefresh;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function stopAutoRefresh() {
|
|
70
|
+
if (timer) {
|
|
71
|
+
clearInterval(timer);
|
|
72
|
+
timer = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { get, startAutoRefresh, stopAutoRefresh };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function fetchUpstreamModels({ baseUrl, headers, connectTimeoutMs = 30_000 }) {
|
|
80
|
+
const url = `${baseUrl}/zen/v1/models`;
|
|
81
|
+
for (let attempt = 0; ; attempt++) {
|
|
82
|
+
const res = await attemptFetch(url, headers, connectTimeoutMs);
|
|
83
|
+
if (res instanceof Error) {
|
|
84
|
+
if (attempt < NETWORK_RETRIES) continue;
|
|
85
|
+
throw res;
|
|
86
|
+
}
|
|
87
|
+
if (isRetryable(res.status) && attempt < STATUS_RETRIES) {
|
|
88
|
+
await sleep(2000);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!res.ok) throw new Error(`models fetch failed: HTTP ${res.status}`);
|
|
92
|
+
const json = await res.json().catch(() => ({}));
|
|
93
|
+
const raw = Array.isArray(json) ? json : json.data ?? json.models ?? [];
|
|
94
|
+
return { object: "list", data: filterFreeModels(raw) };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function attemptFetch(url, headers, connectTimeoutMs) {
|
|
99
|
+
const controller = new AbortController();
|
|
100
|
+
const timer = setTimeout(() => controller.abort(), connectTimeoutMs);
|
|
101
|
+
try {
|
|
102
|
+
return await fetch(url, { headers, signal: controller.signal });
|
|
103
|
+
} catch (err) {
|
|
104
|
+
return err;
|
|
105
|
+
} finally {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isRetryable(status) {
|
|
111
|
+
return status === 429 || status === 502 || status === 503 || status === 504;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function persistModels(data, cacheFile) {
|
|
115
|
+
try {
|
|
116
|
+
mkdirSync(dirname(cacheFile), { recursive: true });
|
|
117
|
+
writeFileSync(cacheFile, JSON.stringify({ cachedAt: Date.now(), ...data }));
|
|
118
|
+
} catch {
|
|
119
|
+
// persistence is best-effort
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sleep(ms) {
|
|
124
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const NETWORK_RETRIES = 2;
|
|
92
128
|
const STATUS_RETRIES = 2;
|