auto-model-router 0.2.31 → 0.3.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/.omp-plugin/marketplace.json +2 -2
- package/README.md +211 -29
- package/docs/review-2026-09-05.md +267 -0
- package/omp-extension/configure-logic.ts +71 -15
- package/omp-extension/pi-coding-agent.d.ts +79 -2
- package/omp-extension/report-hub.ts +376 -0
- package/omp-extension/report-logic.ts +115 -0
- package/omp-extension/router-configure.ts +203 -51
- package/omp-extension/router-url.ts +52 -0
- package/omp-extension/toast-logic.ts +14 -2
- package/package.json +1 -1
- package/src/catalog/composite.ts +97 -0
- package/src/catalog/ollama-catalog.ts +309 -0
- package/src/catalog/ollama-prices.ts +85 -0
- package/src/catalog/openrouter-catalog.ts +39 -1
- package/src/catalog/types.ts +31 -1
- package/src/cli/args.ts +1 -0
- package/src/cli/config-wizard.ts +189 -28
- package/src/cli/explain.ts +2 -4
- package/src/cli/models.ts +2 -4
- package/src/cli/report.ts +37 -0
- package/src/config/defaults.ts +43 -2
- package/src/config/load.ts +25 -1
- package/src/config/omp-credentials.ts +31 -7
- package/src/config/schema.ts +28 -0
- package/src/config/types.ts +127 -2
- package/src/cost/ledger.ts +73 -4
- package/src/cost/report.ts +340 -0
- package/src/cost/types.ts +33 -1
- package/src/index.ts +5 -8
- package/src/router/candidates.ts +90 -11
- package/src/router/classify.ts +33 -6
- package/src/router/features.ts +13 -1
- package/src/router/select.ts +55 -8
- package/src/router/state.ts +6 -2
- package/src/router/tier-plan.ts +49 -11
- package/src/router/types.ts +11 -0
- package/src/server/http.ts +47 -6
- package/src/server/providers.ts +54 -0
- package/src/server/turn.ts +122 -34
- package/src/tokens/estimate.ts +16 -0
- package/src/upstream/multi.ts +26 -0
- package/src/upstream/ollama-usage.ts +157 -0
- package/src/upstream/ollama.ts +275 -0
- package/src/upstream/openrouter.ts +19 -1
- package/src/upstream/types.ts +2 -0
- package/src/util/sqlite.ts +25 -1
- package/test/catalog.test.ts +44 -0
- package/test/classify.test.ts +41 -5
- package/test/compaction.test.ts +1 -0
- package/test/config-wizard.test.ts +77 -1
- package/test/configure-logic.test.ts +129 -33
- package/test/embed-lifecycle.test.ts +1 -0
- package/test/failover.test.ts +148 -3
- package/test/features.test.ts +35 -0
- package/test/http-resilience.test.ts +24 -0
- package/test/ollama.test.ts +506 -0
- package/test/omp-credentials.test.ts +43 -1
- package/test/report-hub.test.ts +341 -0
- package/test/report-logic.test.ts +92 -0
- package/test/report.test.ts +217 -0
- package/test/select.test.ts +176 -1
- package/test/tier-plan.test.ts +159 -1
- package/test/toast-logic.test.ts +11 -2
- package/test/tokens.test.ts +71 -1
- package/test/trust-attribution.test.ts +2 -2
- package/test/turn.test.ts +124 -7
- package/tools/build-site.ts +1 -0
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* omp extension: `/router` —
|
|
3
|
-
*
|
|
2
|
+
* omp extension: `/router` — configure auto-model-router and pull usage
|
|
3
|
+
* reports without leaving the session.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* /router menu: Configure / Report / Status
|
|
6
|
+
* /router config edit any section of the router's config.yml
|
|
7
|
+
* /router report usage analytics in a fullscreen hub styled like
|
|
8
|
+
* /models: views for overview, providers, models,
|
|
9
|
+
* tiers, by day and status, with the time window
|
|
10
|
+
* (24h / 7d / 30d / 90d) and harness scope chosen
|
|
11
|
+
* in the sidebar. `/router report 30d --all` presets
|
|
12
|
+
* them. Headless sessions get the text instead.
|
|
13
|
+
* /router status the router's /health: keys, catalog, Ollama
|
|
14
|
+
* availability and plan usage, agentdox.
|
|
15
|
+
*
|
|
16
|
+
* Configuration walks the same sections and fields as `auto-model-router
|
|
17
|
+
* config` (reusing `WIZARD_SECTIONS` / `PROFILE_FIELDS` from the router's
|
|
18
|
+
* CLI) but prompts through `ctx.ui` select/input dialogs. Edits are persisted
|
|
19
|
+
* through the router's own validated merge (`writeRouterConfig`), so the
|
|
20
|
+
* on-disk config.yml is schema-checked and backed up exactly as the CLI
|
|
21
|
+
* wizard does.
|
|
22
|
+
*
|
|
23
|
+
* Reports and status are fetched from the running router
|
|
24
|
+
* (`GET /v1/router/report`, `GET /health`) and posted into the transcript as
|
|
25
|
+
* a custom message, so they scroll with the conversation and the model can
|
|
26
|
+
* answer questions about them. If the router is unreachable the report falls
|
|
27
|
+
* back to reading the ledger directly.
|
|
11
28
|
*
|
|
12
29
|
* Install alongside router-embed.ts:
|
|
13
30
|
*
|
|
@@ -15,64 +32,198 @@
|
|
|
15
32
|
* extensions:
|
|
16
33
|
* - /path/to/auto-model-router/omp-extension/router-embed.ts
|
|
17
34
|
* - /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
35
|
*/
|
|
22
36
|
|
|
23
|
-
import {
|
|
37
|
+
import { existsSync } from "node:fs";
|
|
38
|
+
|
|
24
39
|
import { applyAnswers, PROFILE_FIELDS, WIZARD_SECTIONS } from "../src/cli/config-wizard.ts";
|
|
25
|
-
import {
|
|
40
|
+
import { routerConfigPath, writeRouterConfig } from "../src/cli/config-cmd.ts";
|
|
41
|
+
import { loadConfig } from "../src/config/load.ts";
|
|
26
42
|
import type { RouterConfig } from "../src/config/types.ts";
|
|
43
|
+
import { buildUsageReport, renderUsageReport, type UsageReport } from "../src/cost/report.ts";
|
|
44
|
+
import { openDb } from "../src/util/sqlite.ts";
|
|
27
45
|
|
|
28
|
-
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
|
46
|
+
import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
|
29
47
|
|
|
30
|
-
import {
|
|
48
|
+
import { matchesKey, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
49
|
+
|
|
50
|
+
import { editProfile, editSectionMenu, type ConfigUi, type SelectOption } from "./configure-logic.ts";
|
|
51
|
+
import { ReportHub } from "./report-hub.ts";
|
|
52
|
+
import { fetchReport, parseReportArgs, renderStatus, type HealthSnapshot, type ReportRequest } from "./report-logic.ts";
|
|
53
|
+
import { routerAuthHeaders, routerBaseUrl } from "./router-url.ts";
|
|
54
|
+
|
|
55
|
+
// This harness's id, matching the X-Omp-Harness header the router records.
|
|
56
|
+
// Empty ⇒ reports cover every harness (single-harness default).
|
|
57
|
+
const HARNESS_ID = process.env.OMP_HARNESS_ID ?? "";
|
|
58
|
+
|
|
59
|
+
/** Custom message type for report/status output in the transcript. */
|
|
60
|
+
const MESSAGE_TYPE = "auto-model-router";
|
|
31
61
|
|
|
32
62
|
export default function (pi: ExtensionAPI): void {
|
|
33
|
-
pi.setLabel("auto-model-router
|
|
63
|
+
pi.setLabel("auto-model-router");
|
|
34
64
|
|
|
35
65
|
pi.registerCommand("router", {
|
|
36
|
-
description: "
|
|
37
|
-
handler: async (
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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;
|
|
66
|
+
description: "auto-model-router: configure, usage report, status",
|
|
67
|
+
handler: async (args, ctx) => {
|
|
68
|
+
const [verb = "", ...rest] = args.trim().split(/\s+/).filter((t) => t !== "");
|
|
69
|
+
const tail = rest.join(" ");
|
|
70
|
+
switch (verb.toLowerCase()) {
|
|
71
|
+
case "config":
|
|
72
|
+
case "configure":
|
|
73
|
+
return configure(ctx);
|
|
74
|
+
case "report":
|
|
75
|
+
return report(pi, ctx, tail);
|
|
76
|
+
case "status":
|
|
77
|
+
case "health":
|
|
78
|
+
return status(pi, ctx);
|
|
79
|
+
case "":
|
|
80
|
+
break;
|
|
81
|
+
default:
|
|
82
|
+
ctx.ui.notify(`unknown /router subcommand "${verb}" (config | report [7d] [--all] | status)`, "warn");
|
|
83
|
+
return;
|
|
62
84
|
}
|
|
63
85
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
86
|
+
const chosen = await ctx.ui.select("auto-model-router", [
|
|
87
|
+
{ label: "Configure", description: "edit any router setting" },
|
|
88
|
+
{ label: "Report", description: "usage analytics; window and scope adjustable inside" },
|
|
89
|
+
{ label: "Status", description: "keys, catalog, Ollama, agentdox" },
|
|
90
|
+
]);
|
|
91
|
+
if (chosen === undefined) return;
|
|
92
|
+
if (chosen === "Configure") return configure(ctx);
|
|
93
|
+
if (chosen === "Status") return status(pi, ctx);
|
|
94
|
+
if (chosen === "Report") return report(pi, ctx, "");
|
|
72
95
|
},
|
|
73
96
|
});
|
|
74
97
|
}
|
|
75
98
|
|
|
99
|
+
/** Posts a block of text into the transcript without triggering a turn. */
|
|
100
|
+
function post(pi: ExtensionAPI, text: string): void {
|
|
101
|
+
pi.sendMessage({ customType: MESSAGE_TYPE, content: `\`\`\`text\n${text}\n\`\`\``, display: true }, { triggerTurn: false });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Loads a report: the running router first, the ledger directly if it is down. */
|
|
105
|
+
async function loadReport(req: ReportRequest): Promise<UsageReport> {
|
|
106
|
+
try {
|
|
107
|
+
return await fetchReport(routerBaseUrl(), req, routerAuthHeaders());
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// Router not reachable (standalone `serve` not running, or embed still
|
|
110
|
+
// starting): read the ledger directly so the report still comes back.
|
|
111
|
+
const cfg = loadConfig();
|
|
112
|
+
if (!existsSync(cfg.ledger.path)) {
|
|
113
|
+
throw new Error(`router unreachable (${err instanceof Error ? err.message : String(err)}) and no ledger at ${cfg.ledger.path}`);
|
|
114
|
+
}
|
|
115
|
+
const db = openDb(cfg.ledger.path);
|
|
116
|
+
try {
|
|
117
|
+
return buildUsageReport(db, req);
|
|
118
|
+
} finally {
|
|
119
|
+
db.close();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function loadStatus(): Promise<string> {
|
|
125
|
+
const baseUrl = routerBaseUrl();
|
|
126
|
+
const res = await fetch(`${baseUrl}/health`, { headers: routerAuthHeaders(), signal: AbortSignal.timeout(5_000) });
|
|
127
|
+
if (!res.ok) throw new Error(`router returned ${res.status}`);
|
|
128
|
+
return renderStatus(baseUrl, (await res.json()) as HealthSnapshot);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function report(pi: ExtensionAPI, ctx: ExtensionContext, argText: string): Promise<void> {
|
|
132
|
+
const req = parseReportArgs(argText, HARNESS_ID);
|
|
133
|
+
// Interactive sessions get the fullscreen hub (the /models look); headless
|
|
134
|
+
// and print modes get the text posted into the transcript.
|
|
135
|
+
if (ctx.hasUI && typeof ctx.ui.custom === "function") {
|
|
136
|
+
await ctx.ui.custom<void>(
|
|
137
|
+
(tui, theme, keybindings, done) =>
|
|
138
|
+
new ReportHub({
|
|
139
|
+
theme,
|
|
140
|
+
text: { visibleWidth, truncateToWidth },
|
|
141
|
+
keys: {
|
|
142
|
+
up: (d) => keybindings.matches(d, "tui.select.up"),
|
|
143
|
+
down: (d) => keybindings.matches(d, "tui.select.down"),
|
|
144
|
+
pageUp: (d) => keybindings.matches(d, "tui.select.pageUp"),
|
|
145
|
+
pageDown: (d) => keybindings.matches(d, "tui.select.pageDown"),
|
|
146
|
+
cancel: (d) => keybindings.matches(d, "tui.select.cancel"),
|
|
147
|
+
confirm: (d) => keybindings.matches(d, "tui.select.confirm"),
|
|
148
|
+
left: (d) => matchesKey(d, "left"),
|
|
149
|
+
right: (d) => matchesKey(d, "right"),
|
|
150
|
+
},
|
|
151
|
+
source: { report: loadReport, status: loadStatus },
|
|
152
|
+
rows: () => tui.terminal?.rows ?? process.stdout.rows ?? 40,
|
|
153
|
+
requestRender: () => tui.requestRender(),
|
|
154
|
+
close: () => done(undefined),
|
|
155
|
+
initial: req,
|
|
156
|
+
harnessId: HARNESS_ID,
|
|
157
|
+
}),
|
|
158
|
+
{ overlay: true, overlayOptions: { fullscreen: true, width: "100%", maxHeight: "100%", anchor: "center" } },
|
|
159
|
+
);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
let data: UsageReport;
|
|
163
|
+
try {
|
|
164
|
+
data = await loadReport(req);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (data.totals.dispatches === 0) {
|
|
170
|
+
ctx.ui.notify(`no routed turns in the last ${req.windowDays}d${req.harnessId === "" ? "" : ` for harness ${req.harnessId}`}`, "info");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
post(pi, renderUsageReport(data));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function status(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
177
|
+
try {
|
|
178
|
+
post(pi, await loadStatus());
|
|
179
|
+
} catch (err) {
|
|
180
|
+
ctx.ui.notify(`router unreachable at ${routerBaseUrl()}: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function configure(ctx: ExtensionContext): Promise<void> {
|
|
185
|
+
const ui = ctx.ui;
|
|
186
|
+
const cfg = loadConfig();
|
|
187
|
+
const answers: Record<string, unknown> = {};
|
|
188
|
+
|
|
189
|
+
for (;;) {
|
|
190
|
+
// Each section shows how many of its fields have pending edits.
|
|
191
|
+
const options: SelectOption[] = WIZARD_SECTIONS.map((s) => {
|
|
192
|
+
const touched = s.fields.filter((f) => f.path in answers).length;
|
|
193
|
+
return touched > 0 ? { label: s.title, description: `${touched} pending` } : { label: s.title, description: `${s.fields.length} settings` };
|
|
194
|
+
});
|
|
195
|
+
options.push("profiles" in answers ? { label: "Profiles", description: "pending" } : { label: "Profiles", description: `${cfg.profiles.length} profiles` }, "Save and exit", "Quit without saving");
|
|
196
|
+
const pending = Object.keys(answers).length;
|
|
197
|
+
const chosen = await ui.select(`auto-model-router configure${pending > 0 ? ` (${pending} pending)` : ""}`, options);
|
|
198
|
+
if (chosen === undefined) return;
|
|
199
|
+
if (chosen === "Quit without saving") return;
|
|
200
|
+
if (chosen === "Save and exit") break;
|
|
201
|
+
|
|
202
|
+
if (chosen === "Profiles") {
|
|
203
|
+
await editProfiles(ui, cfg, answers);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const section = WIZARD_SECTIONS.find((s) => s.title === chosen);
|
|
208
|
+
if (section === undefined) continue;
|
|
209
|
+
await editSectionMenu(ui, section, cfg, answers);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (Object.keys(answers).length === 0) {
|
|
213
|
+
ui.notify("no changes made", "info");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const target = routerConfigPath();
|
|
219
|
+
const partial = applyAnswers(answers);
|
|
220
|
+
const backup = writeRouterConfig(target, partial);
|
|
221
|
+
ui.notify(`wrote ${target}${backup ? ` (backup: ${backup})` : ""} — restart omp for server/upstream/ledger changes`, "info");
|
|
222
|
+
} catch (err) {
|
|
223
|
+
ui.notify(err instanceof Error ? err.message : String(err), "error");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
76
227
|
/** Edits the profiles array as whole elements, mirroring the CLI wizard. */
|
|
77
228
|
async function editProfiles(
|
|
78
229
|
ui: ConfigUi,
|
|
@@ -83,7 +234,8 @@ async function editProfiles(
|
|
|
83
234
|
// expects), converting at the boundary to/from ProfileConfig.
|
|
84
235
|
const list: Record<string, unknown>[] = cfg.profiles.map((p) => ({ ...p }));
|
|
85
236
|
const names = list.map((p, i) => `${i + 1}) ${p.id} (${p.name})`);
|
|
86
|
-
const
|
|
237
|
+
const items: SelectOption[] = list.map((p, i) => ({ label: names[i] ?? "", description: `${p.minTier}..${p.maxTier} · ctx ${p.contextWindow} · out ${p.maxTokens}` }));
|
|
238
|
+
const choice = await ui.select("Profiles", [...items, "+ Add profile", "Back"]);
|
|
87
239
|
if (choice === undefined || choice === "Back") return;
|
|
88
240
|
|
|
89
241
|
if (choice === "+ Add profile") {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the router is listening, resolved fresh on every call.
|
|
3
|
+
*
|
|
4
|
+
* The embedded router binds a free OS-assigned port and writes it to the
|
|
5
|
+
* port file at session start, so nothing can cache the URL: the toast poll,
|
|
6
|
+
* `/router report` and `/router status` all resolve it at the moment of use.
|
|
7
|
+
* Precedence: `AUTO_MODEL_ROUTER_URL`, the embed port file,
|
|
8
|
+
* `AUTO_MODEL_ROUTER_PORT`, then `server.port` in the router's config.yml.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import { parse as parseYaml } from "yaml";
|
|
16
|
+
|
|
17
|
+
import { embedPortPath, readEmbedPort } from "./embed-logic.ts";
|
|
18
|
+
import { resolveRouterUrl } from "./toast-logic.ts";
|
|
19
|
+
|
|
20
|
+
/** `$AUTO_MODEL_ROUTER_HOME` with `~` expanded, default `~/.auto-model-router`. */
|
|
21
|
+
export function routerHome(): string {
|
|
22
|
+
const raw = process.env.AUTO_MODEL_ROUTER_HOME ?? join(homedir(), ".auto-model-router");
|
|
23
|
+
return raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\") ? join(homedir(), raw.slice(1)) : raw;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Raw router config.yml, or null when there is none to read. */
|
|
27
|
+
export function readRouterConfigText(): string | null {
|
|
28
|
+
const path = join(routerHome(), "config.yml");
|
|
29
|
+
if (!existsSync(path)) return null;
|
|
30
|
+
try {
|
|
31
|
+
return readFileSync(path, "utf8");
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Base URL of the router this omp process should talk to. */
|
|
38
|
+
export function routerBaseUrl(): string {
|
|
39
|
+
return resolveRouterUrl(
|
|
40
|
+
process.env.AUTO_MODEL_ROUTER_URL,
|
|
41
|
+
readRouterConfigText(),
|
|
42
|
+
parseYaml,
|
|
43
|
+
process.env.AUTO_MODEL_ROUTER_PORT,
|
|
44
|
+
readEmbedPort(embedPortPath(routerHome())),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Authorization header for a router configured with `server.apiKey`. */
|
|
49
|
+
export function routerAuthHeaders(): Record<string, string> {
|
|
50
|
+
const key = process.env.AUTO_MODEL_ROUTER_API_KEY;
|
|
51
|
+
return key === undefined || key === "" ? {} : { authorization: `Bearer ${key}` };
|
|
52
|
+
}
|
|
@@ -98,10 +98,22 @@ export interface ToastMessage {
|
|
|
98
98
|
text: string;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Provider and model for display. Catalog slugs namespace providers by
|
|
103
|
+
* prefix: `ollama/<id>` is Ollama Cloud, anything else is OpenRouter (whose
|
|
104
|
+
* slugs keep their vendor segment, e.g. `z-ai/glm-5.3-flash`). The Ollama
|
|
105
|
+
* prefix is dropped from the displayed model since the provider label
|
|
106
|
+
* already says it.
|
|
107
|
+
*/
|
|
108
|
+
export function providerOf(slug: string): { provider: string; model: string } {
|
|
109
|
+
if (slug.startsWith("ollama/")) return { provider: "ollama", model: slug.slice("ollama/".length) };
|
|
110
|
+
return { provider: "openrouter", model: slug };
|
|
111
|
+
}
|
|
112
|
+
|
|
101
113
|
export function toToastText(d: ToastDecision): string {
|
|
102
|
-
const model = d.servedSlug ?? d.slug;
|
|
114
|
+
const { provider, model } = providerOf(d.servedSlug ?? d.slug);
|
|
103
115
|
const cost = d.reportedUsd === null ? "" : ` \u00b7 $${d.reportedUsd.toFixed(5)}`;
|
|
104
|
-
return `${model} [${d.tier}]${cost}`;
|
|
116
|
+
return `${provider} \u00b7 ${model} [${d.tier}]${cost}`;
|
|
105
117
|
}
|
|
106
118
|
|
|
107
119
|
/**
|
package/package.json
CHANGED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `CatalogSource` that presents OpenRouter's catalog and Ollama Cloud's as
|
|
3
|
+
* one snapshot, so selection ranks them together.
|
|
4
|
+
*
|
|
5
|
+
* The merged snapshot object is reused until either side actually changes,
|
|
6
|
+
* because `tierPlanFor` memoises per snapshot identity and a fresh object per
|
|
7
|
+
* turn would recompute the tier plan every turn for nothing.
|
|
8
|
+
*
|
|
9
|
+
* While the Ollama breaker is open (plan quota or concurrency limit hit), the
|
|
10
|
+
* Ollama models are left out entirely: a candidate that will 402 or 429 is
|
|
11
|
+
* not a candidate, and hiding it here means the turn routes straight to an
|
|
12
|
+
* OpenRouter model instead of paying a doomed dispatch first.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { OllamaAvailability } from "../upstream/ollama.ts";
|
|
16
|
+
import { effectiveOllamaBias, NO_USAGE, type OllamaUsageSource } from "../upstream/ollama-usage.ts";
|
|
17
|
+
import { mergeSnapshots, type OllamaCatalogSource } from "./ollama-catalog.ts";
|
|
18
|
+
import type { CatalogModel, CatalogShrink, CatalogSnapshot, CatalogSource } from "./types.ts";
|
|
19
|
+
|
|
20
|
+
export interface CompositeBias {
|
|
21
|
+
/** Static multiplier from config. */
|
|
22
|
+
costBias: number;
|
|
23
|
+
/** Plan usage fraction at which the bias switches off (list price). */
|
|
24
|
+
biasUntilUsage: number;
|
|
25
|
+
usage: OllamaUsageSource;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createCompositeCatalog(
|
|
29
|
+
openrouter: CatalogSource,
|
|
30
|
+
ollama: OllamaCatalogSource,
|
|
31
|
+
availability: OllamaAvailability,
|
|
32
|
+
bias: CompositeBias = { costBias: 1, biasUntilUsage: 1, usage: NO_USAGE },
|
|
33
|
+
): CatalogSource & { ollamaModels(): CatalogModel[]; ollamaBias(): number } {
|
|
34
|
+
let lastBase: CatalogSnapshot | null = null;
|
|
35
|
+
let lastOllama: readonly CatalogModel[] = [];
|
|
36
|
+
let lastAvailable = true;
|
|
37
|
+
let lastBias = 1;
|
|
38
|
+
let merged: CatalogSnapshot | null = null;
|
|
39
|
+
|
|
40
|
+
/** The multiplier in force from the latest usage reading (no network). */
|
|
41
|
+
function currentBias(): number {
|
|
42
|
+
return effectiveOllamaBias(bias.costBias, bias.biasUntilUsage, bias.usage.peek());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function combine(base: CatalogSnapshot, models: readonly CatalogModel[]): CatalogSnapshot {
|
|
46
|
+
const available = availability.available();
|
|
47
|
+
const providerBias = currentBias();
|
|
48
|
+
if (merged !== null && base === lastBase && models === lastOllama && available === lastAvailable && providerBias === lastBias) return merged;
|
|
49
|
+
lastBase = base;
|
|
50
|
+
lastOllama = models;
|
|
51
|
+
lastAvailable = available;
|
|
52
|
+
lastBias = providerBias;
|
|
53
|
+
merged = mergeSnapshots(base, available ? models : []);
|
|
54
|
+
// A fresh object either way once anything changed; stamp the live bias so
|
|
55
|
+
// candidate scoring reads it off the snapshot it is ranking.
|
|
56
|
+
merged = { ...merged, providerBias: { ollama: providerBias } };
|
|
57
|
+
return merged;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function find(slug: string): CatalogModel | undefined {
|
|
61
|
+
const fromBase = openrouter.find(slug);
|
|
62
|
+
if (fromBase !== undefined) return fromBase;
|
|
63
|
+
for (const m of lastOllama) if (m.slug === slug) return m;
|
|
64
|
+
for (const m of ollama.peek()) if (m.slug === slug) return m;
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
async get(): Promise<CatalogSnapshot> {
|
|
70
|
+
const base = await openrouter.get();
|
|
71
|
+
const models = await ollama.get(base.models);
|
|
72
|
+
// Refreshes on its own poll interval; a cached reading returns at once.
|
|
73
|
+
await bias.usage.get();
|
|
74
|
+
return combine(base, models);
|
|
75
|
+
},
|
|
76
|
+
async refresh(): Promise<CatalogSnapshot> {
|
|
77
|
+
const base = await openrouter.refresh();
|
|
78
|
+
ollama.invalidate();
|
|
79
|
+
const models = await ollama.get(base.models);
|
|
80
|
+
await bias.usage.get();
|
|
81
|
+
return combine(base, models);
|
|
82
|
+
},
|
|
83
|
+
ollamaBias: currentBias,
|
|
84
|
+
peek(): CatalogSnapshot | null {
|
|
85
|
+
const base = openrouter.peek();
|
|
86
|
+
if (base === null) return null;
|
|
87
|
+
return combine(base, ollama.peek());
|
|
88
|
+
},
|
|
89
|
+
find,
|
|
90
|
+
lastShrink(): CatalogShrink | null {
|
|
91
|
+
return openrouter.lastShrink?.() ?? null;
|
|
92
|
+
},
|
|
93
|
+
ollamaModels(): CatalogModel[] {
|
|
94
|
+
return [...ollama.peek()];
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|