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
package/src/index.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* CLI entry point.
|
|
4
|
+
*
|
|
5
|
+
* Argv parsing is hand-rolled in `cli/args.ts`: five subcommands and a dozen
|
|
6
|
+
* flags do not justify a dependency, and the shape stays obvious.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { parseArgv } from "./cli/args.ts";
|
|
12
|
+
import { configCommand } from "./cli/config-cmd.ts";
|
|
13
|
+
import { explainCommand } from "./cli/explain.ts";
|
|
14
|
+
import { modelsCommand } from "./cli/models.ts";
|
|
15
|
+
import { statsCommand } from "./cli/stats.ts";
|
|
16
|
+
|
|
17
|
+
const USAGE = `auto-model-router - local cost/complexity-aware model router for omp, backed by OpenRouter
|
|
18
|
+
|
|
19
|
+
Usage: auto-model-router <command> [options]
|
|
20
|
+
|
|
21
|
+
Commands:
|
|
22
|
+
stats Show routed spend, per-model share, and escalation rates
|
|
23
|
+
models Show what each complexity tier would consider, and why
|
|
24
|
+
explain Route a saved request without dispatching it, and explain the decision
|
|
25
|
+
config Interactive wizard over the router's own config.yml
|
|
26
|
+
(--print shows the models.yml block; --write splices it into omp)
|
|
27
|
+
|
|
28
|
+
Global options:
|
|
29
|
+
--config <path> Use a specific router config file
|
|
30
|
+
--help, -h Show help
|
|
31
|
+
--version Show version
|
|
32
|
+
|
|
33
|
+
Command options:
|
|
34
|
+
stats --days <n> --json
|
|
35
|
+
models --tier <trivial|simple|moderate|hard> --limit <n> --json
|
|
36
|
+
explain --file <request.json> --json (reads stdin when --file is absent)
|
|
37
|
+
config --print --write --path <models.yml> --config <router-config.yml>
|
|
38
|
+
|
|
39
|
+
Environment:
|
|
40
|
+
OPENROUTER_API_KEY Required for completions; the catalog is readable without it.
|
|
41
|
+
AUTO_MODEL_ROUTER_HOME Config and database directory (default ~/.auto-model-router)
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
async function main(): Promise<number> {
|
|
45
|
+
const args = parseArgv(process.argv.slice(2));
|
|
46
|
+
|
|
47
|
+
if (args.flags.has("version")) {
|
|
48
|
+
const pkg: unknown = await Bun.file(join(import.meta.dir, "..", "package.json")).json();
|
|
49
|
+
const value =
|
|
50
|
+
typeof pkg === "object" && pkg !== null && "version" in pkg && typeof pkg.version === "string"
|
|
51
|
+
? pkg.version
|
|
52
|
+
: "unknown";
|
|
53
|
+
console.log(value);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
if (args.command === "") {
|
|
57
|
+
// No command is a usage question rather than an error when help was asked for.
|
|
58
|
+
process.stdout.write(USAGE);
|
|
59
|
+
return args.flags.has("help") ? 0 : 1;
|
|
60
|
+
}
|
|
61
|
+
if (args.flags.has("help")) {
|
|
62
|
+
process.stdout.write(USAGE);
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
switch (args.command) {
|
|
67
|
+
case "stats":
|
|
68
|
+
await statsCommand(args);
|
|
69
|
+
return 0;
|
|
70
|
+
case "models":
|
|
71
|
+
await modelsCommand(args);
|
|
72
|
+
return 0;
|
|
73
|
+
case "explain":
|
|
74
|
+
await explainCommand(args);
|
|
75
|
+
return 0;
|
|
76
|
+
case "config":
|
|
77
|
+
await configCommand(args);
|
|
78
|
+
return 0;
|
|
79
|
+
default:
|
|
80
|
+
process.stderr.write(`unknown command "${args.command}"\n\n${USAGE}`);
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (import.meta.main) {
|
|
86
|
+
try {
|
|
87
|
+
const code = await main();
|
|
88
|
+
if (code !== 0) process.exit(code);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache-breakpoint placement (Anthropic-style `cache_control: ephemeral`;
|
|
3
|
+
* OpenRouter translates these to OpenAI/Google cache primitives, so one
|
|
4
|
+
* mechanism covers every target). Returns message indices to mark.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { CatalogModel } from "../catalog/types.ts";
|
|
8
|
+
import type { RouterConfig } from "../config/types.ts";
|
|
9
|
+
import { priceAt } from "../cost/forecast.ts";
|
|
10
|
+
import { estimateTokens } from "../tokens/estimate.ts";
|
|
11
|
+
import type { NormMessage, NormRequest } from "../wire/types.ts";
|
|
12
|
+
|
|
13
|
+
export function planCacheBreakpoints(req: NormRequest, model: CatalogModel, cfg: RouterConfig): number[] {
|
|
14
|
+
if (!cfg.cache.injectBreakpoints) return [];
|
|
15
|
+
const promptTokens = estimateTokens(req.promptBytes, model.tokenizer, null);
|
|
16
|
+
// Small prompts cannot amortize cache-write cost.
|
|
17
|
+
if (promptTokens < cfg.cache.minPromptTokens) return [];
|
|
18
|
+
// A breakpoint on a model with no published cache-read price cannot pay for itself.
|
|
19
|
+
if (priceAt(model, Math.max(1, promptTokens)).cacheRead === undefined) return [];
|
|
20
|
+
|
|
21
|
+
const messages = req.messages;
|
|
22
|
+
const picks: number[] = [];
|
|
23
|
+
|
|
24
|
+
// 1. End of the last system message: the most stable, usually largest prefix.
|
|
25
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
26
|
+
const role = messages[i]?.role;
|
|
27
|
+
if (role === "system" || role === "developer") {
|
|
28
|
+
picks.push(i);
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 2. End of the last message before the volatile tail — the newest
|
|
34
|
+
// user-authored content, or the trailing tool-result run of an agent
|
|
35
|
+
// loop. Caches everything the model has already seen, leaving only the
|
|
36
|
+
// fresh tail uncached.
|
|
37
|
+
const tail = messages[messages.length - 1];
|
|
38
|
+
if (tail !== undefined) {
|
|
39
|
+
let pred: (m: NormMessage) => boolean;
|
|
40
|
+
if (tail.role === "user") pred = (m) => m.role === "user";
|
|
41
|
+
else if (tail.role === "tool") pred = (m) => m.role === "tool" || (m.role === "assistant" && m.toolCalls.length > 0);
|
|
42
|
+
// An assistant tail has no fresh human content; the whole history is prefix.
|
|
43
|
+
else pred = () => false;
|
|
44
|
+
let i = messages.length - 1;
|
|
45
|
+
while (i >= 0) {
|
|
46
|
+
const m = messages[i];
|
|
47
|
+
if (m === undefined || !pred(m)) break;
|
|
48
|
+
i--;
|
|
49
|
+
}
|
|
50
|
+
if (i >= 0) picks.push(i);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 3. Stable prefix boundary at roughly 75% of history.
|
|
54
|
+
if (messages.length > 1) picks.push(Math.floor((messages.length - 1) * 0.75));
|
|
55
|
+
|
|
56
|
+
// Dedupe preserving priority order, cap, return ascending indices.
|
|
57
|
+
const seen = new Set<number>();
|
|
58
|
+
const out: number[] = [];
|
|
59
|
+
for (const i of picks) {
|
|
60
|
+
if (i < 0 || i >= messages.length || seen.has(i)) continue;
|
|
61
|
+
seen.add(i);
|
|
62
|
+
out.push(i);
|
|
63
|
+
if (out.length >= cfg.cache.maxBreakpoints) break;
|
|
64
|
+
}
|
|
65
|
+
return out.sort((a, b) => a - b);
|
|
66
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Candidate construction: hard capability filters over the catalog, then
|
|
3
|
+
* forecast + scoring for the survivors. The `rejected` array is what
|
|
4
|
+
* `auto-model-router explain` shows, so every drop records its precise reason.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { CatalogModel, CatalogSnapshot } from "../catalog/types.ts";
|
|
8
|
+
import type { QualityAxis, RouterConfig } from "../config/types.ts";
|
|
9
|
+
import { forecast, priceAt } from "../cost/forecast.ts";
|
|
10
|
+
import type { Ledger } from "../cost/types.ts";
|
|
11
|
+
import type { NormRequest } from "../wire/types.ts";
|
|
12
|
+
import { effectiveQualityFloor, tierPlanFor } from "./tier-plan.ts";
|
|
13
|
+
import type { Candidate, Features, Rejection, TaskType, Tier } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
export interface BuildCandidatesArgs {
|
|
16
|
+
req: NormRequest;
|
|
17
|
+
features: Features;
|
|
18
|
+
tier: Tier;
|
|
19
|
+
/** Task type; its config selects the axis, quality floor, and image filter. */
|
|
20
|
+
task: TaskType;
|
|
21
|
+
snapshot: CatalogSnapshot;
|
|
22
|
+
ledger: Ledger | null;
|
|
23
|
+
cfg: RouterConfig;
|
|
24
|
+
expectedCompletionTokens: number;
|
|
25
|
+
/** Slug whose prompt cache is warm this turn; wins score ties. */
|
|
26
|
+
warmSlug: string | null;
|
|
27
|
+
/**
|
|
28
|
+
* Tier rescue depth when the strict config excludes every available model.
|
|
29
|
+
* 0 = strict (price ceiling + quality floor + trust bar all enforced).
|
|
30
|
+
* Higher levels drop constraints in order: 1 removes price ceilings, 2 also
|
|
31
|
+
* drops the quality floor, 3 also ignores the trust bar. Never lifts the
|
|
32
|
+
* hard capability filters (tools/images/context) or the key-scoped allowlist.
|
|
33
|
+
*/
|
|
34
|
+
relaxLevel?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Slugs this turn must not select — the models that already failed on it.
|
|
37
|
+
* Failover re-selects with the failed slug excluded so a retry lands on a
|
|
38
|
+
* DIFFERENT model instead of re-issuing the one that just errored.
|
|
39
|
+
*/
|
|
40
|
+
excludeSlugs?: readonly string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Tiny glob: `*` matches any run of characters; everything else is literal. */
|
|
44
|
+
function globToRe(glob: string): RegExp {
|
|
45
|
+
const escaped = glob.replace(/[.*+?^${}()|[\]\\]/g, (ch) => (ch === "*" ? ".*" : `\\${ch}`));
|
|
46
|
+
return new RegExp(`^${escaped}$`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Quality fallback chain: score the preferred axis first, then the general
|
|
51
|
+
* intelligence index (the most widely published), then the remaining axis.
|
|
52
|
+
* A model absent on every axis is UNSCORED — never impute a score from price.
|
|
53
|
+
*/
|
|
54
|
+
const AXIS_FALLBACK: Record<QualityAxis, readonly [QualityAxis, QualityAxis, QualityAxis]> = {
|
|
55
|
+
coding: ["coding", "intelligence", "agentic"],
|
|
56
|
+
agentic: ["agentic", "intelligence", "coding"],
|
|
57
|
+
intelligence: ["intelligence", "coding", "agentic"],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function resolveQuality(model: CatalogModel, axis: QualityAxis): { score: number; axis: QualityAxis } | null {
|
|
61
|
+
for (const a of AXIS_FALLBACK[axis]) {
|
|
62
|
+
const v = model.quality[a];
|
|
63
|
+
if (v !== undefined) return { score: v, axis: a };
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Neutral trust prior for models our ledger has never observed. */
|
|
69
|
+
const UNMEASURED_TRUST = 0.9;
|
|
70
|
+
|
|
71
|
+
export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candidate[]; rejected: Rejection[] } {
|
|
72
|
+
const { req, features, tier, task, snapshot, ledger, cfg, expectedCompletionTokens, warmSlug, relaxLevel = 0 } = args;
|
|
73
|
+
// A Set only when non-empty: the common path allocates nothing.
|
|
74
|
+
const excluded = args.excludeSlugs === undefined || args.excludeSlugs.length === 0 ? null : new Set(args.excludeSlugs);
|
|
75
|
+
const tierCfg = cfg.tiers[tier];
|
|
76
|
+
const taskCfg = cfg.tasks[task];
|
|
77
|
+
const filters = cfg.filters;
|
|
78
|
+
const relaxPrice = relaxLevel >= 1;
|
|
79
|
+
const relaxQuality = relaxLevel >= 2;
|
|
80
|
+
const relaxTrust = relaxLevel >= 3;
|
|
81
|
+
const allowRes = filters.allow.map(globToRe);
|
|
82
|
+
const denyRes = filters.deny.map(globToRe);
|
|
83
|
+
const needTools = req.tools.length > 0 && filters.requireToolSupport;
|
|
84
|
+
const minContext = Math.ceil(features.promptTokens * filters.contextHeadroom) + expectedCompletionTokens;
|
|
85
|
+
// Task selects the quality axis and capability filters; the tier still
|
|
86
|
+
// bounds cost.
|
|
87
|
+
const effectiveAxis = taskCfg.axis;
|
|
88
|
+
// Two floors with different meanings, and only one of them may be relaxed:
|
|
89
|
+
// - the TIER floor is an economic envelope tuned against the full catalog,
|
|
90
|
+
// so when a guardrail narrows availability below it, relaxing to the
|
|
91
|
+
// best available band is right (otherwise the tier is empty forever).
|
|
92
|
+
// - the TASK floor is a capability requirement (vision needs a model that
|
|
93
|
+
// can actually see), so adaptive relaxation must never lower it.
|
|
94
|
+
const taskFloor = taskCfg.minQuality ?? 0;
|
|
95
|
+
const adaptiveTierFloor = cfg.adaptiveTierFloors
|
|
96
|
+
? effectiveQualityFloor(tierCfg.minQuality, tier, effectiveAxis, tierPlanFor(snapshot, cfg))
|
|
97
|
+
: tierCfg.minQuality;
|
|
98
|
+
const qualityFloor = Math.max(taskFloor, adaptiveTierFloor);
|
|
99
|
+
const taskPins = taskCfg.prefer ?? [];
|
|
100
|
+
let images = 0;
|
|
101
|
+
if (req.hasImages) for (const m of req.messages) images += m.images;
|
|
102
|
+
|
|
103
|
+
const candidates: Candidate[] = [];
|
|
104
|
+
const rejected: Rejection[] = [];
|
|
105
|
+
|
|
106
|
+
for (const model of snapshot.models) {
|
|
107
|
+
const slug = model.slug;
|
|
108
|
+
|
|
109
|
+
// Failover exclusion comes first: a model that already failed this turn
|
|
110
|
+
// is not a candidate no matter how well it scores.
|
|
111
|
+
if (excluded !== null && excluded.has(slug)) {
|
|
112
|
+
rejected.push({ slug, reason: "failed_this_turn" });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Hard-coded denials, before any user configuration. These slugs can
|
|
117
|
+
// never serve an interactive turn:
|
|
118
|
+
// - "~vendor/model-latest": floating aliases whose identity changes
|
|
119
|
+
// underneath us, poisoning ledger trust statistics.
|
|
120
|
+
// - ":batch": asynchronous batch endpoints, unusable for streaming.
|
|
121
|
+
// - "stealth/": cloaked models with no stable identity.
|
|
122
|
+
// - "openrouter/": their meta-routers do our job at unknown cost.
|
|
123
|
+
if (slug.startsWith("~") || slug.endsWith(":batch") || slug.startsWith("stealth/") || model.author === "openrouter") {
|
|
124
|
+
rejected.push({ slug, reason: "denylisted", detail: "built-in deny: floating alias, batch endpoint, stealth, or meta-router" });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
// A negative price is OpenRouter's unknown/dynamic sentinel (-1), never a discount.
|
|
128
|
+
if (model.price.prompt < 0 || model.price.completion < 0) {
|
|
129
|
+
rejected.push({ slug, reason: "denylisted", detail: "dynamic pricing sentinel" });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (allowRes.length > 0 && !allowRes.some((re) => re.test(slug))) {
|
|
133
|
+
rejected.push({ slug, reason: "not_allowlisted" });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (denyRes.some((re) => re.test(slug))) {
|
|
137
|
+
rejected.push({ slug, reason: "denylisted", detail: "filters.deny" });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (model.isFree && !filters.includeFree) {
|
|
141
|
+
rejected.push({ slug, reason: "free_tier_excluded" });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (needTools && !model.supportsTools) {
|
|
145
|
+
rejected.push({ slug, reason: "no_tool_support" });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if ((req.hasImages || taskCfg.requireImage === true) && !model.inputModalities.includes("image")) {
|
|
149
|
+
rejected.push({ slug, reason: "no_image_support" });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (model.contextLength < minContext) {
|
|
153
|
+
rejected.push({ slug, reason: "context_too_small", detail: `window ${model.contextLength} < required ${minContext}` });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const pinned = tierCfg.pin.includes(slug) || taskPins.includes(slug);
|
|
158
|
+
const quality = resolveQuality(model, effectiveAxis);
|
|
159
|
+
if (!pinned && !relaxQuality && qualityFloor > 0) {
|
|
160
|
+
if (quality === null) {
|
|
161
|
+
rejected.push({ slug, reason: "below_quality_floor", detail: "no published quality score" });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (quality.score < qualityFloor) {
|
|
165
|
+
rejected.push({ slug, reason: "below_quality_floor", detail: `${quality.score} < floor ${qualityFloor}` });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Price ceilings at the ACTUAL prompt size: long-context overrides can
|
|
171
|
+
// push a model over the ceiling exactly when conversations get long.
|
|
172
|
+
// Catalog prices are per-token; ceilings are per million tokens.
|
|
173
|
+
const price = priceAt(model, Math.max(1, features.promptTokens));
|
|
174
|
+
if (!relaxPrice && tierCfg.maxInputPerMtok !== undefined && price.prompt * 1e6 > tierCfg.maxInputPerMtok) {
|
|
175
|
+
rejected.push({
|
|
176
|
+
slug,
|
|
177
|
+
reason: "over_price_ceiling",
|
|
178
|
+
detail: `input $${(price.prompt * 1e6).toFixed(2)}/Mtok > ceiling $${tierCfg.maxInputPerMtok}`,
|
|
179
|
+
});
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (!relaxPrice && tierCfg.maxOutputPerMtok !== undefined && price.completion * 1e6 > tierCfg.maxOutputPerMtok) {
|
|
183
|
+
rejected.push({
|
|
184
|
+
slug,
|
|
185
|
+
reason: "over_price_ceiling",
|
|
186
|
+
detail: `output $${(price.completion * 1e6).toFixed(2)}/Mtok > ceiling $${tierCfg.maxOutputPerMtok}`,
|
|
187
|
+
});
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const trust =
|
|
192
|
+
ledger?.trust(slug, filters.trustScopedByHarness ? req.harnessId : undefined) ?? null;
|
|
193
|
+
if (!relaxTrust && trust !== null && trust.attempts >= filters.minTrustSamples && trust.successRate < filters.minTrust) {
|
|
194
|
+
rejected.push({
|
|
195
|
+
slug,
|
|
196
|
+
reason: "untrusted",
|
|
197
|
+
detail: `success ${trust.successRate.toFixed(2)} over ${trust.attempts} attempts < ${filters.minTrust}`,
|
|
198
|
+
});
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const fc = forecast(model, {
|
|
203
|
+
promptTokens: features.promptTokens,
|
|
204
|
+
completionTokens: expectedCompletionTokens,
|
|
205
|
+
cacheHitRate: 0,
|
|
206
|
+
images,
|
|
207
|
+
});
|
|
208
|
+
const trustScore = trust !== null && trust.attempts > 0 ? trust.successRate : UNMEASURED_TRUST;
|
|
209
|
+
const qualityScore = quality?.score ?? 0;
|
|
210
|
+
// Shared scoring: trust converts flakiness into money — a model failing
|
|
211
|
+
// 20% of the time really costs ~25% more in retries. qualityExponent 0
|
|
212
|
+
// makes this "cheapest above the floor"; the floor does the quality work.
|
|
213
|
+
const effectiveUsd = fc.expectedUsd / Math.max(trustScore, 0.5);
|
|
214
|
+
const score = Math.pow(qualityScore / 100, tierCfg.qualityExponent) / Math.max(effectiveUsd, 1e-9);
|
|
215
|
+
|
|
216
|
+
const reasons: string[] = [
|
|
217
|
+
quality === null
|
|
218
|
+
? "unscored on every quality axis"
|
|
219
|
+
: `quality ${quality.score} on ${quality.axis}${quality.axis === effectiveAxis ? "" : ` (fallback from ${effectiveAxis})`}`,
|
|
220
|
+
trust === null || trust.attempts === 0
|
|
221
|
+
? `trust unmeasured: neutral prior ${UNMEASURED_TRUST}`
|
|
222
|
+
: `trust ${trustScore.toFixed(2)} over ${trust.attempts} attempts`,
|
|
223
|
+
`expected $${fc.expectedUsd.toFixed(6)}`,
|
|
224
|
+
];
|
|
225
|
+
if (pinned) reasons.push("pinned into tier");
|
|
226
|
+
candidates.push({ model, forecast: fc, qualityScore, trustScore, score, reasons });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
candidates.sort((a, b) => {
|
|
230
|
+
const d = b.score - a.score;
|
|
231
|
+
if (d !== 0) return d;
|
|
232
|
+
// When the quality floor is relaxed, unscored models all score 0 and the
|
|
233
|
+
// lexical tie-break would pick alphabetically. Prefer the cheaper model
|
|
234
|
+
// first, then the warm slug, then lexical for determinism.
|
|
235
|
+
if (relaxQuality) {
|
|
236
|
+
const cd = a.forecast.expectedUsd - b.forecast.expectedUsd;
|
|
237
|
+
if (cd !== 0) return cd;
|
|
238
|
+
}
|
|
239
|
+
// Ties break toward the model already warm in this conversation, then
|
|
240
|
+
// lexically for determinism.
|
|
241
|
+
if (a.model.slug === warmSlug) return -1;
|
|
242
|
+
if (b.model.slug === warmSlug) return 1;
|
|
243
|
+
return a.model.slug < b.model.slug ? -1 : 1;
|
|
244
|
+
});
|
|
245
|
+
return { candidates, rejected };
|
|
246
|
+
}
|