auto-model-router 0.2.31 → 0.3.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 +211 -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 +115 -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 +189 -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 +43 -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 +127 -2
- package/src/cost/ledger.ts +73 -4
- package/src/cost/report.ts +340 -0
- package/src/cost/types.ts +33 -1
- package/src/index.ts +5 -8
- package/src/router/candidates.ts +90 -11
- 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 +11 -0
- package/src/server/http.ts +47 -6
- package/src/server/providers.ts +54 -0
- package/src/server/turn.ts +122 -34
- package/src/tokens/estimate.ts +16 -0
- package/src/upstream/multi.ts +26 -0
- package/src/upstream/ollama-usage.ts +157 -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/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 +506 -0
- package/test/omp-credentials.test.ts +43 -1
- package/test/report-hub.test.ts +341 -0
- package/test/report-logic.test.ts +92 -0
- package/test/report.test.ts +217 -0
- package/test/select.test.ts +176 -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 +124 -7
- package/tools/build-site.ts +1 -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 },
|
|
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,114 @@ 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
|
+
|
|
732
849
|
});
|
package/tools/build-site.ts
CHANGED
|
@@ -298,6 +298,7 @@ const configBody = `<h2>Configuration reference</h2>
|
|
|
298
298
|
<dt>filters.minTrust / minTrustSamples</dt><dd>Demote models whose measured reliability falls below the floor once enough samples exist. <span class="default">Default: 0.7 over 12 samples.</span></dd>
|
|
299
299
|
<dt>filters.contextHeadroom</dt><dd>Require a context window this multiple of the estimated prompt. <span class="default">Default: 1.25.</span></dd>
|
|
300
300
|
<dt>filters.latencyWeight</dt><dd>Inflate a model's effective cost by expected wait (TTFT + completion time). <span class="default">Default: 0 (off) \u2014 opt in after establishing a baseline.</span></dd>
|
|
301
|
+
<dt>filters.maxExpectedWaitMs</dt><dd>Absolute expected-wait ceiling: a hard drop for models <em>proven</em> slower than this (≥ latencyMinSamples), regardless of price \u2014 the soft penalty above is multiplicative and capped, so it cannot demote a slow-but-cheap model. New models keep their cold-start turns. <span class="default">Default: unset (off).</span></dd>
|
|
301
302
|
</dl>
|
|
302
303
|
|
|
303
304
|
<h3>escalation \u2014 mid-stream recovery</h3>
|