@nvae/llmswitch 1.0.0 → 1.3.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.
@@ -13,10 +13,10 @@
13
13
  * blocked: a slightly loose limit is preferable to stalling live traffic on
14
14
  * lock contention.
15
15
  */
16
- import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
17
- import { randomBytes } from "node:crypto";
16
+ import { existsSync, readFileSync, rmSync } from "node:fs";
18
17
  import { join } from "node:path";
19
18
  import { atomicWriteFile, ensureDir } from "../utils/fs.js";
19
+ import { acquireFileLock, } from "../utils/file-lock.js";
20
20
  import { getGatewayDir } from "../utils/paths.js";
21
21
  const WINDOW_MS = 60_000;
22
22
  const DAY_MS = 86_400_000;
@@ -36,112 +36,16 @@ const UNLIMITED = {
36
36
  resetAt: 0,
37
37
  retryAfterSeconds: 0,
38
38
  };
39
- function sleepSync(ms) {
40
- const shared = new Int32Array(new SharedArrayBuffer(4));
41
- Atomics.wait(shared, 0, 0, ms);
42
- }
43
- function pidAlive(pid) {
44
- if (!Number.isInteger(pid) || pid < 1)
45
- return false;
46
- try {
47
- process.kill(pid, 0);
48
- return true;
49
- }
50
- catch (err) {
51
- return err.code === "EPERM";
52
- }
53
- }
54
39
  /**
55
40
  * Acquire the counter lock, or return null when it stays busy past the timeout.
56
- * A lock whose owner process is gone and which is older than the stale age is
57
- * reclaimed.
41
+ * Shared implementation with the usage accounting file (see utils/file-lock).
58
42
  */
59
43
  function acquireLock(now) {
60
- const path = getRateLimitLockPath();
61
- const id = randomBytes(8).toString("hex");
62
- const payload = JSON.stringify({ id, pid: process.pid, at: now });
63
- const deadline = now + LOCK_TIMEOUT_MS;
64
- // The directory may not exist yet on a fresh install; without this the
65
- // exclusive create below fails with ENOENT and persistence never engages.
66
- try {
67
- ensureDir(getGatewayDir());
68
- }
69
- catch {
70
- return null;
71
- }
72
- for (;;) {
73
- try {
74
- writeFileSync(path, payload, { encoding: "utf8", flag: "wx", mode: 0o600 });
75
- return makeLock(path, id);
76
- }
77
- catch (err) {
78
- if (err.code !== "EEXIST")
79
- return null;
80
- }
81
- if (reclaimable(path)) {
82
- try {
83
- const tmp = `${path}.${id}.tmp`;
84
- writeFileSync(tmp, payload, { encoding: "utf8", mode: 0o600 });
85
- renameSync(tmp, path);
86
- if (readLockId(path) === id)
87
- return makeLock(path, id);
88
- }
89
- catch {
90
- // Someone else won the race; fall through and retry.
91
- }
92
- }
93
- if (Date.now() >= deadline)
94
- return null;
95
- sleepSync(LOCK_SPIN_MS);
96
- }
97
- }
98
- function readLockId(path) {
99
- try {
100
- const raw = JSON.parse(readFileSync(path, "utf8"));
101
- return typeof raw.id === "string" ? raw.id : null;
102
- }
103
- catch {
104
- return null;
105
- }
106
- }
107
- /**
108
- * A lock is reclaimable when its owner is gone. A dead owner can never release
109
- * the lock, so that case is reclaimed immediately; when ownership cannot be
110
- * determined we fall back to an age check that trusts whichever of the file
111
- * mtime or the recorded timestamp looks older.
112
- */
113
- function reclaimable(path) {
114
- let record = {};
115
- let readable = false;
116
- try {
117
- record = JSON.parse(readFileSync(path, "utf8"));
118
- readable = true;
119
- }
120
- catch {
121
- // Unreadable lock file: fall back to the age check below.
122
- }
123
- if (readable && typeof record.pid === "number") {
124
- return !pidAlive(record.pid);
125
- }
126
- let fileAge = Number.POSITIVE_INFINITY;
127
- try {
128
- fileAge = Date.now() - statSync(path).mtimeMs;
129
- }
130
- catch {
131
- return true;
132
- }
133
- const recordedAge = typeof record.at === "number"
134
- ? Date.now() - record.at
135
- : Number.NEGATIVE_INFINITY;
136
- return Math.max(fileAge, recordedAge) > LOCK_STALE_MS;
137
- }
138
- function makeLock(path, id) {
139
- return {
140
- release() {
141
- if (readLockId(path) === id)
142
- rmSync(path, { force: true });
143
- },
144
- };
44
+ return acquireFileLock(getRateLimitLockPath(), {
45
+ timeoutMs: LOCK_TIMEOUT_MS,
46
+ staleMs: LOCK_STALE_MS,
47
+ spinMs: LOCK_SPIN_MS,
48
+ }, now);
145
49
  }
