@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 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
 
@@ -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": "1.0.0",
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": ">=20"
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. 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.
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.