@ast-ai-model-router/cli 0.1.1 → 1.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/.claude-plugin/plugin.json +6 -3
- package/.codex-plugin/plugin.json +8 -5
- package/README.md +133 -26
- package/bin/ast-ai-model-router.js +126 -20
- package/lib/analyzer.js +3 -0
- package/lib/config.js +46 -0
- package/lib/cost.js +47 -0
- package/lib/decision.js +68 -0
- package/lib/policy.js +35 -0
- package/package.json +13 -4
- package/skills/model-router/SKILL.md +16 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ast-ai-model-router",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "AST-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "AST-based Claude Code and Codex model routing with token-cost estimates and CI policy checks.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Faraazuddin Mohammed",
|
|
7
7
|
"email": "opensource@faraa2m.dev",
|
|
@@ -11,11 +11,14 @@
|
|
|
11
11
|
"repository": "https://github.com/faraa2m/ast-ai-model-router",
|
|
12
12
|
"license": "MIT",
|
|
13
13
|
"keywords": [
|
|
14
|
+
"ai-coding-agent",
|
|
14
15
|
"token-economics",
|
|
16
|
+
"llm-cost-optimization",
|
|
15
17
|
"model-router",
|
|
16
18
|
"claude-code",
|
|
17
19
|
"codex",
|
|
18
|
-
"ast"
|
|
20
|
+
"ast",
|
|
21
|
+
"ci"
|
|
19
22
|
],
|
|
20
23
|
"skills": "./skills/"
|
|
21
24
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ast-ai-model-router",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "AST-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "AST-based Claude Code and Codex model routing with token-cost estimates and CI policy checks.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Faraazuddin Mohammed",
|
|
7
7
|
"email": "opensource@faraa2m.dev",
|
|
@@ -11,17 +11,20 @@
|
|
|
11
11
|
"repository": "https://github.com/faraa2m/ast-ai-model-router",
|
|
12
12
|
"license": "MIT",
|
|
13
13
|
"keywords": [
|
|
14
|
+
"ai-coding-agent",
|
|
14
15
|
"token-economics",
|
|
16
|
+
"llm-cost-optimization",
|
|
15
17
|
"model-router",
|
|
16
18
|
"claude-code",
|
|
17
19
|
"codex",
|
|
18
|
-
"ast"
|
|
20
|
+
"ast",
|
|
21
|
+
"ci"
|
|
19
22
|
],
|
|
20
23
|
"skills": "./skills/",
|
|
21
24
|
"interface": {
|
|
22
25
|
"displayName": "AST AI Model Router",
|
|
23
|
-
"shortDescription": "Pick Claude or Codex models from AST and
|
|
24
|
-
"longDescription": "AST AI Model Router analyzes JavaScript, TypeScript, and Python project structure plus the current task to choose an appropriate Claude or Codex model before launch. It is part of
|
|
26
|
+
"shortDescription": "Pick Claude Code or Codex models from AST, task, cost, and policy signals.",
|
|
27
|
+
"longDescription": "AST AI Model Router analyzes JavaScript, TypeScript, and Python project structure plus the current task to choose an appropriate Claude Code or Codex model before launch. It adds Tokenometer-backed token-cost estimates, explainable decision records, and CI policy checks for teams. It is part of the faraa2m token-economics toolkit for measuring, reducing, and routing LLM spend.",
|
|
25
28
|
"developerName": "Faraazuddin Mohammed",
|
|
26
29
|
"category": "Developer Tools",
|
|
27
30
|
"capabilities": [
|
package/README.md
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
# AST AI Model Router
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@ast-ai-model-router/cli)
|
|
4
|
+
[](https://github.com/faraa2m/ast-ai-model-router/actions/workflows/ci.yml)
|
|
5
|
+
[](https://github.com/faraa2m/ast-ai-model-router/actions/workflows/npm-smoke.yml)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://github.com/faraa2m/ast-ai-model-router/stargazers)
|
|
4
8
|
|
|
5
|
-
|
|
9
|
+
AST-based Claude Code and Codex model router for developers who want explainable AI coding-agent model selection, token-cost visibility, and CI policy checks.
|
|
6
10
|
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
-
|
|
11
|
+
`ast-ai-model-router` inspects the current task, JavaScript/TypeScript ASTs, Python ASTs, and repo shape, then recommends or launches Claude Code / Codex with the right model tier. It is deterministic, local-first, and designed for both personal coding workflows and production team guardrails.
|
|
12
|
+
|
|
13
|
+
## Why Use It
|
|
14
|
+
|
|
15
|
+
- Avoid defaulting every coding-agent task to the strongest model.
|
|
16
|
+
- Keep simple documentation and explanation tasks on cheaper/faster models.
|
|
17
|
+
- Escalate refactors, migrations, security, auth, database, and architecture work.
|
|
18
|
+
- Get a readable rationale for every model decision.
|
|
19
|
+
- Add CI checks for max model tier and estimated prompt cost.
|
|
20
|
+
- Connect local coding-agent routing to the same token-economics stack as Tokenometer and RouterLab.
|
|
12
21
|
|
|
13
22
|
## Install
|
|
14
23
|
|
|
@@ -16,39 +25,78 @@ This project is part of the [`faraa2m`](https://github.com/faraa2m) token econom
|
|
|
16
25
|
npm install -g @ast-ai-model-router/cli
|
|
17
26
|
```
|
|
18
27
|
|
|
19
|
-
|
|
28
|
+
Run without installing:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx --yes --package @ast-ai-model-router/cli ast-ai-model-router --help
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
Initialize config in a repo:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
ast-ai-model-router init
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Analyze a Claude Code task:
|
|
20
43
|
|
|
21
44
|
```bash
|
|
22
|
-
|
|
23
|
-
npm link
|
|
45
|
+
ast-ai-model-router analyze --agent claude --task "write docs for the parser"
|
|
24
46
|
```
|
|
25
47
|
|
|
26
|
-
|
|
48
|
+
Explain the decision:
|
|
27
49
|
|
|
28
|
-
|
|
50
|
+
```bash
|
|
51
|
+
ast-ai-model-router explain --agent codex --task "refactor auth middleware and add regression tests"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Preview a launch command without starting an agent:
|
|
29
55
|
|
|
30
56
|
```bash
|
|
31
|
-
ast-ai-model-router
|
|
57
|
+
ast-ai-model-router run codex --task "fix failing Python AST tests" --dry-run -- --cd .
|
|
32
58
|
```
|
|
33
59
|
|
|
34
60
|
Launch Codex with the selected model:
|
|
35
61
|
|
|
36
62
|
```bash
|
|
37
|
-
ast-ai-model-router run codex --task "fix
|
|
63
|
+
ast-ai-model-router run codex --task "fix failing Python AST tests" -- --cd .
|
|
38
64
|
```
|
|
39
65
|
|
|
40
66
|
Launch Claude Code with the selected alias:
|
|
41
67
|
|
|
42
68
|
```bash
|
|
43
|
-
ast-ai-model-router run claude --task "plan a cross-module migration" -- --permission-mode plan
|
|
69
|
+
ast-ai-model-router run claude --task "plan a cross-module database migration" -- --permission-mode plan
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## CI And Team Policy
|
|
73
|
+
|
|
74
|
+
Fail if a task would exceed the allowed tier:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
ast-ai-model-router ci \
|
|
78
|
+
--agent claude \
|
|
79
|
+
--task "plan a production database migration" \
|
|
80
|
+
--max-tier complex
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Fail if Tokenometer can estimate the task prompt above your budget:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
ast-ai-model-router ci \
|
|
87
|
+
--agent codex \
|
|
88
|
+
--task "review this large auth refactor" \
|
|
89
|
+
--max-cost-usd 0.001
|
|
44
90
|
```
|
|
45
91
|
|
|
46
|
-
Machine-readable output:
|
|
92
|
+
Machine-readable decision output:
|
|
47
93
|
|
|
48
94
|
```bash
|
|
49
|
-
ast-ai-model-router analyze --agent codex --task "write
|
|
95
|
+
ast-ai-model-router analyze --agent codex --task "write tests" --json
|
|
50
96
|
```
|
|
51
97
|
|
|
98
|
+
The JSON includes `selectedModel`, `tier`, `confidence`, `signals`, `rationale`, `warnings`, `costEstimate`, `policy`, and `commandPreview`.
|
|
99
|
+
|
|
52
100
|
## How Routing Works
|
|
53
101
|
|
|
54
102
|
The router scores four groups of signals:
|
|
@@ -56,20 +104,38 @@ The router scores four groups of signals:
|
|
|
56
104
|
- Prompt intent: docs, tests, debugging, refactors, architecture, security, migrations.
|
|
57
105
|
- Repo shape: file count, AST file count, package/build/config files.
|
|
58
106
|
- AST complexity: functions, classes, branches, imports, and language mix.
|
|
59
|
-
- Agent model catalog: Codex models are discovered through `codex debug models`; Claude uses dynamic aliases.
|
|
107
|
+
- Agent model catalog: Codex models are discovered through `codex debug models`; Claude Code uses dynamic aliases.
|
|
60
108
|
|
|
61
|
-
Claude targets are
|
|
109
|
+
Claude Code targets are aliases, not dated model names:
|
|
62
110
|
|
|
63
111
|
- `simple` -> `haiku`
|
|
64
112
|
- `balanced` -> `sonnet`
|
|
65
113
|
- `complex` -> `opus`
|
|
66
114
|
- `planning` -> `opusplan`
|
|
67
115
|
|
|
68
|
-
Codex targets are selected from the installed Codex model catalog. If discovery fails, the router falls back to
|
|
116
|
+
Codex targets are selected from the installed Codex model catalog. If discovery fails, the router falls back to configured defaults.
|
|
117
|
+
|
|
118
|
+
## Token-Cost Estimates
|
|
119
|
+
|
|
120
|
+
Cost estimates use [`@tokenometer/core`](https://github.com/faraa2m/tokenometer) when the selected model maps to a known provider model.
|
|
121
|
+
|
|
122
|
+
Examples:
|
|
123
|
+
|
|
124
|
+
- Claude alias `haiku` maps to `claude-haiku-4-5`.
|
|
125
|
+
- Claude alias `sonnet` maps to `claude-sonnet-4-6`.
|
|
126
|
+
- Codex `gpt-5.4-mini` maps to Tokenometer model `gpt-5-mini`.
|
|
127
|
+
|
|
128
|
+
If a model cannot be mapped, routing still works and the decision includes a warning:
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
Cost estimate unavailable: No Tokenometer model mapping for codex model "..."
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Cost estimates are for the task prompt text, not source-file contents. This keeps the tool privacy-preserving and fast by default.
|
|
69
135
|
|
|
70
136
|
## Configuration
|
|
71
137
|
|
|
72
|
-
|
|
138
|
+
`ast-ai-model-router init` writes `model-router.config.json`:
|
|
73
139
|
|
|
74
140
|
```json
|
|
75
141
|
{
|
|
@@ -86,25 +152,66 @@ Add `model-router.config.json` to a project root:
|
|
|
86
152
|
}
|
|
87
153
|
},
|
|
88
154
|
"codex": {
|
|
89
|
-
"discoveryCommand": "codex debug models"
|
|
155
|
+
"discoveryCommand": "codex debug models",
|
|
156
|
+
"fallbackModels": {
|
|
157
|
+
"simple": "gpt-5.4-mini",
|
|
158
|
+
"balanced": "gpt-5.4",
|
|
159
|
+
"complex": "gpt-5.5",
|
|
160
|
+
"planning": "gpt-5.5"
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
"policy": {
|
|
164
|
+
"maxTier": "planning",
|
|
165
|
+
"maxCostUsd": null
|
|
166
|
+
},
|
|
167
|
+
"logging": {
|
|
168
|
+
"enabled": false,
|
|
169
|
+
"path": ".model-router/decisions.jsonl"
|
|
90
170
|
}
|
|
91
171
|
}
|
|
92
172
|
```
|
|
93
173
|
|
|
94
|
-
|
|
174
|
+
Decision logging is disabled by default. When enabled with config or `--log`, logs store model decisions and scores, not source code.
|
|
175
|
+
|
|
176
|
+
## Exit Codes
|
|
177
|
+
|
|
178
|
+
- `0`: success
|
|
179
|
+
- `1`: runtime failure
|
|
180
|
+
- `2`: invalid input or config
|
|
181
|
+
- `3`: policy failure
|
|
182
|
+
|
|
183
|
+
## Plugin Support
|
|
184
|
+
|
|
185
|
+
This repo includes:
|
|
95
186
|
|
|
96
|
-
|
|
187
|
+
- `.codex-plugin/plugin.json`
|
|
188
|
+
- `.claude-plugin/plugin.json`
|
|
189
|
+
- `skills/model-router/SKILL.md`
|
|
97
190
|
|
|
98
|
-
Use
|
|
191
|
+
Use the plugin locally:
|
|
99
192
|
|
|
100
193
|
```bash
|
|
101
194
|
claude --plugin-dir .
|
|
102
195
|
codex plugin marketplace add .
|
|
103
196
|
```
|
|
104
197
|
|
|
198
|
+
## Token Economics Stack
|
|
199
|
+
|
|
200
|
+
This project is part of the [`faraa2m`](https://github.com/faraa2m) token-economics ecosystem:
|
|
201
|
+
|
|
202
|
+
- [`tokenometer`](https://github.com/faraa2m/tokenometer): token counts, USD cost, latency benchmarks, and CI prompt-cost guardrails.
|
|
203
|
+
- [`llm-tokens-atlas`](https://github.com/faraa2m/llm-tokens-atlas): empirical tokenizer calibration dataset.
|
|
204
|
+
- [`routerlab`](https://github.com/faraa2m/routerlab): cost-quality routing frontiers for LLM APIs.
|
|
205
|
+
- [`promptc`](https://github.com/faraa2m/promptc): deterministic prompt compiler for cost reduction.
|
|
206
|
+
- `ast-ai-model-router`: model routing for local coding agents.
|
|
207
|
+
|
|
105
208
|
## Privacy
|
|
106
209
|
|
|
107
|
-
The router reads local source files to compute AST complexity and launches the local `claude` or `codex` CLI. It does not
|
|
210
|
+
The router reads local source files to compute AST complexity and launches the local `claude` or `codex` CLI. It does not run a separate network service and does not upload source code. Any model traffic comes from the Claude Code or Codex CLI you choose to run.
|
|
211
|
+
|
|
212
|
+
## Status
|
|
213
|
+
|
|
214
|
+
This is an explainable heuristic router. It is production-usable for policy and workflow guardrails, but it does not claim empirically proven model-quality optimization yet. Future releases can add outcome logging and calibration against real task success.
|
|
108
215
|
|
|
109
216
|
## License
|
|
110
217
|
|
|
@@ -1,25 +1,37 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
+
import { access, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
3
5
|
import { parseArgs } from "node:util";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
6
|
+
import { CONFIG_TEMPLATE, loadConfig, validateTier } from "../lib/config.js";
|
|
7
|
+
import { createDecision, maybeLogDecision } from "../lib/decision.js";
|
|
8
|
+
import { formatUsd } from "../lib/policy.js";
|
|
7
9
|
|
|
8
10
|
const HELP = `ast-ai-model-router
|
|
9
11
|
|
|
10
12
|
Usage:
|
|
11
13
|
ast-ai-model-router analyze --agent claude|codex --task "fix auth bug" [--json]
|
|
14
|
+
ast-ai-model-router explain --agent claude|codex --task "plan migration" [--json]
|
|
15
|
+
ast-ai-model-router ci --agent claude|codex --task "deploy change" [--max-tier complex]
|
|
12
16
|
ast-ai-model-router run claude --task "refactor parser" -- [extra claude args]
|
|
13
17
|
ast-ai-model-router run codex --task "write tests" -- [extra codex args]
|
|
18
|
+
ast-ai-model-router init [--cwd <path>] [--force]
|
|
14
19
|
|
|
15
20
|
Options:
|
|
16
21
|
--agent <agent> claude or codex for analyze
|
|
17
22
|
--task <text> Current task description
|
|
18
23
|
--cwd <path> Workspace to inspect, defaults to current directory
|
|
19
24
|
--json Emit machine-readable JSON
|
|
25
|
+
--max-tier <tier> Policy ceiling: simple, balanced, complex, planning
|
|
26
|
+
--max-cost-usd <n> Policy ceiling when cost estimate is available
|
|
27
|
+
--log Append a local JSONL decision record
|
|
28
|
+
--dry-run For run: print the command instead of launching
|
|
20
29
|
--refresh-models Refresh Codex model catalog cache
|
|
21
30
|
`;
|
|
22
31
|
|
|
32
|
+
const EXIT_INVALID = 2;
|
|
33
|
+
const EXIT_POLICY = 3;
|
|
34
|
+
|
|
23
35
|
async function main() {
|
|
24
36
|
const [command, maybeAgent, ...rest] = process.argv.slice(2);
|
|
25
37
|
if (!command || command === "--help" || command === "-h") {
|
|
@@ -27,7 +39,30 @@ async function main() {
|
|
|
27
39
|
return;
|
|
28
40
|
}
|
|
29
41
|
|
|
30
|
-
if (command === "
|
|
42
|
+
if (command === "init") {
|
|
43
|
+
const { values } = parseArgs({
|
|
44
|
+
args: [maybeAgent, ...rest].filter(Boolean),
|
|
45
|
+
options: {
|
|
46
|
+
cwd: { type: "string" },
|
|
47
|
+
force: { type: "boolean" }
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
const cwd = path.resolve(values.cwd ?? process.cwd());
|
|
51
|
+
const configPath = path.join(cwd, "model-router.config.json");
|
|
52
|
+
if (!values.force) {
|
|
53
|
+
try {
|
|
54
|
+
await access(configPath);
|
|
55
|
+
throw new Error(`model-router.config.json already exists at ${configPath}. Use --force to replace it.`);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error.code !== "ENOENT") throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
await writeFile(configPath, `${JSON.stringify(CONFIG_TEMPLATE, null, 2)}\n`, "utf8");
|
|
61
|
+
process.stdout.write(`Wrote ${configPath}\n`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (command === "analyze" || command === "explain" || command === "ci") {
|
|
31
66
|
const { values } = parseArgs({
|
|
32
67
|
args: [maybeAgent, ...rest].filter(Boolean),
|
|
33
68
|
options: {
|
|
@@ -35,11 +70,24 @@ async function main() {
|
|
|
35
70
|
task: { type: "string" },
|
|
36
71
|
cwd: { type: "string" },
|
|
37
72
|
json: { type: "boolean" },
|
|
73
|
+
log: { type: "boolean" },
|
|
74
|
+
"max-tier": { type: "string" },
|
|
75
|
+
"max-cost-usd": { type: "string" },
|
|
38
76
|
"refresh-models": { type: "boolean" }
|
|
39
77
|
}
|
|
40
78
|
});
|
|
41
79
|
const agent = assertAgent(values.agent);
|
|
42
|
-
const result = await
|
|
80
|
+
const result = await routeFromValues({ agent, values });
|
|
81
|
+
if (values.log) await maybeLogDecision(result, await loadConfig(values.cwd ?? process.cwd()), true);
|
|
82
|
+
if (command === "explain") {
|
|
83
|
+
printExplanation(result, Boolean(values.json));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (command === "ci") {
|
|
87
|
+
printCi(result, Boolean(values.json));
|
|
88
|
+
if (!result.policy.passed) process.exit(EXIT_POLICY);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
43
91
|
printResult(result, Boolean(values.json));
|
|
44
92
|
return;
|
|
45
93
|
}
|
|
@@ -54,12 +102,25 @@ async function main() {
|
|
|
54
102
|
options: {
|
|
55
103
|
task: { type: "string" },
|
|
56
104
|
cwd: { type: "string" },
|
|
105
|
+
log: { type: "boolean" },
|
|
106
|
+
"max-tier": { type: "string" },
|
|
107
|
+
"max-cost-usd": { type: "string" },
|
|
108
|
+
"dry-run": { type: "boolean" },
|
|
57
109
|
"refresh-models": { type: "boolean" }
|
|
58
110
|
}
|
|
59
111
|
});
|
|
60
|
-
const result = await
|
|
112
|
+
const result = await routeFromValues({ agent, values });
|
|
113
|
+
if (values.log) await maybeLogDecision(result, await loadConfig(values.cwd ?? process.cwd()), true);
|
|
114
|
+
if (!result.policy.passed) {
|
|
115
|
+
process.stderr.write(`[model-router] policy failed: ${result.policy.failures.join("; ")}\n`);
|
|
116
|
+
process.exit(EXIT_POLICY);
|
|
117
|
+
}
|
|
61
118
|
const executable = agent === "claude" ? "claude" : "codex";
|
|
62
119
|
const args = ["--model", result.selectedModel, ...passthrough];
|
|
120
|
+
if (values["dry-run"]) {
|
|
121
|
+
process.stdout.write(`${executable} ${args.map(shellQuote).join(" ")}\n`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
63
124
|
process.stderr.write(`[model-router] ${agent}: ${result.selectedModel} (${result.tier}, confidence ${result.confidence.toFixed(2)})\n`);
|
|
64
125
|
const child = spawn(executable, args, { cwd: result.cwd, stdio: "inherit" });
|
|
65
126
|
child.on("exit", (code, signal) => {
|
|
@@ -79,20 +140,19 @@ async function main() {
|
|
|
79
140
|
throw new Error(`Unknown command: ${command}`);
|
|
80
141
|
}
|
|
81
142
|
|
|
82
|
-
async function
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
return {
|
|
143
|
+
async function routeFromValues({ agent, values }) {
|
|
144
|
+
const maxTier = values["max-tier"] ? validateTier(values["max-tier"], "--max-tier") : undefined;
|
|
145
|
+
const maxCostUsd = parseOptionalNumber(values["max-cost-usd"], "--max-cost-usd");
|
|
146
|
+
const config = await loadConfig(values.cwd ?? process.cwd());
|
|
147
|
+
return createDecision({
|
|
87
148
|
agent,
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
};
|
|
149
|
+
task: values.task ?? "",
|
|
150
|
+
cwd: values.cwd ?? process.cwd(),
|
|
151
|
+
config,
|
|
152
|
+
refreshModels: Boolean(values["refresh-models"]),
|
|
153
|
+
maxTier,
|
|
154
|
+
maxCostUsd
|
|
155
|
+
});
|
|
96
156
|
}
|
|
97
157
|
|
|
98
158
|
function assertAgent(agent) {
|
|
@@ -110,10 +170,56 @@ function printResult(result, asJson) {
|
|
|
110
170
|
process.stdout.write(`Tier: ${result.tier}\n`);
|
|
111
171
|
process.stdout.write(`Confidence: ${result.confidence.toFixed(2)}\n`);
|
|
112
172
|
process.stdout.write(`Signals: ${result.signals.map((signal) => `${signal.name}=${signal.value}`).join(", ")}\n`);
|
|
173
|
+
process.stdout.write(`Cost: ${formatCost(result.costEstimate)}\n`);
|
|
174
|
+
if (result.warnings.length) process.stdout.write(`Warnings: ${result.warnings.join(" ")}\n`);
|
|
175
|
+
process.stdout.write(`Why: ${result.rationale[0]}\n`);
|
|
113
176
|
process.stdout.write(`Run: ${result.commandPreview}\n`);
|
|
114
177
|
}
|
|
115
178
|
|
|
179
|
+
function printExplanation(result, asJson) {
|
|
180
|
+
if (asJson) {
|
|
181
|
+
process.stdout.write(`${JSON.stringify({ ...result, explanation: result.rationale }, null, 2)}\n`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
printResult(result, false);
|
|
185
|
+
process.stdout.write("\nExplanation:\n");
|
|
186
|
+
for (const line of result.rationale) process.stdout.write(`- ${line}\n`);
|
|
187
|
+
process.stdout.write("\nSignal details:\n");
|
|
188
|
+
for (const signal of result.signals) {
|
|
189
|
+
process.stdout.write(`- ${signal.name}: ${signal.value} (${signal.detail})\n`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function printCi(result, asJson) {
|
|
194
|
+
if (asJson) {
|
|
195
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
process.stdout.write(result.policy.passed ? "model-router ci: passed\n" : "model-router ci: failed\n");
|
|
199
|
+
printResult(result, false);
|
|
200
|
+
if (!result.policy.passed) {
|
|
201
|
+
for (const failure of result.policy.failures) process.stdout.write(`Policy failure: ${failure}\n`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function formatCost(costEstimate) {
|
|
206
|
+
if (!costEstimate?.available) return `unavailable (${costEstimate?.reason ?? "unknown"})`;
|
|
207
|
+
return `${costEstimate.inputTokens} input tokens, ${formatUsd(costEstimate.inputCostUsd)} (${costEstimate.model})`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function parseOptionalNumber(raw, field) {
|
|
211
|
+
if (raw === undefined || raw === null) return undefined;
|
|
212
|
+
const value = Number(raw);
|
|
213
|
+
if (!Number.isFinite(value) || value < 0) throw new Error(`${field} must be a non-negative number`);
|
|
214
|
+
return value;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function shellQuote(value) {
|
|
218
|
+
if (/^[A-Za-z0-9_./:=@-]+$/.test(value)) return value;
|
|
219
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
220
|
+
}
|
|
221
|
+
|
|
116
222
|
main().catch((error) => {
|
|
117
223
|
process.stderr.write(`[model-router] ${error.message}\n`);
|
|
118
|
-
process.exit(1);
|
|
224
|
+
process.exit(error.message.includes("must be") || error.message.includes("Expected") ? EXIT_INVALID : 1);
|
|
119
225
|
});
|
package/lib/analyzer.js
CHANGED
|
@@ -100,6 +100,9 @@ function scoreRepo(files, astFiles) {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
function tierFor(score, task, config) {
|
|
103
|
+
if (/explain|summarize|readme|docs|comment/i.test(task) && !/architecture|migration|security|auth|database|billing|payment|production|deploy/i.test(task) && score <= config.thresholds.balancedMax) {
|
|
104
|
+
return "simple";
|
|
105
|
+
}
|
|
103
106
|
if (/plan|architecture|migration|strategy/i.test(task) && score >= config.thresholds.simpleMax) return "planning";
|
|
104
107
|
if (score <= config.thresholds.simpleMax) return "simple";
|
|
105
108
|
if (score <= config.thresholds.balancedMax) return "balanced";
|
package/lib/config.js
CHANGED
|
@@ -23,6 +23,41 @@ export const DEFAULT_CONFIG = {
|
|
|
23
23
|
planning: "gpt-5.5"
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
|
+
modelMappings: {
|
|
27
|
+
claude: {
|
|
28
|
+
haiku: "claude-haiku-4-5",
|
|
29
|
+
sonnet: "claude-sonnet-4-6",
|
|
30
|
+
opus: "claude-opus-4-7",
|
|
31
|
+
opusplan: "claude-opus-4-7"
|
|
32
|
+
},
|
|
33
|
+
codex: {
|
|
34
|
+
"gpt-5.4-mini": "gpt-5-mini",
|
|
35
|
+
"gpt-5.4": "gpt-5",
|
|
36
|
+
"gpt-5.5": "gpt-5",
|
|
37
|
+
"codex-mini-latest": "codex-mini-latest"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
policy: {
|
|
41
|
+
maxTier: "planning",
|
|
42
|
+
maxCostUsd: null
|
|
43
|
+
},
|
|
44
|
+
logging: {
|
|
45
|
+
enabled: false,
|
|
46
|
+
path: ".model-router/decisions.jsonl"
|
|
47
|
+
},
|
|
48
|
+
overrides: []
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const CONFIG_TEMPLATE = {
|
|
52
|
+
thresholds: DEFAULT_CONFIG.thresholds,
|
|
53
|
+
claude: DEFAULT_CONFIG.claude,
|
|
54
|
+
codex: DEFAULT_CONFIG.codex,
|
|
55
|
+
modelMappings: DEFAULT_CONFIG.modelMappings,
|
|
56
|
+
policy: {
|
|
57
|
+
maxTier: "planning",
|
|
58
|
+
maxCostUsd: null
|
|
59
|
+
},
|
|
60
|
+
logging: DEFAULT_CONFIG.logging,
|
|
26
61
|
overrides: []
|
|
27
62
|
};
|
|
28
63
|
|
|
@@ -52,6 +87,17 @@ function mergeConfig(base, override) {
|
|
|
52
87
|
...override.codex,
|
|
53
88
|
fallbackModels: { ...base.codex.fallbackModels, ...override.codex?.fallbackModels }
|
|
54
89
|
},
|
|
90
|
+
modelMappings: {
|
|
91
|
+
claude: { ...base.modelMappings.claude, ...override.modelMappings?.claude },
|
|
92
|
+
codex: { ...base.modelMappings.codex, ...override.modelMappings?.codex }
|
|
93
|
+
},
|
|
94
|
+
policy: { ...base.policy, ...override.policy },
|
|
95
|
+
logging: { ...base.logging, ...override.logging },
|
|
55
96
|
overrides: override.overrides ?? base.overrides
|
|
56
97
|
};
|
|
57
98
|
}
|
|
99
|
+
|
|
100
|
+
export function validateTier(tier, field = "tier") {
|
|
101
|
+
if (["simple", "balanced", "complex", "planning"].includes(tier)) return tier;
|
|
102
|
+
throw new Error(`${field} must be one of: simple, balanced, complex, planning`);
|
|
103
|
+
}
|
package/lib/cost.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { tokenize } from "@tokenometer/core";
|
|
2
|
+
|
|
3
|
+
export function estimateCost({ agent, selectedModel, task, config }) {
|
|
4
|
+
const mappedModel = config.modelMappings?.[agent]?.[selectedModel];
|
|
5
|
+
if (!task?.trim()) {
|
|
6
|
+
return unavailable("No task text was provided, so there is no prompt to estimate.");
|
|
7
|
+
}
|
|
8
|
+
if (!mappedModel) {
|
|
9
|
+
return unavailable(`No Tokenometer model mapping for ${agent} model "${selectedModel}".`);
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
const result = tokenize({
|
|
13
|
+
format: "text",
|
|
14
|
+
modelId: mappedModel,
|
|
15
|
+
prompt: task
|
|
16
|
+
});
|
|
17
|
+
return {
|
|
18
|
+
available: true,
|
|
19
|
+
scope: "task",
|
|
20
|
+
source: "tokenometer",
|
|
21
|
+
model: mappedModel,
|
|
22
|
+
inputTokens: result.inputTokens,
|
|
23
|
+
inputCostUsd: result.inputCost,
|
|
24
|
+
approximate: result.approximate,
|
|
25
|
+
tokenizer: result.tokenizer,
|
|
26
|
+
reason: result.approximate
|
|
27
|
+
? "Estimated from Tokenometer offline/proxy tokenizer."
|
|
28
|
+
: "Estimated from Tokenometer exact offline tokenizer."
|
|
29
|
+
};
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return unavailable(error instanceof Error ? error.message : String(error), mappedModel);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function unavailable(reason, model = null) {
|
|
36
|
+
return {
|
|
37
|
+
available: false,
|
|
38
|
+
scope: "task",
|
|
39
|
+
source: "tokenometer",
|
|
40
|
+
model,
|
|
41
|
+
inputTokens: null,
|
|
42
|
+
inputCostUsd: null,
|
|
43
|
+
approximate: null,
|
|
44
|
+
tokenizer: null,
|
|
45
|
+
reason
|
|
46
|
+
};
|
|
47
|
+
}
|
package/lib/decision.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { analyzeTask } from "./analyzer.js";
|
|
4
|
+
import { estimateCost } from "./cost.js";
|
|
5
|
+
import { chooseModel } from "./models.js";
|
|
6
|
+
import { evaluatePolicy } from "./policy.js";
|
|
7
|
+
|
|
8
|
+
export async function createDecision({ agent, task, cwd, config, refreshModels = false, maxTier, maxCostUsd }) {
|
|
9
|
+
const analysis = await analyzeTask({ cwd, task, config });
|
|
10
|
+
const model = await chooseModel({ agent, tier: analysis.tier, task, config, refreshModels });
|
|
11
|
+
const costEstimate = estimateCost({ agent, selectedModel: model.model, task, config });
|
|
12
|
+
const policy = evaluatePolicy({ tier: analysis.tier, costEstimate, config, maxTier, maxCostUsd });
|
|
13
|
+
const warnings = [];
|
|
14
|
+
if (model.source === "fallback") warnings.push("Codex model discovery failed; using configured fallback model.");
|
|
15
|
+
if (!costEstimate.available) warnings.push(`Cost estimate unavailable: ${costEstimate.reason}`);
|
|
16
|
+
if (!policy.passed) warnings.push(`Policy failed: ${policy.failures.join("; ")}`);
|
|
17
|
+
const rationale = buildRationale({ analysis, model, costEstimate, policy });
|
|
18
|
+
return {
|
|
19
|
+
agent,
|
|
20
|
+
cwd: analysis.cwd,
|
|
21
|
+
selectedModel: model.model,
|
|
22
|
+
tier: analysis.tier,
|
|
23
|
+
confidence: analysis.confidence,
|
|
24
|
+
score: analysis.score,
|
|
25
|
+
signals: analysis.signals,
|
|
26
|
+
rationale,
|
|
27
|
+
warnings,
|
|
28
|
+
costEstimate,
|
|
29
|
+
policy,
|
|
30
|
+
modelSource: model.source,
|
|
31
|
+
commandPreview: `${agent} --model ${model.model}`
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function maybeLogDecision(decision, config, explicitLog = false) {
|
|
36
|
+
if (!explicitLog && !config.logging?.enabled) return;
|
|
37
|
+
const logPath = path.resolve(decision.cwd, config.logging?.path ?? ".model-router/decisions.jsonl");
|
|
38
|
+
await mkdir(path.dirname(logPath), { recursive: true });
|
|
39
|
+
const record = {
|
|
40
|
+
timestamp: new Date().toISOString(),
|
|
41
|
+
agent: decision.agent,
|
|
42
|
+
selectedModel: decision.selectedModel,
|
|
43
|
+
tier: decision.tier,
|
|
44
|
+
confidence: decision.confidence,
|
|
45
|
+
score: decision.score,
|
|
46
|
+
costEstimate: decision.costEstimate,
|
|
47
|
+
policy: decision.policy,
|
|
48
|
+
signals: decision.signals
|
|
49
|
+
};
|
|
50
|
+
await appendFile(logPath, `${JSON.stringify(record)}\n`, "utf8");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildRationale({ analysis, model, costEstimate, policy }) {
|
|
54
|
+
const sorted = [...analysis.signals].sort((a, b) => Number(b.value) - Number(a.value));
|
|
55
|
+
const top = sorted.slice(0, 3).map((signal) => `${signal.name}=${signal.value} (${signal.detail})`);
|
|
56
|
+
const lines = [
|
|
57
|
+
`Selected ${model.model} because the task scored as ${analysis.tier} complexity.`,
|
|
58
|
+
`Top signals: ${top.join("; ")}.`,
|
|
59
|
+
`Model source: ${model.source}.`
|
|
60
|
+
];
|
|
61
|
+
if (costEstimate.available) {
|
|
62
|
+
lines.push(`Estimated task prompt cost: ${costEstimate.inputTokens} input tokens, ${costEstimate.inputCostUsd.toFixed(6)} USD (${costEstimate.model}).`);
|
|
63
|
+
} else {
|
|
64
|
+
lines.push(`Cost estimate unavailable: ${costEstimate.reason}`);
|
|
65
|
+
}
|
|
66
|
+
lines.push(policy.passed ? "Policy: passed." : `Policy: failed (${policy.failures.join("; ")}).`);
|
|
67
|
+
return lines;
|
|
68
|
+
}
|
package/lib/policy.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const TIER_ORDER = {
|
|
2
|
+
simple: 0,
|
|
3
|
+
balanced: 1,
|
|
4
|
+
complex: 2,
|
|
5
|
+
planning: 3
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function evaluatePolicy({ tier, costEstimate, config, maxTier, maxCostUsd }) {
|
|
9
|
+
const effectiveMaxTier = maxTier ?? config.policy?.maxTier ?? "planning";
|
|
10
|
+
const effectiveMaxCostUsd = maxCostUsd ?? config.policy?.maxCostUsd ?? null;
|
|
11
|
+
const failures = [];
|
|
12
|
+
if (TIER_ORDER[tier] > TIER_ORDER[effectiveMaxTier]) {
|
|
13
|
+
failures.push(`tier ${tier} exceeds max tier ${effectiveMaxTier}`);
|
|
14
|
+
}
|
|
15
|
+
if (
|
|
16
|
+
effectiveMaxCostUsd !== null &&
|
|
17
|
+
effectiveMaxCostUsd !== undefined &&
|
|
18
|
+
costEstimate?.available &&
|
|
19
|
+
costEstimate.inputCostUsd > Number(effectiveMaxCostUsd)
|
|
20
|
+
) {
|
|
21
|
+
failures.push(`estimated cost ${formatUsd(costEstimate.inputCostUsd)} exceeds max cost ${formatUsd(Number(effectiveMaxCostUsd))}`);
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
passed: failures.length === 0,
|
|
25
|
+
failures,
|
|
26
|
+
maxTier: effectiveMaxTier,
|
|
27
|
+
maxCostUsd: effectiveMaxCostUsd
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function formatUsd(value) {
|
|
32
|
+
if (!Number.isFinite(value)) return "unavailable";
|
|
33
|
+
if (value === 0) return "$0.000000";
|
|
34
|
+
return `$${value.toFixed(6)}`;
|
|
35
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ast-ai-model-router/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "AST-
|
|
3
|
+
"version": "1.0.0",
|
|
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": {
|
|
7
7
|
"ast-ai-model-router": "bin/ast-ai-model-router.js",
|
|
@@ -28,12 +28,20 @@
|
|
|
28
28
|
},
|
|
29
29
|
"keywords": [
|
|
30
30
|
"llm",
|
|
31
|
+
"ai-coding-agent",
|
|
31
32
|
"token-economics",
|
|
33
|
+
"llm-cost-optimization",
|
|
32
34
|
"model-router",
|
|
35
|
+
"model-selection",
|
|
33
36
|
"claude-code",
|
|
37
|
+
"claude-code-model-router",
|
|
34
38
|
"codex",
|
|
39
|
+
"codex-cli",
|
|
40
|
+
"codex-model-router",
|
|
35
41
|
"ast",
|
|
36
|
-
"prompt-cost"
|
|
42
|
+
"prompt-cost",
|
|
43
|
+
"developer-tools",
|
|
44
|
+
"ci"
|
|
37
45
|
],
|
|
38
46
|
"author": {
|
|
39
47
|
"name": "Faraazuddin Mohammed",
|
|
@@ -51,7 +59,8 @@
|
|
|
51
59
|
"provenance": true
|
|
52
60
|
},
|
|
53
61
|
"dependencies": {
|
|
54
|
-
"@babel/parser": "^7.28.5"
|
|
62
|
+
"@babel/parser": "^7.28.5",
|
|
63
|
+
"@tokenometer/core": "^1.1.0"
|
|
55
64
|
},
|
|
56
65
|
"devDependencies": {
|
|
57
66
|
"@changesets/cli": "^2.31.0"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: model-router
|
|
3
|
-
description: Use AST AI Model Router to choose an appropriate Claude or Codex model from task
|
|
3
|
+
description: Use AST AI Model Router to choose an appropriate Claude Code or Codex model from task, code complexity, token-cost, and policy signals before launching or recommending an agent command.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Model Router
|
|
@@ -21,7 +21,19 @@ or:
|
|
|
21
21
|
ast-ai-model-router analyze --agent claude --task "<task>"
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
2.
|
|
24
|
+
2. When the user needs rationale, use:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
ast-ai-model-router explain --agent codex --task "<task>"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
3. For CI or production policy checks, use:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
ast-ai-model-router ci --agent codex --task "<task>" --max-tier complex
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
4. If the user wants execution, launch through the wrapper:
|
|
25
37
|
|
|
26
38
|
```bash
|
|
27
39
|
ast-ai-model-router run codex --task "<task>" -- <codex args>
|
|
@@ -31,10 +43,11 @@ ast-ai-model-router run codex --task "<task>" -- <codex args>
|
|
|
31
43
|
ast-ai-model-router run claude --task "<task>" -- <claude args>
|
|
32
44
|
```
|
|
33
45
|
|
|
34
|
-
|
|
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.
|
|
35
47
|
|
|
36
48
|
## Notes
|
|
37
49
|
|
|
38
50
|
- Codex model names are discovered dynamically from `codex debug models`.
|
|
39
51
|
- Claude model names use aliases: `haiku`, `sonnet`, `opus`, and `opusplan`.
|
|
52
|
+
- Token-cost estimates use Tokenometer when a selected model maps cleanly to a known provider model.
|
|
40
53
|
- The router analyzes JavaScript/TypeScript with Babel ASTs and Python with stdlib `ast`.
|