@nvae/llmswitch 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.
@@ -0,0 +1,240 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { normalizeBaseUrlForFormat } from "../utils/base-url.js";
5
+ import { bridgeBaseUrl, bridgeRootUrl, getBridgePidPath, readBridgeState, readBridgeUpstreams, writeBridgeState, writeBridgeUpstream, } from "./state.js";
6
+ import { DEFAULT_BRIDGE_HOST, DEFAULT_BRIDGE_PORT, emptyUpstreams, hasAnyUpstream, } from "./types.js";
7
+ import { listenBridge } from "./server.js";
8
+ export function profileNeedsBridge(profile) {
9
+ return profile.apiFormat === "openai-chat";
10
+ }
11
+ export function bridgeToolForCliTool(tool) {
12
+ if (tool === "codex" || tool === "claude")
13
+ return tool;
14
+ return null;
15
+ }
16
+ export function upstreamFromProfile(profile, tool) {
17
+ const mode = tool === "claude" ? "chat" : profile.bridgeMode || "chat";
18
+ return {
19
+ baseUrl: normalizeBaseUrlForFormat(profile.apiFormat, profile.baseUrl),
20
+ apiKey: profile.apiKey,
21
+ mode,
22
+ proxy: profile.proxy,
23
+ headers: profile.headers,
24
+ profileName: profile.name,
25
+ updatedAt: new Date().toISOString(),
26
+ };
27
+ }
28
+ /**
29
+ * Probe local bridge. Old builds may return 503 without `upstreams` — treat as
30
+ * reachable but not healthy so callers can restart.
31
+ */
32
+ export async function probeBridge(host = readBridgeState().host, port = readBridgeState().port) {
33
+ try {
34
+ const res = await fetch(`http://${host}:${port}/health`, {
35
+ signal: AbortSignal.timeout(800),
36
+ });
37
+ let body = null;
38
+ try {
39
+ body = (await res.json());
40
+ }
41
+ catch {
42
+ body = null;
43
+ }
44
+ const healthy = res.ok && body?.ok === true && body != null && "upstreams" in body;
45
+ return { reachable: true, healthy };
46
+ }
47
+ catch {
48
+ return { reachable: false, healthy: false };
49
+ }
50
+ }
51
+ export async function isBridgeAlive(host = readBridgeState().host, port = readBridgeState().port) {
52
+ return (await probeBridge(host, port)).healthy;
53
+ }
54
+ export function readPid() {
55
+ const path = getBridgePidPath();
56
+ if (!existsSync(path))
57
+ return null;
58
+ const n = Number(readFileSync(path, "utf8").trim());
59
+ return Number.isFinite(n) ? n : null;
60
+ }
61
+ export function isPidRunning(pid) {
62
+ try {
63
+ process.kill(pid, 0);
64
+ return true;
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
70
+ function urlForTool(tool, host, port, pid) {
71
+ const upstreams = readBridgeUpstreams();
72
+ const state = { host, port, upstreams, pid };
73
+ return tool === "claude" ? bridgeRootUrl(state) : bridgeBaseUrl(state);
74
+ }
75
+ /**
76
+ * Configure per-tool upstream and ensure local bridge is listening.
77
+ * Returns Codex base (`…/v1`) or Claude root (`…` without /v1).
78
+ */
79
+ export async function ensureBridgeForProfile(profile, tool) {
80
+ const upstream = upstreamFromProfile(profile, tool);
81
+ writeBridgeUpstream(tool, upstream);
82
+ const state = readBridgeState();
83
+ const host = state.host || DEFAULT_BRIDGE_HOST;
84
+ const port = Number(process.env.LLM_SWITCH_BRIDGE_PORT) ||
85
+ state.port ||
86
+ DEFAULT_BRIDGE_PORT;
87
+ const upstreams = readBridgeUpstreams();
88
+ writeBridgeState({
89
+ ...state,
90
+ host,
91
+ port,
92
+ upstreams,
93
+ pid: state.pid,
94
+ });
95
+ const probe = await probeBridge(host, port);
96
+ if (probe.healthy) {
97
+ return urlForTool(tool, host, port, state.pid);
98
+ }
99
+ // Stale / incompatible process holding the port (e.g. pre-dual-upstream build).
100
+ if (probe.reachable) {
101
+ await forceStopBridge(host, port);
102
+ }
103
+ await startBridgeDaemon(host, port);
104
+ for (let i = 0; i < 50; i++) {
105
+ if (await isBridgeAlive(host, port)) {
106
+ return urlForTool(tool, host, port, readPid());
107
+ }
108
+ await new Promise((r) => setTimeout(r, 100));
109
+ }
110
+ throw new Error(`Bridge 启动超时(${host}:${port})。可手动运行:llms bridge serve`);
111
+ }
112
+ export async function clearBridgeUpstream(tool) {
113
+ writeBridgeUpstream(tool, null);
114
+ const upstreams = readBridgeUpstreams();
115
+ if (!hasAnyUpstream(upstreams)) {
116
+ await stopBridge();
117
+ }
118
+ }
119
+ export async function startBridgeDaemon(host = DEFAULT_BRIDGE_HOST, port = DEFAULT_BRIDGE_PORT) {
120
+ if (await isBridgeAlive(host, port)) {
121
+ return readPid() || 0;
122
+ }
123
+ const probe = await probeBridge(host, port);
124
+ if (probe.reachable) {
125
+ await forceStopBridge(host, port);
126
+ }
127
+ const entry = resolveCliEntry();
128
+ const child = spawn(process.execPath, [entry, "bridge", "serve", "--host", host, "--port", String(port)], {
129
+ detached: true,
130
+ stdio: "ignore",
131
+ env: {
132
+ ...process.env,
133
+ LLM_SWITCH_BRIDGE_PORT: String(port),
134
+ LLM_SWITCH_BRIDGE_HOST: host,
135
+ },
136
+ });
137
+ child.unref();
138
+ const pid = child.pid;
139
+ if (!pid)
140
+ throw new Error("无法启动 bridge 进程");
141
+ const state = readBridgeState();
142
+ writeBridgeState({
143
+ ...state,
144
+ host,
145
+ port,
146
+ pid,
147
+ upstreams: state.upstreams,
148
+ });
149
+ return pid;
150
+ }
151
+ /** Stop by recorded pid, then free the listen port if still held. */
152
+ export async function forceStopBridge(host = readBridgeState().host, port = readBridgeState().port) {
153
+ await stopBridge();
154
+ await killListenersOnPort(port);
155
+ // Brief wait so TIME_WAIT / bind release settles.
156
+ for (let i = 0; i < 20; i++) {
157
+ const probe = await probeBridge(host, port);
158
+ if (!probe.reachable)
159
+ return;
160
+ await new Promise((r) => setTimeout(r, 50));
161
+ }
162
+ }
163
+ export async function stopBridge() {
164
+ const state = readBridgeState();
165
+ const pid = state.pid || readPid();
166
+ let stopped = false;
167
+ if (pid && isPidRunning(pid)) {
168
+ try {
169
+ process.kill(pid, "SIGTERM");
170
+ stopped = true;
171
+ }
172
+ catch {
173
+ // ignore
174
+ }
175
+ }
176
+ writeBridgeState({
177
+ ...state,
178
+ pid: null,
179
+ upstreams: state.upstreams,
180
+ });
181
+ return stopped;
182
+ }
183
+ function killListenersOnPort(port) {
184
+ if (process.platform === "win32")
185
+ return;
186
+ try {
187
+ const out = execFileSync("lsof", ["-ti", `tcp:${port}`, `-sTCP:LISTEN`], { encoding: "utf8" });
188
+ for (const line of out.split(/\n/)) {
189
+ const pid = Number(line.trim());
190
+ if (!pid || pid === process.pid)
191
+ continue;
192
+ try {
193
+ process.kill(pid, "SIGTERM");
194
+ }
195
+ catch {
196
+ // ignore
197
+ }
198
+ }
199
+ }
200
+ catch {
201
+ // lsof miss / no listeners — ignore
202
+ }
203
+ }
204
+ export async function runBridgeForeground(host, port) {
205
+ const state = readBridgeState();
206
+ writeBridgeState({
207
+ ...state,
208
+ host,
209
+ port,
210
+ pid: process.pid,
211
+ upstreams: state.upstreams.codex || state.upstreams.claude
212
+ ? state.upstreams
213
+ : readBridgeUpstreams() || emptyUpstreams(),
214
+ });
215
+ const server = await listenBridge(port, host);
216
+ const shutdown = () => {
217
+ server.close(() => process.exit(0));
218
+ };
219
+ process.on("SIGINT", shutdown);
220
+ process.on("SIGTERM", shutdown);
221
+ console.error(`llm-switch bridge listening on http://${host}:${port} (POST /v1/responses · POST /v1/messages → upstream chat/completions)`);
222
+ await new Promise(() => undefined);
223
+ }
224
+ /**
225
+ * Prefer the entry currently running this CLI so `bun run ./src/index.ts`
226
+ * respawns the same source tree; published installs use dist/index.js.
227
+ */
228
+ function resolveCliEntry() {
229
+ const running = process.argv[1];
230
+ if (running && existsSync(running)) {
231
+ return running;
232
+ }
233
+ const compiled = fileURLToPath(new URL("../index.js", import.meta.url));
234
+ if (existsSync(compiled))
235
+ return compiled;
236
+ const source = fileURLToPath(new URL("../index.ts", import.meta.url));
237
+ if (existsSync(source))
238
+ return source;
239
+ throw new Error("无法定位 llmswitch 入口文件");
240
+ }