@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.
@@ -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" || value.provider === "openai_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 replaceManagedBlock(existing, block) {
33
+ function managedBlockBounds(existing) {
25
34
  const start = existing.indexOf(START);
26
35
  const end = existing.indexOf(END);
27
- if (start >= 0 && end >= start) {
28
- const before = existing.slice(0, start).trimEnd();
29
- const after = existing.slice(end + END.length).trimStart();
30
- return [before, block, after].filter(Boolean).join("\n\n") + "\n";
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 [existing.trimEnd(), block].filter(Boolean).join("\n\n") + "\n";
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 fs = opts.fs ?? { existsSync, readFileSync, writeFileSync, mkdirSync };
36
- const homeDir = opts.homeDir ?? os.homedir();
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 next = replaceManagedBlock(existing, managedBlock(opts.baseUrl, tokenEnvKey, opts.defaultModel));
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: Boolean(cfg.client.remoteSecret),
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.11.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",