auto-model-router 0.31.0 → 0.33.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 +61 -2
- package/omp-extension/router-configure.ts +9 -7
- package/package.json +1 -1
- package/src/cli/config-cmd.ts +8 -7
- package/src/cli/explain.ts +10 -5
- package/src/cli/export.ts +6 -5
- package/src/cli/models.ts +10 -7
- package/src/cli/report.ts +6 -1
- package/src/cli/stats.ts +7 -7
- package/src/config/load.ts +10 -1
- package/src/config/types.ts +10 -1
- package/src/context/bridge.ts +7 -7
- package/src/context/index.ts +3 -3
- package/src/context/store.ts +39 -56
- package/src/context/types.ts +7 -6
- package/src/cost/blended.ts +28 -7
- package/src/cost/feedback.ts +33 -37
- package/src/cost/ledger-sql.ts +547 -0
- package/src/cost/ledger.ts +30 -459
- package/src/cost/report.ts +171 -129
- package/src/cost/retention.ts +10 -10
- package/src/cost/summary.ts +15 -10
- package/src/cost/types.ts +43 -62
- package/src/cost/views.ts +79 -49
- package/src/lib.ts +6 -2
- package/src/router/candidates.ts +16 -15
- package/src/router/classify.ts +6 -4
- package/src/router/index.ts +95 -9
- package/src/router/select.ts +38 -21
- package/src/router/state.ts +90 -102
- package/src/router/types.ts +13 -5
- package/src/server/advise.ts +6 -4
- package/src/server/compaction-digest.ts +1 -1
- package/src/server/digest.ts +9 -10
- package/src/server/http.ts +107 -41
- package/src/server/providers.ts +18 -4
- package/src/server/turn.ts +35 -10
- package/src/tokens/estimate.ts +16 -6
- package/src/upstream/anthropic.ts +5 -4
- package/src/upstream/compat.ts +13 -7
- package/src/upstream/ollama-usage.ts +21 -11
- package/src/upstream/ollama.ts +4 -3
- package/src/upstream/openrouter.ts +4 -3
- package/src/upstream/types.ts +7 -0
- package/src/util/schema.ts +201 -0
- package/src/util/sql.ts +246 -0
- package/src/wire/anthropic/messages.ts +3 -4
- package/src/wire/openai/request.ts +30 -0
- package/src/wire/types.ts +22 -0
- package/test/anthropic-wire.test.ts +9 -9
- package/test/benchmark-feeds.test.ts +7 -7
- package/test/cache-control.test.ts +7 -7
- package/test/cache-estimate.test.ts +5 -5
- package/test/catalog-view.test.ts +4 -4
- package/test/catalog.test.ts +11 -11
- package/test/classify.test.ts +24 -24
- package/test/compaction.test.ts +20 -20
- package/test/config-wizard.test.ts +32 -32
- package/test/config.test.ts +10 -10
- package/test/connect-harnesses.test.ts +11 -11
- package/test/context-bridge.test.ts +40 -30
- package/test/context-prune.test.ts +43 -36
- package/test/context-query.test.ts +8 -8
- package/test/controls.test.ts +54 -27
- package/test/cost.test.ts +12 -12
- package/test/digest.test.ts +55 -44
- package/test/embed-lifecycle.test.ts +5 -5
- package/test/embed-logic.test.ts +26 -26
- package/test/escalate.test.ts +17 -17
- package/test/eval.test.ts +13 -13
- package/test/executable.test.ts +6 -6
- package/test/exploration.test.ts +19 -20
- package/test/failover.test.ts +63 -21
- package/test/fakes.ts +105 -0
- package/test/features.test.ts +21 -21
- package/test/harness-requests.test.ts +3 -3
- package/test/harness-switch.test.ts +5 -5
- package/test/hold-exploration.test.ts +13 -13
- package/test/hot-reload.test.ts +5 -5
- package/test/learned.test.ts +5 -5
- package/test/ledger-sql.test.ts +342 -0
- package/test/mcp-entry.test.ts +5 -5
- package/test/migrations.test.ts +28 -22
- package/test/models-yml.test.ts +18 -18
- package/test/ollama.test.ts +40 -34
- package/test/omp-credentials.test.ts +16 -16
- package/test/policy.test.ts +3 -3
- package/test/reconfigure.test.ts +4 -4
- package/test/redaction.test.ts +41 -35
- package/test/remote.test.ts +12 -12
- package/test/report-logic.test.ts +8 -8
- package/test/report.test.ts +95 -87
- package/test/retention.test.ts +79 -66
- package/test/schema.test.ts +123 -0
- package/test/scope.test.ts +8 -8
- package/test/select.test.ts +216 -257
- package/test/skills.test.ts +3 -3
- package/test/sql-shim.test.ts +154 -0
- package/test/state.test.ts +43 -36
- package/test/summary.test.ts +38 -27
- package/test/tier-plan.test.ts +45 -62
- package/test/toast-logic.test.ts +31 -31
- package/test/tokens.test.ts +95 -80
- package/test/trust-attribution.test.ts +217 -187
- package/test/trust-window.test.ts +37 -32
- package/test/turn.test.ts +55 -23
- package/test/upstream-keys.test.ts +240 -0
- package/test/upstreams.test.ts +13 -13
- package/test/views.test.ts +81 -59
- package/test/wire-request.test.ts +17 -17
- package/test/wire-responses.test.ts +4 -4
- package/tools/agentdox-e2e.ts +5 -2
- package/tools/export-benchmarks.ts +5 -5
- package/tools/ledger-parity.ts +266 -0
- package/tools/replay.ts +16 -8
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.33.0",
|
|
11
11
|
"pluginRoot": "."
|
|
12
12
|
},
|
|
13
13
|
"plugins": [
|
|
14
14
|
{
|
|
15
15
|
"name": "auto-model-router",
|
|
16
16
|
"description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
|
|
17
|
-
"version": "0.
|
|
17
|
+
"version": "0.33.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -888,7 +888,9 @@ disk and back up the previous file to a timestamped `.bak`.
|
|
|
888
888
|
### Configuration file location
|
|
889
889
|
|
|
890
890
|
- Router config: `$AUTO_MODEL_ROUTER_HOME/config.yml` (default `~/.auto-model-router/config.yml`).
|
|
891
|
-
- Ledger DB: `$AUTO_MODEL_ROUTER_HOME/router.db` (SQLite, WAL)
|
|
891
|
+
- Ledger DB: `$AUTO_MODEL_ROUTER_HOME/router.db` (SQLite, WAL), or a
|
|
892
|
+
`postgres://` URL in `ledger.path` when two replicas must share one store —
|
|
893
|
+
see [Sharing the store](#sharing-the-store).
|
|
892
894
|
|
|
893
895
|
### Environment variables
|
|
894
896
|
|
|
@@ -1158,13 +1160,41 @@ task needed, and `digest.maxOutputTokens` or `digest.model` is the lever.
|
|
|
1158
1160
|
|
|
1159
1161
|
| Key | Default | Meaning |
|
|
1160
1162
|
| --- | --- | --- |
|
|
1161
|
-
| `path` | `$AUTO_MODEL_ROUTER_HOME/router.db` | SQLite ledger path. |
|
|
1163
|
+
| `path` | `$AUTO_MODEL_ROUTER_HOME/router.db` | SQLite ledger path, or a `postgres://` URL. See [Sharing the store](#sharing-the-store). |
|
|
1162
1164
|
| `blendWindowDays` | `7` | Window for the blended cost rate. |
|
|
1163
1165
|
| `blendMinSamples` | `25` | Turns before the measured blend replaces the fallback. |
|
|
1164
1166
|
| `fallbackBlend` | input `1.5`, output `7.5` | Pre-measurement blend (USD/Mtok) for omp's cost display. |
|
|
1165
1167
|
| `conversationTtlMs` | `604800000` (7 d) | Drop conversation state untouched this long. |
|
|
1166
1168
|
| `retentionDays` | `null` | Delete ledger rows — and the feedback keyed to them — older than this many days, checked at most hourly; `null` (the default) and `0` keep everything. The ledger grows about 2.5 MB a day under steady use. See [Data governance](#data-governance). |
|
|
1167
1169
|
|
|
1170
|
+
#### Sharing the store
|
|
1171
|
+
|
|
1172
|
+
`ledger.path` accepts a `postgres://` URL as well as a SQLite path. One
|
|
1173
|
+
implementation serves both (`src/cost/ledger-sql.ts` over the dialect shim in
|
|
1174
|
+
`src/util/sql.ts`), and `tools/ledger-parity.ts` compares every signal and
|
|
1175
|
+
every report engine-against-engine on real rows.
|
|
1176
|
+
|
|
1177
|
+
What moves to the shared store is what a second replica must see one copy of:
|
|
1178
|
+
|
|
1179
|
+
- the turn rows a budget cap is counted from,
|
|
1180
|
+
- conversation routing memory (held tier, warm prompt-cache model, spend),
|
|
1181
|
+
- the agentdox context blocks.
|
|
1182
|
+
|
|
1183
|
+
What stays local is the cache layer — the catalog payload, the benchmark
|
|
1184
|
+
feeds, the local eval scores, the once-a-day summary marker. With a Postgres
|
|
1185
|
+
ledger those live in `$AUTO_MODEL_ROUTER_HOME/cache.db`. A cache shared
|
|
1186
|
+
between replicas buys contention and nothing else, and `local_scores` belongs
|
|
1187
|
+
to the machine that measured it.
|
|
1188
|
+
|
|
1189
|
+
Two replicas on one Postgres were verified end to end: a turn served by one
|
|
1190
|
+
replica leaves the next turn of that conversation on the same warm model when
|
|
1191
|
+
it lands on the other (`cache: keeping warm …`), and a replica that has served
|
|
1192
|
+
nothing refuses with `402 budget_exceeded` once the shared spend is past its
|
|
1193
|
+
cap — where the same cap against an empty store serves.
|
|
1194
|
+
|
|
1195
|
+
A SQLite deployment is unchanged: the file is still migrated in place through
|
|
1196
|
+
the nineteen shipped versions, and both halves live in the one file.
|
|
1197
|
+
|
|
1168
1198
|
### `redaction` — keep configured strings out of every request
|
|
1169
1199
|
|
|
1170
1200
|
| Key | Default | Meaning |
|
|
@@ -1396,6 +1426,35 @@ ignored rather than failing the turn. The decision trail records what the
|
|
|
1396
1426
|
policy changed (`policy: …`), and `GET /v1/router/catalog?policy=…` shows what a
|
|
1397
1427
|
policy admits, model by model, without routing a turn.
|
|
1398
1428
|
|
|
1429
|
+
## Per-turn upstream credentials
|
|
1430
|
+
|
|
1431
|
+
The same front door can also send the credentials one turn dispatches with, in
|
|
1432
|
+
an `X-Omp-Upstream-Keys` header carrying JSON that maps an upstream id to a
|
|
1433
|
+
credential:
|
|
1434
|
+
|
|
1435
|
+
```json
|
|
1436
|
+
{ "openrouter": "sk-or-v1-…", "azure-eu": "…" }
|
|
1437
|
+
```
|
|
1438
|
+
|
|
1439
|
+
The ids are the ones the catalog and `/health` use: `openrouter`, `ollama`, or
|
|
1440
|
+
a named entry's `id`. An upstream named here dispatches with that credential
|
|
1441
|
+
for the whole turn — every retry, same-tier failover and tier escalation
|
|
1442
|
+
included — instead of its configured `apiKey`; one not named keeps the
|
|
1443
|
+
configured key. An upstream mapped to `""` has **no** credential this turn and
|
|
1444
|
+
is excluded from selection rather than dispatched keyless, so a turn that
|
|
1445
|
+
carries nothing usable for a provider simply routes elsewhere.
|
|
1446
|
+
|
|
1447
|
+
Nothing is stored: the override lives on the parsed request and is read when a
|
|
1448
|
+
header is built, so the shared configuration is never written to and concurrent
|
|
1449
|
+
turns carrying different callers' keys cannot see each other's. The credential
|
|
1450
|
+
is a secret and is treated as one — it is never logged, never recorded in the
|
|
1451
|
+
ledger, and never repeated in an error or a decision reason. A malformed header
|
|
1452
|
+
is ignored like a malformed `X-Omp-Policy`, leaving the configured keys in
|
|
1453
|
+
force.
|
|
1454
|
+
|
|
1455
|
+
This is what lets one router serve callers who bring their own keys — the team
|
|
1456
|
+
edition's per-user credentials — without a process per credential set.
|
|
1457
|
+
|
|
1399
1458
|
## Data governance
|
|
1400
1459
|
|
|
1401
1460
|
Two things an operator with a compliance obligation needs from a router: that
|
|
@@ -51,7 +51,7 @@ import { loadConfig } from "../src/config/load.ts";
|
|
|
51
51
|
import type { RouterConfig } from "../src/config/types.ts";
|
|
52
52
|
import { buildUsageReport, renderUsageReport, type UsageReport } from "../src/cost/report.ts";
|
|
53
53
|
import { buildDailySummary, renderDailySummary } from "../src/cost/summary.ts";
|
|
54
|
-
import {
|
|
54
|
+
import { openSqlDb } from "../src/util/sql.ts";
|
|
55
55
|
|
|
56
56
|
import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
|
57
57
|
|
|
@@ -180,11 +180,13 @@ async function loadReport(req: ReportRequest): Promise<UsageReport> {
|
|
|
180
180
|
if (!existsSync(cfg.ledger.path)) {
|
|
181
181
|
throw new Error(`router unreachable (${err instanceof Error ? err.message : String(err)}) and no ledger at ${cfg.ledger.path}`);
|
|
182
182
|
}
|
|
183
|
-
|
|
183
|
+
// Fallback read when the router is unreachable: straight at the ledger,
|
|
184
|
+
// through the handle that works whichever engine holds it.
|
|
185
|
+
const db = openSqlDb(cfg.ledger.path);
|
|
184
186
|
try {
|
|
185
|
-
return buildUsageReport(db, req);
|
|
187
|
+
return await buildUsageReport(db, req);
|
|
186
188
|
} finally {
|
|
187
|
-
db.close();
|
|
189
|
+
await db.close();
|
|
188
190
|
}
|
|
189
191
|
}
|
|
190
192
|
}
|
|
@@ -343,11 +345,11 @@ async function summary(pi: ExtensionAPI, ctx: ExtensionContext, argText: string)
|
|
|
343
345
|
ctx.ui.notify(`router unreachable at ${routerBaseUrl()} and no ledger at ${cfg.ledger.path}`, "error");
|
|
344
346
|
return;
|
|
345
347
|
}
|
|
346
|
-
const db =
|
|
348
|
+
const db = openSqlDb(cfg.ledger.path);
|
|
347
349
|
try {
|
|
348
|
-
post(pi, `${renderDailySummary(buildDailySummary(db, { harnessId }))}\n(router unreachable: read from the ledger; spikes and the Ollama meter need the router)`);
|
|
350
|
+
post(pi, `${renderDailySummary(await buildDailySummary(db, { harnessId }))}\n(router unreachable: read from the ledger; spikes and the Ollama meter need the router)`);
|
|
349
351
|
} finally {
|
|
350
|
-
db.close();
|
|
352
|
+
await db.close();
|
|
351
353
|
}
|
|
352
354
|
}
|
|
353
355
|
|
package/package.json
CHANGED
package/src/cli/config-cmd.ts
CHANGED
|
@@ -24,9 +24,9 @@ import { parse as parseYaml, stringify } from "yaml";
|
|
|
24
24
|
import { loadConfig, resolveTilde } from "../config/load.ts";
|
|
25
25
|
import { configInputSchema } from "../config/schema.ts";
|
|
26
26
|
import type { RouterConfig } from "../config/types.ts";
|
|
27
|
-
import {
|
|
27
|
+
import { createSqlLedger } from "../cost/ledger-sql.ts";
|
|
28
|
+
import { dialectOf, openSqlDb } from "../util/sql.ts";
|
|
28
29
|
import type { BlendedRate } from "../cost/types.ts";
|
|
29
|
-
import { openDb } from "../util/sqlite.ts";
|
|
30
30
|
import { configOpts, flagString, type CliArgs } from "./args.ts";
|
|
31
31
|
import {
|
|
32
32
|
mergeConfigPartial,
|
|
@@ -330,14 +330,15 @@ export function ompModelsPath(): string {
|
|
|
330
330
|
export async function configCommand(args: CliArgs): Promise<void> {
|
|
331
331
|
const cfg = loadConfig(configOpts(args));
|
|
332
332
|
|
|
333
|
-
// Reading a blend must not create a
|
|
333
|
+
// Reading a blend must not create a store just by asking. A Postgres URL is
|
|
334
|
+
// always there to be asked; a file has to exist first.
|
|
334
335
|
let blend: BlendedRate | null = null;
|
|
335
|
-
if (existsSync(cfg.ledger.path)) {
|
|
336
|
-
const db =
|
|
336
|
+
if (dialectOf(cfg.ledger.path) === "postgres" || existsSync(cfg.ledger.path)) {
|
|
337
|
+
const db = openSqlDb(cfg.ledger.path);
|
|
337
338
|
try {
|
|
338
|
-
blend =
|
|
339
|
+
blend = await createSqlLedger(db, cfg, { findModel: () => null }).blendedRate(cfg.ledger.blendWindowDays);
|
|
339
340
|
} finally {
|
|
340
|
-
db.close();
|
|
341
|
+
await db.close();
|
|
341
342
|
}
|
|
342
343
|
}
|
|
343
344
|
|
package/src/cli/explain.ts
CHANGED
|
@@ -12,11 +12,12 @@ import { existsSync } from "node:fs";
|
|
|
12
12
|
|
|
13
13
|
import { createProviders } from "../server/providers.ts";
|
|
14
14
|
import { loadConfig } from "../config/load.ts";
|
|
15
|
-
import {
|
|
15
|
+
import { createSqlLedger } from "../cost/ledger-sql.ts";
|
|
16
16
|
import { createRouter } from "../router/index.ts";
|
|
17
17
|
import { createConversationStore } from "../router/state.ts";
|
|
18
18
|
import type { Candidate, Decision, Features, Rejection } from "../router/types.ts";
|
|
19
19
|
import { openDb } from "../util/sqlite.ts";
|
|
20
|
+
import { openSqlDb } from "../util/sql.ts";
|
|
20
21
|
import { parseChatRequest } from "../wire/openai/request.ts";
|
|
21
22
|
import { configOpts, flagString, type CliArgs } from "./args.ts";
|
|
22
23
|
|
|
@@ -130,10 +131,13 @@ export async function explainCommand(args: CliArgs): Promise<void> {
|
|
|
130
131
|
const req = parseChatRequest(body, new Headers());
|
|
131
132
|
|
|
132
133
|
const db = openDb(cfg.ledger.path);
|
|
134
|
+
// The conversation store reads through the engine-agnostic handle; the
|
|
135
|
+
// catalog and the ledger still use the bun:sqlite one. Same store.
|
|
136
|
+
const sdb = openSqlDb(cfg.ledger.path);
|
|
133
137
|
try {
|
|
134
|
-
const ledger =
|
|
135
|
-
const { upstream, catalog } = createProviders(cfg, db);
|
|
136
|
-
const conversations = createConversationStore(
|
|
138
|
+
const ledger = createSqlLedger(sdb, cfg, { findModel: (slug: string) => catalog.find(slug) });
|
|
139
|
+
const { upstream, catalog } = createProviders(cfg, db, sdb);
|
|
140
|
+
const conversations = createConversationStore(sdb);
|
|
137
141
|
const router = createRouter({ config: cfg, catalog, ledger, conversations, upstream });
|
|
138
142
|
|
|
139
143
|
const decision = await router.route(req, { attempt: 0 });
|
|
@@ -143,7 +147,7 @@ export async function explainCommand(args: CliArgs): Promise<void> {
|
|
|
143
147
|
return;
|
|
144
148
|
}
|
|
145
149
|
|
|
146
|
-
const state = conversations.get(req.conversationKey);
|
|
150
|
+
const state = await conversations.get(req.conversationKey);
|
|
147
151
|
console.log(`request: ${req.messages.length} messages, ${req.tools.length} tools, model "${req.requestedModel}"`);
|
|
148
152
|
console.log(`conversation: ${req.conversationKey} (turn ${state?.turn ?? 0}, prior model ${state?.currentSlug ?? "none"})`);
|
|
149
153
|
console.log(
|
|
@@ -161,6 +165,7 @@ export async function explainCommand(args: CliArgs): Promise<void> {
|
|
|
161
165
|
renderDecision(decision);
|
|
162
166
|
console.log("\n(no completion was dispatched; nothing was billed)");
|
|
163
167
|
} finally {
|
|
168
|
+
await sdb.close();
|
|
164
169
|
db.close();
|
|
165
170
|
}
|
|
166
171
|
}
|
package/src/cli/export.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync } from "node:fs";
|
|
8
|
-
import {
|
|
8
|
+
import { openSqlDb } from "../util/sql.ts";
|
|
9
9
|
import { loadConfig } from "../config/load.ts";
|
|
10
10
|
import { exportCsv, exportRows } from "../cost/views.ts";
|
|
11
11
|
import { configOpts, flagInt, flagString, type CliArgs } from "./args.ts";
|
|
@@ -18,12 +18,13 @@ export async function exportCommand(args: CliArgs): Promise<void> {
|
|
|
18
18
|
process.stdout.write(args.flags.has("json") ? "[]\n" : exportCsv([]));
|
|
19
19
|
return;
|
|
20
20
|
}
|
|
21
|
-
//
|
|
22
|
-
|
|
21
|
+
// The shim opens the ledger wherever it lives; an export must never create
|
|
22
|
+
// or migrate it, so nothing here calls migrateLedger.
|
|
23
|
+
const db = openSqlDb(cfg.ledger.path);
|
|
23
24
|
try {
|
|
24
|
-
const rows = exportRows(db, Date.now() - days * 86_400_000, harness === "" ? null : harness.split(",").map((s) => s.trim()).filter((s) => s !== ""));
|
|
25
|
+
const rows = await exportRows(db, Date.now() - days * 86_400_000, harness === "" ? null : harness.split(",").map((s) => s.trim()).filter((s) => s !== ""));
|
|
25
26
|
process.stdout.write(args.flags.has("json") ? `${JSON.stringify(rows, null, 2)}\n` : exportCsv(rows));
|
|
26
27
|
} finally {
|
|
27
|
-
db.close();
|
|
28
|
+
await db.close();
|
|
28
29
|
}
|
|
29
30
|
}
|
package/src/cli/models.ts
CHANGED
|
@@ -7,21 +7,19 @@
|
|
|
7
7
|
* command's whole value is that it cannot lie about what would be chosen.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { existsSync } from "node:fs";
|
|
11
10
|
import type { Database } from "bun:sqlite";
|
|
12
11
|
|
|
13
12
|
import { createProviders } from "../server/providers.ts";
|
|
14
13
|
import { effectiveQualityFloor, tierPlanFor } from "../router/tier-plan.ts";
|
|
15
14
|
import { loadConfig } from "../config/load.ts";
|
|
16
15
|
import type { QualityAxis, RouterConfig } from "../config/types.ts";
|
|
17
|
-
import { createLedger } from "../cost/ledger.ts";
|
|
18
|
-
import type { Ledger } from "../cost/types.ts";
|
|
19
16
|
import { buildCandidates } from "../router/candidates.ts";
|
|
20
17
|
import { classifyTask } from "../router/classify.ts";
|
|
21
18
|
import { extractFeatures } from "../router/features.ts";
|
|
22
19
|
import { TIER_ORDER, type Candidate, type Rejection, type Tier } from "../router/types.ts";
|
|
23
20
|
import { estimatePromptTokens } from "../tokens/estimate.ts";
|
|
24
21
|
import { openDb } from "../util/sqlite.ts";
|
|
22
|
+
import { openSqlDb } from "../util/sql.ts";
|
|
25
23
|
import { parseChatRequest } from "../wire/openai/request.ts";
|
|
26
24
|
import { configOpts, flagInt, flagString, type CliArgs } from "./args.ts";
|
|
27
25
|
|
|
@@ -153,13 +151,18 @@ export async function modelsCommand(args: CliArgs): Promise<void> {
|
|
|
153
151
|
|
|
154
152
|
// Reuse the on-disk catalog cache when present so a survey costs no network.
|
|
155
153
|
const db: Database = openDb(cfg.ledger.path);
|
|
156
|
-
|
|
154
|
+
// The engine-agnostic handle exists only to satisfy the providers' calibration
|
|
155
|
+
// store; a survey reads the catalog and nothing else.
|
|
156
|
+
const sqlDb = openSqlDb(cfg.ledger.path);
|
|
157
|
+
// A measured token ratio would change the estimate by a few percent and cost a
|
|
158
|
+
// read per survey; the family default is what a cold router uses anyway.
|
|
159
|
+
const ledgerRatio = null;
|
|
157
160
|
try {
|
|
158
|
-
const { catalog } = createProviders(cfg, db);
|
|
161
|
+
const { catalog } = createProviders(cfg, db, sqlDb);
|
|
159
162
|
const snapshot = await catalog.get();
|
|
160
163
|
|
|
161
164
|
const req = syntheticRequest();
|
|
162
|
-
const promptTokens = estimatePromptTokens(req, "gpt",
|
|
165
|
+
const promptTokens = estimatePromptTokens(req, "gpt", ledgerRatio);
|
|
163
166
|
const features = extractFeatures(req, promptTokens);
|
|
164
167
|
|
|
165
168
|
const reports: TierReport[] = tiers.map((tier) => {
|
|
@@ -172,7 +175,6 @@ export async function modelsCommand(args: CliArgs): Promise<void> {
|
|
|
172
175
|
tier,
|
|
173
176
|
task,
|
|
174
177
|
snapshot,
|
|
175
|
-
ledger,
|
|
176
178
|
cfg,
|
|
177
179
|
expectedCompletionTokens: EXPECTED_COMPLETION_TOKENS,
|
|
178
180
|
warmSlug: null,
|
|
@@ -233,6 +235,7 @@ export async function modelsCommand(args: CliArgs): Promise<void> {
|
|
|
233
235
|
console.log(`survey request: ${promptTokens} estimated prompt tokens, ${req.tools.length} tools offered`);
|
|
234
236
|
for (const report of reports) renderTier(report, limit);
|
|
235
237
|
} finally {
|
|
238
|
+
await sqlDb.close();
|
|
236
239
|
db.close();
|
|
237
240
|
}
|
|
238
241
|
}
|
package/src/cli/report.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { loadConfig } from "../config/load.ts";
|
|
|
10
10
|
import { createCatalog } from "../catalog/openrouter-catalog.ts";
|
|
11
11
|
import { baselinePrices, buildUsageReport, renderUsageReport } from "../cost/report.ts";
|
|
12
12
|
import { openDb } from "../util/sqlite.ts";
|
|
13
|
+
import { openSqlDb } from "../util/sql.ts";
|
|
13
14
|
import { configOpts, flagInt, flagString, type CliArgs } from "./args.ts";
|
|
14
15
|
|
|
15
16
|
export async function reportCommand(args: CliArgs): Promise<void> {
|
|
@@ -28,15 +29,19 @@ export async function reportCommand(args: CliArgs): Promise<void> {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
const db = openDb(cfg.ledger.path);
|
|
32
|
+
// The catalog cache is read through the bun:sqlite handle; the report reads
|
|
33
|
+
// through the engine-agnostic one. Same store, two readers.
|
|
34
|
+
const sdb = openSqlDb(cfg.ledger.path);
|
|
31
35
|
try {
|
|
32
36
|
// Baseline prices from the cached catalog: no network for a report.
|
|
33
37
|
const dead = { dispatch: () => Promise.reject(new Error("offline")), complete: () => Promise.reject(new Error("offline")), fetchModels: () => Promise.reject(new Error("offline")), fetchModelsForUser: () => Promise.reject(new Error("offline")) };
|
|
34
38
|
const snapshot = createCatalog(cfg, dead, db).peek();
|
|
35
39
|
const baselines = baselinePrices(cfg.report.baselines, (s) => snapshot?.models.find((m) => m.slug === s));
|
|
36
|
-
const report = buildUsageReport(
|
|
40
|
+
const report = await buildUsageReport(sdb, { windowDays: days, harnessId, baselines });
|
|
37
41
|
if (args.flags.has("json")) console.log(JSON.stringify(report, null, 2));
|
|
38
42
|
else console.log(renderUsageReport(report));
|
|
39
43
|
} finally {
|
|
44
|
+
await sdb.close();
|
|
40
45
|
db.close();
|
|
41
46
|
}
|
|
42
47
|
}
|
package/src/cli/stats.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import type { Database } from "bun:sqlite";
|
|
3
2
|
import { loadConfig } from "../config/load.ts";
|
|
4
|
-
import {
|
|
3
|
+
import { createSqlLedger } from "../cost/ledger-sql.ts";
|
|
5
4
|
import { computeStats, type RouterStats } from "../server/http.ts";
|
|
6
|
-
import {
|
|
5
|
+
import { openSqlDb, type SqlDb } from "../util/sql.ts";
|
|
7
6
|
import { configOpts, flagInt, type CliArgs } from "./args.ts";
|
|
8
7
|
|
|
9
8
|
function usd(v: number): string {
|
|
@@ -38,11 +37,12 @@ export async function statsCommand(args: CliArgs): Promise<void> {
|
|
|
38
37
|
const cfg = loadConfig(configOpts(args));
|
|
39
38
|
|
|
40
39
|
// A stats query must not create the ledger file just by looking.
|
|
41
|
-
let db:
|
|
40
|
+
let db: SqlDb | null = null;
|
|
42
41
|
let stats: RouterStats;
|
|
43
42
|
if (existsSync(cfg.ledger.path)) {
|
|
44
|
-
db =
|
|
45
|
-
|
|
43
|
+
db = openSqlDb(cfg.ledger.path);
|
|
44
|
+
// No catalog here, so rows cannot be re-priced; stats only read.
|
|
45
|
+
stats = await computeStats(createSqlLedger(db, cfg, { findModel: () => null }), { windowDays: days });
|
|
46
46
|
} else {
|
|
47
47
|
stats = {
|
|
48
48
|
generatedAtMs: Date.now(),
|
|
@@ -64,6 +64,6 @@ export async function statsCommand(args: CliArgs): Promise<void> {
|
|
|
64
64
|
if (args.flags.has("json")) console.log(JSON.stringify(stats, null, 2));
|
|
65
65
|
else renderStats(stats);
|
|
66
66
|
} finally {
|
|
67
|
-
db?.close();
|
|
67
|
+
await db?.close();
|
|
68
68
|
}
|
|
69
69
|
}
|
package/src/config/load.ts
CHANGED
|
@@ -17,6 +17,15 @@ export function resolveTilde(p: string): string {
|
|
|
17
17
|
return p;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The router's own directory: config, the local cache database, and the
|
|
22
|
+
* default ledger file. One resolver so a caller that needs a sibling file
|
|
23
|
+
* lands in the same place `loadConfig` reads from.
|
|
24
|
+
*/
|
|
25
|
+
export function routerHome(): string {
|
|
26
|
+
return resolveTilde(process.env.AUTO_MODEL_ROUTER_HOME ?? "~/.auto-model-router");
|
|
27
|
+
}
|
|
28
|
+
|
|
20
29
|
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
21
30
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
22
31
|
}
|
|
@@ -68,7 +77,7 @@ export type DeepPartial<T> = T extends readonly unknown[] | Date | RegExp
|
|
|
68
77
|
* fail at dispatch time.
|
|
69
78
|
*/
|
|
70
79
|
export function loadConfig(opts?: { path?: string; overrides?: DeepPartial<RouterConfig> }): RouterConfig {
|
|
71
|
-
const home =
|
|
80
|
+
const home = routerHome();
|
|
72
81
|
|
|
73
82
|
// Config file, when present.
|
|
74
83
|
const filePath = opts?.path !== undefined
|
package/src/config/types.ts
CHANGED
|
@@ -719,7 +719,16 @@ export interface ProfileConfig {
|
|
|
719
719
|
}
|
|
720
720
|
|
|
721
721
|
export interface LedgerConfig {
|
|
722
|
-
/**
|
|
722
|
+
/**
|
|
723
|
+
* Where the ledger lives: a SQLite path (default
|
|
724
|
+
* `$AUTO_MODEL_ROUTER_HOME/router.db`), or a `postgres://` URL for a store
|
|
725
|
+
* two replicas share. The shared store holds what correctness depends on
|
|
726
|
+
* being one copy — the turn rows a cap is counted from, conversation
|
|
727
|
+
* routing memory, and the context blocks. The local caches (catalog
|
|
728
|
+
* payloads, benchmark feeds, the summary marker) stay in a SQLite file
|
|
729
|
+
* beside the config either way: a cache is a per-process convenience, and
|
|
730
|
+
* sharing one would only add contention.
|
|
731
|
+
*/
|
|
723
732
|
path: string;
|
|
724
733
|
/** Window for the blended rate published to omp, days. */
|
|
725
734
|
blendWindowDays: number;
|
package/src/context/bridge.ts
CHANGED
|
@@ -119,7 +119,7 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
|
|
|
119
119
|
async resolve(input) {
|
|
120
120
|
if (input.scope === "") return null;
|
|
121
121
|
|
|
122
|
-
const pinned = input.pinnedVersion === null ? null : store.get(input.pinnedVersion);
|
|
122
|
+
const pinned = input.pinnedVersion === null ? null : await store.get(input.pinnedVersion);
|
|
123
123
|
if (!shouldRefresh(input, pinned) && pinned !== null) {
|
|
124
124
|
// Carry the conversation's own pin time forward, so the TTL keeps
|
|
125
125
|
// counting from its last real refresh rather than resetting to
|
|
@@ -163,7 +163,7 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
|
|
|
163
163
|
const version = sha256Hex(block).slice(0, 32);
|
|
164
164
|
const pin: ContextPin = { version, block, fetchedAtMs: Date.now() };
|
|
165
165
|
try {
|
|
166
|
-
store.put(input.scope, pin);
|
|
166
|
+
await store.put(input.scope, pin);
|
|
167
167
|
} catch (err) {
|
|
168
168
|
log.debug("context block persist failed", { error: err instanceof Error ? err.message : String(err) });
|
|
169
169
|
}
|
|
@@ -215,11 +215,11 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
|
|
|
215
215
|
queued++;
|
|
216
216
|
queue = queue
|
|
217
217
|
.then(async () => {
|
|
218
|
-
let sessionId = store.sessionFor(rec.conversationKey);
|
|
218
|
+
let sessionId = await store.sessionFor(rec.conversationKey);
|
|
219
219
|
if (sessionId === null) {
|
|
220
220
|
sessionId = await client.createSession(rec.scope, rec.title);
|
|
221
221
|
if (sessionId === null) return;
|
|
222
|
-
store.bindSession(rec.conversationKey, rec.scope, sessionId);
|
|
222
|
+
await store.bindSession(rec.conversationKey, rec.scope, sessionId);
|
|
223
223
|
}
|
|
224
224
|
// Model attribution rides on refs, which agentdox already carries
|
|
225
225
|
// per message. This is what makes the transcript newly useful:
|
|
@@ -245,8 +245,8 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
|
|
|
245
245
|
await queue;
|
|
246
246
|
},
|
|
247
247
|
|
|
248
|
-
pruneBlocks(maxAgeMs: number) {
|
|
249
|
-
return store.prune(maxAgeMs);
|
|
248
|
+
async pruneBlocks(maxAgeMs: number) {
|
|
249
|
+
return await store.prune(maxAgeMs);
|
|
250
250
|
},
|
|
251
251
|
|
|
252
252
|
close() {
|
|
@@ -263,7 +263,7 @@ export function createDisabledBridge(): ContextBridge {
|
|
|
263
263
|
resolve: async () => null,
|
|
264
264
|
recordTurn: () => {},
|
|
265
265
|
flush: async () => {},
|
|
266
|
-
pruneBlocks: () => 0,
|
|
266
|
+
pruneBlocks: async () => 0,
|
|
267
267
|
close: () => {},
|
|
268
268
|
};
|
|
269
269
|
}
|
package/src/context/index.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* reconfigurable, so those settings can change while the router runs.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type {
|
|
7
|
+
import type { SqlDb } from "../util/sql.ts";
|
|
8
8
|
|
|
9
9
|
import type { RouterConfig } from "../config/types.ts";
|
|
10
10
|
import { createLogger } from "../util/log.ts";
|
|
@@ -35,7 +35,7 @@ export interface ReloadableContextBridge extends ContextBridge {
|
|
|
35
35
|
* restart. The block store is the database, not the bridge, so a rebuild keeps
|
|
36
36
|
* every pinned block and session binding.
|
|
37
37
|
*/
|
|
38
|
-
export function createBridgeFromConfig(cfg: RouterConfig, db:
|
|
38
|
+
export function createBridgeFromConfig(cfg: RouterConfig, db: SqlDb): ReloadableContextBridge {
|
|
39
39
|
let inner = buildBridge(cfg, db);
|
|
40
40
|
return {
|
|
41
41
|
get enabled() {
|
|
@@ -58,7 +58,7 @@ export function createBridgeFromConfig(cfg: RouterConfig, db: Database): Reloada
|
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
function buildBridge(cfg: RouterConfig, db:
|
|
61
|
+
function buildBridge(cfg: RouterConfig, db: SqlDb): ContextBridge {
|
|
62
62
|
const c = cfg.context;
|
|
63
63
|
if (!c.enabled || c.baseUrl === "" || c.token === "") return createDisabledBridge();
|
|
64
64
|
const log = createLogger(cfg.logLevel);
|
package/src/context/store.ts
CHANGED
|
@@ -7,85 +7,68 @@
|
|
|
7
7
|
* a conversation was already using — OpenRouter's prompt cache outlives our
|
|
8
8
|
* process, and re-fetching would needlessly change the prefix.
|
|
9
9
|
*
|
|
10
|
-
* Tables are created by
|
|
10
|
+
* Tables are created by the store's migration (`util/schema.ts`).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import
|
|
13
|
+
import { num, type SqlDb } from "../util/sql.ts";
|
|
14
14
|
|
|
15
15
|
import type { ContextBlockStore, ContextPin } from "./types.ts";
|
|
16
16
|
|
|
17
17
|
interface BlockRow {
|
|
18
18
|
version: string;
|
|
19
19
|
block: string;
|
|
20
|
-
fetched_at_ms:
|
|
20
|
+
fetched_at_ms: unknown;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
interface SessionRow {
|
|
24
|
-
session_id: string;
|
|
25
|
-
}
|
|
26
23
|
|
|
27
|
-
export function createContextStore(db:
|
|
28
|
-
|
|
29
|
-
const selectBlock: Statement<BlockRow, [string]> = db.query(
|
|
30
|
-
"SELECT version, block, fetched_at_ms FROM context_blocks WHERE version = ?",
|
|
31
|
-
);
|
|
32
|
-
const insertBlock = db.query(`
|
|
33
|
-
INSERT INTO context_blocks (version, scope, block, fetched_at_ms)
|
|
34
|
-
VALUES ($version, $scope, $block, $fetchedAtMs)
|
|
35
|
-
ON CONFLICT(version) DO UPDATE SET fetched_at_ms = excluded.fetched_at_ms
|
|
36
|
-
`);
|
|
37
|
-
const selectSession: Statement<SessionRow, [string]> = db.query(
|
|
38
|
-
"SELECT session_id FROM agentdox_sessions WHERE conversation_key = ?",
|
|
39
|
-
);
|
|
40
|
-
const insertSession = db.query(`
|
|
41
|
-
INSERT INTO agentdox_sessions (conversation_key, scope, session_id, created_at_ms)
|
|
42
|
-
VALUES ($key, $scope, $sessionId, $createdAtMs)
|
|
43
|
-
ON CONFLICT(conversation_key) DO UPDATE SET session_id = excluded.session_id
|
|
44
|
-
`);
|
|
45
|
-
// Age alone is the wrong test: a block older than the staleness TTL may still
|
|
46
|
-
// be PINNED by a live conversation, and deleting it forces that conversation
|
|
47
|
-
// to refetch and re-inject different bytes — a prompt-cache miss caused by
|
|
48
|
-
// housekeeping. Blocks are content-addressed and shared, so the safe set is
|
|
49
|
-
// "old AND referenced by no conversation".
|
|
50
|
-
const deleteStale: Statement<unknown, [number]> = db.query(`
|
|
51
|
-
DELETE FROM context_blocks
|
|
52
|
-
WHERE fetched_at_ms < ?
|
|
53
|
-
AND version NOT IN (SELECT context_version FROM conversations WHERE context_version IS NOT NULL)
|
|
54
|
-
`);
|
|
24
|
+
export function createContextStore(db: SqlDb): ContextBlockStore {
|
|
25
|
+
const { sql } = db;
|
|
55
26
|
|
|
56
27
|
return {
|
|
57
|
-
get(version) {
|
|
58
|
-
const row =
|
|
28
|
+
async get(version) {
|
|
29
|
+
const row = await db.one<BlockRow>(
|
|
30
|
+
"SELECT version, block, fetched_at_ms FROM context_blocks WHERE version = $version",
|
|
31
|
+
{ version },
|
|
32
|
+
);
|
|
59
33
|
if (row === null) return null;
|
|
60
|
-
return { version: row.version, block: row.block, fetchedAtMs: row.fetched_at_ms };
|
|
34
|
+
return { version: row.version, block: row.block, fetchedAtMs: num(row.fetched_at_ms) };
|
|
61
35
|
},
|
|
62
36
|
|
|
63
|
-
put(scope, pin: ContextPin) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
$
|
|
67
|
-
|
|
68
|
-
$fetchedAtMs: pin.fetchedAtMs,
|
|
69
|
-
});
|
|
37
|
+
async put(scope, pin: ContextPin) {
|
|
38
|
+
await sql`
|
|
39
|
+
INSERT INTO context_blocks (version, scope, block, fetched_at_ms)
|
|
40
|
+
VALUES (${pin.version}, ${scope}, ${pin.block}, ${pin.fetchedAtMs})
|
|
41
|
+
ON CONFLICT (version) DO UPDATE SET fetched_at_ms = excluded.fetched_at_ms`;
|
|
70
42
|
},
|
|
71
43
|
|
|
72
|
-
sessionFor(conversationKey) {
|
|
73
|
-
const row =
|
|
44
|
+
async sessionFor(conversationKey) {
|
|
45
|
+
const row = await db.one<{ session_id: string }>(
|
|
46
|
+
"SELECT session_id FROM agentdox_sessions WHERE conversation_key = $key",
|
|
47
|
+
{ key: conversationKey },
|
|
48
|
+
);
|
|
74
49
|
return row === null ? null : row.session_id;
|
|
75
50
|
},
|
|
76
51
|
|
|
77
|
-
bindSession(conversationKey, scope, sessionId) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
$
|
|
81
|
-
|
|
82
|
-
$createdAtMs: Date.now(),
|
|
83
|
-
});
|
|
52
|
+
async bindSession(conversationKey, scope, sessionId) {
|
|
53
|
+
await sql`
|
|
54
|
+
INSERT INTO agentdox_sessions (conversation_key, scope, session_id, created_at_ms)
|
|
55
|
+
VALUES (${conversationKey}, ${scope}, ${sessionId}, ${Date.now()})
|
|
56
|
+
ON CONFLICT (conversation_key) DO UPDATE SET session_id = excluded.session_id`;
|
|
84
57
|
},
|
|
85
58
|
|
|
86
|
-
prune(maxAgeMs) {
|
|
87
|
-
|
|
88
|
-
|
|
59
|
+
async prune(maxAgeMs) {
|
|
60
|
+
// Age alone is the wrong test: a block older than the staleness TTL may
|
|
61
|
+
// still be PINNED by a live conversation, and deleting it forces that
|
|
62
|
+
// conversation to refetch and re-inject different bytes — a prompt-cache
|
|
63
|
+
// miss caused by housekeeping. Blocks are content-addressed and shared,
|
|
64
|
+
// so the safe set is "old AND referenced by no conversation". With a
|
|
65
|
+
// shared store that now means no conversation on ANY replica.
|
|
66
|
+
const deleted = (await sql`
|
|
67
|
+
DELETE FROM context_blocks
|
|
68
|
+
WHERE fetched_at_ms < ${Date.now() - maxAgeMs}
|
|
69
|
+
AND version NOT IN (SELECT context_version FROM conversations WHERE context_version IS NOT NULL)
|
|
70
|
+
RETURNING version`) as { version: string }[];
|
|
71
|
+
return deleted.length;
|
|
89
72
|
},
|
|
90
73
|
};
|
|
91
74
|
}
|