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.
- package/.env.example +24 -0
- package/.github/workflows/publish.yml +40 -0
- package/.omp-plugin/marketplace.json +30 -0
- package/LICENSE +21 -0
- package/README.md +639 -0
- package/bun.lock +32 -0
- package/docs/claude-anthropic-wire.md +116 -0
- package/omp-extension/configure-logic.ts +128 -0
- package/omp-extension/embed-logic.ts +141 -0
- package/omp-extension/router-configure.ts +111 -0
- package/omp-extension/router-embed.ts +118 -0
- package/omp-extension/router-toast.ts +130 -0
- package/omp-extension/toast-logic.ts +136 -0
- package/package.json +56 -0
- package/src/catalog/openrouter-catalog.ts +428 -0
- package/src/catalog/types.ts +104 -0
- package/src/cli/args.ts +105 -0
- package/src/cli/config-cmd.ts +362 -0
- package/src/cli/config-wizard.ts +636 -0
- package/src/cli/explain.ts +167 -0
- package/src/cli/models.ts +240 -0
- package/src/cli/stats.ts +69 -0
- package/src/config/defaults.ts +136 -0
- package/src/config/load.ts +143 -0
- package/src/config/omp-credentials.ts +124 -0
- package/src/config/schema.ts +161 -0
- package/src/config/types.ts +244 -0
- package/src/cost/blended.ts +80 -0
- package/src/cost/forecast.ts +129 -0
- package/src/cost/ledger.ts +291 -0
- package/src/cost/types.ts +148 -0
- package/src/index.ts +93 -0
- package/src/router/cache-control.ts +66 -0
- package/src/router/candidates.ts +246 -0
- package/src/router/classify.ts +329 -0
- package/src/router/escalate.ts +264 -0
- package/src/router/features.ts +225 -0
- package/src/router/index.ts +99 -0
- package/src/router/select.ts +365 -0
- package/src/router/state.ts +118 -0
- package/src/router/tier-plan.ts +151 -0
- package/src/router/types.ts +222 -0
- package/src/server/http.ts +343 -0
- package/src/server/turn.ts +393 -0
- package/src/tokens/estimate.ts +74 -0
- package/src/upstream/openrouter.ts +221 -0
- package/src/upstream/sse-parse.ts +208 -0
- package/src/upstream/types.ts +75 -0
- package/src/util/hash.ts +0 -0
- package/src/util/log.ts +53 -0
- package/src/util/sqlite.ts +140 -0
- package/src/util/sse.ts +23 -0
- package/src/wire/openai/errors.ts +48 -0
- package/src/wire/openai/models.ts +37 -0
- package/src/wire/openai/request.ts +279 -0
- package/src/wire/openai/sink.ts +213 -0
- package/src/wire/types.ts +156 -0
- package/test/catalog.test.ts +319 -0
- package/test/classify.test.ts +269 -0
- package/test/config-wizard.test.ts +482 -0
- package/test/config.test.ts +121 -0
- package/test/configure-logic.test.ts +151 -0
- package/test/cost.test.ts +137 -0
- package/test/embed-logic.test.ts +107 -0
- package/test/escalate.test.ts +223 -0
- package/test/failover.test.ts +494 -0
- package/test/features.test.ts +228 -0
- package/test/fixtures/openrouter-models.json +15340 -0
- package/test/models-yml.test.ts +186 -0
- package/test/omp-credentials.test.ts +185 -0
- package/test/select.test.ts +538 -0
- package/test/sse-parse.test.ts +142 -0
- package/test/tier-plan.test.ts +302 -0
- package/test/toast-logic.test.ts +160 -0
- package/test/tokens.test.ts +160 -0
- package/test/trust-attribution.test.ts +175 -0
- package/test/turn.test.ts +498 -0
- package/test/wire-request.test.ts +297 -0
- package/test/wire-sink.test.ts +179 -0
- package/tools/install.ts +140 -0
- package/tools/mock-openrouter.ts +269 -0
- package/tools/smoke.ts +326 -0
- 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
|
+
}
|