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/router/select.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { CatalogSnapshot } from "../catalog/types.ts";
|
|
10
10
|
import type { ProfileConfig, RouterConfig } from "../config/types.ts";
|
|
11
11
|
import { forecast, priceAt } from "../cost/forecast.ts";
|
|
12
|
-
import type {
|
|
12
|
+
import type { LedgerSignals, ModelCacheReliability } from "../cost/types.ts";
|
|
13
13
|
import { explorationDraw } from "./explore.ts";
|
|
14
14
|
import type { CompactionEdit, NormRequest, ReasoningLevel } from "../wire/types.ts";
|
|
15
15
|
import { compactedBytes, planCompaction, validatePlan, type CompactionResult } from "./compaction.ts";
|
|
@@ -35,7 +35,6 @@ export interface SelectArgs {
|
|
|
35
35
|
profile: ProfileConfig;
|
|
36
36
|
state: ConversationState;
|
|
37
37
|
snapshot: CatalogSnapshot;
|
|
38
|
-
ledger: Ledger | null;
|
|
39
38
|
cfg: RouterConfig;
|
|
40
39
|
nowMs: number;
|
|
41
40
|
/**
|
|
@@ -49,6 +48,30 @@ export interface SelectArgs {
|
|
|
49
48
|
* stay/switch comparison. Ignored when the catalog has no such model.
|
|
50
49
|
*/
|
|
51
50
|
forceSlug?: string;
|
|
51
|
+
/** Ledger reads already performed for this turn; see TurnReads. */
|
|
52
|
+
reads?: TurnReads;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every ledger read a turn needs, fetched BEFORE selection.
|
|
57
|
+
*
|
|
58
|
+
* `select` is synchronous on purpose — it is a pure ranking function, and
|
|
59
|
+
* `explain` depends on being able to run it without side effects. A ledger on
|
|
60
|
+
* Postgres cannot be read synchronously, so the reads move up to `route`,
|
|
61
|
+
* which is already async, and arrive here as data. Absent ⇒ read through the
|
|
62
|
+
* absent reads degrade to no signals rather than reaching for the store.
|
|
63
|
+
*/
|
|
64
|
+
export interface TurnReads {
|
|
65
|
+
/** Trust and latency per candidate slug; one batch query per signal kind. */
|
|
66
|
+
signals?: Map<string, LedgerSignals>;
|
|
67
|
+
/** Observed warm-cache hit rate per slug. */
|
|
68
|
+
cacheReliability?: Map<string, ModelCacheReliability>;
|
|
69
|
+
/** What an escalated retry bills per prompt token; null ⇒ the term is inert. */
|
|
70
|
+
escalationUsdPerPromptToken?: number | null;
|
|
71
|
+
/** Spend since the start of the UTC month, scoped as the filters say. */
|
|
72
|
+
monthSpendUsd?: number;
|
|
73
|
+
/** Spend over the rolling 24h, scoped as the filters say. */
|
|
74
|
+
daySpendUsd?: number;
|
|
52
75
|
}
|
|
53
76
|
|
|
54
77
|
/**
|
|
@@ -67,7 +90,6 @@ export class BudgetExceededError extends Error {
|
|
|
67
90
|
// Mid-range completion assumption for forecasts. Long generations amortize
|
|
68
91
|
// into prompt-dominated cost anyway; precision here does not move rankings.
|
|
69
92
|
const EXPECTED_COMPLETION_TOKENS = 1024;
|
|
70
|
-
const DAY_MS = 86_400_000;
|
|
71
93
|
|
|
72
94
|
/**
|
|
73
95
|
* Authors known to accept replayed assistant reasoning over chat completions:
|
|
@@ -129,7 +151,7 @@ export function monthPace(nowMs: number, perMonthUsd: number, spentUsd: number):
|
|
|
129
151
|
}
|
|
130
152
|
|
|
131
153
|
export function select(args: SelectArgs): Decision {
|
|
132
|
-
const { req, features, classification, profile, state, snapshot,
|
|
154
|
+
const { req, features, classification, profile, state, snapshot, cfg, nowMs } = args;
|
|
133
155
|
const reasons: string[] = [];
|
|
134
156
|
const minI = tierIdx(profile.minTier);
|
|
135
157
|
const maxI = tierIdx(profile.maxTier);
|
|
@@ -351,29 +373,23 @@ export function select(args: SelectArgs): Decision {
|
|
|
351
373
|
const warmSlug = cacheWarm ? state.cacheWarmSlug : null;
|
|
352
374
|
// Expected cache hit for a model: its observed rate once enough warm-expected
|
|
353
375
|
// samples exist (filters.cacheReliabilityMinSamples), else a reliable 1.
|
|
376
|
+
const reads = args.reads;
|
|
354
377
|
const cacheHitExpectation = (slug: string): { rate: number; measured: boolean; samples: number } => {
|
|
355
378
|
const min = cfg.filters.cacheReliabilityMinSamples;
|
|
356
|
-
const rel = min > 0 ? (
|
|
379
|
+
const rel = min > 0 ? (reads?.cacheReliability?.get(slug) ?? null) : null;
|
|
357
380
|
if (rel === null || rel.samples < min) return { rate: 1, measured: false, samples: rel?.samples ?? 0 };
|
|
358
381
|
return { rate: rel.hitRate, measured: true, samples: rel.samples };
|
|
359
382
|
};
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
snapshot.models.map((m) => m.slug),
|
|
366
|
-
cfg.filters.trustScopedByHarness ? req.harnessId : undefined,
|
|
367
|
-
cfg.filters.feedbackByTask ? classification.task : undefined,
|
|
368
|
-
)
|
|
369
|
-
: undefined;
|
|
383
|
+
// Trust and latency for every candidate, fetched by `route` before this ran.
|
|
384
|
+
// There is no fallback read here on purpose: selection is synchronous and the
|
|
385
|
+
// store may be a shared database, so a missing prefetch must degrade to
|
|
386
|
+
// "no signals" rather than silently reach for a handle it cannot await.
|
|
387
|
+
const candidateSignals = reads?.signals;
|
|
370
388
|
// What an escalated retry has actually been billing per prompt token, for
|
|
371
389
|
// the escalation-cost term in candidate scoring. Read once per turn; null
|
|
372
390
|
// (term inert) when the weight is 0 or the ledger has too few samples.
|
|
373
391
|
const escalationUsdPerPromptToken =
|
|
374
|
-
cfg.filters.escalationCostWeight > 0
|
|
375
|
-
? (ledger?.escalationCost?.(cfg.ledger.blendWindowDays)?.usdPerPromptToken ?? null)
|
|
376
|
-
: null;
|
|
392
|
+
cfg.filters.escalationCostWeight > 0 ? (reads?.escalationUsdPerPromptToken ?? null) : null;
|
|
377
393
|
// A pinned slug is admitted the way a config pin is: into the tier's pin
|
|
378
394
|
// list for this call only, so the quality floor cannot keep it out.
|
|
379
395
|
const pinSlug = args.forceSlug !== undefined && snapshot.models.some((m) => m.slug === args.forceSlug) ? args.forceSlug : undefined;
|
|
@@ -386,7 +402,6 @@ export function select(args: SelectArgs): Decision {
|
|
|
386
402
|
tier: t,
|
|
387
403
|
task: classification.task,
|
|
388
404
|
snapshot,
|
|
389
|
-
ledger,
|
|
390
405
|
cfg: buildCfg,
|
|
391
406
|
expectedCompletionTokens: EXPECTED_COMPLETION_TOKENS,
|
|
392
407
|
warmSlug,
|
|
@@ -515,13 +530,15 @@ export function select(args: SelectArgs): Decision {
|
|
|
515
530
|
// left, becomes a daily ceiling that tightens as the month runs ahead.
|
|
516
531
|
let paceNote = "";
|
|
517
532
|
if (budget.perMonthUsd !== undefined) {
|
|
518
|
-
const
|
|
533
|
+
const monthSpend = reads?.monthSpendUsd ?? 0;
|
|
534
|
+
const pace = monthPace(nowMs, budget.perMonthUsd, monthSpend);
|
|
519
535
|
if (budget.perDayUsd === undefined || pace.dailyCapUsd < budget.perDayUsd) {
|
|
520
536
|
budget.perDayUsd = pace.dailyCapUsd;
|
|
521
537
|
paceNote = ` (month pacing: $${pace.spentUsd.toFixed(2)} of $${budget.perMonthUsd} spent, $${pace.dailyCapUsd.toFixed(2)}/day for ${pace.daysLeft} more days)`;
|
|
522
538
|
}
|
|
523
539
|
}
|
|
524
|
-
const daySpend =
|
|
540
|
+
const daySpend =
|
|
541
|
+
budget.perDayUsd !== undefined ? (reads?.daySpendUsd ?? 0) : 0;
|
|
525
542
|
const breach = (c: Candidate): string | null => {
|
|
526
543
|
if (budget.perTurnUsd !== undefined && c.forecast.coldUsd > budget.perTurnUsd) {
|
|
527
544
|
return `cold forecast $${c.forecast.coldUsd.toFixed(4)} > per-turn budget $${budget.perTurnUsd}`;
|
package/src/router/state.ts
CHANGED
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
* survive a restart: an omp session outlives this process, and forgetting
|
|
6
6
|
* which model is warm would cold-start a paid prompt cache for no reason.
|
|
7
7
|
*
|
|
8
|
-
* The `conversations` table is created by
|
|
9
|
-
*
|
|
8
|
+
* The `conversations` table is created by the store's migration
|
|
9
|
+
* (`util/schema.ts`, or `util/sqlite.ts` for a file that predates it).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import
|
|
12
|
+
import { jsonParam, jsonValue, num, type SqlDb } from "../util/sql.ts";
|
|
13
13
|
|
|
14
14
|
import type { CompactionEdit } from "../wire/types.ts";
|
|
15
15
|
import type { ConversationState, ConversationStore, Tier } from "./types.ts";
|
|
@@ -19,147 +19,135 @@ import type { ConversationState, ConversationStore, Tier } from "./types.ts";
|
|
|
19
19
|
interface Row {
|
|
20
20
|
key: string;
|
|
21
21
|
session_id: string;
|
|
22
|
-
turn:
|
|
22
|
+
turn: unknown;
|
|
23
23
|
current_slug: string | null;
|
|
24
24
|
current_tier: string | null;
|
|
25
|
-
sticky_until_turn:
|
|
26
|
-
escalations:
|
|
27
|
-
spent_usd:
|
|
28
|
-
last_prompt_tokens:
|
|
25
|
+
sticky_until_turn: unknown;
|
|
26
|
+
escalations: unknown;
|
|
27
|
+
spent_usd: unknown;
|
|
28
|
+
last_prompt_tokens: unknown;
|
|
29
29
|
cache_warm_slug: string | null;
|
|
30
|
-
cache_warm_at_ms:
|
|
30
|
+
cache_warm_at_ms: unknown;
|
|
31
31
|
context_version: string | null;
|
|
32
|
-
context_fetched_at_ms:
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
context_fetched_at_ms: unknown;
|
|
33
|
+
/** JSON: text on sqlite, already parsed on postgres. */
|
|
34
|
+
compaction_plan: unknown;
|
|
35
|
+
compaction_plan_tokens: unknown;
|
|
35
36
|
upgrade_deferred_tier: string | null;
|
|
36
|
-
updated_at_ms:
|
|
37
|
+
updated_at_ms: unknown;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
function toState(row: Row): ConversationState {
|
|
40
41
|
return {
|
|
41
42
|
key: row.key,
|
|
42
43
|
sessionId: row.session_id,
|
|
43
|
-
|
|
44
|
+
// Counts and sums arrive as strings from Postgres, and every one of these
|
|
45
|
+
// feeds arithmetic — the sticky window, the budget guard, cache warmth.
|
|
46
|
+
turn: num(row.turn),
|
|
44
47
|
currentSlug: row.current_slug,
|
|
45
48
|
// Stored as free text; the column is only ever written from a Tier.
|
|
46
49
|
currentTier: row.current_tier as Tier | null,
|
|
47
|
-
stickyUntilTurn: row.sticky_until_turn,
|
|
48
|
-
escalations: row.escalations,
|
|
49
|
-
spentUsd: row.spent_usd,
|
|
50
|
-
lastPromptTokens: row.last_prompt_tokens,
|
|
50
|
+
stickyUntilTurn: num(row.sticky_until_turn),
|
|
51
|
+
escalations: num(row.escalations),
|
|
52
|
+
spentUsd: num(row.spent_usd),
|
|
53
|
+
lastPromptTokens: num(row.last_prompt_tokens),
|
|
51
54
|
cacheWarmSlug: row.cache_warm_slug,
|
|
52
|
-
cacheWarmAtMs: row.cache_warm_at_ms,
|
|
55
|
+
cacheWarmAtMs: num(row.cache_warm_at_ms),
|
|
53
56
|
contextVersion: row.context_version,
|
|
54
|
-
contextFetchedAtMs: row.context_fetched_at_ms,
|
|
55
|
-
compactionPlan:
|
|
56
|
-
compactionPlanTokens: row.compaction_plan_tokens,
|
|
57
|
+
contextFetchedAtMs: num(row.context_fetched_at_ms),
|
|
58
|
+
compactionPlan: jsonValue<CompactionEdit[]>(row.compaction_plan),
|
|
59
|
+
compactionPlanTokens: num(row.compaction_plan_tokens),
|
|
57
60
|
upgradeDeferredTier: row.upgrade_deferred_tier as Tier | null,
|
|
58
|
-
updatedAtMs: row.updated_at_ms,
|
|
61
|
+
updatedAtMs: num(row.updated_at_ms),
|
|
59
62
|
};
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
export function createConversationStore(db:
|
|
63
|
-
|
|
64
|
-
const selectOne
|
|
65
|
-
|
|
66
|
-
"INSERT INTO conversations (key, session_id, updated_at_ms) VALUES (?, ?, ?)",
|
|
67
|
-
);
|
|
68
|
-
// `spent_usd` and `escalations` are ABSENT from this statement on purpose.
|
|
69
|
-
// They accumulate through `accrueOne` below, so writing a turn-start snapshot
|
|
70
|
-
// back here would erase whatever a billed-but-uncommitted dispatch added.
|
|
71
|
-
// The schema defaults both to 0, so the INSERT arm still works.
|
|
72
|
-
const upsert = db.query(`
|
|
73
|
-
INSERT INTO conversations (
|
|
74
|
-
key, session_id, turn, current_slug, current_tier, sticky_until_turn,
|
|
75
|
-
last_prompt_tokens, cache_warm_slug, cache_warm_at_ms,
|
|
76
|
-
context_version, context_fetched_at_ms, compaction_plan, compaction_plan_tokens, upgrade_deferred_tier, updated_at_ms
|
|
77
|
-
) VALUES ($key, $sessionId, $turn, $currentSlug, $currentTier, $stickyUntilTurn,
|
|
78
|
-
$lastPromptTokens, $cacheWarmSlug, $cacheWarmAtMs,
|
|
79
|
-
$contextVersion, $contextFetchedAtMs, $compactionPlan, $compactionPlanTokens, $upgradeDeferredTier, $updatedAtMs)
|
|
80
|
-
ON CONFLICT(key) DO UPDATE SET
|
|
81
|
-
session_id = excluded.session_id,
|
|
82
|
-
turn = excluded.turn,
|
|
83
|
-
current_slug = excluded.current_slug,
|
|
84
|
-
current_tier = excluded.current_tier,
|
|
85
|
-
sticky_until_turn = excluded.sticky_until_turn,
|
|
86
|
-
last_prompt_tokens = excluded.last_prompt_tokens,
|
|
87
|
-
cache_warm_slug = excluded.cache_warm_slug,
|
|
88
|
-
cache_warm_at_ms = excluded.cache_warm_at_ms,
|
|
89
|
-
context_version = excluded.context_version,
|
|
90
|
-
context_fetched_at_ms = excluded.context_fetched_at_ms,
|
|
91
|
-
compaction_plan = excluded.compaction_plan,
|
|
92
|
-
compaction_plan_tokens = excluded.compaction_plan_tokens,
|
|
93
|
-
upgrade_deferred_tier = excluded.upgrade_deferred_tier,
|
|
94
|
-
updated_at_ms = excluded.updated_at_ms
|
|
95
|
-
`);
|
|
96
|
-
// Read-modify-write in JS lost money: an aborted or failed dispatch is still
|
|
97
|
-
// billed by the upstream, but it returns before the commit path, so the next
|
|
98
|
-
// dispatch loaded a stale total and overwrote it. Measured on live data:
|
|
99
|
-
// 152 aborted dispatches billing $0.9985 — 30% of all spend — never reached
|
|
100
|
-
// `spent_usd`, leaving the per-conversation budget guard blind to it.
|
|
101
|
-
// Accumulating in SQL is correct regardless of who raced whom.
|
|
102
|
-
const accrueOne = db.query(`
|
|
103
|
-
UPDATE conversations
|
|
104
|
-
SET spent_usd = spent_usd + $spentUsd,
|
|
105
|
-
escalations = escalations + $escalations,
|
|
106
|
-
updated_at_ms = $updatedAtMs
|
|
107
|
-
WHERE key = $key
|
|
108
|
-
`);
|
|
109
|
-
const deleteStale: Statement<unknown, [number]> = db.query("DELETE FROM conversations WHERE updated_at_ms < ?");
|
|
65
|
+
export function createConversationStore(db: SqlDb): ConversationStore {
|
|
66
|
+
const { sql } = db;
|
|
67
|
+
const selectOne = async (key: string): Promise<Row | null> =>
|
|
68
|
+
await db.one<Row>("SELECT * FROM conversations WHERE key = $key", { key });
|
|
110
69
|
|
|
111
70
|
return {
|
|
112
|
-
get(key) {
|
|
113
|
-
const row = selectOne
|
|
71
|
+
async get(key) {
|
|
72
|
+
const row = await selectOne(key);
|
|
114
73
|
return row === null ? null : toState(row);
|
|
115
74
|
},
|
|
116
75
|
|
|
117
|
-
load(key) {
|
|
118
|
-
const existing = selectOne
|
|
76
|
+
async load(key) {
|
|
77
|
+
const existing = await selectOne(key);
|
|
119
78
|
if (existing !== null) return toState(existing);
|
|
120
79
|
// Session id is derived, not random, so it stays stable if this row is
|
|
121
80
|
// ever pruned and the same conversation continues afterwards.
|
|
122
81
|
const sessionId = `omp-${key}`;
|
|
123
|
-
|
|
124
|
-
|
|
82
|
+
// Two replicas can reach this at once for the same conversation; the
|
|
83
|
+
// loser must read the winner's row rather than fail the turn.
|
|
84
|
+
await sql`INSERT INTO conversations (key, session_id, updated_at_ms) VALUES (${key}, ${sessionId}, ${Date.now()})
|
|
85
|
+
ON CONFLICT (key) DO NOTHING`;
|
|
86
|
+
const inserted = await selectOne(key);
|
|
125
87
|
if (inserted === null) throw new Error(`conversation row vanished immediately after insert: ${key}`);
|
|
126
88
|
return toState(inserted);
|
|
127
89
|
},
|
|
128
90
|
|
|
129
|
-
save(state) {
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
91
|
+
async save(state) {
|
|
92
|
+
// `spent_usd` and `escalations` are ABSENT on purpose: they accumulate
|
|
93
|
+
// through `accrue`, so writing a turn-start snapshot back here would
|
|
94
|
+
// erase whatever a billed-but-uncommitted dispatch added. The schema
|
|
95
|
+
// defaults both to 0, so the INSERT arm still works.
|
|
96
|
+
await sql`
|
|
97
|
+
INSERT INTO conversations (
|
|
98
|
+
key, session_id, turn, current_slug, current_tier, sticky_until_turn,
|
|
99
|
+
last_prompt_tokens, cache_warm_slug, cache_warm_at_ms,
|
|
100
|
+
context_version, context_fetched_at_ms, compaction_plan, compaction_plan_tokens,
|
|
101
|
+
upgrade_deferred_tier, updated_at_ms
|
|
102
|
+
) VALUES (
|
|
103
|
+
${state.key}, ${state.sessionId}, ${state.turn}, ${state.currentSlug}, ${state.currentTier},
|
|
104
|
+
${state.stickyUntilTurn}, ${state.lastPromptTokens}, ${state.cacheWarmSlug}, ${state.cacheWarmAtMs},
|
|
105
|
+
${state.contextVersion}, ${state.contextFetchedAtMs}, ${jsonParam(db, state.compactionPlan)},
|
|
106
|
+
${state.compactionPlanTokens ?? 0}, ${state.upgradeDeferredTier ?? null}, ${Date.now()}
|
|
107
|
+
)
|
|
108
|
+
ON CONFLICT (key) DO UPDATE SET
|
|
109
|
+
session_id = excluded.session_id,
|
|
110
|
+
turn = excluded.turn,
|
|
111
|
+
current_slug = excluded.current_slug,
|
|
112
|
+
current_tier = excluded.current_tier,
|
|
113
|
+
sticky_until_turn = excluded.sticky_until_turn,
|
|
114
|
+
last_prompt_tokens = excluded.last_prompt_tokens,
|
|
115
|
+
cache_warm_slug = excluded.cache_warm_slug,
|
|
116
|
+
cache_warm_at_ms = excluded.cache_warm_at_ms,
|
|
117
|
+
context_version = excluded.context_version,
|
|
118
|
+
context_fetched_at_ms = excluded.context_fetched_at_ms,
|
|
119
|
+
compaction_plan = excluded.compaction_plan,
|
|
120
|
+
compaction_plan_tokens = excluded.compaction_plan_tokens,
|
|
121
|
+
upgrade_deferred_tier = excluded.upgrade_deferred_tier,
|
|
122
|
+
updated_at_ms = excluded.updated_at_ms`;
|
|
150
123
|
},
|
|
151
124
|
|
|
152
|
-
accrue(key, delta) {
|
|
125
|
+
async accrue(key, delta) {
|
|
153
126
|
const spentUsd = delta.spentUsd ?? 0;
|
|
154
127
|
const escalations = delta.escalations ?? 0;
|
|
155
128
|
// Nothing to add: skip the write rather than bump updated_at_ms and
|
|
156
129
|
// keep a dead conversation alive against `prune`.
|
|
157
130
|
if (spentUsd === 0 && escalations === 0) return;
|
|
158
|
-
|
|
131
|
+
// Read-modify-write in JS lost money: an aborted or failed dispatch is
|
|
132
|
+
// still billed by the upstream, but it returns before the commit path,
|
|
133
|
+
// so the next dispatch loaded a stale total and overwrote it. Measured
|
|
134
|
+
// on live data: 152 aborted dispatches billing $0.9985 — 30% of all
|
|
135
|
+
// spend — never reached `spent_usd`. Accumulating in SQL is correct
|
|
136
|
+
// regardless of who raced whom, and with a shared store the racers can
|
|
137
|
+
// now be different processes.
|
|
138
|
+
await sql`
|
|
139
|
+
UPDATE conversations
|
|
140
|
+
SET spent_usd = spent_usd + ${spentUsd},
|
|
141
|
+
escalations = escalations + ${escalations},
|
|
142
|
+
updated_at_ms = ${Date.now()}
|
|
143
|
+
WHERE key = ${key}`;
|
|
159
144
|
},
|
|
160
145
|
|
|
161
|
-
prune(maxAgeMs) {
|
|
162
|
-
|
|
146
|
+
async prune(maxAgeMs) {
|
|
147
|
+
const deleted = (await sql`DELETE FROM conversations WHERE updated_at_ms < ${Date.now() - maxAgeMs} RETURNING key`) as {
|
|
148
|
+
key: string;
|
|
149
|
+
}[];
|
|
150
|
+
return deleted.length;
|
|
163
151
|
},
|
|
164
152
|
};
|
|
165
153
|
}
|
package/src/router/types.ts
CHANGED
|
@@ -226,16 +226,22 @@ export interface ConversationState {
|
|
|
226
226
|
updatedAtMs: number;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Every method is asynchronous because the store may be a shared database
|
|
231
|
+
* rather than a local file: replicas that cannot see each other's conversation
|
|
232
|
+
* state re-cold-start prompt caches for turns they did not serve, which at an
|
|
233
|
+
* observed 93% cache hit rate is a cost regression, not a latency one.
|
|
234
|
+
*/
|
|
229
235
|
export interface ConversationStore {
|
|
230
|
-
get(key: string): ConversationState | null
|
|
236
|
+
get(key: string): Promise<ConversationState | null>;
|
|
231
237
|
/** Loads existing state or creates a fresh record. */
|
|
232
|
-
load(key: string): ConversationState
|
|
238
|
+
load(key: string): Promise<ConversationState>;
|
|
233
239
|
/**
|
|
234
240
|
* Persists the latest-wins fields. Deliberately does NOT write `spentUsd` or
|
|
235
241
|
* `escalations` — those accumulate via `accrue`, and writing back a snapshot
|
|
236
242
|
* here would clobber what a concurrent or already-billed dispatch added.
|
|
237
243
|
*/
|
|
238
|
-
save(state: ConversationState): void
|
|
244
|
+
save(state: ConversationState): Promise<void>;
|
|
239
245
|
/**
|
|
240
246
|
* Adds to the persisted counters, atomically in SQL.
|
|
241
247
|
*
|
|
@@ -244,9 +250,9 @@ export interface ConversationStore {
|
|
|
244
250
|
* reach the per-conversation budget guard anyway. Requires `load` to have
|
|
245
251
|
* created the row.
|
|
246
252
|
*/
|
|
247
|
-
accrue(key: string, delta: { spentUsd?: number; escalations?: number }): void
|
|
253
|
+
accrue(key: string, delta: { spentUsd?: number; escalations?: number }): Promise<void>;
|
|
248
254
|
/** Drops records untouched for longer than `maxAgeMs`. */
|
|
249
|
-
prune(maxAgeMs: number): number
|
|
255
|
+
prune(maxAgeMs: number): Promise<number>;
|
|
250
256
|
}
|
|
251
257
|
|
|
252
258
|
/** Guarded-probe configuration for one dispatch. */
|
package/src/server/advise.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { RouterConfig } from "../config/types.ts";
|
|
19
|
-
import type {
|
|
19
|
+
import type { AsyncLedger } from "../cost/types.ts";
|
|
20
20
|
import { scoreHeuristic } from "../router/classify.ts";
|
|
21
21
|
import { extractFeatures } from "../router/features.ts";
|
|
22
22
|
import { estimateTokens } from "../tokens/estimate.ts";
|
|
@@ -69,11 +69,13 @@ function requestOf(req: AdviseRequest): NormRequest {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/** Classifies a prompt the way the first turn of a conversation would be, without dispatching anything. */
|
|
72
|
-
export function advise(cfg: RouterConfig, ledger:
|
|
72
|
+
export async function advise(cfg: RouterConfig, ledger: AsyncLedger | null, req: AdviseRequest): Promise<Advice> {
|
|
73
73
|
const norm = requestOf(req);
|
|
74
|
-
|
|
74
|
+
// Advice is a hint for a client that has not dispatched yet: the default
|
|
75
|
+
// family ratio is enough, and it keeps this off the store entirely.
|
|
76
|
+
const features = extractFeatures(norm, estimateTokens(norm.promptBytes, "unknown", null));
|
|
75
77
|
const cls = scoreHeuristic(features, cfg);
|
|
76
|
-
const last = req.ompSessionId === "" ? null : (ledger?.latestForSession
|
|
78
|
+
const last = req.ompSessionId === "" ? null : ((await ledger?.latestForSession(req.ompSessionId))?.tier ?? null);
|
|
77
79
|
return {
|
|
78
80
|
tier: cls.tier,
|
|
79
81
|
task: cls.task,
|
|
@@ -24,7 +24,7 @@ import type { DigestRequest, DigestResult } from "./digest.ts";
|
|
|
24
24
|
export interface CompactionDigester {
|
|
25
25
|
digest(req: DigestRequest): Promise<DigestResult>;
|
|
26
26
|
/** See Digester.noteToolCalls; optional so a fake need not implement it. */
|
|
27
|
-
noteToolCalls?(ompSessionId: string, calls: readonly { name: string; argsJson: string }[], nowMs?: number): number
|
|
27
|
+
noteToolCalls?(ompSessionId: string, calls: readonly { name: string; argsJson: string }[], nowMs?: number): Promise<number>;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export interface DigestCompactionArgs {
|
package/src/server/digest.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import type { CatalogModel, CatalogSource } from "../catalog/types.ts";
|
|
22
22
|
import type { DigestConfig, RouterConfig } from "../config/types.ts";
|
|
23
23
|
import { computeCost, forecast } from "../cost/forecast.ts";
|
|
24
|
-
import type {
|
|
24
|
+
import type { AsyncLedger, LedgerEntry } from "../cost/types.ts";
|
|
25
25
|
import { buildCandidates } from "../router/candidates.ts";
|
|
26
26
|
import { primaryArg } from "../router/compaction.ts";
|
|
27
27
|
import { extractFeatures } from "../router/features.ts";
|
|
@@ -57,7 +57,7 @@ export type DigestResult =
|
|
|
57
57
|
export interface DigesterDeps {
|
|
58
58
|
cfg: RouterConfig;
|
|
59
59
|
catalog: CatalogSource;
|
|
60
|
-
ledger:
|
|
60
|
+
ledger: AsyncLedger;
|
|
61
61
|
upstream: UpstreamClient;
|
|
62
62
|
log: Logger;
|
|
63
63
|
}
|
|
@@ -144,7 +144,7 @@ export interface Digester {
|
|
|
144
144
|
* back for the full output; that digest's ledger row is marked wasted and
|
|
145
145
|
* the report shows the re-run rate. Returns how many were marked.
|
|
146
146
|
*/
|
|
147
|
-
noteToolCalls(ompSessionId: string, calls: readonly { name: string; argsJson: string }[], nowMs?: number): number
|
|
147
|
+
noteToolCalls(ompSessionId: string, calls: readonly { name: string; argsJson: string }[], nowMs?: number): Promise<number>;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
export function createDigester(deps: DigesterDeps): Digester {
|
|
@@ -163,7 +163,6 @@ export function createDigester(deps: DigesterDeps): Digester {
|
|
|
163
163
|
tier: cfg.digest.tier,
|
|
164
164
|
task: "documentation",
|
|
165
165
|
snapshot,
|
|
166
|
-
ledger,
|
|
167
166
|
cfg,
|
|
168
167
|
expectedCompletionTokens: cfg.digest.maxOutputTokens,
|
|
169
168
|
warmSlug: null,
|
|
@@ -179,14 +178,14 @@ export function createDigester(deps: DigesterDeps): Digester {
|
|
|
179
178
|
async digest(req) {
|
|
180
179
|
const inputBytes = Buffer.byteLength(req.content);
|
|
181
180
|
const source = req.source ?? "tool_result";
|
|
182
|
-
const currentTier = req.tier ?? ledger.latestForSession
|
|
181
|
+
const currentTier = req.tier ?? (await ledger.latestForSession(req.ompSessionId))?.tier ?? null;
|
|
183
182
|
const gate = source === "compaction" ? { ...cfg.digest, enabled: cfg.compaction.digestToolResults } : cfg.digest;
|
|
184
183
|
const applies = digestApplies(gate, req.toolName, inputBytes, false, currentTier);
|
|
185
184
|
if (!applies.ok) return { digested: false, reason: applies.reason };
|
|
186
185
|
|
|
187
186
|
const promptText = `Task: ${req.query === "" ? "(unknown)" : req.query}\nTool: ${req.toolName} ${JSON.stringify(req.input)}\n--- output ---\n${req.content}`;
|
|
188
187
|
const synthetic = syntheticRequest(req, promptText);
|
|
189
|
-
const promptTokens = estimateTokens(synthetic.promptBytes, "unknown",
|
|
188
|
+
const promptTokens = estimateTokens(synthetic.promptBytes, "unknown", null);
|
|
190
189
|
const model = await pickModel(synthetic, promptTokens);
|
|
191
190
|
if (model === null) return { digested: false, reason: "no digest model available" };
|
|
192
191
|
const est = forecast(model, { promptTokens, completionTokens: cfg.digest.maxOutputTokens, cacheHitRate: 0, images: 0 });
|
|
@@ -222,7 +221,7 @@ export function createDigester(deps: DigesterDeps): Digester {
|
|
|
222
221
|
clearTimeout(timer);
|
|
223
222
|
}
|
|
224
223
|
const ms = Date.now() - startedAt;
|
|
225
|
-
const completionTokens = estimateTokens(Buffer.byteLength(text), model.tokenizer,
|
|
224
|
+
const completionTokens = estimateTokens(Buffer.byteLength(text), model.tokenizer, null);
|
|
226
225
|
const usage = { promptTokens, cachedTokens: 0, cacheWriteTokens: 0, completionTokens, reasoningTokens: 0, images: 0 };
|
|
227
226
|
const usd = costUsd ?? computeCost(model, usage).total;
|
|
228
227
|
|
|
@@ -264,7 +263,7 @@ export function createDigester(deps: DigesterDeps): Digester {
|
|
|
264
263
|
priceModel: model,
|
|
265
264
|
};
|
|
266
265
|
try {
|
|
267
|
-
ledger.record(entry);
|
|
266
|
+
await ledger.record(entry);
|
|
268
267
|
} catch (err) {
|
|
269
268
|
log.debug("digest ledger record failed", { error: err instanceof Error ? err.message : String(err) });
|
|
270
269
|
}
|
|
@@ -285,7 +284,7 @@ export function createDigester(deps: DigesterDeps): Digester {
|
|
|
285
284
|
ms,
|
|
286
285
|
};
|
|
287
286
|
},
|
|
288
|
-
noteToolCalls(ompSessionId, calls, nowMs = Date.now()) {
|
|
287
|
+
async noteToolCalls(ompSessionId, calls, nowMs = Date.now()) {
|
|
289
288
|
const list = recent.get(ompSessionId);
|
|
290
289
|
if (list === undefined || list.length === 0) return 0;
|
|
291
290
|
let marked = 0;
|
|
@@ -303,7 +302,7 @@ export function createDigester(deps: DigesterDeps): Digester {
|
|
|
303
302
|
d.rerun = true;
|
|
304
303
|
marked++;
|
|
305
304
|
try {
|
|
306
|
-
ledger.markWasted
|
|
305
|
+
await ledger.markWasted(d.ledgerId);
|
|
307
306
|
} catch (err) {
|
|
308
307
|
log.debug("digest re-run mark failed", { error: err instanceof Error ? err.message : String(err) });
|
|
309
308
|
}
|