@ast-ai-model-router/cli 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -0
- package/bin/ast-ai-model-router.js +36 -0
- package/lib/adapters.js +51 -0
- package/lib/gateway.js +143 -0
- package/package.json +2 -2
- package/skills/model-router/SKILL.md +18 -1
package/README.md
CHANGED
|
@@ -69,6 +69,18 @@ Launch Claude Code with the selected alias:
|
|
|
69
69
|
ast-ai-model-router run claude --task "plan a cross-module database migration" -- --permission-mode plan
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
+
Route each prompt through a gateway session:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
ast-ai-model-router gateway codex -- --sandbox workspace-write
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Preview one gateway turn without launching an agent:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
ast-ai-model-router gateway claude --once --task "write docs for this repo" --dry-run -- --permission-mode plan
|
|
82
|
+
```
|
|
83
|
+
|
|
72
84
|
## CI And Team Policy
|
|
73
85
|
|
|
74
86
|
Fail if a task would exceed the allowed tier:
|
|
@@ -97,6 +109,30 @@ ast-ai-model-router analyze --agent codex --task "write tests" --json
|
|
|
97
109
|
|
|
98
110
|
The JSON includes `selectedModel`, `tier`, `confidence`, `signals`, `rationale`, `warnings`, `costEstimate`, `policy`, and `commandPreview`.
|
|
99
111
|
|
|
112
|
+
## Per-Turn Gateway
|
|
113
|
+
|
|
114
|
+
`run` chooses one model before launching a Claude Code or Codex session. `gateway` is different: it keeps a small router prompt open, scores every message you type, then invokes the selected agent model for that turn.
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
ast-ai-model-router gateway claude -- --permission-mode plan
|
|
118
|
+
ast-ai-model-router gateway codex -- --sandbox workspace-write
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Inside the gateway, type a prompt and press Enter. Use `/exit` or `/quit` to stop.
|
|
122
|
+
|
|
123
|
+
For single-turn automation or CI smoke tests:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
ast-ai-model-router gateway codex --once --task "add regression tests for parser errors" --dry-run
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The gateway uses non-interactive agent execution:
|
|
130
|
+
|
|
131
|
+
- Claude Code: `claude --print --model <selected-model> ... <prompt>`
|
|
132
|
+
- Codex: `codex exec --model <selected-model> ... <prompt>`
|
|
133
|
+
|
|
134
|
+
This is not an invisible hook inside an already-running Claude Code or Codex TUI. To route every turn, enter prompts through `ast-ai-model-router gateway ...`.
|
|
135
|
+
|
|
100
136
|
## How Routing Works
|
|
101
137
|
|
|
102
138
|
The router scores four groups of signals:
|
|
@@ -5,6 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
6
|
import { CONFIG_TEMPLATE, loadConfig, validateTier } from "../lib/config.js";
|
|
7
7
|
import { createDecision, maybeLogDecision } from "../lib/decision.js";
|
|
8
|
+
import { runGateway } from "../lib/gateway.js";
|
|
8
9
|
import { formatUsd } from "../lib/policy.js";
|
|
9
10
|
|
|
10
11
|
const HELP = `ast-ai-model-router
|
|
@@ -15,6 +16,7 @@ Usage:
|
|
|
15
16
|
ast-ai-model-router ci --agent claude|codex --task "deploy change" [--max-tier complex]
|
|
16
17
|
ast-ai-model-router run claude --task "refactor parser" -- [extra claude args]
|
|
17
18
|
ast-ai-model-router run codex --task "write tests" -- [extra codex args]
|
|
19
|
+
ast-ai-model-router gateway claude|codex [--once --task "write docs"] -- [extra agent args]
|
|
18
20
|
ast-ai-model-router init [--cwd <path>] [--force]
|
|
19
21
|
|
|
20
22
|
Options:
|
|
@@ -26,6 +28,7 @@ Options:
|
|
|
26
28
|
--max-cost-usd <n> Policy ceiling when cost estimate is available
|
|
27
29
|
--log Append a local JSONL decision record
|
|
28
30
|
--dry-run For run: print the command instead of launching
|
|
31
|
+
--once For gateway: route one --task prompt and exit
|
|
29
32
|
--refresh-models Refresh Codex model catalog cache
|
|
30
33
|
`;
|
|
31
34
|
|
|
@@ -137,6 +140,39 @@ async function main() {
|
|
|
137
140
|
return;
|
|
138
141
|
}
|
|
139
142
|
|
|
143
|
+
if (command === "gateway" || command === "intercept") {
|
|
144
|
+
const agent = assertAgent(maybeAgent);
|
|
145
|
+
const split = rest.indexOf("--");
|
|
146
|
+
const optionArgs = split === -1 ? rest : rest.slice(0, split);
|
|
147
|
+
const passthrough = split === -1 ? [] : rest.slice(split + 1);
|
|
148
|
+
const { values } = parseArgs({
|
|
149
|
+
args: optionArgs,
|
|
150
|
+
options: {
|
|
151
|
+
task: { type: "string" },
|
|
152
|
+
cwd: { type: "string" },
|
|
153
|
+
log: { type: "boolean" },
|
|
154
|
+
once: { type: "boolean" },
|
|
155
|
+
"max-tier": { type: "string" },
|
|
156
|
+
"max-cost-usd": { type: "string" },
|
|
157
|
+
"dry-run": { type: "boolean" },
|
|
158
|
+
"refresh-models": { type: "boolean" }
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
if (values.once && !values.task) throw new Error("--task is required with --once.");
|
|
162
|
+
const exitCode = await runGateway({
|
|
163
|
+
agent,
|
|
164
|
+
cwd: values.cwd ?? process.cwd(),
|
|
165
|
+
log: Boolean(values.log),
|
|
166
|
+
dryRun: Boolean(values["dry-run"]),
|
|
167
|
+
refreshModels: Boolean(values["refresh-models"]),
|
|
168
|
+
maxTier: values["max-tier"] ? validateTier(values["max-tier"], "--max-tier") : undefined,
|
|
169
|
+
maxCostUsd: parseOptionalNumber(values["max-cost-usd"], "--max-cost-usd"),
|
|
170
|
+
passthrough,
|
|
171
|
+
onceTask: values.once ? values.task : undefined
|
|
172
|
+
});
|
|
173
|
+
process.exit(exitCode);
|
|
174
|
+
}
|
|
175
|
+
|
|
140
176
|
throw new Error(`Unknown command: ${command}`);
|
|
141
177
|
}
|
|
142
178
|
|
package/lib/adapters.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export function buildAgentCommand({ agent, model, prompt, passthrough = [] }) {
|
|
4
|
+
if (agent === "claude") {
|
|
5
|
+
return {
|
|
6
|
+
command: "claude",
|
|
7
|
+
args: ["--print", "--model", model, ...passthrough, prompt]
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
if (agent === "codex") {
|
|
11
|
+
return {
|
|
12
|
+
command: "codex",
|
|
13
|
+
args: ["exec", "--model", model, ...passthrough, prompt]
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
throw new Error("Expected agent to be 'claude' or 'codex'.");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatAgentCommand(request) {
|
|
20
|
+
const { command, args } = buildAgentCommand(request);
|
|
21
|
+
return `${command} ${args.map(shellQuote).join(" ")}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function executeAgent({ agent, model, prompt, cwd, passthrough = [] }) {
|
|
25
|
+
const { command, args } = buildAgentCommand({ agent, model, prompt, passthrough });
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
28
|
+
let stdout = "";
|
|
29
|
+
let stderr = "";
|
|
30
|
+
|
|
31
|
+
child.stdout?.setEncoding("utf8");
|
|
32
|
+
child.stderr?.setEncoding("utf8");
|
|
33
|
+
child.stdout?.on("data", (chunk) => {
|
|
34
|
+
stdout += chunk;
|
|
35
|
+
});
|
|
36
|
+
child.stderr?.on("data", (chunk) => {
|
|
37
|
+
stderr += chunk;
|
|
38
|
+
});
|
|
39
|
+
child.on("error", (error) => {
|
|
40
|
+
resolve({ exitCode: 1, stdout, stderr, error });
|
|
41
|
+
});
|
|
42
|
+
child.on("exit", (code, signal) => {
|
|
43
|
+
resolve({ exitCode: code ?? 1, signal, stdout, stderr });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function shellQuote(value) {
|
|
49
|
+
if (/^[A-Za-z0-9_./:=@-]+$/.test(value)) return value;
|
|
50
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
51
|
+
}
|
package/lib/gateway.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
import { loadConfig } from "./config.js";
|
|
4
|
+
import { createDecision, maybeLogDecision } from "./decision.js";
|
|
5
|
+
import { executeAgent as defaultExecuteAgent, formatAgentCommand } from "./adapters.js";
|
|
6
|
+
|
|
7
|
+
export async function runGatewayTurn({
|
|
8
|
+
agent,
|
|
9
|
+
prompt,
|
|
10
|
+
cwd,
|
|
11
|
+
config,
|
|
12
|
+
refreshModels = false,
|
|
13
|
+
maxTier,
|
|
14
|
+
maxCostUsd,
|
|
15
|
+
log = false,
|
|
16
|
+
dryRun = false,
|
|
17
|
+
passthrough = [],
|
|
18
|
+
executeAgent = defaultExecuteAgent
|
|
19
|
+
}) {
|
|
20
|
+
const decision = await createDecision({
|
|
21
|
+
agent,
|
|
22
|
+
task: prompt,
|
|
23
|
+
cwd,
|
|
24
|
+
config,
|
|
25
|
+
refreshModels,
|
|
26
|
+
maxTier,
|
|
27
|
+
maxCostUsd
|
|
28
|
+
});
|
|
29
|
+
await maybeLogDecision(decision, config, log);
|
|
30
|
+
if (!decision.policy.passed) {
|
|
31
|
+
return {
|
|
32
|
+
decision,
|
|
33
|
+
output: "",
|
|
34
|
+
exitCode: 3,
|
|
35
|
+
error: new Error(`Policy failed: ${decision.policy.failures.join("; ")}`)
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (dryRun) {
|
|
39
|
+
const commandPreview = formatAgentCommand({
|
|
40
|
+
agent,
|
|
41
|
+
model: decision.selectedModel,
|
|
42
|
+
prompt,
|
|
43
|
+
passthrough
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
decision,
|
|
47
|
+
output: `${commandPreview}\n`,
|
|
48
|
+
exitCode: 0,
|
|
49
|
+
dryRun: true
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const result = await executeAgent({
|
|
54
|
+
agent,
|
|
55
|
+
model: decision.selectedModel,
|
|
56
|
+
prompt,
|
|
57
|
+
cwd: decision.cwd,
|
|
58
|
+
passthrough
|
|
59
|
+
});
|
|
60
|
+
return {
|
|
61
|
+
decision,
|
|
62
|
+
output: result.stdout ?? "",
|
|
63
|
+
stderr: result.stderr ?? "",
|
|
64
|
+
exitCode: result.exitCode ?? 1,
|
|
65
|
+
signal: result.signal,
|
|
66
|
+
error: result.error
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function runGateway({
|
|
71
|
+
agent,
|
|
72
|
+
cwd = process.cwd(),
|
|
73
|
+
log = false,
|
|
74
|
+
refreshModels = false,
|
|
75
|
+
maxTier,
|
|
76
|
+
maxCostUsd,
|
|
77
|
+
dryRun = false,
|
|
78
|
+
passthrough = [],
|
|
79
|
+
onceTask
|
|
80
|
+
}) {
|
|
81
|
+
const config = await loadConfig(cwd);
|
|
82
|
+
if (onceTask !== undefined) {
|
|
83
|
+
const result = await runGatewayTurn({
|
|
84
|
+
agent,
|
|
85
|
+
prompt: onceTask,
|
|
86
|
+
cwd,
|
|
87
|
+
config,
|
|
88
|
+
log,
|
|
89
|
+
dryRun,
|
|
90
|
+
refreshModels,
|
|
91
|
+
maxTier,
|
|
92
|
+
maxCostUsd,
|
|
93
|
+
passthrough
|
|
94
|
+
});
|
|
95
|
+
printGatewayTurn(result);
|
|
96
|
+
return result.exitCode;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const rl = createInterface({ input, output });
|
|
100
|
+
process.stdout.write(`[model-router] gateway for ${agent}. Type /exit or /quit to stop.\n`);
|
|
101
|
+
try {
|
|
102
|
+
for (;;) {
|
|
103
|
+
const prompt = await rl.question("> ");
|
|
104
|
+
const trimmed = prompt.trim();
|
|
105
|
+
if (!trimmed) continue;
|
|
106
|
+
if (trimmed === "/exit" || trimmed === "/quit") return 0;
|
|
107
|
+
const result = await runGatewayTurn({
|
|
108
|
+
agent,
|
|
109
|
+
prompt,
|
|
110
|
+
cwd,
|
|
111
|
+
config,
|
|
112
|
+
log,
|
|
113
|
+
dryRun,
|
|
114
|
+
refreshModels,
|
|
115
|
+
maxTier,
|
|
116
|
+
maxCostUsd,
|
|
117
|
+
passthrough
|
|
118
|
+
});
|
|
119
|
+
printGatewayTurn(result);
|
|
120
|
+
}
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error?.code === "ERR_USE_AFTER_CLOSE") return 0;
|
|
123
|
+
throw error;
|
|
124
|
+
} finally {
|
|
125
|
+
rl.close();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function printGatewayTurn(result) {
|
|
130
|
+
const { decision } = result;
|
|
131
|
+
process.stderr.write(`[model-router] ${decision.agent}: ${decision.selectedModel} (${decision.tier}, confidence ${decision.confidence.toFixed(2)})\n`);
|
|
132
|
+
if (!decision.policy.passed) {
|
|
133
|
+
process.stderr.write(`[model-router] policy failed: ${decision.policy.failures.join("; ")}\n`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
137
|
+
if (result.error) {
|
|
138
|
+
process.stderr.write(`[model-router] failed to launch ${decision.agent}: ${result.error.message}\n`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (result.output) process.stdout.write(result.output);
|
|
142
|
+
if (result.exitCode !== 0) process.stderr.write(`[model-router] ${decision.agent} exited with code ${result.exitCode}\n`);
|
|
143
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ast-ai-model-router/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "AST-based Claude Code and Codex model router with token-cost estimates, CI policy checks, and explainable AI coding-agent model selection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -66,6 +66,6 @@
|
|
|
66
66
|
"@changesets/cli": "^2.31.0"
|
|
67
67
|
},
|
|
68
68
|
"engines": {
|
|
69
|
-
"node": ">=
|
|
69
|
+
"node": ">=26"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -43,7 +43,23 @@ ast-ai-model-router run codex --task "<task>" -- <codex args>
|
|
|
43
43
|
ast-ai-model-router run claude --task "<task>" -- <claude args>
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
5.
|
|
46
|
+
5. If the user wants every new prompt routed independently, use the gateway:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
ast-ai-model-router gateway codex -- <codex args>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
ast-ai-model-router gateway claude -- <claude args>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
For a single routed turn:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
ast-ai-model-router gateway codex --once --task "<task>" -- <codex args>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
6. Explain the selected model in token-economics terms: simple tasks should use faster/cheaper models; complex migrations, security-sensitive work, and architecture planning should use stronger models.
|
|
47
63
|
|
|
48
64
|
## Notes
|
|
49
65
|
|
|
@@ -51,3 +67,4 @@ ast-ai-model-router run claude --task "<task>" -- <claude args>
|
|
|
51
67
|
- Claude model names use aliases: `haiku`, `sonnet`, `opus`, and `opusplan`.
|
|
52
68
|
- Token-cost estimates use Tokenometer when a selected model maps cleanly to a known provider model.
|
|
53
69
|
- The router analyzes JavaScript/TypeScript with Babel ASTs and Python with stdlib `ast`.
|
|
70
|
+
- Gateway mode routes prompts entered through `ast-ai-model-router gateway`; it does not invisibly intercept an already-running Claude Code or Codex TUI.
|