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.
@@ -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 (day-1 support)
163
+ ### Hermes
164
164
 
165
165
  Hermes speaks the OpenAI-compatible wire, so it connects to the router with no
166
- code change just a custom provider pointing at the router's URL. Add a named
167
- provider to `~/.hermes/config.yaml`:
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)
@@ -0,0 +1,5 @@
1
+ name: auto-model-router-provider
2
+ kind: model-provider
3
+ version: 1.0.0
4
+ description: auto-model-router — per-turn cost/complexity-aware model routing
5
+ author: drewappling
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.1.0",
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
@@ -27,6 +27,7 @@ const BOOLEAN_FLAGS: Record<string, true> = {
27
27
  };
28
28
 
29
29
  const COMMANDS: Record<string, true> = {
30
+ serve: true,
30
31
  stats: true,
31
32
  models: true,
32
33
  explain: true,
@@ -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
- Commands:
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
- Command options:
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 }}