auto-model-router 0.33.0 → 0.35.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 +10 -0
- package/docs/data-governance.md +42 -1
- package/omp-extension/router-toast.ts +14 -4
- package/omp-extension/toast-logic.ts +20 -2
- package/package.json +1 -1
- package/src/catalog/composite.ts +6 -1
- package/src/cli/config-wizard.ts +2 -0
- package/src/config/defaults.ts +5 -0
- package/src/config/schema.ts +2 -0
- package/src/config/types.ts +9 -0
- package/src/cost/ledger-sql.ts +41 -3
- package/src/server/http.ts +1 -1
- package/src/server/providers.ts +12 -0
- package/src/upstream/anthropic.ts +2 -1
- package/src/upstream/compat.ts +4 -2
- package/src/upstream/openrouter-usage.ts +120 -0
- package/src/upstream/openrouter.ts +6 -1
- package/src/util/schema.ts +148 -19
- package/test/failover.test.ts +42 -1
- package/test/ledger-partitions.test.ts +321 -0
- package/test/openrouter-usage.test.ts +59 -0
- package/test/toast-logic.test.ts +55 -0
- package/test/turn.test.ts +1 -1
- package/test/upstreams.test.ts +26 -0
|
@@ -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.35.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.35.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -1192,6 +1192,16 @@ it lands on the other (`cache: keeping warm …`), and a replica that has served
|
|
|
1192
1192
|
nothing refuses with `402 budget_exceeded` once the shared spend is past its
|
|
1193
1193
|
cap — where the same cap against an empty store serves.
|
|
1194
1194
|
|
|
1195
|
+
On Postgres the `ledger` table is partitioned by UTC day
|
|
1196
|
+
(`PARTITION BY RANGE (created_at_ms)`), so `ledger.retentionDays` drops whole
|
|
1197
|
+
days as partitions instead of deleting rows — at the write rates a shared store
|
|
1198
|
+
is for, a bulk `DELETE` competes for I/O with the inserts it is making room
|
|
1199
|
+
for. Partitions are provisioned a few days ahead on every boot and created on
|
|
1200
|
+
demand if a turn arrives for a day nobody provisioned, so a write never fails
|
|
1201
|
+
for a missing partition. A Postgres ledger created before this is left exactly
|
|
1202
|
+
as it is (Postgres cannot convert a populated table in place) and logs how to
|
|
1203
|
+
convert; see [Data governance](docs/data-governance.md).
|
|
1204
|
+
|
|
1195
1205
|
A SQLite deployment is unchanged: the file is still migrated in place through
|
|
1196
1206
|
the nineteen shipped versions, and both halves live in the one file.
|
|
1197
1207
|
|
package/docs/data-governance.md
CHANGED
|
@@ -166,7 +166,12 @@ one a library default should make for them. (Before v0.21.0 the default was
|
|
|
166
166
|
because the subquery needs the rows that are about to be deleted.
|
|
167
167
|
2. `ollama_meter_samples` past the cutoff. They only calibrate the ledger's own
|
|
168
168
|
Ollama estimate, so they age out with the rows they calibrate.
|
|
169
|
-
3. `ledger` rows past the cutoff.
|
|
169
|
+
3. `ledger` rows past the cutoff. On Postgres the ledger is partitioned by UTC
|
|
170
|
+
day, so every day the cutoff covers *completely* is dropped as a partition —
|
|
171
|
+
a metadata operation — and only the boundary day the cutoff falls inside is
|
|
172
|
+
deleted row by row. `deleted` still counts ROWS: each partition is counted
|
|
173
|
+
before it is dropped, so the number means turns, not tables. SQLite has no
|
|
174
|
+
declarative partitioning and deletes rows as it always has.
|
|
170
175
|
|
|
171
176
|
Then, when anything was deleted, `PRAGMA incremental_vacuum` hands freed pages
|
|
172
177
|
back to the filesystem and `PRAGMA wal_checkpoint(TRUNCATE)` folds the WAL so
|
|
@@ -182,6 +187,42 @@ behaviour and is fine.
|
|
|
182
187
|
honest answer to "how far back does this ledger go now", which is what the
|
|
183
188
|
question was actually about, and it is reported even when nothing was deleted.
|
|
184
189
|
|
|
190
|
+
### Partitions, on Postgres
|
|
191
|
+
|
|
192
|
+
A ledger row is ~2.6 kB, and a thousand-tenant deployment writes 33-165 GB a
|
|
193
|
+
day at 440-2200 writes a second. Deleting that competes for I/O with the
|
|
194
|
+
inserts it is trying to make room for, and leaves bloat autovacuum has to
|
|
195
|
+
chase. So on Postgres `ledger` is `PARTITION BY RANGE (created_at_ms)`, one
|
|
196
|
+
partition per UTC day, and retention becomes a `DROP TABLE` per day.
|
|
197
|
+
|
|
198
|
+
The bounds are the ms-epoch integers the column already holds, not a derived
|
|
199
|
+
timestamp, so every existing query (`created_at_ms >= ?`) prunes partitions on
|
|
200
|
+
its own: no read — export, spend, the caps, the decision trail — knows the
|
|
201
|
+
table is partitioned. The one visible consequence is the primary key, which a
|
|
202
|
+
partitioned table requires to include the partition key: it is
|
|
203
|
+
`(id, created_at_ms)` there. Ids are per-row UUIDs and a re-recorded entry
|
|
204
|
+
carries the same instant, so the `ON CONFLICT DO NOTHING` guard still collapses
|
|
205
|
+
a duplicate write.
|
|
206
|
+
|
|
207
|
+
`migrateStore` provisions yesterday through three days ahead on every boot. A
|
|
208
|
+
row for a day nobody provisioned is not an error either: the insert is retried
|
|
209
|
+
once after `ensureLedgerPartitions` creates that day (and the next few), so a
|
|
210
|
+
turn is never lost because a partition was late.
|
|
211
|
+
|
|
212
|
+
A Postgres ledger created before this shipped **stays exactly as it is**.
|
|
213
|
+
Postgres cannot convert a populated table to a partitioned one in place, and
|
|
214
|
+
copying a billing table at boot is not a failure mode a ledger can have — there
|
|
215
|
+
is no second copy of it. The router logs one line saying retention will keep
|
|
216
|
+
deleting rows there, with the conversion an operator can run deliberately, with
|
|
217
|
+
the router stopped:
|
|
218
|
+
|
|
219
|
+
```sql
|
|
220
|
+
ALTER TABLE ledger RENAME TO ledger_legacy; -- then start the router:
|
|
221
|
+
-- boot recreates it partitioned
|
|
222
|
+
INSERT INTO ledger SELECT * FROM ledger_legacy; -- verify the counts, then
|
|
223
|
+
DROP TABLE ledger_legacy;
|
|
224
|
+
```
|
|
225
|
+
|
|
185
226
|
### The schedule, and the route
|
|
186
227
|
|
|
187
228
|
`createRetentionRunner` (`src/cost/retention.ts`) owns the once-an-hour floor.
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
|
|
28
28
|
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
|
29
29
|
|
|
30
|
-
import { routerAuthHeaders, routerBaseUrl } from "./router-url.ts";
|
|
30
|
+
import { routerAuthHeaders, routerBaseUrl, routerHome } from "./router-url.ts";
|
|
31
|
+
import { readRemoteRouter } from "./remote-logic.ts";
|
|
31
32
|
import { newestId, selectToasts, type ToastDecision } from "./toast-logic.ts";
|
|
32
33
|
|
|
33
34
|
/** Raw router config.yml, or null when there is none to read. */
|
|
@@ -35,8 +36,12 @@ import { newestId, selectToasts, type ToastDecision } from "./toast-logic.ts";
|
|
|
35
36
|
/** Absolute path of the shared embed port file (main session writes it). */
|
|
36
37
|
|
|
37
38
|
// This harness's id, matching the X-Omp-Harness header the router records.
|
|
38
|
-
//
|
|
39
|
-
|
|
39
|
+
// In remote (team) mode the team rewrites that header to the member id from
|
|
40
|
+
// the key, so remote.json's userId is the same fact — and the only way to
|
|
41
|
+
// keep OTHER members' task toasts out, because task turns carry their own
|
|
42
|
+
// session id and cannot be scoped by session. Empty ⇒ toast every harness
|
|
43
|
+
// (single-harness default).
|
|
44
|
+
const HARNESS_ID = process.env.OMP_HARNESS_ID ?? readRemoteRouter(routerHome())?.userId ?? "";
|
|
40
45
|
const POLL_MS = 2_000;
|
|
41
46
|
// The toast explains the choice by default: model, tier, cost, why it was picked
|
|
42
47
|
// and what it was handed. `AUTO_MODEL_ROUTER_TOAST=compact` restores the one-liner.
|
|
@@ -47,6 +52,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
47
52
|
|
|
48
53
|
// The newest ledger entry already toasted. Ledger is `created_at_ms DESC`.
|
|
49
54
|
let lastSeenId: string | null = null;
|
|
55
|
+
// Task session → the model the task last toasted. Task turns belong to
|
|
56
|
+
// their own session ids, so this — not the session filter — is what keeps
|
|
57
|
+
// a task's forty same-model dispatches down to one toast, and surfaces the
|
|
58
|
+
// mid-task model change when an escalation switches the model.
|
|
59
|
+
const taskModels = new Map<string, string | null>();
|
|
50
60
|
|
|
51
61
|
pi.on("session_start", (_event, ctx) => {
|
|
52
62
|
// Headless/print/subagent sessions have no UI to toast into; skip the
|
|
@@ -94,7 +104,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
94
104
|
const entries = body.entries;
|
|
95
105
|
if (!Array.isArray(entries) || entries.length === 0) return;
|
|
96
106
|
|
|
97
|
-
for (const t of selectToasts(entries, lastSeenId, HARNESS_ID, sessionId, VERBOSE)) {
|
|
107
|
+
for (const t of selectToasts(entries, lastSeenId, HARNESS_ID, sessionId, VERBOSE, taskModels)) {
|
|
98
108
|
ctx.ui.notify(t.text, "info");
|
|
99
109
|
}
|
|
100
110
|
lastSeenId = newestId(entries) ?? lastSeenId;
|
|
@@ -91,7 +91,7 @@ export interface ToastDecision {
|
|
|
91
91
|
/** The router's own decision trail, already written for people. */
|
|
92
92
|
reasons?: string[];
|
|
93
93
|
/** Classifier inputs; only a few are worth surfacing. */
|
|
94
|
-
features?: { promptTokens?: number; toolCount?: number; turnDepth?: number; isToolResultContinuation?: boolean } | null;
|
|
94
|
+
features?: { promptTokens?: number; toolCount?: number; turnDepth?: number; isToolResultContinuation?: boolean; isSubagent?: boolean } | null;
|
|
95
95
|
/** Attempt index within the turn; >0 means this served after an escalation. */
|
|
96
96
|
attempt?: number;
|
|
97
97
|
/** Prompt tokens compaction removed before dispatch. */
|
|
@@ -172,6 +172,7 @@ const tokens = (n: number): string => (n >= 1000 ? `${Math.round(n / 100) / 10}k
|
|
|
172
172
|
export function factsOf(d: ToastDecision): string[] {
|
|
173
173
|
const out: string[] = [];
|
|
174
174
|
const f = d.features ?? undefined;
|
|
175
|
+
if (f?.isSubagent === true) out.push("task");
|
|
175
176
|
if (f?.promptTokens !== undefined && f.promptTokens > 0) out.push(`${tokens(f.promptTokens)} prompt`);
|
|
176
177
|
if (d.promptTokensSaved !== undefined && d.promptTokensSaved > 0) out.push(`${tokens(d.promptTokensSaved)} compacted`);
|
|
177
178
|
if (f?.toolCount !== undefined && f.toolCount > 0) out.push(`${f.toolCount} tools`);
|
|
@@ -215,6 +216,14 @@ export function toToastText(d: ToastDecision, verbose = true): string {
|
|
|
215
216
|
*
|
|
216
217
|
* When `harnessId` is non-empty, only entries from that harness are toasted,
|
|
217
218
|
* so multiple harnesses sharing one router don't spam each other's toasts.
|
|
219
|
+
*
|
|
220
|
+
* `ompSessionId` scopes the toast to this interactive session's own turns.
|
|
221
|
+
* Subagent (task) turns carry their OWN session id — the subagent process's —
|
|
222
|
+
* so they never match it and would otherwise be invisible. They are admitted
|
|
223
|
+
* whenever the harness matches, deduplicated per task session on the served
|
|
224
|
+
* model through `subModels` (the caller holds the map across ticks): the
|
|
225
|
+
* first dispatch of a task toasts, a mid-task model change (an escalation)
|
|
226
|
+
* toasts again, and the forty same-model dispatches after it toast nothing.
|
|
218
227
|
*/
|
|
219
228
|
export function selectToasts(
|
|
220
229
|
entries: ToastDecision[],
|
|
@@ -222,6 +231,7 @@ export function selectToasts(
|
|
|
222
231
|
harnessId = "",
|
|
223
232
|
ompSessionId = "",
|
|
224
233
|
verbose = true,
|
|
234
|
+
subModels?: Map<string, string | null>,
|
|
225
235
|
): ToastMessage[] {
|
|
226
236
|
if (lastSeenId === null) return [];
|
|
227
237
|
// `entries` is newest-first. Entries strictly newer than lastSeenId are the
|
|
@@ -235,7 +245,15 @@ export function selectToasts(
|
|
|
235
245
|
if (d === undefined) continue;
|
|
236
246
|
if (d.wasted) continue;
|
|
237
247
|
if (harnessId !== "" && d.harnessId !== harnessId) continue;
|
|
238
|
-
|
|
248
|
+
const isTask = d.features?.isSubagent === true;
|
|
249
|
+
if (!isTask && ompSessionId !== "" && d.ompSessionId !== ompSessionId) continue;
|
|
250
|
+
if (isTask && subModels !== undefined) {
|
|
251
|
+
const session = d.ompSessionId ?? "";
|
|
252
|
+
const model = d.servedSlug ?? d.slug;
|
|
253
|
+
// Same task, same model as last toasted: not a change, not news.
|
|
254
|
+
if (subModels.get(session) === model) continue;
|
|
255
|
+
subModels.set(session, model);
|
|
256
|
+
}
|
|
239
257
|
out.push({ model: d.servedSlug ?? d.slug, tier: d.tier, costUsd: d.reportedUsd, text: toToastText(d, verbose) });
|
|
240
258
|
}
|
|
241
259
|
return out;
|
package/package.json
CHANGED
package/src/catalog/composite.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import type { OllamaAvailability } from "../upstream/ollama.ts";
|
|
23
23
|
import { effectiveOllamaBias, NO_USAGE, type OllamaUsageSource } from "../upstream/ollama-usage.ts";
|
|
24
|
+
import { openRouterServing, type OpenRouterCredits } from "../upstream/openrouter-usage.ts";
|
|
24
25
|
import { mergeSnapshots, type OllamaCatalogSource } from "./ollama-catalog.ts";
|
|
25
26
|
import type { CatalogModel, CatalogShrink, CatalogSnapshot, CatalogSource } from "./types.ts";
|
|
26
27
|
|
|
@@ -34,6 +35,10 @@ export interface CompositeBias {
|
|
|
34
35
|
live?: () => { costBias: number; biasUntilUsage: number };
|
|
35
36
|
/** False when OpenRouter cannot dispatch (no key): its models are listed for metadata only, never served. Default true. */
|
|
36
37
|
serveOpenRouter?: () => boolean;
|
|
38
|
+
/** Last known OpenRouter credit balance (USD), or null when never fetched. Read every combine, no network. */
|
|
39
|
+
openRouterCredits?: () => OpenRouterCredits | null;
|
|
40
|
+
/** Balance at or below which OpenRouter stops serving. 0 disables the gate. */
|
|
41
|
+
minCreditsUsd?: number;
|
|
37
42
|
/** Named upstreams' models, built from the OpenRouter models (twins) and filtered by each upstream's breaker. */
|
|
38
43
|
named?: {
|
|
39
44
|
models(openrouter: readonly CatalogModel[]): readonly CatalogModel[];
|
|
@@ -69,7 +74,7 @@ export function createCompositeCatalog(
|
|
|
69
74
|
|
|
70
75
|
function combine(base: CatalogSnapshot, models: readonly CatalogModel[]): CatalogSnapshot {
|
|
71
76
|
const available = availability.available();
|
|
72
|
-
const serveBase = bias.serveOpenRouter?.() ?? true;
|
|
77
|
+
const serveBase = (bias.serveOpenRouter?.() ?? true) && openRouterServing(bias.openRouterCredits?.() ?? null, bias.minCreditsUsd ?? 0);
|
|
73
78
|
const providerBias = currentBias();
|
|
74
79
|
// Named upstreams: every enabled entry's models, minus those of an upstream in cooldown.
|
|
75
80
|
const namedAll = bias.named?.models(base.models) ?? NO_NAMED;
|
package/src/cli/config-wizard.ts
CHANGED
|
@@ -128,6 +128,8 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
|
|
|
128
128
|
{ path: "openrouter.timeoutMs", label: "Request timeout", kind: "number", min: 1, hint: "ms" },
|
|
129
129
|
{ path: "openrouter.catalogTtlMs", label: "Catalog TTL", kind: "number", min: 1, hint: "ms" },
|
|
130
130
|
{ path: "openrouter.catalogRefreshMs", label: "Catalog refresh", kind: "number", min: 0, hint: "ms, 0=off" },
|
|
131
|
+
{ path: "openrouter.minCreditsUsd", label: "Credit floor USD", kind: "number", min: 0, hint: "0=serve regardless of balance" },
|
|
132
|
+
{ path: "openrouter.usagePollMs", label: "Credits poll", kind: "number", min: 0, hint: "ms, 0=off" },
|
|
131
133
|
],
|
|
132
134
|
},
|
|
133
135
|
{
|
package/src/config/defaults.ts
CHANGED
|
@@ -32,6 +32,11 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
32
32
|
// Refetch the key-scoped catalog every 5 minutes in the background so
|
|
33
33
|
// guardrail changes are picked up without waiting for traffic + TTL.
|
|
34
34
|
catalogRefreshMs: 5 * 60 * 1000,
|
|
35
|
+
// Stop serving OpenRouter at/below this balance (USD); top up to rejoin.
|
|
36
|
+
// 0 disables the gate. Mirrors ollama.blockBelowUsage's intent.
|
|
37
|
+
minCreditsUsd: 5,
|
|
38
|
+
// Balance moves slowly; 10 minutes matches the Ollama usage poll.
|
|
39
|
+
usagePollMs: 10 * 60 * 1000,
|
|
35
40
|
},
|
|
36
41
|
ollama: {
|
|
37
42
|
// Off: a second upstream changes what every turn can route to.
|
package/src/config/schema.ts
CHANGED
|
@@ -33,6 +33,8 @@ const openrouter = z.strictObject({
|
|
|
33
33
|
timeoutMs: z.number().positive().optional(),
|
|
34
34
|
catalogTtlMs: z.number().positive().optional(),
|
|
35
35
|
catalogRefreshMs: z.number().nonnegative().optional(),
|
|
36
|
+
minCreditsUsd: z.number().nonnegative().optional(),
|
|
37
|
+
usagePollMs: z.number().nonnegative().optional(),
|
|
36
38
|
});
|
|
37
39
|
|
|
38
40
|
const ollamaRate = z.strictObject({
|
package/src/config/types.ts
CHANGED
|
@@ -70,6 +70,15 @@ export interface OpenRouterConfig {
|
|
|
70
70
|
catalogTtlMs: number;
|
|
71
71
|
/** Background catalog refresh cadence, ms. 0 disables the periodic refresh. */
|
|
72
72
|
catalogRefreshMs: number;
|
|
73
|
+
/**
|
|
74
|
+
* Balance (USD, `total_credits − total_usage`) at or below which OpenRouter
|
|
75
|
+
* stops serving: its models drop from the catalog until the account is
|
|
76
|
+
* topped up. 0 disables the gate — the 402 breaker still catches the real
|
|
77
|
+
* thing. Default 5.
|
|
78
|
+
*/
|
|
79
|
+
minCreditsUsd: number;
|
|
80
|
+
/** Credits poll interval, ms. 0 disables the poll (and the gate never fires). */
|
|
81
|
+
usagePollMs: number;
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
/**
|
package/src/cost/ledger-sql.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import type { CatalogModel } from "../catalog/types.ts";
|
|
23
23
|
import type { RouterConfig } from "../config/types.ts";
|
|
24
24
|
import { consumePendingEstimate } from "../tokens/estimate.ts";
|
|
25
|
+
import { droppableLedgerPartitions, ensureLedgerPartitions } from "../util/schema.ts";
|
|
25
26
|
import { jsonParam, jsonValue, num, numOrNull, type SqlDb } from "../util/sql.ts";
|
|
26
27
|
import { foldBlendSamples, type BlendSample } from "./blended.ts";
|
|
27
28
|
import { computeCost } from "./forecast.ts";
|
|
@@ -290,7 +291,12 @@ export function createSqlLedger(db: SqlDb, cfg: RouterConfig, deps: LedgerDeps):
|
|
|
290
291
|
deps.findModel(entry.slug) ??
|
|
291
292
|
null;
|
|
292
293
|
const breakdown = model !== null ? computeCost(model, entry.usage) : null;
|
|
293
|
-
|
|
294
|
+
// No conflict target: on Postgres the ledger is partitioned by day, so
|
|
295
|
+
// its unique index has to include `created_at_ms` and `(id)` alone is
|
|
296
|
+
// not an arbiter. A re-recorded entry carries the same instant, which
|
|
297
|
+
// is the case this guard exists for.
|
|
298
|
+
const insert = async (): Promise<void> => {
|
|
299
|
+
await sql`
|
|
294
300
|
INSERT INTO ledger (
|
|
295
301
|
id, created_at_ms, conversation_key, session_id, turn, requested_model, harness_id, omp_session_id,
|
|
296
302
|
slug, served_slug, tier, classification_source, reasons, predicted_usd, reported_usd, usage,
|
|
@@ -309,7 +315,22 @@ export function createSqlLedger(db: SqlDb, cfg: RouterConfig, deps: LedgerDeps):
|
|
|
309
315
|
${entry.promptTokensSaved},
|
|
310
316
|
${entry.scope === undefined || entry.scope === "" ? null : entry.scope}, ${entry.redactions ?? null}
|
|
311
317
|
)
|
|
312
|
-
ON CONFLICT
|
|
318
|
+
ON CONFLICT DO NOTHING`;
|
|
319
|
+
};
|
|
320
|
+
try {
|
|
321
|
+
await insert();
|
|
322
|
+
} catch (err) {
|
|
323
|
+
// A day nobody provisioned: Postgres refuses the row with 23514
|
|
324
|
+
// ("no partition of relation ledger found for row"). The ledger has
|
|
325
|
+
// no CHECK constraints of its own, so on this statement that code
|
|
326
|
+
// can only mean partition routing. Create the day — and the next
|
|
327
|
+
// few, so this happens once rather than daily — and write again. A
|
|
328
|
+
// turn is never lost because a partition was late.
|
|
329
|
+
const code = err !== null && typeof err === "object" && "errno" in err ? String(err.errno) : "";
|
|
330
|
+
if (code !== "23514") throw err;
|
|
331
|
+
await ensureLedgerPartitions(db, entry.createdAtMs);
|
|
332
|
+
await insert();
|
|
333
|
+
}
|
|
313
334
|
// Always consume the pending estimate, even when the turn failed, so a
|
|
314
335
|
// dead turn's bytes can never pair with a later turn's tokens.
|
|
315
336
|
const pending = consumePendingEstimate(entry.conversationKey);
|
|
@@ -520,8 +541,25 @@ export function createSqlLedger(db: SqlDb, cfg: RouterConfig, deps: LedgerDeps):
|
|
|
520
541
|
// swept up too.
|
|
521
542
|
await sql`DELETE FROM feedback WHERE created_at_ms < ${cutoff} OR ledger_id IN (SELECT id FROM ledger WHERE created_at_ms < ${cutoff})`;
|
|
522
543
|
await sql`DELETE FROM ollama_meter_samples WHERE at_ms < ${cutoff}`;
|
|
544
|
+
// Whole days the cutoff covers go as metadata: a DROP of a day's
|
|
545
|
+
// partition is O(1) where deleting its rows is hours of I/O competing
|
|
546
|
+
// with the inserts it is making room for. Counted before the drop, so
|
|
547
|
+
// `deleted` still means rows, not partitions.
|
|
548
|
+
//
|
|
549
|
+
// ponytail: an exact COUNT(*) scans each doomed day once — a fraction of
|
|
550
|
+
// what deleting it costs, but not free. If that scan ever matters, read
|
|
551
|
+
// pg_class.reltuples instead and report the count as an estimate.
|
|
552
|
+
let dropped = 0;
|
|
553
|
+
for (const partition of await droppableLedgerPartitions(db, cutoff)) {
|
|
554
|
+
const rows = (await sql.unsafe(`SELECT COUNT(*) AS n FROM ${partition}`)) as { n: unknown }[];
|
|
555
|
+
dropped += num(rows[0]?.n);
|
|
556
|
+
await sql.unsafe(`DROP TABLE ${partition}`);
|
|
557
|
+
}
|
|
558
|
+
// What the drops could not cover: the boundary day the cutoff falls
|
|
559
|
+
// inside (a partial day, so row-wise), anything in a partition whose
|
|
560
|
+
// bounds could not be read, and every row on SQLite.
|
|
523
561
|
const deleted = (await sql`DELETE FROM ledger WHERE created_at_ms < ${cutoff} RETURNING id`) as { id: string }[];
|
|
524
|
-
return { deleted: deleted.length, oldestKeptMs: await oldest() };
|
|
562
|
+
return { deleted: dropped + deleted.length, oldestKeptMs: await oldest() };
|
|
525
563
|
},
|
|
526
564
|
|
|
527
565
|
async markWasted(id: string): Promise<void> {
|
package/src/server/http.ts
CHANGED
|
@@ -282,7 +282,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
282
282
|
// A Postgres store has no bootstrap of its own to run synchronously, so the
|
|
283
283
|
// shape is created on the way up and every entry point waits for it once.
|
|
284
284
|
// Resolved already on SQLite, where `openDb` just did it.
|
|
285
|
-
const storeReady = postgres ? migrateStore(sqlDb) : Promise.resolve();
|
|
285
|
+
const storeReady = postgres ? migrateStore(sqlDb, log) : Promise.resolve();
|
|
286
286
|
// The ledger reads and writes through the engine-agnostic handle. `findModel`
|
|
287
287
|
// closes over the catalog built just below: a shared store has no catalog
|
|
288
288
|
// cache of its own to price a row from.
|
package/src/server/providers.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { createOllamaClient, type OllamaClient } from "../upstream/ollama.ts";
|
|
|
19
19
|
import { setKnownUpstreamIds } from "../cost/report.ts";
|
|
20
20
|
import { createOllamaUsageSource, type OllamaUsageSource } from "../upstream/ollama-usage.ts";
|
|
21
21
|
import { createOpenRouterClient } from "../upstream/openrouter.ts";
|
|
22
|
+
import { createOpenRouterUsageSource } from "../upstream/openrouter-usage.ts";
|
|
22
23
|
import type { UpstreamClient } from "../upstream/types.ts";
|
|
23
24
|
import { createLogger, type Logger } from "../util/log.ts";
|
|
24
25
|
|
|
@@ -75,6 +76,15 @@ export function createProviders(
|
|
|
75
76
|
// estimate can be scaled to what ollama.com actually bills.
|
|
76
77
|
calibration: { db: sqlDb, ledgerUsd: ollamaLedgerUsd, planCreditsOverrideUsd: cfg.ollama.planCreditsUsd },
|
|
77
78
|
});
|
|
79
|
+
// OpenRouter's credit balance on the same slow cadence: at/below
|
|
80
|
+
// `openrouter.minCreditsUsd` its models drop from the catalog until the
|
|
81
|
+
// account is topped up (a 402 trip only blocks for the rate-limit minute).
|
|
82
|
+
const openrouterUsage = createOpenRouterUsageSource({
|
|
83
|
+
apiKey: () => cfg.openrouter.apiKey,
|
|
84
|
+
pollMs: cfg.openrouter.usagePollMs,
|
|
85
|
+
timeoutMs: 15_000,
|
|
86
|
+
log,
|
|
87
|
+
});
|
|
78
88
|
// Named upstreams (OpenAI, Azure, Anthropic, vLLM…): a client per id, built when
|
|
79
89
|
// first needed and kept — its breaker state must survive config reloads — while
|
|
80
90
|
// the entry it reads is looked up live, so a changed key or URL applies at once.
|
|
@@ -105,6 +115,8 @@ export function createProviders(
|
|
|
105
115
|
usage: ollamaUsage,
|
|
106
116
|
live: () => ({ costBias: cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage }),
|
|
107
117
|
serveOpenRouter: () => cfg.openrouter.apiKey !== "",
|
|
118
|
+
openRouterCredits: () => openrouterUsage.peek(),
|
|
119
|
+
minCreditsUsd: cfg.openrouter.minCreditsUsd,
|
|
108
120
|
named: { models: (base) => staticCatalog.get(base), serving: namedServingOne, bias: (id) => cfg.upstreams.find((u) => u.id === id)?.costBias ?? 1 },
|
|
109
121
|
}),
|
|
110
122
|
ollama,
|
|
@@ -340,6 +340,7 @@ export function classifyAnthropicStatus(id: string, status: number, body: unknow
|
|
|
340
340
|
const message = typeof msg === "string" && msg !== "" ? msg : `${id} HTTP ${status}`;
|
|
341
341
|
const fail = (kind: UpstreamErrorKind, retryable: boolean): UpstreamError => new UpstreamError(kind, status, message, retryable, body);
|
|
342
342
|
if (status === 401 || status === 403) return fail("auth", false);
|
|
343
|
+
if (status === 402) return fail("quota", true);
|
|
343
344
|
if (status === 404) return fail("model_unavailable", true);
|
|
344
345
|
if (status === 413) return fail("context_length", false);
|
|
345
346
|
if (status === 429) return fail("rate_limit", true);
|
|
@@ -431,7 +432,7 @@ export function createAnthropicClient(cfg: RouterConfig, id: string, fetchImpl:
|
|
|
431
432
|
/* status alone */
|
|
432
433
|
}
|
|
433
434
|
const err = classifyAnthropicStatus(id, res.status, body);
|
|
434
|
-
if (err.kind === "rate_limit" || res.status === 529) breaker.trip(err);
|
|
435
|
+
if (err.kind === "rate_limit" || err.kind === "quota" || res.status === 529) breaker.trip(err);
|
|
435
436
|
return err;
|
|
436
437
|
}
|
|
437
438
|
|
package/src/upstream/compat.ts
CHANGED
|
@@ -94,8 +94,10 @@ export function classifyCompatStatus(id: string, status: number, body: unknown):
|
|
|
94
94
|
if (status === 401) return fail("auth", false);
|
|
95
95
|
if (status === 402) return fail("quota", true);
|
|
96
96
|
if (status === 403) return /credit|quota|plan|limit|billing/i.test(message) ? fail("quota", true) : fail("moderation", true);
|
|
97
|
-
// OpenAI reports an exhausted balance as a 429 with insufficient_quota
|
|
98
|
-
|
|
97
|
+
// OpenAI reports an exhausted balance as a 429 with insufficient_quota; Kimi
|
|
98
|
+
// ("exceed your available credits given your current in-flight requests") says
|
|
99
|
+
// credits. Both are the account, not the moment.
|
|
100
|
+
if (status === 429) return /insufficient_quota|exceeded your current quota|available credits|in-flight requests/i.test(`${code} ${message}`) ? fail("quota", true) : fail("rate_limit", true);
|
|
99
101
|
if (status === 404) return fail("model_unavailable", true);
|
|
100
102
|
if (status === 400 || status === 413 || status === 422) {
|
|
101
103
|
if (/context|too many tokens|token limit|maximum context|too long/i.test(message)) return fail("context_length", false);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenRouter credit balance, polled on a slow interval and read without
|
|
3
|
+
* network on every routing decision (`GET /api/v1/credits`: management-key
|
|
4
|
+
* scope, `data.total_credits - data.total_usage` = the balance that gates a
|
|
5
|
+
* dispatch — OpenRouter reserves credits per in-flight request, so the
|
|
6
|
+
* UNRESERVED balance is what a 402 means).
|
|
7
|
+
*
|
|
8
|
+
* Mirrors the Ollama usage source: a key that is empty now may be set from
|
|
9
|
+
* the dashboard later, so the reader stays live and idles until it is not.
|
|
10
|
+
* A failed or unparseable poll keeps the last reading — the balance moves
|
|
11
|
+
* slowly, and hiding a provider on a poll glitch would route around it for
|
|
12
|
+
* nothing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Logger } from "../util/log.ts";
|
|
16
|
+
|
|
17
|
+
/** Minimal fetch surface, injectable for tests. */
|
|
18
|
+
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
19
|
+
|
|
20
|
+
export interface OpenRouterCredits {
|
|
21
|
+
/** Balance in USD: total_credits − total_usage. Null when the payload lacks it. */
|
|
22
|
+
remainingUsd: number | null;
|
|
23
|
+
/** Lifetime credits bought and used, for the dashboard. */
|
|
24
|
+
totalCreditsUsd: number | null;
|
|
25
|
+
totalUsageUsd: number | null;
|
|
26
|
+
fetchedAtMs: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OpenRouterUsageSource {
|
|
30
|
+
/** Latest reading, refreshed when older than the poll interval; last good value on failure. */
|
|
31
|
+
get(): Promise<OpenRouterCredits | null>;
|
|
32
|
+
/** Last fetched value without touching the network. */
|
|
33
|
+
peek(): OpenRouterCredits | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const NO_OPENROUTER_USAGE: OpenRouterUsageSource = { get: async () => null, peek: () => null };
|
|
37
|
+
|
|
38
|
+
function parseCredits(json: unknown): OpenRouterCredits | null {
|
|
39
|
+
if (typeof json !== "object" || json === null || !("data" in json)) return null;
|
|
40
|
+
const data: unknown = json.data;
|
|
41
|
+
if (typeof data !== "object" || data === null) return null;
|
|
42
|
+
const d = data as Record<string, unknown>; // narrowed above; cast names the wire shape once
|
|
43
|
+
const total = typeof d.total_credits === "number" ? d.total_credits : null;
|
|
44
|
+
const used = typeof d.total_usage === "number" ? d.total_usage : null;
|
|
45
|
+
if (total === null || used === null) return null;
|
|
46
|
+
return { remainingUsd: total - used, totalCreditsUsd: total, totalUsageUsd: used, fetchedAtMs: Date.now() };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createOpenRouterUsageSource(opts: {
|
|
50
|
+
apiKey: () => string;
|
|
51
|
+
pollMs: number;
|
|
52
|
+
timeoutMs: number;
|
|
53
|
+
log: Logger;
|
|
54
|
+
fetchImpl?: FetchLike;
|
|
55
|
+
root?: string;
|
|
56
|
+
}): OpenRouterUsageSource {
|
|
57
|
+
if (opts.pollMs <= 0) return NO_OPENROUTER_USAGE;
|
|
58
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
59
|
+
const root = (opts.root ?? "https://openrouter.ai/api/v1").replace(/\/+$/, "");
|
|
60
|
+
let current: OpenRouterCredits | null = null;
|
|
61
|
+
let checkedAtMs = 0;
|
|
62
|
+
let inflight: Promise<OpenRouterCredits | null> | null = null;
|
|
63
|
+
let warned = false;
|
|
64
|
+
|
|
65
|
+
async function refresh(): Promise<OpenRouterCredits | null> {
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetchImpl(`${root}/credits`, {
|
|
68
|
+
headers: { authorization: `Bearer ${opts.apiKey()}` },
|
|
69
|
+
signal: AbortSignal.timeout(opts.timeoutMs),
|
|
70
|
+
});
|
|
71
|
+
if (res.ok) {
|
|
72
|
+
const parsed = parseCredits(await res.json());
|
|
73
|
+
if (parsed !== null) {
|
|
74
|
+
current = parsed;
|
|
75
|
+
warned = false;
|
|
76
|
+
} else if (!warned) {
|
|
77
|
+
warned = true;
|
|
78
|
+
opts.log.warn("openrouter credits payload had no recognisable fields; credit gate keeps its last reading");
|
|
79
|
+
}
|
|
80
|
+
} else if (!warned) {
|
|
81
|
+
warned = true;
|
|
82
|
+
opts.log.warn("openrouter credits endpoint unavailable; credit gate keeps its last reading", { status: res.status });
|
|
83
|
+
}
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (!warned) {
|
|
86
|
+
warned = true;
|
|
87
|
+
opts.log.warn("openrouter credits fetch failed; credit gate keeps its last reading", {
|
|
88
|
+
error: err instanceof Error ? err.message : String(err),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
checkedAtMs = Date.now();
|
|
93
|
+
return current;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
async get() {
|
|
98
|
+
if (opts.apiKey() === "") return null;
|
|
99
|
+
if (Date.now() - checkedAtMs < opts.pollMs) return current;
|
|
100
|
+
inflight ??= refresh().finally(() => {
|
|
101
|
+
inflight = null;
|
|
102
|
+
});
|
|
103
|
+
return inflight;
|
|
104
|
+
},
|
|
105
|
+
peek: () => current,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* True while OpenRouter may serve: the balance is strictly above the floor, or
|
|
111
|
+
* unknown (a poll that has not landed yet, no key for the endpoint, a payload
|
|
112
|
+
* change). Hiding on unknown would take a provider down for a dashboard's
|
|
113
|
+
* missing field — fail open instead; the 402 breaker still catches the real
|
|
114
|
+
* thing.
|
|
115
|
+
*/
|
|
116
|
+
export function openRouterServing(credits: OpenRouterCredits | null, minCreditsUsd: number): boolean {
|
|
117
|
+
if (minCreditsUsd <= 0) return true;
|
|
118
|
+
if (credits === null || credits.remainingUsd === null) return true;
|
|
119
|
+
return credits.remainingUsd > minCreditsUsd;
|
|
120
|
+
}
|
|
@@ -55,7 +55,12 @@ function classifyStatus(status: number, body: unknown): UpstreamError {
|
|
|
55
55
|
// retrying is pointless.
|
|
56
56
|
if (status === 401) return fail("auth", false);
|
|
57
57
|
// 402 = out of credits; retrying changes nothing, only topping up does.
|
|
58
|
-
|
|
58
|
+
// EXCEPT the concurrency shape: OpenRouter reserves credits for in-flight
|
|
59
|
+
// requests, so a burst can exhaust the UNRESERVED balance on an account
|
|
60
|
+
// with plenty left ("would exceed your available credits given your
|
|
61
|
+
// current in-flight requests"). That clears when the streams settle —
|
|
62
|
+
// a moment, not an account state — so fail over like a rate limit.
|
|
63
|
+
if (status === 402) return /in-flight requests/i.test(message) ? fail("rate_limit", true) : fail("auth", false);
|
|
59
64
|
// 403 = provider content-moderation or per-model policy gate (prompt-injection
|
|
60
65
|
// block, age/data-policy confirmation). This indicts the model/provider, NOT
|
|
61
66
|
// the key: siblings routinely serve the same content. Retryable so the turn
|