146
50
  function readWindows() {
147
51
  const path = getRateLimitPath();
@@ -43,7 +43,13 @@ function providerHasModel(provider, model) {
43
43
  return provider.models.some((item) => item.toLowerCase() === wanted);
44
44
  }
45
45
  /** Split `provider/model` or `provider:model`; the model part may contain `/`. */
46
- function splitQualified(requested) {
46
+ /**
47
+ * Split `provider/model` or `provider:model`. Exported so the CLI parses
48
+ * `--fallback` with exactly the same rules the router uses to resolve a
49
+ * requested model id — otherwise `provider:model` written as a fallback would be
50
+ * silently taken as a whole provider name.
51
+ */
52
+ export function splitQualified(requested) {
47
53
  for (const separator of ["/", ":"]) {
48
54
  const index = requested.indexOf(separator);
49
55
  if (index <= 0)
@@ -7,7 +7,7 @@
7
7
  * translated between formats as needed.
8
8
  */
9
9
  import { createServer, } from "node:http";
10
- import { parseBridgeRuntimeLimits } from "../bridge/runtime.js";
10
+ import { ConcurrencyGate, parseBridgeRuntimeLimits, } from "../bridge/runtime.js";
11
11
  import { randomBytes } from "node:crypto";
12
12
  import { requestWithNodeTransport, } from "../bridge/transport.js";
13
13
  import { authenticateGatewayKey, keyAllowsTarget, listGatewayKeys, touchGatewayKey, } from "./keys.js";
@@ -292,27 +292,6 @@ function applyCors(req, res, config) {
292
292
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
293
293
  res.setHeader("Access-Control-Max-Age", "600");
294
294
  }
295
- /** Bounded in-flight request counter shared by all data-plane endpoints. */
296
- class ConcurrencyGate {
297
- max;
298
- active = 0;
299
- constructor(max) {
300
- this.max = max;
301
- }
302
- get inFlight() {
303
- return this.active;
304
- }
305
- tryAcquire() {
306
- if (this.active >= this.max)
307
- return false;
308
- this.active += 1;
309
- return true;
310
- }
311
- release() {
312
- if (this.active > 0)
313
- this.active -= 1;
314
- }
315
- }
316
295
  export function createGatewayServer(options = {}) {
317
296
  const limits = parseBridgeRuntimeLimits();
318
297
  const gate = new ConcurrencyGate(limits.maxConcurrency);
@@ -5,7 +5,7 @@
5
5
  * tool (claude/codex/opencode), so the same upstream can exist three times with
6
6
  * different names. The gateway needs one tool-independent provider list.
7
7
  */
8
- import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
8
+ import { existsSync, readdirSync, readFileSync, statSync, unlinkSync, } from "node:fs";
9
9
  import { chmodSync } from "node:fs";
10
10
  import { TOOLS, isApiFormat, normalizeProxyValue } from "../types.js";
11
11
  import { ensureOpenAiV1BaseUrl, isOpenAiApiFormat } from "../utils/base-url.js";
@@ -101,10 +101,39 @@ function normalizeProvider(raw, fallbackName) {
101
101
  : new Date(0).toISOString(),
102
102
  };
103
103
  }
104
+ /**
105
+ * Provider list cache.
106
+ *
107
+ * The gateway data plane reads the provider list on every request (routing,
108
+ * /v1/models, key scoping), and each read scanned the directory and JSON-parsed
109
+ * every file. Invalidation is by directory mtime plus a short TTL, so an edit
110
+ * from another process is still picked up within a second.
111
+ */
112
+ const PROVIDER_CACHE_TTL_MS = 1_000;
113
+ let providerCache = null;
114
+ /** Drop the cache after any write (same process). */
115
+ export function invalidateGatewayProviderCache() {
116
+ providerCache = null;
117
+ }
104
118
  export function listGatewayProviders() {
105
119
  const dir = getGatewayProvidersDir();
106
120
  if (!existsSync(dir))
107
121
  return [];
122
+ let dirMtimeMs = 0;
123
+ try {
124
+ dirMtimeMs = statSync(dir).mtimeMs;
125
+ }
126
+ catch {
127
+ dirMtimeMs = 0;
128
+ }
129
+ const now = Date.now();
130
+ if (providerCache &&
131
+ // 目录路径也要比对:LLM_SWITCH_HOME 变了(测试、多配置目录)就必须重读。
132
+ providerCache.dir === dir &&
133
+ providerCache.dirMtimeMs === dirMtimeMs &&
134
+ now - providerCache.at < PROVIDER_CACHE_TTL_MS) {
135
+ return providerCache.value;
136
+ }
108
137
  const out = [];
109
138
  for (const file of readdirSync(dir)) {
110
139
  if (!file.endsWith(".json"))
@@ -114,7 +143,9 @@ export function listGatewayProviders() {
114
143
  if (provider)
115
144
  out.push(provider);
116
145
  }
117
- return out.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name));
146
+ const sorted = out.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name));
147
+ providerCache = { at: now, dir, dirMtimeMs, value: sorted };
148
+ return sorted;
118
149
  }
119
150
  export function readGatewayProvider(name) {
120
151
  const path = getGatewayProviderPath(name);
@@ -164,6 +195,7 @@ export function saveGatewayProvider(provider) {
164
195
  ensureGatewayDir();
165
196
  ensureDir(getGatewayProvidersDir());
166
197
  atomicWriteFile(getGatewayProviderPath(next.name), JSON.stringify(next, null, 2) + "\n");
198
+ invalidateGatewayProviderCache();
167
199
  return next;
168
200
  }
169
201
  export function deleteGatewayProvider(name) {
@@ -172,6 +204,7 @@ export function deleteGatewayProvider(name) {
172
204
  throw new Error(`未找到 gateway provider「${name}」`);
173
205
  }
174
206
  unlinkSync(path);
207
+ invalidateGatewayProviderCache();
175
208
  // Drop routes that pointed at the removed provider.
176
209
  const routes = listGatewayRoutes().filter((route) => {
177
210
  if (route.provider === name)
@@ -228,9 +261,9 @@ export function importProvidersFromProfiles(tools = TOOLS) {
228
261
  }
229
262
  const models = Array.from(new Set([
230
263
  profile.models.default,
231
- profile.models.fast,
264
+ profile.models.smallModel,
232
265
  ...(profile.models.list || []),
233
- ].filter((m) => Boolean(m && m.trim()))));
266
+ ].filter((m) => Boolean(m?.trim()))));
234
267
  const provider = saveGatewayProvider({
235
268
  name,
236
269
  displayName: profile.displayName || name,
@@ -2,13 +2,19 @@
2
2
  * Per-day usage accounting for the gateway data plane.
3
3
  *
4
4
  * One JSON file with daily buckets; each row aggregates requests and token
5
- * counts for a (key, provider, model) triple. Writes are read-modify-write via
6
- * the same atomic-replace pattern as the rest of the store — the daemon is the
7
- * single writer in practice, so no lock is taken and a crashed process can lose
8
- * at most the in-flight update.
5
+ * counts for a (key, provider, model) triple.
6
+ *
7
+ * The read-modify-write runs under the same kind of advisory file lock as the
8
+ * rate-limit counters. A foreground `gateway serve` and a daemon can be up at
9
+ * the same time (both record usage), and since each write atomically replaces
10
+ * the whole file, an unlocked update would silently discard the other process's
11
+ * accounting. Losing the lock race degrades to "skip this record" rather than
12
+ * blocking a live request.
9
13
  */
10
14
  import { existsSync, readFileSync } from "node:fs";
15
+ import { join } from "node:path";
11
16
  import { atomicWriteFile, ensureDir } from "../utils/fs.js";
17
+ import { acquireFileLock } from "../utils/file-lock.js";
12
18
  import { getGatewayDir, getGatewayUsagePath } from "../utils/paths.js";
13
19
  const RETENTION_DAYS = 90;
14
20
  function emptyFile() {
@@ -28,6 +34,9 @@ function nonNegativeNumber(value) {
28
34
  export function getUsagePath() {
29
35
  return getGatewayUsagePath();
30
36
  }
37
+ export function getUsageLockPath() {
38
+ return join(getGatewayDir(), "usage.lock");
39
+ }
31
40
  function dayKey(now = Date.now()) {
32
41
  return new Date(now).toISOString().slice(0, 10);
33
42
  }
@@ -108,6 +117,10 @@ function mergeRow(rows, record) {
108
117
  }
109
118
  /** Best-effort: accounting failures must never break a live request. */
110
119
  export function recordUsage(record, now = Date.now()) {
120
+ // 拿不到锁就放弃这一条统计:宁可少记一次,也不要覆盖掉另一个进程的整份账。
121
+ const lock = acquireFileLock(getUsageLockPath(), { timeoutMs: 200 }, now);
122
+ if (!lock)
123
+ return;
111
124
  try {
112
125
  const file = readUsage();
113
126
  const day = dayKey(now);
@@ -119,6 +132,9 @@ export function recordUsage(record, now = Date.now()) {
119
132
  catch {
120
133
  // Non-fatal.
121
134
  }
135
+ finally {
136
+ lock.release();
137
+ }
122
138
  }
123
139
  /** Aggregated rows for the last `days` days (inclusive of today). */
124
140
  export function summarizeUsage(options = {}, now = Date.now()) {
@@ -143,10 +159,14 @@ export function summarizeUsage(options = {}, now = Date.now()) {
143
159
  }
144
160
  /** Drop every recorded usage (test seam and `llms gateway usage reset`). */
145
161
  export function resetUsage() {
162
+ const lock = acquireFileLock(getUsageLockPath(), { timeoutMs: 200 });
146
163
  try {
147
164
  atomicWriteFile(getUsagePath(), `${JSON.stringify(emptyFile(), null, 2)}\n`);
148
165
  }
149
166
  catch {
150
167
  // Nothing persisted yet.
151
168
  }
169
+ finally {
170
+ lock?.release();
171
+ }
152
172
  }
package/dist/index.js CHANGED
File without changes
@@ -1,8 +1,8 @@
1
1
  import { existsSync, readdirSync, unlinkSync } from "node:fs";
2
2
  import { readFileSync } from "node:fs";
3
- import { isApiFormat, normalizeProxyValue } from "../types.js";
3
+ import { API_FORMATS, isApiFormat, normalizeProxyValue } from "../types.js";
4
4
  import { normalizeBaseUrlForFormat } from "../utils/base-url.js";
5
- import { atomicWriteFile, ensureDir, maskSecret } from "../utils/fs.js";
5
+ import { atomicWriteFile, ensureDir, maskSecret, readStructuredFile, } from "../utils/fs.js";
6
6
  import { getProfilePath, getProfilesDir, getStatePath, getToolStoreDir, } from "../utils/paths.js";
7
7
  const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
8
8
  /** Keep only metadata entries whose model id is still in the list. */
@@ -49,7 +49,18 @@ export function listProfiles(tool) {
49
49
  return [];
50
50
  return readdirSync(dir)
51
51
  .filter((f) => f.endsWith(".json"))
52
- .map((f) => readProfile(tool, f.replace(/\.json$/, "")))
52
+ .map((f) => {
53
+ const name = f.replace(/\.json$/, "");
54
+ try {
55
+ return readProfile(tool, name);
56
+ }
57
+ catch (err) {
58
+ // 单个坏 profile 不应让整份列表(以及所有命令)不可用。
59
+ const msg = err instanceof Error ? err.message : String(err);
60
+ console.warn(`警告:跳过损坏的 ${tool} profile「${name}」:${msg}`);
61
+ return null;
62
+ }
63
+ })
53
64
  .filter((p) => p !== null)
54
65
  .sort((a, b) => a.name.localeCompare(b.name));
55
66
  }
@@ -60,7 +71,9 @@ export function readProfile(tool, name) {
60
71
  const path = getProfilePath(tool, name);
61
72
  if (!existsSync(path))
62
73
  return null;
63
- const raw = JSON.parse(readFileSync(path, "utf8"));
74
+ const raw = readStructuredFile(path, (text) => JSON.parse(text), { label: `${tool} 供应商配置`, fallback: () => null });
75
+ if (!raw)
76
+ return null;
64
77
  return normalizeProfile(raw, name);
65
78
  }
66
79
  export function requireProfile(tool, name) {
@@ -87,6 +100,10 @@ export function resolveProfile(tool, query) {
87
100
  return byName;
88
101
  const profiles = listProfiles(tool);
89
102
  const normalized = normalizeReference(trimmed);
103
+ // 纯分隔符的输入(如 "---")归一化后为空串,而 "".includes("") 恒真,
104
+ // 会让下面的包含匹配命中任意 profile。这种查询直接视为无匹配。
105
+ if (!normalized)
106
+ return null;
90
107
  const exactDisplay = profiles.find((p) => normalizeReference(p.displayName) === normalized);
91
108
  if (exactDisplay)
92
109
  return exactDisplay;
@@ -119,18 +136,25 @@ export function saveProfile(tool, profile) {
119
136
  if (!profile.models?.default?.trim()) {
120
137
  throw new Error("默认模型不能为空");
121
138
  }
122
- const list = Array.from(new Set([profile.models.default, profile.models.fast, ...(profile.models.list || [])]
139
+ const list = Array.from(new Set([
140
+ profile.models.default,
141
+ profile.models.smallModel,
142
+ ...(profile.models.list || []),
143
+ ]
123
144
  .filter(Boolean)
124
145
  .map((m) => m.trim())));
125
146
  const meta = filterModelMeta(profile.models.meta, list);
147
+ const defaultModel = profile.models.default.trim();
148
+ const smallModel = profile.models.smallModel?.trim() || undefined;
126
149
  const next = {
127
150
  ...profile,
128
151
  displayName: profile.displayName || profile.name,
129
152
  baseUrl: normalizeBaseUrlForFormat(profile.apiFormat, profile.baseUrl),
130
153
  apiKey: profile.apiKey ?? "",
131
154
  models: {
132
- default: profile.models.default.trim(),
133
- fast: profile.models.fast?.trim() || undefined,
155
+ default: defaultModel,
156
+ // Same as the default model → no point declaring a separate small model.
157
+ smallModel: smallModel && smallModel !== defaultModel ? smallModel : undefined,
134
158
  list,
135
159
  meta,
136
160
  },
@@ -216,26 +240,42 @@ export function publicProfileView(profile) {
216
240
  updatedAt: profile.updatedAt,
217
241
  };
218
242
  }
243
+ /**
244
+ * Small model as stored on disk. Older profiles used `models.fast`; keep reading
245
+ * it so existing installs do not silently lose the setting after the rename.
246
+ */
247
+ function legacySmallModel(raw) {
248
+ const models = raw.models;
249
+ const value = models?.smallModel ?? models?.fast;
250
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
251
+ }
219
252
  function normalizeProfile(raw, fallbackName) {
220
253
  const name = raw.name || fallbackName;
254
+ // apiFormat 非法时不要透传成合法类型再等到 apply 才炸——那时用户已经改了一堆东西。
255
+ if (!isApiFormat(raw.apiFormat)) {
256
+ throw new Error(`供应商「${name}」的 apiFormat「${String(raw.apiFormat)}」无效。` +
257
+ `可选:${API_FORMATS.join("、")}。请修正该 profile 或重新添加。`);
258
+ }
259
+ const apiFormat = raw.apiFormat;
221
260
  const list = Array.from(new Set([
222
261
  raw.models?.default,
223
- raw.models?.fast,
262
+ legacySmallModel(raw),
224
263
  ...(raw.models?.list || []),
225
264
  ]
226
265
  .filter(Boolean)
227
266
  .map((m) => String(m).trim())));
267
+ const defaultModel = raw.models?.default || list[0] || "";
228
268
  return {
229
269
  name,
230
270
  displayName: raw.displayName || name,
231
- apiFormat: raw.apiFormat,
232
- baseUrl: isApiFormat(raw.apiFormat)
233
- ? normalizeBaseUrlForFormat(raw.apiFormat, String(raw.baseUrl || ""))
234
- : String(raw.baseUrl || "").replace(/\/+$/, ""),
271
+ apiFormat,
272
+ baseUrl: normalizeBaseUrlForFormat(apiFormat, String(raw.baseUrl || "")),
235
273
  apiKey: raw.apiKey ?? "",
236
274
  models: {
237
- default: raw.models?.default || list[0] || "",
238
- fast: raw.models?.fast || undefined,
275
+ default: defaultModel,
276
+ smallModel: legacySmallModel(raw) && legacySmallModel(raw) !== defaultModel
277
+ ? legacySmallModel(raw)
278
+ : undefined,
239
279
  list: list.length ? list : raw.models?.default ? [raw.models.default] : [],
240
280
  meta: filterModelMeta(raw.models?.meta, list),
241
281
  },
package/dist/types.js CHANGED
@@ -4,6 +4,18 @@ export const API_FORMATS = [
4
4
  "openai-chat",
5
5
  "openai-responses",
6
6
  ];
7
+ /**
8
+ * Tools whose config supports a second, lightweight model alongside the main
9
+ * one (title generation, summaries and other cheap tasks):
10
+ * - claude: ANTHROPIC_SMALL_FAST_MODEL / ANTHROPIC_DEFAULT_HAIKU_MODEL
11
+ * - opencode: top-level `small_model`
12
+ * Codex has no equivalent knob.
13
+ */
14
+ const SMALL_MODEL_TOOLS = ["claude", "opencode"];
15
+ /** Whether `models.smallModel` is meaningful for the given tool. */
16
+ export function supportsSmallModel(tool) {
17
+ return SMALL_MODEL_TOOLS.includes(tool);
18
+ }
7
19
  export function isTool(value) {
8
20
  return TOOLS.includes(value);
9
21
  }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Terminal display-width helpers.
3
+ *
4
+ * `String.padEnd` counts UTF-16 code units, but CJK characters occupy two
5
+ * terminal columns. Padding Chinese headers or provider names with padEnd/
6
+ * padStart therefore produces visibly ragged columns. These helpers pad by
7
+ * rendered width instead.
8
+ */
9
+ /**
10
+ * Columns occupied by one code point. Covers the East Asian Wide/Fullwidth
11
+ * ranges we actually emit (CJK, kana, hangul, fullwidth forms, common
12
+ * punctuation and emoji); everything else counts as one column.
13
+ */
14
+ function codePointWidth(cp) {
15
+ // Combining marks and zero-width characters take no space.
16
+ if (cp === 0x200b || cp === 0x200c || cp === 0x200d || cp === 0xfeff)
17
+ return 0;
18
+ if (cp >= 0x0300 && cp <= 0x036f)
19
+ return 0;
20
+ if ((cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
21
+ (cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals, Kangxi, CJK punctuation
22
+ (cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, Hangul compat, CJK compat
23
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Ext A
24
+ (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified
25
+ (cp >= 0xa000 && cp <= 0xa4cf) || // Yi
26
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
27
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compat ideographs
28
+ (cp >= 0xfe30 && cp <= 0xfe6f) || // CJK compat forms
29
+ (cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms
30
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
31
+ (cp >= 0x1f300 && cp <= 0x1f64f) || // Emoji
32
+ (cp >= 0x1f900 && cp <= 0x1f9ff) ||
33
+ (cp >= 0x20000 && cp <= 0x3fffd) // CJK Ext B+
34
+ ) {
35
+ return 2;
36
+ }
37
+ return 1;
38
+ }
39
+ /** Rendered width of a string in terminal columns. */
40
+ export function displayWidth(value) {
41
+ let width = 0;
42
+ for (const char of value) {
43
+ width += codePointWidth(char.codePointAt(0));
44
+ }
45
+ return width;
46
+ }
47
+ /** Pad on the right to `width` rendered columns. */
48
+ export function padEndDisplay(value, width) {
49
+ const missing = width - displayWidth(value);
50
+ return missing > 0 ? value + " ".repeat(missing) : value;
51
+ }
52
+ /** Pad on the left to `width` rendered columns. */
53
+ export function padStartDisplay(value, width) {
54
+ const missing = width - displayWidth(value);
55
+ return missing > 0 ? " ".repeat(missing) + value : value;
56
+ }
57
+ /**
58
+ * Render a fixed-width table sized to its content, aligned by display width.
59
+ * Returns header + rows; the caller decides how to print them.
60
+ */
61
+ export function renderTable(rows, columns) {
62
+ const cells = rows.map((row) => columns.map((col) => col.value(row)));
63
+ const widths = columns.map((col, i) => Math.max(displayWidth(col.header), ...cells.map((row) => displayWidth(row[i] ?? ""))));
64
+ const line = (values) => values
65
+ .map((value, i) => columns[i].align === "right"
66
+ ? padStartDisplay(value, widths[i])
67
+ : padEndDisplay(value, widths[i]))
68
+ .join(" ")
69
+ .trimEnd();
70
+ return [line(columns.map((col) => col.header)), ...cells.map(line)];
71
+ }
@@ -69,7 +69,25 @@ export function buildModelsRequestHeaders(apiFormat, apiKey, customHeaders = {})
69
69
  }
70
70
  return headers;
71
71
  }
72
- export function parseModelIds(payload) {
72
+ /**
73
+ * Extract a model identifier from a list item. Providers disagree on the field
74
+ * name: OpenAI uses `id`, some gateways `name`/`model`, and Codex-style
75
+ * catalogs (e.g. BigModel) use `slug`.
76
+ */
77
+ export function modelItemId(item) {
78
+ if (typeof item === "string")
79
+ return item.trim();
80
+ if (!item || typeof item !== "object")
81
+ return "";
82
+ const row = item;
83
+ const id = row.id ?? row.name ?? row.model ?? row.slug;
84
+ return typeof id === "string" ? id.trim() : "";
85
+ }
86
+ /**
87
+ * Collect the model-array buckets from a /models payload, accepting the common
88
+ * `data` and `models` shapes plus a bare array.
89
+ */
90
+ export function modelsFromPayload(payload) {
73
91
  if (!payload || typeof payload !== "object")
74
92
  return [];
75
93
  const root = payload;
@@ -80,18 +98,14 @@ export function parseModelIds(payload) {
80
98
  buckets.push(...root.models);
81
99
  if (Array.isArray(payload))
82
100
  buckets.push(...payload);
101
+ return buckets;
102
+ }
103
+ export function parseModelIds(payload) {
83
104
  const ids = new Set();
84
- for (const item of buckets) {
85
- if (typeof item === "string" && item.trim()) {
86
- ids.add(item.trim());
87
- continue;
88
- }
89
- if (!item || typeof item !== "object")
90
- continue;
91
- const row = item;
92
- const id = row.id ?? row.name ?? row.model;
93
- if (typeof id === "string" && id.trim())
94
- ids.add(id.trim());
105
+ for (const item of modelsFromPayload(payload)) {
106
+ const id = modelItemId(item);
107
+ if (id)
108
+ ids.add(id);
95
109
  }
96
110
  return Array.from(ids).sort((a, b) => a.localeCompare(b));
97
111
  }