auto-model-router 0.1.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.
Files changed (83) hide show
  1. package/.env.example +24 -0
  2. package/.github/workflows/publish.yml +40 -0
  3. package/.omp-plugin/marketplace.json +30 -0
  4. package/LICENSE +21 -0
  5. package/README.md +639 -0
  6. package/bun.lock +32 -0
  7. package/docs/claude-anthropic-wire.md +116 -0
  8. package/omp-extension/configure-logic.ts +128 -0
  9. package/omp-extension/embed-logic.ts +141 -0
  10. package/omp-extension/router-configure.ts +111 -0
  11. package/omp-extension/router-embed.ts +118 -0
  12. package/omp-extension/router-toast.ts +130 -0
  13. package/omp-extension/toast-logic.ts +136 -0
  14. package/package.json +56 -0
  15. package/src/catalog/openrouter-catalog.ts +428 -0
  16. package/src/catalog/types.ts +104 -0
  17. package/src/cli/args.ts +105 -0
  18. package/src/cli/config-cmd.ts +362 -0
  19. package/src/cli/config-wizard.ts +636 -0
  20. package/src/cli/explain.ts +167 -0
  21. package/src/cli/models.ts +240 -0
  22. package/src/cli/stats.ts +69 -0
  23. package/src/config/defaults.ts +136 -0
  24. package/src/config/load.ts +143 -0
  25. package/src/config/omp-credentials.ts +124 -0
  26. package/src/config/schema.ts +161 -0
  27. package/src/config/types.ts +244 -0
  28. package/src/cost/blended.ts +80 -0
  29. package/src/cost/forecast.ts +129 -0
  30. package/src/cost/ledger.ts +291 -0
  31. package/src/cost/types.ts +148 -0
  32. package/src/index.ts +93 -0
  33. package/src/router/cache-control.ts +66 -0
  34. package/src/router/candidates.ts +246 -0
  35. package/src/router/classify.ts +329 -0
  36. package/src/router/escalate.ts +264 -0
  37. package/src/router/features.ts +225 -0
  38. package/src/router/index.ts +99 -0
  39. package/src/router/select.ts +365 -0
  40. package/src/router/state.ts +118 -0
  41. package/src/router/tier-plan.ts +151 -0
  42. package/src/router/types.ts +222 -0
  43. package/src/server/http.ts +343 -0
  44. package/src/server/turn.ts +393 -0
  45. package/src/tokens/estimate.ts +74 -0
  46. package/src/upstream/openrouter.ts +221 -0
  47. package/src/upstream/sse-parse.ts +208 -0
  48. package/src/upstream/types.ts +75 -0
  49. package/src/util/hash.ts +0 -0
  50. package/src/util/log.ts +53 -0
  51. package/src/util/sqlite.ts +140 -0
  52. package/src/util/sse.ts +23 -0
  53. package/src/wire/openai/errors.ts +48 -0
  54. package/src/wire/openai/models.ts +37 -0
  55. package/src/wire/openai/request.ts +279 -0
  56. package/src/wire/openai/sink.ts +213 -0
  57. package/src/wire/types.ts +156 -0
  58. package/test/catalog.test.ts +319 -0
  59. package/test/classify.test.ts +269 -0
  60. package/test/config-wizard.test.ts +482 -0
  61. package/test/config.test.ts +121 -0
  62. package/test/configure-logic.test.ts +151 -0
  63. package/test/cost.test.ts +137 -0
  64. package/test/embed-logic.test.ts +107 -0
  65. package/test/escalate.test.ts +223 -0
  66. package/test/failover.test.ts +494 -0
  67. package/test/features.test.ts +228 -0
  68. package/test/fixtures/openrouter-models.json +15340 -0
  69. package/test/models-yml.test.ts +186 -0
  70. package/test/omp-credentials.test.ts +185 -0
  71. package/test/select.test.ts +538 -0
  72. package/test/sse-parse.test.ts +142 -0
  73. package/test/tier-plan.test.ts +302 -0
  74. package/test/toast-logic.test.ts +160 -0
  75. package/test/tokens.test.ts +160 -0
  76. package/test/trust-attribution.test.ts +175 -0
  77. package/test/turn.test.ts +498 -0
  78. package/test/wire-request.test.ts +297 -0
  79. package/test/wire-sink.test.ts +179 -0
  80. package/tools/install.ts +140 -0
  81. package/tools/mock-openrouter.ts +269 -0
  82. package/tools/smoke.ts +326 -0
  83. package/tsconfig.json +23 -0
