@khanglvm/llm-router 1.3.1 → 2.0.0-beta.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/CHANGELOG.md +39 -0
- package/README.md +337 -41
- package/package.json +19 -3
- package/src/cli/router-module.js +7331 -3805
- package/src/cli/wrangler-toml.js +1 -1
- package/src/cli-entry.js +162 -24
- package/src/node/amp-client-config.js +426 -0
- package/src/node/coding-tool-config.js +763 -0
- package/src/node/config-store.js +49 -18
- package/src/node/instance-state.js +213 -12
- package/src/node/listen-port.js +5 -37
- package/src/node/local-server-settings.js +122 -0
- package/src/node/local-server.js +3 -2
- package/src/node/provider-probe.js +13 -0
- package/src/node/start-command.js +282 -40
- package/src/node/startup-manager.js +64 -29
- package/src/node/web-command.js +106 -0
- package/src/node/web-console-assets.js +26 -0
- package/src/node/web-console-client.js +56 -0
- package/src/node/web-console-dev-assets.js +258 -0
- package/src/node/web-console-server.js +3146 -0
- package/src/node/web-console-styles.generated.js +1 -0
- package/src/node/web-console-ui/config-editor-utils.js +616 -0
- package/src/node/web-console-ui/lib/utils.js +6 -0
- package/src/node/web-console-ui/rate-limit-utils.js +144 -0
- package/src/node/web-console-ui/select-search-utils.js +36 -0
- package/src/runtime/codex-request-transformer.js +46 -5
- package/src/runtime/codex-response-transformer.js +268 -35
- package/src/runtime/config.js +1394 -35
- package/src/runtime/handler/amp-gemini.js +913 -0
- package/src/runtime/handler/amp-response.js +308 -0
- package/src/runtime/handler/amp.js +290 -0
- package/src/runtime/handler/auth.js +17 -2
- package/src/runtime/handler/provider-call.js +168 -50
- package/src/runtime/handler/provider-translation.js +937 -26
- package/src/runtime/handler/request.js +149 -6
- package/src/runtime/handler/route-debug.js +22 -1
- package/src/runtime/handler.js +449 -9
- package/src/runtime/subscription-auth.js +1 -6
- package/src/shared/local-router-defaults.js +62 -0
- package/src/translator/index.js +3 -1
- package/src/translator/request/openai-to-claude.js +217 -6
- package/src/translator/response/openai-to-claude.js +206 -58
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
|
|
5
|
+
const BACKUP_SUFFIX = ".llm_router_backup";
|
|
6
|
+
const CODEX_PROVIDER_ID = "llm-router";
|
|
7
|
+
const CODEX_MODEL_CATALOG_FILENAME = "llm-router-model-catalog.json";
|
|
8
|
+
const CLAUDE_MANAGED_ENV_KEYS = Object.freeze([
|
|
9
|
+
"ANTHROPIC_BASE_URL",
|
|
10
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
11
|
+
"ANTHROPIC_MODEL",
|
|
12
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
13
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
14
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
15
|
+
"CLAUDE_CODE_SUBAGENT_MODEL"
|
|
16
|
+
]);
|
|
17
|
+
const CLAUDE_BACKUP_ENV_KEYS = Object.freeze([
|
|
18
|
+
...CLAUDE_MANAGED_ENV_KEYS,
|
|
19
|
+
"ANTHROPIC_API_KEY",
|
|
20
|
+
"ANTHROPIC_SMALL_FAST_MODEL"
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
function escapeRegex(value) {
|
|
24
|
+
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeHttpUrl(value) {
|
|
28
|
+
const text = String(value || "").trim();
|
|
29
|
+
if (!text) return "";
|
|
30
|
+
|
|
31
|
+
let parsed;
|
|
32
|
+
try {
|
|
33
|
+
parsed = new URL(text);
|
|
34
|
+
} catch {
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if ([
|
|
43
|
+
"localhost",
|
|
44
|
+
"0.0.0.0",
|
|
45
|
+
"::",
|
|
46
|
+
"[::]",
|
|
47
|
+
"::0",
|
|
48
|
+
"[::0]",
|
|
49
|
+
"::1",
|
|
50
|
+
"[::1]"
|
|
51
|
+
].includes(parsed.hostname.toLowerCase())) {
|
|
52
|
+
parsed.hostname = "127.0.0.1";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
parsed.username = "";
|
|
56
|
+
parsed.password = "";
|
|
57
|
+
parsed.hash = "";
|
|
58
|
+
parsed.search = "";
|
|
59
|
+
parsed.pathname = parsed.pathname.replace(/\/+$/, "") || "/";
|
|
60
|
+
|
|
61
|
+
const out = parsed.toString();
|
|
62
|
+
return parsed.pathname === "/" && out.endsWith("/") ? out.slice(0, -1) : out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeModelBinding(value) {
|
|
66
|
+
return String(value || "").trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function backupHasData(backup) {
|
|
70
|
+
return Boolean(backup && typeof backup === "object" && !Array.isArray(backup) && Object.keys(backup).length > 0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readTextFile(filePath) {
|
|
74
|
+
try {
|
|
75
|
+
return {
|
|
76
|
+
text: await fs.readFile(filePath, "utf8"),
|
|
77
|
+
existed: true
|
|
78
|
+
};
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error && typeof error === "object" && error.code === "ENOENT") {
|
|
81
|
+
return {
|
|
82
|
+
text: "",
|
|
83
|
+
existed: false
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function writeTextFile(filePath, text) {
|
|
91
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
92
|
+
await fs.writeFile(filePath, String(text || ""), { encoding: "utf8", mode: 0o600 });
|
|
93
|
+
await fs.chmod(filePath, 0o600);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function readJsonObjectFile(filePath, label) {
|
|
97
|
+
try {
|
|
98
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
99
|
+
const parsed = raw.trim() ? JSON.parse(raw) : {};
|
|
100
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
101
|
+
throw new Error(`${label} must contain a JSON object.`);
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
data: parsed,
|
|
105
|
+
existed: true
|
|
106
|
+
};
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error && typeof error === "object" && error.code === "ENOENT") {
|
|
109
|
+
return {
|
|
110
|
+
data: {},
|
|
111
|
+
existed: false
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (error instanceof SyntaxError) {
|
|
115
|
+
throw new Error(`${label} contains invalid JSON.`);
|
|
116
|
+
}
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function writeJsonObjectFile(filePath, data) {
|
|
122
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
123
|
+
await fs.writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
124
|
+
await fs.chmod(filePath, 0o600);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function splitLinesPreserveNewline(text = "") {
|
|
128
|
+
const matches = String(text || "").match(/[^\n]*\n|[^\n]+/g);
|
|
129
|
+
return matches ? [...matches] : [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function splitTomlDocument(text = "") {
|
|
133
|
+
const lines = splitLinesPreserveNewline(text);
|
|
134
|
+
const preamble = [];
|
|
135
|
+
const sections = [];
|
|
136
|
+
let currentSection = null;
|
|
137
|
+
|
|
138
|
+
for (const line of lines) {
|
|
139
|
+
const headerMatch = line.match(/^\s*\[([^\]]+)\]\s*(?:#.*)?(?:\r?\n)?$/);
|
|
140
|
+
if (headerMatch) {
|
|
141
|
+
currentSection = {
|
|
142
|
+
name: String(headerMatch[1] || "").trim(),
|
|
143
|
+
headerLine: line.endsWith("\n") ? line : `${line}\n`,
|
|
144
|
+
lines: []
|
|
145
|
+
};
|
|
146
|
+
sections.push(currentSection);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (currentSection) currentSection.lines.push(line);
|
|
151
|
+
else preamble.push(line);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { preamble, sections };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function serializeTomlDocument(document) {
|
|
158
|
+
const parts = [];
|
|
159
|
+
const preambleText = (document?.preamble || []).join("").trimEnd();
|
|
160
|
+
if (preambleText) parts.push(preambleText);
|
|
161
|
+
|
|
162
|
+
for (const section of (document?.sections || [])) {
|
|
163
|
+
const headerLine = String(section?.headerLine || `[${String(section?.name || "").trim()}]`).trimEnd();
|
|
164
|
+
const bodyText = (section?.lines || []).join("").trimEnd();
|
|
165
|
+
parts.push(bodyText ? `${headerLine}\n${bodyText}` : headerLine);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return `${parts.join("\n\n").trimEnd()}\n`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseTomlStringValue(rawValue) {
|
|
172
|
+
const text = String(rawValue || "").trim();
|
|
173
|
+
if (!text) return "";
|
|
174
|
+
if (text.startsWith("\"")) {
|
|
175
|
+
try {
|
|
176
|
+
return JSON.parse(text);
|
|
177
|
+
} catch {
|
|
178
|
+
return text.slice(1, -1);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (text.startsWith("'") && text.endsWith("'")) {
|
|
182
|
+
return text.slice(1, -1);
|
|
183
|
+
}
|
|
184
|
+
return text;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function encodeTomlString(value) {
|
|
188
|
+
return JSON.stringify(String(value || ""));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function findTopLevelAssignmentIndex(lines, key) {
|
|
192
|
+
const pattern = new RegExp(`^\\s*${escapeRegex(key)}\\s*=`);
|
|
193
|
+
return (lines || []).findIndex((line) => pattern.test(line));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function getTopLevelTomlStringField(document, key) {
|
|
197
|
+
const index = findTopLevelAssignmentIndex(document?.preamble || [], key);
|
|
198
|
+
if (index === -1) {
|
|
199
|
+
return { exists: false, value: "" };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const match = String(document.preamble[index] || "").match(/^\s*[^=]+\s*=\s*(.*?)\s*(?:#.*)?(?:\r?\n)?$/);
|
|
203
|
+
return {
|
|
204
|
+
exists: true,
|
|
205
|
+
value: parseTomlStringValue(match?.[1] || "")
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function setTopLevelTomlStringField(document, key, value) {
|
|
210
|
+
const lines = [...(document?.preamble || [])];
|
|
211
|
+
const encoded = `${key} = ${encodeTomlString(value)}\n`;
|
|
212
|
+
const index = findTopLevelAssignmentIndex(lines, key);
|
|
213
|
+
if (index >= 0) lines[index] = encoded;
|
|
214
|
+
else lines.push(encoded);
|
|
215
|
+
document.preamble = lines;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function deleteTopLevelTomlField(document, key) {
|
|
219
|
+
const lines = [...(document?.preamble || [])];
|
|
220
|
+
const index = findTopLevelAssignmentIndex(lines, key);
|
|
221
|
+
if (index >= 0) lines.splice(index, 1);
|
|
222
|
+
document.preamble = lines;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function findTomlSectionIndex(document, name) {
|
|
226
|
+
return (document?.sections || []).findIndex((section) => String(section?.name || "").trim() === String(name || "").trim());
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function getTomlSection(document, name) {
|
|
230
|
+
const index = findTomlSectionIndex(document, name);
|
|
231
|
+
return index >= 0 ? document.sections[index] : null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function deleteTomlSection(document, name) {
|
|
235
|
+
const index = findTomlSectionIndex(document, name);
|
|
236
|
+
if (index >= 0) document.sections.splice(index, 1);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function setTomlSection(document, name, bodyText = "") {
|
|
240
|
+
const normalizedBody = String(bodyText || "").trim();
|
|
241
|
+
const section = {
|
|
242
|
+
name: String(name || "").trim(),
|
|
243
|
+
headerLine: `[${String(name || "").trim()}]\n`,
|
|
244
|
+
lines: normalizedBody ? splitLinesPreserveNewline(`${normalizedBody}\n`) : []
|
|
245
|
+
};
|
|
246
|
+
const index = findTomlSectionIndex(document, name);
|
|
247
|
+
if (index >= 0) document.sections[index] = section;
|
|
248
|
+
else document.sections.push(section);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function parseTomlSectionKeyValues(section) {
|
|
252
|
+
const values = {};
|
|
253
|
+
for (const line of section?.lines || []) {
|
|
254
|
+
const match = String(line || "").match(/^\s*([A-Za-z0-9_.-]+)\s*=\s*(.*?)\s*(?:#.*)?(?:\r?\n)?$/);
|
|
255
|
+
if (!match) continue;
|
|
256
|
+
values[match[1]] = parseTomlStringValue(match[2]);
|
|
257
|
+
}
|
|
258
|
+
return values;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function createCodexProviderSection({ baseUrl, apiKey }) {
|
|
262
|
+
return [
|
|
263
|
+
`name = ${encodeTomlString("LLM Router")}`,
|
|
264
|
+
`base_url = ${encodeTomlString(baseUrl)}`,
|
|
265
|
+
`wire_api = ${encodeTomlString("responses")}`,
|
|
266
|
+
`experimental_bearer_token = ${encodeTomlString(apiKey)}`
|
|
267
|
+
].join("\n");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function buildCodexProviderBaseUrl(endpointUrl) {
|
|
271
|
+
const normalized = normalizeHttpUrl(endpointUrl);
|
|
272
|
+
return normalized ? `${normalized}/openai/v1` : "";
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function buildClaudeCodeBaseUrl(endpointUrl) {
|
|
276
|
+
const normalized = normalizeHttpUrl(endpointUrl);
|
|
277
|
+
return normalized ? `${normalized}/anthropic` : "";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function normalizeCodexModelCatalog(modelCatalog) {
|
|
281
|
+
if (!modelCatalog || typeof modelCatalog !== "object" || Array.isArray(modelCatalog)) {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const models = Array.isArray(modelCatalog.models)
|
|
286
|
+
? modelCatalog.models.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry))
|
|
287
|
+
: [];
|
|
288
|
+
if (models.length === 0) return { models: [] };
|
|
289
|
+
|
|
290
|
+
const next = {
|
|
291
|
+
...modelCatalog,
|
|
292
|
+
models
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
if (!next.fetched_at) {
|
|
296
|
+
next.fetched_at = new Date().toISOString();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return next;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function getBackupValue(value) {
|
|
303
|
+
return {
|
|
304
|
+
exists: value !== undefined,
|
|
305
|
+
value: value === undefined ? "" : String(value || "")
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function applyBackupValue(target, key, snapshot) {
|
|
310
|
+
if (!snapshot?.exists) {
|
|
311
|
+
delete target[key];
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
target[key] = String(snapshot.value || "");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function normalizeClaudeBindings(bindings = {}) {
|
|
318
|
+
const source = bindings && typeof bindings === "object" && !Array.isArray(bindings) ? bindings : {};
|
|
319
|
+
return {
|
|
320
|
+
primaryModel: normalizeModelBinding(source.primaryModel),
|
|
321
|
+
defaultOpusModel: normalizeModelBinding(source.defaultOpusModel),
|
|
322
|
+
defaultSonnetModel: normalizeModelBinding(source.defaultSonnetModel),
|
|
323
|
+
defaultHaikuModel: normalizeModelBinding(source.defaultHaikuModel),
|
|
324
|
+
subagentModel: normalizeModelBinding(source.subagentModel)
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function normalizeCodexBindings(bindings = {}) {
|
|
329
|
+
const source = bindings && typeof bindings === "object" && !Array.isArray(bindings) ? bindings : {};
|
|
330
|
+
return {
|
|
331
|
+
defaultModel: normalizeModelBinding(source.defaultModel)
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function captureCodexBackup(document) {
|
|
336
|
+
const providerSection = getTomlSection(document, `model_providers.${CODEX_PROVIDER_ID}`);
|
|
337
|
+
return {
|
|
338
|
+
tool: "codex-cli",
|
|
339
|
+
version: 1,
|
|
340
|
+
modelProvider: getTopLevelTomlStringField(document, "model_provider"),
|
|
341
|
+
model: getTopLevelTomlStringField(document, "model"),
|
|
342
|
+
modelCatalogJson: getTopLevelTomlStringField(document, "model_catalog_json"),
|
|
343
|
+
providerSection: {
|
|
344
|
+
exists: Boolean(providerSection),
|
|
345
|
+
body: providerSection ? (providerSection.lines || []).join("").trimEnd() : ""
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function applyCodexBackup(document, backup = {}) {
|
|
351
|
+
if (backup?.modelProvider?.exists) setTopLevelTomlStringField(document, "model_provider", backup.modelProvider.value);
|
|
352
|
+
else deleteTopLevelTomlField(document, "model_provider");
|
|
353
|
+
|
|
354
|
+
if (backup?.model?.exists) setTopLevelTomlStringField(document, "model", backup.model.value);
|
|
355
|
+
else deleteTopLevelTomlField(document, "model");
|
|
356
|
+
|
|
357
|
+
if (backup?.modelCatalogJson?.exists) setTopLevelTomlStringField(document, "model_catalog_json", backup.modelCatalogJson.value);
|
|
358
|
+
else deleteTopLevelTomlField(document, "model_catalog_json");
|
|
359
|
+
|
|
360
|
+
if (backup?.providerSection?.exists) {
|
|
361
|
+
setTomlSection(document, `model_providers.${CODEX_PROVIDER_ID}`, backup.providerSection.body || "");
|
|
362
|
+
} else {
|
|
363
|
+
deleteTomlSection(document, `model_providers.${CODEX_PROVIDER_ID}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function captureClaudeBackup(config) {
|
|
368
|
+
const env = config?.env && typeof config.env === "object" && !Array.isArray(config.env) ? config.env : {};
|
|
369
|
+
const backupEnv = {};
|
|
370
|
+
for (const key of CLAUDE_BACKUP_ENV_KEYS) {
|
|
371
|
+
if (Object.prototype.hasOwnProperty.call(env, key)) {
|
|
372
|
+
backupEnv[key] = getBackupValue(env[key]);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
tool: "claude-code",
|
|
377
|
+
version: 1,
|
|
378
|
+
env: backupEnv
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function applyClaudeBackup(config, backup = {}) {
|
|
383
|
+
const next = config && typeof config === "object" && !Array.isArray(config)
|
|
384
|
+
? structuredClone(config)
|
|
385
|
+
: {};
|
|
386
|
+
const env = next.env && typeof next.env === "object" && !Array.isArray(next.env)
|
|
387
|
+
? { ...next.env }
|
|
388
|
+
: {};
|
|
389
|
+
|
|
390
|
+
for (const key of CLAUDE_BACKUP_ENV_KEYS) {
|
|
391
|
+
if (backup?.env && Object.prototype.hasOwnProperty.call(backup.env, key)) {
|
|
392
|
+
applyBackupValue(env, key, backup.env[key]);
|
|
393
|
+
} else {
|
|
394
|
+
delete env[key];
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (Object.keys(env).length > 0) next.env = env;
|
|
399
|
+
else delete next.env;
|
|
400
|
+
return next;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function ensureToolBackupFileExists(backupFilePath) {
|
|
404
|
+
const backupState = await readJsonObjectFile(backupFilePath, `Backup file '${backupFilePath}'`);
|
|
405
|
+
if (!backupState.existed) {
|
|
406
|
+
await writeJsonObjectFile(backupFilePath, {});
|
|
407
|
+
}
|
|
408
|
+
return backupState;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function sanitizeBackup(backup, tool) {
|
|
412
|
+
if (!backup || typeof backup !== "object" || Array.isArray(backup)) return {};
|
|
413
|
+
if (String(backup.tool || "").trim() && String(backup.tool || "").trim() !== tool) return {};
|
|
414
|
+
return backup;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function resolveCodingToolBackupFilePath(configFilePath = "") {
|
|
418
|
+
return `${path.resolve(String(configFilePath || "").trim())}${BACKUP_SUFFIX}`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function resolveCodexCliConfigFilePath({
|
|
422
|
+
explicitPath = "",
|
|
423
|
+
env = process.env,
|
|
424
|
+
homeDir = os.homedir()
|
|
425
|
+
} = {}) {
|
|
426
|
+
const direct = String(explicitPath || "").trim();
|
|
427
|
+
if (direct) return path.resolve(direct);
|
|
428
|
+
const codexHome = String(env?.CODEX_HOME || "").trim() || path.join(homeDir, ".codex");
|
|
429
|
+
return path.join(codexHome, "config.toml");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function resolveCodexCliModelCatalogFilePath({
|
|
433
|
+
configFilePath = "",
|
|
434
|
+
env = process.env,
|
|
435
|
+
homeDir = os.homedir()
|
|
436
|
+
} = {}) {
|
|
437
|
+
const resolvedConfigPath = path.resolve(String(configFilePath || resolveCodexCliConfigFilePath({ env, homeDir })).trim());
|
|
438
|
+
return path.join(path.dirname(resolvedConfigPath), CODEX_MODEL_CATALOG_FILENAME);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export function resolveClaudeCodeSettingsFilePath({
|
|
442
|
+
explicitPath = "",
|
|
443
|
+
env = process.env,
|
|
444
|
+
homeDir = os.homedir()
|
|
445
|
+
} = {}) {
|
|
446
|
+
const direct = String(explicitPath || "").trim();
|
|
447
|
+
if (direct) return path.resolve(direct);
|
|
448
|
+
const configDir = String(env?.CLAUDE_CONFIG_DIR || "").trim() || path.join(homeDir, ".claude");
|
|
449
|
+
return path.join(configDir, "settings.json");
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export async function ensureCodexCliConfigFileExists({
|
|
453
|
+
configFilePath = "",
|
|
454
|
+
backupFilePath = "",
|
|
455
|
+
env = process.env,
|
|
456
|
+
homeDir = os.homedir()
|
|
457
|
+
} = {}) {
|
|
458
|
+
const resolvedConfigPath = path.resolve(String(configFilePath || resolveCodexCliConfigFilePath({ env, homeDir })).trim());
|
|
459
|
+
const resolvedBackupPath = path.resolve(String(backupFilePath || resolveCodingToolBackupFilePath(resolvedConfigPath)).trim());
|
|
460
|
+
const configState = await readTextFile(resolvedConfigPath);
|
|
461
|
+
if (!configState.existed) {
|
|
462
|
+
await writeTextFile(resolvedConfigPath, "");
|
|
463
|
+
}
|
|
464
|
+
await ensureToolBackupFileExists(resolvedBackupPath);
|
|
465
|
+
return {
|
|
466
|
+
configFilePath: resolvedConfigPath,
|
|
467
|
+
backupFilePath: resolvedBackupPath,
|
|
468
|
+
configCreated: !configState.existed
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export async function ensureClaudeCodeSettingsFileExists({
|
|
473
|
+
settingsFilePath = "",
|
|
474
|
+
backupFilePath = "",
|
|
475
|
+
env = process.env,
|
|
476
|
+
homeDir = os.homedir()
|
|
477
|
+
} = {}) {
|
|
478
|
+
const resolvedSettingsPath = path.resolve(String(settingsFilePath || resolveClaudeCodeSettingsFilePath({ env, homeDir })).trim());
|
|
479
|
+
const resolvedBackupPath = path.resolve(String(backupFilePath || resolveCodingToolBackupFilePath(resolvedSettingsPath)).trim());
|
|
480
|
+
const settingsState = await readJsonObjectFile(resolvedSettingsPath, `Claude Code settings file '${resolvedSettingsPath}'`);
|
|
481
|
+
if (!settingsState.existed) {
|
|
482
|
+
await writeJsonObjectFile(resolvedSettingsPath, {});
|
|
483
|
+
}
|
|
484
|
+
await ensureToolBackupFileExists(resolvedBackupPath);
|
|
485
|
+
return {
|
|
486
|
+
settingsFilePath: resolvedSettingsPath,
|
|
487
|
+
backupFilePath: resolvedBackupPath,
|
|
488
|
+
settingsCreated: !settingsState.existed
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export async function readCodexCliRoutingState({
|
|
493
|
+
configFilePath = "",
|
|
494
|
+
backupFilePath = "",
|
|
495
|
+
endpointUrl = "",
|
|
496
|
+
env = process.env,
|
|
497
|
+
homeDir = os.homedir()
|
|
498
|
+
} = {}) {
|
|
499
|
+
const resolvedConfigPath = path.resolve(String(configFilePath || resolveCodexCliConfigFilePath({ env, homeDir })).trim());
|
|
500
|
+
const resolvedBackupPath = path.resolve(String(backupFilePath || resolveCodingToolBackupFilePath(resolvedConfigPath)).trim());
|
|
501
|
+
const expectedBaseUrl = buildCodexProviderBaseUrl(endpointUrl);
|
|
502
|
+
const configState = await readTextFile(resolvedConfigPath);
|
|
503
|
+
const backupState = await readJsonObjectFile(resolvedBackupPath, `Backup file '${resolvedBackupPath}'`);
|
|
504
|
+
const document = splitTomlDocument(configState.text);
|
|
505
|
+
const modelProvider = getTopLevelTomlStringField(document, "model_provider");
|
|
506
|
+
const model = getTopLevelTomlStringField(document, "model");
|
|
507
|
+
const providerSection = parseTomlSectionKeyValues(getTomlSection(document, `model_providers.${CODEX_PROVIDER_ID}`));
|
|
508
|
+
const configuredBaseUrl = String(providerSection.base_url || "").trim();
|
|
509
|
+
const configuredBearerToken = String(providerSection.experimental_bearer_token || "").trim();
|
|
510
|
+
const routedViaRouter = Boolean(
|
|
511
|
+
expectedBaseUrl
|
|
512
|
+
&& modelProvider.value === CODEX_PROVIDER_ID
|
|
513
|
+
&& configuredBaseUrl === expectedBaseUrl
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
return {
|
|
517
|
+
tool: "codex-cli",
|
|
518
|
+
configFilePath: resolvedConfigPath,
|
|
519
|
+
backupFilePath: resolvedBackupPath,
|
|
520
|
+
configExists: configState.existed,
|
|
521
|
+
backupExists: backupState.existed,
|
|
522
|
+
routedViaRouter,
|
|
523
|
+
configuredBaseUrl,
|
|
524
|
+
modelProvider: modelProvider.value,
|
|
525
|
+
bindings: {
|
|
526
|
+
defaultModel: model.value
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export async function patchCodexCliConfigFile({
|
|
532
|
+
configFilePath = "",
|
|
533
|
+
backupFilePath = "",
|
|
534
|
+
modelCatalogFilePath = "",
|
|
535
|
+
endpointUrl = "",
|
|
536
|
+
apiKey = "",
|
|
537
|
+
bindings = {},
|
|
538
|
+
modelCatalog = undefined,
|
|
539
|
+
captureBackup = true,
|
|
540
|
+
env = process.env,
|
|
541
|
+
homeDir = os.homedir()
|
|
542
|
+
} = {}) {
|
|
543
|
+
const resolvedConfigPath = path.resolve(String(configFilePath || resolveCodexCliConfigFilePath({ env, homeDir })).trim());
|
|
544
|
+
const resolvedBackupPath = path.resolve(String(backupFilePath || resolveCodingToolBackupFilePath(resolvedConfigPath)).trim());
|
|
545
|
+
const resolvedCatalogPath = path.resolve(String(modelCatalogFilePath || resolveCodexCliModelCatalogFilePath({
|
|
546
|
+
configFilePath: resolvedConfigPath,
|
|
547
|
+
env,
|
|
548
|
+
homeDir
|
|
549
|
+
})).trim());
|
|
550
|
+
const baseUrl = buildCodexProviderBaseUrl(endpointUrl);
|
|
551
|
+
const normalizedApiKey = String(apiKey || "").trim();
|
|
552
|
+
const normalizedBindings = normalizeCodexBindings(bindings);
|
|
553
|
+
const normalizedModelCatalog = normalizeCodexModelCatalog(modelCatalog);
|
|
554
|
+
|
|
555
|
+
if (!baseUrl) {
|
|
556
|
+
throw new Error("Codex CLI endpoint URL must be a valid http:// or https:// URL.");
|
|
557
|
+
}
|
|
558
|
+
if (!normalizedApiKey) {
|
|
559
|
+
throw new Error("Codex CLI API key is required.");
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const configState = await readTextFile(resolvedConfigPath);
|
|
563
|
+
const document = splitTomlDocument(configState.text);
|
|
564
|
+
const backupState = await ensureToolBackupFileExists(resolvedBackupPath);
|
|
565
|
+
const existingBackup = sanitizeBackup(backupState.data, "codex-cli");
|
|
566
|
+
|
|
567
|
+
if (captureBackup && !backupHasData(existingBackup)) {
|
|
568
|
+
const backup = configState.existed ? captureCodexBackup(document) : {};
|
|
569
|
+
await writeJsonObjectFile(resolvedBackupPath, backup);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
setTopLevelTomlStringField(document, "model_provider", CODEX_PROVIDER_ID);
|
|
573
|
+
if (normalizedBindings.defaultModel) setTopLevelTomlStringField(document, "model", normalizedBindings.defaultModel);
|
|
574
|
+
setTomlSection(document, `model_providers.${CODEX_PROVIDER_ID}`, createCodexProviderSection({
|
|
575
|
+
baseUrl,
|
|
576
|
+
apiKey: normalizedApiKey
|
|
577
|
+
}));
|
|
578
|
+
if (normalizedModelCatalog) {
|
|
579
|
+
if (normalizedModelCatalog.models.length > 0) {
|
|
580
|
+
await writeJsonObjectFile(resolvedCatalogPath, normalizedModelCatalog);
|
|
581
|
+
setTopLevelTomlStringField(document, "model_catalog_json", resolvedCatalogPath);
|
|
582
|
+
} else {
|
|
583
|
+
deleteTopLevelTomlField(document, "model_catalog_json");
|
|
584
|
+
await fs.rm(resolvedCatalogPath, { force: true });
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
await writeTextFile(resolvedConfigPath, serializeTomlDocument(document));
|
|
589
|
+
return {
|
|
590
|
+
configFilePath: resolvedConfigPath,
|
|
591
|
+
backupFilePath: resolvedBackupPath,
|
|
592
|
+
modelCatalogFilePath: normalizedModelCatalog?.models?.length > 0 ? resolvedCatalogPath : "",
|
|
593
|
+
configCreated: !configState.existed,
|
|
594
|
+
baseUrl,
|
|
595
|
+
bindings: normalizedBindings
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export async function unpatchCodexCliConfigFile({
|
|
600
|
+
configFilePath = "",
|
|
601
|
+
backupFilePath = "",
|
|
602
|
+
env = process.env,
|
|
603
|
+
homeDir = os.homedir()
|
|
604
|
+
} = {}) {
|
|
605
|
+
const resolvedConfigPath = path.resolve(String(configFilePath || resolveCodexCliConfigFilePath({ env, homeDir })).trim());
|
|
606
|
+
const resolvedBackupPath = path.resolve(String(backupFilePath || resolveCodingToolBackupFilePath(resolvedConfigPath)).trim());
|
|
607
|
+
const resolvedCatalogPath = resolveCodexCliModelCatalogFilePath({
|
|
608
|
+
configFilePath: resolvedConfigPath,
|
|
609
|
+
env,
|
|
610
|
+
homeDir
|
|
611
|
+
});
|
|
612
|
+
const configState = await readTextFile(resolvedConfigPath);
|
|
613
|
+
const document = splitTomlDocument(configState.text);
|
|
614
|
+
const backupState = await readJsonObjectFile(resolvedBackupPath, `Backup file '${resolvedBackupPath}'`);
|
|
615
|
+
const backup = sanitizeBackup(backupState.data, "codex-cli");
|
|
616
|
+
|
|
617
|
+
applyCodexBackup(document, backup);
|
|
618
|
+
await writeTextFile(resolvedConfigPath, serializeTomlDocument(document));
|
|
619
|
+
if (String(backup?.modelCatalogJson?.value || "").trim() !== resolvedCatalogPath) {
|
|
620
|
+
await fs.rm(resolvedCatalogPath, { force: true });
|
|
621
|
+
}
|
|
622
|
+
await writeJsonObjectFile(resolvedBackupPath, {});
|
|
623
|
+
|
|
624
|
+
return {
|
|
625
|
+
configFilePath: resolvedConfigPath,
|
|
626
|
+
backupFilePath: resolvedBackupPath,
|
|
627
|
+
configExisted: configState.existed,
|
|
628
|
+
backupRestored: backupHasData(backup)
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export async function readClaudeCodeRoutingState({
|
|
633
|
+
settingsFilePath = "",
|
|
634
|
+
backupFilePath = "",
|
|
635
|
+
endpointUrl = "",
|
|
636
|
+
env = process.env,
|
|
637
|
+
homeDir = os.homedir()
|
|
638
|
+
} = {}) {
|
|
639
|
+
const resolvedSettingsPath = path.resolve(String(settingsFilePath || resolveClaudeCodeSettingsFilePath({ env, homeDir })).trim());
|
|
640
|
+
const resolvedBackupPath = path.resolve(String(backupFilePath || resolveCodingToolBackupFilePath(resolvedSettingsPath)).trim());
|
|
641
|
+
const expectedBaseUrl = buildClaudeCodeBaseUrl(endpointUrl);
|
|
642
|
+
const settingsState = await readJsonObjectFile(resolvedSettingsPath, `Claude Code settings file '${resolvedSettingsPath}'`);
|
|
643
|
+
const backupState = await readJsonObjectFile(resolvedBackupPath, `Backup file '${resolvedBackupPath}'`);
|
|
644
|
+
const envConfig = settingsState.data?.env && typeof settingsState.data.env === "object" && !Array.isArray(settingsState.data.env)
|
|
645
|
+
? settingsState.data.env
|
|
646
|
+
: {};
|
|
647
|
+
const configuredBaseUrl = normalizeHttpUrl(envConfig.ANTHROPIC_BASE_URL || "");
|
|
648
|
+
const routedViaRouter = Boolean(
|
|
649
|
+
expectedBaseUrl
|
|
650
|
+
&& configuredBaseUrl === expectedBaseUrl
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
return {
|
|
654
|
+
tool: "claude-code",
|
|
655
|
+
settingsFilePath: resolvedSettingsPath,
|
|
656
|
+
backupFilePath: resolvedBackupPath,
|
|
657
|
+
settingsExists: settingsState.existed,
|
|
658
|
+
backupExists: backupState.existed,
|
|
659
|
+
routedViaRouter,
|
|
660
|
+
configuredBaseUrl,
|
|
661
|
+
bindings: normalizeClaudeBindings({
|
|
662
|
+
primaryModel: envConfig.ANTHROPIC_MODEL,
|
|
663
|
+
defaultOpusModel: envConfig.ANTHROPIC_DEFAULT_OPUS_MODEL,
|
|
664
|
+
defaultSonnetModel: envConfig.ANTHROPIC_DEFAULT_SONNET_MODEL,
|
|
665
|
+
defaultHaikuModel: envConfig.ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
|
666
|
+
subagentModel: envConfig.CLAUDE_CODE_SUBAGENT_MODEL
|
|
667
|
+
})
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
export async function patchClaudeCodeSettingsFile({
|
|
672
|
+
settingsFilePath = "",
|
|
673
|
+
backupFilePath = "",
|
|
674
|
+
endpointUrl = "",
|
|
675
|
+
apiKey = "",
|
|
676
|
+
bindings = {},
|
|
677
|
+
captureBackup = true,
|
|
678
|
+
env = process.env,
|
|
679
|
+
homeDir = os.homedir()
|
|
680
|
+
} = {}) {
|
|
681
|
+
const resolvedSettingsPath = path.resolve(String(settingsFilePath || resolveClaudeCodeSettingsFilePath({ env, homeDir })).trim());
|
|
682
|
+
const resolvedBackupPath = path.resolve(String(backupFilePath || resolveCodingToolBackupFilePath(resolvedSettingsPath)).trim());
|
|
683
|
+
const baseUrl = buildClaudeCodeBaseUrl(endpointUrl);
|
|
684
|
+
const normalizedApiKey = String(apiKey || "").trim();
|
|
685
|
+
const normalizedBindings = normalizeClaudeBindings(bindings);
|
|
686
|
+
|
|
687
|
+
if (!baseUrl) {
|
|
688
|
+
throw new Error("Claude Code endpoint URL must be a valid http:// or https:// URL.");
|
|
689
|
+
}
|
|
690
|
+
if (!normalizedApiKey) {
|
|
691
|
+
throw new Error("Claude Code API key is required.");
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const settingsState = await readJsonObjectFile(resolvedSettingsPath, `Claude Code settings file '${resolvedSettingsPath}'`);
|
|
695
|
+
const backupState = await ensureToolBackupFileExists(resolvedBackupPath);
|
|
696
|
+
const existingBackup = sanitizeBackup(backupState.data, "claude-code");
|
|
697
|
+
const nextSettings = settingsState.data && typeof settingsState.data === "object" && !Array.isArray(settingsState.data)
|
|
698
|
+
? structuredClone(settingsState.data)
|
|
699
|
+
: {};
|
|
700
|
+
|
|
701
|
+
if (captureBackup && !backupHasData(existingBackup)) {
|
|
702
|
+
const backup = settingsState.existed ? captureClaudeBackup(nextSettings) : {};
|
|
703
|
+
await writeJsonObjectFile(resolvedBackupPath, backup);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (!nextSettings.env || typeof nextSettings.env !== "object" || Array.isArray(nextSettings.env)) {
|
|
707
|
+
nextSettings.env = {};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
nextSettings.env.ANTHROPIC_BASE_URL = baseUrl;
|
|
711
|
+
nextSettings.env.ANTHROPIC_AUTH_TOKEN = normalizedApiKey;
|
|
712
|
+
delete nextSettings.env.ANTHROPIC_API_KEY;
|
|
713
|
+
|
|
714
|
+
if (normalizedBindings.primaryModel) nextSettings.env.ANTHROPIC_MODEL = normalizedBindings.primaryModel;
|
|
715
|
+
else delete nextSettings.env.ANTHROPIC_MODEL;
|
|
716
|
+
|
|
717
|
+
if (normalizedBindings.defaultOpusModel) nextSettings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = normalizedBindings.defaultOpusModel;
|
|
718
|
+
else delete nextSettings.env.ANTHROPIC_DEFAULT_OPUS_MODEL;
|
|
719
|
+
|
|
720
|
+
if (normalizedBindings.defaultSonnetModel) nextSettings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = normalizedBindings.defaultSonnetModel;
|
|
721
|
+
else delete nextSettings.env.ANTHROPIC_DEFAULT_SONNET_MODEL;
|
|
722
|
+
|
|
723
|
+
if (normalizedBindings.defaultHaikuModel) nextSettings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = normalizedBindings.defaultHaikuModel;
|
|
724
|
+
else delete nextSettings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
|
725
|
+
|
|
726
|
+
delete nextSettings.env.ANTHROPIC_SMALL_FAST_MODEL;
|
|
727
|
+
|
|
728
|
+
if (normalizedBindings.subagentModel) nextSettings.env.CLAUDE_CODE_SUBAGENT_MODEL = normalizedBindings.subagentModel;
|
|
729
|
+
else delete nextSettings.env.CLAUDE_CODE_SUBAGENT_MODEL;
|
|
730
|
+
|
|
731
|
+
await writeJsonObjectFile(resolvedSettingsPath, nextSettings);
|
|
732
|
+
return {
|
|
733
|
+
settingsFilePath: resolvedSettingsPath,
|
|
734
|
+
backupFilePath: resolvedBackupPath,
|
|
735
|
+
settingsCreated: !settingsState.existed,
|
|
736
|
+
baseUrl,
|
|
737
|
+
bindings: normalizedBindings
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
export async function unpatchClaudeCodeSettingsFile({
|
|
742
|
+
settingsFilePath = "",
|
|
743
|
+
backupFilePath = "",
|
|
744
|
+
env = process.env,
|
|
745
|
+
homeDir = os.homedir()
|
|
746
|
+
} = {}) {
|
|
747
|
+
const resolvedSettingsPath = path.resolve(String(settingsFilePath || resolveClaudeCodeSettingsFilePath({ env, homeDir })).trim());
|
|
748
|
+
const resolvedBackupPath = path.resolve(String(backupFilePath || resolveCodingToolBackupFilePath(resolvedSettingsPath)).trim());
|
|
749
|
+
const settingsState = await readJsonObjectFile(resolvedSettingsPath, `Claude Code settings file '${resolvedSettingsPath}'`);
|
|
750
|
+
const backupState = await readJsonObjectFile(resolvedBackupPath, `Backup file '${resolvedBackupPath}'`);
|
|
751
|
+
const backup = sanitizeBackup(backupState.data, "claude-code");
|
|
752
|
+
const restoredSettings = applyClaudeBackup(settingsState.data, backup);
|
|
753
|
+
|
|
754
|
+
await writeJsonObjectFile(resolvedSettingsPath, restoredSettings);
|
|
755
|
+
await writeJsonObjectFile(resolvedBackupPath, {});
|
|
756
|
+
|
|
757
|
+
return {
|
|
758
|
+
settingsFilePath: resolvedSettingsPath,
|
|
759
|
+
backupFilePath: resolvedBackupPath,
|
|
760
|
+
settingsExisted: settingsState.existed,
|
|
761
|
+
backupRestored: backupHasData(backup)
|
|
762
|
+
};
|
|
763
|
+
}
|