auto-model-router 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +24 -0
- package/.github/workflows/publish.yml +40 -0
- package/.omp-plugin/marketplace.json +30 -0
- package/LICENSE +21 -0
- package/README.md +639 -0
- package/bun.lock +32 -0
- package/docs/claude-anthropic-wire.md +116 -0
- package/omp-extension/configure-logic.ts +128 -0
- package/omp-extension/embed-logic.ts +141 -0
- package/omp-extension/router-configure.ts +111 -0
- package/omp-extension/router-embed.ts +118 -0
- package/omp-extension/router-toast.ts +130 -0
- package/omp-extension/toast-logic.ts +136 -0
- package/package.json +56 -0
- package/src/catalog/openrouter-catalog.ts +428 -0
- package/src/catalog/types.ts +104 -0
- package/src/cli/args.ts +105 -0
- package/src/cli/config-cmd.ts +362 -0
- package/src/cli/config-wizard.ts +636 -0
- package/src/cli/explain.ts +167 -0
- package/src/cli/models.ts +240 -0
- package/src/cli/stats.ts +69 -0
- package/src/config/defaults.ts +136 -0
- package/src/config/load.ts +143 -0
- package/src/config/omp-credentials.ts +124 -0
- package/src/config/schema.ts +161 -0
- package/src/config/types.ts +244 -0
- package/src/cost/blended.ts +80 -0
- package/src/cost/forecast.ts +129 -0
- package/src/cost/ledger.ts +291 -0
- package/src/cost/types.ts +148 -0
- package/src/index.ts +93 -0
- package/src/router/cache-control.ts +66 -0
- package/src/router/candidates.ts +246 -0
- package/src/router/classify.ts +329 -0
- package/src/router/escalate.ts +264 -0
- package/src/router/features.ts +225 -0
- package/src/router/index.ts +99 -0
- package/src/router/select.ts +365 -0
- package/src/router/state.ts +118 -0
- package/src/router/tier-plan.ts +151 -0
- package/src/router/types.ts +222 -0
- package/src/server/http.ts +343 -0
- package/src/server/turn.ts +393 -0
- package/src/tokens/estimate.ts +74 -0
- package/src/upstream/openrouter.ts +221 -0
- package/src/upstream/sse-parse.ts +208 -0
- package/src/upstream/types.ts +75 -0
- package/src/util/hash.ts +0 -0
- package/src/util/log.ts +53 -0
- package/src/util/sqlite.ts +140 -0
- package/src/util/sse.ts +23 -0
- package/src/wire/openai/errors.ts +48 -0
- package/src/wire/openai/models.ts +37 -0
- package/src/wire/openai/request.ts +279 -0
- package/src/wire/openai/sink.ts +213 -0
- package/src/wire/types.ts +156 -0
- package/test/catalog.test.ts +319 -0
- package/test/classify.test.ts +269 -0
- package/test/config-wizard.test.ts +482 -0
- package/test/config.test.ts +121 -0
- package/test/configure-logic.test.ts +151 -0
- package/test/cost.test.ts +137 -0
- package/test/embed-logic.test.ts +107 -0
- package/test/escalate.test.ts +223 -0
- package/test/failover.test.ts +494 -0
- package/test/features.test.ts +228 -0
- package/test/fixtures/openrouter-models.json +15340 -0
- package/test/models-yml.test.ts +186 -0
- package/test/omp-credentials.test.ts +185 -0
- package/test/select.test.ts +538 -0
- package/test/sse-parse.test.ts +142 -0
- package/test/tier-plan.test.ts +302 -0
- package/test/toast-logic.test.ts +160 -0
- package/test/tokens.test.ts +160 -0
- package/test/trust-attribution.test.ts +175 -0
- package/test/turn.test.ts +498 -0
- package/test/wire-request.test.ts +297 -0
- package/test/wire-sink.test.ts +179 -0
- package/tools/install.ts +140 -0
- package/tools/mock-openrouter.ts +269 -0
- package/tools/smoke.ts +326 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blended rate: the spend-weighted average price per million tokens, over a
|
|
3
|
+
* rolling window, that omp displays as "what tokens cost through this router".
|
|
4
|
+
*
|
|
5
|
+
* The ledger stores each entry's REPORTED total and the component split its
|
|
6
|
+
* own model pricing implies (`cost_breakdown`). OpenRouter gives us no
|
|
7
|
+
* per-component reported prices, so each entry's reported dollars are
|
|
8
|
+
* apportioned across buckets in proportion to its predicted component split —
|
|
9
|
+
* exact when our pricing matches theirs, and self-correcting via the reported
|
|
10
|
+
* total when it does not.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Database } from "bun:sqlite";
|
|
14
|
+
import type { RouterConfig } from "../config/types.ts";
|
|
15
|
+
import type { BlendedRate, CostBreakdown, UsageCounts } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
interface BlendRow {
|
|
18
|
+
usage: string;
|
|
19
|
+
reported_usd: number;
|
|
20
|
+
cost_breakdown: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Buckets with no window traffic still need a rate for the display; these are
|
|
24
|
+
// the typical Anthropic-style relative prices, used only as display priors.
|
|
25
|
+
const OUTPUT_TO_INPUT_PRIOR = 5;
|
|
26
|
+
const CACHE_READ_TO_INPUT_PRIOR = 0.1;
|
|
27
|
+
const CACHE_WRITE_TO_INPUT_PRIOR = 1.25;
|
|
28
|
+
|
|
29
|
+
export function computeBlendedRate(db: Database, cfg: RouterConfig, windowDays: number): BlendedRate | null {
|
|
30
|
+
const sinceMs = Date.now() - windowDays * 86_400_000;
|
|
31
|
+
// We own the schema; row shape fixed by util/sqlite.ts and cost/ledger.ts.
|
|
32
|
+
const rows = db
|
|
33
|
+
.query(
|
|
34
|
+
`SELECT usage, reported_usd, cost_breakdown FROM ledger
|
|
35
|
+
WHERE created_at_ms >= ? AND reported_usd IS NOT NULL AND cost_breakdown IS NOT NULL`,
|
|
36
|
+
)
|
|
37
|
+
.all(sinceMs) as BlendRow[];
|
|
38
|
+
|
|
39
|
+
let sampleCount = 0;
|
|
40
|
+
let inputUsd = 0;
|
|
41
|
+
let inputTokens = 0;
|
|
42
|
+
let outputUsd = 0;
|
|
43
|
+
let outputTokens = 0;
|
|
44
|
+
let cacheReadUsd = 0;
|
|
45
|
+
let cacheReadTokens = 0;
|
|
46
|
+
let cacheWriteUsd = 0;
|
|
47
|
+
let cacheWriteTokens = 0;
|
|
48
|
+
|
|
49
|
+
for (const row of rows) {
|
|
50
|
+
const usage = JSON.parse(row.usage) as UsageCounts;
|
|
51
|
+
const split = JSON.parse(row.cost_breakdown) as CostBreakdown;
|
|
52
|
+
const tokenSplit = split.freshPrompt + split.cacheRead + split.cacheWrite + split.completion + split.reasoning;
|
|
53
|
+
// Entries whose usage produced no token-billed cost (pure image/request
|
|
54
|
+
// billing) carry no price signal per token; skip them.
|
|
55
|
+
if (tokenSplit <= 0) continue;
|
|
56
|
+
const freshTokens = Math.max(usage.promptTokens - usage.cachedTokens - usage.cacheWriteTokens, 0);
|
|
57
|
+
|
|
58
|
+
inputUsd += (row.reported_usd * split.freshPrompt) / tokenSplit;
|
|
59
|
+
inputTokens += freshTokens;
|
|
60
|
+
outputUsd += (row.reported_usd * (split.completion + split.reasoning)) / tokenSplit;
|
|
61
|
+
outputTokens += usage.completionTokens;
|
|
62
|
+
cacheReadUsd += (row.reported_usd * split.cacheRead) / tokenSplit;
|
|
63
|
+
cacheReadTokens += usage.cachedTokens;
|
|
64
|
+
cacheWriteUsd += (row.reported_usd * split.cacheWrite) / tokenSplit;
|
|
65
|
+
cacheWriteTokens += usage.cacheWriteTokens;
|
|
66
|
+
sampleCount += 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (sampleCount < cfg.ledger.blendMinSamples) return null;
|
|
70
|
+
|
|
71
|
+
const inputPerMtok = inputTokens > 0 ? (inputUsd / inputTokens) * 1e6 : 0;
|
|
72
|
+
return {
|
|
73
|
+
inputPerMtok,
|
|
74
|
+
outputPerMtok: outputTokens > 0 ? (outputUsd / outputTokens) * 1e6 : inputPerMtok * OUTPUT_TO_INPUT_PRIOR,
|
|
75
|
+
cacheReadPerMtok: cacheReadTokens > 0 ? (cacheReadUsd / cacheReadTokens) * 1e6 : inputPerMtok * CACHE_READ_TO_INPUT_PRIOR,
|
|
76
|
+
cacheWritePerMtok: cacheWriteTokens > 0 ? (cacheWriteUsd / cacheWriteTokens) * 1e6 : inputPerMtok * CACHE_WRITE_TO_INPUT_PRIOR,
|
|
77
|
+
sampleCount,
|
|
78
|
+
windowDays,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cost arithmetic. This is the only place token counts become dollars, so
|
|
3
|
+
* the billing subtleties live here exactly once:
|
|
4
|
+
*
|
|
5
|
+
* - `usage.promptTokens` INCLUDES cached tokens (OpenAI/OpenRouter
|
|
6
|
+
* convention), so fresh tokens are what remains after subtracting cache
|
|
7
|
+
* reads and writes.
|
|
8
|
+
* - `usage.reasoningTokens` is a SUBSET of `completionTokens`; billing both
|
|
9
|
+
* at full rate would double-count. When the model publishes no separate
|
|
10
|
+
* reasoning rate, completion swallows reasoning at the completion price.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { CatalogModel, Price } from "../catalog/types.ts";
|
|
14
|
+
import type { CostBreakdown, CostForecast, UsageCounts } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
/** Highest override tier applicable at this prompt size; base price below every threshold. */
|
|
17
|
+
export function priceAt(model: CatalogModel, promptTokens: number): Price {
|
|
18
|
+
// priceTiers is sorted ascending by minPromptTokens, so the last match wins.
|
|
19
|
+
let price = model.price;
|
|
20
|
+
for (const tier of model.priceTiers) {
|
|
21
|
+
if (tier.minPromptTokens <= promptTokens) price = tier.price;
|
|
22
|
+
}
|
|
23
|
+
return price;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function tierThresholdAt(model: CatalogModel, promptTokens: number): number {
|
|
27
|
+
let threshold = 0;
|
|
28
|
+
for (const tier of model.priceTiers) {
|
|
29
|
+
if (tier.minPromptTokens <= promptTokens) threshold = tier.minPromptTokens;
|
|
30
|
+
}
|
|
31
|
+
return threshold;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function clampCount(value: number, max: number): number {
|
|
35
|
+
return Math.min(Math.max(value, 0), max);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function computeCost(model: CatalogModel, usage: UsageCounts): CostBreakdown {
|
|
39
|
+
const price = priceAt(model, usage.promptTokens);
|
|
40
|
+
|
|
41
|
+
const cachedTokens = clampCount(usage.cachedTokens, usage.promptTokens);
|
|
42
|
+
const cacheWriteTokens = clampCount(usage.cacheWriteTokens, usage.promptTokens - cachedTokens);
|
|
43
|
+
const freshTokens = Math.max(usage.promptTokens - cachedTokens - cacheWriteTokens, 0);
|
|
44
|
+
const reasoningTokens = clampCount(usage.reasoningTokens, usage.completionTokens);
|
|
45
|
+
|
|
46
|
+
const freshPrompt = freshTokens * price.prompt;
|
|
47
|
+
// Unpublished cache rates fall back to the full prompt price: absence of a
|
|
48
|
+
// published discount must never become a predicted discount.
|
|
49
|
+
const cacheRead = cachedTokens * (price.cacheRead ?? price.prompt);
|
|
50
|
+
const cacheWrite = cacheWriteTokens * (price.cacheWrite ?? price.prompt);
|
|
51
|
+
|
|
52
|
+
let completion: number;
|
|
53
|
+
let reasoning: number;
|
|
54
|
+
if (price.reasoning !== undefined) {
|
|
55
|
+
reasoning = reasoningTokens * price.reasoning;
|
|
56
|
+
completion = (usage.completionTokens - reasoningTokens) * price.completion;
|
|
57
|
+
} else {
|
|
58
|
+
reasoning = 0;
|
|
59
|
+
completion = usage.completionTokens * price.completion;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const images = usage.images * (price.image ?? 0);
|
|
63
|
+
const request = price.request ?? 0;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
freshPrompt,
|
|
67
|
+
cacheRead,
|
|
68
|
+
cacheWrite,
|
|
69
|
+
completion,
|
|
70
|
+
reasoning,
|
|
71
|
+
images,
|
|
72
|
+
request,
|
|
73
|
+
total: freshPrompt + cacheRead + cacheWrite + completion + reasoning + images + request,
|
|
74
|
+
tierAtPromptTokens: tierThresholdAt(model, usage.promptTokens),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function forecast(
|
|
79
|
+
model: CatalogModel,
|
|
80
|
+
args: { promptTokens: number; completionTokens: number; cacheHitRate: number; images: number },
|
|
81
|
+
): CostForecast {
|
|
82
|
+
const cacheHitRate = Math.min(Math.max(args.cacheHitRate, 0), 1);
|
|
83
|
+
const usage: UsageCounts = {
|
|
84
|
+
promptTokens: args.promptTokens,
|
|
85
|
+
cachedTokens: Math.round(args.promptTokens * cacheHitRate),
|
|
86
|
+
cacheWriteTokens: 0,
|
|
87
|
+
completionTokens: args.completionTokens,
|
|
88
|
+
reasoningTokens: 0,
|
|
89
|
+
images: args.images,
|
|
90
|
+
};
|
|
91
|
+
const breakdown = computeCost(model, usage);
|
|
92
|
+
|
|
93
|
+
// The worst case a budget guard must survive is a completely cold cache.
|
|
94
|
+
// That is the MAX of two computations: no cache activity at all, and a
|
|
95
|
+
// full first-time cache write. Taking the write case alone would understate
|
|
96
|
+
// models (several Geminis) whose published cache-write rate is BELOW their
|
|
97
|
+
// prompt rate; taking the no-cache case alone would understate Anthropic-
|
|
98
|
+
// style models whose write rate is a premium over prompt.
|
|
99
|
+
const coldNoCache = computeCost(model, {
|
|
100
|
+
promptTokens: args.promptTokens,
|
|
101
|
+
cachedTokens: 0,
|
|
102
|
+
cacheWriteTokens: 0,
|
|
103
|
+
completionTokens: args.completionTokens,
|
|
104
|
+
reasoningTokens: 0,
|
|
105
|
+
images: args.images,
|
|
106
|
+
});
|
|
107
|
+
let coldUsd = coldNoCache.total;
|
|
108
|
+
if (priceAt(model, args.promptTokens).cacheWrite !== undefined) {
|
|
109
|
+
const coldFullWrite = computeCost(model, {
|
|
110
|
+
promptTokens: args.promptTokens,
|
|
111
|
+
cachedTokens: 0,
|
|
112
|
+
cacheWriteTokens: args.promptTokens,
|
|
113
|
+
completionTokens: args.completionTokens,
|
|
114
|
+
reasoningTokens: 0,
|
|
115
|
+
images: args.images,
|
|
116
|
+
});
|
|
117
|
+
coldUsd = Math.max(coldUsd, coldFullWrite.total);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
slug: model.slug,
|
|
122
|
+
expectedUsd: breakdown.total,
|
|
123
|
+
coldUsd,
|
|
124
|
+
breakdown,
|
|
125
|
+
assumedPromptTokens: args.promptTokens,
|
|
126
|
+
assumedCompletionTokens: args.completionTokens,
|
|
127
|
+
assumedCacheHitRate: cacheHitRate,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The spend ledger: one row per dispatched generation, plus the two derived
|
|
3
|
+
* signals the router consumes — per-model trust (Laplace-smoothed success) and
|
|
4
|
+
* per-tokenizer-family token calibration.
|
|
5
|
+
*
|
|
6
|
+
* `record` also persists the cost component split implied by the entry's own
|
|
7
|
+
* model pricing (`cost_breakdown`). The split needs catalog prices, which the
|
|
8
|
+
* ledger does not receive; it reads them back from the `catalog_cache` row the
|
|
9
|
+
* catalog slice already persists, re-normalized lazily and re-read only when
|
|
10
|
+
* the cache's `fetched_at_ms` changes. An entry recorded before the first
|
|
11
|
+
* catalog fetch simply stores NULL and is skipped by the blended rate.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Database } from "bun:sqlite";
|
|
15
|
+
import { normalizeCatalogModel } from "../catalog/openrouter-catalog.ts";
|
|
16
|
+
import type { CatalogModel } from "../catalog/types.ts";
|
|
17
|
+
import type { RouterConfig } from "../config/types.ts";
|
|
18
|
+
import { consumePendingEstimate } from "../tokens/estimate.ts";
|
|
19
|
+
import { computeBlendedRate } from "./blended.ts";
|
|
20
|
+
import { computeCost } from "./forecast.ts";
|
|
21
|
+
import type { BlendedRate, Ledger, LedgerEntry, ModelTrust, UsageCounts } from "./types.ts";
|
|
22
|
+
|
|
23
|
+
/** Estimates below this many samples are noise; the default ratio is better. */
|
|
24
|
+
const MIN_CALIBRATION_SAMPLES = 20;
|
|
25
|
+
|
|
26
|
+
// Row shapes below are fixed by our own schema in util/sqlite.ts.
|
|
27
|
+
interface LedgerRow {
|
|
28
|
+
id: string;
|
|
29
|
+
created_at_ms: number;
|
|
30
|
+
conversation_key: string;
|
|
31
|
+
session_id: string;
|
|
32
|
+
turn: number;
|
|
33
|
+
requested_model: string;
|
|
34
|
+
harness_id: string;
|
|
35
|
+
slug: string;
|
|
36
|
+
served_slug: string | null;
|
|
37
|
+
tier: string;
|
|
38
|
+
classification_source: string;
|
|
39
|
+
reasons: string;
|
|
40
|
+
predicted_usd: number;
|
|
41
|
+
reported_usd: number | null;
|
|
42
|
+
usage: string;
|
|
43
|
+
cost_breakdown: string | null;
|
|
44
|
+
attempt: number;
|
|
45
|
+
escalation_signal: string | null;
|
|
46
|
+
latency_ms: number;
|
|
47
|
+
ttft_ms: number | null;
|
|
48
|
+
finish_reason: string | null;
|
|
49
|
+
wasted: number;
|
|
50
|
+
upstream_generation_id: string | null;
|
|
51
|
+
error: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface TrustRow {
|
|
55
|
+
attempts: number;
|
|
56
|
+
escalations: number;
|
|
57
|
+
errors: number;
|
|
58
|
+
failures: number;
|
|
59
|
+
mean_cost_error: number | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface CalibrationRow {
|
|
63
|
+
est_bytes: number;
|
|
64
|
+
actual_tokens: number;
|
|
65
|
+
samples: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Error kinds that say nothing about a MODEL's reliability, and so must not
|
|
70
|
+
* count against its trust:
|
|
71
|
+
* - `aborted`: the client hung up (user pressed escape mid-turn).
|
|
72
|
+
* - `auth`: credential, credit, or account-policy refusal (age confirmation,
|
|
73
|
+
* prompt-injection blocking) — identical for every model on the key.
|
|
74
|
+
* - `model_unavailable`: the guardrail or data policy excludes the endpoint;
|
|
75
|
+
* an availability fact, not a quality one, and failover already handles it.
|
|
76
|
+
*
|
|
77
|
+
* Everything else (upstream_error, timeout, network, rate_limit, …) stays
|
|
78
|
+
* attributable. A NULL `error_kind` on a row that HAS an error is an
|
|
79
|
+
* unclassifiable legacy row and stays attributable, preserving the old,
|
|
80
|
+
* stricter behaviour rather than silently forgiving it.
|
|
81
|
+
*/
|
|
82
|
+
const UNATTRIBUTABLE_KINDS = "('aborted', 'auth', 'model_unavailable')";
|
|
83
|
+
|
|
84
|
+
const ATTRIBUTABLE_ERROR = `error IS NOT NULL AND (error_kind IS NULL OR error_kind NOT IN ${UNATTRIBUTABLE_KINDS})`;
|
|
85
|
+
|
|
86
|
+
const TRUST_SELECT = `COUNT(*) AS attempts,
|
|
87
|
+
COALESCE(SUM(CASE WHEN escalation_signal IS NOT NULL THEN 1 ELSE 0 END), 0) AS escalations,
|
|
88
|
+
COALESCE(SUM(CASE WHEN ${ATTRIBUTABLE_ERROR} THEN 1 ELSE 0 END), 0) AS errors,
|
|
89
|
+
COALESCE(SUM(CASE WHEN escalation_signal IS NOT NULL OR (${ATTRIBUTABLE_ERROR}) THEN 1 ELSE 0 END), 0) AS failures,
|
|
90
|
+
AVG(CASE WHEN reported_usd IS NOT NULL AND reported_usd > 0
|
|
91
|
+
THEN ABS(reported_usd - predicted_usd) / reported_usd END) AS mean_cost_error`;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Recovers the `UpstreamErrorKind` from the text turn.ts stored.
|
|
95
|
+
*
|
|
96
|
+
* Errors are written as `"<kind>: <message>"`, except the abort path which
|
|
97
|
+
* writes the bare message. Returning null for anything unrecognised keeps that
|
|
98
|
+
* row model-attributable — the stricter reading — rather than quietly
|
|
99
|
+
* forgiving a failure we cannot classify.
|
|
100
|
+
*/
|
|
101
|
+
function errorKindOf(error: string | null): string | null {
|
|
102
|
+
if (error === null) return null;
|
|
103
|
+
if (error === "request aborted") return "aborted";
|
|
104
|
+
const sep = error.indexOf(": ");
|
|
105
|
+
if (sep <= 0) return null;
|
|
106
|
+
return error.slice(0, sep);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function toTrust(slug: string, row: TrustRow): ModelTrust {
|
|
110
|
+
// Laplace smoothing: an untried model scores a neutral 1/2, and a failure
|
|
111
|
+
// is an attempt superseded by an escalation or ended in an upstream error.
|
|
112
|
+
return {
|
|
113
|
+
slug,
|
|
114
|
+
attempts: row.attempts,
|
|
115
|
+
escalations: row.escalations,
|
|
116
|
+
errors: row.errors,
|
|
117
|
+
successRate: (row.attempts - row.failures + 1) / (row.attempts + 2),
|
|
118
|
+
meanCostError: row.mean_cost_error ?? 0,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function toEntry(row: LedgerRow): LedgerEntry {
|
|
123
|
+
return {
|
|
124
|
+
id: row.id,
|
|
125
|
+
createdAtMs: row.created_at_ms,
|
|
126
|
+
conversationKey: row.conversation_key,
|
|
127
|
+
sessionId: row.session_id,
|
|
128
|
+
turn: row.turn,
|
|
129
|
+
requestedModel: row.requested_model,
|
|
130
|
+
harnessId: row.harness_id,
|
|
131
|
+
slug: row.slug,
|
|
132
|
+
servedSlug: row.served_slug,
|
|
133
|
+
tier: row.tier,
|
|
134
|
+
classificationSource: row.classification_source,
|
|
135
|
+
reasons: JSON.parse(row.reasons) as string[],
|
|
136
|
+
predictedUsd: row.predicted_usd,
|
|
137
|
+
reportedUsd: row.reported_usd,
|
|
138
|
+
usage: JSON.parse(row.usage) as UsageCounts,
|
|
139
|
+
attempt: row.attempt,
|
|
140
|
+
escalationSignal: row.escalation_signal,
|
|
141
|
+
latencyMs: row.latency_ms,
|
|
142
|
+
ttftMs: row.ttft_ms,
|
|
143
|
+
finishReason: row.finish_reason,
|
|
144
|
+
wasted: row.wasted === 1,
|
|
145
|
+
upstreamGenerationId: row.upstream_generation_id,
|
|
146
|
+
error: row.error,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
151
|
+
// Prepared once: record() runs on every turn.
|
|
152
|
+
const insertStmt = db.query(
|
|
153
|
+
`INSERT INTO ledger (
|
|
154
|
+
id, created_at_ms, conversation_key, session_id, turn, requested_model, harness_id, slug, served_slug,
|
|
155
|
+
tier, classification_source, reasons, predicted_usd, reported_usd, usage, cost_breakdown,
|
|
156
|
+
attempt, escalation_signal, latency_ms, ttft_ms, finish_reason, wasted, upstream_generation_id, error,
|
|
157
|
+
error_kind
|
|
158
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
159
|
+
);
|
|
160
|
+
const calibrationStmt = db.query(
|
|
161
|
+
`INSERT INTO token_calibration (tokenizer, est_bytes, actual_tokens, samples) VALUES (?, ?, ?, 1)
|
|
162
|
+
ON CONFLICT(tokenizer) DO UPDATE SET
|
|
163
|
+
est_bytes = est_bytes + excluded.est_bytes,
|
|
164
|
+
actual_tokens = actual_tokens + excluded.actual_tokens,
|
|
165
|
+
samples = samples + 1`,
|
|
166
|
+
);
|
|
167
|
+
const spendByConversationStmt = db.query(
|
|
168
|
+
"SELECT COALESCE(SUM(COALESCE(reported_usd, predicted_usd)), 0) AS total FROM ledger WHERE conversation_key = ?",
|
|
169
|
+
);
|
|
170
|
+
const spendSinceStmt = db.query(
|
|
171
|
+
"SELECT COALESCE(SUM(COALESCE(reported_usd, predicted_usd)), 0) AS total FROM ledger WHERE created_at_ms >= ?",
|
|
172
|
+
);
|
|
173
|
+
const spendSinceHarnessStmt = db.query(
|
|
174
|
+
"SELECT COALESCE(SUM(COALESCE(reported_usd, predicted_usd)), 0) AS total FROM ledger WHERE created_at_ms >= ? AND harness_id = ?",
|
|
175
|
+
);
|
|
176
|
+
const trustStmt = db.query(`SELECT ${TRUST_SELECT} FROM ledger WHERE slug = ?`);
|
|
177
|
+
const trustHarnessStmt = db.query(`SELECT ${TRUST_SELECT} FROM ledger WHERE slug = ? AND harness_id = ?`);
|
|
178
|
+
const allTrustStmt = db.query(`SELECT slug, ${TRUST_SELECT} FROM ledger GROUP BY slug`);
|
|
179
|
+
const ratioStmt = db.query("SELECT est_bytes, actual_tokens, samples FROM token_calibration WHERE tokenizer = ?");
|
|
180
|
+
const recentStmt = db.query("SELECT * FROM ledger ORDER BY created_at_ms DESC LIMIT ?");
|
|
181
|
+
const cacheMetaStmt = db.query("SELECT fetched_at_ms FROM catalog_cache WHERE id = 1");
|
|
182
|
+
const cachePayloadStmt = db.query("SELECT payload FROM catalog_cache WHERE id = 1");
|
|
183
|
+
|
|
184
|
+
let indexFetchedAtMs = -1;
|
|
185
|
+
let modelBySlug: Map<string, CatalogModel> | null = null;
|
|
186
|
+
|
|
187
|
+
/** Slug → catalog model, rebuilt only when the catalog cache row changes. */
|
|
188
|
+
function priceIndex(): Map<string, CatalogModel> | null {
|
|
189
|
+
const meta = cacheMetaStmt.get() as { fetched_at_ms: number } | null;
|
|
190
|
+
if (meta === null) return null;
|
|
191
|
+
if (modelBySlug !== null && indexFetchedAtMs === meta.fetched_at_ms) return modelBySlug;
|
|
192
|
+
const row = cachePayloadStmt.get() as { payload: string } | null;
|
|
193
|
+
if (row === null) return null;
|
|
194
|
+
const payload: unknown = JSON.parse(row.payload);
|
|
195
|
+
if (!Array.isArray(payload)) return null;
|
|
196
|
+
const map = new Map<string, CatalogModel>();
|
|
197
|
+
for (const record of payload) {
|
|
198
|
+
const model = normalizeCatalogModel(record);
|
|
199
|
+
if (model !== null) map.set(model.slug, model);
|
|
200
|
+
}
|
|
201
|
+
modelBySlug = map;
|
|
202
|
+
indexFetchedAtMs = meta.fetched_at_ms;
|
|
203
|
+
return map;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
record(entry: LedgerEntry): void {
|
|
208
|
+
const models = priceIndex();
|
|
209
|
+
const model = (entry.servedSlug !== null ? models?.get(entry.servedSlug) : undefined) ?? models?.get(entry.slug) ?? null;
|
|
210
|
+
insertStmt.run(
|
|
211
|
+
entry.id,
|
|
212
|
+
entry.createdAtMs,
|
|
213
|
+
entry.conversationKey,
|
|
214
|
+
entry.sessionId,
|
|
215
|
+
entry.turn,
|
|
216
|
+
entry.requestedModel,
|
|
217
|
+
entry.harnessId,
|
|
218
|
+
entry.slug,
|
|
219
|
+
entry.servedSlug,
|
|
220
|
+
entry.tier,
|
|
221
|
+
entry.classificationSource,
|
|
222
|
+
JSON.stringify(entry.reasons),
|
|
223
|
+
entry.predictedUsd,
|
|
224
|
+
entry.reportedUsd,
|
|
225
|
+
JSON.stringify(entry.usage),
|
|
226
|
+
model !== null ? JSON.stringify(computeCost(model, entry.usage)) : null,
|
|
227
|
+
entry.attempt,
|
|
228
|
+
entry.escalationSignal,
|
|
229
|
+
entry.latencyMs,
|
|
230
|
+
entry.ttftMs,
|
|
231
|
+
entry.finishReason,
|
|
232
|
+
entry.wasted ? 1 : 0,
|
|
233
|
+
entry.upstreamGenerationId,
|
|
234
|
+
entry.error,
|
|
235
|
+
errorKindOf(entry.error),
|
|
236
|
+
);
|
|
237
|
+
// Always consume the pending estimate, even when the turn failed, so a
|
|
238
|
+
// dead turn's bytes can never pair with a later turn's tokens. Only
|
|
239
|
+
// actually-billed prompt tokens calibrate.
|
|
240
|
+
const pending = consumePendingEstimate(entry.conversationKey);
|
|
241
|
+
if (entry.usage.promptTokens > 0 && pending !== null) {
|
|
242
|
+
// The SERVED model's tokenizer produced the billing; the estimate-time
|
|
243
|
+
// family is the fallback when the model is unknown to the catalog.
|
|
244
|
+
const tokenizer = (model?.tokenizer ?? pending.tokenizer).trim().toLowerCase();
|
|
245
|
+
calibrationStmt.run(tokenizer, pending.bytes, entry.usage.promptTokens);
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
conversationSpend(conversationKey: string): number {
|
|
250
|
+
const row = spendByConversationStmt.get(conversationKey) as { total: number } | null;
|
|
251
|
+
return row?.total ?? 0;
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
spendSince(sinceMs: number, harnessId?: string): number {
|
|
255
|
+
const row =
|
|
256
|
+
harnessId !== undefined && harnessId !== ""
|
|
257
|
+
? (spendSinceHarnessStmt.get(sinceMs, harnessId) as { total: number } | null)
|
|
258
|
+
: (spendSinceStmt.get(sinceMs) as { total: number } | null);
|
|
259
|
+
return row?.total ?? 0;
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
blendedRate(windowDays: number): BlendedRate | null {
|
|
263
|
+
return computeBlendedRate(db, cfg, windowDays);
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
trust(slug: string, harnessId?: string): ModelTrust | null {
|
|
267
|
+
const row =
|
|
268
|
+
harnessId !== undefined && harnessId !== ""
|
|
269
|
+
? (trustHarnessStmt.get(slug, harnessId) as TrustRow | null)
|
|
270
|
+
: (trustStmt.get(slug) as TrustRow | null);
|
|
271
|
+
if (row === null || row.attempts === 0) return null;
|
|
272
|
+
return toTrust(slug, row);
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
allTrust(): ModelTrust[] {
|
|
276
|
+
const rows = allTrustStmt.all() as (TrustRow & { slug: string })[];
|
|
277
|
+
return rows.map((row) => toTrust(row.slug, row));
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
tokenRatio(tokenizer: string): number | null {
|
|
281
|
+
const row = ratioStmt.get(tokenizer.trim().toLowerCase()) as CalibrationRow | null;
|
|
282
|
+
if (row === null || row.samples < MIN_CALIBRATION_SAMPLES || row.actual_tokens <= 0) return null;
|
|
283
|
+
return row.est_bytes / row.actual_tokens;
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
recentEntries(limit: number): LedgerEntry[] {
|
|
287
|
+
const rows = recentStmt.all(limit) as LedgerRow[];
|
|
288
|
+
return rows.map(toEntry);
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cost prediction, reconciliation, and the spend ledger.
|
|
3
|
+
*
|
|
4
|
+
* Two numbers exist for every request and they are never conflated:
|
|
5
|
+
* - **predicted**: our arithmetic over the catalog, computed *before* dispatch.
|
|
6
|
+
* Drives routing and budget enforcement.
|
|
7
|
+
* - **reported**: `usage.cost` returned by OpenRouter, authoritative after the
|
|
8
|
+
* fact. Drives the ledger, `stats`, and prediction-error calibration.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Token counts for one upstream generation. */
|
|
12
|
+
export interface UsageCounts {
|
|
13
|
+
/** Total prompt tokens, *including* `cachedTokens` (OpenAI/OpenRouter convention). */
|
|
14
|
+
promptTokens: number;
|
|
15
|
+
/** Prompt tokens served from cache (`prompt_tokens_details.cached_tokens`). */
|
|
16
|
+
cachedTokens: number;
|
|
17
|
+
/** Prompt tokens written to cache (`prompt_tokens_details.cache_write_tokens`). */
|
|
18
|
+
cacheWriteTokens: number;
|
|
19
|
+
completionTokens: number;
|
|
20
|
+
/** `completion_tokens_details.reasoning_tokens`. Subset of completion tokens. */
|
|
21
|
+
reasoningTokens: number;
|
|
22
|
+
/** Images in the prompt, for per-image surcharges. */
|
|
23
|
+
images: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const EMPTY_USAGE: UsageCounts = {
|
|
27
|
+
promptTokens: 0,
|
|
28
|
+
cachedTokens: 0,
|
|
29
|
+
cacheWriteTokens: 0,
|
|
30
|
+
completionTokens: 0,
|
|
31
|
+
reasoningTokens: 0,
|
|
32
|
+
images: 0,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Per-component cost decomposition, USD. Components sum to `total`. */
|
|
36
|
+
export interface CostBreakdown {
|
|
37
|
+
freshPrompt: number;
|
|
38
|
+
cacheRead: number;
|
|
39
|
+
cacheWrite: number;
|
|
40
|
+
completion: number;
|
|
41
|
+
reasoning: number;
|
|
42
|
+
images: number;
|
|
43
|
+
request: number;
|
|
44
|
+
total: number;
|
|
45
|
+
/** Which price tier was applied (`minPromptTokens` of the winning tier, 0 = base). */
|
|
46
|
+
tierAtPromptTokens: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What a candidate model is expected to cost for a pending request. */
|
|
50
|
+
export interface CostForecast {
|
|
51
|
+
slug: string;
|
|
52
|
+
/** Expected total, USD. */
|
|
53
|
+
expectedUsd: number;
|
|
54
|
+
/** Forecast assuming zero cache hits — the worst case a budget guard must survive. */
|
|
55
|
+
coldUsd: number;
|
|
56
|
+
breakdown: CostBreakdown;
|
|
57
|
+
/** Prompt tokens the forecast assumed. */
|
|
58
|
+
assumedPromptTokens: number;
|
|
59
|
+
/** Completion tokens the forecast assumed. */
|
|
60
|
+
assumedCompletionTokens: number;
|
|
61
|
+
/** Fraction of prompt tokens assumed to hit cache, 0-1. */
|
|
62
|
+
assumedCacheHitRate: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One dispatched upstream generation, successful or not. */
|
|
66
|
+
export interface LedgerEntry {
|
|
67
|
+
id: string;
|
|
68
|
+
createdAtMs: number;
|
|
69
|
+
conversationKey: string;
|
|
70
|
+
sessionId: string;
|
|
71
|
+
/** Turn index within the conversation, 1-based. */
|
|
72
|
+
turn: number;
|
|
73
|
+
/** Virtual model omp asked for, e.g. `auto`. */
|
|
74
|
+
requestedModel: string;
|
|
75
|
+
/** Harness id from the request header; empty for the default harness. */
|
|
76
|
+
harnessId: string;
|
|
77
|
+
/** Concrete slug we dispatched to. */
|
|
78
|
+
slug: string;
|
|
79
|
+
/** Slug that actually served it, per the response `model` field. */
|
|
80
|
+
servedSlug: string | null;
|
|
81
|
+
tier: string;
|
|
82
|
+
classificationSource: string;
|
|
83
|
+
/** Human-readable decision trail. */
|
|
84
|
+
reasons: string[];
|
|
85
|
+
predictedUsd: number;
|
|
86
|
+
reportedUsd: number | null;
|
|
87
|
+
usage: UsageCounts;
|
|
88
|
+
/** Attempt index within this turn; >0 means this was an escalation retry. */
|
|
89
|
+
attempt: number;
|
|
90
|
+
/** Why this attempt was superseded, if it was. */
|
|
91
|
+
escalationSignal: string | null;
|
|
92
|
+
/** Wall-clock ms from dispatch to final chunk. */
|
|
93
|
+
latencyMs: number;
|
|
94
|
+
/** Time to first content token, ms. */
|
|
95
|
+
ttftMs: number | null;
|
|
96
|
+
finishReason: string | null;
|
|
97
|
+
/** Tokens billed but discarded because the attempt was aborted and retried. */
|
|
98
|
+
wasted: boolean;
|
|
99
|
+
upstreamGenerationId: string | null;
|
|
100
|
+
error: string | null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Rolling blended rate used to keep omp's cost display honest. */
|
|
104
|
+
export interface BlendedRate {
|
|
105
|
+
/** USD per million prompt tokens, spend-weighted over the window. */
|
|
106
|
+
inputPerMtok: number;
|
|
107
|
+
/** USD per million completion tokens, spend-weighted over the window. */
|
|
108
|
+
outputPerMtok: number;
|
|
109
|
+
/** USD per million cached prompt tokens. */
|
|
110
|
+
cacheReadPerMtok: number;
|
|
111
|
+
/** USD per million cache-write tokens. */
|
|
112
|
+
cacheWritePerMtok: number;
|
|
113
|
+
/** Requests the blend is based on. Low counts ⇒ fall back to a config default. */
|
|
114
|
+
sampleCount: number;
|
|
115
|
+
windowDays: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Per-model reliability learned from our own traffic. Feeds candidate scoring. */
|
|
119
|
+
export interface ModelTrust {
|
|
120
|
+
slug: string;
|
|
121
|
+
attempts: number;
|
|
122
|
+
/** Attempts superseded by an escalation. */
|
|
123
|
+
escalations: number;
|
|
124
|
+
/** Attempts that ended in an upstream error. */
|
|
125
|
+
errors: number;
|
|
126
|
+
/** Laplace-smoothed success rate, 0-1. */
|
|
127
|
+
successRate: number;
|
|
128
|
+
/** Mean absolute relative prediction error, for forecast calibration. */
|
|
129
|
+
meanCostError: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface Ledger {
|
|
133
|
+
record(entry: LedgerEntry): void;
|
|
134
|
+
/** Total reported (or predicted, when reported is null) spend for a conversation. */
|
|
135
|
+
conversationSpend(conversationKey: string): number;
|
|
136
|
+
/**
|
|
137
|
+
* Total spend since a wall-clock instant. When `harnessId` is non-empty,
|
|
138
|
+
* scoped to that harness only; empty ⇒ all harnesses (global).
|
|
139
|
+
*/
|
|
140
|
+
spendSince(sinceMs: number, harnessId?: string): number;
|
|
141
|
+
blendedRate(windowDays: number): BlendedRate | null;
|
|
142
|
+
/** Per-model reliability over the ledger, optionally scoped to a harness. */
|
|
143
|
+
trust(slug: string, harnessId?: string): ModelTrust | null;
|
|
144
|
+
allTrust(): ModelTrust[];
|
|
145
|
+
/** Observed chars-per-token ratio for a tokenizer family; null until calibrated. */
|
|
146
|
+
tokenRatio(tokenizer: string): number | null;
|
|
147
|
+
recentEntries(limit: number): LedgerEntry[];
|
|
148
|
+
}
|