@@ -0,0 +1,116 @@
1
+ # Claude Code support: Anthropic wire front end
2
+
3
+ Claude Code speaks the **Anthropic Messages API** (`POST /v1/messages`, SSE
4
+ with `message_start`/`content_block_delta`/`message_delta`/`message_stop`).
5
+ The router currently serves only the OpenAI wire (`/v1/chat/completions`), so
6
+ Claude cannot use it yet. This is the plan to add an `anthropic-messages`
7
+ front end so Claude Code gets the router's per-turn cost/complexity routing.
8
+
9
+ The router core is already wire-agnostic (`src/wire/types.ts` defines
10
+ `NormRequest`/`UpstreamChunk`/`ResponseSink`; `WireProtocol` already lists
11
+ `"pi-native"` as a future front end). Adding Anthropic is a bounded new front
12
+ end, not a core change.
13
+
14
+ ## What Claude Code sends
15
+
16
+ - `POST /v1/messages` with `{ model, max_tokens, system, messages, tools, stream, thinking }`.
17
+ - Messages: `user`/`assistant` roles with `content` as a string or a
18
+ content-block array (`text`, `image`, `tool_use`, `tool_result`).
19
+ - Tools: `{ name, description, input_schema }`.
20
+ - SSE response: `message_start`, `content_block_start`, `content_block_delta`
21
+ (`text_delta`, `input_json_delta`, `thinking_delta`), `content_block_stop`,
22
+ `message_delta`, `message_stop`.
23
+
24
+ ## Work items
25
+
26
+ ### 1. `src/wire/anthropic/request.ts` — parse `/v1/messages` → `NormRequest`
27
+
28
+ Mirror `src/wire/openai/request.ts`:
29
+
30
+ - `parseMessagesRequest(body, headers)` → `NormRequest`.
31
+ - Map Anthropic roles → `NormRole`: `user`/`assistant`/`system` → the router's
32
+ `system`/`user`/`assistant`; `tool_result`/`tool_use` content blocks → the
33
+ router's `tool` role with `toolCallId`/`name`/`args`.
34
+ - Flatten `content` blocks (text + image + tool blocks) into `NormMessage`
35
+ for feature extraction.
36
+ - Parse `tools[].input_schema` → `NormTool` (the router sizes tools for
37
+ prompt-cost accounting).
38
+ - Parse `thinking` → `ReasoningLevel`.
39
+ - `conversationKeyOf` from the same hash util.
40
+
41
+ ### 2. `src/wire/anthropic/sink.ts` — render `UpstreamChunk` → Anthropic SSE
42
+
43
+ Mirror `src/wire/openai/sink.ts`:
44
+
45
+ - `createAnthropicStreamingSink(requestedModel)` → `ResponseSink` that emits
46
+ Anthropic SSE frames from `StreamEvent`s:
47
+ - `start` → `message_start` + `content_block_start` (text or tool_use).
48
+ - `text` → `content_block_delta` with `text_delta`.
49
+ - `reasoning` → `content_block_delta` with `thinking_delta`.
50
+ - `tool_call` → `content_block_delta` with `input_json_delta` (partial JSON).
51
+ - `finish` → `content_block_stop` + `message_delta` (stop_reason) +
52
+ `message_stop`.
53
+ - `usage` → `message_delta` with `usage` (input_tokens/output_tokens).
54
+ - `createAnthropicBufferedSink` for non-streaming clients (re-buffer the SSE).
55
+ - Reuse `encodeSseData` from `src/util/sse.ts`.
56
+
57
+ ### 3. `src/wire/anthropic/errors.ts` — map `WireError` → Anthropic error envelope
58
+
59
+ Anthropic errors are `{ type: "error", error: { type, message } }` with
60
+ `type` ∈ `invalid_request_error` | `authentication_error` | `rate_limit_error`
61
+ | `api_error`. Map the router's `WireError.status`/`code` accordingly.
62
+
63
+ ### 4. `src/server/http.ts` — add `POST /v1/messages` route
64
+
65
+ - In the `fetch` handler, add `if (req.method === "POST" && url.pathname === "/v1/messages")`.
66
+ - Parse with `parseMessagesRequest`, build the Anthropic sink, call the same
67
+ `runTurn(normReq, sink, turnDeps, req.signal)` core.
68
+ - Reuse the existing Host validation, auth check, and concurrency cap.
69
+
70
+ ### 5. Upstream dispatch — the key decision
71
+
72
+ The router dispatches to OpenRouter. OpenRouter accepts **both** Anthropic and
73
+ OpenAI formats. Two options:
74
+
75
+ - **A (recommended): dispatch to OpenRouter in Anthropic format** for
76
+ Claude-originated turns. Add an `anthropic-messages` renderer to
77
+ `src/upstream/openrouter.ts` that builds the `/v1/messages` body from
78
+ `NormRequest` + `UpstreamMutations`. This preserves Anthropic-native
79
+ features (thinking blocks, tool_use framing) end to end.
80
+ - **B: dispatch in OpenAI format** and let OpenRouter translate. Simpler, but
81
+ loses Anthropic-native thinking/tool framing and may degrade Claude's
82
+ tool-call fidelity.
83
+
84
+ Recommend **A** — the router's `renderUpstreamBody` is already per-wire, so
85
+ adding an Anthropic variant is consistent.
86
+
87
+ ### 6. `/v1/models` for Claude
88
+
89
+ Claude Code may probe `/v1/models`. The router already returns the profiles in
90
+ OpenAI shape. If Claude needs Anthropic shape, add a small renderer; otherwise
91
+ reuse the existing list (verify during implementation).
92
+
93
+ ### 7. Tests
94
+
95
+ - `test/wire-anthropic-request.test.ts` — parse `/v1/messages` bodies
96
+ (roles, content blocks, tools, thinking) into `NormRequest`.
97
+ - `test/wire-anthropic-sink.test.ts` — render `StreamEvent`s into Anthropic
98
+ SSE frames (message_start, deltas, tool_use, finish, usage).
99
+ - `test/wire-anthropic-errors.test.ts` — `WireError` → Anthropic envelope.
100
+ - Extend `tools/smoke.ts` with an Anthropic-wire turn against the mock
101
+ OpenRouter.
102
+
103
+ ## Day-1 support
104
+
105
+ - **Hermes**: already works (OpenAI wire) — config snippet added to README.
106
+ - **Claude**: requires this front end. Not day-1; a deliberate follow-up.
107
+
108
+ ## Open questions
109
+
110
+ 1. **Upstream dispatch format** — confirm A (Anthropic-native) vs B (OpenAI +
111
+ OpenRouter translation). Recommend A.
112
+ 2. **`/v1/models` shape** — does Claude Code need Anthropic-shaped model
113
+ metadata, or is the OpenAI list sufficient?
114
+ 3. **Thinking blocks** — how faithfully to round-trip Claude's `thinking`
115
+ through the router's `reasoning` `StreamEvent` (the router already models
116
+ `reasoning` deltas).
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Pure configuration-driving logic for the `/router configure` slash command.
3
+ *
4
+ * Reuses the router's existing wizard field definitions and validation
5
+ * (`src/cli/config-wizard.ts`) so the in-omp UI edits exactly the same set of
6
+ * settings as `auto-model-router config`, and persists them through the same
7
+ * validated merge (`writeRouterConfig`). The only thing this module adds is a
8
+ * UI-adapter seam so the command can drive omp's native dialogs (`ctx.ui`)
9
+ * while remaining unit-testable with a fake UI.
10
+ */
11
+
12
+ import type { FieldSpec, SectionSpec } from "../src/cli/config-wizard.ts";
13
+ import { CLEAR_TOKEN, formatValue, validateField } from "../src/cli/config-wizard.ts";
14
+ import type { RouterConfig } from "../src/config/types.ts";
15
+
16
+ export interface ConfigUi {
17
+ /** Show a selector, return the chosen option label, or undefined on cancel. */
18
+ select(title: string, options: string[], selected?: number): Promise<string | undefined>;
19
+ /** Show a text input with a placeholder, or undefined on cancel. */
20
+ input(title: string, placeholder?: string, initial?: string): Promise<string | undefined>;
21
+ /** Yes/no confirmation. */
22
+ confirm(title: string, message: string): Promise<boolean>;
23
+ /** Surface a status/result line. */
24
+ notify(text: string, level?: "info" | "warn" | "error"): void;
25
+ }
26
+
27
+
28
+ /**
29
+ * Prompts for one field, returning the parsed value or null when the user kept
30
+ * the current value. An empty answer keeps the current; CLEAR_TOKEN clears an
31
+ * optional field. Returns `undefined` when the user cancelled the dialog.
32
+ */
33
+ export async function promptField(
34
+ ui: ConfigUi,
35
+ field: FieldSpec,
36
+ current: unknown,
37
+ ): Promise<{ value: unknown; changed: boolean } | undefined> {
38
+ const label = field.hint !== undefined ? `${field.label} (${field.hint})` : field.label;
39
+
40
+ if (field.kind === "boolean") {
41
+ const chosen = await ui.select(label, ["true", "false"], current === true ? 0 : 1);
42
+ if (chosen === undefined) return undefined;
43
+ const value = chosen === "true";
44
+ return { value, changed: value !== current };
45
+ }
46
+
47
+ if (field.kind === "enum") {
48
+ const options = field.options ?? [];
49
+ const idx = options.indexOf(String(current));
50
+ const chosen = await ui.select(label, [...options], idx >= 0 ? idx : 0);
51
+ if (chosen === undefined) return undefined;
52
+ return { value: chosen, changed: chosen !== current };
53
+ }
54
+
55
+ // string | number | stringArray: free-text input.
56
+ const placeholder = formatValue(current);
57
+ const answer = await ui.input(label, placeholder, "");
58
+ if (answer === undefined) return undefined;
59
+ if (answer.trim() === "") return { value: current, changed: false }; // keep
60
+ if (answer.trim() === CLEAR_TOKEN) {
61
+ if (field.optional !== true) return { value: current, changed: false };
62
+ return { value: null, changed: true };
63
+ }
64
+
65
+ const result = validateField(field, answer);
66
+ if (!result.ok) {
67
+ ui.notify(`invalid: ${result.error}`, "warn");
68
+ return { value: current, changed: false };
69
+ }
70
+ return { value: result.value, changed: result.value !== current };
71
+ }
72
+
73
+ /**
74
+ * Walks a section's fields, prompting the user for each and collecting edits
75
+ * into `answers`. Returns true if any field changed, false if the walk was
76
+ * cancelled (a dialog returned undefined).
77
+ */
78
+ export async function walkSection(
79
+ ui: ConfigUi,
80
+ section: SectionSpec,
81
+ cfg: RouterConfig,
82
+ answers: Record<string, unknown>,
83
+ ): Promise<boolean> {
84
+ let any = false;
85
+ for (const field of section.fields) {
86
+ const current = field.path in answers ? answers[field.path] : getPathValue(cfg, field.path);
87
+ const result = await promptField(ui, field, current);
88
+ if (result === undefined) return false;
89
+ if (result.changed) {
90
+ answers[field.path] = result.value;
91
+ any = true;
92
+ }
93
+ }
94
+ return any;
95
+ }
96
+
97
+ /** Reads a dotted path from the config, with `undefined` for missing keys. */
98
+ function getPathValue(obj: unknown, path: string): unknown {
99
+ let cur: unknown = obj;
100
+ for (const part of path.split(".")) {
101
+ if (typeof cur !== "object" || cur === null) return undefined;
102
+ cur = (cur as Record<string, unknown>)[part];
103
+ }
104
+ return cur;
105
+ }
106
+
107
+ /** Section titles in menu order, mirroring `omp config`'s menu. */
108
+ export function sectionTitles(sections: readonly SectionSpec[]): string[] {
109
+ return sections.map((s) => s.title);
110
+ }
111
+
112
+ /**
113
+ * Edits one virtual profile (a whole array element) through the UI. Returns
114
+ * the updated profile record, or null when cancelled.
115
+ */
116
+ export async function editProfile(
117
+ ui: ConfigUi,
118
+ profile: Record<string, unknown>,
119
+ fields: readonly FieldSpec[],
120
+ ): Promise<Record<string, unknown> | null> {
121
+ const next: Record<string, unknown> = { ...profile };
122
+ for (const field of fields) {
123
+ const result = await promptField(ui, field, next[field.path]);
124
+ if (result === undefined) return null;
125
+ if (result.changed) next[field.path] = result.value;
126
+ }
127
+ return next;
128
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Pure embed configuration logic, extracted from the omp extension so it can
3
+ * be unit-tested without omp's runtime or a live Bun.serve.
4
+ *
5
+ * The embedded router runs IN the main omp process and binds a free
6
+ * OS-assigned port (`port: 0`). Subagents do NOT bind their own router — they
7
+ * route to the main session's router, whose bound port is published in a
8
+ * single shared file. This avoids the PID-reuse race: subagents are ephemeral
9
+ * worker processes whose PIDs get recycled, so keying a port file by PID means
10
+ * a subagent can read a stale file written by a dead worker that reused its
11
+ * PID. One shared file, written only by the main session, has exactly one
12
+ * authoritative writer.
13
+ */
14
+
15
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+
18
+ /**
19
+ * The provider id registered into omp. Kept stable so a `models.yml` that
20
+ * already pins `baseUrl`/`auth` for the same id is overridden by the
21
+ * extension (extension registration wins at runtime).
22
+ */
23
+ export const EMBED_PROVIDER_ID = "auto-model-router";
24
+
25
+ /**
26
+ * A dummy bearer the extension registers so omp treats the provider as
27
+ * authenticated. The router's `server.apiKey` is unset by default, so it does
28
+ * not enforce auth; the value only needs to satisfy omp's "has credentials"
29
+ * gate.
30
+ */
31
+ export const EMBED_DUMMY_API_KEY = "embedded";
32
+
33
+ /**
34
+ * Filename (in `$AUTO_MODEL_ROUTER_HOME` / `~/.auto-model-router`) of the shared embed port
35
+ * file. Written only by the main session's router; read by every subagent and
36
+ * the toast. A single writer and single file means there is never a stale
37
+ * per-PID file pointing at a recycled process's dead port.
38
+ */
39
+ export const EMBED_PORT_FILE = "embed.port";
40
+
41
+ export interface EmbedModelSpec {
42
+ id: string;
43
+ name: string;
44
+ contextWindow: number;
45
+ maxTokens: number;
46
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
47
+ }
48
+
49
+ export interface EmbedConfig {
50
+ port: number;
51
+ host: string;
52
+ baseUrl: string;
53
+ models: EmbedModelSpec[];
54
+ harnessId?: string;
55
+ }
56
+
57
+ /**
58
+ * Resolves the desired bind port: an explicit `AUTO_MODEL_ROUTER_PORT` when set and
59
+ * valid, else `0` so Bun assigns a free ephemeral port (the "random port" that
60
+ * lets multiple local omp sessions coexist without colliding). Returning 0
61
+ * means "let the OS pick"; the caller must read the actual port back off the
62
+ * started server.
63
+ */
64
+ export function resolveEmbedPort(envPort: string | undefined): number {
65
+ if (envPort !== undefined && envPort !== "") {
66
+ const port = Number.parseInt(envPort, 10);
67
+ if (Number.isInteger(port) && port >= 0 && port <= 65_535) return port;
68
+ }
69
+ return 0;
70
+ }
71
+
72
+ /**
73
+ * Absolute path of the shared embed port file under a router home directory.
74
+ */
75
+ export function embedPortPath(homeDir: string): string {
76
+ return join(homeDir, EMBED_PORT_FILE);
77
+ }
78
+
79
+ /**
80
+ * Persists the embedded router's actual bound port so subagents and the toast
81
+ * can follow it. Creates the parent directory when absent.
82
+ */
83
+ export function writeEmbedPort(path: string, port: number): void {
84
+ mkdirSync(dirname(path), { recursive: true });
85
+ writeFileSync(path, String(port), "utf8");
86
+ }
87
+
88
+ /**
89
+ * Reads the embedded router's last-known port from the port file, or null when
90
+ * the file is absent/unreadable/malformed.
91
+ */
92
+ export function readEmbedPort(path: string): number | null {
93
+ try {
94
+ const raw = readFileSync(path, "utf8").trim();
95
+ const port = Number.parseInt(raw, 10);
96
+ if (Number.isInteger(port) && port > 0 && port <= 65_535) return port;
97
+ return null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Builds the provider config for `pi.registerProvider(EMBED_PROVIDER_ID, …)`
105
+ * given the shared bound port. `models` are the router's own `profiles`, mapped
106
+ * into omp's provider-model shape (cost is USD per million tokens, same unit
107
+ * `renderProviderBlock` uses for `config --write`). A wildcard listen address
108
+ * maps to loopback, since a wildcard is not a connectable target.
109
+ */
110
+ export function buildProviderConfig(
111
+ port: number,
112
+ cfg: {
113
+ server: { host: string; harnessId?: string };
114
+ profiles: Array<{ id: string; name: string; contextWindow: number; maxTokens: number }>;
115
+ ledger: { fallbackBlend: { inputPerMtok: number; outputPerMtok: number } };
116
+ },
117
+ ): EmbedConfig {
118
+ const host = cfg.server.host === "0.0.0.0" || cfg.server.host === "::" ? "127.0.0.1" : cfg.server.host;
119
+ const round = (v: number): number => Math.round(v * 1e4) / 1e4;
120
+ const input = cfg.ledger.fallbackBlend.inputPerMtok;
121
+ const output = cfg.ledger.fallbackBlend.outputPerMtok;
122
+ const cacheRead = input * 0.1;
123
+ const cacheWrite = input * 1.25;
124
+ const models = cfg.profiles.map((p) => ({
125
+ id: p.id,
126
+ name: p.name,
127
+ contextWindow: p.contextWindow,
128
+ maxTokens: p.maxTokens,
129
+ cost: { input: round(input), output: round(output), cacheRead: round(cacheRead), cacheWrite: round(cacheWrite) },
130
+ }));
131
+ const out: EmbedConfig = {
132
+ port,
133
+ host,
134
+ baseUrl: `http://${host}:${port}/v1`,
135
+ models,
136
+ };
137
+ if (cfg.server.harnessId !== undefined && cfg.server.harnessId !== "") {
138
+ out.harnessId = cfg.server.harnessId;
139
+ }
140
+ return out;
141
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * omp extension: `/router` — edit auto-model-router's settings through
3
+ * omp's native UI dialogs.
4
+ *
5
+ * The command walks the same sections and fields as `auto-model-router config`
6
+ * (reusing `WIZARD_SECTIONS` / `PROFILE_FIELDS` from the router's CLI) but
7
+ * prompts through `ctx.ui` select/input/confirm dialogs instead of stdin.
8
+ * Edits are persisted through the router's own validated merge
9
+ * (`writeRouterConfig`), so the on-disk config.yml is schema-checked and
10
+ * backed up exactly as the CLI wizard does.
11
+ *
12
+ * Install alongside router-embed.ts:
13
+ *
14
+ * # ~/.omp/agent/config.yml
15
+ * extensions:
16
+ * - /path/to/auto-model-router/omp-extension/router-embed.ts
17
+ * - /path/to/auto-model-router/omp-extension/router-configure.ts
18
+ *
19
+ * Run `/router` in an omp session to pick a section, edit its
20
+ * fields, and save.
21
+ */
22
+
23
+ import { loadConfig } from "../src/config/load.ts";
24
+ import { applyAnswers, PROFILE_FIELDS, WIZARD_SECTIONS } from "../src/cli/config-wizard.ts";
25
+ import { writeRouterConfig, routerConfigPath } from "../src/cli/config-cmd.ts";
26
+ import type { RouterConfig } from "../src/config/types.ts";
27
+
28
+ import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
29
+
30
+ import { editProfile, sectionTitles, walkSection, type ConfigUi } from "./configure-logic.ts";
31
+
32
+ export default function (pi: ExtensionAPI): void {
33
+ pi.setLabel("auto-model-router configure");
34
+
35
+ pi.registerCommand("router", {
36
+ description: "Configure auto-model-router settings through the native UI",
37
+ handler: async (_args, ctx) => {
38
+ const ui = ctx.ui;
39
+ const cfg = loadConfig();
40
+ const answers: Record<string, unknown> = {};
41
+
42
+ for (;;) {
43
+ const options = [...sectionTitles(WIZARD_SECTIONS), "Profiles", "Save and exit", "Quit without saving"];
44
+ const chosen = await ui.select("auto-model-router configure", options);
45
+ if (chosen === undefined) return;
46
+ if (chosen === "Quit without saving") return;
47
+ if (chosen === "Save and exit") break;
48
+
49
+ if (chosen === "Profiles") {
50
+ await editProfiles(ui, cfg, answers);
51
+ continue;
52
+ }
53
+
54
+ const section = WIZARD_SECTIONS.find((s) => s.title === chosen);
55
+ if (section === undefined) continue;
56
+ await walkSection(ui, section, cfg, answers);
57
+ }
58
+
59
+ if (Object.keys(answers).length === 0) {
60
+ ui.notify("no changes made", "info");
61
+ return;
62
+ }
63
+
64
+ try {
65
+ const target = routerConfigPath();
66
+ const partial = applyAnswers(answers);
67
+ const backup = writeRouterConfig(target, partial);
68
+ ui.notify(`wrote ${target}${backup ? ` (backup: ${backup})` : ""}`, "info");
69
+ } catch (err) {
70
+ ui.notify(err instanceof Error ? err.message : String(err), "error");
71
+ }
72
+ },
73
+ });
74
+ }
75
+
76
+ /** Edits the profiles array as whole elements, mirroring the CLI wizard. */
77
+ async function editProfiles(
78
+ ui: ConfigUi,
79
+ cfg: RouterConfig,
80
+ answers: Record<string, unknown>,
81
+ ): Promise<void> {
82
+ // Work over the profiles as plain records (the shape the wizard's merge
83
+ // expects), converting at the boundary to/from ProfileConfig.
84
+ const list: Record<string, unknown>[] = cfg.profiles.map((p) => ({ ...p }));
85
+ const names = list.map((p, i) => `${i + 1}) ${p.id} (${p.name})`);
86
+ const choice = await ui.select("Profiles", [...names, "+ Add profile", "Back"]);
87
+ if (choice === undefined || choice === "Back") return;
88
+
89
+ if (choice === "+ Add profile") {
90
+ const blank: Record<string, unknown> = {
91
+ id: "",
92
+ name: "",
93
+ minTier: "trivial",
94
+ maxTier: "hard",
95
+ contextWindow: 400000,
96
+ maxTokens: 32000,
97
+ };
98
+ const updated = await editProfile(ui, blank, PROFILE_FIELDS);
99
+ if (updated === null || updated.id === "" || updated.name === "") return;
100
+ answers.profiles = [...list, updated];
101
+ return;
102
+ }
103
+
104
+ const idx = names.indexOf(choice);
105
+ if (idx < 0) return;
106
+ const updated = await editProfile(ui, list[idx] ?? {}, PROFILE_FIELDS);
107
+ if (updated === null) return;
108
+ const next = list.slice();
109
+ next[idx] = updated;
110
+ answers.profiles = next;
111
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * omp extension: run auto-model-router IN the omp process.
3
+ *
4
+ * The MAIN omp session embeds the router: it binds a free OS-assigned port,
5
+ * publishes it to the shared `$AUTO_MODEL_ROUTER_HOME/embed.port`, and registers the
6
+ * auto-model-router provider. Subagents do NOT bind their own router — they are
7
+ * ephemeral worker processes whose PIDs get recycled, so a per-PID port file
8
+ * is a race. Instead every subagent registers the same shared provider and
9
+ * routes to the main session's single router.
10
+ *
11
+ * The discriminator is `ctx.hasUI`: the main interactive session has a UI,
12
+ * subagents do not.
13
+ *
14
+ * Install by adding this file's absolute path to omp's `extensions:` list:
15
+ *
16
+ * # ~/.omp/agent/config.yml
17
+ * extensions:
18
+ * - /path/to/auto-model-router/omp-extension/router-embed.ts
19
+ * - /path/to/auto-model-router/omp-extension/router-toast.ts
20
+ */
21
+
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+
25
+ import { loadConfig } from "../src/config/load.ts";
26
+ import { startServer } from "../src/server/http.ts";
27
+ import type { StartedServer } from "../src/server/http.ts";
28
+ import type { RouterConfig } from "../src/config/types.ts";
29
+
30
+ import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
31
+
32
+ import {
33
+ buildProviderConfig,
34
+ EMBED_DUMMY_API_KEY,
35
+ EMBED_PROVIDER_ID,
36
+ embedPortPath,
37
+ readEmbedPort,
38
+ resolveEmbedPort,
39
+ writeEmbedPort,
40
+ } from "./embed-logic.ts";
41
+
42
+ /**
43
+ * Registers the auto-model-router provider (and its virtual models) into omp's model
44
+ * registry at a specific bound port.
45
+ */
46
+ function registerRouterProvider(pi: ExtensionAPI, port: number, cfg: RouterConfig): void {
47
+ const providerConfig = buildProviderConfig(port, cfg);
48
+ pi.registerProvider(EMBED_PROVIDER_ID, {
49
+ baseUrl: providerConfig.baseUrl,
50
+ api: "openai-completions",
51
+ apiKey: EMBED_DUMMY_API_KEY,
52
+ ...(providerConfig.harnessId !== undefined && providerConfig.harnessId !== ""
53
+ ? { headers: { "X-Omp-Harness": providerConfig.harnessId } }
54
+ : {}),
55
+ models: providerConfig.models.map((m) => ({
56
+ id: m.id,
57
+ name: m.name,
58
+ api: "openai-completions",
59
+ reasoning: false,
60
+ input: ["text", "image"],
61
+ contextWindow: m.contextWindow,
62
+ maxTokens: m.maxTokens,
63
+ cost: {
64
+ input: m.cost.input,
65
+ output: m.cost.output,
66
+ cacheRead: m.cost.cacheRead,
67
+ cacheWrite: m.cost.cacheWrite,
68
+ },
69
+ })),
70
+ });
71
+ }
72
+
73
+ export default function (pi: ExtensionAPI): void {
74
+ pi.setLabel("auto-model-router embed");
75
+
76
+ const requestedPort = resolveEmbedPort(process.env.AUTO_MODEL_ROUTER_PORT);
77
+
78
+ // Shared port file, written only by the main session's router.
79
+ const homeRaw = process.env.AUTO_MODEL_ROUTER_HOME ?? join(homedir(), ".auto-model-router");
80
+ const home =
81
+ homeRaw === "~" || homeRaw.startsWith("~/") || homeRaw.startsWith("~\\")
82
+ ? join(homedir(), homeRaw.slice(1))
83
+ : homeRaw;
84
+ const portFile = embedPortPath(home);
85
+ const cfg = loadConfig({ overrides: { server: { host: "127.0.0.1", port: requestedPort } } });
86
+
87
+ let app: StartedServer | null = null;
88
+
89
+ pi.on("session_start", (_event, ctx) => {
90
+ // Subagents and headless sessions do not bind their own router; they
91
+ // route to the main's router via the shared port file. The main writes
92
+ // the file before spawning subagents, so the port is available here.
93
+ if (!ctx.hasUI) {
94
+ const port = readEmbedPort(portFile);
95
+ if (port !== null) registerRouterProvider(pi, port, cfg);
96
+ return;
97
+ }
98
+
99
+ // Main interactive session: bind the router once, then register the
100
+ // provider against the exact bound port. Registration happens only
101
+ // here — never at factory load, where a stale shared port would be
102
+ // captured into omp's model registry and defeat the correct bound URL.
103
+ if (app) return;
104
+ const started = startServer(cfg);
105
+ const actualPort = started.server.port;
106
+ if (actualPort === undefined) return;
107
+ app = started;
108
+
109
+ // Publish the shared port; subagents and the toast read it from here.
110
+ writeEmbedPort(portFile, actualPort);
111
+ registerRouterProvider(pi, actualPort, cfg);
112
+
113
+ pi.on("session_shutdown", () => {
114
+ void app?.stop().catch(() => {});
115
+ app = null;
116
+ });
117
+ });
118
+ }