@nvae/llmswitch 0.7.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,1040 @@
1
+ import { Option } from "commander";
2
+ import { cancel, confirm, isCancel, password, select, text } from "@clack/prompts";
3
+ import { closeSync, existsSync, openSync, readFileSync, readSync, statSync, watchFile, } from "node:fs";
4
+ import { isApiFormat } from "../types.js";
5
+ import { formatLabel } from "../formats/compatibility.js";
6
+ import { detectApiFormat } from "../utils/detect-format.js";
7
+ import { fetchModelList } from "../utils/fetch-models.js";
8
+ import { requestWithNodeTransport, } from "../bridge/transport.js";
9
+ import { providerFormat } from "../gateway/types.js";
10
+ import { buildUpstreamHeaders, upstreamUrl, } from "../gateway/server.js";
11
+ import { chatRequestToUpstream, upstreamPath, } from "../gateway/pipeline.js";
12
+ import { getGatewayLogPath, isGatewayAlive, isPidRunning, probeGateway, readGatewayPid, runGatewayForeground, startGatewayDaemon, stopGateway, } from "../gateway/manager.js";
13
+ import { gatewayBaseUrl, gatewayRootUrl, readGatewayState } from "../gateway/state.js";
14
+ import { parseGatewayPort } from "../gateway/runtime.js";
15
+ import { createGatewayKey, deleteGatewayKey, listGatewayKeys, peekDailyQuota, peekRateLimit, publicKeyView, resetRateLimits, revokeGatewayKey, rotateGatewayKey, updateGatewayKey, } from "../gateway/keys.js";
16
+ import { parseBridgeRuntimeLimits } from "../bridge/runtime.js";
17
+ import { rotateGatewayLogIfNeeded } from "../gateway/manager.js";
18
+ import { deleteGatewayProvider, deleteGatewayRoute, importProvidersFromProfiles, listGatewayProviders, listGatewayRoutes, publicProviderView, readGatewayConfig, requireGatewayProvider, saveGatewayProvider, saveGatewayRoute, writeGatewayConfig, } from "../gateway/store.js";
19
+ import { listRoutableModelIds, listRoutableModels, resolveModelRoute } from "../gateway/router.js";
20
+ import { resetUsage, summarizeUsage } from "../gateway/usage.js";
21
+ import { DEFAULT_GATEWAY_HOST, DEFAULT_GATEWAY_PORT, } from "../gateway/types.js";
22
+ function bail(message) {
23
+ cancel(message);
24
+ process.exit(1);
25
+ }
26
+ function formatDuration(totalSeconds) {
27
+ const seconds = Math.max(0, Math.floor(totalSeconds));
28
+ const days = Math.floor(seconds / 86400);
29
+ const hours = Math.floor((seconds % 86400) / 3600);
30
+ const minutes = Math.floor((seconds % 3600) / 60);
31
+ if (days)
32
+ return `${days}天${hours}小时`;
33
+ if (hours)
34
+ return `${hours}小时${minutes}分钟`;
35
+ if (minutes)
36
+ return `${minutes}分钟`;
37
+ return `${seconds}秒`;
38
+ }
39
+ function splitList(value) {
40
+ if (!value)
41
+ return [];
42
+ return value
43
+ .split(",")
44
+ .map((item) => item.trim())
45
+ .filter(Boolean);
46
+ }
47
+ /** Parse a repeatable `--header "Name: value"` option. */
48
+ function parseHeaderEntry(value, collected) {
49
+ const index = value.indexOf(":");
50
+ const name = index > 0 ? value.slice(0, index).trim() : "";
51
+ const headerValue = index > 0 ? value.slice(index + 1).trim() : "";
52
+ if (!name || !headerValue) {
53
+ bail(`无效的 --header「${value}」,格式应为 "名称: 值"`);
54
+ }
55
+ return { ...collected, [name]: headerValue };
56
+ }
57
+ function randomProviderName() {
58
+ const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
59
+ let name = "";
60
+ for (let i = 0; i < 5; i += 1) {
61
+ name += alphabet[Math.floor(Math.random() * alphabet.length)];
62
+ }
63
+ return name;
64
+ }
65
+ export function registerGatewayCommand(program) {
66
+ const gateway = program
67
+ .command("gateway")
68
+ .description("对外 AI 网关:独立端口 + 网关 API Key,按模型路由到多个供应商并做格式转换");
69
+ registerServerCommands(gateway);
70
+ registerProviderCommands(gateway);
71
+ registerKeyCommands(gateway);
72
+ registerRouteCommands(gateway);
73
+ registerConfigCommands(gateway);
74
+ registerRateLimitCommands(gateway);
75
+ registerUsageCommands(gateway);
76
+ registerLogsCommands(gateway);
77
+ }
78
+ // --- logs -------------------------------------------------------------------
79
+ function registerLogsCommands(gateway) {
80
+ gateway
81
+ .command("logs")
82
+ .description("查看网关日志(gateway.log)")
83
+ .option("--lines <n>", "显示最后 N 行(默认 100)", "100")
84
+ .option("--follow", "持续跟踪新日志(Ctrl+C 退出)")
85
+ .action((opts) => {
86
+ const lines = Number(opts.lines ?? "100");
87
+ if (!Number.isInteger(lines) || lines < 1 || lines > 10_000) {
88
+ bail("--lines 必须是 1..10000 的整数");
89
+ }
90
+ const path = getGatewayLogPath();
91
+ rotateGatewayLogIfNeeded();
92
+ if (!existsSync(path)) {
93
+ console.log("暂无日志文件。");
94
+ return;
95
+ }
96
+ const printTail = () => {
97
+ const raw = readFileSync(path, "utf8");
98
+ if (!raw)
99
+ return 0;
100
+ const all = raw.split(/\r?\n/);
101
+ if (all.length && all[all.length - 1] === "")
102
+ all.pop();
103
+ const tail = all.slice(Math.max(0, all.length - lines));
104
+ for (const line of tail)
105
+ console.log(line);
106
+ return raw.length;
107
+ };
108
+ printTail();
109
+ if (!opts.follow)
110
+ return;
111
+ console.log("── 正在跟踪日志,Ctrl+C 退出 ──");
112
+ let shown = statSync(path).size;
113
+ watchFile(path, { interval: 1_000 }, () => {
114
+ try {
115
+ const stat = statSync(path);
116
+ if (stat.size < shown) {
117
+ // Rotated or truncated: restart from the beginning.
118
+ shown = 0;
119
+ }
120
+ if (stat.size === shown)
121
+ return;
122
+ const fd = openSync(path, "r");
123
+ const buffer = Buffer.alloc(stat.size - shown);
124
+ readSync(fd, buffer, 0, buffer.length, shown);
125
+ closeSync(fd);
126
+ shown = stat.size;
127
+ process.stdout.write(buffer.toString("utf8"));
128
+ }
129
+ catch {
130
+ // File vanished mid-follow; retry on next tick.
131
+ }
132
+ });
133
+ });
134
+ }
135
+ // --- rate limits ------------------------------------------------------------
136
+ function registerRateLimitCommands(gateway) {
137
+ const rateLimit = gateway
138
+ .command("ratelimit")
139
+ .description("查看或清空限流计数(计数持久化,重启不丢失)");
140
+ rateLimit
141
+ .command("show", { isDefault: true })
142
+ .description("显示每个 Key 当前窗口的用量")
143
+ .option("--json", "JSON 输出")
144
+ .action((opts) => {
145
+ const config = readGatewayConfig();
146
+ const rows = listGatewayKeys().map((key) => {
147
+ const limit = key.rateLimitPerMinute === -1
148
+ ? 0
149
+ : key.rateLimitPerMinute > 0
150
+ ? key.rateLimitPerMinute
151
+ : config.rateLimitPerMinute;
152
+ const snapshot = peekRateLimit(key.id, limit);
153
+ const daily = key.requestsPerDay
154
+ ? peekDailyQuota(key.id, key.requestsPerDay)
155
+ : null;
156
+ return {
157
+ id: key.id,
158
+ name: key.name,
159
+ limit: snapshot.limit,
160
+ remaining: snapshot.remaining,
161
+ resetAt: snapshot.resetAt
162
+ ? new Date(snapshot.resetAt * 1000).toISOString()
163
+ : null,
164
+ unlimited: key.rateLimitPerMinute === -1,
165
+ daily: daily
166
+ ? { limit: daily.limit, remaining: daily.remaining }
167
+ : null,
168
+ };
169
+ });
170
+ if (opts.json) {
171
+ console.log(JSON.stringify(rows, null, 2));
172
+ return;
173
+ }
174
+ if (!rows.length) {
175
+ console.log("暂无 API Key。");
176
+ return;
177
+ }
178
+ for (const row of rows) {
179
+ if (row.unlimited) {
180
+ console.log(`${row.id} ${row.name}:不限流`);
181
+ continue;
182
+ }
183
+ if (row.limit <= 0) {
184
+ console.log(`${row.id} ${row.name}:不限流(未设置限额)`);
185
+ continue;
186
+ }
187
+ const daily = row.daily
188
+ ? `,今日剩余 ${row.daily.remaining}/${row.daily.limit}`
189
+ : "";
190
+ console.log(`${row.id} ${row.name}:剩余 ${row.remaining}/${row.limit},窗口重置于 ${row.resetAt}${daily}`);
191
+ }
192
+ });
193
+ rateLimit
194
+ .command("reset")
195
+ .description("清空所有限流计数")
196
+ .action(() => {
197
+ resetRateLimits();
198
+ console.log("已清空限流计数");
199
+ });
200
+ }
201
+ // --- usage ------------------------------------------------------------------
202
+ function registerUsageCommands(gateway) {
203
+ const usage = gateway
204
+ .command("usage")
205
+ .description("查看按天聚合的用量统计(Key / 供应商 / 模型)");
206
+ usage
207
+ .command("show", { isDefault: true })
208
+ .description("显示最近 N 天的用量")
209
+ .option("--days <n>", "统计最近几天的数据(默认 7)", "7")
210
+ .option("--json", "JSON 输出")
211
+ .action((opts) => {
212
+ const days = Number(opts.days ?? "7");
213
+ if (!Number.isInteger(days) || days < 1 || days > 90) {
214
+ bail("--days 必须是 1..90 的整数");
215
+ }
216
+ const rows = summarizeUsage({ days });
217
+ if (opts.json) {
218
+ console.log(JSON.stringify(rows, null, 2));
219
+ return;
220
+ }
221
+ if (!rows.length) {
222
+ console.log(`最近 ${days} 天暂无用量记录。`);
223
+ return;
224
+ }
225
+ console.log(`统计范围:最近 ${days} 天\n`);
226
+ console.log("日期 请求数 输入tokens 输出tokens Key 供应商 模型");
227
+ for (const row of rows) {
228
+ console.log(`${row.day} ${String(row.requests).padStart(5)} ${String(row.inputTokens).padStart(10)} ${String(row.outputTokens).padStart(10)} ${row.key.padEnd(8)} ${row.provider.padEnd(12)} ${row.model}`);
229
+ }
230
+ });
231
+ usage
232
+ .command("reset")
233
+ .description("清空所有用量记录")
234
+ .action(() => {
235
+ resetUsage();
236
+ console.log("已清空用量记录");
237
+ });
238
+ }
239
+ // --- serve / start / stop / status -----------------------------------------
240
+ function registerServerCommands(gateway) {
241
+ gateway
242
+ .command("serve")
243
+ .description("前台运行网关(Ctrl+C 停止)")
244
+ .option("--host <host>", "监听地址", DEFAULT_GATEWAY_HOST)
245
+ .option("--port <port>", "监听端口", String(DEFAULT_GATEWAY_PORT))
246
+ .option("--allow-remote", "允许非回环监听(必须已创建至少一个 API Key)")
247
+ .action(async (opts) => {
248
+ await runGatewayForeground(opts.host || DEFAULT_GATEWAY_HOST, parseGatewayPort(opts.port), Boolean(opts.allowRemote));
249
+ });
250
+ gateway
251
+ .command("start")
252
+ .description("后台启动网关")
253
+ .option("--host <host>", "监听地址", DEFAULT_GATEWAY_HOST)
254
+ .option("--port <port>", "监听端口", String(DEFAULT_GATEWAY_PORT))
255
+ .option("--allow-remote", "允许非回环监听(必须已创建至少一个 API Key)")
256
+ .action(async (opts) => {
257
+ const host = opts.host || DEFAULT_GATEWAY_HOST;
258
+ const port = parseGatewayPort(opts.port);
259
+ const pid = await startGatewayDaemon(host, port, Boolean(opts.allowRemote));
260
+ for (let attempt = 0; attempt < 40; attempt += 1) {
261
+ if (await isGatewayAlive())
262
+ break;
263
+ await new Promise((resolve) => setTimeout(resolve, 100));
264
+ }
265
+ if (!(await isGatewayAlive())) {
266
+ throw new Error(`网关启动失败。查看日志:${getGatewayLogPath()},或前台运行:llms gateway serve`);
267
+ }
268
+ if (pid > 0) {
269
+ console.log(`网关已启动 pid=${pid} ${gatewayRootUrl()}`);
270
+ }
271
+ else {
272
+ console.log(`网关已在运行 ${gatewayRootUrl()}`);
273
+ }
274
+ console.log(`OpenAI base:${gatewayBaseUrl()}`);
275
+ });
276
+ gateway
277
+ .command("stop")
278
+ .description("停止网关")
279
+ .action(async () => {
280
+ const stopped = await stopGateway();
281
+ console.log(stopped ? "已发送停止信号" : "没有正在运行的网关进程");
282
+ });
283
+ gateway
284
+ .command("status")
285
+ .description("查看网关状态")
286
+ .option("--json", "JSON 输出")
287
+ .action(async (opts) => {
288
+ const state = readGatewayState();
289
+ const probe = await probeGateway(state.listener.advertiseHost, state.listener.port);
290
+ const pid = readGatewayPid();
291
+ const providers = listGatewayProviders();
292
+ const keys = listGatewayKeys().map(publicKeyView);
293
+ const data = {
294
+ alive: probe.healthy,
295
+ reachable: probe.reachable,
296
+ listener: state.listener,
297
+ rootUrl: gatewayRootUrl(state),
298
+ openaiBaseUrl: gatewayBaseUrl(state),
299
+ anthropicBaseUrl: gatewayRootUrl(state),
300
+ pid,
301
+ pidRunning: pid ? isPidRunning(pid) : false,
302
+ logPath: getGatewayLogPath(),
303
+ providers: providers.map(publicProviderView),
304
+ routes: listGatewayRoutes(),
305
+ keys,
306
+ config: readGatewayConfig(),
307
+ breakers: probe.breakers ?? [],
308
+ };
309
+ if (opts.json) {
310
+ console.log(JSON.stringify(data, null, 2));
311
+ return;
312
+ }
313
+ console.log(`状态:${data.alive ? "运行中" : data.reachable ? "端口被占用(非本网关)" : "未运行"}`);
314
+ if (data.alive && probe.uptimeSeconds !== undefined) {
315
+ console.log(`已运行:${formatDuration(probe.uptimeSeconds)}`);
316
+ }
317
+ if (data.alive && probe.stats) {
318
+ const stats = probe.stats;
319
+ console.log(`请求:共 ${stats.requests} 次(4xx ${stats.errors4xx},5xx ${stats.errors5xx}),并发 ${stats.activeConnections}/${stats.maxConcurrency}`);
320
+ }
321
+ console.log(`监听:${data.listener.bindHost}:${data.listener.port}${data.listener.allowRemote ? "(已对外暴露)" : "(仅本机)"}`);
322
+ console.log(`OpenAI base:${data.openaiBaseUrl}`);
323
+ console.log(`Anthropic base:${data.anthropicBaseUrl}`);
324
+ console.log(`PID:${pid ?? "-"}`);
325
+ console.log(`日志:${data.logPath}`);
326
+ console.log(`供应商:${providers.length} 个(启用 ${providers.filter((p) => p.enabled).length} 个)`);
327
+ console.log(`API Key:${keys.length} 个(有效 ${keys.filter((k) => k.status === "active").length} 个)`);
328
+ console.log(`模型路由:${data.routes.length} 条`);
329
+ const cooling = data.breakers.filter((row) => row.coolingMsRemaining > 0);
330
+ for (const row of cooling) {
331
+ console.log(`熔断冷却:${row.provider}(连续失败 ${row.consecutiveFailures} 次,剩余 ${Math.ceil(row.coolingMsRemaining / 1000)}s,最近错误:${row.lastError || "未知"})`);
332
+ }
333
+ if (!keys.some((key) => key.status === "active")) {
334
+ console.log("提示:尚无有效 API Key,请执行 llms gateway key create");
335
+ }
336
+ });
337
+ gateway
338
+ .command("models")
339
+ .description("列出网关可路由的模型")
340
+ .option("--json", "JSON 输出")
341
+ .action((opts) => {
342
+ const models = listRoutableModels();
343
+ if (opts.json) {
344
+ console.log(JSON.stringify(models, null, 2));
345
+ return;
346
+ }
347
+ if (!models.length) {
348
+ console.log("暂无可路由模型。请先执行:llms gateway provider add");
349
+ return;
350
+ }
351
+ for (const model of models) {
352
+ console.log(`${model.id} → ${model.provider}(${model.format}) 上游模型 ${model.upstreamModel}`);
353
+ }
354
+ });
355
+ gateway
356
+ .command("resolve")
357
+ .description("查看某个模型 id 的路由与 fallback 顺序")
358
+ .argument("<model>", "客户端请求里的 model 值")
359
+ .option("--json", "JSON 输出")
360
+ .action((model, opts) => {
361
+ const resolution = resolveModelRoute(model);
362
+ const rows = resolution.candidates.map((candidate, index) => ({
363
+ order: index + 1,
364
+ provider: candidate.provider.name,
365
+ format: candidate.provider.apiFormat,
366
+ upstreamModel: candidate.model,
367
+ source: candidate.source,
368
+ }));
369
+ if (opts.json) {
370
+ console.log(JSON.stringify({ requested: resolution.requested, candidates: rows }, null, 2));
371
+ return;
372
+ }
373
+ console.log(`模型「${resolution.requested}」路由顺序:`);
374
+ for (const row of rows) {
375
+ console.log(` ${row.order}. ${row.provider}(${row.format}) → ${row.upstreamModel} [${row.source}]`);
376
+ }
377
+ });
378
+ }
379
+ // --- providers --------------------------------------------------------------
380
+ function registerProviderCommands(gateway) {
381
+ const provider = gateway
382
+ .command("provider")
383
+ .description("管理网关供应商(与 llms <tool> provider 相互独立)");
384
+ provider
385
+ .command("list", { isDefault: true })
386
+ .description("列出网关供应商")
387
+ .option("--json", "JSON 输出")
388
+ .action((opts) => {
389
+ const providers = listGatewayProviders().map(publicProviderView);
390
+ if (opts.json) {
391
+ console.log(JSON.stringify(providers, null, 2));
392
+ return;
393
+ }
394
+ if (!providers.length) {
395
+ console.log("暂无供应商。添加:llms gateway provider add");
396
+ console.log("或从现有工具配置导入:llms gateway provider import");
397
+ return;
398
+ }
399
+ for (const item of providers) {
400
+ console.log(`${item.enabled ? "●" : "○"} ${item.name}(${item.displayName}) ${item.apiFormat} ${item.baseUrl} key=${item.apiKey} priority=${item.priority} models=${item.models.length}${item.pathPrefix && item.pathPrefix !== "v1" ? ` path=${item.pathPrefix || "(直连)"}` : ""}${item.headerNames?.length ? ` headers=${item.headerNames.join("/")}` : ""}`);
401
+ }
402
+ });
403
+ provider
404
+ .command("add")
405
+ .description("添加网关供应商(缺省参数时进入交互式引导)")
406
+ .option("--name <name>", "供应商名称(默认随机生成)")
407
+ .option("--display-name <name>", "显示名称")
408
+ .option("--base-url <url>", "API 地址")
409
+ .option("--api-key <key>", "API Key")
410
+ .option("--format <format>", "接口类型:anthropic | openai-chat | openai-responses(缺省自动探测)")
411
+ .option("--models <list>", "逗号分隔的模型列表(缺省尝试自动获取)")
412
+ .option("--priority <n>", "优先级,越小越先被选中", "100")
413
+ .option("--proxy <url>", "上游代理 URL")
414
+ .option("--path-prefix <prefix>", "baseUrl 与 API 路径之间的前缀,默认 v1;传空字符串表示直连 baseUrl(如 Gemini 兼容端点)")
415
+ .addOption(new Option("--header <value>", '自定义上游请求头,格式 "名称: 值",可重复传入')
416
+ .argParser((value, previous) => parseHeaderEntry(value, previous ?? {}))
417
+ .default({}))
418
+ .action(async (opts) => {
419
+ const baseUrl = opts.baseUrl ?? (await promptText("API 地址(base URL)"));
420
+ if (!baseUrl)
421
+ bail("已取消");
422
+ const apiKey = opts.apiKey ?? (await promptSecret("API Key(本地上游可留空)"));
423
+ let apiFormat;
424
+ if (opts.format) {
425
+ if (!isApiFormat(opts.format)) {
426
+ bail(`无效的 --format:${opts.format}`);
427
+ }
428
+ apiFormat = opts.format;
429
+ }
430
+ else {
431
+ // opencode supports all three formats, so detection is unconstrained.
432
+ const detected = await detectApiFormat("opencode", {
433
+ baseUrl,
434
+ apiKey: apiKey || "",
435
+ });
436
+ if (detected.detected) {
437
+ apiFormat = detected.apiFormat;
438
+ console.log(`已自动识别接口类型:${formatLabel(apiFormat)}`);
439
+ }
440
+ else {
441
+ apiFormat = await promptFormat();
442
+ }
443
+ }
444
+ let models = splitList(opts.models);
445
+ if (!models.length) {
446
+ try {
447
+ const result = await fetchModelList({
448
+ baseUrl,
449
+ apiKey: apiKey || "",
450
+ apiFormat,
451
+ });
452
+ models = result.models;
453
+ if (models.length) {
454
+ console.log(`已获取 ${models.length} 个模型`);
455
+ }
456
+ }
457
+ catch {
458
+ console.log("未能自动获取模型列表;该供应商将接受任意模型 id(passthrough)");
459
+ }
460
+ }
461
+ const priority = Number(opts.priority ?? "100");
462
+ const saved = saveGatewayProvider({
463
+ name: opts.name || randomProviderName(),
464
+ displayName: opts.displayName || opts.name || "",
465
+ apiFormat,
466
+ baseUrl,
467
+ apiKey: apiKey || "",
468
+ models,
469
+ headers: opts.header ?? {},
470
+ proxy: opts.proxy,
471
+ ...(opts.pathPrefix !== undefined
472
+ ? { pathPrefix: opts.pathPrefix }
473
+ : {}),
474
+ priority: Number.isFinite(priority) ? priority : 100,
475
+ enabled: true,
476
+ sourceProfile: null,
477
+ updatedAt: new Date().toISOString(),
478
+ });
479
+ console.log(`已添加供应商 ${saved.name}(${formatLabel(saved.apiFormat)}) ${saved.baseUrl}`);
480
+ if (!saved.models.length) {
481
+ console.log("该供应商未声明模型列表,将作为兜底 passthrough 上游。");
482
+ }
483
+ });
484
+ provider
485
+ .command("import")
486
+ .description("从现有 llms <tool> provider 配置导入(按上游去重)")
487
+ .action(() => {
488
+ const result = importProvidersFromProfiles();
489
+ if (!result.imported.length) {
490
+ console.log("没有新的供应商需要导入。");
491
+ }
492
+ for (const item of result.imported) {
493
+ console.log(`已导入 ${item.name}(来自 ${item.sourceProfile?.tool}/${item.sourceProfile?.name}) ${item.apiFormat} ${item.baseUrl}`);
494
+ }
495
+ for (const item of result.skipped) {
496
+ console.log(`跳过 ${item.tool}/${item.name}:${item.reason}`);
497
+ }
498
+ });
499
+ provider
500
+ .command("test")
501
+ .description("测试供应商连通性:拉取模型列表,可选发送一次最小补全请求")
502
+ .argument("<name>", "供应商名称")
503
+ .option("--model <id>", "用于 --call 的模型 id(缺省取模型列表第一个)")
504
+ .option("--call", "额外发送一次 1-token 补全请求验证推理可用")
505
+ .option("--json", "JSON 输出")
506
+ .action(async (name, opts) => {
507
+ const provider = requireGatewayProvider(name);
508
+ const result = { provider: provider.name };
509
+ const startedAt = Date.now();
510
+ try {
511
+ const fetched = await fetchModelList({
512
+ baseUrl: provider.baseUrl,
513
+ apiKey: provider.apiKey,
514
+ apiFormat: provider.apiFormat,
515
+ proxy: provider.proxy,
516
+ headers: provider.headers,
517
+ });
518
+ result.modelsEndpoint = { ok: true, count: fetched.models.length, endpoint: fetched.endpoint };
519
+ result.modelsLatencyMs = Date.now() - startedAt;
520
+ result.models = fetched.models.slice(0, 10);
521
+ if (fetched.models.length > 10)
522
+ result.modelsTruncated = true;
523
+ }
524
+ catch (err) {
525
+ result.modelsEndpoint = {
526
+ ok: false,
527
+ error: err instanceof Error ? err.message : String(err),
528
+ };
529
+ }
530
+ if (opts.call) {
531
+ const model = opts.model || provider.models[0] || result.models?.[0];
532
+ if (!model) {
533
+ result.completion = { ok: false, error: "没有可用模型 id;请用 --model 指定" };
534
+ }
535
+ else {
536
+ const targetFormat = providerFormat(provider);
537
+ const hub = {
538
+ model,
539
+ messages: [{ role: "user", content: "ping" }],
540
+ max_tokens: 1,
541
+ };
542
+ const callStarted = Date.now();
543
+ try {
544
+ const response = await requestWithNodeTransport({
545
+ url: upstreamUrl(provider, upstreamPath(targetFormat)),
546
+ method: "POST",
547
+ headers: buildUpstreamHeaders(provider),
548
+ body: JSON.stringify(chatRequestToUpstream(targetFormat, hub)),
549
+ proxy: provider.proxy,
550
+ signal: AbortSignal.timeout(30_000),
551
+ totalTimeoutMs: 30_000,
552
+ });
553
+ const text = await response.text().catch(() => "");
554
+ result.completion = {
555
+ ok: response.ok,
556
+ status: response.status,
557
+ latencyMs: Date.now() - callStarted,
558
+ model,
559
+ ...(response.ok
560
+ ? { body: text.slice(0, 300) }
561
+ : { error: text.slice(0, 300) }),
562
+ };
563
+ }
564
+ catch (err) {
565
+ result.completion = {
566
+ ok: false,
567
+ latencyMs: Date.now() - callStarted,
568
+ model,
569
+ error: err instanceof Error ? err.message : String(err),
570
+ };
571
+ }
572
+ }
573
+ }
574
+ if (opts.json) {
575
+ console.log(JSON.stringify(result, null, 2));
576
+ return;
577
+ }
578
+ const models = result.modelsEndpoint;
579
+ if (models?.ok) {
580
+ console.log(`模型列表:OK(${models.count} 个,${result.modelsLatencyMs}ms)`);
581
+ }
582
+ else {
583
+ console.log(`模型列表:失败 — ${models?.error ?? "未知错误"}`);
584
+ }
585
+ const completion = result.completion;
586
+ if (completion) {
587
+ if (completion.ok) {
588
+ console.log(`补全请求:OK(HTTP ${completion.status},${completion.latencyMs}ms)`);
589
+ }
590
+ else {
591
+ console.log(`补全请求:失败 — ${completion.error ?? `HTTP ${completion.status}`}`);
592
+ }
593
+ }
594
+ const allOk = models?.ok && (!completion || completion.ok);
595
+ if (!allOk)
596
+ process.exitCode = 1;
597
+ });
598
+ provider
599
+ .command("refresh-models")
600
+ .description("从上游重新拉取模型列表并覆盖本地缓存")
601
+ .argument("<name>", "供应商名称")
602
+ .action(async (name) => {
603
+ const provider = requireGatewayProvider(name);
604
+ const fetched = await fetchModelList({
605
+ baseUrl: provider.baseUrl,
606
+ apiKey: provider.apiKey,
607
+ apiFormat: provider.apiFormat,
608
+ proxy: provider.proxy,
609
+ headers: provider.headers,
610
+ });
611
+ if (!fetched.models.length) {
612
+ console.log("上游返回空列表,未做修改。");
613
+ return;
614
+ }
615
+ const saved = saveGatewayProvider({ ...provider, models: fetched.models });
616
+ console.log(`已更新 ${saved.name} 的模型列表(${saved.models.length} 个)`);
617
+ for (const model of saved.models.slice(0, 20))
618
+ console.log(` - ${model}`);
619
+ if (saved.models.length > 20)
620
+ console.log(` … 共 ${saved.models.length} 个`);
621
+ });
622
+ provider
623
+ .command("edit")
624
+ .description("修改供应商字段")
625
+ .argument("<name>", "供应商名称")
626
+ .option("--display-name <name>", "显示名称")
627
+ .option("--base-url <url>", "API 地址")
628
+ .option("--api-key <key>", "API Key")
629
+ .option("--format <format>", "接口类型")
630
+ .option("--models <list>", "逗号分隔的模型列表(覆盖)")
631
+ .option("--priority <n>", "优先级")
632
+ .option("--proxy <url>", "上游代理 URL(传空字符串清除)")
633
+ .option("--path-prefix <prefix>", "baseUrl 与 API 路径之间的前缀,默认 v1;传空字符串表示直连 baseUrl")
634
+ .option("--clear-headers", "清除已配置的自定义请求头")
635
+ .addOption(new Option("--header <value>", '自定义上游请求头,格式 "名称: 值",可重复传入(合并进现有配置)')
636
+ .argParser((value, previous) => parseHeaderEntry(value, previous ?? {}))
637
+ .default({}))
638
+ .action((name, opts) => {
639
+ const current = requireGatewayProvider(name);
640
+ const next = { ...current };
641
+ if (opts.displayName)
642
+ next.displayName = opts.displayName;
643
+ if (opts.baseUrl)
644
+ next.baseUrl = opts.baseUrl;
645
+ if (opts.apiKey !== undefined)
646
+ next.apiKey = opts.apiKey;
647
+ if (opts.format) {
648
+ if (!isApiFormat(opts.format))
649
+ bail(`无效的 --format:${opts.format}`);
650
+ next.apiFormat = opts.format;
651
+ }
652
+ if (opts.models !== undefined)
653
+ next.models = splitList(opts.models);
654
+ if (opts.priority !== undefined) {
655
+ const priority = Number(opts.priority);
656
+ if (!Number.isFinite(priority))
657
+ bail("--priority 必须是数字");
658
+ next.priority = priority;
659
+ }
660
+ if (opts.proxy !== undefined) {
661
+ next.proxy = opts.proxy.trim() ? opts.proxy.trim() : undefined;
662
+ }
663
+ if (opts.pathPrefix !== undefined) {
664
+ next.pathPrefix = opts.pathPrefix.trim() ? opts.pathPrefix : "";
665
+ }
666
+ if (opts.clearHeaders) {
667
+ next.headers = {};
668
+ }
669
+ if (opts.header && Object.keys(opts.header).length) {
670
+ next.headers = { ...next.headers, ...opts.header };
671
+ }
672
+ const saved = saveGatewayProvider(next);
673
+ console.log(`已更新供应商 ${saved.name}`);
674
+ });
675
+ provider
676
+ .command("enable")
677
+ .description("启用供应商")
678
+ .argument("<name>", "供应商名称")
679
+ .action((name) => {
680
+ const saved = saveGatewayProvider({
681
+ ...requireGatewayProvider(name),
682
+ enabled: true,
683
+ });
684
+ console.log(`已启用 ${saved.name}`);
685
+ });
686
+ provider
687
+ .command("disable")
688
+ .description("停用供应商(保留配置,不参与路由)")
689
+ .argument("<name>", "供应商名称")
690
+ .action((name) => {
691
+ const saved = saveGatewayProvider({
692
+ ...requireGatewayProvider(name),
693
+ enabled: false,
694
+ });
695
+ console.log(`已停用 ${saved.name}`);
696
+ });
697
+ provider
698
+ .command("remove")
699
+ .description("删除供应商(同时清理相关路由)")
700
+ .argument("<name>", "供应商名称")
701
+ .option("--yes", "跳过确认")
702
+ .action(async (name, opts) => {
703
+ requireGatewayProvider(name);
704
+ if (!opts.yes) {
705
+ const ok = await confirm({ message: `确认删除供应商 ${name}?` });
706
+ if (isCancel(ok) || !ok)
707
+ bail("已取消");
708
+ }
709
+ deleteGatewayProvider(name);
710
+ console.log(`已删除供应商 ${name}`);
711
+ });
712
+ }
713
+ // --- keys -------------------------------------------------------------------
714
+ function registerKeyCommands(gateway) {
715
+ const key = gateway
716
+ .command("key")
717
+ .description("管理网关 API Key(发给第三方客户端使用)");
718
+ key
719
+ .command("list", { isDefault: true })
720
+ .description("列出 API Key(不含明文)")
721
+ .option("--json", "JSON 输出")
722
+ .action((opts) => {
723
+ const keys = listGatewayKeys().map(publicKeyView);
724
+ if (opts.json) {
725
+ console.log(JSON.stringify(keys, null, 2));
726
+ return;
727
+ }
728
+ if (!keys.length) {
729
+ console.log("暂无 API Key。创建:llms gateway key create");
730
+ return;
731
+ }
732
+ for (const item of keys) {
733
+ const scopes = [];
734
+ if (item.providers.length)
735
+ scopes.push(`providers=${item.providers.join("/")}`);
736
+ if (item.models.length)
737
+ scopes.push(`models=${item.models.join("/")}`);
738
+ if (!item.formats.includes("*"))
739
+ scopes.push(`formats=${item.formats.join("/")}`);
740
+ if (item.rateLimitPerMinute === -1)
741
+ scopes.push("rpm=unlimited");
742
+ else if (item.rateLimitPerMinute > 0)
743
+ scopes.push(`rpm=${item.rateLimitPerMinute}`);
744
+ if (item.requestsPerDay)
745
+ scopes.push(`daily=${item.requestsPerDay}`);
746
+ console.log(`${item.status === "active" ? "●" : "○"} ${item.id} ${item.name} ${item.hint} ${item.status}${item.expiresAt ? ` 过期=${item.expiresAt}` : ""}${scopes.length ? ` [${scopes.join(" ")}]` : ""}`);
747
+ }
748
+ });
749
+ key
750
+ .command("create")
751
+ .description("创建 API Key(明文仅显示一次)")
752
+ .option("--name <name>", "备注名称")
753
+ .option("--expires-in-days <n>", "有效期天数(缺省永不过期)")
754
+ .option("--providers <list>", "限定可用供应商,逗号分隔")
755
+ .option("--models <list>", "限定可用模型,逗号分隔")
756
+ .option("--formats <list>", "限定可用接口格式:openai-chat,anthropic,openai-responses")
757
+ .option("--rate-limit <rpm>", "该 Key 每分钟请求上限;-1 表示完全不限流,0 表示继承全局默认")
758
+ .option("--daily-requests <n>", "该 Key 每日请求配额(UTC 日重置,0 表示不限)")
759
+ .action((opts) => {
760
+ const days = opts.expiresInDays ? Number(opts.expiresInDays) : 0;
761
+ if (opts.expiresInDays && !Number.isFinite(days)) {
762
+ bail("--expires-in-days 必须是数字");
763
+ }
764
+ const rateLimitRaw = opts.rateLimit ? Number(opts.rateLimit) : 0;
765
+ if (opts.rateLimit && !Number.isFinite(rateLimitRaw)) {
766
+ bail("--rate-limit 必须是数字");
767
+ }
768
+ const dailyRaw = opts.dailyRequests ? Number(opts.dailyRequests) : 0;
769
+ if (opts.dailyRequests && (!Number.isInteger(dailyRaw) || dailyRaw < 0)) {
770
+ bail("--daily-requests 必须是非负整数");
771
+ }
772
+ // Typos in scope lists fail closed (all requests denied) and are hard to
773
+ // diagnose later, so flag anything unknown at creation time.
774
+ const knownProviders = new Set(listGatewayProviders().map((p) => p.name.toLowerCase()));
775
+ for (const name of splitList(opts.providers)) {
776
+ if (!knownProviders.has(name.toLowerCase())) {
777
+ console.warn(`警告:供应商「${name}」不存在,限定该供应商的请求将全部被拒绝。`);
778
+ }
779
+ }
780
+ const routable = new Set(listRoutableModelIds().map((m) => m.toLowerCase()));
781
+ for (const model of splitList(opts.models)) {
782
+ if (!routable.has(model.toLowerCase())) {
783
+ console.warn(`警告:模型「${model}」当前不在可路由列表中(passthrough 上游仍可能接受它)。`);
784
+ }
785
+ }
786
+ const created = createGatewayKey({
787
+ name: opts.name,
788
+ expiresInDays: days,
789
+ providers: splitList(opts.providers),
790
+ models: splitList(opts.models),
791
+ formats: splitList(opts.formats),
792
+ rateLimitPerMinute: Math.trunc(rateLimitRaw),
793
+ requestsPerDay: opts.dailyRequests ? Number(opts.dailyRequests) : 0,
794
+ });
795
+ console.log("已创建 API Key。请立即保存,明文不会再次显示:");
796
+ console.log("");
797
+ console.log(` ${created.plaintext}`);
798
+ console.log("");
799
+ console.log(`id=${created.key.id} name=${created.key.name}`);
800
+ console.log(`OpenAI base:${gatewayBaseUrl()}`);
801
+ console.log(`Anthropic base:${gatewayRootUrl()}`);
802
+ });
803
+ key
804
+ .command("edit")
805
+ .description("修改 API Key 的作用域、限额或有效期")
806
+ .argument("<idOrName>", "Key id 或名称")
807
+ .option("--name <name>", "备注名称")
808
+ .option("--providers <list>", "限定可用供应商,逗号分隔(覆盖)")
809
+ .option("--models <list>", "限定可用模型,逗号分隔(覆盖)")
810
+ .option("--formats <list>", "限定可用接口格式:openai-chat,anthropic,openai-responses(覆盖)")
811
+ .option("--rate-limit <rpm>", "每分钟请求上限;-1 表示完全不限流,0 表示继承全局默认")
812
+ .option("--expires-in-days <n>", "新的有效期天数(从现在起算);0 表示永不过期")
813
+ .action((idOrName, opts) => {
814
+ const patch = {};
815
+ if (opts.name !== undefined)
816
+ patch.name = opts.name;
817
+ if (opts.providers !== undefined)
818
+ patch.providers = splitList(opts.providers);
819
+ if (opts.models !== undefined)
820
+ patch.models = splitList(opts.models);
821
+ if (opts.formats !== undefined)
822
+ patch.formats = splitList(opts.formats);
823
+ if (opts.rateLimit !== undefined) {
824
+ const value = Number(opts.rateLimit);
825
+ if (!Number.isFinite(value))
826
+ bail("--rate-limit 必须是数字");
827
+ patch.rateLimitPerMinute = Math.trunc(value);
828
+ }
829
+ if (opts.dailyRequests !== undefined) {
830
+ const value = Number(opts.dailyRequests);
831
+ if (!Number.isInteger(value) || value < 0) {
832
+ bail("--daily-requests 必须是非负整数");
833
+ }
834
+ patch.requestsPerDay = value;
835
+ }
836
+ if (opts.expiresInDays !== undefined) {
837
+ const value = Number(opts.expiresInDays);
838
+ if (!Number.isFinite(value) || value < 0) {
839
+ bail("--expires-in-days 必须是非负数字");
840
+ }
841
+ patch.expiresInDays = value;
842
+ }
843
+ if (!Object.keys(patch).length) {
844
+ bail("没有指定任何修改项;可用 --name/--providers/--models/--formats/--rate-limit/--expires-in-days");
845
+ }
846
+ const updated = updateGatewayKey(idOrName, patch);
847
+ console.log(`已更新 ${updated.id}(${updated.name})`);
848
+ console.log(JSON.stringify(publicKeyView(updated), null, 2));
849
+ });
850
+ key
851
+ .command("rotate")
852
+ .description("换发 API Key(保留作用域,旧明文立即失效,新明文只显示一次)")
853
+ .argument("<idOrName>", "Key id 或名称")
854
+ .action((idOrName) => {
855
+ const rotated = rotateGatewayKey(idOrName);
856
+ console.log(`已换发 ${rotated.key.id}(${rotated.key.name})。请立即保存新明文,不会再次显示:`);
857
+ console.log("");
858
+ console.log(` ${rotated.plaintext}`);
859
+ console.log("");
860
+ });
861
+ key
862
+ .command("revoke")
863
+ .description("吊销 API Key(保留记录)")
864
+ .argument("<idOrName>", "Key id 或名称")
865
+ .action((idOrName) => {
866
+ const revoked = revokeGatewayKey(idOrName);
867
+ console.log(`已吊销 ${revoked.id}(${revoked.name})`);
868
+ });
869
+ key
870
+ .command("remove")
871
+ .description("彻底删除 API Key 记录")
872
+ .argument("<idOrName>", "Key id 或名称")
873
+ .option("--yes", "跳过确认")
874
+ .action(async (idOrName, opts) => {
875
+ if (!opts.yes) {
876
+ const ok = await confirm({
877
+ message: `确认删除 API Key ${idOrName}?删除后无法审计其历史。`,
878
+ });
879
+ if (isCancel(ok) || !ok)
880
+ bail("已取消");
881
+ }
882
+ const removed = deleteGatewayKey(idOrName);
883
+ console.log(`已删除 ${removed.id}(${removed.name})`);
884
+ });
885
+ }
886
+ // --- routes -----------------------------------------------------------------
887
+ function registerRouteCommands(gateway) {
888
+ const route = gateway
889
+ .command("route")
890
+ .description("管理模型别名路由(把客户端模型 id 映射到供应商/模型)");
891
+ route
892
+ .command("list", { isDefault: true })
893
+ .description("列出模型路由")
894
+ .option("--json", "JSON 输出")
895
+ .action((opts) => {
896
+ const routes = listGatewayRoutes();
897
+ if (opts.json) {
898
+ console.log(JSON.stringify(routes, null, 2));
899
+ return;
900
+ }
901
+ if (!routes.length) {
902
+ console.log("暂无模型路由。添加:llms gateway route add <alias> --provider <name>");
903
+ return;
904
+ }
905
+ for (const item of routes) {
906
+ const fallbacks = (item.fallbacks || [])
907
+ .map((fb) => `${fb.provider}${fb.model ? `/${fb.model}` : ""}`)
908
+ .join(" → ");
909
+ console.log(`${item.alias} → ${item.provider}${item.model ? `/${item.model}` : ""}${fallbacks ? ` fallback: ${fallbacks}` : ""}`);
910
+ }
911
+ });
912
+ route
913
+ .command("add")
914
+ .description("添加或覆盖模型路由")
915
+ .argument("<alias>", "客户端看到的模型 id")
916
+ .requiredOption("--provider <name>", "主供应商名称")
917
+ .option("--model <model>", "上游模型 id(缺省与 alias 相同)")
918
+ .option("--fallback <list>", "fallback 列表,逗号分隔,支持 provider 或 provider/model")
919
+ .action((alias, opts) => {
920
+ const fallbacks = splitList(opts.fallback).map((entry) => {
921
+ const index = entry.indexOf("/");
922
+ if (index <= 0)
923
+ return { provider: entry };
924
+ return {
925
+ provider: entry.slice(0, index),
926
+ model: entry.slice(index + 1),
927
+ };
928
+ });
929
+ const saved = saveGatewayRoute({
930
+ alias,
931
+ provider: opts.provider,
932
+ ...(opts.model ? { model: opts.model } : {}),
933
+ ...(fallbacks.length ? { fallbacks } : {}),
934
+ updatedAt: new Date().toISOString(),
935
+ });
936
+ console.log(`已配置路由 ${saved.alias} → ${saved.provider}${saved.model ? `/${saved.model}` : ""}`);
937
+ });
938
+ route
939
+ .command("remove")
940
+ .description("删除模型路由")
941
+ .argument("<alias>", "模型别名")
942
+ .action((alias) => {
943
+ deleteGatewayRoute(alias);
944
+ console.log(`已删除路由 ${alias}`);
945
+ });
946
+ }
947
+ // --- config -----------------------------------------------------------------
948
+ function registerConfigCommands(gateway) {
949
+ const config = gateway
950
+ .command("config")
951
+ .description("查看或修改网关全局配置");
952
+ config
953
+ .command("show", { isDefault: true })
954
+ .description("显示当前配置与生效的运行时限额")
955
+ .action(() => {
956
+ const config = readGatewayConfig();
957
+ const limits = parseBridgeRuntimeLimits();
958
+ console.log(JSON.stringify({ config, runtimeLimits: limits }, null, 2));
959
+ });
960
+ config
961
+ .command("set")
962
+ .description("修改配置项")
963
+ .option("--default-provider <name>", "未知模型的兜底供应商(传空清除)")
964
+ .option("--fallback <bool>", "是否启用 provider fallback:true | false")
965
+ .option("--max-attempts <n>", "单次请求最多尝试的上游数量")
966
+ .option("--retry-statuses <list>", "触发 fallback 的 HTTP 状态码,逗号分隔")
967
+ .option("--cors-origins <list>", "允许的浏览器来源,逗号分隔,* 表示全部")
968
+ .option("--rate-limit <rpm>", "默认每分钟请求上限(0 表示不限)")
969
+ .action((opts) => {
970
+ const current = readGatewayConfig();
971
+ const next = { ...current };
972
+ if (opts.defaultProvider !== undefined) {
973
+ const name = opts.defaultProvider.trim();
974
+ if (name)
975
+ requireGatewayProvider(name);
976
+ next.defaultProvider = name || null;
977
+ }
978
+ if (opts.fallback !== undefined) {
979
+ if (!/^(true|false)$/i.test(opts.fallback)) {
980
+ bail("--fallback 只能是 true 或 false");
981
+ }
982
+ next.fallback = {
983
+ ...next.fallback,
984
+ enabled: /^true$/i.test(opts.fallback),
985
+ };
986
+ }
987
+ if (opts.maxAttempts !== undefined) {
988
+ const value = Number(opts.maxAttempts);
989
+ if (!Number.isInteger(value) || value < 1 || value > 10) {
990
+ bail("--max-attempts 必须是 1..10 的整数");
991
+ }
992
+ next.fallback = { ...next.fallback, maxAttempts: value };
993
+ }
994
+ if (opts.retryStatuses !== undefined) {
995
+ const statuses = splitList(opts.retryStatuses).map(Number);
996
+ if (statuses.some((code) => !Number.isInteger(code) || code < 100 || code > 599)) {
997
+ bail("--retry-statuses 必须是 100..599 的整数列表");
998
+ }
999
+ next.fallback = { ...next.fallback, retryStatuses: statuses };
1000
+ }
1001
+ if (opts.corsOrigins !== undefined) {
1002
+ next.corsOrigins = splitList(opts.corsOrigins);
1003
+ }
1004
+ if (opts.rateLimit !== undefined) {
1005
+ const value = Number(opts.rateLimit);
1006
+ if (!Number.isInteger(value) || value < 0) {
1007
+ bail("--rate-limit 必须是非负整数");
1008
+ }
1009
+ next.rateLimitPerMinute = value;
1010
+ }
1011
+ writeGatewayConfig(next);
1012
+ console.log(JSON.stringify(readGatewayConfig(), null, 2));
1013
+ });
1014
+ }
1015
+ // --- prompts ----------------------------------------------------------------
1016
+ async function promptText(message) {
1017
+ const value = await text({ message });
1018
+ if (isCancel(value))
1019
+ bail("已取消");
1020
+ return String(value ?? "").trim();
1021
+ }
1022
+ async function promptSecret(message) {
1023
+ const value = await password({ message });
1024
+ if (isCancel(value))
1025
+ bail("已取消");
1026
+ return String(value ?? "").trim();
1027
+ }
1028
+ async function promptFormat() {
1029
+ const value = await select({
1030
+ message: "无法自动识别接口类型,请选择",
1031
+ options: [
1032
+ { value: "openai-chat", label: formatLabel("openai-chat") },
1033
+ { value: "anthropic", label: formatLabel("anthropic") },
1034
+ { value: "openai-responses", label: formatLabel("openai-responses") },
1035
+ ],
1036
+ });
1037
+ if (isCancel(value))
1038
+ bail("已取消");
1039
+ return value;
1040
+ }