@nvae/llmswitch 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +220 -0
- package/dist/adapters/opencode.js +28 -12
- package/dist/bridge/anthropic-to-chat-response.js +332 -0
- package/dist/bridge/chat-to-anthropic-request.js +270 -0
- package/dist/bridge/chat-to-responses-request.js +216 -0
- package/dist/bridge/manager.js +45 -13
- package/dist/bridge/responses-to-chat-response.js +393 -0
- package/dist/bridge/server.js +109 -4
- package/dist/bridge/state.js +8 -2
- package/dist/bridge/types.js +4 -2
- package/dist/cli.js +2 -0
- package/dist/commands/bridge-cmd.js +3 -2
- package/dist/commands/gateway-cmd.js +1040 -0
- package/dist/gateway/health.js +45 -0
- package/dist/gateway/keys.js +433 -0
- package/dist/gateway/manager.js +278 -0
- package/dist/gateway/pipeline.js +328 -0
- package/dist/gateway/rate-limit.js +285 -0
- package/dist/gateway/router.js +163 -0
- package/dist/gateway/runtime.js +45 -0
- package/dist/gateway/server.js +1053 -0
- package/dist/gateway/state.js +135 -0
- package/dist/gateway/store.js +392 -0
- package/dist/gateway/tokens.js +423 -0
- package/dist/gateway/types.js +30 -0
- package/dist/gateway/usage.js +152 -0
- package/dist/utils/paths.js +24 -0
- package/package.json +1 -1
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway runtime state: listener address and the live daemon instance.
|
|
3
|
+
* Kept in its own file/port namespace so the outward-facing gateway never
|
|
4
|
+
* interferes with the local bridge.
|
|
5
|
+
*/
|
|
6
|
+
import { chmodSync, existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
8
|
+
import { atomicWriteFile, ensureDir } from "../utils/fs.js";
|
|
9
|
+
import { getGatewayDir, getGatewayStatePath } from "../utils/paths.js";
|
|
10
|
+
import { DEFAULT_GATEWAY_HOST, DEFAULT_GATEWAY_PORT, } from "./types.js";
|
|
11
|
+
const STATE_VERSION = 1;
|
|
12
|
+
const MAX_PID = 2_147_483_647;
|
|
13
|
+
export function generateGatewayControlToken() {
|
|
14
|
+
return randomBytes(32).toString("base64url");
|
|
15
|
+
}
|
|
16
|
+
export function constantTimeTokenEqual(a, b) {
|
|
17
|
+
if (typeof a !== "string" || typeof b !== "string")
|
|
18
|
+
return false;
|
|
19
|
+
const bufA = Buffer.from(a, "utf8");
|
|
20
|
+
const bufB = Buffer.from(b, "utf8");
|
|
21
|
+
if (bufA.length !== bufB.length)
|
|
22
|
+
return false;
|
|
23
|
+
return timingSafeEqual(bufA, bufB);
|
|
24
|
+
}
|
|
25
|
+
function isValidPid(pid) {
|
|
26
|
+
return (typeof pid === "number" &&
|
|
27
|
+
Number.isInteger(pid) &&
|
|
28
|
+
pid >= 1 &&
|
|
29
|
+
pid <= MAX_PID);
|
|
30
|
+
}
|
|
31
|
+
function defaultListener() {
|
|
32
|
+
return {
|
|
33
|
+
bindHost: process.env.LLM_SWITCH_GATEWAY_HOST || DEFAULT_GATEWAY_HOST,
|
|
34
|
+
advertiseHost: process.env.LLM_SWITCH_GATEWAY_HOST || DEFAULT_GATEWAY_HOST,
|
|
35
|
+
port: Number(process.env.LLM_SWITCH_GATEWAY_PORT) || DEFAULT_GATEWAY_PORT,
|
|
36
|
+
allowRemote: false,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function parseListener(raw) {
|
|
40
|
+
const fallback = defaultListener();
|
|
41
|
+
if (!raw || typeof raw !== "object")
|
|
42
|
+
return fallback;
|
|
43
|
+
const row = raw;
|
|
44
|
+
return {
|
|
45
|
+
bindHost: typeof row.bindHost === "string" ? row.bindHost : fallback.bindHost,
|
|
46
|
+
advertiseHost: typeof row.advertiseHost === "string"
|
|
47
|
+
? row.advertiseHost
|
|
48
|
+
: fallback.advertiseHost,
|
|
49
|
+
port: typeof row.port === "number" ? row.port : fallback.port,
|
|
50
|
+
allowRemote: row.allowRemote === true,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function parseInstance(raw) {
|
|
54
|
+
if (!raw || typeof raw !== "object")
|
|
55
|
+
return null;
|
|
56
|
+
const row = raw;
|
|
57
|
+
if (typeof row.id !== "string" ||
|
|
58
|
+
typeof row.controlToken !== "string" ||
|
|
59
|
+
!isValidPid(row.pid)) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
id: row.id,
|
|
64
|
+
controlToken: row.controlToken,
|
|
65
|
+
pid: row.pid,
|
|
66
|
+
startedAt: typeof row.startedAt === "string" ? row.startedAt : "",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export function readGatewayState() {
|
|
70
|
+
const path = getGatewayStatePath();
|
|
71
|
+
if (!existsSync(path)) {
|
|
72
|
+
return {
|
|
73
|
+
version: STATE_VERSION,
|
|
74
|
+
revision: 0,
|
|
75
|
+
listener: defaultListener(),
|
|
76
|
+
instance: null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
81
|
+
return {
|
|
82
|
+
version: STATE_VERSION,
|
|
83
|
+
revision: typeof raw.revision === "number" ? raw.revision : 0,
|
|
84
|
+
listener: parseListener(raw.listener),
|
|
85
|
+
instance: parseInstance(raw.instance),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return {
|
|
90
|
+
version: STATE_VERSION,
|
|
91
|
+
revision: 0,
|
|
92
|
+
listener: defaultListener(),
|
|
93
|
+
instance: null,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function persist(state) {
|
|
98
|
+
ensureDir(getGatewayDir());
|
|
99
|
+
try {
|
|
100
|
+
chmodSync(getGatewayDir(), 0o700);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Windows relies on user-directory ACLs.
|
|
104
|
+
}
|
|
105
|
+
atomicWriteFile(getGatewayStatePath(), JSON.stringify({
|
|
106
|
+
version: STATE_VERSION,
|
|
107
|
+
revision: state.revision,
|
|
108
|
+
listener: state.listener,
|
|
109
|
+
instance: state.instance,
|
|
110
|
+
}, null, 2) + "\n");
|
|
111
|
+
}
|
|
112
|
+
export function updateGatewayState(mutate) {
|
|
113
|
+
const current = readGatewayState();
|
|
114
|
+
const mutated = mutate(current);
|
|
115
|
+
const next = {
|
|
116
|
+
version: STATE_VERSION,
|
|
117
|
+
revision: current.revision + 1,
|
|
118
|
+
listener: mutated.listener,
|
|
119
|
+
instance: mutated.instance,
|
|
120
|
+
};
|
|
121
|
+
persist(next);
|
|
122
|
+
return next;
|
|
123
|
+
}
|
|
124
|
+
function hostForUrl(host) {
|
|
125
|
+
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
126
|
+
}
|
|
127
|
+
/** Root URL third parties connect to (clients append /v1/...). */
|
|
128
|
+
export function gatewayRootUrl(state) {
|
|
129
|
+
const current = state || readGatewayState();
|
|
130
|
+
return `http://${hostForUrl(current.listener.advertiseHost)}:${current.listener.port}`;
|
|
131
|
+
}
|
|
132
|
+
/** OpenAI-style base URL (includes /v1). */
|
|
133
|
+
export function gatewayBaseUrl(state) {
|
|
134
|
+
return `${gatewayRootUrl(state)}/v1`;
|
|
135
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway provider / route / config persistence.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately separate from `store/profiles.ts`: tool profiles are stored per
|
|
5
|
+
* tool (claude/codex/opencode), so the same upstream can exist three times with
|
|
6
|
+
* different names. The gateway needs one tool-independent provider list.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
9
|
+
import { chmodSync } from "node:fs";
|
|
10
|
+
import { TOOLS, isApiFormat, normalizeProxyValue } from "../types.js";
|
|
11
|
+
import { ensureOpenAiV1BaseUrl, isOpenAiApiFormat } from "../utils/base-url.js";
|
|
12
|
+
import { atomicWriteFile, ensureDir, maskSecret } from "../utils/fs.js";
|
|
13
|
+
import { getGatewayConfigPath, getGatewayDir, getGatewayProviderPath, getGatewayProvidersDir, getGatewayRoutesPath, } from "../utils/paths.js";
|
|
14
|
+
import { listProfiles } from "../store/profiles.js";
|
|
15
|
+
import { defaultGatewayConfig, DEFAULT_GATEWAY_FALLBACK, } from "./types.js";
|
|
16
|
+
const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
|
17
|
+
/** Sanitized shape for `pathPrefix`: no slashes at the edges, bounded length. */
|
|
18
|
+
const PATH_PREFIX_MAX = 64;
|
|
19
|
+
export function normalizePathPrefix(value) {
|
|
20
|
+
const raw = typeof value === "string" ? value.trim() : "";
|
|
21
|
+
const trimmed = raw.replace(/^\/+|\/+$/g, "");
|
|
22
|
+
if (!trimmed)
|
|
23
|
+
return "";
|
|
24
|
+
if (trimmed.length > PATH_PREFIX_MAX || /[?#]/.test(trimmed))
|
|
25
|
+
return "v1";
|
|
26
|
+
return trimmed;
|
|
27
|
+
}
|
|
28
|
+
export function assertValidProviderName(name) {
|
|
29
|
+
if (!NAME_RE.test(name)) {
|
|
30
|
+
throw new Error(`无效的 provider 名称「${name}」。仅允许字母、数字、下划线、连字符,且以字母或数字开头。`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function ensureGatewayDir() {
|
|
34
|
+
ensureDir(getGatewayDir());
|
|
35
|
+
try {
|
|
36
|
+
chmodSync(getGatewayDir(), 0o700);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Windows relies on user-directory ACLs.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Gateway-specific base URL normalization.
|
|
44
|
+
*
|
|
45
|
+
* With the default prefix (`v1`, undefined field) OpenAI-format bases keep the
|
|
46
|
+
* historical "exactly one /v1" shape. Once a provider pins an explicit
|
|
47
|
+
* `pathPrefix` (including `""`), the base URL is the operator's business: only
|
|
48
|
+
* trailing slashes are trimmed, and `upstreamUrl` joins the prefix.
|
|
49
|
+
*/
|
|
50
|
+
function normalizeProviderBaseUrl(apiFormat, baseUrl, hasExplicitPrefix) {
|
|
51
|
+
const trimmed = baseUrl.trim();
|
|
52
|
+
if (!hasExplicitPrefix && isOpenAiApiFormat(apiFormat)) {
|
|
53
|
+
return ensureOpenAiV1BaseUrl(trimmed);
|
|
54
|
+
}
|
|
55
|
+
return trimmed.replace(/\/+$/, "");
|
|
56
|
+
}
|
|
57
|
+
function asRecord(value) {
|
|
58
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
function stringList(value) {
|
|
64
|
+
if (!Array.isArray(value))
|
|
65
|
+
return [];
|
|
66
|
+
return Array.from(new Set(value
|
|
67
|
+
.filter((item) => typeof item === "string")
|
|
68
|
+
.map((item) => item.trim())
|
|
69
|
+
.filter(Boolean)));
|
|
70
|
+
}
|
|
71
|
+
// --- providers --------------------------------------------------------------
|
|
72
|
+
function normalizeProvider(raw, fallbackName) {
|
|
73
|
+
const name = typeof raw.name === "string" && raw.name ? raw.name : fallbackName;
|
|
74
|
+
const apiFormat = String(raw.apiFormat || "");
|
|
75
|
+
if (!isApiFormat(apiFormat))
|
|
76
|
+
return null;
|
|
77
|
+
const baseUrl = String(raw.baseUrl || "");
|
|
78
|
+
if (!baseUrl)
|
|
79
|
+
return null;
|
|
80
|
+
const source = asRecord(raw.sourceProfile);
|
|
81
|
+
const pathPrefix = raw.pathPrefix === undefined ? undefined : normalizePathPrefix(raw.pathPrefix);
|
|
82
|
+
return {
|
|
83
|
+
name,
|
|
84
|
+
displayName: typeof raw.displayName === "string" && raw.displayName
|
|
85
|
+
? raw.displayName
|
|
86
|
+
: name,
|
|
87
|
+
apiFormat,
|
|
88
|
+
baseUrl: normalizeProviderBaseUrl(apiFormat, baseUrl, pathPrefix !== undefined),
|
|
89
|
+
apiKey: typeof raw.apiKey === "string" ? raw.apiKey : "",
|
|
90
|
+
models: stringList(raw.models),
|
|
91
|
+
headers: asRecord(raw.headers) ?? {},
|
|
92
|
+
proxy: normalizeProxyValue(raw.proxy),
|
|
93
|
+
pathPrefix: raw.pathPrefix === undefined ? undefined : normalizePathPrefix(raw.pathPrefix),
|
|
94
|
+
priority: typeof raw.priority === "number" ? raw.priority : 100,
|
|
95
|
+
enabled: raw.enabled !== false,
|
|
96
|
+
sourceProfile: source && typeof source.tool === "string" && typeof source.name === "string"
|
|
97
|
+
? { tool: source.tool, name: source.name }
|
|
98
|
+
: null,
|
|
99
|
+
updatedAt: typeof raw.updatedAt === "string"
|
|
100
|
+
? raw.updatedAt
|
|
101
|
+
: new Date(0).toISOString(),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export function listGatewayProviders() {
|
|
105
|
+
const dir = getGatewayProvidersDir();
|
|
106
|
+
if (!existsSync(dir))
|
|
107
|
+
return [];
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const file of readdirSync(dir)) {
|
|
110
|
+
if (!file.endsWith(".json"))
|
|
111
|
+
continue;
|
|
112
|
+
const name = file.replace(/\.json$/, "");
|
|
113
|
+
const provider = readGatewayProvider(name);
|
|
114
|
+
if (provider)
|
|
115
|
+
out.push(provider);
|
|
116
|
+
}
|
|
117
|
+
return out.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name));
|
|
118
|
+
}
|
|
119
|
+
export function readGatewayProvider(name) {
|
|
120
|
+
const path = getGatewayProviderPath(name);
|
|
121
|
+
if (!existsSync(path))
|
|
122
|
+
return null;
|
|
123
|
+
try {
|
|
124
|
+
const raw = asRecord(JSON.parse(readFileSync(path, "utf8")));
|
|
125
|
+
return raw ? normalizeProvider(raw, name) : null;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export function requireGatewayProvider(name) {
|
|
132
|
+
const provider = readGatewayProvider(name);
|
|
133
|
+
if (provider)
|
|
134
|
+
return provider;
|
|
135
|
+
const available = listGatewayProviders().map((p) => p.name);
|
|
136
|
+
throw new Error(available.length
|
|
137
|
+
? `未找到 gateway provider「${name}」。现有:${available.join(", ")}`
|
|
138
|
+
: `未找到 gateway provider「${name}」。请先执行 llms gateway provider add`);
|
|
139
|
+
}
|
|
140
|
+
export function saveGatewayProvider(provider) {
|
|
141
|
+
assertValidProviderName(provider.name);
|
|
142
|
+
if (!isApiFormat(provider.apiFormat)) {
|
|
143
|
+
throw new Error(`无效的 apiFormat: ${provider.apiFormat}`);
|
|
144
|
+
}
|
|
145
|
+
if (!provider.baseUrl?.trim()) {
|
|
146
|
+
throw new Error("baseUrl 不能为空");
|
|
147
|
+
}
|
|
148
|
+
const nextPathPrefix = provider.pathPrefix === undefined
|
|
149
|
+
? undefined
|
|
150
|
+
: normalizePathPrefix(provider.pathPrefix);
|
|
151
|
+
const next = {
|
|
152
|
+
...provider,
|
|
153
|
+
displayName: provider.displayName || provider.name,
|
|
154
|
+
baseUrl: normalizeProviderBaseUrl(provider.apiFormat, provider.baseUrl, nextPathPrefix !== undefined),
|
|
155
|
+
apiKey: provider.apiKey ?? "",
|
|
156
|
+
models: stringList(provider.models),
|
|
157
|
+
headers: provider.headers || {},
|
|
158
|
+
pathPrefix: nextPathPrefix,
|
|
159
|
+
priority: typeof provider.priority === "number" ? provider.priority : 100,
|
|
160
|
+
enabled: provider.enabled !== false,
|
|
161
|
+
sourceProfile: provider.sourceProfile ?? null,
|
|
162
|
+
updatedAt: new Date().toISOString(),
|
|
163
|
+
};
|
|
164
|
+
ensureGatewayDir();
|
|
165
|
+
ensureDir(getGatewayProvidersDir());
|
|
166
|
+
atomicWriteFile(getGatewayProviderPath(next.name), JSON.stringify(next, null, 2) + "\n");
|
|
167
|
+
return next;
|
|
168
|
+
}
|
|
169
|
+
export function deleteGatewayProvider(name) {
|
|
170
|
+
const path = getGatewayProviderPath(name);
|
|
171
|
+
if (!existsSync(path)) {
|
|
172
|
+
throw new Error(`未找到 gateway provider「${name}」`);
|
|
173
|
+
}
|
|
174
|
+
unlinkSync(path);
|
|
175
|
+
// Drop routes that pointed at the removed provider.
|
|
176
|
+
const routes = listGatewayRoutes().filter((route) => {
|
|
177
|
+
if (route.provider === name)
|
|
178
|
+
return false;
|
|
179
|
+
route.fallbacks = (route.fallbacks || []).filter((item) => item.provider !== name);
|
|
180
|
+
return true;
|
|
181
|
+
});
|
|
182
|
+
writeGatewayRoutes(routes);
|
|
183
|
+
const config = readGatewayConfig();
|
|
184
|
+
if (config.defaultProvider === name) {
|
|
185
|
+
writeGatewayConfig({ ...config, defaultProvider: null });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
export function publicProviderView(provider) {
|
|
189
|
+
return {
|
|
190
|
+
name: provider.name,
|
|
191
|
+
displayName: provider.displayName,
|
|
192
|
+
apiFormat: provider.apiFormat,
|
|
193
|
+
baseUrl: provider.baseUrl,
|
|
194
|
+
apiKey: maskSecret(provider.apiKey),
|
|
195
|
+
models: provider.models,
|
|
196
|
+
pathPrefix: provider.pathPrefix ?? "v1",
|
|
197
|
+
/** Header names only; values may carry secrets. */
|
|
198
|
+
headerNames: Object.keys(provider.headers || {}),
|
|
199
|
+
priority: provider.priority,
|
|
200
|
+
enabled: provider.enabled,
|
|
201
|
+
proxy: provider.proxy || null,
|
|
202
|
+
sourceProfile: provider.sourceProfile || null,
|
|
203
|
+
updatedAt: provider.updatedAt,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Import tool profiles as gateway providers, de-duplicated by
|
|
208
|
+
* (apiFormat, baseUrl, apiKey). Existing providers are never overwritten.
|
|
209
|
+
*/
|
|
210
|
+
export function importProvidersFromProfiles(tools = TOOLS) {
|
|
211
|
+
const existing = listGatewayProviders();
|
|
212
|
+
const fingerprint = (apiFormat, baseUrl, apiKey) => `${apiFormat}|${baseUrl.replace(/\/+$/, "")}|${apiKey}`;
|
|
213
|
+
const seen = new Set(existing.map((p) => fingerprint(p.apiFormat, p.baseUrl, p.apiKey)));
|
|
214
|
+
const usedNames = new Set(existing.map((p) => p.name));
|
|
215
|
+
const imported = [];
|
|
216
|
+
const skipped = [];
|
|
217
|
+
for (const tool of tools) {
|
|
218
|
+
for (const profile of listProfiles(tool)) {
|
|
219
|
+
const key = fingerprint(profile.apiFormat, profile.baseUrl, profile.apiKey);
|
|
220
|
+
if (seen.has(key)) {
|
|
221
|
+
skipped.push({ tool, name: profile.name, reason: "重复上游" });
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
let name = profile.name;
|
|
225
|
+
let suffix = 2;
|
|
226
|
+
while (usedNames.has(name)) {
|
|
227
|
+
name = `${profile.name}-${suffix++}`;
|
|
228
|
+
}
|
|
229
|
+
const models = Array.from(new Set([
|
|
230
|
+
profile.models.default,
|
|
231
|
+
profile.models.fast,
|
|
232
|
+
...(profile.models.list || []),
|
|
233
|
+
].filter((m) => Boolean(m && m.trim()))));
|
|
234
|
+
const provider = saveGatewayProvider({
|
|
235
|
+
name,
|
|
236
|
+
displayName: profile.displayName || name,
|
|
237
|
+
apiFormat: profile.apiFormat,
|
|
238
|
+
baseUrl: profile.baseUrl,
|
|
239
|
+
apiKey: profile.apiKey,
|
|
240
|
+
models,
|
|
241
|
+
headers: profile.headers || {},
|
|
242
|
+
proxy: profile.proxy,
|
|
243
|
+
priority: 100,
|
|
244
|
+
enabled: true,
|
|
245
|
+
sourceProfile: { tool, name: profile.name },
|
|
246
|
+
updatedAt: new Date().toISOString(),
|
|
247
|
+
});
|
|
248
|
+
seen.add(key);
|
|
249
|
+
usedNames.add(name);
|
|
250
|
+
imported.push(provider);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return { imported, skipped };
|
|
254
|
+
}
|
|
255
|
+
// --- routes -----------------------------------------------------------------
|
|
256
|
+
function normalizeRoute(raw) {
|
|
257
|
+
const row = asRecord(raw);
|
|
258
|
+
if (!row)
|
|
259
|
+
return null;
|
|
260
|
+
const alias = String(row.alias || "").trim();
|
|
261
|
+
const provider = String(row.provider || "").trim();
|
|
262
|
+
if (!alias || !provider)
|
|
263
|
+
return null;
|
|
264
|
+
const fallbacks = [];
|
|
265
|
+
if (Array.isArray(row.fallbacks)) {
|
|
266
|
+
for (const item of row.fallbacks) {
|
|
267
|
+
const entry = asRecord(item);
|
|
268
|
+
const name = String(entry?.provider || "").trim();
|
|
269
|
+
if (!name)
|
|
270
|
+
continue;
|
|
271
|
+
const model = typeof entry?.model === "string" && entry.model.trim()
|
|
272
|
+
? entry.model.trim()
|
|
273
|
+
: undefined;
|
|
274
|
+
fallbacks.push(model ? { provider: name, model } : { provider: name });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const model = typeof row.model === "string" && row.model.trim()
|
|
278
|
+
? row.model.trim()
|
|
279
|
+
: undefined;
|
|
280
|
+
return {
|
|
281
|
+
alias,
|
|
282
|
+
provider,
|
|
283
|
+
...(model ? { model } : {}),
|
|
284
|
+
...(fallbacks.length ? { fallbacks } : {}),
|
|
285
|
+
updatedAt: typeof row.updatedAt === "string"
|
|
286
|
+
? row.updatedAt
|
|
287
|
+
: new Date(0).toISOString(),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
export function listGatewayRoutes() {
|
|
291
|
+
const path = getGatewayRoutesPath();
|
|
292
|
+
if (!existsSync(path))
|
|
293
|
+
return [];
|
|
294
|
+
try {
|
|
295
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
296
|
+
const rows = Array.isArray(raw)
|
|
297
|
+
? raw
|
|
298
|
+
: Array.isArray(asRecord(raw)?.routes)
|
|
299
|
+
? asRecord(raw).routes
|
|
300
|
+
: [];
|
|
301
|
+
return rows
|
|
302
|
+
.map(normalizeRoute)
|
|
303
|
+
.filter((route) => route !== null);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
return [];
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
export function writeGatewayRoutes(routes) {
|
|
310
|
+
ensureGatewayDir();
|
|
311
|
+
atomicWriteFile(getGatewayRoutesPath(), JSON.stringify({ version: 1, routes }, null, 2) + "\n");
|
|
312
|
+
}
|
|
313
|
+
export function saveGatewayRoute(route) {
|
|
314
|
+
const alias = route.alias.trim();
|
|
315
|
+
if (!alias)
|
|
316
|
+
throw new Error("模型别名不能为空");
|
|
317
|
+
requireGatewayProvider(route.provider);
|
|
318
|
+
for (const fallback of route.fallbacks || []) {
|
|
319
|
+
requireGatewayProvider(fallback.provider);
|
|
320
|
+
}
|
|
321
|
+
const next = {
|
|
322
|
+
...route,
|
|
323
|
+
alias,
|
|
324
|
+
updatedAt: new Date().toISOString(),
|
|
325
|
+
};
|
|
326
|
+
const routes = listGatewayRoutes().filter((item) => item.alias.toLowerCase() !== alias.toLowerCase());
|
|
327
|
+
routes.push(next);
|
|
328
|
+
routes.sort((a, b) => a.alias.localeCompare(b.alias));
|
|
329
|
+
writeGatewayRoutes(routes);
|
|
330
|
+
return next;
|
|
331
|
+
}
|
|
332
|
+
export function deleteGatewayRoute(alias) {
|
|
333
|
+
const routes = listGatewayRoutes();
|
|
334
|
+
const next = routes.filter((item) => item.alias.toLowerCase() !== alias.trim().toLowerCase());
|
|
335
|
+
if (next.length === routes.length) {
|
|
336
|
+
throw new Error(`未找到模型路由「${alias}」`);
|
|
337
|
+
}
|
|
338
|
+
writeGatewayRoutes(next);
|
|
339
|
+
}
|
|
340
|
+
// --- config -----------------------------------------------------------------
|
|
341
|
+
export function readGatewayConfig() {
|
|
342
|
+
const path = getGatewayConfigPath();
|
|
343
|
+
const fallback = defaultGatewayConfig();
|
|
344
|
+
if (!existsSync(path))
|
|
345
|
+
return fallback;
|
|
346
|
+
try {
|
|
347
|
+
const raw = asRecord(JSON.parse(readFileSync(path, "utf8")));
|
|
348
|
+
if (!raw)
|
|
349
|
+
return fallback;
|
|
350
|
+
const fallbackRaw = asRecord(raw.fallback);
|
|
351
|
+
const retryStatuses = Array.isArray(fallbackRaw?.retryStatuses)
|
|
352
|
+
? fallbackRaw.retryStatuses.filter((item) => typeof item === "number" && item >= 100 && item <= 599)
|
|
353
|
+
: [...DEFAULT_GATEWAY_FALLBACK.retryStatuses];
|
|
354
|
+
return {
|
|
355
|
+
version: 1,
|
|
356
|
+
defaultProvider: typeof raw.defaultProvider === "string" && raw.defaultProvider
|
|
357
|
+
? raw.defaultProvider
|
|
358
|
+
: null,
|
|
359
|
+
fallback: {
|
|
360
|
+
enabled: fallbackRaw?.enabled !== false,
|
|
361
|
+
maxAttempts: typeof fallbackRaw?.maxAttempts === "number" &&
|
|
362
|
+
fallbackRaw.maxAttempts >= 1
|
|
363
|
+
? Math.min(fallbackRaw.maxAttempts, 10)
|
|
364
|
+
: DEFAULT_GATEWAY_FALLBACK.maxAttempts,
|
|
365
|
+
retryStatuses: retryStatuses.length
|
|
366
|
+
? retryStatuses
|
|
367
|
+
: [...DEFAULT_GATEWAY_FALLBACK.retryStatuses],
|
|
368
|
+
},
|
|
369
|
+
corsOrigins: stringList(raw.corsOrigins),
|
|
370
|
+
rateLimitPerMinute: typeof raw.rateLimitPerMinute === "number" &&
|
|
371
|
+
raw.rateLimitPerMinute >= 0
|
|
372
|
+
? raw.rateLimitPerMinute
|
|
373
|
+
: 0,
|
|
374
|
+
updatedAt: typeof raw.updatedAt === "string"
|
|
375
|
+
? raw.updatedAt
|
|
376
|
+
: fallback.updatedAt,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return fallback;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
export function writeGatewayConfig(config) {
|
|
384
|
+
const next = {
|
|
385
|
+
...config,
|
|
386
|
+
version: 1,
|
|
387
|
+
updatedAt: new Date().toISOString(),
|
|
388
|
+
};
|
|
389
|
+
ensureGatewayDir();
|
|
390
|
+
atomicWriteFile(getGatewayConfigPath(), JSON.stringify(next, null, 2) + "\n");
|
|
391
|
+
return next;
|
|
392
|
+
}
|