auto-model-router 0.30.3 → 0.32.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 +32 -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/eval/calibrate.ts +47 -12
- package/src/eval/run.ts +18 -2
- package/src/lib.ts +6 -2
- package/src/router/candidates.ts +7 -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 +11 -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 +109 -46
- package/src/server/providers.ts +18 -4
- package/src/server/turn.ts +32 -9
- package/src/tokens/estimate.ts +16 -6
- package/src/upstream/ollama-usage.ts +21 -11
- 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 +1 -0
- package/src/wire/types.ts +7 -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 +73 -16
- package/test/executable.test.ts +6 -6
- package/test/exploration.test.ts +19 -20
- package/test/failover.test.ts +22 -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/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
package/src/server/http.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { mkdirSync } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
3
4
|
import type { Server } from "bun";
|
|
4
5
|
import { createProviders } from "./providers.ts";
|
|
5
6
|
import { createBridgeFromConfig } from "../context/index.ts";
|
|
6
7
|
import { createFeedbackStore, type Verdict } from "../cost/feedback.ts";
|
|
7
|
-
import {
|
|
8
|
+
import { createSqlLedger } from "../cost/ledger-sql.ts";
|
|
8
9
|
import { createRetentionRunner } from "../cost/retention.ts";
|
|
9
10
|
import { redactionRulesFor } from "../config/redaction.ts";
|
|
10
11
|
import { createSessionOverrides } from "./overrides.ts";
|
|
@@ -18,7 +19,7 @@ import { runEval, type Completer } from "../eval/run.ts";
|
|
|
18
19
|
import type { ToolSpec } from "../eval/agentic.ts";
|
|
19
20
|
import type { ToolCall } from "../upstream/types.ts";
|
|
20
21
|
import type { QualityAxis } from "../config/types.ts";
|
|
21
|
-
import { fitCalibration, pickAnchors, toLocalFeedScores, MIN_ANCHORS } from "../eval/calibrate.ts";
|
|
22
|
+
import { fitCalibration, hardRaw, pickAnchors, toLocalFeedScores, MIN_ANCHORS, PUBLISH_MIN_R } from "../eval/calibrate.ts";
|
|
22
23
|
import { makeJudge } from "../eval/judge.ts";
|
|
23
24
|
import { loadLocalScores, saveLocalScores } from "../catalog/benchmark-feeds.ts";
|
|
24
25
|
import { advise } from "./advise.ts";
|
|
@@ -27,11 +28,11 @@ import { baselinePrices, buildUsageReport, renderUsageReport } from "../cost/rep
|
|
|
27
28
|
import { decisionEntries, exportCsv, exportRows, feedbackView, harnessScopeParam, spendUsdSince } from "../cost/views.ts";
|
|
28
29
|
import { anthropicErrorResponse, countAnthropicTokens, createMessagesWire } from "../wire/anthropic/messages.ts";
|
|
29
30
|
import { buildDailySummary, createKv, markSummaryShown, renderDailySummary, summaryDue, summaryHasNews, type SummaryOllama } from "../cost/summary.ts";
|
|
30
|
-
import type {
|
|
31
|
+
import type { AsyncLedger, ModelTrust } from "../cost/types.ts";
|
|
31
32
|
import { createRouter } from "../router/index.ts";
|
|
32
33
|
import { createConversationStore } from "../router/state.ts";
|
|
33
34
|
import { UpstreamError } from "../upstream/types.ts";
|
|
34
|
-
import { apiKeySource, ollamaKeySource } from "../config/load.ts";
|
|
35
|
+
import { apiKeySource, ollamaKeySource, routerHome } from "../config/load.ts";
|
|
35
36
|
import { ollamaMeter } from "../upstream/ollama-usage.ts";
|
|
36
37
|
import { routerConfigPath } from "../cli/config-cmd.ts";
|
|
37
38
|
import { applyConfigPatch, touched } from "../config/apply.ts";
|
|
@@ -40,6 +41,8 @@ import { PINNED_CONFIG_PATHS, watchConfig } from "../config/hot-reload.ts";
|
|
|
40
41
|
import type { RouterConfig } from "../config/types.ts";
|
|
41
42
|
import { createLogger } from "../util/log.ts";
|
|
42
43
|
import { openDb } from "../util/sqlite.ts";
|
|
44
|
+
import { dialectOf, openSqlDb } from "../util/sql.ts";
|
|
45
|
+
import { migrateStore } from "../util/schema.ts";
|
|
43
46
|
import { WireErrorException, renderErrorEnvelope } from "../wire/openai/errors.ts";
|
|
44
47
|
import { renderModelList } from "../wire/openai/models.ts";
|
|
45
48
|
import { parseChatRequest } from "../wire/openai/request.ts";
|
|
@@ -110,14 +113,14 @@ export interface RouterStats {
|
|
|
110
113
|
* computed over a bounded tail of recent entries; the headline spend numbers
|
|
111
114
|
* use `spendSince`, which is exact.
|
|
112
115
|
*/
|
|
113
|
-
export function computeStats(ledger:
|
|
116
|
+
export async function computeStats(ledger: AsyncLedger, opts?: { windowDays?: number; nowMs?: number }): Promise<RouterStats> {
|
|
114
117
|
const nowMs = opts?.nowMs ?? Date.now();
|
|
115
118
|
const windowDays = opts?.windowDays;
|
|
116
119
|
const cutoffMs = windowDays === undefined ? 0 : nowMs - windowDays * 86_400_000;
|
|
117
120
|
|
|
118
121
|
// 100k turns is operational eternity for a single-operator router; the cap
|
|
119
122
|
// only bounds memory on this read, never what the ledger retains.
|
|
120
|
-
const entries = ledger.recentEntries(100_000).filter((e) => e.createdAtMs >= cutoffMs);
|
|
123
|
+
const entries = (await ledger.recentEntries(100_000)).filter((e) => e.createdAtMs >= cutoffMs);
|
|
121
124
|
|
|
122
125
|
const dayStart = new Date(nowMs);
|
|
123
126
|
dayStart.setHours(0, 0, 0, 0);
|
|
@@ -157,16 +160,16 @@ export function computeStats(ledger: Ledger, opts?: { windowDays?: number; nowMs
|
|
|
157
160
|
return {
|
|
158
161
|
generatedAtMs: nowMs,
|
|
159
162
|
windowDays: windowDays ?? null,
|
|
160
|
-
spendTodayUsd: ledger.spendSince(dayStart.getTime()),
|
|
161
|
-
spend7dUsd: ledger.spendSince(nowMs - 7 * 86_400_000),
|
|
162
|
-
spendAllTimeUsd: ledger.spendSince(0),
|
|
163
|
+
spendTodayUsd: await ledger.spendSince(dayStart.getTime()),
|
|
164
|
+
spend7dUsd: await ledger.spendSince(nowMs - 7 * 86_400_000),
|
|
165
|
+
spendAllTimeUsd: await ledger.spendSince(0),
|
|
163
166
|
windowSpendUsd,
|
|
164
167
|
requests: entries.length,
|
|
165
168
|
escalations,
|
|
166
169
|
escalationRate: entries.length > 0 ? escalations / entries.length : 0,
|
|
167
170
|
meanPredictionError: errorSamples > 0 ? errorSum / errorSamples : null,
|
|
168
171
|
perModel: rows,
|
|
169
|
-
trust: ledger.allTrust(),
|
|
172
|
+
trust: await ledger.allTrust(),
|
|
170
173
|
};
|
|
171
174
|
}
|
|
172
175
|
|
|
@@ -250,16 +253,47 @@ function clampDays(raw: string | null, dflt: number): number {
|
|
|
250
253
|
export function startServer(cfg: RouterConfig): StartedServer {
|
|
251
254
|
const log = createLogger(cfg.logLevel);
|
|
252
255
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
256
|
+
// `:memory:` names a store that cannot be shared: a second handle gets its
|
|
257
|
+
// OWN empty database, so the ledger views would query a table that does not
|
|
258
|
+
// exist there. Resolve it to a private file instead — still discarded, still
|
|
259
|
+
// isolated per server, but one store that both handles agree on.
|
|
260
|
+
const memoryStore = cfg.ledger.path === ":memory:" ? mkdtempSync(join(tmpdir(), "amr-mem-")) : null;
|
|
261
|
+
// The config is NOT rewritten: a caller that asked for `:memory:` still
|
|
262
|
+
// reads `:memory:` back, and `reconfigure` refuses a ledger-path change by
|
|
263
|
+
// comparing against what it was given.
|
|
264
|
+
const storePath = memoryStore === null ? cfg.ledger.path : join(memoryStore, "router.db");
|
|
265
|
+
// Two stores, chosen by what the data is for:
|
|
266
|
+
// - the SHARED store (`ledger.path`, SQLite file or Postgres URL) holds
|
|
267
|
+
// what a second replica must see the same copy of: the turn rows a cap
|
|
268
|
+
// counts, conversation routing memory, context blocks.
|
|
269
|
+
// - the LOCAL store is always a SQLite file and holds only caches — the
|
|
270
|
+
// catalog payload, benchmark feeds, the once-a-day summary marker.
|
|
271
|
+
// Sharing a cache buys contention and nothing else, and its one durable
|
|
272
|
+
// output (`local_scores`) belongs to the machine that measured it.
|
|
273
|
+
// On SQLite they are the same file, which is what every existing install
|
|
274
|
+
// already has.
|
|
275
|
+
const postgres = dialectOf(storePath) === "postgres";
|
|
276
|
+
const cachePath = postgres ? join(routerHome(), "cache.db") : storePath;
|
|
277
|
+
mkdirSync(dirname(cachePath), { recursive: true });
|
|
278
|
+
// `openDb` is the migration path for a SQLite file: nineteen versions, in
|
|
279
|
+
// order, on whatever an older release left behind.
|
|
280
|
+
const db = openDb(cachePath);
|
|
281
|
+
const sqlDb = openSqlDb(storePath);
|
|
282
|
+
// A Postgres store has no bootstrap of its own to run synchronously, so the
|
|
283
|
+
// shape is created on the way up and every entry point waits for it once.
|
|
284
|
+
// Resolved already on SQLite, where `openDb` just did it.
|
|
285
|
+
const storeReady = postgres ? migrateStore(sqlDb) : Promise.resolve();
|
|
286
|
+
// The ledger reads and writes through the engine-agnostic handle. `findModel`
|
|
287
|
+
// closes over the catalog built just below: a shared store has no catalog
|
|
288
|
+
// cache of its own to price a row from.
|
|
289
|
+
const ledger = createSqlLedger(sqlDb, cfg, { findModel: (slug: string) => catalog.find(slug) });
|
|
290
|
+
const providers = createProviders(cfg, db, sqlDb, log);
|
|
257
291
|
const { upstream, catalog, ollama, ollamaServing, ollamaUsage, ollamaCostScale } = providers;
|
|
258
|
-
const conversations = createConversationStore(
|
|
292
|
+
const conversations = createConversationStore(sqlDb);
|
|
259
293
|
const router = createRouter({ config: cfg, catalog, ledger, conversations, upstream });
|
|
260
|
-
const context = createBridgeFromConfig(cfg,
|
|
294
|
+
const context = createBridgeFromConfig(cfg, sqlDb);
|
|
261
295
|
const overrides = createSessionOverrides();
|
|
262
|
-
const feedback = createFeedbackStore(
|
|
296
|
+
const feedback = createFeedbackStore(sqlDb);
|
|
263
297
|
const kv = createKv(db);
|
|
264
298
|
/**
|
|
265
299
|
* Background local-benchmark runs. In memory and not persisted: a run is an explicit,
|
|
@@ -341,9 +375,10 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
341
375
|
// run a whole-ledger delete more often than that. It reads the window live,
|
|
342
376
|
// so a hot reload that lowers it applies on the next tick.
|
|
343
377
|
const retention = createRetentionRunner({ ledger, retentionDays: () => cfg.ledger.retentionDays });
|
|
344
|
-
const retain = (): void => {
|
|
378
|
+
const retain = async (): Promise<void> => {
|
|
379
|
+
await storeReady;
|
|
345
380
|
try {
|
|
346
|
-
const result = retention.maybeRun();
|
|
381
|
+
const result = await retention.maybeRun();
|
|
347
382
|
if (result !== null && result.deleted > 0) {
|
|
348
383
|
log.info("pruned ledger rows past retention", { deleted: result.deleted, retentionDays: cfg.ledger.retentionDays });
|
|
349
384
|
}
|
|
@@ -355,8 +390,12 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
355
390
|
// One housekeeping timer for all three tables. `unref`'d so it never holds
|
|
356
391
|
// the process open.
|
|
357
392
|
const pruneTimer = setInterval(() => {
|
|
393
|
+
// Fire and forget, with both halves guarded: a housekeeping failure must
|
|
394
|
+
// never become an unhandled rejection that takes the process down.
|
|
395
|
+
void (async () => {
|
|
396
|
+
await storeReady;
|
|
358
397
|
try {
|
|
359
|
-
const dropped = conversations.prune(cfg.ledger.conversationTtlMs);
|
|
398
|
+
const dropped = await conversations.prune(cfg.ledger.conversationTtlMs);
|
|
360
399
|
if (dropped > 0) log.debug("pruned stale conversations", { dropped });
|
|
361
400
|
} catch (err) {
|
|
362
401
|
log.warn("conversation prune failed", { error: err instanceof Error ? err.message : String(err) });
|
|
@@ -366,18 +405,21 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
366
405
|
// block of that age has no future reader. Nothing else reclaims these:
|
|
367
406
|
// blocks are content-addressed and shared, so they accumulated for the
|
|
368
407
|
// life of the install (measured: 220 rows / 2.7 MB, 68 unreferenced).
|
|
369
|
-
const dropped = context.pruneBlocks(cfg.context.maxStalenessMs);
|
|
408
|
+
const dropped = await context.pruneBlocks(cfg.context.maxStalenessMs);
|
|
370
409
|
if (dropped > 0) log.debug("pruned unreferenced context blocks", { dropped });
|
|
371
410
|
} catch (err) {
|
|
372
411
|
log.warn("context block prune failed", { error: err instanceof Error ? err.message : String(err) });
|
|
373
412
|
}
|
|
374
|
-
|
|
413
|
+
await retain();
|
|
414
|
+
})();
|
|
375
415
|
}, 60_000);
|
|
376
416
|
pruneTimer.unref();
|
|
377
417
|
|
|
378
418
|
// Once shortly after boot, so a lowered window takes effect without waiting
|
|
379
419
|
// out an hour; the runner's own floor governs everything after that.
|
|
380
|
-
|
|
420
|
+
// `void`: a boot-time prune failure is logged inside `retain`, and an
|
|
421
|
+
// unhandled rejection here would take the process down.
|
|
422
|
+
setTimeout(() => void retain(), 5_000).unref();
|
|
381
423
|
|
|
382
424
|
// Periodically refetch the (key-scoped) catalog in the background so
|
|
383
425
|
// guardrail/preference changes are picked up without needing traffic and a
|
|
@@ -483,6 +525,10 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
483
525
|
// SECONDS (max 255), so convert from the ms upstream timeout and cap.
|
|
484
526
|
idleTimeout: Math.min(Math.ceil(cfg.openrouter.timeoutMs / 1000), 255),
|
|
485
527
|
async fetch(req: Request): Promise<Response> {
|
|
528
|
+
// One await on the first request of a Postgres deployment, already
|
|
529
|
+
// resolved on SQLite: no route may read a table the bootstrap has
|
|
530
|
+
// not created yet.
|
|
531
|
+
await storeReady;
|
|
486
532
|
// Reject requests whose Host header does not name a loopback address
|
|
487
533
|
// when the server is bound to loopback. This blunts DNS rebinding: a
|
|
488
534
|
// malicious page resolving a host to 127.0.0.1 sends its own domain as
|
|
@@ -523,17 +569,19 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
523
569
|
}
|
|
524
570
|
if (req.method === "POST" && url.pathname === "/v1/messages/count_tokens") {
|
|
525
571
|
try {
|
|
526
|
-
return json({
|
|
572
|
+
return json({
|
|
573
|
+
input_tokens: countAnthropicTokens(await req.json(), cfg.anthropic.models, await ledger.tokenRatio("anthropic")),
|
|
574
|
+
});
|
|
527
575
|
} catch (err) {
|
|
528
576
|
if (err instanceof WireErrorException) return anthropicErrorResponse(err.wireError);
|
|
529
577
|
return anthropicErrorResponse({ status: 400, code: "invalid_json", message: err instanceof Error ? err.message : "request body is not valid JSON" });
|
|
530
578
|
}
|
|
531
579
|
}
|
|
532
580
|
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
533
|
-
return json(renderModelList(cfg, ledger.blendedRate(cfg.ledger.blendWindowDays)));
|
|
581
|
+
return json(renderModelList(cfg, await ledger.blendedRate(cfg.ledger.blendWindowDays)));
|
|
534
582
|
}
|
|
535
583
|
if (req.method === "GET" && url.pathname === "/v1/router/stats") {
|
|
536
|
-
return json(computeStats(ledger));
|
|
584
|
+
return json(await computeStats(ledger));
|
|
537
585
|
}
|
|
538
586
|
if (req.method === "GET" && url.pathname === "/v1/router/catalog") {
|
|
539
587
|
// The catalog as data, judged under `?policy=` (the X-Omp-Policy
|
|
@@ -576,15 +624,15 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
576
624
|
if (!Number.isFinite(since)) return wireErrorResponse({ status: 400, code: "invalid_request_error", message: "sinceMs required" });
|
|
577
625
|
// `scope` narrows to one agentdox context scope: a project's own spend.
|
|
578
626
|
const contextScope = url.searchParams.get("scope") ?? "";
|
|
579
|
-
return json({ sinceMs: since, usd: spendUsdSince(
|
|
627
|
+
return json({ sinceMs: since, usd: await spendUsdSince(sqlDb, since, harnessScopeParam(url.searchParams.get("harness")), contextScope), ...(contextScope === "" ? {} : { scope: contextScope }) });
|
|
580
628
|
}
|
|
581
629
|
if (req.method === "GET" && url.pathname === "/v1/router/feedback") {
|
|
582
630
|
const days = clampDays(url.searchParams.get("days"), 30);
|
|
583
|
-
return json({ days, ...feedbackView(
|
|
631
|
+
return json({ days, ...await feedbackView(sqlDb, Date.now() - days * 86_400_000, harnessScopeParam(url.searchParams.get("harness"))) });
|
|
584
632
|
}
|
|
585
633
|
if (req.method === "GET" && url.pathname === "/v1/router/export") {
|
|
586
634
|
const days = clampDays(url.searchParams.get("days"), 30);
|
|
587
|
-
const rows = exportRows(
|
|
635
|
+
const rows = await exportRows(sqlDb, Date.now() - days * 86_400_000, harnessScopeParam(url.searchParams.get("harness")));
|
|
588
636
|
if (url.searchParams.get("format") === "json") return json({ days, rows });
|
|
589
637
|
return new Response(exportCsv(rows), { headers: { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="auto-model-router-export-${new Date().toISOString().slice(0, 10)}.csv"` } });
|
|
590
638
|
}
|
|
@@ -593,7 +641,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
593
641
|
// optional harness scope (the X-Omp-Harness header value).
|
|
594
642
|
const windowDays = clampDays(url.searchParams.get("days"), 7);
|
|
595
643
|
const harnessId = url.searchParams.get("harness") ?? "";
|
|
596
|
-
const report = buildUsageReport(
|
|
644
|
+
const report = await buildUsageReport(sqlDb, { windowDays, harnessId, baselines: baselinePrices(cfg.report.baselines, (s) => catalog.find(s)) });
|
|
597
645
|
// ?format=text: the rendered report for harnesses without a renderer of their own (the Hermes plugin).
|
|
598
646
|
if (url.searchParams.get("format") === "text") return new Response(renderUsageReport(report), { headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
599
647
|
return json(report);
|
|
@@ -627,13 +675,13 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
627
675
|
if (auto && !cfg.report.dailySummary) return json({ due: false, reason: "report.dailySummary is off", summary: null });
|
|
628
676
|
if (auto && !summaryDue(kv, harnessId)) return json({ due: false, reason: "posted in the last 20h", summary: null });
|
|
629
677
|
const meter = ollamaMeter(ollamaUsage.peek(), cfg.ollama.planCreditsUsd);
|
|
630
|
-
const runway = ollamaRunway(meter, ledger.providerSpendSince
|
|
678
|
+
const runway = ollamaRunway(meter, await ledger.providerSpendSince("ollama/", Date.now() - 7 * 86_400_000), ollamaUsage.calibration()?.factor ?? 1);
|
|
631
679
|
const ollamaSummary: SummaryOllama | null =
|
|
632
680
|
!cfg.ollama.enabled || meter === null ? null : { plan: meter.plan ?? null, usedUsd: meter.usedUsd, creditsUsd: meter.creditsUsd, runwayDays: runway?.days ?? null };
|
|
633
|
-
const summary = buildDailySummary(
|
|
681
|
+
const summary = await buildDailySummary(sqlDb, {
|
|
634
682
|
harnessId,
|
|
635
683
|
baselines: baselinePrices(cfg.report.baselines, (s) => catalog.find(s)),
|
|
636
|
-
spikes: ledger.softFailureSpikes
|
|
684
|
+
spikes: await ledger.softFailureSpikes(),
|
|
637
685
|
ollama: ollamaSummary,
|
|
638
686
|
});
|
|
639
687
|
if (auto && !summaryHasNews(summary)) return json({ due: false, reason: "nothing to report", summary: null });
|
|
@@ -651,7 +699,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
651
699
|
const sinceRaw = Number.parseInt(url.searchParams.get("since") ?? "", 10);
|
|
652
700
|
const daysRaw = url.searchParams.get("days");
|
|
653
701
|
const sinceMs = Number.isFinite(sinceRaw) ? sinceRaw : daysRaw === null ? 0 : Date.now() - clampDays(daysRaw, 30) * 86_400_000;
|
|
654
|
-
const entries = decisionEntries(
|
|
702
|
+
const entries = await decisionEntries(sqlDb, {
|
|
655
703
|
sinceMs,
|
|
656
704
|
harness: harnessScopeParam(url.searchParams.get("harness")),
|
|
657
705
|
limit,
|
|
@@ -717,7 +765,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
717
765
|
// ledger file by design, so this route is the only way it can act
|
|
718
766
|
// on its own retention policy — and the once-an-hour floor is the
|
|
719
767
|
// runner's, not the caller's, so calling it in a loop is harmless.
|
|
720
|
-
const result = retention.runNow();
|
|
768
|
+
const result = await retention.runNow();
|
|
721
769
|
if (result.deleted > 0) log.info("pruned ledger rows past retention", { deleted: result.deleted, retentionDays: cfg.ledger.retentionDays });
|
|
722
770
|
return json({ ...result, retentionDays: cfg.ledger.retentionDays });
|
|
723
771
|
}
|
|
@@ -812,12 +860,15 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
812
860
|
});
|
|
813
861
|
const target = results[0]!;
|
|
814
862
|
const published = (s: string, axis: QualityAxis): number | undefined => models.find((m) => m.slug === s)?.quality[axis];
|
|
815
|
-
const cal = fitCalibration(results.slice(1), published);
|
|
863
|
+
const cal = fitCalibration(results.slice(1), published, hardRaw);
|
|
816
864
|
const authorOf = (s: string): string => models.find((m) => m.slug === s)?.author ?? "";
|
|
817
|
-
const fresh = toLocalFeedScores([target], cal, authorOf);
|
|
818
|
-
|
|
865
|
+
const fresh = toLocalFeedScores([target], cal, authorOf, hardRaw);
|
|
866
|
+
// The fit is reported, not just used: `r` and `n` say whether the numbers deserve
|
|
867
|
+
// belief, and an axis dropped for a weak fit should say so rather than vanish.
|
|
868
|
+
const fitDetail = Object.fromEntries(Object.entries(cal).map(([axis, f]) => [axis, { r: Number(f.r.toFixed(3)), n: f.n, published: f.r >= PUBLISH_MIN_R }]));
|
|
869
|
+
const shared = { slug, anchors, raw: target.axes, rawHard: target.axesHard, byComplexity: target.byComplexity, repeats: target.repeats, spread: target.spread, errors: target.errors, fit: fitDetail, publishMinR: PUBLISH_MIN_R, tookMs: Date.now() - started };
|
|
819
870
|
if (fresh.length === 0) {
|
|
820
|
-
benchmarkJobs.set(jobId, { ...benchmarkJobs.get(jobId)!, state: "done", result: { ...shared, calibrated: null, applied: false, reason:
|
|
871
|
+
benchmarkJobs.set(jobId, { ...benchmarkJobs.get(jobId)!, state: "done", result: { ...shared, calibrated: null, applied: false, reason: `no axis produced a fit at r >= ${PUBLISH_MIN_R} on the hard band; try more or better-spread anchors` } });
|
|
821
872
|
return;
|
|
822
873
|
}
|
|
823
874
|
// Merge, never replace: other models' measurements are not this run's to discard.
|
|
@@ -850,10 +901,10 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
850
901
|
}
|
|
851
902
|
const target =
|
|
852
903
|
typeof body?.ledgerId === "string"
|
|
853
|
-
? (ledger.recentEntries(1_000).find((e) => e.id === body.ledgerId) ?? null)
|
|
854
|
-
: (ledger.latestForSession
|
|
904
|
+
? ((await ledger.recentEntries(1_000)).find((e) => e.id === body.ledgerId) ?? null)
|
|
905
|
+
: ((await ledger.latestForSession(session)) ?? null);
|
|
855
906
|
if (target === null) return wireErrorResponse({ status: 404, code: "not_found", message: "no routed turn for that session yet" });
|
|
856
|
-
const id = feedback.record({
|
|
907
|
+
const id = await feedback.record({
|
|
857
908
|
ledgerId: target.id,
|
|
858
909
|
ompSessionId: session,
|
|
859
910
|
slug: target.servedSlug ?? target.slug,
|
|
@@ -901,12 +952,12 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
901
952
|
meter: ollamaMeter(ollamaUsage.peek(), cfg.ollama.planCreditsUsd),
|
|
902
953
|
// Ledger vs meter, and how long the credits last at the recent burn.
|
|
903
954
|
calibration: ollamaUsage.calibration(),
|
|
904
|
-
runway: ollamaRunway(ollamaMeter(ollamaUsage.peek(), cfg.ollama.planCreditsUsd), ledger.providerSpendSince
|
|
955
|
+
runway: ollamaRunway(ollamaMeter(ollamaUsage.peek(), cfg.ollama.planCreditsUsd), await ledger.providerSpendSince("ollama/", Date.now() - 7 * 86_400_000), ollamaUsage.calibration()?.factor ?? 1),
|
|
905
956
|
costBias: { configured: cfg.ollama.costBias, effective: catalog.ollamaBias?.() ?? cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage },
|
|
906
957
|
},
|
|
907
958
|
// Models failing well above their own baseline in the last hour.
|
|
908
959
|
// Visibility only: nothing routes around a spike.
|
|
909
|
-
softFailures: { recentMs: 3_600_000, baselineDays: 7, spikes: ledger.softFailureSpikes
|
|
960
|
+
softFailures: { recentMs: 3_600_000, baselineDays: 7, spikes: await ledger.softFailureSpikes() },
|
|
910
961
|
catalog: snap === null
|
|
911
962
|
? null
|
|
912
963
|
: {
|
|
@@ -997,7 +1048,19 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
997
1048
|
// Drain queued agentdox write-backs before the DB closes under them.
|
|
998
1049
|
context.close();
|
|
999
1050
|
await context.flush();
|
|
1051
|
+
// Both handles on the store, or Windows keeps the file locked and a
|
|
1052
|
+
// caller that deletes its temp directory gets EBUSY.
|
|
1053
|
+
await sqlDb.close();
|
|
1000
1054
|
db.close();
|
|
1055
|
+
if (memoryStore !== null) {
|
|
1056
|
+
// A `:memory:` ledger promised nothing durable, so its stand-in file
|
|
1057
|
+
// goes with the server. Best-effort: Windows may still hold the WAL.
|
|
1058
|
+
try {
|
|
1059
|
+
rmSync(memoryStore, { recursive: true, force: true });
|
|
1060
|
+
} catch {
|
|
1061
|
+
/* the OS reclaims a temp directory soon enough */
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1001
1064
|
},
|
|
1002
1065
|
};
|
|
1003
1066
|
}
|
package/src/server/providers.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Database } from "bun:sqlite";
|
|
8
|
+
import { num, type SqlDb } from "../util/sql.ts";
|
|
8
9
|
import { createCompositeCatalog } from "../catalog/composite.ts";
|
|
9
10
|
import { createStaticCatalogSource } from "../catalog/static-catalog.ts";
|
|
10
11
|
import { createAnthropicClient } from "../upstream/anthropic.ts";
|
|
@@ -15,7 +16,6 @@ import type { CatalogSnapshot, CatalogSource } from "../catalog/types.ts";
|
|
|
15
16
|
import type { RouterConfig } from "../config/types.ts";
|
|
16
17
|
import { createMultiUpstream } from "../upstream/multi.ts";
|
|
17
18
|
import { createOllamaClient, type OllamaClient } from "../upstream/ollama.ts";
|
|
18
|
-
import { createLedger } from "../cost/ledger.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";
|
|
@@ -39,7 +39,13 @@ export interface Providers {
|
|
|
39
39
|
namedServing(): string[];
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
export function createProviders(
|
|
42
|
+
export function createProviders(
|
|
43
|
+
cfg: RouterConfig,
|
|
44
|
+
db: Database,
|
|
45
|
+
/** The engine-agnostic handle on the same store, for the calibration samples. */
|
|
46
|
+
sqlDb: SqlDb,
|
|
47
|
+
log: Logger = createLogger(cfg.logLevel),
|
|
48
|
+
): Providers {
|
|
43
49
|
const openrouter = createOpenRouterClient(cfg);
|
|
44
50
|
const openrouterCatalog = createCatalog(cfg, openrouter, db);
|
|
45
51
|
// Ollama Cloud is a second upstream ranked in the same catalog: `ollama/…`
|
|
@@ -51,7 +57,15 @@ export function createProviders(cfg: RouterConfig, db: Database, log: Logger = c
|
|
|
51
57
|
const ollamaServing = (): boolean => cfg.ollama.enabled && ollama.available();
|
|
52
58
|
// Plan usage lives on ollama.com whichever base URL dispatches; it needs the
|
|
53
59
|
// key, so the daemon path without `/login ollama-cloud` keeps a static bias.
|
|
54
|
-
|
|
60
|
+
// Only the ledger's Ollama total is needed, so this reads through the shim
|
|
61
|
+
// rather than constructing a second full ledger.
|
|
62
|
+
const ollamaLedgerUsd = async (): Promise<number> => {
|
|
63
|
+
const row = await sqlDb.one<{ total: unknown }>(
|
|
64
|
+
"SELECT COALESCE(SUM(COALESCE(reported_usd, predicted_usd)), 0) AS total FROM ledger WHERE COALESCE(served_slug, slug) LIKE $prefix",
|
|
65
|
+
{ prefix: "ollama/%" },
|
|
66
|
+
);
|
|
67
|
+
return num(row?.total);
|
|
68
|
+
};
|
|
55
69
|
const ollamaUsage = createOllamaUsageSource({
|
|
56
70
|
apiKey: () => cfg.ollama.apiKey,
|
|
57
71
|
pollMs: cfg.ollama.usagePollMs,
|
|
@@ -59,7 +73,7 @@ export function createProviders(cfg: RouterConfig, db: Database, log: Logger = c
|
|
|
59
73
|
log,
|
|
60
74
|
// Each poll records the meter beside the ledger's Ollama total, so the
|
|
61
75
|
// estimate can be scaled to what ollama.com actually bills.
|
|
62
|
-
calibration: { db, ledgerUsd:
|
|
76
|
+
calibration: { db: sqlDb, ledgerUsd: ollamaLedgerUsd, planCreditsOverrideUsd: cfg.ollama.planCreditsUsd },
|
|
63
77
|
});
|
|
64
78
|
// Named upstreams (OpenAI, Azure, Anthropic, vLLM…): a client per id, built when
|
|
65
79
|
// first needed and kept — its breaker state must survive config reloads — while
|
package/src/server/turn.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { ContextBridge } from "../context/types.ts";
|
|
|
14
14
|
import type { RouterConfig } from "../config/types.ts";
|
|
15
15
|
import { estimateUnreportedCache } from "../cost/cache-estimate.ts";
|
|
16
16
|
import { computeCost } from "../cost/forecast.ts";
|
|
17
|
-
import { EMPTY_USAGE, type
|
|
17
|
+
import { EMPTY_USAGE, type AsyncLedger, type UsageCounts } from "../cost/types.ts";
|
|
18
18
|
import { createProbe, type Probe } from "../router/escalate.ts";
|
|
19
19
|
import { resolveHoldTurns } from "../router/explore.ts";
|
|
20
20
|
import { adjustPendingEstimate } from "../tokens/estimate.ts";
|
|
@@ -65,7 +65,7 @@ export interface TurnDeps {
|
|
|
65
65
|
config: RouterConfig;
|
|
66
66
|
router: Router;
|
|
67
67
|
upstream: UpstreamClient;
|
|
68
|
-
ledger:
|
|
68
|
+
ledger: AsyncLedger;
|
|
69
69
|
conversations: ConversationStore;
|
|
70
70
|
catalog: CatalogSource;
|
|
71
71
|
/** agentdox bridge. The disabled bridge makes every call here a no-op. */
|
|
@@ -86,6 +86,17 @@ class SinkError extends Error {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* A deliberate refusal from `route()` — `BudgetExceededError` today — reports
|
|
91
|
+
* the status and code it chose. Anything else is an internal failure and gets
|
|
92
|
+
* a 500, so a new throw never silently becomes a 4xx.
|
|
93
|
+
*/
|
|
94
|
+
function routerRefusal(err: unknown): { status: number; code: string } | null {
|
|
95
|
+
if (!(err instanceof Error) || !("status" in err) || !("code" in err)) return null;
|
|
96
|
+
const { status, code } = err as Error & { status: unknown; code: unknown };
|
|
97
|
+
return typeof status === "number" && typeof code === "string" ? { status, code } : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
89
100
|
/** Latest user text, used to bias agentdox relevance ranking and as the recorded turn. */
|
|
90
101
|
function lastUserText(req: NormRequest): string {
|
|
91
102
|
for (let i = req.messages.length - 1; i >= 0; i--) {
|
|
@@ -122,7 +133,7 @@ export async function runTurn(
|
|
|
122
133
|
): Promise<void> {
|
|
123
134
|
const { config, router, upstream, ledger, conversations, context: bridge } = deps;
|
|
124
135
|
const log = createLogger(config.logLevel);
|
|
125
|
-
const state = conversations.load(req.conversationKey);
|
|
136
|
+
const state = await conversations.load(req.conversationKey);
|
|
126
137
|
const turnNumber = state.turn + 1;
|
|
127
138
|
// Digest quality signal: the calls the agent just made, matched against
|
|
128
139
|
// recent digests of this session (a re-run of a digested read means the
|
|
@@ -131,7 +142,9 @@ export async function runTurn(
|
|
|
131
142
|
for (let i = req.messages.length - 1; i >= 0; i--) {
|
|
132
143
|
const m = req.messages[i];
|
|
133
144
|
if (m === undefined || m.role !== "assistant") continue;
|
|
134
|
-
if (m.toolCalls.length > 0)
|
|
145
|
+
if (m.toolCalls.length > 0) {
|
|
146
|
+
await deps.digester.noteToolCalls(req.ompSessionId, m.toolCalls.map((c) => ({ name: c.name, argsJson: c.argsJson })));
|
|
147
|
+
}
|
|
135
148
|
break;
|
|
136
149
|
}
|
|
137
150
|
}
|
|
@@ -191,7 +204,17 @@ export async function runTurn(
|
|
|
191
204
|
}
|
|
192
205
|
decision = await router.route(req, opts);
|
|
193
206
|
} catch (err) {
|
|
194
|
-
|
|
207
|
+
// A refusal the router MEANS carries its own status and code — a
|
|
208
|
+
// budget cap in `reject` mode is a 402 `budget_exceeded`, not an
|
|
209
|
+
// internal failure. Reported as a 500 it reads to the caller (and
|
|
210
|
+
// to a team front door relaying it) as "the router broke", which
|
|
211
|
+
// sent a capped deployment looking for a crash.
|
|
212
|
+
const refusal = routerRefusal(err);
|
|
213
|
+
await sink.error({
|
|
214
|
+
status: refusal?.status ?? 500,
|
|
215
|
+
code: refusal?.code ?? "router_error",
|
|
216
|
+
message: err instanceof Error ? err.message : String(err),
|
|
217
|
+
});
|
|
195
218
|
return;
|
|
196
219
|
}
|
|
197
220
|
}
|
|
@@ -314,7 +337,7 @@ export async function runTurn(
|
|
|
314
337
|
generationId = await dispatch.generationId().catch(() => null);
|
|
315
338
|
}
|
|
316
339
|
const priceModel = deps.catalog.find(servedSlug ?? decision.slug);
|
|
317
|
-
ledger.record({
|
|
340
|
+
await ledger.record({
|
|
318
341
|
id: crypto.randomUUID(),
|
|
319
342
|
createdAtMs: Date.now(),
|
|
320
343
|
conversationKey: req.conversationKey,
|
|
@@ -364,7 +387,7 @@ export async function runTurn(
|
|
|
364
387
|
// the committed path used to reach the state update below, so aborted
|
|
365
388
|
// dispatches (30% of real spend on live data) stayed invisible to the
|
|
366
389
|
// per-conversation budget guard.
|
|
367
|
-
conversations.accrue(req.conversationKey, { spentUsd: reportedUsd ?? decision.forecast.expectedUsd });
|
|
390
|
+
await conversations.accrue(req.conversationKey, { spentUsd: reportedUsd ?? decision.forecast.expectedUsd });
|
|
368
391
|
};
|
|
369
392
|
|
|
370
393
|
// Same-tier failover: re-route with every failed slug excluded and accept
|
|
@@ -641,7 +664,7 @@ export async function runTurn(
|
|
|
641
664
|
state.currentTier = decision.tier;
|
|
642
665
|
// Escalations accumulate in SQL for the same reason spend does: `save`
|
|
643
666
|
// below no longer writes this column, so a snapshot cannot clobber it.
|
|
644
|
-
conversations.accrue(req.conversationKey, { escalations });
|
|
667
|
+
await conversations.accrue(req.conversationKey, { escalations });
|
|
645
668
|
// Hysteresis window. Only re-arm when the served tier actually changed
|
|
646
669
|
// (or this turn escalated). Re-arming on EVERY turn — even a trivial one
|
|
647
670
|
// served by a held hard model — extends the lock forever: the classifier
|
|
@@ -672,7 +695,7 @@ export async function runTurn(
|
|
|
672
695
|
state.cacheWarmAtMs = Date.now();
|
|
673
696
|
}
|
|
674
697
|
state.updatedAtMs = Date.now();
|
|
675
|
-
conversations.save(state);
|
|
698
|
+
await conversations.save(state);
|
|
676
699
|
|
|
677
700
|
// Record the settled turn into agentdox, attributed to the model that
|
|
678
701
|
// actually served it. Queued and never awaited: the transcript is an
|
package/src/tokens/estimate.ts
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
* `token_calibration` per tokenizer family on measurement.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { Ledger } from "../cost/types.ts";
|
|
10
9
|
import type { NormRequest } from "../wire/types.ts";
|
|
11
10
|
|
|
12
11
|
/** Chars-per-token when nothing better is known. Conservative-ish for mixed prose+code. */
|
|
@@ -39,9 +38,20 @@ function familyKey(tokenizer: string): string {
|
|
|
39
38
|
return tokenizer.trim().toLowerCase();
|
|
40
39
|
}
|
|
41
40
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
/**
|
|
42
|
+
* A measured bytes-per-token ratio for a tokenizer family, or null when the
|
|
43
|
+
* ledger has too few samples to have calibrated one.
|
|
44
|
+
*
|
|
45
|
+
* Passed as a VALUE rather than read from the ledger here: estimation runs
|
|
46
|
+
* inside synchronous code (the Anthropic token counter, candidate scoring), and
|
|
47
|
+
* the ledger may be a shared database that cannot be read synchronously. The
|
|
48
|
+
* caller fetches the one ratio it needs before estimating.
|
|
49
|
+
*/
|
|
50
|
+
export type TokenRatio = number | null;
|
|
51
|
+
|
|
52
|
+
export function estimateTokens(bytes: number, tokenizer: string, ratio: TokenRatio): number {
|
|
53
|
+
const perToken = ratio ?? FAMILY_BYTES_PER_TOKEN[familyKey(tokenizer)] ?? DEFAULT_BYTES_PER_TOKEN;
|
|
54
|
+
return Math.max(0, Math.ceil(bytes / perToken));
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
/**
|
|
@@ -53,10 +63,10 @@ export function estimateTokens(bytes: number, tokenizer: string, ledger: Ledger
|
|
|
53
63
|
const PENDING_CAP = 1024;
|
|
54
64
|
const pendingEstimates = new Map<string, { tokenizer: string; bytes: number }>();
|
|
55
65
|
|
|
56
|
-
export function estimatePromptTokens(req: NormRequest, tokenizer: string,
|
|
66
|
+
export function estimatePromptTokens(req: NormRequest, tokenizer: string, ratio: TokenRatio): number {
|
|
57
67
|
let images = 0;
|
|
58
68
|
for (const message of req.messages) images += message.images;
|
|
59
|
-
const tokens = estimateTokens(req.promptBytes, tokenizer,
|
|
69
|
+
const tokens = estimateTokens(req.promptBytes, tokenizer, ratio) + images * IMAGE_TOKEN_ALLOWANCE;
|
|
60
70
|
if (pendingEstimates.size >= PENDING_CAP && !pendingEstimates.has(req.conversationKey)) {
|
|
61
71
|
// Map iteration order is insertion order: drop the eldest.
|
|
62
72
|
const eldest = pendingEstimates.keys().next();
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* percents) rather than 100%, which errs toward keeping the bias on.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import type
|
|
27
|
+
import { num, type SqlDb } from "../util/sql.ts";
|
|
28
28
|
import type { Logger } from "../util/log.ts";
|
|
29
29
|
|
|
30
30
|
export interface OllamaUsage {
|
|
@@ -158,9 +158,9 @@ export const NO_USAGE: OllamaUsageSource = { get: async () => null, peek: () =>
|
|
|
158
158
|
|
|
159
159
|
/** Where calibration samples come from and go: the ledger's Ollama total and the plan's credits. */
|
|
160
160
|
export interface CalibrationDeps {
|
|
161
|
-
db:
|
|
161
|
+
db: SqlDb;
|
|
162
162
|
/** The ledger's all-time Ollama spend, read at sampling time. */
|
|
163
|
-
ledgerUsd(): number
|
|
163
|
+
ledgerUsd(): Promise<number>;
|
|
164
164
|
/** Configured credit override (0 = detect from the plan). */
|
|
165
165
|
planCreditsOverrideUsd: number;
|
|
166
166
|
}
|
|
@@ -172,17 +172,25 @@ export function createOllamaUsageSource(
|
|
|
172
172
|
// the dashboard later, so the reader stays live and simply idles until it is.
|
|
173
173
|
if (opts.pollMs <= 0) return NO_USAGE;
|
|
174
174
|
const cal = opts.calibration;
|
|
175
|
-
const insertSample = cal === undefined ? null : cal.db.query("INSERT OR REPLACE INTO ollama_meter_samples (at_ms, meter_usd, ledger_usd) VALUES (?, ?, ?)");
|
|
176
|
-
const readSamples = cal === undefined ? null : cal.db.query("SELECT at_ms, meter_usd, ledger_usd FROM ollama_meter_samples WHERE at_ms >= ? ORDER BY at_ms ASC");
|
|
177
175
|
let calibrationMemo: OllamaCalibration | null = null;
|
|
178
|
-
function sample(usage: OllamaUsage): void {
|
|
179
|
-
if (cal === null || cal === undefined
|
|
176
|
+
async function sample(usage: OllamaUsage): Promise<void> {
|
|
177
|
+
if (cal === null || cal === undefined) return;
|
|
180
178
|
const credits = ollamaPlanCredits(usage, cal.planCreditsOverrideUsd);
|
|
181
179
|
if (credits === null || usage.monthlyUsedFraction === null) return;
|
|
182
180
|
try {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
181
|
+
const db = cal.db;
|
|
182
|
+
// `INSERT OR REPLACE` is SQLite-only; `ON CONFLICT` says the same thing
|
|
183
|
+
// on both engines, and the key is the sample instant.
|
|
184
|
+
await db.sql`INSERT INTO ollama_meter_samples (at_ms, meter_usd, ledger_usd)
|
|
185
|
+
VALUES (${usage.fetchedAtMs}, ${usage.monthlyUsedFraction * credits}, ${await cal.ledgerUsd()})
|
|
186
|
+
ON CONFLICT (at_ms) DO UPDATE SET meter_usd = excluded.meter_usd, ledger_usd = excluded.ledger_usd`;
|
|
187
|
+
const rows = await db.query<{ at_ms: unknown; meter_usd: unknown; ledger_usd: unknown }>(
|
|
188
|
+
"SELECT at_ms, meter_usd, ledger_usd FROM ollama_meter_samples WHERE at_ms >= $since ORDER BY at_ms ASC",
|
|
189
|
+
{ since: usage.fetchedAtMs - 30 * 86_400_000 },
|
|
190
|
+
);
|
|
191
|
+
calibrationMemo = calibrationFrom(
|
|
192
|
+
rows.map((r) => ({ atMs: num(r.at_ms), meterUsd: num(r.meter_usd), ledgerUsd: num(r.ledger_usd) })),
|
|
193
|
+
);
|
|
186
194
|
} catch (err) {
|
|
187
195
|
opts.log.debug("ollama calibration sample failed", { error: err instanceof Error ? err.message : String(err) });
|
|
188
196
|
}
|
|
@@ -232,7 +240,9 @@ export function createOllamaUsageSource(
|
|
|
232
240
|
if (parsed !== null) {
|
|
233
241
|
current = { ...parsed, plan };
|
|
234
242
|
warned = false;
|
|
235
|
-
|
|
243
|
+
// Awaited: the next poll's calibration reads what this one wrote,
|
|
244
|
+
// and a shared store makes that a round trip rather than a call.
|
|
245
|
+
await sample(current);
|
|
236
246
|
} else if (!warned) {
|
|
237
247
|
warned = true;
|
|
238
248
|
opts.log.warn("ollama usage payload had no recognisable fields; credit-aware bias stays on its last reading");
|