@nvae/llmswitch 0.6.0 → 0.8.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,393 @@
1
+ /**
2
+ * Translate OpenAI Responses API results (stream/non-stream) → Chat Completions.
3
+ *
4
+ * Reverse direction of `translate-response.ts`. Used by the gateway when a
5
+ * Responses upstream must be presented in Chat Completions shape (the
6
+ * gateway's hub format).
7
+ */
8
+ function newId(prefix) {
9
+ return `${prefix}-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
10
+ }
11
+ function asRecord(value) {
12
+ if (value && typeof value === "object" && !Array.isArray(value)) {
13
+ return value;
14
+ }
15
+ return null;
16
+ }
17
+ function numberOr(value, fallback = 0) {
18
+ return typeof value === "number" ? value : fallback;
19
+ }
20
+ function mapUsage(usage) {
21
+ if (!usage)
22
+ return undefined;
23
+ const prompt = numberOr(usage.input_tokens, numberOr(usage.prompt_tokens));
24
+ const completion = numberOr(usage.output_tokens, numberOr(usage.completion_tokens));
25
+ const out = {
26
+ prompt_tokens: prompt,
27
+ completion_tokens: completion,
28
+ total_tokens: numberOr(usage.total_tokens, prompt + completion),
29
+ };
30
+ const details = asRecord(usage.output_tokens_details);
31
+ if (details && typeof details.reasoning_tokens === "number") {
32
+ out.completion_tokens_details = {
33
+ reasoning_tokens: details.reasoning_tokens,
34
+ };
35
+ }
36
+ const inputDetails = asRecord(usage.input_tokens_details);
37
+ if (inputDetails && typeof inputDetails.cached_tokens === "number") {
38
+ out.prompt_tokens_details = { cached_tokens: inputDetails.cached_tokens };
39
+ }
40
+ return out;
41
+ }
42
+ /** Responses status → Chat Completions finish_reason. */
43
+ export function responsesStatusToFinishReason(status, incompleteDetails) {
44
+ if (status === "incomplete") {
45
+ return incompleteDetails?.reason === "content_filter"
46
+ ? "content_filter"
47
+ : "length";
48
+ }
49
+ return "stop";
50
+ }
51
+ function extractOutputText(content) {
52
+ if (typeof content === "string")
53
+ return content;
54
+ if (!Array.isArray(content))
55
+ return "";
56
+ const parts = [];
57
+ for (const raw of content) {
58
+ const part = asRecord(raw);
59
+ if (!part)
60
+ continue;
61
+ if (typeof part.text === "string")
62
+ parts.push(part.text);
63
+ }
64
+ return parts.join("");
65
+ }
66
+ function extractReasoning(item) {
67
+ const summary = Array.isArray(item.summary) ? item.summary : [];
68
+ const parts = [];
69
+ for (const raw of summary) {
70
+ const entry = asRecord(raw);
71
+ if (entry && typeof entry.text === "string")
72
+ parts.push(entry.text);
73
+ else if (typeof raw === "string")
74
+ parts.push(raw);
75
+ }
76
+ if (!parts.length && typeof item.text === "string")
77
+ parts.push(item.text);
78
+ return parts.join("\n");
79
+ }
80
+ /** Custom (freeform) tool input → Chat function arguments JSON string. */
81
+ function customInputToArguments(input) {
82
+ const raw = typeof input === "string" ? input : "";
83
+ try {
84
+ return JSON.stringify({ input: raw });
85
+ }
86
+ catch {
87
+ return "{}";
88
+ }
89
+ }
90
+ /** Non-streaming Responses object → Chat Completions object. */
91
+ export function responseToChatCompletion(response, fallbackModel = "") {
92
+ const output = Array.isArray(response.output) ? response.output : [];
93
+ const textParts = [];
94
+ const reasoningParts = [];
95
+ const toolCalls = [];
96
+ for (const raw of output) {
97
+ const item = asRecord(raw);
98
+ if (!item)
99
+ continue;
100
+ const type = String(item.type || "");
101
+ if (type === "message") {
102
+ const text = extractOutputText(item.content);
103
+ if (text)
104
+ textParts.push(text);
105
+ continue;
106
+ }
107
+ if (type === "reasoning") {
108
+ const text = extractReasoning(item);
109
+ if (text)
110
+ reasoningParts.push(text);
111
+ continue;
112
+ }
113
+ if (type === "function_call") {
114
+ toolCalls.push({
115
+ index: toolCalls.length,
116
+ id: String(item.call_id || item.id || newId("call")),
117
+ type: "function",
118
+ function: {
119
+ name: String(item.name || "tool"),
120
+ arguments: typeof item.arguments === "string"
121
+ ? item.arguments
122
+ : JSON.stringify(item.arguments ?? {}),
123
+ },
124
+ });
125
+ continue;
126
+ }
127
+ if (type === "custom_tool_call") {
128
+ toolCalls.push({
129
+ index: toolCalls.length,
130
+ id: String(item.call_id || item.id || newId("call")),
131
+ type: "function",
132
+ function: {
133
+ name: String(item.name || "tool"),
134
+ arguments: customInputToArguments(item.input),
135
+ },
136
+ });
137
+ }
138
+ }
139
+ const message = {
140
+ role: "assistant",
141
+ content: textParts.length ? textParts.join("") : null,
142
+ };
143
+ if (reasoningParts.length) {
144
+ message.reasoning_content = reasoningParts.join("\n");
145
+ }
146
+ if (toolCalls.length)
147
+ message.tool_calls = toolCalls;
148
+ const finishReason = toolCalls.length
149
+ ? "tool_calls"
150
+ : responsesStatusToFinishReason(response.status, asRecord(response.incomplete_details));
151
+ const out = {
152
+ id: typeof response.id === "string" && response.id
153
+ ? response.id.replace(/^resp_/, "chatcmpl-")
154
+ : newId("chatcmpl"),
155
+ object: "chat.completion",
156
+ created: numberOr(response.created_at, Math.floor(Date.now() / 1000)),
157
+ model: String(response.model || fallbackModel || ""),
158
+ choices: [
159
+ { index: 0, message, finish_reason: finishReason, logprobs: null },
160
+ ],
161
+ };
162
+ const usage = mapUsage(asRecord(response.usage));
163
+ if (usage)
164
+ out.usage = usage;
165
+ return out;
166
+ }
167
+ export function createResponsesToChatStreamState(model, options = {}) {
168
+ return {
169
+ id: newId("chatcmpl"),
170
+ model,
171
+ created: Math.floor(Date.now() / 1000),
172
+ items: new Map(),
173
+ nextToolIndex: 0,
174
+ roleEmitted: false,
175
+ finishReason: null,
176
+ usage: undefined,
177
+ completed: false,
178
+ includeUsage: options.includeUsage !== false,
179
+ };
180
+ }
181
+ function chunk(state, delta, finishReason = null) {
182
+ return {
183
+ id: state.id,
184
+ object: "chat.completion.chunk",
185
+ created: state.created,
186
+ model: state.model,
187
+ choices: [{ index: 0, delta, finish_reason: finishReason, logprobs: null }],
188
+ };
189
+ }
190
+ function ensureRole(state, out) {
191
+ if (state.roleEmitted)
192
+ return;
193
+ state.roleEmitted = true;
194
+ out.push(chunk(state, { role: "assistant", content: "" }));
195
+ }
196
+ /** Parse one Responses SSE line; the JSON payload carries the event `type`. */
197
+ export function parseResponsesSseLine(line) {
198
+ const trimmed = line.trim();
199
+ if (!trimmed.startsWith("data:"))
200
+ return null;
201
+ const data = trimmed.slice(5).trim();
202
+ if (!data)
203
+ return null;
204
+ if (data === "[DONE]")
205
+ return "done";
206
+ try {
207
+ return JSON.parse(data);
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ }
213
+ export class ResponsesStreamError extends Error {
214
+ errorType;
215
+ constructor(message, errorType = "api_error") {
216
+ super(message);
217
+ this.errorType = errorType;
218
+ this.name = "ResponsesStreamError";
219
+ }
220
+ }
221
+ function toolEntryFor(state, itemId) {
222
+ if (!itemId)
223
+ return undefined;
224
+ return state.items.get(itemId);
225
+ }
226
+ /**
227
+ * Convert one Responses SSE event into zero or more Chat Completions chunks.
228
+ * Throws `ResponsesStreamError` on upstream error/failed events.
229
+ */
230
+ export function responsesEventToChatChunks(event, state) {
231
+ const out = [];
232
+ const type = String(event.type || "");
233
+ if (type === "response.created" || type === "response.in_progress") {
234
+ const response = asRecord(event.response);
235
+ if (response) {
236
+ if (typeof response.model === "string" && response.model) {
237
+ state.model = response.model;
238
+ }
239
+ if (typeof response.id === "string" && response.id) {
240
+ state.id = response.id.replace(/^resp_/, "chatcmpl-");
241
+ }
242
+ }
243
+ ensureRole(state, out);
244
+ return out;
245
+ }
246
+ if (type === "response.output_item.added") {
247
+ ensureRole(state, out);
248
+ const item = asRecord(event.item);
249
+ if (!item)
250
+ return out;
251
+ const itemType = String(item.type || "");
252
+ if (itemType !== "function_call" && itemType !== "custom_tool_call") {
253
+ return out;
254
+ }
255
+ const itemId = String(item.id || "");
256
+ const entry = {
257
+ toolIndex: state.nextToolIndex++,
258
+ callId: String(item.call_id || item.id || newId("call")),
259
+ name: String(item.name || "tool"),
260
+ custom: itemType === "custom_tool_call",
261
+ customInput: "",
262
+ emittedStart: true,
263
+ };
264
+ if (itemId)
265
+ state.items.set(itemId, entry);
266
+ out.push(chunk(state, {
267
+ tool_calls: [
268
+ {
269
+ index: entry.toolIndex,
270
+ id: entry.callId,
271
+ type: "function",
272
+ function: { name: entry.name, arguments: "" },
273
+ },
274
+ ],
275
+ }));
276
+ return out;
277
+ }
278
+ if (type === "response.output_text.delta" ||
279
+ type === "response.refusal.delta") {
280
+ ensureRole(state, out);
281
+ const delta = event.delta;
282
+ if (typeof delta === "string" && delta) {
283
+ out.push(chunk(state, { content: delta }));
284
+ }
285
+ return out;
286
+ }
287
+ if (type === "response.reasoning_summary_text.delta" ||
288
+ type === "response.reasoning_text.delta") {
289
+ ensureRole(state, out);
290
+ const delta = event.delta;
291
+ if (typeof delta === "string" && delta) {
292
+ out.push(chunk(state, { reasoning_content: delta }));
293
+ }
294
+ return out;
295
+ }
296
+ if (type === "response.function_call_arguments.delta") {
297
+ const entry = toolEntryFor(state, String(event.item_id || ""));
298
+ const delta = event.delta;
299
+ if (entry && typeof delta === "string" && delta) {
300
+ out.push(chunk(state, {
301
+ tool_calls: [
302
+ { index: entry.toolIndex, function: { arguments: delta } },
303
+ ],
304
+ }));
305
+ }
306
+ return out;
307
+ }
308
+ if (type === "response.custom_tool_call_input.delta") {
309
+ const entry = toolEntryFor(state, String(event.item_id || ""));
310
+ const delta = event.delta;
311
+ // Freeform input is not valid JSON; buffer and emit once complete.
312
+ if (entry && typeof delta === "string")
313
+ entry.customInput += delta;
314
+ return out;
315
+ }
316
+ if (type === "response.custom_tool_call_input.done") {
317
+ const entry = toolEntryFor(state, String(event.item_id || ""));
318
+ if (entry) {
319
+ const input = typeof event.input === "string" ? event.input : entry.customInput;
320
+ out.push(chunk(state, {
321
+ tool_calls: [
322
+ {
323
+ index: entry.toolIndex,
324
+ function: { arguments: customInputToArguments(input) },
325
+ },
326
+ ],
327
+ }));
328
+ entry.customInput = "";
329
+ }
330
+ return out;
331
+ }
332
+ if (type === "response.output_item.done") {
333
+ const item = asRecord(event.item);
334
+ if (!item)
335
+ return out;
336
+ const itemId = String(item.id || "");
337
+ const entry = toolEntryFor(state, itemId);
338
+ // Non-streamed argument payloads only appear on the done event.
339
+ if (entry && entry.custom && entry.customInput) {
340
+ out.push(chunk(state, {
341
+ tool_calls: [
342
+ {
343
+ index: entry.toolIndex,
344
+ function: {
345
+ arguments: customInputToArguments(entry.customInput),
346
+ },
347
+ },
348
+ ],
349
+ }));
350
+ entry.customInput = "";
351
+ }
352
+ return out;
353
+ }
354
+ if (type === "response.completed" || type === "response.incomplete") {
355
+ const response = asRecord(event.response);
356
+ if (response) {
357
+ state.usage = mapUsage(asRecord(response.usage));
358
+ state.finishReason = state.items.size
359
+ ? "tool_calls"
360
+ : responsesStatusToFinishReason(response.status ?? (type === "response.incomplete" ? "incomplete" : "completed"), asRecord(response.incomplete_details));
361
+ }
362
+ return finishResponsesToChatStream(state);
363
+ }
364
+ if (type === "response.failed" || type === "error") {
365
+ const response = asRecord(event.response);
366
+ const error = asRecord(event.error) || asRecord(response?.error);
367
+ throw new ResponsesStreamError(String(error?.message || "上游 Responses 流返回错误"), String(error?.type || error?.code || "api_error"));
368
+ }
369
+ return out;
370
+ }
371
+ function finishResponsesToChatStream(state) {
372
+ if (state.completed)
373
+ return [];
374
+ state.completed = true;
375
+ const out = [];
376
+ const finishReason = state.finishReason || (state.items.size ? "tool_calls" : "stop");
377
+ out.push(chunk(state, {}, finishReason));
378
+ if (state.includeUsage && state.usage) {
379
+ out.push({
380
+ id: state.id,
381
+ object: "chat.completion.chunk",
382
+ created: state.created,
383
+ model: state.model,
384
+ choices: [],
385
+ usage: state.usage,
386
+ });
387
+ }
388
+ return out;
389
+ }
390
+ /** Emit terminal chunks when the upstream stream ends without a completion event. */
391
+ export function forceCompleteResponsesToChatStream(state) {
392
+ return finishResponsesToChatStream(state);
393
+ }
@@ -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
  }
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import { TOOLS } from "./types.js";
3
3
  import { registerToolCommand } from "./commands/tool.js";
4
4
  import { registerLaunchCommand } from "./commands/launch-cmd.js";
5
5
  import { registerBridgeCommand } from "./commands/bridge-cmd.js";
6
+ import { registerGatewayCommand } from "./commands/gateway-cmd.js";
6
7
  import { registerSetupCommand } from "./commands/setup-cmd.js";
7
8
  import { registerHomeCommand } from "./commands/home-cmd.js";
8
9
  import { getAppConfigRoot } from "./utils/paths.js";
@@ -22,6 +23,7 @@ export function createProgram() {
22
23
  });
23
24
  registerLaunchCommand(program);
24
25
  registerBridgeCommand(program);
26
+ registerGatewayCommand(program);
25
27
  registerSetupCommand(program);
26
28
  for (const tool of TOOLS) {
27
29
  registerToolCommand(program, tool);
@@ -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 ?? "-"}`);