@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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ast-ai-model-router",
3
- "version": "0.1.0",
4
- "description": "AST-aware Claude and Codex model routing for cost-conscious coding tasks.",
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": "0.1.0",
4
- "description": "AST-aware Claude and Codex model routing for cost-conscious coding tasks.",
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 task complexity signals.",
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 a token-economics toolkit for measuring, reducing, and routing LLM spend.",
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
- AST-aware model selection for Claude Code and Codex. It inspects the current task, JavaScript/TypeScript ASTs, Python ASTs, and repo shape, then launches the coding agent with a dynamically chosen model.
3
+ [![npm](https://img.shields.io/npm/v/@ast-ai-model-router/cli.svg?label=%40ast-ai-model-router%2Fcli)](https://www.npmjs.com/package/@ast-ai-model-router/cli)
4
+ [![CI](https://github.com/faraa2m/ast-ai-model-router/actions/workflows/ci.yml/badge.svg)](https://github.com/faraa2m/ast-ai-model-router/actions/workflows/ci.yml)
5
+ [![npm smoke](https://github.com/faraa2m/ast-ai-model-router/actions/workflows/npm-smoke.yml/badge.svg)](https://github.com/faraa2m/ast-ai-model-router/actions/workflows/npm-smoke.yml)
6
+ [![License: MIT](https://img.shields.io/github/license/faraa2m/ast-ai-model-router.svg)](LICENSE)
7
+ [![GitHub stars](https://img.shields.io/github/stars/faraa2m/ast-ai-model-router.svg?style=social)](https://github.com/faraa2m/ast-ai-model-router/stargazers)
4
8
 
5
- This project is part of the [`faraa2m`](https://github.com/faraa2m) token economics stack:
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
- - [`tokenometer`](https://github.com/faraa2m/tokenometer) measures tokens, USD cost, latency, and CI prompt-cost regressions.
8
- - [`llm-tokens-atlas`](https://github.com/faraa2m/llm-tokens-atlas) calibrates offline tokenizers against empirical provider counts.
9
- - [`routerlab`](https://github.com/faraa2m/routerlab) builds cost-quality routing frontiers for LLM APIs.
10
- - [`promptc`](https://github.com/faraa2m/promptc) compiles prompts through deterministic cost-reduction passes.
11
- - `ast-ai-model-router` brings that cost-aware routing idea into local coding agents.
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
- For local development:
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
- npm install
23
- npm link
45
+ ast-ai-model-router analyze --agent claude --task "write docs for the parser"
24
46
  ```
25
47
 
26
- ## Usage
48
+ Explain the decision:
27
49
 
28
- Analyze a task without launching an agent:
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 analyze --agent codex --task "refactor the auth parser and add tests"
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 the failing Python AST tests" -- --cd .
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 docs" --json
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 intentionally aliases, not dated model names:
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 configurable defaults in `model-router.config.json`.
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
- Add `model-router.config.json` to a project root:
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
- ## Plugin
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
- This repo includes a Codex/Claude plugin manifest at `.codex-plugin/plugin.json` and a skill under `skills/model-router/`.
187
+ - `.codex-plugin/plugin.json`
188
+ - `.claude-plugin/plugin.json`
189
+ - `skills/model-router/SKILL.md`
97
190
 
98
- Use it directly while developing:
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 add a separate network service. Any model traffic comes from the Claude/Codex CLI you choose to run.
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 { analyzeTask } from "../lib/analyzer.js";
5
- import { loadConfig } from "../lib/config.js";
6
- import { chooseModel } from "../lib/models.js";
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 === "analyze") {
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 route({ agent, task: values.task, cwd: values.cwd, refreshModels: values["refresh-models"] });
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 route({ agent, task: values.task, cwd: values.cwd, refreshModels: values["refresh-models"] });
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 route({ agent, task, cwd, refreshModels }) {
83
- const config = await loadConfig(cwd ?? process.cwd());
84
- const analysis = await analyzeTask({ cwd: cwd ?? process.cwd(), task: task ?? "", config });
85
- const model = await chooseModel({ agent, tier: analysis.tier, task: task ?? "", config, refreshModels: Boolean(refreshModels) });
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
- cwd: analysis.cwd,
89
- selectedModel: model.model,
90
- tier: analysis.tier,
91
- confidence: analysis.confidence,
92
- signals: analysis.signals,
93
- modelSource: model.source,
94
- commandPreview: `${agent} --model ${model.model}`
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
+ }
@@ -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.1.1",
4
- "description": "AST-aware Claude and Codex model router for cost-conscious coding tasks.",
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 and code complexity before launching or recommending an agent command.
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. If the user wants execution, launch through the wrapper:
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
- 3. 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. 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`.