@nvae/llmswitch 0.6.0 → 0.7.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.
@@ -6,6 +6,7 @@ import { atomicWriteFile, backupFile, ensureDir } from "../utils/fs.js";
6
6
  import { applyProxyToEnvRecord, clearProxyEnvKeys } from "../utils/proxy.js";
7
7
  import { getBackupsDir, getOpenCodeAuthPath, getOpenCodeConfigDir, getOpenCodeConfigPath, } from "../utils/paths.js";
8
8
  import { setActiveProfile } from "../store/profiles.js";
9
+ import { ensureBridgeForProfile, profileNeedsBridge, } from "../bridge/manager.js";
9
10
  function providerId(name) {
10
11
  return `llms-${name}`.replace(/[^a-zA-Z0-9_-]/g, "-");
11
12
  }
@@ -30,7 +31,7 @@ export function readOpenCodeAuth(path = getOpenCodeAuthPath()) {
30
31
  return {};
31
32
  return JSON.parse(readFileSync(path, "utf8"));
32
33
  }
33
- export function buildOpenCodeProviderBlock(profile) {
34
+ export function buildOpenCodeProviderBlock(profile, overrides) {
34
35
  const models = {};
35
36
  for (const id of profile.models.list) {
36
37
  models[id] = { name: id };
@@ -39,10 +40,12 @@ export function buildOpenCodeProviderBlock(profile) {
39
40
  models[profile.models.default] = { name: profile.models.default };
40
41
  }
41
42
  const options = {
42
- baseURL: normalizeBaseUrlForFormat(profile.apiFormat, profile.baseUrl),
43
+ baseURL: overrides?.baseURL ||
44
+ normalizeBaseUrlForFormat(profile.apiFormat, profile.baseUrl),
43
45
  };
44
- if (profile.apiKey) {
45
- options.apiKey = profile.apiKey;
46
+ const apiKey = overrides?.apiKey ?? profile.apiKey;
47
+ if (apiKey) {
48
+ options.apiKey = apiKey;
46
49
  }
47
50
  if (profile.headers && Object.keys(profile.headers).length > 0) {
48
51
  options.headers = { ...profile.headers };
@@ -54,19 +57,23 @@ export function buildOpenCodeProviderBlock(profile) {
54
57
  models,
55
58
  };
56
59
  }
57
- export function buildOpenCodeConfig(existing, profile) {
60
+ export function buildOpenCodeConfig(existing, profile, overrides) {
58
61
  assertCompatible("opencode", profile.apiFormat);
59
62
  const id = providerId(profile.name);
60
63
  const providers = {
61
64
  ...(existing.provider || {}),
62
65
  };
63
- providers[id] = buildOpenCodeProviderBlock(profile);
64
- // Optional top-level env for proxy (OpenCode may pass through)
66
+ providers[id] = buildOpenCodeProviderBlock(profile, overrides);
67
+ // Optional top-level env for proxy (OpenCode may pass through). When the
68
+ // profile routes through the bridge, the upstream proxy is applied inside the
69
+ // bridge; skip env-var proxy injection so OpenCode doesn't apply its own.
65
70
  const env = {
66
71
  ...(existing.env || {}),
67
72
  };
68
73
  clearProxyEnvKeys(env);
69
- applyProxyToEnvRecord(env, profile.proxy);
74
+ if (!overrides) {
75
+ applyProxyToEnvRecord(env, profile.proxy);
76
+ }
70
77
  const next = {
71
78
  ...existing,
72
79
  $schema: existing.$schema || "https://opencode.ai/config.json",
@@ -92,18 +99,25 @@ export function buildOpenCodeAuth(existing, profile) {
92
99
  }
93
100
  return next;
94
101
  }
95
- export function applyOpenCodeProfile(profile) {
102
+ export async function applyOpenCodeProfile(profile) {
96
103
  assertCompatible("opencode", profile.apiFormat);
97
104
  ensureDir(getOpenCodeConfigDir());
98
105
  ensureDir(dirname(getOpenCodeAuthPath()));
106
+ let bridgeConnection = null;
107
+ if (profileNeedsBridge(profile)) {
108
+ bridgeConnection = await ensureBridgeForProfile(profile, "opencode");
109
+ }
99
110
  const configPath = getOpenCodeConfigPath();
100
111
  const authPath = getOpenCodeAuthPath();
101
112
  const existing = readOpenCodeConfig(configPath);
102
113
  const backupPath = backupFile(configPath, getBackupsDir("opencode"), "opencode");
103
114
  backupFile(authPath, getBackupsDir("opencode"), "auth");
104
- const nextConfig = buildOpenCodeConfig(existing, profile);
115
+ const nextConfig = buildOpenCodeConfig(existing, profile, bridgeConnection
116
+ ? { baseURL: bridgeConnection.baseUrl, apiKey: bridgeConnection.clientToken }
117
+ : undefined);
105
118
  atomicWriteFile(configPath, JSON.stringify(nextConfig, null, 2) + "\n");
106
- const nextAuth = buildOpenCodeAuth(readOpenCodeAuth(authPath), profile);
119
+ const bridgeApiKey = bridgeConnection?.clientToken || profile.apiKey;
120
+ const nextAuth = buildOpenCodeAuth(readOpenCodeAuth(authPath), { ...profile, apiKey: bridgeApiKey });
107
121
  atomicWriteFile(authPath, JSON.stringify(nextAuth, null, 2) + "\n");
108
122
  setActiveProfile("opencode", profile.name);
109
123
  return {
@@ -111,7 +125,9 @@ export function applyOpenCodeProfile(profile) {
111
125
  profile: profile.name,
112
126
  configPath,
113
127
  backupPath,
114
- restartHint: "请重新启动 OpenCode 会话以使配置与代理生效。",
128
+ restartHint: bridgeConnection
129
+ ? "已通过本地 bridge 启用供应商(上游代理在 bridge 内生效)。请重新启动 OpenCode 会话使配置生效。"
130
+ : "请重新启动 OpenCode 会话以使配置与代理生效。",
115
131
  };
116
132
  }
117
133
  export function deactivateOpenCodeProfile(profileName) {
@@ -28,8 +28,14 @@ export class BridgeControlError extends Error {
28
28
  export function profileNeedsBridge(profile) {
29
29
  return profile.apiFormat === "openai-chat";
30
30
  }
31
+ function isBunRuntime() {
32
+ return typeof process.versions?.bun ===
33
+ "string";
34
+ }
31
35
  export function bridgeToolForCliTool(tool) {
32
- return tool === "codex" || tool === "claude" ? tool : null;
36
+ return tool === "codex" || tool === "claude" || tool === "opencode"
37
+ ? tool
38
+ : null;
33
39
  }
34
40
  export function upstreamFromProfile(profile, tool, clientToken = null) {
35
41
  const mode = tool === "claude" ? "chat" : profile.bridgeMode || "chat";
@@ -186,17 +192,26 @@ export async function startBridgeDaemon(host = DEFAULT_BRIDGE_HOST, port = DEFAU
186
192
  instance: null,
187
193
  pid: null,
188
194
  }));
189
- const entry = resolveCliEntry();
190
- const args = [entry, "bridge", "serve", "--host", host, "--port", String(port)];
195
+ const runner = resolveBridgeDaemonRunner();
196
+ const args = [
197
+ runner.entry,
198
+ "bridge",
199
+ "serve",
200
+ "--host",
201
+ host,
202
+ "--port",
203
+ String(port),
204
+ ];
191
205
  if (allowRemote)
192
206
  args.push("--allow-remote");
193
- const child = spawn(process.execPath, args, {
207
+ const child = spawn(runner.command, args, {
194
208
  detached: true,
195
209
  stdio: "ignore",
196
210
  env: {
197
211
  ...process.env,
198
212
  LLM_SWITCH_BRIDGE_PORT: String(port),
199
213
  LLM_SWITCH_BRIDGE_HOST: host,
214
+ LLM_SWITCH_BRIDGE_RUNTIME: runner.command,
200
215
  },
201
216
  });
202
217
  child.unref();
@@ -243,6 +258,9 @@ export async function forceStopBridge() {
243
258
  await stopBridge();
244
259
  }
245
260
  export async function runBridgeForeground(host, port, allowRemote = false) {
261
+ if (process.env.LLM_SWITCH_BRIDGE_RUNTIME !== "node" && isBunRuntime()) {
262
+ console.error("注意:bridge 当前由 Bun 运行,Bun 会本地解析 DNS,可能导致 socks5h 代理出口地区错误。建议用「node dist/index.js bridge serve」运行。");
263
+ }
246
264
  assertBridgeListenerAllowed(host, allowRemote);
247
265
  const listener = resolveBridgeListener({ host, port, allowRemote });
248
266
  const previous = readBridgeState();
@@ -321,15 +339,29 @@ export async function runBridgeForeground(host, port, allowRemote = false) {
321
339
  process.removeListener("SIGINT", onSignal);
322
340
  process.removeListener("SIGTERM", onSignal);
323
341
  }
324
- function resolveCliEntry() {
342
+ /**
343
+ * Resolve how the detached bridge daemon is launched. The bridge must run under
344
+ * Node.js: Bun's node:https stack resolves target DNS locally, which defeats
345
+ * socks5h remote-DNS and can produce wrong-region egress. Node delegates the
346
+ * hostname to the socks agent, giving correct remote-DNS behavior.
347
+ */
348
+ function resolveBridgeDaemonRunner() {
349
+ const candidates = [
350
+ fileURLToPath(new URL("../index.js", import.meta.url)),
351
+ fileURLToPath(new URL("../../dist/index.js", import.meta.url)),
352
+ fileURLToPath(new URL("../index.ts", import.meta.url)),
353
+ ];
354
+ for (const candidate of candidates) {
355
+ if (existsSync(candidate)) {
356
+ if (candidate.endsWith(".js")) {
357
+ return { command: "node", entry: candidate };
358
+ }
359
+ return { command: process.execPath, entry: candidate };
360
+ }
361
+ }
325
362
  const running = process.argv[1];
326
- if (running && existsSync(running))
327
- return running;
328
- const compiled = fileURLToPath(new URL("../index.js", import.meta.url));
329
- if (existsSync(compiled))
330
- return compiled;
331
- const source = fileURLToPath(new URL("../index.ts", import.meta.url));
332
- if (existsSync(source))
333
- return source;
363
+ if (running && existsSync(running)) {
364
+ return { command: process.execPath, entry: running };
365
+ }
334
366
  throw new BridgeControlError("无法定位 llmswitch 入口文件");
335
367
  }
@@ -29,7 +29,7 @@ function authenticateDataRequest(req, upstream, tool) {
29
29
  if (!upstream?.clientToken || upstream.migrationRequired)
30
30
  return false;
31
31
  const bearer = bearerToken(req);
32
- if (tool === "codex") {
32
+ if (tool === "codex" || tool === "opencode") {
33
33
  return constantTimeTokenEqual(upstream.clientToken, bearer);
34
34
  }
35
35
  const apiKey = headerValue(req.headers["x-api-key"]);
@@ -40,7 +40,8 @@ function authenticateDataRequest(req, upstream, tool) {
40
40
  }
41
41
  function authenticateModelsRequest(req, upstreams) {
42
42
  return (authenticateDataRequest(req, upstreams.codex, "codex") ||
43
- authenticateDataRequest(req, upstreams.claude, "claude"));
43
+ authenticateDataRequest(req, upstreams.claude, "claude") ||
44
+ authenticateDataRequest(req, upstreams.opencode, "opencode"));
44
45
  }
45
46
  function readBody(req, maxBytes = parseBridgeRuntimeLimits().maxBodyBytes) {
46
47
  return new Promise((resolve, reject) => {
@@ -156,7 +157,7 @@ async function fetchModelsJson(upstream) {
156
157
  }
157
158
  }
158
159
  async function proxyModelsMerged(_req, res, upstreams) {
159
- const sides = [upstreams.codex, upstreams.claude].filter((u) => Boolean(u?.baseUrl));
160
+ const sides = [upstreams.codex, upstreams.claude, upstreams.opencode].filter((u) => Boolean(u?.baseUrl));
160
161
  if (!sides.length) {
161
162
  sendJson(res, 503, {
162
163
  error: { message: "Bridge 未配置上游" },
@@ -293,6 +294,52 @@ async function forwardChatResponses(req, res, upstream, body, wantStream) {
293
294
  }
294
295
  await pipeChatStreamToResponses(response, res, String(body.model || ""), customTools, true);
295
296
  }
297
+ /**
298
+ * OpenCode-facing passthrough: forward an OpenAI chat request verbatim to the
299
+ * upstream `/chat/completions` (with llm-switch transport applying the proxy)
300
+ * and relay the raw response, preserving streaming for SSE.
301
+ */
302
+ async function forwardOpenCodeChat(_req, res, upstream, bodyBuf) {
303
+ let body;
304
+ try {
305
+ body = JSON.parse(bodyBuf.toString("utf8"));
306
+ }
307
+ catch {
308
+ sendJson(res, 400, { error: { message: "Invalid JSON body" } });
309
+ return;
310
+ }
311
+ const wantStream = Boolean(body.stream);
312
+ const url = joinUrl(upstream.baseUrl, "/chat/completions");
313
+ let response;
314
+ try {
315
+ response = await requestUpstream(upstream, url, "POST", bodyBuf.toString("utf8"));
316
+ }
317
+ catch (err) {
318
+ sendJson(res, 502, {
319
+ error: {
320
+ message: `Upstream chat 请求失败: ${err instanceof Error ? err.message : String(err)}`,
321
+ },
322
+ });
323
+ return;
324
+ }
325
+ if (!response.ok) {
326
+ const text = await response.text();
327
+ res.writeHead(response.status, {
328
+ "Content-Type": response.headers.get("content-type") || "application/json",
329
+ });
330
+ res.end(text);
331
+ return;
332
+ }
333
+ if (!wantStream) {
334
+ const text = await response.text();
335
+ res.writeHead(response.status, {
336
+ "Content-Type": response.headers.get("content-type") || "application/json",
337
+ });
338
+ res.end(text);
339
+ return;
340
+ }
341
+ await pipeRawStream(response, res);
342
+ }
296
343
  async function forwardCompletions(_req, res, upstream, body, wantStream) {
297
344
  const completionReq = responsesToCompletionsRequest(body);
298
345
  const customTools = collectCustomToolNames(body.tools);
@@ -444,6 +491,34 @@ async function pipeChatStreamToAnthropic(upstream, res, model) {
444
491
  res.end();
445
492
  }
446
493
  }
494
+ /** Relay an upstream SSE stream verbatim (OpenCode chat passthrough). */
495
+ async function pipeRawStream(upstream, res) {
496
+ res.writeHead(200, {
497
+ "Content-Type": "text/event-stream; charset=utf-8",
498
+ "Cache-Control": "no-cache, no-transform",
499
+ Connection: "keep-alive",
500
+ "X-Accel-Buffering": "no",
501
+ });
502
+ const reader = upstream.body?.getReader();
503
+ if (!reader) {
504
+ res.end();
505
+ return;
506
+ }
507
+ try {
508
+ while (true) {
509
+ const { done, value } = await reader.read();
510
+ if (done)
511
+ break;
512
+ res.write(value);
513
+ }
514
+ }
515
+ catch {
516
+ // Connection dropped; best-effort close.
517
+ }
518
+ finally {
519
+ res.end();
520
+ }
521
+ }
447
522
  export function createBridgeServer(options = {}) {
448
523
  return createServer(async (req, res) => {
449
524
  try {
@@ -487,6 +562,13 @@ export function createBridgeServer(options = {}) {
487
562
  migrationRequired: merged.claude.migrationRequired === true,
488
563
  }
489
564
  : null,
565
+ opencode: merged.opencode
566
+ ? {
567
+ mode: merged.opencode.mode,
568
+ profile: merged.opencode.profileName || null,
569
+ migrationRequired: merged.opencode.migrationRequired === true,
570
+ }
571
+ : null,
490
572
  },
491
573
  });
492
574
  return;
@@ -584,9 +666,32 @@ export function createBridgeServer(options = {}) {
584
666
  await handleMessages(req, res, merged.claude, body);
585
667
  return;
586
668
  }
669
+ if (req.method === "POST" &&
670
+ (path === "/v1/chat/completions" || path === "/chat/completions")) {
671
+ if (!merged.opencode?.baseUrl) {
672
+ sendJson(res, 503, {
673
+ error: {
674
+ message: "Bridge 未配置 OpenCode 上游。请先 llms opencode use <openai-chat profile>",
675
+ },
676
+ });
677
+ return;
678
+ }
679
+ if (!authenticateDataRequest(req, merged.opencode, "opencode")) {
680
+ sendJson(res, 401, {
681
+ error: {
682
+ code: "invalid_bridge_token",
683
+ message: "Bridge token 无效;请重新执行 llms opencode use <profile>",
684
+ },
685
+ });
686
+ return;
687
+ }
688
+ const body = await readBody(req);
689
+ await forwardOpenCodeChat(req, res, merged.opencode, body);
690
+ return;
691
+ }
587
692
  sendJson(res, 404, {
588
693
  error: {
589
- message: `Bridge 支持 GET /v1/models、POST /v1/responses、POST /v1/messages(当前: ${req.method} ${path})`,
694
+ message: `Bridge 支持 GET /v1/models、POST /v1/responses、POST /v1/messages、POST /v1/chat/completions(当前: ${req.method} ${path})`,
590
695
  },
591
696
  });
592
697
  }
@@ -63,7 +63,10 @@ function isLegacyUpstream(raw) {
63
63
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
64
64
  return false;
65
65
  const row = raw;
66
- return typeof row.baseUrl === "string" && !("codex" in row) && !("claude" in row);
66
+ return (typeof row.baseUrl === "string" &&
67
+ !("codex" in row) &&
68
+ !("claude" in row) &&
69
+ !("opencode" in row));
67
70
  }
68
71
  export function normalizeBridgeUpstreams(raw) {
69
72
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
@@ -71,11 +74,12 @@ export function normalizeBridgeUpstreams(raw) {
71
74
  }
72
75
  const row = raw;
73
76
  if (isLegacyUpstream(raw)) {
74
- return { codex: raw, claude: null };
77
+ return { codex: raw, claude: null, opencode: null };
75
78
  }
76
79
  return {
77
80
  codex: row.codex ?? null,
78
81
  claude: row.claude ?? null,
82
+ opencode: row.opencode ?? null,
79
83
  };
80
84
  }
81
85
  /** Legacy upstreams cannot authenticate until reapplied. */
@@ -155,6 +159,7 @@ function readLegacyMigrationState() {
155
159
  upstreams: {
156
160
  codex: markUpstreamMigrationRequired(upstreams.codex),
157
161
  claude: markUpstreamMigrationRequired(upstreams.claude),
162
+ opencode: markUpstreamMigrationRequired(upstreams.opencode),
158
163
  },
159
164
  pending: null,
160
165
  });
@@ -224,6 +229,7 @@ function persistState(next) {
224
229
  upstreams: {
225
230
  codex: next.upstreams.codex,
226
231
  claude: next.upstreams.claude,
232
+ opencode: next.upstreams.opencode,
227
233
  },
228
234
  pending: next.pending,
229
235
  };
@@ -1,8 +1,10 @@
1
1
  export const DEFAULT_BRIDGE_PORT = 17890;
2
2
  export const DEFAULT_BRIDGE_HOST = "127.0.0.1";
3
3
  export function emptyUpstreams() {
4
- return { codex: null, claude: null };
4
+ return { codex: null, claude: null, opencode: null };
5
5
  }
6
6
  export function hasAnyUpstream(upstreams) {
7
- return Boolean(upstreams.codex?.baseUrl || upstreams.claude?.baseUrl);
7
+ return Boolean(upstreams.codex?.baseUrl ||
8
+ upstreams.claude?.baseUrl ||
9
+ upstreams.opencode?.baseUrl);
8
10
  }
@@ -72,6 +72,7 @@ export function registerBridgeCommand(program) {
72
72
  upstreams: {
73
73
  codex: summarize(state.upstreams.codex),
74
74
  claude: summarize(state.upstreams.claude),
75
+ opencode: summarize(state.upstreams.opencode),
75
76
  },
76
77
  };
77
78
  if (opts.json) {
@@ -80,9 +81,9 @@ export function registerBridgeCommand(program) {
80
81
  }
81
82
  console.log(`状态:${alive ? "运行中" : "未运行"}`);
82
83
  console.log(`根地址:${data.rootUrl}`);
83
- console.log(`Codex base:${data.codexBaseUrl}`);
84
+ console.log(`Codex/OpenCode base:${data.codexBaseUrl}`);
84
85
  console.log(`PID:${pid ?? "-"}`);
85
- for (const tool of ["codex", "claude"]) {
86
+ for (const tool of ["codex", "claude", "opencode"]) {
86
87
  const u = data.upstreams[tool];
87
88
  if (u) {
88
89
  console.log(`${tool} 上游:${u.baseUrl}(${u.mode}) profile=${u.profile ?? "-"}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nvae/llmswitch",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "CLI to switch LLM providers and models for Claude Code, Codex, and OpenCode",
5
5
  "type": "module",
6
6
  "bin": {