@timo972/cc-router 0.11.0 → 0.12.0-rc.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 +12 -0
- package/README.md +32 -3
- package/dist/cli/cmd-accounts.js +91 -13
- package/dist/cli/cmd-cli-targets.js +177 -0
- package/dist/cli/cmd-client.js +3 -2
- package/dist/cli/cmd-configure.js +18 -4
- package/dist/cli/cmd-status.js +4 -1
- package/dist/cli/cmd-stop.js +18 -0
- package/dist/cli/index.js +10 -3
- package/dist/config/manager.js +19 -3
- package/dist/config/paths.js +1 -0
- package/dist/protocol/model-ref.js +1 -1
- package/dist/providers/openai/account-state.js +4 -1
- package/dist/providers/openai/usage.js +14 -1
- package/dist/providers/xai/account-record.js +42 -0
- package/dist/providers/xai/device-oauth.js +116 -0
- package/dist/providers/xai/import-auth.js +70 -0
- package/dist/providers/xai/overview.js +279 -0
- package/dist/providers/xai/subscription-fetch.js +56 -0
- package/dist/proxy/allowance.js +142 -0
- package/dist/proxy/logger.js +4 -3
- package/dist/proxy/models-server.js +30 -2
- package/dist/proxy/openai-ingress.js +7 -1
- package/dist/proxy/openai-routing.js +12 -7
- package/dist/proxy/server.js +110 -6
- package/dist/ui/Dashboard.js +383 -124
- package/dist/ui/accountsApi.js +3 -1
- package/dist/utils/cli-routing.js +80 -0
- package/dist/utils/codex-config.js +337 -15
- package/package.json +3 -2
package/dist/ui/accountsApi.js
CHANGED
|
@@ -48,7 +48,9 @@ function isRecord(value) {
|
|
|
48
48
|
function publicAccountSafeView(value) {
|
|
49
49
|
if (!isRecord(value) || typeof value.id !== "string")
|
|
50
50
|
return [];
|
|
51
|
-
const provider = value.provider === "anthropic_subscription"
|
|
51
|
+
const provider = value.provider === "anthropic_subscription"
|
|
52
|
+
|| value.provider === "openai_subscription"
|
|
53
|
+
|| value.provider === "xai_subscription"
|
|
52
54
|
? value.provider
|
|
53
55
|
: undefined;
|
|
54
56
|
const rateLimits = publicRateLimits(value.rateLimits);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { writeClaudeSettings, removeClaudeSettings, readClaudeProxySettings } from "./claude-config.js";
|
|
2
|
+
import { writeCodexRouterConfig, writeCodexRouterConfigFromClient, removeCodexRouterConfig, readCodexRouterConfig, codexBaseUrlFromRouterUrl, } from "./codex-config.js";
|
|
3
|
+
import { readConfig } from "../config/manager.js";
|
|
4
|
+
import { PROXY_PORT, CLAUDE_SETTINGS_PATH } from "../config/paths.js";
|
|
5
|
+
function configuredPort(explicit) {
|
|
6
|
+
if (explicit !== undefined && Number.isFinite(explicit))
|
|
7
|
+
return explicit;
|
|
8
|
+
return readConfig().runPreferences?.port ?? PROXY_PORT;
|
|
9
|
+
}
|
|
10
|
+
export function readClaudeRouting() {
|
|
11
|
+
const current = readClaudeProxySettings();
|
|
12
|
+
return {
|
|
13
|
+
target: "claude",
|
|
14
|
+
enabled: Boolean(current.baseUrl),
|
|
15
|
+
baseUrl: current.baseUrl,
|
|
16
|
+
model: current.model,
|
|
17
|
+
path: CLAUDE_SETTINGS_PATH,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function readCodexRouting() {
|
|
21
|
+
const current = readCodexRouterConfig();
|
|
22
|
+
return {
|
|
23
|
+
target: "codex",
|
|
24
|
+
enabled: current.configured,
|
|
25
|
+
baseUrl: current.baseUrl,
|
|
26
|
+
model: current.model,
|
|
27
|
+
modelProvider: current.modelProvider,
|
|
28
|
+
path: current.path,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function setClaudeRouting(enabled, opts = {}) {
|
|
32
|
+
const current = readClaudeRouting();
|
|
33
|
+
if (!enabled) {
|
|
34
|
+
if (!current.enabled)
|
|
35
|
+
return { changed: false, enabled: false, path: current.path };
|
|
36
|
+
removeClaudeSettings();
|
|
37
|
+
return { changed: true, enabled: false, path: current.path };
|
|
38
|
+
}
|
|
39
|
+
const cfg = readConfig();
|
|
40
|
+
const port = configuredPort(opts.port);
|
|
41
|
+
if (cfg.client?.remoteUrl) {
|
|
42
|
+
writeClaudeSettings(0, cfg.client.remoteUrl, cfg.client.remoteSecret ?? "proxy-managed", opts.model);
|
|
43
|
+
return { changed: true, enabled: true, baseUrl: cfg.client.remoteUrl, path: current.path };
|
|
44
|
+
}
|
|
45
|
+
writeClaudeSettings(port, undefined, undefined, opts.model);
|
|
46
|
+
return { changed: true, enabled: true, baseUrl: `http://localhost:${port}`, path: current.path };
|
|
47
|
+
}
|
|
48
|
+
export function setCodexRouting(enabled, opts = {}) {
|
|
49
|
+
const current = readCodexRouting();
|
|
50
|
+
if (!enabled) {
|
|
51
|
+
if (!current.enabled)
|
|
52
|
+
return { changed: false, enabled: false, path: current.path };
|
|
53
|
+
const result = removeCodexRouterConfig();
|
|
54
|
+
return { changed: result.removed, enabled: false, path: result.path };
|
|
55
|
+
}
|
|
56
|
+
const cfg = readConfig();
|
|
57
|
+
const port = configuredPort(opts.port);
|
|
58
|
+
if (cfg.client?.remoteUrl) {
|
|
59
|
+
const result = writeCodexRouterConfigFromClient(cfg, { defaultModel: opts.model });
|
|
60
|
+
return {
|
|
61
|
+
changed: true,
|
|
62
|
+
enabled: true,
|
|
63
|
+
baseUrl: codexBaseUrlFromRouterUrl(cfg.client.remoteUrl),
|
|
64
|
+
path: result.path,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// Local routing has no auth in front of the proxy, so no env_key is
|
|
68
|
+
// written — Codex would otherwise hard-abort on startup if that env var
|
|
69
|
+
// isn't set.
|
|
70
|
+
const result = writeCodexRouterConfig({
|
|
71
|
+
baseUrl: `http://localhost:${port}/v1`,
|
|
72
|
+
defaultModel: opts.model,
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
changed: true,
|
|
76
|
+
enabled: true,
|
|
77
|
+
baseUrl: `http://localhost:${port}/v1`,
|
|
78
|
+
path: result.path,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import os from "os";
|
|
4
|
+
import { parse as parseToml } from "smol-toml";
|
|
5
|
+
import { CODEX_CONFIG_PATH } from "../config/paths.js";
|
|
4
6
|
const START = "# cc-router:start";
|
|
5
7
|
const END = "# cc-router:end";
|
|
8
|
+
/**
|
|
9
|
+
* Marker prefix for a top-level key the managed block's own value shadows
|
|
10
|
+
* (e.g. a pre-existing `model_provider = "x"` before the block). Commenting
|
|
11
|
+
* it out (rather than deleting it) makes the write reversible: `removeCodex
|
|
12
|
+
* RouterConfig` strips this exact prefix to hand the line back unchanged.
|
|
13
|
+
*/
|
|
14
|
+
const SUPERSEDED_PREFIX = "# cc-router:superseded ";
|
|
6
15
|
export function codexBaseUrlFromRouterUrl(remoteUrl) {
|
|
7
16
|
const base = remoteUrl.trim().replace(/\/+$/, "");
|
|
8
17
|
return base.endsWith("/v1") ? base : `${base}/v1`;
|
|
@@ -17,46 +26,359 @@ function managedBlock(baseUrl, tokenEnvKey, defaultModel) {
|
|
|
17
26
|
"name = \"CC-Router\"",
|
|
18
27
|
`base_url = "${baseUrl}"`,
|
|
19
28
|
"wire_api = \"responses\"",
|
|
20
|
-
`env_key = "${tokenEnvKey}"
|
|
29
|
+
...(tokenEnvKey ? [`env_key = "${tokenEnvKey}"`] : []),
|
|
21
30
|
END,
|
|
22
31
|
].join("\n");
|
|
23
32
|
}
|
|
24
|
-
function
|
|
33
|
+
function managedBlockBounds(existing) {
|
|
25
34
|
const start = existing.indexOf(START);
|
|
26
35
|
const end = existing.indexOf(END);
|
|
27
|
-
if (start
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
36
|
+
if (start < 0 && end < 0)
|
|
37
|
+
return undefined;
|
|
38
|
+
if (start < 0 || end < start) {
|
|
39
|
+
throw new Error("Malformed cc-router managed block in Codex config");
|
|
31
40
|
}
|
|
32
|
-
return
|
|
41
|
+
return { start, end: end + END.length };
|
|
42
|
+
}
|
|
43
|
+
function countOccurrences(line, needle) {
|
|
44
|
+
let count = 0;
|
|
45
|
+
let idx = 0;
|
|
46
|
+
while (true) {
|
|
47
|
+
idx = line.indexOf(needle, idx);
|
|
48
|
+
if (idx < 0)
|
|
49
|
+
break;
|
|
50
|
+
count++;
|
|
51
|
+
idx += needle.length;
|
|
52
|
+
}
|
|
53
|
+
return count;
|
|
54
|
+
}
|
|
55
|
+
/** Root-level keys the managed block ever supersedes (bare or quoted). */
|
|
56
|
+
const SUPERSEDABLE_KEYS = ["model_provider", "model"];
|
|
57
|
+
/** Builds a regex matching `key = ` with `key` optionally `"quoted"`/`'quoted'`. */
|
|
58
|
+
function assignmentPattern(key) {
|
|
59
|
+
return new RegExp(`^\\s*(?:"${key}"|'${key}'|${key})\\s*=`);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Reduces a line to its "structural" code: comment tails and single-line
|
|
63
|
+
* quoted-string bodies are stripped out (never inspected), while a `"""`/
|
|
64
|
+
* `'''` delimiter that is NOT nested inside a single-line `"…"`/`'…'` string
|
|
65
|
+
* is preserved verbatim. This is deliberately char-by-char rather than a
|
|
66
|
+
* full TOML parser, but — unlike a naive quote-state toggle — it correctly
|
|
67
|
+
* leaves literal `"""`/`'''` text INSIDE an ordinary single-line string
|
|
68
|
+
* (e.g. `hint = 'use """ here'`) out of the output entirely, so callers
|
|
69
|
+
* scanning for multiline-string delimiters never mistake it for one.
|
|
70
|
+
* Escaped quotes (`\"`) inside basic (`"…"`) strings are honored; literal
|
|
71
|
+
* strings (`'…'`) have no escapes, per TOML.
|
|
72
|
+
*/
|
|
73
|
+
function codeOnly(line) {
|
|
74
|
+
let out = "";
|
|
75
|
+
let i = 0;
|
|
76
|
+
while (i < line.length) {
|
|
77
|
+
const three = line.slice(i, i + 3);
|
|
78
|
+
if (three === '"""' || three === "'''") {
|
|
79
|
+
out += three;
|
|
80
|
+
i += 3;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const ch = line[i];
|
|
84
|
+
if (ch === "#")
|
|
85
|
+
break;
|
|
86
|
+
if (ch === '"') {
|
|
87
|
+
let j = i + 1;
|
|
88
|
+
while (j < line.length && line[j] !== '"') {
|
|
89
|
+
if (line[j] === "\\")
|
|
90
|
+
j++;
|
|
91
|
+
j++;
|
|
92
|
+
}
|
|
93
|
+
i = j < line.length ? j + 1 : line.length;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (ch === "'") {
|
|
97
|
+
let j = i + 1;
|
|
98
|
+
while (j < line.length && line[j] !== "'")
|
|
99
|
+
j++;
|
|
100
|
+
i = j < line.length ? j + 1 : line.length;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
out += ch;
|
|
104
|
+
i++;
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Walks every line once, tracking `"""`/`'''` multiline-string open/close
|
|
110
|
+
* state AND `[`/`]` array-bracket nesting depth (deliberately not a full
|
|
111
|
+
* TOML parser), against `codeOnly`'s output so neither comments nor
|
|
112
|
+
* single-line string content can be mistaken for real delimiters/brackets.
|
|
113
|
+
* This is the single shared lexical pass used by `findFirstTableHeaderLine`
|
|
114
|
+
* and `supersedeTopLevelKey` so they never disagree about what counts as
|
|
115
|
+
* "inside a multiline string" or "inside an unclosed array".
|
|
116
|
+
*/
|
|
117
|
+
function scanTomlLines(lines) {
|
|
118
|
+
const insideMultilineString = [];
|
|
119
|
+
const bracketDepthAtLineStart = [];
|
|
120
|
+
let openDelim = null;
|
|
121
|
+
let depth = 0;
|
|
122
|
+
for (const line of lines) {
|
|
123
|
+
insideMultilineString.push(openDelim !== null);
|
|
124
|
+
bracketDepthAtLineStart.push(depth);
|
|
125
|
+
if (openDelim) {
|
|
126
|
+
if (countOccurrences(line, openDelim) % 2 === 1)
|
|
127
|
+
openDelim = null;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const code = codeOnly(line);
|
|
131
|
+
for (const ch of code) {
|
|
132
|
+
if (ch === "[")
|
|
133
|
+
depth++;
|
|
134
|
+
else if (ch === "]")
|
|
135
|
+
depth = Math.max(0, depth - 1);
|
|
136
|
+
}
|
|
137
|
+
if (countOccurrences(code, '"""') % 2 === 1) {
|
|
138
|
+
openDelim = '"""';
|
|
139
|
+
}
|
|
140
|
+
else if (countOccurrences(code, "'''") % 2 === 1) {
|
|
141
|
+
openDelim = "'''";
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { insideMultilineString, bracketDepthAtLineStart };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* A line is only a real top-level table-header CANDIDATE when it is not
|
|
148
|
+
* inside a multiline string, bracket depth is 0 at its start (i.e. it is
|
|
149
|
+
* not a continuation line of a still-open multiline array — see F2: a
|
|
150
|
+
* `[1, 2],` row inside `matrix = [ … ]` must never be mistaken for a table
|
|
151
|
+
* header), and the trimmed line structurally starts with `[`.
|
|
152
|
+
*/
|
|
153
|
+
function isTableHeaderLine(line) {
|
|
154
|
+
return /^\s*\[/.test(line);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Line index of the first top-level table header (`[section]` / `[[array]]`),
|
|
158
|
+
* or `undefined` if the file has none.
|
|
159
|
+
*/
|
|
160
|
+
function findFirstTableHeaderLine(lines, scan) {
|
|
161
|
+
for (let i = 0; i < lines.length; i++) {
|
|
162
|
+
if (scan.insideMultilineString[i])
|
|
163
|
+
continue;
|
|
164
|
+
if (scan.bracketDepthAtLineStart[i] !== 0)
|
|
165
|
+
continue;
|
|
166
|
+
if (isTableHeaderLine(lines[i]))
|
|
167
|
+
return i;
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Reverses `SUPERSEDED_PREFIX` commenting, but only on lines with clear
|
|
173
|
+
* provenance: outside a multiline string (per `scanTomlLines`), and whose
|
|
174
|
+
* remainder (after stripping the prefix) parses as an assignment of one of
|
|
175
|
+
* `SUPERSEDABLE_KEYS` — the only keys this module ever supersedes. Any other
|
|
176
|
+
* line that happens to start with the same text (e.g. a user's own comment,
|
|
177
|
+
* or the literal marker text sitting inside an unrelated multiline string)
|
|
178
|
+
* is left byte-identical.
|
|
179
|
+
*/
|
|
180
|
+
function restoreSupersededLines(text) {
|
|
181
|
+
const lines = text.split("\n");
|
|
182
|
+
const scan = scanTomlLines(lines);
|
|
183
|
+
return lines
|
|
184
|
+
.map((line, i) => {
|
|
185
|
+
if (scan.insideMultilineString[i])
|
|
186
|
+
return line;
|
|
187
|
+
if (!line.startsWith(SUPERSEDED_PREFIX))
|
|
188
|
+
return line;
|
|
189
|
+
const remainder = line.slice(SUPERSEDED_PREFIX.length);
|
|
190
|
+
const isManagedKeyAssignment = SUPERSEDABLE_KEYS.some(key => assignmentPattern(key).test(remainder));
|
|
191
|
+
return isManagedKeyAssignment ? remainder : line;
|
|
192
|
+
})
|
|
193
|
+
.join("\n");
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Comments out (in place) every top-level `key = ...` assignment among
|
|
197
|
+
* `lines[0, scopeEnd)` — i.e. before the managed block's insertion point —
|
|
198
|
+
* so the block's own value for that key is the only one TOML sees. Matches
|
|
199
|
+
* `key` bare or quoted (`"key"`/`'key'` — TOML permits both for root keys).
|
|
200
|
+
* Already `SUPERSEDED_PREFIX`-marked lines are left untouched: re-running
|
|
201
|
+
* this on an already-commented line must not double-comment it. Lines
|
|
202
|
+
* inside a multiline string value (per `scanTomlLines`) are skipped too — a
|
|
203
|
+
* `key = ` looking line THERE is opaque string content, not a live
|
|
204
|
+
* assignment, and must never be mutated.
|
|
205
|
+
*/
|
|
206
|
+
function supersedeTopLevelKey(lines, key, scopeEnd, insideMultilineString) {
|
|
207
|
+
const assignment = assignmentPattern(key);
|
|
208
|
+
for (let i = 0; i < scopeEnd; i++) {
|
|
209
|
+
if (insideMultilineString[i])
|
|
210
|
+
continue;
|
|
211
|
+
const line = lines[i];
|
|
212
|
+
if (line.startsWith(SUPERSEDED_PREFIX))
|
|
213
|
+
continue;
|
|
214
|
+
if (assignment.test(line)) {
|
|
215
|
+
lines[i] = `${SUPERSEDED_PREFIX}${line}`;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Removes the managed block (if present) and restores any superseded lines
|
|
221
|
+
* to their original form. This is the shared "get back to the user's plain
|
|
222
|
+
* config" step used both by the explicit remove path and as the first step
|
|
223
|
+
* of a write (which then re-derives the block position and superseded set
|
|
224
|
+
* from scratch, making writes idempotent).
|
|
225
|
+
*/
|
|
226
|
+
function stripManagedBlock(existing) {
|
|
227
|
+
const bounds = managedBlockBounds(existing);
|
|
228
|
+
if (!bounds) {
|
|
229
|
+
return { next: existing, removed: false };
|
|
230
|
+
}
|
|
231
|
+
const before = existing.slice(0, bounds.start).trimEnd();
|
|
232
|
+
const after = existing.slice(bounds.end).trimStart();
|
|
233
|
+
const merged = [before, after].filter(Boolean).join("\n\n");
|
|
234
|
+
const restored = restoreSupersededLines(merged);
|
|
235
|
+
return { next: restored ? `${restored}\n` : "", removed: true };
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Inserts `block` immediately before the first top-level table header in
|
|
239
|
+
* `existing` (after stripping any prior managed block and restoring any
|
|
240
|
+
* superseded lines), so the block's top-level keys (`model`,
|
|
241
|
+
* `model_provider`) always land at the document's top level per TOML
|
|
242
|
+
* semantics — never silently swallowed into a later `[table]`. Falls back to
|
|
243
|
+
* EOF when the config has no table header at all. Any pre-existing top-level
|
|
244
|
+
* assignment of a key the block will also emit is commented out
|
|
245
|
+
* (`SUPERSEDED_PREFIX`) rather than deleted, so `stripManagedBlock` can hand
|
|
246
|
+
* it back unchanged later.
|
|
247
|
+
*/
|
|
248
|
+
function replaceManagedBlock(existing, block, keysToSupersede) {
|
|
249
|
+
const { next: base } = stripManagedBlock(existing);
|
|
250
|
+
const lines = base.length > 0 ? base.split("\n") : [];
|
|
251
|
+
const scan = scanTomlLines(lines);
|
|
252
|
+
const headerLine = findFirstTableHeaderLine(lines, scan);
|
|
253
|
+
const scopeEnd = headerLine ?? lines.length;
|
|
254
|
+
for (const key of keysToSupersede) {
|
|
255
|
+
supersedeTopLevelKey(lines, key, scopeEnd, scan.insideMultilineString);
|
|
256
|
+
}
|
|
257
|
+
if (headerLine === undefined) {
|
|
258
|
+
const trimmedBase = lines.join("\n").trimEnd();
|
|
259
|
+
return [trimmedBase, block].filter(Boolean).join("\n\n") + "\n";
|
|
260
|
+
}
|
|
261
|
+
const before = lines.slice(0, headerLine).join("\n").trimEnd();
|
|
262
|
+
const after = lines.slice(headerLine).join("\n").trimEnd();
|
|
263
|
+
return [before, block, after].filter(Boolean).join("\n\n") + "\n";
|
|
264
|
+
}
|
|
265
|
+
function quotedTomlValue(block, key) {
|
|
266
|
+
const match = block.match(new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, "m"));
|
|
267
|
+
return match?.[1];
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Safety net (F0): the line-based scanner above is deliberately not a full
|
|
271
|
+
* TOML parser and can have blind spots. Before any write actually touches
|
|
272
|
+
* disk, the candidate content is parsed with a real TOML parser and checked
|
|
273
|
+
* against the invariants the write is supposed to establish. Any violation
|
|
274
|
+
* throws — loudly refusing to write — rather than silently corrupting the
|
|
275
|
+
* user's `~/.codex/config.toml`.
|
|
276
|
+
*/
|
|
277
|
+
function assertValidManagedWrite(next, defaultModel) {
|
|
278
|
+
let parsed;
|
|
279
|
+
try {
|
|
280
|
+
parsed = parseToml(next);
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
284
|
+
throw new Error(`cc-router refused to write ~/.codex/config.toml: the rewritten config failed TOML validation (${detail}). Your config was NOT modified.`);
|
|
285
|
+
}
|
|
286
|
+
if (parsed.model_provider !== "cc-router") {
|
|
287
|
+
throw new Error("cc-router refused to write ~/.codex/config.toml: the rewritten config failed TOML validation " +
|
|
288
|
+
"(top-level model_provider is not \"cc-router\" after write). Your config was NOT modified.");
|
|
289
|
+
}
|
|
290
|
+
if (defaultModel !== undefined && parsed.model !== defaultModel) {
|
|
291
|
+
throw new Error("cc-router refused to write ~/.codex/config.toml: the rewritten config failed TOML validation " +
|
|
292
|
+
"(top-level model does not match the configured default model). Your config was NOT modified.");
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Safety net (F0) for the remove/strip path: the result of removing the
|
|
297
|
+
* managed block and restoring superseded lines must still be valid TOML.
|
|
298
|
+
* On failure, throw instead of writing — same loud-refusal contract as
|
|
299
|
+
* `assertValidManagedWrite`.
|
|
300
|
+
*/
|
|
301
|
+
function assertParsesAsToml(next) {
|
|
302
|
+
try {
|
|
303
|
+
parseToml(next);
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
307
|
+
throw new Error(`cc-router refused to update ~/.codex/config.toml: the rewritten config failed TOML validation (${detail}). Your config was NOT modified.`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function codexConfigPath(homeDir) {
|
|
311
|
+
return join(homeDir, ".codex", "config.toml");
|
|
312
|
+
}
|
|
313
|
+
function resolveCodexFs(opts) {
|
|
314
|
+
const fs = opts?.fs ?? { existsSync, readFileSync, writeFileSync, mkdirSync };
|
|
315
|
+
const homeDir = opts?.homeDir ?? os.homedir();
|
|
316
|
+
const configPath = opts?.homeDir ? codexConfigPath(homeDir) : CODEX_CONFIG_PATH;
|
|
317
|
+
return { fs, homeDir, configPath };
|
|
33
318
|
}
|
|
34
319
|
export function writeCodexRouterConfig(opts) {
|
|
35
|
-
const
|
|
36
|
-
const
|
|
320
|
+
const { fs, homeDir, configPath } = resolveCodexFs(opts);
|
|
321
|
+
const tokenEnvKey = opts.tokenEnvKey;
|
|
37
322
|
const codexDir = join(homeDir, ".codex");
|
|
38
|
-
const configPath = join(codexDir, "config.toml");
|
|
39
|
-
const tokenEnvKey = opts.tokenEnvKey ?? "CC_ROUTER_TOKEN";
|
|
40
323
|
if (!fs.existsSync(codexDir))
|
|
41
324
|
fs.mkdirSync(codexDir, { recursive: true });
|
|
42
325
|
const existing = fs.existsSync(configPath)
|
|
43
326
|
? fs.readFileSync(configPath, "utf-8")
|
|
44
327
|
: "";
|
|
45
|
-
const
|
|
328
|
+
const keysToSupersede = ["model_provider", ...(opts.defaultModel ? ["model"] : [])];
|
|
329
|
+
const next = replaceManagedBlock(existing, managedBlock(opts.baseUrl, tokenEnvKey, opts.defaultModel), keysToSupersede);
|
|
330
|
+
assertValidManagedWrite(next, opts.defaultModel);
|
|
46
331
|
fs.writeFileSync(configPath, next, "utf-8");
|
|
47
|
-
return { path: configPath };
|
|
332
|
+
return { path: configPath, tokenEnvKey };
|
|
333
|
+
}
|
|
334
|
+
/** Strip the managed `# cc-router:start` … `# cc-router:end` block from Codex config. */
|
|
335
|
+
export function removeCodexRouterConfig(opts = {}) {
|
|
336
|
+
const { fs, configPath } = resolveCodexFs(opts);
|
|
337
|
+
if (!fs.existsSync(configPath))
|
|
338
|
+
return { path: configPath, removed: false };
|
|
339
|
+
const existing = fs.readFileSync(configPath, "utf-8");
|
|
340
|
+
const { next, removed } = stripManagedBlock(existing);
|
|
341
|
+
if (removed) {
|
|
342
|
+
assertParsesAsToml(next);
|
|
343
|
+
fs.writeFileSync(configPath, next, "utf-8");
|
|
344
|
+
}
|
|
345
|
+
return { path: configPath, removed };
|
|
346
|
+
}
|
|
347
|
+
/** Read whether Codex CLI is currently pointed at CC-Router. */
|
|
348
|
+
export function readCodexRouterConfig(opts = {}) {
|
|
349
|
+
const { fs, configPath } = resolveCodexFs(opts);
|
|
350
|
+
if (!fs.existsSync(configPath))
|
|
351
|
+
return { path: configPath, configured: false };
|
|
352
|
+
const existing = fs.readFileSync(configPath, "utf-8");
|
|
353
|
+
const bounds = managedBlockBounds(existing);
|
|
354
|
+
if (!bounds)
|
|
355
|
+
return { path: configPath, configured: false };
|
|
356
|
+
const block = existing.slice(bounds.start, bounds.end);
|
|
357
|
+
return {
|
|
358
|
+
path: configPath,
|
|
359
|
+
configured: true,
|
|
360
|
+
baseUrl: quotedTomlValue(block, "base_url"),
|
|
361
|
+
model: quotedTomlValue(block, "model"),
|
|
362
|
+
modelProvider: quotedTomlValue(block, "model_provider"),
|
|
363
|
+
};
|
|
48
364
|
}
|
|
49
365
|
export function writeCodexRouterConfigFromClient(cfg, opts = {}) {
|
|
50
366
|
if (!cfg.client?.remoteUrl) {
|
|
51
367
|
throw new Error("Client mode is not configured. Run: cc-router client connect <url>");
|
|
52
368
|
}
|
|
369
|
+
// Codex hard-aborts on startup with "Missing environment variable" when
|
|
370
|
+
// env_key names a var that isn't set. Only emit it when a remote secret is
|
|
371
|
+
// actually configured — otherwise there is nothing for that var to hold,
|
|
372
|
+
// and a router-side 401 on first request is a far better failure mode than
|
|
373
|
+
// Codex refusing to launch at all.
|
|
374
|
+
const hasSecret = Boolean(cfg.client.remoteSecret);
|
|
53
375
|
const result = writeCodexRouterConfig({
|
|
54
376
|
...opts,
|
|
55
377
|
baseUrl: codexBaseUrlFromRouterUrl(cfg.client.remoteUrl),
|
|
56
|
-
tokenEnvKey: "CC_ROUTER_TOKEN",
|
|
378
|
+
tokenEnvKey: hasSecret ? "CC_ROUTER_TOKEN" : undefined,
|
|
57
379
|
});
|
|
58
380
|
return {
|
|
59
381
|
...result,
|
|
60
|
-
hasSecret
|
|
382
|
+
hasSecret,
|
|
61
383
|
};
|
|
62
384
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timo972/cc-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0-rc.0",
|
|
4
4
|
"description": "Cache-aware session router for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"express": "^4.21.0",
|
|
46
46
|
"http-proxy-middleware": "^3.0.5",
|
|
47
47
|
"ink": "^5.0.0",
|
|
48
|
-
"react": "^18.3.0"
|
|
48
|
+
"react": "^18.3.0",
|
|
49
|
+
"smol-toml": "^1.8.0"
|
|
49
50
|
},
|
|
50
51
|
"devDependencies": {
|
|
51
52
|
"@types/express": "^4.17.21",
|