@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.
- package/README.md +52 -9
- package/dist/adapters/claude.js +10 -10
- package/dist/adapters/codex.js +68 -16
- package/dist/adapters/opencode.js +45 -25
- package/dist/bridge/manager.js +8 -1
- package/dist/bridge/responses-to-chat-response.js +1 -1
- package/dist/bridge/runtime.js +40 -2
- package/dist/bridge/server.js +71 -26
- package/dist/bridge/state.js +59 -27
- package/dist/bridge/translate-response.js +98 -0
- package/dist/bridge/transport.js +39 -8
- package/dist/cli.js +18 -6
- package/dist/commands/bridge-cmd.js +15 -8
- package/dist/commands/gateway-cmd.js +112 -31
- package/dist/commands/home-cmd.js +20 -2
- package/dist/commands/launch-cmd.js +4 -0
- package/dist/commands/launch.js +36 -13
- package/dist/commands/prompts.js +96 -13
- package/dist/commands/tool.js +298 -134
- package/dist/gateway/keys.js +11 -2
- package/dist/gateway/rate-limit.js +8 -104
- package/dist/gateway/router.js +7 -1
- package/dist/gateway/server.js +1 -22
- package/dist/gateway/store.js +37 -4
- package/dist/gateway/usage.js +24 -4
- package/dist/index.js +0 -0
- package/dist/store/profiles.js +54 -14
- package/dist/types.js +12 -0
- package/dist/utils/display.js +71 -0
- package/dist/utils/fetch-models.js +26 -12
- package/dist/utils/file-lock.js +149 -0
- package/dist/utils/fs.js +187 -9
- package/dist/utils/model-metadata.js +54 -1
- package/package.json +7 -2
|
@@ -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,
|
|
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
|
-
*
|
|
57
|
-
* reclaimed.
|
|
41
|
+
* Shared implementation with the usage accounting file (see utils/file-lock).
|
|
58
42
|
*/
|
|
59
43
|
function acquireLock(now) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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();
|
package/dist/gateway/router.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/dist/gateway/server.js
CHANGED
|
@@ -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);
|
package/dist/gateway/store.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
264
|
+
profile.models.smallModel,
|
|
232
265
|
...(profile.models.list || []),
|
|
233
|
-
].filter((m) => Boolean(m
|
|
266
|
+
].filter((m) => Boolean(m?.trim()))));
|
|
234
267
|
const provider = saveGatewayProvider({
|
|
235
268
|
name,
|
|
236
269
|
displayName: profile.displayName || name,
|
package/dist/gateway/usage.js
CHANGED
|
@@ -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.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
package/dist/store/profiles.js
CHANGED
|
@@ -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) =>
|
|
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(
|
|
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([
|
|
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:
|
|
133
|
-
|
|
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
|
|
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
|
|
232
|
-
baseUrl:
|
|
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:
|
|
238
|
-
|
|
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
|
-
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
}
|