@ast-ai-model-router/cli 0.1.1
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 +21 -0
- package/.codex-plugin/plugin.json +41 -0
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/bin/ast-ai-model-router.js +119 -0
- package/lib/analyzer.js +113 -0
- package/lib/config.js +57 -0
- package/lib/models.js +50 -0
- package/lib/parsers/javascript.js +38 -0
- package/lib/parsers/python.js +15 -0
- package/package.json +62 -0
- package/scripts/python_ast_summary.py +62 -0
- package/skills/model-router/SKILL.md +40 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
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.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Faraazuddin Mohammed",
|
|
7
|
+
"email": "opensource@faraa2m.dev",
|
|
8
|
+
"url": "https://github.com/faraa2m"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/faraa2m/ast-ai-model-router#readme",
|
|
11
|
+
"repository": "https://github.com/faraa2m/ast-ai-model-router",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"token-economics",
|
|
15
|
+
"model-router",
|
|
16
|
+
"claude-code",
|
|
17
|
+
"codex",
|
|
18
|
+
"ast"
|
|
19
|
+
],
|
|
20
|
+
"skills": "./skills/"
|
|
21
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
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.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Faraazuddin Mohammed",
|
|
7
|
+
"email": "opensource@faraa2m.dev",
|
|
8
|
+
"url": "https://github.com/faraa2m"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/faraa2m/ast-ai-model-router#readme",
|
|
11
|
+
"repository": "https://github.com/faraa2m/ast-ai-model-router",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"token-economics",
|
|
15
|
+
"model-router",
|
|
16
|
+
"claude-code",
|
|
17
|
+
"codex",
|
|
18
|
+
"ast"
|
|
19
|
+
],
|
|
20
|
+
"skills": "./skills/",
|
|
21
|
+
"interface": {
|
|
22
|
+
"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.",
|
|
25
|
+
"developerName": "Faraazuddin Mohammed",
|
|
26
|
+
"category": "Developer Tools",
|
|
27
|
+
"capabilities": [
|
|
28
|
+
"Interactive",
|
|
29
|
+
"Local"
|
|
30
|
+
],
|
|
31
|
+
"websiteURL": "https://github.com/faraa2m/ast-ai-model-router",
|
|
32
|
+
"privacyPolicyURL": "https://github.com/faraa2m/ast-ai-model-router#privacy",
|
|
33
|
+
"termsOfServiceURL": "https://github.com/faraa2m/ast-ai-model-router#license",
|
|
34
|
+
"defaultPrompt": [
|
|
35
|
+
"Analyze this repo and choose the right Codex model.",
|
|
36
|
+
"Route this Claude Code task by AST complexity.",
|
|
37
|
+
"Explain why this coding task needs a stronger model."
|
|
38
|
+
],
|
|
39
|
+
"brandColor": "#2563EB"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Faraazuddin Mohammed
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# AST AI Model Router
|
|
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.
|
|
4
|
+
|
|
5
|
+
This project is part of the [`faraa2m`](https://github.com/faraa2m) token economics stack:
|
|
6
|
+
|
|
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.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g @ast-ai-model-router/cli
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
For local development:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install
|
|
23
|
+
npm link
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
Analyze a task without launching an agent:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
ast-ai-model-router analyze --agent codex --task "refactor the auth parser and add tests"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Launch Codex with the selected model:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
ast-ai-model-router run codex --task "fix the failing Python AST tests" -- --cd .
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Launch Claude Code with the selected alias:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
ast-ai-model-router run claude --task "plan a cross-module migration" -- --permission-mode plan
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Machine-readable output:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
ast-ai-model-router analyze --agent codex --task "write docs" --json
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## How Routing Works
|
|
53
|
+
|
|
54
|
+
The router scores four groups of signals:
|
|
55
|
+
|
|
56
|
+
- Prompt intent: docs, tests, debugging, refactors, architecture, security, migrations.
|
|
57
|
+
- Repo shape: file count, AST file count, package/build/config files.
|
|
58
|
+
- 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.
|
|
60
|
+
|
|
61
|
+
Claude targets are intentionally aliases, not dated model names:
|
|
62
|
+
|
|
63
|
+
- `simple` -> `haiku`
|
|
64
|
+
- `balanced` -> `sonnet`
|
|
65
|
+
- `complex` -> `opus`
|
|
66
|
+
- `planning` -> `opusplan`
|
|
67
|
+
|
|
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`.
|
|
69
|
+
|
|
70
|
+
## Configuration
|
|
71
|
+
|
|
72
|
+
Add `model-router.config.json` to a project root:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"thresholds": {
|
|
77
|
+
"simpleMax": 34,
|
|
78
|
+
"balancedMax": 74
|
|
79
|
+
},
|
|
80
|
+
"claude": {
|
|
81
|
+
"aliases": {
|
|
82
|
+
"simple": "haiku",
|
|
83
|
+
"balanced": "sonnet",
|
|
84
|
+
"complex": "opus",
|
|
85
|
+
"planning": "opusplan"
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"codex": {
|
|
89
|
+
"discoveryCommand": "codex debug models"
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Plugin
|
|
95
|
+
|
|
96
|
+
This repo includes a Codex/Claude plugin manifest at `.codex-plugin/plugin.json` and a skill under `skills/model-router/`.
|
|
97
|
+
|
|
98
|
+
Use it directly while developing:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
claude --plugin-dir .
|
|
102
|
+
codex plugin marketplace add .
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Privacy
|
|
106
|
+
|
|
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.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
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";
|
|
7
|
+
|
|
8
|
+
const HELP = `ast-ai-model-router
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
ast-ai-model-router analyze --agent claude|codex --task "fix auth bug" [--json]
|
|
12
|
+
ast-ai-model-router run claude --task "refactor parser" -- [extra claude args]
|
|
13
|
+
ast-ai-model-router run codex --task "write tests" -- [extra codex args]
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
--agent <agent> claude or codex for analyze
|
|
17
|
+
--task <text> Current task description
|
|
18
|
+
--cwd <path> Workspace to inspect, defaults to current directory
|
|
19
|
+
--json Emit machine-readable JSON
|
|
20
|
+
--refresh-models Refresh Codex model catalog cache
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
async function main() {
|
|
24
|
+
const [command, maybeAgent, ...rest] = process.argv.slice(2);
|
|
25
|
+
if (!command || command === "--help" || command === "-h") {
|
|
26
|
+
process.stdout.write(HELP);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (command === "analyze") {
|
|
31
|
+
const { values } = parseArgs({
|
|
32
|
+
args: [maybeAgent, ...rest].filter(Boolean),
|
|
33
|
+
options: {
|
|
34
|
+
agent: { type: "string" },
|
|
35
|
+
task: { type: "string" },
|
|
36
|
+
cwd: { type: "string" },
|
|
37
|
+
json: { type: "boolean" },
|
|
38
|
+
"refresh-models": { type: "boolean" }
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
const agent = assertAgent(values.agent);
|
|
42
|
+
const result = await route({ agent, task: values.task, cwd: values.cwd, refreshModels: values["refresh-models"] });
|
|
43
|
+
printResult(result, Boolean(values.json));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (command === "run") {
|
|
48
|
+
const agent = assertAgent(maybeAgent);
|
|
49
|
+
const split = rest.indexOf("--");
|
|
50
|
+
const optionArgs = split === -1 ? rest : rest.slice(0, split);
|
|
51
|
+
const passthrough = split === -1 ? [] : rest.slice(split + 1);
|
|
52
|
+
const { values } = parseArgs({
|
|
53
|
+
args: optionArgs,
|
|
54
|
+
options: {
|
|
55
|
+
task: { type: "string" },
|
|
56
|
+
cwd: { type: "string" },
|
|
57
|
+
"refresh-models": { type: "boolean" }
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
const result = await route({ agent, task: values.task, cwd: values.cwd, refreshModels: values["refresh-models"] });
|
|
61
|
+
const executable = agent === "claude" ? "claude" : "codex";
|
|
62
|
+
const args = ["--model", result.selectedModel, ...passthrough];
|
|
63
|
+
process.stderr.write(`[model-router] ${agent}: ${result.selectedModel} (${result.tier}, confidence ${result.confidence.toFixed(2)})\n`);
|
|
64
|
+
const child = spawn(executable, args, { cwd: result.cwd, stdio: "inherit" });
|
|
65
|
+
child.on("exit", (code, signal) => {
|
|
66
|
+
if (signal) {
|
|
67
|
+
process.kill(process.pid, signal);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
process.exit(code ?? 1);
|
|
71
|
+
});
|
|
72
|
+
child.on("error", (error) => {
|
|
73
|
+
process.stderr.write(`[model-router] failed to launch ${executable}: ${error.message}\n`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
throw new Error(`Unknown command: ${command}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
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 {
|
|
87
|
+
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
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function assertAgent(agent) {
|
|
99
|
+
if (agent === "claude" || agent === "codex") return agent;
|
|
100
|
+
throw new Error("Expected agent to be 'claude' or 'codex'.");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function printResult(result, asJson) {
|
|
104
|
+
if (asJson) {
|
|
105
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
process.stdout.write(`Agent: ${result.agent}\n`);
|
|
109
|
+
process.stdout.write(`Model: ${result.selectedModel}\n`);
|
|
110
|
+
process.stdout.write(`Tier: ${result.tier}\n`);
|
|
111
|
+
process.stdout.write(`Confidence: ${result.confidence.toFixed(2)}\n`);
|
|
112
|
+
process.stdout.write(`Signals: ${result.signals.map((signal) => `${signal.name}=${signal.value}`).join(", ")}\n`);
|
|
113
|
+
process.stdout.write(`Run: ${result.commandPreview}\n`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
main().catch((error) => {
|
|
117
|
+
process.stderr.write(`[model-router] ${error.message}\n`);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
});
|
package/lib/analyzer.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { analyzeJavaScriptFile } from "./parsers/javascript.js";
|
|
4
|
+
import { analyzePythonFile } from "./parsers/python.js";
|
|
5
|
+
|
|
6
|
+
const IGNORE_DIRS = new Set([".git", "node_modules", "dist", "build", ".next", ".venv", "venv", "__pycache__", "coverage"]);
|
|
7
|
+
const JS_EXTENSIONS = new Set([".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"]);
|
|
8
|
+
const PY_EXTENSIONS = new Set([".py"]);
|
|
9
|
+
|
|
10
|
+
export async function analyzeTask({ cwd, task, config }) {
|
|
11
|
+
const root = path.resolve(cwd);
|
|
12
|
+
const files = await collectFiles(root);
|
|
13
|
+
const astFiles = files.filter((file) => JS_EXTENSIONS.has(path.extname(file)) || PY_EXTENSIONS.has(path.extname(file))).slice(0, 80);
|
|
14
|
+
const ast = await summarizeAst(astFiles);
|
|
15
|
+
const prompt = scorePrompt(task);
|
|
16
|
+
const repo = scoreRepo(files, astFiles);
|
|
17
|
+
const score = prompt.score + repo.score + ast.score;
|
|
18
|
+
const tier = tierFor(score, task, config);
|
|
19
|
+
const signals = [
|
|
20
|
+
{ name: "prompt", value: prompt.score, detail: prompt.matches.join(",") || "none" },
|
|
21
|
+
{ name: "repo", value: repo.score, detail: `${files.length} files, ${astFiles.length} AST files` },
|
|
22
|
+
{ name: "ast", value: ast.score, detail: `${ast.functions} functions, ${ast.classes} classes, ${ast.branches} branches` },
|
|
23
|
+
{ name: "languages", value: ast.languages.size, detail: [...ast.languages].join(",") || "none" }
|
|
24
|
+
];
|
|
25
|
+
return {
|
|
26
|
+
cwd: root,
|
|
27
|
+
tier,
|
|
28
|
+
confidence: confidenceFor(score, tier, config),
|
|
29
|
+
score,
|
|
30
|
+
signals
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function collectFiles(root, relative = "") {
|
|
35
|
+
let entries;
|
|
36
|
+
try {
|
|
37
|
+
entries = await readdir(path.join(root, relative), { withFileTypes: true });
|
|
38
|
+
} catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
const files = [];
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (entry.name.startsWith(".") && entry.name !== ".github") continue;
|
|
44
|
+
const child = path.join(relative, entry.name);
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
if (!IGNORE_DIRS.has(entry.name)) files.push(...await collectFiles(root, child));
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (entry.isFile()) files.push(path.join(root, child));
|
|
50
|
+
}
|
|
51
|
+
return files;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function summarizeAst(files) {
|
|
55
|
+
const total = { score: 0, functions: 0, classes: 0, branches: 0, imports: 0, languages: new Set() };
|
|
56
|
+
for (const file of files) {
|
|
57
|
+
const ext = path.extname(file);
|
|
58
|
+
const summary = PY_EXTENSIONS.has(ext) ? await analyzePythonFile(file) : await analyzeJavaScriptFile(file);
|
|
59
|
+
if (!summary.ok) continue;
|
|
60
|
+
total.functions += summary.functions;
|
|
61
|
+
total.classes += summary.classes;
|
|
62
|
+
total.branches += summary.branches;
|
|
63
|
+
total.imports += summary.imports;
|
|
64
|
+
total.languages.add(summary.language);
|
|
65
|
+
}
|
|
66
|
+
total.score = Math.min(45, total.functions * 0.45 + total.classes * 1.5 + total.branches * 0.7 + total.imports * 0.15 + total.languages.size * 4);
|
|
67
|
+
return total;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function scorePrompt(task) {
|
|
71
|
+
const text = task.toLowerCase();
|
|
72
|
+
const patterns = [
|
|
73
|
+
[/architecture|design|migration|refactor|rewrite|multi-file|cross-module/g, 18],
|
|
74
|
+
[/security|auth|database|billing|payment|production|deploy/g, 16],
|
|
75
|
+
[/debug|bug|failing|regression|race|performance/g, 12],
|
|
76
|
+
[/test|coverage|unit|integration|e2e/g, 8],
|
|
77
|
+
[/explain|summarize|readme|docs|comment/g, 4]
|
|
78
|
+
];
|
|
79
|
+
const matches = [];
|
|
80
|
+
let score = 0;
|
|
81
|
+
for (const [regex, weight] of patterns) {
|
|
82
|
+
const found = text.match(regex);
|
|
83
|
+
if (!found) continue;
|
|
84
|
+
matches.push(...found);
|
|
85
|
+
score += Math.min(weight, found.length * weight);
|
|
86
|
+
}
|
|
87
|
+
if (text.length > 800) score += 10;
|
|
88
|
+
if (text.length > 1800) score += 10;
|
|
89
|
+
return { score: Math.min(50, score), matches };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function scoreRepo(files, astFiles) {
|
|
93
|
+
let score = 0;
|
|
94
|
+
if (files.length > 100) score += 8;
|
|
95
|
+
if (files.length > 500) score += 12;
|
|
96
|
+
if (astFiles.length > 20) score += 8;
|
|
97
|
+
if (astFiles.length > 60) score += 12;
|
|
98
|
+
if (files.some((file) => /package\.json|pyproject\.toml|requirements\.txt|Dockerfile|\.github\/workflows/.test(file))) score += 8;
|
|
99
|
+
return { score: Math.min(35, score) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function tierFor(score, task, config) {
|
|
103
|
+
if (/plan|architecture|migration|strategy/i.test(task) && score >= config.thresholds.simpleMax) return "planning";
|
|
104
|
+
if (score <= config.thresholds.simpleMax) return "simple";
|
|
105
|
+
if (score <= config.thresholds.balancedMax) return "balanced";
|
|
106
|
+
return "complex";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function confidenceFor(score, tier, config) {
|
|
110
|
+
const target = tier === "simple" ? config.thresholds.simpleMax : tier === "balanced" ? config.thresholds.balancedMax : config.thresholds.balancedMax + 30;
|
|
111
|
+
const distance = Math.min(40, Math.abs(score - target));
|
|
112
|
+
return Math.max(0.55, Math.min(0.95, 0.95 - (40 - distance) / 120));
|
|
113
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_CONFIG = {
|
|
5
|
+
thresholds: {
|
|
6
|
+
simpleMax: 34,
|
|
7
|
+
balancedMax: 74
|
|
8
|
+
},
|
|
9
|
+
claude: {
|
|
10
|
+
aliases: {
|
|
11
|
+
simple: "haiku",
|
|
12
|
+
balanced: "sonnet",
|
|
13
|
+
complex: "opus",
|
|
14
|
+
planning: "opusplan"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
codex: {
|
|
18
|
+
discoveryCommand: "codex debug models",
|
|
19
|
+
fallbackModels: {
|
|
20
|
+
simple: "gpt-5.4-mini",
|
|
21
|
+
balanced: "gpt-5.4",
|
|
22
|
+
complex: "gpt-5.5",
|
|
23
|
+
planning: "gpt-5.5"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
overrides: []
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export async function loadConfig(cwd) {
|
|
30
|
+
const configPath = path.join(cwd, "model-router.config.json");
|
|
31
|
+
try {
|
|
32
|
+
const raw = await readFile(configPath, "utf8");
|
|
33
|
+
return mergeConfig(DEFAULT_CONFIG, JSON.parse(raw));
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error.code === "ENOENT") return DEFAULT_CONFIG;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mergeConfig(base, override) {
|
|
41
|
+
return {
|
|
42
|
+
...base,
|
|
43
|
+
...override,
|
|
44
|
+
thresholds: { ...base.thresholds, ...override.thresholds },
|
|
45
|
+
claude: {
|
|
46
|
+
...base.claude,
|
|
47
|
+
...override.claude,
|
|
48
|
+
aliases: { ...base.claude.aliases, ...override.claude?.aliases }
|
|
49
|
+
},
|
|
50
|
+
codex: {
|
|
51
|
+
...base.codex,
|
|
52
|
+
...override.codex,
|
|
53
|
+
fallbackModels: { ...base.codex.fallbackModels, ...override.codex?.fallbackModels }
|
|
54
|
+
},
|
|
55
|
+
overrides: override.overrides ?? base.overrides
|
|
56
|
+
};
|
|
57
|
+
}
|
package/lib/models.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export async function chooseModel({ agent, tier, config, refreshModels }) {
|
|
7
|
+
if (agent === "claude") {
|
|
8
|
+
return { model: config.claude.aliases[tier] ?? config.claude.aliases.balanced, source: "claude-alias" };
|
|
9
|
+
}
|
|
10
|
+
const catalog = await loadCodexCatalog(config, refreshModels);
|
|
11
|
+
const selected = selectCodexModel(catalog, tier) ?? config.codex.fallbackModels[tier] ?? config.codex.fallbackModels.balanced;
|
|
12
|
+
return { model: selected, source: catalog.length ? "codex-debug-models" : "fallback" };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function loadCodexCatalog(config) {
|
|
16
|
+
const [command, ...args] = config.codex.discoveryCommand.split(/\s+/);
|
|
17
|
+
try {
|
|
18
|
+
const { stdout } = await execFileAsync(command, args, { timeout: 5000, maxBuffer: 1024 * 1024 * 6 });
|
|
19
|
+
const line = stdout.trim().split("\n").find((item) => item.trim().startsWith("{"));
|
|
20
|
+
const parsed = JSON.parse(line);
|
|
21
|
+
return Array.isArray(parsed.models) ? parsed.models.filter((model) => model.visibility !== "hidden") : [];
|
|
22
|
+
} catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function selectCodexModel(models, tier) {
|
|
28
|
+
if (!models.length) return null;
|
|
29
|
+
const visible = models.map((model) => ({
|
|
30
|
+
slug: model.slug,
|
|
31
|
+
text: `${model.slug} ${model.display_name ?? ""} ${model.description ?? ""}`.toLowerCase(),
|
|
32
|
+
priority: Number.isFinite(model.priority) ? model.priority : 1000,
|
|
33
|
+
context: model.context_window ?? 0
|
|
34
|
+
}));
|
|
35
|
+
if (tier === "simple") return best(visible, [/mini|small|fast|efficient/], [/frontier|complex/]);
|
|
36
|
+
if (tier === "balanced") return best(visible, [/gpt-5\.4$|balanced|coding|everyday/], [/mini/]);
|
|
37
|
+
return best(visible, [/gpt-5\.5|frontier|complex|real-world|large/], [/mini|small/]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function best(models, positive, negative) {
|
|
41
|
+
return models
|
|
42
|
+
.map((model) => {
|
|
43
|
+
let score = 1000 - model.priority;
|
|
44
|
+
for (const regex of positive) if (regex.test(model.text)) score += 100;
|
|
45
|
+
for (const regex of negative) if (regex.test(model.text)) score -= 80;
|
|
46
|
+
score += Math.min(50, model.context / 20000);
|
|
47
|
+
return { ...model, score };
|
|
48
|
+
})
|
|
49
|
+
.sort((a, b) => b.score - a.score)[0]?.slug ?? null;
|
|
50
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { parse } from "@babel/parser";
|
|
3
|
+
|
|
4
|
+
export async function analyzeJavaScriptFile(file) {
|
|
5
|
+
try {
|
|
6
|
+
const source = await readFile(file, "utf8");
|
|
7
|
+
const ast = parse(source, {
|
|
8
|
+
sourceType: "unambiguous",
|
|
9
|
+
plugins: ["typescript", "jsx", "decorators-legacy", "classProperties", "dynamicImport"]
|
|
10
|
+
});
|
|
11
|
+
const summary = { ok: true, language: "javascript", functions: 0, classes: 0, branches: 0, imports: 0 };
|
|
12
|
+
walk(ast, (node) => {
|
|
13
|
+
if (!node || typeof node.type !== "string") return;
|
|
14
|
+
if (["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression", "ObjectMethod", "ClassMethod"].includes(node.type)) summary.functions += 1;
|
|
15
|
+
if (["ClassDeclaration", "ClassExpression"].includes(node.type)) summary.classes += 1;
|
|
16
|
+
if (["IfStatement", "ConditionalExpression", "ForStatement", "ForOfStatement", "ForInStatement", "WhileStatement", "DoWhileStatement", "SwitchCase", "TryStatement"].includes(node.type)) summary.branches += 1;
|
|
17
|
+
if (["ImportDeclaration", "ExportNamedDeclaration", "ExportAllDeclaration", "CallExpression"].includes(node.type)) {
|
|
18
|
+
if (node.type !== "CallExpression" || node.callee?.name === "require") summary.imports += 1;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
return summary;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
return { ok: false, file, error: error.message };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function walk(value, visit) {
|
|
28
|
+
if (!value || typeof value !== "object") return;
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
for (const item of value) walk(item, visit);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
visit(value);
|
|
34
|
+
for (const [key, child] of Object.entries(value)) {
|
|
35
|
+
if (key === "loc" || key === "start" || key === "end") continue;
|
|
36
|
+
walk(child, visit);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
export async function analyzePythonFile(file) {
|
|
8
|
+
try {
|
|
9
|
+
const script = path.resolve(new URL("../../../scripts/python_ast_summary.py", import.meta.url).pathname);
|
|
10
|
+
const { stdout } = await execFileAsync("python3", [script, file], { timeout: 5000 });
|
|
11
|
+
return { ok: true, language: "python", ...JSON.parse(stdout) };
|
|
12
|
+
} catch (error) {
|
|
13
|
+
return { ok: false, file, error: error.message };
|
|
14
|
+
}
|
|
15
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
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.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ast-ai-model-router": "bin/ast-ai-model-router.js",
|
|
8
|
+
"model-router": "bin/ast-ai-model-router.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"lib/",
|
|
13
|
+
"scripts/",
|
|
14
|
+
"skills/",
|
|
15
|
+
".claude-plugin/",
|
|
16
|
+
".codex-plugin/",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test",
|
|
22
|
+
"lint": "node --check bin/ast-ai-model-router.js && node --check lib/*.js && node --check lib/parsers/*.js",
|
|
23
|
+
"changeset": "changeset",
|
|
24
|
+
"changeset:version": "changeset version",
|
|
25
|
+
"changeset:publish": "changeset publish",
|
|
26
|
+
"pack:dry-run": "npm publish --dry-run --registry=https://registry.npmjs.org --cache=.npm-cache",
|
|
27
|
+
"publish:local": "npm publish --registry=https://registry.npmjs.org --cache=.npm-cache --provenance --access public"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"llm",
|
|
31
|
+
"token-economics",
|
|
32
|
+
"model-router",
|
|
33
|
+
"claude-code",
|
|
34
|
+
"codex",
|
|
35
|
+
"ast",
|
|
36
|
+
"prompt-cost"
|
|
37
|
+
],
|
|
38
|
+
"author": {
|
|
39
|
+
"name": "Faraazuddin Mohammed",
|
|
40
|
+
"url": "https://github.com/faraa2m"
|
|
41
|
+
},
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/faraa2m/ast-ai-model-router.git"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://github.com/faraa2m/ast-ai-model-router#readme",
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"registry": "https://registry.npmjs.org",
|
|
50
|
+
"access": "public",
|
|
51
|
+
"provenance": true
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@babel/parser": "^7.28.5"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@changesets/cli": "^2.31.0"
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=20"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import ast
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Counter(ast.NodeVisitor):
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self.functions = 0
|
|
10
|
+
self.classes = 0
|
|
11
|
+
self.branches = 0
|
|
12
|
+
self.imports = 0
|
|
13
|
+
|
|
14
|
+
def visit_FunctionDef(self, node):
|
|
15
|
+
self.functions += 1
|
|
16
|
+
self.generic_visit(node)
|
|
17
|
+
|
|
18
|
+
def visit_AsyncFunctionDef(self, node):
|
|
19
|
+
self.functions += 1
|
|
20
|
+
self.generic_visit(node)
|
|
21
|
+
|
|
22
|
+
def visit_ClassDef(self, node):
|
|
23
|
+
self.classes += 1
|
|
24
|
+
self.generic_visit(node)
|
|
25
|
+
|
|
26
|
+
def visit_If(self, node):
|
|
27
|
+
self.branches += 1
|
|
28
|
+
self.generic_visit(node)
|
|
29
|
+
|
|
30
|
+
def visit_For(self, node):
|
|
31
|
+
self.branches += 1
|
|
32
|
+
self.generic_visit(node)
|
|
33
|
+
|
|
34
|
+
def visit_AsyncFor(self, node):
|
|
35
|
+
self.branches += 1
|
|
36
|
+
self.generic_visit(node)
|
|
37
|
+
|
|
38
|
+
def visit_While(self, node):
|
|
39
|
+
self.branches += 1
|
|
40
|
+
self.generic_visit(node)
|
|
41
|
+
|
|
42
|
+
def visit_Try(self, node):
|
|
43
|
+
self.branches += 1
|
|
44
|
+
self.generic_visit(node)
|
|
45
|
+
|
|
46
|
+
def visit_Import(self, node):
|
|
47
|
+
self.imports += len(node.names)
|
|
48
|
+
|
|
49
|
+
def visit_ImportFrom(self, node):
|
|
50
|
+
self.imports += len(node.names)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main():
|
|
54
|
+
with open(sys.argv[1], "r", encoding="utf-8") as handle:
|
|
55
|
+
tree = ast.parse(handle.read(), filename=sys.argv[1])
|
|
56
|
+
counter = Counter()
|
|
57
|
+
counter.visit(tree)
|
|
58
|
+
print(json.dumps(counter.__dict__))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
main()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
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.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Model Router
|
|
7
|
+
|
|
8
|
+
Use this skill when the user wants cost-aware model selection for a coding task.
|
|
9
|
+
|
|
10
|
+
## Workflow
|
|
11
|
+
|
|
12
|
+
1. Run an analysis command from the project root:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
ast-ai-model-router analyze --agent codex --task "<task>"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
or:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
ast-ai-model-router analyze --agent claude --task "<task>"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
2. If the user wants execution, launch through the wrapper:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
ast-ai-model-router run codex --task "<task>" -- <codex args>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
ast-ai-model-router run claude --task "<task>" -- <claude args>
|
|
32
|
+
```
|
|
33
|
+
|
|
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.
|
|
35
|
+
|
|
36
|
+
## Notes
|
|
37
|
+
|
|
38
|
+
- Codex model names are discovered dynamically from `codex debug models`.
|
|
39
|
+
- Claude model names use aliases: `haiku`, `sonnet`, `opus`, and `opusplan`.
|
|
40
|
+
- The router analyzes JavaScript/TypeScript with Babel ASTs and Python with stdlib `ast`.
|