auto-model-router 0.2.32 → 0.3.1
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 +225 -29
- package/docs/review-2026-09-05.md +267 -0
- package/omp-extension/configure-logic.ts +71 -15
- package/omp-extension/pi-coding-agent.d.ts +79 -2
- package/omp-extension/report-hub.ts +376 -0
- package/omp-extension/report-logic.ts +117 -0
- package/omp-extension/router-configure.ts +203 -51
- package/omp-extension/router-url.ts +52 -0
- package/omp-extension/toast-logic.ts +14 -2
- package/package.json +1 -1
- package/src/catalog/composite.ts +97 -0
- package/src/catalog/ollama-catalog.ts +309 -0
- package/src/catalog/ollama-prices.ts +85 -0
- package/src/catalog/openrouter-catalog.ts +39 -1
- package/src/catalog/types.ts +31 -1
- package/src/cli/args.ts +1 -0
- package/src/cli/config-wizard.ts +190 -28
- package/src/cli/explain.ts +2 -4
- package/src/cli/models.ts +2 -4
- package/src/cli/report.ts +37 -0
- package/src/config/defaults.ts +46 -2
- package/src/config/load.ts +25 -1
- package/src/config/omp-credentials.ts +31 -7
- package/src/config/schema.ts +28 -0
- package/src/config/types.ts +120 -2
- package/src/cost/cache-estimate.ts +52 -0
- package/src/cost/ledger.ts +73 -4
- package/src/cost/report.ts +351 -0
- package/src/cost/types.ts +39 -1
- package/src/index.ts +5 -8
- package/src/router/candidates.ts +52 -4
- package/src/router/classify.ts +33 -6
- package/src/router/features.ts +13 -1
- package/src/router/select.ts +55 -8
- package/src/router/state.ts +6 -2
- package/src/router/tier-plan.ts +49 -11
- package/src/router/types.ts +10 -0
- package/src/server/http.ts +50 -6
- package/src/server/providers.ts +54 -0
- package/src/server/turn.ts +138 -34
- package/src/tokens/estimate.ts +16 -0
- package/src/upstream/multi.ts +26 -0
- package/src/upstream/ollama-usage.ts +163 -0
- package/src/upstream/ollama.ts +275 -0
- package/src/upstream/openrouter.ts +19 -1
- package/src/upstream/types.ts +2 -0
- package/src/util/sqlite.ts +25 -1
- package/test/cache-estimate.test.ts +48 -0
- package/test/catalog.test.ts +44 -0
- package/test/classify.test.ts +41 -5
- package/test/compaction.test.ts +1 -0
- package/test/config-wizard.test.ts +77 -1
- package/test/configure-logic.test.ts +129 -33
- package/test/embed-lifecycle.test.ts +1 -0
- package/test/failover.test.ts +148 -3
- package/test/features.test.ts +35 -0
- package/test/http-resilience.test.ts +24 -0
- package/test/ollama.test.ts +521 -0
- package/test/omp-credentials.test.ts +43 -1
- package/test/report-hub.test.ts +343 -0
- package/test/report-logic.test.ts +93 -0
- package/test/report.test.ts +233 -0
- package/test/select.test.ts +151 -1
- package/test/tier-plan.test.ts +159 -1
- package/test/toast-logic.test.ts +11 -2
- package/test/tokens.test.ts +71 -1
- package/test/trust-attribution.test.ts +2 -2
- package/test/turn.test.ts +173 -7
- package/tools/recompute-ollama-cache.ts +129 -0
package/test/turn.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { createDisabledBridge } from "../src/context/bridge.ts";
|
|
3
3
|
import type { ContextBridge, TurnRecord } from "../src/context/types.ts";
|
|
4
|
-
import type { CatalogSource } from "../src/catalog/types.ts";
|
|
4
|
+
import type { CatalogModel, CatalogSource } from "../src/catalog/types.ts";
|
|
5
5
|
import type { EscalationConfig, RouterConfig } from "../src/config/types.ts";
|
|
6
6
|
import { EMPTY_USAGE, type Ledger, type LedgerEntry, type UsageCounts } from "../src/cost/types.ts";
|
|
7
7
|
import type {
|
|
@@ -31,6 +31,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
31
31
|
return {
|
|
32
32
|
server: { host: "127.0.0.1", port: 8787, maxConcurrentTurns: 24 },
|
|
33
33
|
openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0 },
|
|
34
|
+
ollama: { enabled: false, baseUrl: "http://127.0.0.1:11434/v1", apiKey: "", timeoutMs: 30_000, catalogTtlMs: 300_000, includeLocal: false, prices: {}, twins: {}, costBias: 1, biasUntilUsage: 0.9, usagePollMs: 0, quotaCooldownMs: 0, rateLimitCooldownMs: 0, planCreditsUsd: 0 },
|
|
34
35
|
benchmarks: { enabled: false, artificialAnalysisApiKey: "", benchlm: true, refreshMs: 86_400_000, timeoutMs: 30_000, useLocalScores: false },
|
|
35
36
|
tiers: {
|
|
36
37
|
trivial: { minQuality: 0, maxInputPerMtok: 0.3, qualityExponent: 0, pin: [] },
|
|
@@ -45,7 +46,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
45
46
|
data: { axis: "intelligence", minQuality: 0 },
|
|
46
47
|
chat: { axis: "intelligence", minQuality: 0 },
|
|
47
48
|
},
|
|
48
|
-
filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, latencyMinSamples: 20 },
|
|
49
|
+
filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, latencyMinSamples: 20, escalationCostWeight: 0 },
|
|
49
50
|
classifier: {
|
|
50
51
|
ambiguityThreshold: 0,
|
|
51
52
|
model: "test/adjudicator",
|
|
@@ -56,6 +57,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
56
57
|
toolAxis: "coding",
|
|
57
58
|
chatAxis: "intelligence",
|
|
58
59
|
agenticLoopDepth: 3,
|
|
60
|
+
mechanicalRetryFactor: 0.2,
|
|
59
61
|
reasoningWeights: { medium: 0.14, high: 0.24, xhigh: 0.3, max: 0.34 },
|
|
60
62
|
},
|
|
61
63
|
escalation: {
|
|
@@ -68,11 +70,11 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
68
70
|
escalateOnLengthStop: false,
|
|
69
71
|
...escalation,
|
|
70
72
|
},
|
|
71
|
-
hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false },
|
|
73
|
+
hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false, switchHorizonTurns: 1 },
|
|
72
74
|
exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
|
|
73
75
|
cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
|
|
74
76
|
context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },
|
|
75
|
-
compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
|
|
77
|
+
compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1 },
|
|
76
78
|
budget: { onExceeded: "downgrade" },
|
|
77
79
|
profiles: [],
|
|
78
80
|
ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
|
|
@@ -147,6 +149,8 @@ function mkDecision(tier: Tier, slug: string, probe: Partial<ProbePlan> = {}): D
|
|
|
147
149
|
cacheBreakpointMessageIndices: [],
|
|
148
150
|
compactionPlan: [],
|
|
149
151
|
promptTokensSaved: 0,
|
|
152
|
+
compactionSavedBytes: 0,
|
|
153
|
+
compactionPlanTokens: 0,
|
|
150
154
|
reasoning: undefined,
|
|
151
155
|
maxTokens: undefined,
|
|
152
156
|
stripAssistantReasoning: false,
|
|
@@ -403,10 +407,13 @@ describe("runTurn", () => {
|
|
|
403
407
|
expect(entries[1]!.attempt).toBe(1);
|
|
404
408
|
expect(entries[1]!.slug).toBe("better/model");
|
|
405
409
|
|
|
406
|
-
//
|
|
407
|
-
|
|
410
|
+
// A refusal indicts the provider, so a same-tier sibling is probed first;
|
|
411
|
+
// this fake router only has the simple-tier decision left, which is the
|
|
412
|
+
// wrong tier, so the turn then escalates one tier up for real.
|
|
413
|
+
expect(calls).toHaveLength(3);
|
|
408
414
|
expect(calls[0]).toEqual({ attempt: 0 });
|
|
409
|
-
expect(calls[1]).toEqual({ attempt: 1
|
|
415
|
+
expect(calls[1]).toEqual({ attempt: 1 });
|
|
416
|
+
expect(calls[2]).toEqual({ attempt: 1, escalateFrom: "trivial" });
|
|
410
417
|
|
|
411
418
|
// The held refusal text never reached the client.
|
|
412
419
|
expect(textOut(chunks)).toBe("Here is the answer.");
|
|
@@ -729,4 +736,163 @@ describe("latency measurement covers the work the router actually does", () => {
|
|
|
729
736
|
expect(entries[0]?.ttftMs).not.toBeNull();
|
|
730
737
|
expect(entries[0]?.ttftMs ?? -1).toBeGreaterThanOrEqual(0);
|
|
731
738
|
});
|
|
739
|
+
|
|
740
|
+
test("a client hang-up AFTER the finish event is a completed turn, not an error", async () => {
|
|
741
|
+
// Measured live: 1,842 of 2,068 "request aborted" rows carried a finish
|
|
742
|
+
// reason and full usage — the generation had finished and the client
|
|
743
|
+
// closed before the trailing [DONE] was read. Recording that as an error
|
|
744
|
+
// skipped the state save on 12% of turns.
|
|
745
|
+
const { router } = mkRouter([mkDecision("trivial", "cheap/model", { escalateTo: "simple" })]);
|
|
746
|
+
const { upstream } = mkUpstream([
|
|
747
|
+
{
|
|
748
|
+
kind: "die",
|
|
749
|
+
chunks: [
|
|
750
|
+
startChunk("cheap/model"),
|
|
751
|
+
textChunk("all done here"),
|
|
752
|
+
finishChunk("stop"),
|
|
753
|
+
usageChunk({ promptTokens: 120, cachedTokens: 100, completionTokens: 4 }, 0.0004),
|
|
754
|
+
],
|
|
755
|
+
error: new UpstreamError("aborted", 0, "request aborted", false),
|
|
756
|
+
},
|
|
757
|
+
]);
|
|
758
|
+
const { ledger, entries } = mkLedger();
|
|
759
|
+
const { store, map } = mkConversations();
|
|
760
|
+
const { sink, errors, finishes } = mkSink();
|
|
761
|
+
|
|
762
|
+
await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge() }, new AbortController().signal);
|
|
763
|
+
|
|
764
|
+
expect(errors).toHaveLength(0);
|
|
765
|
+
expect(entries).toHaveLength(1);
|
|
766
|
+
expect(entries[0]!.error).toBeNull();
|
|
767
|
+
expect(entries[0]!.wasted).toBe(false);
|
|
768
|
+
expect(entries[0]!.finishReason).toBe("stop");
|
|
769
|
+
expect(entries[0]!.reportedUsd).toBe(0.0004);
|
|
770
|
+
// The turn settled: hysteresis, cache warmth and the turn counter advance.
|
|
771
|
+
const state = map.get("conv-test")!;
|
|
772
|
+
expect(state.turn).toBe(1);
|
|
773
|
+
expect(state.currentSlug).toBe("cheap/model");
|
|
774
|
+
expect(state.cacheWarmSlug).toBe("cheap/model");
|
|
775
|
+
expect(state.lastPromptTokens).toBe(120);
|
|
776
|
+
expect(finishes).toHaveLength(1);
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
test("a client hang-up BEFORE the finish event is still recorded as aborted", async () => {
|
|
780
|
+
const { router } = mkRouter([mkDecision("trivial", "cheap/model", { escalateTo: "simple", maxTokens: 1 })]);
|
|
781
|
+
const { upstream } = mkUpstream([
|
|
782
|
+
{
|
|
783
|
+
kind: "die",
|
|
784
|
+
chunks: [startChunk("cheap/model"), textChunk("partial answer that committed")],
|
|
785
|
+
error: new UpstreamError("aborted", 0, "request aborted", false),
|
|
786
|
+
},
|
|
787
|
+
]);
|
|
788
|
+
const { ledger, entries } = mkLedger();
|
|
789
|
+
const { store, map } = mkConversations();
|
|
790
|
+
const { sink, finishes } = mkSink();
|
|
791
|
+
|
|
792
|
+
await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge() }, new AbortController().signal);
|
|
793
|
+
|
|
794
|
+
expect(entries).toHaveLength(1);
|
|
795
|
+
expect(entries[0]!.error).toBe("request aborted");
|
|
796
|
+
expect(entries[0]!.finishReason).toBeNull();
|
|
797
|
+
expect(map.get("conv-test")!.turn).toBe(0);
|
|
798
|
+
expect(finishes).toHaveLength(0);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
test("a provider that reports usage but no cost gets its actual tokens priced at the catalog rate", async () => {
|
|
803
|
+
// Ollama returns usage without `cost`. Leaving the forecast (assumed 1,024
|
|
804
|
+
// completion tokens) as the recorded figure overstated a 26-token turn ~3x.
|
|
805
|
+
const { router } = mkRouter([mkDecision("trivial", "ollama/glm-5.3-flash")]);
|
|
806
|
+
const { upstream } = mkUpstream([
|
|
807
|
+
{
|
|
808
|
+
kind: "chunks",
|
|
809
|
+
chunks: [startChunk("ollama/glm-5.3-flash"), textChunk("ok"), finishChunk("stop"), usageChunk({ promptTokens: 1_000_000, completionTokens: 1_000_000 }, null)],
|
|
810
|
+
},
|
|
811
|
+
]);
|
|
812
|
+
const { ledger, entries } = mkLedger();
|
|
813
|
+
const { store } = mkConversations();
|
|
814
|
+
const { sink, finishes } = mkSink();
|
|
815
|
+
const priced = {
|
|
816
|
+
...catalog,
|
|
817
|
+
find: (slug: string) =>
|
|
818
|
+
slug === "ollama/glm-5.3-flash"
|
|
819
|
+
? ({
|
|
820
|
+
slug,
|
|
821
|
+
provider: "ollama",
|
|
822
|
+
canonicalSlug: slug,
|
|
823
|
+
name: slug,
|
|
824
|
+
contextLength: 1_000_000,
|
|
825
|
+
supportsTools: true,
|
|
826
|
+
supportsReasoning: true,
|
|
827
|
+
reasoningMandatory: false,
|
|
828
|
+
supportsToolChoice: false,
|
|
829
|
+
inputModalities: ["text"],
|
|
830
|
+
price: { prompt: 0.15 / 1e6, completion: 0.5 / 1e6 },
|
|
831
|
+
priceTiers: [],
|
|
832
|
+
quality: {},
|
|
833
|
+
tokenizer: "Other",
|
|
834
|
+
isFree: false,
|
|
835
|
+
createdAtMs: 0,
|
|
836
|
+
author: "ollama",
|
|
837
|
+
} satisfies CatalogModel)
|
|
838
|
+
: undefined,
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog: priced, context: createDisabledBridge() }, new AbortController().signal);
|
|
842
|
+
|
|
843
|
+
// 1M prompt tokens at $0.15/M + 1M completion tokens at $0.50/M.
|
|
844
|
+
expect(entries[0]!.reportedUsd).toBeCloseTo(0.65, 6);
|
|
845
|
+
expect(entries[0]!.priceModel?.slug).toBe("ollama/glm-5.3-flash");
|
|
846
|
+
expect(finishes[0]!.reportedUsd).toBeCloseTo(0.65, 6);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
test("an Ollama-served turn after another on the same model prices the previous prompt as cached", async () => {
|
|
850
|
+
// ollama.com caches prefixes and bills them at the cached rate without
|
|
851
|
+
// reporting a count; the router estimates it with its warm-cache rule.
|
|
852
|
+
const ollamaModel: CatalogModel = {
|
|
853
|
+
slug: "ollama/glm-5.3-flash",
|
|
854
|
+
provider: "ollama",
|
|
855
|
+
canonicalSlug: "ollama/glm-5.3-flash",
|
|
856
|
+
name: "glm",
|
|
857
|
+
contextLength: 1_000_000,
|
|
858
|
+
supportsTools: true,
|
|
859
|
+
supportsReasoning: true,
|
|
860
|
+
reasoningMandatory: false,
|
|
861
|
+
supportsToolChoice: false,
|
|
862
|
+
inputModalities: ["text"],
|
|
863
|
+
price: { prompt: 0.15 / 1e6, cacheRead: 0.03 / 1e6, completion: 0.5 / 1e6 },
|
|
864
|
+
priceTiers: [],
|
|
865
|
+
quality: {},
|
|
866
|
+
tokenizer: "Other",
|
|
867
|
+
isFree: false,
|
|
868
|
+
createdAtMs: 0,
|
|
869
|
+
author: "ollama",
|
|
870
|
+
};
|
|
871
|
+
const priced = { ...catalog, find: (slug: string) => (slug === ollamaModel.slug ? ollamaModel : undefined) };
|
|
872
|
+
const turn = (prompt: number) => ({
|
|
873
|
+
kind: "chunks" as const,
|
|
874
|
+
chunks: [startChunk(ollamaModel.slug), textChunk("ok"), finishChunk("stop"), usageChunk({ promptTokens: prompt, completionTokens: 0 }, null)],
|
|
875
|
+
});
|
|
876
|
+
const { router } = mkRouter([mkDecision("simple", ollamaModel.slug)]);
|
|
877
|
+
const { upstream } = mkUpstream([turn(100_000), turn(120_000)]);
|
|
878
|
+
const { ledger, entries } = mkLedger();
|
|
879
|
+
const { store, map } = mkConversations();
|
|
880
|
+
const deps = { config: mkConfig(), router, upstream, ledger, conversations: store, catalog: priced, context: createDisabledBridge() };
|
|
881
|
+
|
|
882
|
+
await runTurn(mkReq(), mkSink().sink, deps, new AbortController().signal);
|
|
883
|
+
// First turn: nothing to be cached yet, full input rate.
|
|
884
|
+
expect(entries[0]!.usage.cachedTokens).toBe(0);
|
|
885
|
+
expect(entries[0]!.usage.cachedEstimated).toBeUndefined();
|
|
886
|
+
expect(entries[0]!.reportedUsd).toBeCloseTo(0.015, 6);
|
|
887
|
+
|
|
888
|
+
await runTurn(mkReq(), mkSink().sink, deps, new AbortController().signal);
|
|
889
|
+
// Second turn on the same model: the 100k previous prompt is the cached
|
|
890
|
+
// prefix at $0.03/M, the 20k of growth is fresh at $0.15/M.
|
|
891
|
+
expect(entries[1]!.usage.cachedTokens).toBe(100_000);
|
|
892
|
+
expect(entries[1]!.usage.cachedEstimated).toBe(true);
|
|
893
|
+
expect(entries[1]!.reportedUsd).toBeCloseTo(0.003 + 0.003, 6);
|
|
894
|
+
// The estimate is evidence enough to keep the Ollama model warm for the stay/switch comparison.
|
|
895
|
+
expect(map.get("conv-test")!.cacheWarmSlug).toBe(ollamaModel.slug);
|
|
896
|
+
});
|
|
897
|
+
|
|
732
898
|
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* One-off backfill: re-price historical Ollama ledger rows with the router's
|
|
4
|
+
* cache estimate (`src/cost/cache-estimate.ts`).
|
|
5
|
+
*
|
|
6
|
+
* Rows recorded before the estimate existed booked every prompt token at the
|
|
7
|
+
* full input rate, overstating Ollama spend ~3.7x against ollama.com's meter.
|
|
8
|
+
* This walks each conversation's Ollama rows in order, applies the same rule
|
|
9
|
+
* the live path applies (same model as the previous kept row within
|
|
10
|
+
* `hysteresis.cacheWarmTtlMs` ⇒ the previous prompt is the cached prefix),
|
|
11
|
+
* and rewrites `usage`, `cost_breakdown` and `reported_usd` for rows whose
|
|
12
|
+
* cost the router itself computed (Ollama never reports a cost). Rows that
|
|
13
|
+
* already carry a cache count are left alone, so it is safe to re-run.
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* bun tools/recompute-ollama-cache.ts # dry run: prints the delta
|
|
17
|
+
* bun tools/recompute-ollama-cache.ts --apply # backs up router.db, then writes
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { copyFileSync, mkdirSync } from "node:fs";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { Database } from "bun:sqlite";
|
|
23
|
+
import { ollamaRateFor } from "../src/catalog/ollama-prices.ts";
|
|
24
|
+
import { loadConfig } from "../src/config/load.ts";
|
|
25
|
+
import { estimateUnreportedCache } from "../src/cost/cache-estimate.ts";
|
|
26
|
+
import type { UsageCounts } from "../src/cost/types.ts";
|
|
27
|
+
|
|
28
|
+
const apply = process.argv.includes("--apply");
|
|
29
|
+
const cfg = loadConfig({});
|
|
30
|
+
const ttl = cfg.hysteresis.cacheWarmTtlMs;
|
|
31
|
+
|
|
32
|
+
interface Row {
|
|
33
|
+
id: string;
|
|
34
|
+
ck: string;
|
|
35
|
+
t: number;
|
|
36
|
+
latency: number;
|
|
37
|
+
slug: string;
|
|
38
|
+
usage: string;
|
|
39
|
+
reported: number | null;
|
|
40
|
+
wasted: number;
|
|
41
|
+
error: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const db = new Database(cfg.ledger.path);
|
|
45
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
46
|
+
const rows = db
|
|
47
|
+
.query(
|
|
48
|
+
`SELECT id, conversation_key ck, created_at_ms t, latency_ms latency, COALESCE(served_slug, slug) slug, usage, reported_usd reported, wasted, error
|
|
49
|
+
FROM ledger WHERE COALESCE(served_slug, slug) LIKE 'ollama/%' ORDER BY conversation_key, created_at_ms`,
|
|
50
|
+
)
|
|
51
|
+
.all() as Row[];
|
|
52
|
+
|
|
53
|
+
let before = 0;
|
|
54
|
+
let after = 0;
|
|
55
|
+
let changed = 0;
|
|
56
|
+
const updates: { id: string; usage: string; breakdown: string; usd: number }[] = [];
|
|
57
|
+
let prev: Row | undefined;
|
|
58
|
+
let prevPrompt = 0;
|
|
59
|
+
for (const r of rows) {
|
|
60
|
+
const usage = JSON.parse(r.usage) as UsageCounts;
|
|
61
|
+
const sameConv = prev !== undefined && prev.ck === r.ck;
|
|
62
|
+
const rate = ollamaRateFor(r.slug.slice("ollama/".length), cfg.ollama.prices);
|
|
63
|
+
before += r.reported ?? 0;
|
|
64
|
+
if (rate === null || r.reported === null || r.error !== null) {
|
|
65
|
+
after += r.reported ?? 0;
|
|
66
|
+
if (r.error === null) {
|
|
67
|
+
prev = r;
|
|
68
|
+
prevPrompt = usage.promptTokens;
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const est = estimateUnreportedCache(usage, {
|
|
73
|
+
previousSlug: sameConv ? (prev?.slug ?? null) : null,
|
|
74
|
+
previousPromptTokens: sameConv ? prevPrompt : 0,
|
|
75
|
+
previousAtMs: sameConv ? (prev?.t ?? 0) : 0,
|
|
76
|
+
servedSlug: r.slug,
|
|
77
|
+
nowMs: r.t,
|
|
78
|
+
cacheWarmTtlMs: ttl,
|
|
79
|
+
});
|
|
80
|
+
const input = rate.rate.input / 1e6;
|
|
81
|
+
const cached = (rate.rate.cachedInput ?? rate.rate.input) / 1e6;
|
|
82
|
+
const output = rate.rate.output / 1e6;
|
|
83
|
+
const fresh = Math.max(0, est.promptTokens - est.cachedTokens);
|
|
84
|
+
const breakdown = {
|
|
85
|
+
freshPrompt: fresh * input,
|
|
86
|
+
cacheRead: est.cachedTokens * cached,
|
|
87
|
+
cacheWrite: 0,
|
|
88
|
+
completion: est.completionTokens * output,
|
|
89
|
+
reasoning: 0,
|
|
90
|
+
images: 0,
|
|
91
|
+
request: 0,
|
|
92
|
+
total: 0,
|
|
93
|
+
tierAtPromptTokens: 0,
|
|
94
|
+
};
|
|
95
|
+
breakdown.total = breakdown.freshPrompt + breakdown.cacheRead + breakdown.completion;
|
|
96
|
+
after += breakdown.total;
|
|
97
|
+
if (est.cachedTokens !== usage.cachedTokens) {
|
|
98
|
+
changed++;
|
|
99
|
+
updates.push({ id: r.id, usage: JSON.stringify(est), breakdown: JSON.stringify(breakdown), usd: breakdown.total });
|
|
100
|
+
}
|
|
101
|
+
// The next row's "previous" is this row as dispatched, whether or not it was wasted:
|
|
102
|
+
// a wasted probe still warmed the prefix.
|
|
103
|
+
prev = r;
|
|
104
|
+
prevPrompt = est.promptTokens;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
console.log(`ollama rows: ${rows.length}, re-priced: ${changed}`);
|
|
108
|
+
console.log(`ledger Ollama spend: $${before.toFixed(2)} → $${after.toFixed(2)}`);
|
|
109
|
+
if (!apply) {
|
|
110
|
+
console.log("dry run; pass --apply to write (router.db is backed up first)");
|
|
111
|
+
db.close();
|
|
112
|
+
process.exit(0);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const backupDir = join(dirname(cfg.ledger.path), "backups");
|
|
116
|
+
mkdirSync(backupDir, { recursive: true });
|
|
117
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "");
|
|
118
|
+
const backup = join(backupDir, `router-pre-ollama-cache-${stamp}.db`);
|
|
119
|
+
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
120
|
+
copyFileSync(cfg.ledger.path, backup);
|
|
121
|
+
console.log(`backup: ${backup}`);
|
|
122
|
+
|
|
123
|
+
const stmt = db.prepare("UPDATE ledger SET usage = $usage, cost_breakdown = $breakdown, reported_usd = $usd WHERE id = $id");
|
|
124
|
+
const tx = db.transaction((list: typeof updates) => {
|
|
125
|
+
for (const u of list) stmt.run({ $usage: u.usage, $breakdown: u.breakdown, $usd: u.usd, $id: u.id });
|
|
126
|
+
});
|
|
127
|
+
tx(updates);
|
|
128
|
+
console.log(`updated ${updates.length} rows`);
|
|
129
|
+
db.close();
|