auto-model-router 0.1.0 → 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/.github/workflows/release.yml +54 -0
- package/README.md +15 -5
- package/hermes-plugin/__init__.py +81 -0
- package/hermes-plugin/plugin.yaml +5 -0
- package/package.json +3 -2
- package/src/cli/args.ts +1 -0
- package/src/cli/serve.ts +67 -0
- package/src/index.ts +17 -7
- package/.github/workflows/publish.yml +0 -40
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
# Release flow: push a version tag (v0.2.0) to publish to npm AND create a
|
|
4
|
+
# visible GitHub Release. Bump package.json, commit, tag vX.Y.Z, push. The tag
|
|
5
|
+
# triggers both the npm publish and the GitHub Release, so every update is
|
|
6
|
+
# visible. If the version is already on npm (e.g. tagging an existing release),
|
|
7
|
+
# the publish step is skipped and only the GitHub Release is created.
|
|
8
|
+
on:
|
|
9
|
+
push:
|
|
10
|
+
tags:
|
|
11
|
+
- 'v*'
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
publish:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
permissions:
|
|
17
|
+
contents: write
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- uses: actions/setup-node@v4
|
|
22
|
+
with:
|
|
23
|
+
node-version: 20
|
|
24
|
+
registry-url: https://registry.npmjs.org
|
|
25
|
+
|
|
26
|
+
- name: Install dependencies
|
|
27
|
+
run: npm install
|
|
28
|
+
|
|
29
|
+
- name: Check if version already published
|
|
30
|
+
id: version
|
|
31
|
+
run: |
|
|
32
|
+
LOCAL=$(node -p "require('./package.json').version")
|
|
33
|
+
PUBLISHED=$(npm view auto-model-router version 2>/dev/null || echo "0.0.0")
|
|
34
|
+
echo "local=$LOCAL published=$PUBLISHED"
|
|
35
|
+
if [ "$LOCAL" != "$PUBLISHED" ]; then
|
|
36
|
+
echo "changed=true" >> "$GITHUB_OUTPUT"
|
|
37
|
+
else
|
|
38
|
+
echo "changed=false" >> "$GITHUB_OUTPUT"
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
- name: Publish to npm
|
|
42
|
+
if: steps.version.outputs.changed == 'true'
|
|
43
|
+
run: npm publish
|
|
44
|
+
env:
|
|
45
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
46
|
+
|
|
47
|
+
- name: Create GitHub Release
|
|
48
|
+
uses: softprops/action-gh-release@v2
|
|
49
|
+
with:
|
|
50
|
+
generate_release_notes: true
|
|
51
|
+
name: auto-model-router ${{ github.ref_name }}
|
|
52
|
+
body: |
|
|
53
|
+
Published to npm as [`auto-model-router@${{ github.ref_name }}`](https://www.npmjs.com/package/auto-model-router).
|
|
54
|
+
Install with `pi install npm:auto-model-router`.
|
package/README.md
CHANGED
|
@@ -160,13 +160,20 @@ To publish to npm (which auto-indexes on pi.dev/packages):
|
|
|
160
160
|
npm publish
|
|
161
161
|
```
|
|
162
162
|
|
|
163
|
-
### Hermes
|
|
163
|
+
### Hermes
|
|
164
164
|
|
|
165
165
|
Hermes speaks the OpenAI-compatible wire, so it connects to the router with no
|
|
166
|
-
code change
|
|
167
|
-
|
|
166
|
+
code change. Two ways to run the router for Hermes:
|
|
167
|
+
|
|
168
|
+
**Standalone server (recommended for Hermes):** run the router as its own
|
|
169
|
+
process on a fixed port, then point Hermes at it:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
auto-model-router serve --port 8788
|
|
173
|
+
```
|
|
168
174
|
|
|
169
175
|
```yaml
|
|
176
|
+
# ~/.hermes/config.yaml
|
|
170
177
|
providers:
|
|
171
178
|
auto-model-router:
|
|
172
179
|
base_url: http://127.0.0.1:8788/v1
|
|
@@ -174,6 +181,11 @@ providers:
|
|
|
174
181
|
default_model: auto
|
|
175
182
|
```
|
|
176
183
|
|
|
184
|
+
**Hermes plugin (native):** copy `hermes-plugin/` to
|
|
185
|
+
`$HERMES_HOME/plugins/model-providers/auto-model-router/` and restart Hermes.
|
|
186
|
+
The plugin spawns the router as a subprocess on load and registers the provider
|
|
187
|
+
profile, so Hermes routes each turn through the router automatically.
|
|
188
|
+
|
|
177
189
|
The router serves `GET /v1/models` (returning the `auto`, `auto-cheap`,
|
|
178
190
|
`auto-max` profiles) and `POST /v1/chat/completions`, which Hermes's custom
|
|
179
191
|
endpoint discovery verifies. Select `auto-model-router/auto` as the model and
|
|
@@ -183,8 +195,6 @@ its own OpenRouter key.
|
|
|
183
195
|
|
|
184
196
|
### The OpenRouter key
|
|
185
197
|
|
|
186
|
-
### The OpenRouter key
|
|
187
|
-
|
|
188
198
|
There should be exactly one OpenRouter key on the machine, and omp already owns
|
|
189
199
|
a credential store. Resolution order:
|
|
190
200
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Hermes provider plugin for auto-model-router.
|
|
2
|
+
|
|
3
|
+
Requires auto-model-router installed globally via npm so its `serve` binary is
|
|
4
|
+
on PATH:
|
|
5
|
+
|
|
6
|
+
npm install -g auto-model-router
|
|
7
|
+
|
|
8
|
+
The plugin spawns `auto-model-router serve` as a subprocess on a fixed port and
|
|
9
|
+
registers a ProviderProfile pointing at it, so Hermes routes each turn through
|
|
10
|
+
the router's per-turn cost/complexity logic. The router is a Bun process; this
|
|
11
|
+
Python plugin manages it as a child process (Hermes plugins may spawn
|
|
12
|
+
subprocesses).
|
|
13
|
+
|
|
14
|
+
Install by copying this directory to
|
|
15
|
+
``$HERMES_HOME/plugins/model-providers/auto-model-router/`` (or symlinking it),
|
|
16
|
+
then restart Hermes. Override the port with ``AUTO_MODEL_ROUTER_PORT``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import time
|
|
25
|
+
import urllib.request
|
|
26
|
+
|
|
27
|
+
from providers import register_provider
|
|
28
|
+
from providers.base import ProviderProfile
|
|
29
|
+
|
|
30
|
+
# Fixed port the standalone router binds. Hermes points at this URL.
|
|
31
|
+
PORT = int(os.environ.get("AUTO_MODEL_ROUTER_PORT", "8788"))
|
|
32
|
+
BASE_URL = f"http://127.0.0.1:{PORT}/v1"
|
|
33
|
+
|
|
34
|
+
# The router binary, provided by `npm install -g auto-model-router`.
|
|
35
|
+
BIN = "auto-model-router"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _router_running() -> bool:
|
|
39
|
+
try:
|
|
40
|
+
with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/health", timeout=1) as resp:
|
|
41
|
+
return resp.status == 200
|
|
42
|
+
except Exception:
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _spawn_router() -> None:
|
|
47
|
+
"""Start the standalone router if it is not already running."""
|
|
48
|
+
if _router_running():
|
|
49
|
+
return
|
|
50
|
+
if shutil.which(BIN) is None:
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
"auto-model-router is not installed or not on PATH. "
|
|
53
|
+
"Run `npm install -g auto-model-router` first."
|
|
54
|
+
)
|
|
55
|
+
subprocess.Popen(
|
|
56
|
+
[BIN, "serve", "--port", str(PORT)],
|
|
57
|
+
stdout=subprocess.DEVNULL,
|
|
58
|
+
stderr=subprocess.DEVNULL,
|
|
59
|
+
start_new_session=True,
|
|
60
|
+
)
|
|
61
|
+
# Wait for it to come up (bounded).
|
|
62
|
+
for _ in range(50):
|
|
63
|
+
if _router_running():
|
|
64
|
+
return
|
|
65
|
+
time.sleep(0.2)
|
|
66
|
+
raise RuntimeError(f"auto-model-router did not come up on port {PORT}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_spawn_router()
|
|
70
|
+
|
|
71
|
+
profile = ProviderProfile(
|
|
72
|
+
name="auto-model-router",
|
|
73
|
+
api_mode="chat_completions",
|
|
74
|
+
base_url=BASE_URL,
|
|
75
|
+
auth_type="api_key",
|
|
76
|
+
env_vars=("AUTO_MODEL_ROUTER_API_KEY",),
|
|
77
|
+
fallback_models=("auto", "auto-cheap", "auto-max"),
|
|
78
|
+
display_name="auto-model-router",
|
|
79
|
+
description="Per-turn cost/complexity-aware model routing",
|
|
80
|
+
)
|
|
81
|
+
register_provider(profile)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auto-model-router",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
|
|
6
6
|
"type": "module",
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"typecheck": "tsc --noEmit",
|
|
12
12
|
"test": "bun test",
|
|
13
|
-
"smoke": "bun run tools/smoke.ts"
|
|
13
|
+
"smoke": "bun run tools/smoke.ts",
|
|
14
|
+
"release": "npm version $1 && git push --follow-tags"
|
|
14
15
|
},
|
|
15
16
|
"dependencies": {
|
|
16
17
|
"yaml": "^2.7.0",
|
package/src/cli/args.ts
CHANGED
package/src/cli/serve.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone `serve` command.
|
|
3
|
+
*
|
|
4
|
+
* Runs the router as its own process on a fixed port, independent of any omp
|
|
5
|
+
* session. This is how non-Bun harnesses (Hermes, Claude, any OpenAI-compatible
|
|
6
|
+
* client) connect: they point at `http://127.0.0.1:<port>/v1` and the router
|
|
7
|
+
* routes each turn. The embedded omp extension is the zero-management path;
|
|
8
|
+
* `serve` is the shared, always-on path for other harnesses.
|
|
9
|
+
*
|
|
10
|
+
* The core (`startServer`) is identical in both modes; only the process
|
|
11
|
+
* ownership and port differ.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { apiKeySource, loadConfig } from "../config/load.ts";
|
|
15
|
+
import type { RouterConfig } from "../config/types.ts";
|
|
16
|
+
import { startServer } from "../server/http.ts";
|
|
17
|
+
import { createLogger } from "../util/log.ts";
|
|
18
|
+
import { configOpts, flagInt, flagString, type CliArgs } from "./args.ts";
|
|
19
|
+
|
|
20
|
+
const LOG_LEVELS = ["silent", "error", "warn", "info", "debug"] as const;
|
|
21
|
+
|
|
22
|
+
export async function serveCommand(args: CliArgs): Promise<void> {
|
|
23
|
+
// Sparse by design: only flags actually given override; deep merge fills the rest.
|
|
24
|
+
const serverOverride: { host?: string; port?: number } = {};
|
|
25
|
+
const port = flagInt(args, "port");
|
|
26
|
+
if (port !== undefined) serverOverride.port = port;
|
|
27
|
+
const host = flagString(args, "host");
|
|
28
|
+
if (host !== undefined) serverOverride.host = host;
|
|
29
|
+
|
|
30
|
+
const overrides: { server?: typeof serverOverride; logLevel?: string } = {};
|
|
31
|
+
if (serverOverride.host !== undefined || serverOverride.port !== undefined) overrides.server = serverOverride;
|
|
32
|
+
const logLevel = flagString(args, "log");
|
|
33
|
+
if (logLevel !== undefined) {
|
|
34
|
+
if (!(LOG_LEVELS as readonly string[]).includes(logLevel)) {
|
|
35
|
+
throw new Error(`--log must be one of ${LOG_LEVELS.join(", ")}, got "${logLevel}"`);
|
|
36
|
+
}
|
|
37
|
+
overrides.logLevel = logLevel;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const cfg = loadConfig(configOpts(args, overrides as Partial<RouterConfig>));
|
|
41
|
+
const log = createLogger(cfg.logLevel);
|
|
42
|
+
const { server, stop } = startServer(cfg);
|
|
43
|
+
|
|
44
|
+
// Report the port the OS actually bound: `port: 0` asks for an ephemeral one.
|
|
45
|
+
const addr = `http://${cfg.server.host}:${server.port}`;
|
|
46
|
+
console.log(`auto-model-router listening on ${addr} (OpenAI-compatible endpoint at ${addr}/v1)`);
|
|
47
|
+
|
|
48
|
+
// State the credential provenance up front. Silence here is how you end up
|
|
49
|
+
// debugging 401s that were really "the key was never found".
|
|
50
|
+
const credential = apiKeySource(cfg);
|
|
51
|
+
if (credential.source === "none") {
|
|
52
|
+
console.warn(`WARNING: no OpenRouter key resolved - ${credential.detail}`);
|
|
53
|
+
console.warn(" the catalog will still load, but every completion will fail at dispatch");
|
|
54
|
+
} else {
|
|
55
|
+
console.log(`OpenRouter key: ${credential.detail}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let stopping = false;
|
|
59
|
+
const shutdown = (signal: string): void => {
|
|
60
|
+
if (stopping) return;
|
|
61
|
+
stopping = true;
|
|
62
|
+
log.info("shutting down", { signal });
|
|
63
|
+
void stop().then(() => process.exit(0));
|
|
64
|
+
};
|
|
65
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
66
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
67
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,13 +12,14 @@ import { parseArgv } from "./cli/args.ts";
|
|
|
12
12
|
import { configCommand } from "./cli/config-cmd.ts";
|
|
13
13
|
import { explainCommand } from "./cli/explain.ts";
|
|
14
14
|
import { modelsCommand } from "./cli/models.ts";
|
|
15
|
+
import { serveCommand } from "./cli/serve.ts";
|
|
15
16
|
import { statsCommand } from "./cli/stats.ts";
|
|
16
17
|
|
|
17
18
|
const USAGE = `auto-model-router - local cost/complexity-aware model router for omp, backed by OpenRouter
|
|
18
19
|
|
|
19
20
|
Usage: auto-model-router <command> [options]
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
serve Run the router as a standalone process (for non-omp harnesses)
|
|
22
23
|
stats Show routed spend, per-model share, and escalation rates
|
|
23
24
|
models Show what each complexity tier would consider, and why
|
|
24
25
|
explain Route a saved request without dispatching it, and explain the decision
|
|
@@ -30,7 +31,8 @@ Global options:
|
|
|
30
31
|
--help, -h Show help
|
|
31
32
|
--version Show version
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
serve --port <n> --host <addr> --log <level>
|
|
35
|
+
stats --days <n> --json
|
|
34
36
|
stats --days <n> --json
|
|
35
37
|
models --tier <trivial|simple|moderate|hard> --limit <n> --json
|
|
36
38
|
explain --file <request.json> --json (reads stdin when --file is absent)
|
|
@@ -58,12 +60,20 @@ async function main(): Promise<number> {
|
|
|
58
60
|
process.stdout.write(USAGE);
|
|
59
61
|
return args.flags.has("help") ? 0 : 1;
|
|
60
62
|
}
|
|
61
|
-
if (args.flags.has("help")) {
|
|
62
|
-
process.stdout.write(USAGE);
|
|
63
|
-
return 0;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
63
|
switch (args.command) {
|
|
64
|
+
case "serve":
|
|
65
|
+
// Resolves once listening; the server itself keeps the loop alive.
|
|
66
|
+
await serveCommand(args);
|
|
67
|
+
return 0;
|
|
68
|
+
case "stats":
|
|
69
|
+
await statsCommand(args);
|
|
70
|
+
return 0;
|
|
71
|
+
case "models":
|
|
72
|
+
|
|
73
|
+
case "serve":
|
|
74
|
+
// Resolves once listening; the server itself keeps the loop alive.
|
|
75
|
+
await serveCommand(args);
|
|
76
|
+
return 0;
|
|
67
77
|
case "stats":
|
|
68
78
|
await statsCommand(args);
|
|
69
79
|
return 0;
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
name: publish
|
|
2
|
-
|
|
3
|
-
# Publish to npm when a change lands on main AND the package version actually
|
|
4
|
-
# changed. Doc-only commits (no version bump) are skipped, so npm stays in
|
|
5
|
-
# sync with real releases without publishing noise on every push.
|
|
6
|
-
on:
|
|
7
|
-
push:
|
|
8
|
-
branches: [main]
|
|
9
|
-
|
|
10
|
-
jobs:
|
|
11
|
-
publish:
|
|
12
|
-
runs-on: ubuntu-latest
|
|
13
|
-
steps:
|
|
14
|
-
- uses: actions/checkout@v4
|
|
15
|
-
|
|
16
|
-
- uses: actions/setup-node@v4
|
|
17
|
-
with:
|
|
18
|
-
node-version: 20
|
|
19
|
-
registry-url: https://registry.npmjs.org
|
|
20
|
-
|
|
21
|
-
- name: Install dependencies
|
|
22
|
-
run: npm install
|
|
23
|
-
|
|
24
|
-
- name: Check if version changed
|
|
25
|
-
id: version
|
|
26
|
-
run: |
|
|
27
|
-
LOCAL=$(node -p "require('./package.json').version")
|
|
28
|
-
PUBLISHED=$(npm view auto-model-router version 2>/dev/null || echo "0.0.0")
|
|
29
|
-
echo "local=$LOCAL published=$PUBLISHED"
|
|
30
|
-
if [ "$LOCAL" != "$PUBLISHED" ]; then
|
|
31
|
-
echo "changed=true" >> "$GITHUB_OUTPUT"
|
|
32
|
-
else
|
|
33
|
-
echo "changed=false" >> "$GITHUB_OUTPUT"
|
|
34
|
-
fi
|
|
35
|
-
|
|
36
|
-
- name: Publish to npm
|
|
37
|
-
if: steps.version.outputs.changed == 'true'
|
|
38
|
-
run: npm publish
|
|
39
|
-
env:
|
|
40
|
-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|