codex-model-router 1.1.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/LICENSE +21 -0
- package/README.md +156 -0
- package/bin/codex-model-router.js +4 -0
- package/lib/router-core.js +679 -0
- package/lib/router.js +210 -0
- package/lib/toml.js +420 -0
- package/package.json +41 -0
package/lib/router.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { run as runCore } from "./router-core.js";
|
|
6
|
+
|
|
7
|
+
export const VERSION = "1.1.0";
|
|
8
|
+
|
|
9
|
+
const SOL_TEMPLATE = `name = "sol"
|
|
10
|
+
description = "High-capability specialist for security-sensitive or high-regression-risk work."
|
|
11
|
+
model = "gpt-5.6-sol"
|
|
12
|
+
model_reasoning_effort = "medium"
|
|
13
|
+
# v1.0.0 used sandbox_mode = "read-only"
|
|
14
|
+
sandbox_mode = "workspace-write"
|
|
15
|
+
developer_instructions = """
|
|
16
|
+
Review or implement only when explicitly delegated by Terra.
|
|
17
|
+
For review-only tasks, report concrete findings without changing files.
|
|
18
|
+
For implementation tasks, make focused changes, run relevant checks, and report the result to Terra.
|
|
19
|
+
Focus on security, authentication, permissions, secrets, destructive actions, financial logic, SQL writes, concurrency, complex state, and regression risk.
|
|
20
|
+
Do not expand the scope beyond the delegated task.
|
|
21
|
+
"""
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
const SKILL_TEMPLATE = `---
|
|
25
|
+
name: model-router
|
|
26
|
+
description: Route Codex work between Terra, Luna, and Sol with the fewest required agents.
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
Terra handles ordinary questions, coding, debugging, fixes, testing, and implementation. Never create a Terra subagent.
|
|
30
|
+
Use Luna only for deterministic low-risk repeated edits, bulk patterns, read-heavy searches, formatting, counting, extraction, or summaries; prefer Luna when the same clear operation repeats at least three times.
|
|
31
|
+
Use Sol for security, authentication, authorization, permissions, secrets, destructive actions, financial logic, SQL writes, concurrency, complex state changes, high-regression-risk logic, or an explicit review. Prefer review-only delegation first; when implementation or a confirmed fix is explicitly required, Sol may edit files in workspace-write mode and run relevant checks.
|
|
32
|
+
Do not spawn a subagent for a simple question. Use the minimum number of agents and run Luna with Sol only when both independent tasks are required.
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
function hash(content) {
|
|
36
|
+
return createHash("sha256").update(content).digest("hex");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function paths(options, global) {
|
|
40
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
41
|
+
const home = resolve(options.home ?? homedir());
|
|
42
|
+
const env = options.env ?? process.env;
|
|
43
|
+
const codexHome = global ? resolve(env.CODEX_HOME || join(home, ".codex")) : join(cwd, ".codex");
|
|
44
|
+
const skillsHome = global ? join(home, ".agents", "skills") : join(cwd, ".agents", "skills");
|
|
45
|
+
return {
|
|
46
|
+
state: join(codexHome, "model-router-state.json"),
|
|
47
|
+
sol: join(codexHome, "agents", "sol.toml"),
|
|
48
|
+
skill: join(skillsHome, "model-router", "SKILL.md")
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function readText(path) {
|
|
53
|
+
try { return await readFile(path, "utf8"); }
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error?.code === "ENOENT") return undefined;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function atomicWrite(path, content) {
|
|
61
|
+
await mkdir(dirname(path), { recursive: true });
|
|
62
|
+
let mode = 0o600;
|
|
63
|
+
try { mode = (await stat(path)).mode; } catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
64
|
+
const temporary = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
65
|
+
let handle;
|
|
66
|
+
try {
|
|
67
|
+
handle = await open(temporary, "wx", mode);
|
|
68
|
+
await handle.writeFile(content, "utf8");
|
|
69
|
+
await handle.sync();
|
|
70
|
+
await handle.close();
|
|
71
|
+
handle = undefined;
|
|
72
|
+
await rename(temporary, path);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
try { await handle?.close(); } catch {}
|
|
75
|
+
try { await rm(temporary, { force: true }); } catch {}
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function upgradeManagedTemplates(options, global) {
|
|
81
|
+
const location = paths(options, global);
|
|
82
|
+
const stateText = await readText(location.state);
|
|
83
|
+
if (!stateText) return;
|
|
84
|
+
|
|
85
|
+
const state = JSON.parse(stateText);
|
|
86
|
+
const updates = [];
|
|
87
|
+
for (const [name, path, template] of [
|
|
88
|
+
["sol", location.sol, SOL_TEMPLATE],
|
|
89
|
+
["skill", location.skill, SKILL_TEMPLATE]
|
|
90
|
+
]) {
|
|
91
|
+
const current = await readText(path);
|
|
92
|
+
const tracked = state.files?.[name];
|
|
93
|
+
if (!current || !tracked || hash(current) !== tracked.hash || current === template) continue;
|
|
94
|
+
updates.push({ name, path, before: current, after: template });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const nextState = structuredClone(state);
|
|
98
|
+
nextState.packageVersion = VERSION;
|
|
99
|
+
for (const update of updates) nextState.files[update.name].hash = hash(update.after);
|
|
100
|
+
const nextStateText = `${JSON.stringify(nextState, null, 2)}\n`;
|
|
101
|
+
if (!updates.length && state.packageVersion === VERSION) return;
|
|
102
|
+
|
|
103
|
+
const snapshots = new Map([[location.state, stateText]]);
|
|
104
|
+
for (const update of updates) snapshots.set(update.path, update.before);
|
|
105
|
+
const applied = [];
|
|
106
|
+
try {
|
|
107
|
+
for (const update of updates) {
|
|
108
|
+
await atomicWrite(update.path, update.after);
|
|
109
|
+
applied.push(update.path);
|
|
110
|
+
}
|
|
111
|
+
await atomicWrite(location.state, nextStateText);
|
|
112
|
+
applied.push(location.state);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
for (const path of applied.reverse()) {
|
|
115
|
+
try { await atomicWrite(path, snapshots.get(path)); } catch {}
|
|
116
|
+
}
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseGlobal(argv) {
|
|
122
|
+
return argv.includes("--global");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function activeValue(content, key) {
|
|
126
|
+
const match = content.match(new RegExp(`^\\s*${key}\\s*=\\s*"([^"]+)"\\s*$`, "m"));
|
|
127
|
+
return match?.[1];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function validateV11(options, global) {
|
|
131
|
+
const location = paths(options, global);
|
|
132
|
+
const stateText = await readText(location.state);
|
|
133
|
+
if (!stateText) return null;
|
|
134
|
+
|
|
135
|
+
let state;
|
|
136
|
+
try { state = JSON.parse(stateText); }
|
|
137
|
+
catch { return { sol: { status: "unsafe-state", detail: "state file is not valid JSON" }, skill: null }; }
|
|
138
|
+
|
|
139
|
+
const result = {};
|
|
140
|
+
const sol = await readText(location.sol);
|
|
141
|
+
if (!sol) result.sol = { status: "missing" };
|
|
142
|
+
else if (state.files?.sol?.hash !== hash(sol)) result.sol = { status: "user-modified", detail: "managed file hash changed" };
|
|
143
|
+
else if (
|
|
144
|
+
activeValue(sol, "name") !== "sol" ||
|
|
145
|
+
activeValue(sol, "model") !== "gpt-5.6-sol" ||
|
|
146
|
+
activeValue(sol, "model_reasoning_effort") !== "medium" ||
|
|
147
|
+
activeValue(sol, "sandbox_mode") !== "workspace-write"
|
|
148
|
+
) result.sol = { status: "invalid", detail: "expected Sol/medium/workspace-write" };
|
|
149
|
+
else result.sol = { status: "healthy" };
|
|
150
|
+
|
|
151
|
+
const skill = await readText(location.skill);
|
|
152
|
+
if (!skill) result.skill = { status: "missing" };
|
|
153
|
+
else if (state.files?.skill?.hash !== hash(skill)) result.skill = { status: "user-modified", detail: "managed file hash changed" };
|
|
154
|
+
else {
|
|
155
|
+
const required = [/^name:\s*model-router\s*$/m, /Terra/i, /Luna/i, /Sol/i, /workspace-write/i, /simple question/i, /minimum number|fewest/i];
|
|
156
|
+
result.skill = required.every((pattern) => pattern.test(skill))
|
|
157
|
+
? { status: "healthy" }
|
|
158
|
+
: { status: "invalid", detail: "missing required routing rule" };
|
|
159
|
+
}
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function format(status, label, detail) {
|
|
164
|
+
return `${status}: ${label}${detail ? ` (${detail})` : ""}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function runDoctor(argv, options, output) {
|
|
168
|
+
const lines = [];
|
|
169
|
+
await runCore(argv, { ...options, output: (line) => lines.push(String(line)) });
|
|
170
|
+
if (lines.some((line) => line.startsWith("unsafe-state: state") || line.startsWith("missing: state"))) {
|
|
171
|
+
for (const line of lines) output(line);
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const v11 = await validateV11(options, parseGlobal(argv));
|
|
176
|
+
const filtered = lines.filter((line) => !/^(?:healthy|invalid|user-modified|missing): (?:sol|skill)\b/.test(line));
|
|
177
|
+
for (const line of filtered) output(line);
|
|
178
|
+
if (v11?.sol) output(format(v11.sol.status, "sol", v11.sol.detail));
|
|
179
|
+
if (v11?.skill) output(format(v11.skill.status, "skill", v11.skill.detail));
|
|
180
|
+
|
|
181
|
+
const statuses = [
|
|
182
|
+
...filtered.map((line) => line.split(":", 1)[0]),
|
|
183
|
+
v11?.sol?.status,
|
|
184
|
+
v11?.skill?.status
|
|
185
|
+
].filter(Boolean);
|
|
186
|
+
return statuses.every((status) => status === "healthy") ? 0 : 1;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function run(argv, options = {}) {
|
|
190
|
+
const output = options.output ?? console.log;
|
|
191
|
+
if (argv.length === 1 && ["--version", "-v"].includes(argv[0])) {
|
|
192
|
+
output(VERSION);
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
if (argv[0] === "doctor") return runDoctor(argv, options, output);
|
|
196
|
+
|
|
197
|
+
const code = await runCore(argv, {
|
|
198
|
+
...options,
|
|
199
|
+
output: (line) => output(String(line).replace("codex-model-router 1.0.0", `codex-model-router ${VERSION}`))
|
|
200
|
+
});
|
|
201
|
+
if (code !== 0 || argv[0] !== "install" || argv.includes("--dry-run")) return code;
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
await upgradeManagedTemplates(options, parseGlobal(argv));
|
|
205
|
+
return 0;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
output(`fail: Sol upgrade (${error.message})`);
|
|
208
|
+
return 1;
|
|
209
|
+
}
|
|
210
|
+
}
|
package/lib/toml.js
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
const TARGET_KEYS = new Set(["model", "model_reasoning_effort"]);
|
|
2
|
+
|
|
3
|
+
function syntax(message, line) {
|
|
4
|
+
const error = new Error(`config.toml line ${line}: ${message}`);
|
|
5
|
+
error.code = "INVALID_TOML";
|
|
6
|
+
return error;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function isNewline(text, index) {
|
|
10
|
+
return text[index] === "\n" || text[index] === "\r";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function consumeNewline(text, index) {
|
|
14
|
+
if (text[index] === "\r" && text[index + 1] === "\n") return index + 2;
|
|
15
|
+
return index + 1;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function countNewline(text, index) {
|
|
19
|
+
return text[index] === "\r" && text[index + 1] === "\n" ? 2 : 1;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function skipComment(text, index) {
|
|
23
|
+
while (index < text.length && !isNewline(text, index)) index += 1;
|
|
24
|
+
return index;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function decodeBasicString(raw) {
|
|
28
|
+
let output = "";
|
|
29
|
+
for (let index = 1; index < raw.length - 1; index += 1) {
|
|
30
|
+
const character = raw[index];
|
|
31
|
+
if (character !== "\\") {
|
|
32
|
+
output += character;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
index += 1;
|
|
36
|
+
const escaped = raw[index];
|
|
37
|
+
const simple = { b: "\b", t: "\t", n: "\n", f: "\f", r: "\r", '"': '"', "\\": "\\" };
|
|
38
|
+
if (Object.hasOwn(simple, escaped)) {
|
|
39
|
+
output += simple[escaped];
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (escaped === "u" || escaped === "U") {
|
|
43
|
+
const size = escaped === "u" ? 4 : 8;
|
|
44
|
+
const digits = raw.slice(index + 1, index + 1 + size);
|
|
45
|
+
if (!new RegExp(`^[0-9A-Fa-f]{${size}}$`).test(digits)) return null;
|
|
46
|
+
const codePoint = Number.parseInt(digits, 16);
|
|
47
|
+
try { output += String.fromCodePoint(codePoint); } catch { return null; }
|
|
48
|
+
index += size;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return output;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function parseStringValue(raw) {
|
|
57
|
+
const value = raw.trim();
|
|
58
|
+
if (value.startsWith('"""') && value.endsWith('"""') && value.length >= 6) {
|
|
59
|
+
return { kind: "string", value: null, style: "multiline-basic" };
|
|
60
|
+
}
|
|
61
|
+
if (value.startsWith("'''") && value.endsWith("'''") && value.length >= 6) {
|
|
62
|
+
return { kind: "string", value: null, style: "multiline-literal" };
|
|
63
|
+
}
|
|
64
|
+
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
|
65
|
+
const decoded = decodeBasicString(value);
|
|
66
|
+
return decoded === null ? { kind: "invalid-string", value: null } : { kind: "string", value: decoded, style: "basic" };
|
|
67
|
+
}
|
|
68
|
+
if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
|
|
69
|
+
return { kind: "string", value: value.slice(1, -1), style: "literal" };
|
|
70
|
+
}
|
|
71
|
+
return { kind: "other", value: null };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseQuotedKey(raw, quote) {
|
|
75
|
+
if (!raw.endsWith(quote) || raw.length < 2) return null;
|
|
76
|
+
if (quote === "'") return raw.slice(1, -1);
|
|
77
|
+
return decodeBasicString(raw);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseKey(raw, line) {
|
|
81
|
+
const segments = [];
|
|
82
|
+
let index = 0;
|
|
83
|
+
while (index < raw.length) {
|
|
84
|
+
while (index < raw.length && /[ \t]/.test(raw[index])) index += 1;
|
|
85
|
+
if (index >= raw.length) throw syntax("empty key", line);
|
|
86
|
+
let segment;
|
|
87
|
+
if (raw[index] === '"' || raw[index] === "'") {
|
|
88
|
+
const quote = raw[index];
|
|
89
|
+
const start = index;
|
|
90
|
+
index += 1;
|
|
91
|
+
let escaped = false;
|
|
92
|
+
while (index < raw.length) {
|
|
93
|
+
const character = raw[index];
|
|
94
|
+
if (quote === '"' && character === "\\" && !escaped) {
|
|
95
|
+
escaped = true;
|
|
96
|
+
index += 1;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (character === quote && !escaped) {
|
|
100
|
+
index += 1;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
escaped = false;
|
|
104
|
+
index += 1;
|
|
105
|
+
}
|
|
106
|
+
const token = raw.slice(start, index);
|
|
107
|
+
segment = parseQuotedKey(token, quote);
|
|
108
|
+
if (segment === null) throw syntax("invalid quoted key", line);
|
|
109
|
+
} else {
|
|
110
|
+
const start = index;
|
|
111
|
+
while (index < raw.length && /[A-Za-z0-9_-]/.test(raw[index])) index += 1;
|
|
112
|
+
if (start === index) throw syntax("invalid bare key", line);
|
|
113
|
+
segment = raw.slice(start, index);
|
|
114
|
+
}
|
|
115
|
+
segments.push(segment);
|
|
116
|
+
while (index < raw.length && /[ \t]/.test(raw[index])) index += 1;
|
|
117
|
+
if (index >= raw.length) break;
|
|
118
|
+
if (raw[index] !== ".") throw syntax("invalid dotted key", line);
|
|
119
|
+
index += 1;
|
|
120
|
+
}
|
|
121
|
+
return segments;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function consumeQuoted(text, index, line, quote, multiline) {
|
|
125
|
+
const delimiter = multiline ? quote.repeat(3) : quote;
|
|
126
|
+
index += delimiter.length;
|
|
127
|
+
while (index < text.length) {
|
|
128
|
+
if (multiline && text.startsWith(delimiter, index)) return index + delimiter.length;
|
|
129
|
+
const character = text[index];
|
|
130
|
+
if (!multiline && character === quote) return index + 1;
|
|
131
|
+
if (!multiline && isNewline(text, index)) throw syntax("newline in single-line string", line);
|
|
132
|
+
if (quote === '"' && character === "\\") {
|
|
133
|
+
const escaped = text[index + 1];
|
|
134
|
+
if (multiline && isNewline(text, index + 1)) {
|
|
135
|
+
index = consumeNewline(text, index + 1);
|
|
136
|
+
line += 1;
|
|
137
|
+
while (index < text.length && (/[ \t]/.test(text[index]) || isNewline(text, index))) {
|
|
138
|
+
if (isNewline(text, index)) { index = consumeNewline(text, index); line += 1; }
|
|
139
|
+
else index += 1;
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (["b", "t", "n", "f", "r", '"', "\\"].includes(escaped)) {
|
|
144
|
+
index += 2;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (escaped === "u" || escaped === "U") {
|
|
148
|
+
const size = escaped === "u" ? 4 : 8;
|
|
149
|
+
const digits = text.slice(index + 2, index + 2 + size);
|
|
150
|
+
if (!new RegExp(`^[0-9A-Fa-f]{${size}}$`).test(digits)) throw syntax("invalid Unicode escape", line);
|
|
151
|
+
const codePoint = Number.parseInt(digits, 16);
|
|
152
|
+
if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) throw syntax("invalid Unicode scalar", line);
|
|
153
|
+
index += 2 + size;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
throw syntax("invalid basic-string escape", line);
|
|
157
|
+
}
|
|
158
|
+
if (isNewline(text, index)) {
|
|
159
|
+
index = consumeNewline(text, index);
|
|
160
|
+
line += 1;
|
|
161
|
+
} else {
|
|
162
|
+
const code = text.charCodeAt(index);
|
|
163
|
+
if (code < 0x20 && character !== "\t") throw syntax("control character in string", line);
|
|
164
|
+
index += 1;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
throw syntax("unterminated string", line);
|
|
168
|
+
}
|
|
169
|
+
function scanValue(text, start, line) {
|
|
170
|
+
let index = start;
|
|
171
|
+
let square = 0;
|
|
172
|
+
let curly = 0;
|
|
173
|
+
let lastSignificant = start - 1;
|
|
174
|
+
let commentStart = null;
|
|
175
|
+
let statementLineEnd = null;
|
|
176
|
+
|
|
177
|
+
while (index < text.length) {
|
|
178
|
+
const character = text[index];
|
|
179
|
+
if (character === "#") {
|
|
180
|
+
if (square === 0 && curly === 0 && commentStart === null) commentStart = index;
|
|
181
|
+
index = skipComment(text, index);
|
|
182
|
+
if (square === 0 && curly === 0) {
|
|
183
|
+
statementLineEnd = index;
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (isNewline(text, index)) {
|
|
189
|
+
if (square === 0 && curly === 0) {
|
|
190
|
+
statementLineEnd = index;
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
index = consumeNewline(text, index);
|
|
194
|
+
line += 1;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (character === '"' || character === "'") {
|
|
198
|
+
const multiline = text.startsWith(character.repeat(3), index);
|
|
199
|
+
index = consumeQuoted(text, index, line, character, multiline);
|
|
200
|
+
lastSignificant = index - 1;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (character === "[") square += 1;
|
|
204
|
+
else if (character === "]") {
|
|
205
|
+
square -= 1;
|
|
206
|
+
if (square < 0) throw syntax("unexpected ]", line);
|
|
207
|
+
} else if (character === "{") curly += 1;
|
|
208
|
+
else if (character === "}") {
|
|
209
|
+
curly -= 1;
|
|
210
|
+
if (curly < 0) throw syntax("unexpected }", line);
|
|
211
|
+
}
|
|
212
|
+
if (!/[ \t]/.test(character)) lastSignificant = index;
|
|
213
|
+
index += 1;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (square !== 0 || curly !== 0) throw syntax("unterminated array or inline table", line);
|
|
217
|
+
if (lastSignificant < start) throw syntax("missing value", line);
|
|
218
|
+
const lineEnd = statementLineEnd ?? index;
|
|
219
|
+
const statementEnd = lineEnd < text.length ? consumeNewline(text, lineEnd) : lineEnd;
|
|
220
|
+
return {
|
|
221
|
+
valueEnd: lastSignificant + 1,
|
|
222
|
+
commentStart,
|
|
223
|
+
lineEnd,
|
|
224
|
+
statementEnd,
|
|
225
|
+
next: statementEnd,
|
|
226
|
+
newlines: lineEnd < text.length ? 1 : 0
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function scanHeader(text, start, line) {
|
|
231
|
+
const arrayTable = text.startsWith("[[", start);
|
|
232
|
+
const openingLength = arrayTable ? 2 : 1;
|
|
233
|
+
const closing = arrayTable ? "]]" : "]";
|
|
234
|
+
let index = start + openingLength;
|
|
235
|
+
let quote = null;
|
|
236
|
+
let escaped = false;
|
|
237
|
+
while (index < text.length) {
|
|
238
|
+
const character = text[index];
|
|
239
|
+
if (quote) {
|
|
240
|
+
if (quote === '"' && character === "\\" && !escaped) {
|
|
241
|
+
escaped = true;
|
|
242
|
+
index += 1;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (character === quote && !escaped) quote = null;
|
|
246
|
+
escaped = false;
|
|
247
|
+
if (isNewline(text, index)) throw syntax("newline in table header", line);
|
|
248
|
+
index += 1;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (character === '"' || character === "'") {
|
|
252
|
+
quote = character;
|
|
253
|
+
index += 1;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (text.startsWith(closing, index)) break;
|
|
257
|
+
if (isNewline(text, index)) throw syntax("unterminated table header", line);
|
|
258
|
+
index += 1;
|
|
259
|
+
}
|
|
260
|
+
if (index >= text.length) throw syntax("unterminated table header", line);
|
|
261
|
+
const keyRaw = text.slice(start + openingLength, index).trim();
|
|
262
|
+
if (!keyRaw) throw syntax("empty table header", line);
|
|
263
|
+
parseKey(keyRaw, line);
|
|
264
|
+
index += closing.length;
|
|
265
|
+
while (index < text.length && /[ \t]/.test(text[index])) index += 1;
|
|
266
|
+
if (text[index] === "#") index = skipComment(text, index);
|
|
267
|
+
if (index < text.length && !isNewline(text, index)) throw syntax("unexpected content after table header", line);
|
|
268
|
+
const next = index < text.length ? consumeNewline(text, index) : index;
|
|
269
|
+
return { next, lineStart: start };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function validateValueShape(raw, line) {
|
|
273
|
+
const value = raw.trim();
|
|
274
|
+
if (!value) throw syntax("missing value", line);
|
|
275
|
+
if (value.startsWith("[") || value.startsWith("{")) return;
|
|
276
|
+
if (value.startsWith('"') || value.startsWith("'")) {
|
|
277
|
+
if (parseStringValue(value).kind === "invalid-string") throw syntax("invalid string value", line);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const booleanOrSpecial = /^(?:true|false|[+-]?(?:inf|nan))$/;
|
|
281
|
+
const integerOrFloat = /^[+-]?(?:0x[0-9A-Fa-f](?:_?[0-9A-Fa-f])*|0o[0-7](?:_?[0-7])*|0b[01](?:_?[01])*|(?:0|[1-9](?:_?\d)*)(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?)$/;
|
|
282
|
+
const dateOrTime = /^(?:\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})?)?|\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/;
|
|
283
|
+
if (!booleanOrSpecial.test(value) && !integerOrFloat.test(value) && !dateOrTime.test(value)) {
|
|
284
|
+
throw syntax("unsupported or invalid bare value", line);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function scanToml(text) {
|
|
289
|
+
if (text.includes("\0")) throw syntax("NUL byte is not valid TOML", 1);
|
|
290
|
+
const bomLength = text.charCodeAt(0) === 0xfeff ? 1 : 0;
|
|
291
|
+
const newline = text.includes("\r\n") ? "\r\n" : "\n";
|
|
292
|
+
const assignments = [];
|
|
293
|
+
const targets = new Map();
|
|
294
|
+
let firstTableStart = null;
|
|
295
|
+
let currentTable = [];
|
|
296
|
+
let index = bomLength;
|
|
297
|
+
let line = 1;
|
|
298
|
+
|
|
299
|
+
while (index < text.length) {
|
|
300
|
+
const physicalLineStart = index;
|
|
301
|
+
while (index < text.length && /[ \t]/.test(text[index])) index += 1;
|
|
302
|
+
if (index >= text.length) break;
|
|
303
|
+
if (isNewline(text, index)) {
|
|
304
|
+
index = consumeNewline(text, index);
|
|
305
|
+
line += 1;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (text[index] === "#") {
|
|
309
|
+
index = skipComment(text, index);
|
|
310
|
+
if (index < text.length) {
|
|
311
|
+
index = consumeNewline(text, index);
|
|
312
|
+
line += 1;
|
|
313
|
+
}
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (text[index] === "[") {
|
|
317
|
+
if (firstTableStart === null) firstTableStart = physicalLineStart;
|
|
318
|
+
const header = scanHeader(text, index, line);
|
|
319
|
+
currentTable = ["<table>"];
|
|
320
|
+
if (header.next > index && header.next <= text.length && header.next !== text.length) line += 1;
|
|
321
|
+
index = header.next;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const statementLine = line;
|
|
326
|
+
const keyStart = index;
|
|
327
|
+
let quote = null;
|
|
328
|
+
let escaped = false;
|
|
329
|
+
while (index < text.length) {
|
|
330
|
+
const character = text[index];
|
|
331
|
+
if (quote) {
|
|
332
|
+
if (quote === '"' && character === "\\" && !escaped) {
|
|
333
|
+
escaped = true;
|
|
334
|
+
index += 1;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (character === quote && !escaped) quote = null;
|
|
338
|
+
escaped = false;
|
|
339
|
+
if (isNewline(text, index)) throw syntax("newline before =", line);
|
|
340
|
+
index += 1;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (character === '"' || character === "'") {
|
|
344
|
+
quote = character;
|
|
345
|
+
index += 1;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (character === "=") break;
|
|
349
|
+
if (character === "#" || isNewline(text, index)) throw syntax("missing =", line);
|
|
350
|
+
index += 1;
|
|
351
|
+
}
|
|
352
|
+
if (index >= text.length) throw syntax("missing =", line);
|
|
353
|
+
const keyRaw = text.slice(keyStart, index).trim();
|
|
354
|
+
const keySegments = parseKey(keyRaw, line);
|
|
355
|
+
index += 1;
|
|
356
|
+
while (index < text.length && /[ \t]/.test(text[index])) index += 1;
|
|
357
|
+
const valueStart = index;
|
|
358
|
+
const value = scanValue(text, valueStart, line);
|
|
359
|
+
const rawValue = text.slice(valueStart, value.valueEnd);
|
|
360
|
+
validateValueShape(rawValue, statementLine);
|
|
361
|
+
const assignment = {
|
|
362
|
+
keySegments,
|
|
363
|
+
table: currentTable,
|
|
364
|
+
line: statementLine,
|
|
365
|
+
lineStart: physicalLineStart,
|
|
366
|
+
valueStart,
|
|
367
|
+
valueEnd: value.valueEnd,
|
|
368
|
+
rawValue,
|
|
369
|
+
parsedValue: parseStringValue(rawValue),
|
|
370
|
+
commentStart: value.commentStart,
|
|
371
|
+
lineEnd: value.lineEnd,
|
|
372
|
+
statementEnd: value.statementEnd
|
|
373
|
+
};
|
|
374
|
+
assignments.push(assignment);
|
|
375
|
+
if (currentTable.length === 0 && keySegments.length === 1 && TARGET_KEYS.has(keySegments[0])) {
|
|
376
|
+
if (targets.has(keySegments[0])) throw syntax(`duplicate top-level ${keySegments[0]}`, statementLine);
|
|
377
|
+
targets.set(keySegments[0], assignment);
|
|
378
|
+
}
|
|
379
|
+
line += (text.slice(valueStart, value.next).match(/\r\n|\r|\n/g) || []).length;
|
|
380
|
+
index = value.next;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
assignments,
|
|
385
|
+
targets,
|
|
386
|
+
bomLength,
|
|
387
|
+
newline,
|
|
388
|
+
firstTableStart: firstTableStart ?? text.length
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function applyEdits(text, edits) {
|
|
393
|
+
const sorted = [...edits].sort((a, b) => b.start - a.start || b.end - a.end);
|
|
394
|
+
let lastStart = text.length + 1;
|
|
395
|
+
let output = text;
|
|
396
|
+
for (const edit of sorted) {
|
|
397
|
+
if (edit.start < 0 || edit.end < edit.start || edit.end > text.length) throw new Error("invalid text edit");
|
|
398
|
+
if (edit.end > lastStart) throw new Error("overlapping text edits");
|
|
399
|
+
output = output.slice(0, edit.start) + edit.text + output.slice(edit.end);
|
|
400
|
+
lastStart = edit.start;
|
|
401
|
+
}
|
|
402
|
+
return output;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function insertionEdit(text, scan, additions) {
|
|
406
|
+
if (!additions.length) return null;
|
|
407
|
+
const at = scan.firstTableStart;
|
|
408
|
+
const before = text.slice(0, at);
|
|
409
|
+
const newline = scan.newline;
|
|
410
|
+
const needsLeadingNewline = before.length > scan.bomLength && !before.endsWith("\n") && !before.endsWith("\r");
|
|
411
|
+
const block = `${needsLeadingNewline ? newline : ""}${additions.join(newline)}${newline}`;
|
|
412
|
+
return { start: at, end: at, text: block };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function removalEdit(assignment) {
|
|
416
|
+
if (assignment.commentStart !== null) {
|
|
417
|
+
return { start: assignment.lineStart, end: assignment.commentStart, text: "" };
|
|
418
|
+
}
|
|
419
|
+
return { start: assignment.lineStart, end: assignment.statementEnd, text: "" };
|
|
420
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "codex-model-router",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Safely install a small Codex model-routing configuration.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"codex-model-router": "./bin/codex-model-router.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"check": "node --check bin/codex-model-router.js && node --check lib/router.js && node --check lib/router-core.js && node --check lib/toml.js",
|
|
11
|
+
"test": "node --test",
|
|
12
|
+
"test:package": "node scripts/package-smoke.js"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin",
|
|
19
|
+
"lib",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"keywords": [
|
|
24
|
+
"codex",
|
|
25
|
+
"openai",
|
|
26
|
+
"subagents",
|
|
27
|
+
"skills"
|
|
28
|
+
],
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/Honguan/codex-model-router.git"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/Honguan/codex-model-router#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/Honguan/codex-model-router/issues"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"license": "MIT"
|
|
41
|
+
}
|