auto-model-router 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +24 -0
- package/.github/workflows/publish.yml +40 -0
- package/.omp-plugin/marketplace.json +30 -0
- package/LICENSE +21 -0
- package/README.md +639 -0
- package/bun.lock +32 -0
- package/docs/claude-anthropic-wire.md +116 -0
- package/omp-extension/configure-logic.ts +128 -0
- package/omp-extension/embed-logic.ts +141 -0
- package/omp-extension/router-configure.ts +111 -0
- package/omp-extension/router-embed.ts +118 -0
- package/omp-extension/router-toast.ts +130 -0
- package/omp-extension/toast-logic.ts +136 -0
- package/package.json +56 -0
- package/src/catalog/openrouter-catalog.ts +428 -0
- package/src/catalog/types.ts +104 -0
- package/src/cli/args.ts +105 -0
- package/src/cli/config-cmd.ts +362 -0
- package/src/cli/config-wizard.ts +636 -0
- package/src/cli/explain.ts +167 -0
- package/src/cli/models.ts +240 -0
- package/src/cli/stats.ts +69 -0
- package/src/config/defaults.ts +136 -0
- package/src/config/load.ts +143 -0
- package/src/config/omp-credentials.ts +124 -0
- package/src/config/schema.ts +161 -0
- package/src/config/types.ts +244 -0
- package/src/cost/blended.ts +80 -0
- package/src/cost/forecast.ts +129 -0
- package/src/cost/ledger.ts +291 -0
- package/src/cost/types.ts +148 -0
- package/src/index.ts +93 -0
- package/src/router/cache-control.ts +66 -0
- package/src/router/candidates.ts +246 -0
- package/src/router/classify.ts +329 -0
- package/src/router/escalate.ts +264 -0
- package/src/router/features.ts +225 -0
- package/src/router/index.ts +99 -0
- package/src/router/select.ts +365 -0
- package/src/router/state.ts +118 -0
- package/src/router/tier-plan.ts +151 -0
- package/src/router/types.ts +222 -0
- package/src/server/http.ts +343 -0
- package/src/server/turn.ts +393 -0
- package/src/tokens/estimate.ts +74 -0
- package/src/upstream/openrouter.ts +221 -0
- package/src/upstream/sse-parse.ts +208 -0
- package/src/upstream/types.ts +75 -0
- package/src/util/hash.ts +0 -0
- package/src/util/log.ts +53 -0
- package/src/util/sqlite.ts +140 -0
- package/src/util/sse.ts +23 -0
- package/src/wire/openai/errors.ts +48 -0
- package/src/wire/openai/models.ts +37 -0
- package/src/wire/openai/request.ts +279 -0
- package/src/wire/openai/sink.ts +213 -0
- package/src/wire/types.ts +156 -0
- package/test/catalog.test.ts +319 -0
- package/test/classify.test.ts +269 -0
- package/test/config-wizard.test.ts +482 -0
- package/test/config.test.ts +121 -0
- package/test/configure-logic.test.ts +151 -0
- package/test/cost.test.ts +137 -0
- package/test/embed-logic.test.ts +107 -0
- package/test/escalate.test.ts +223 -0
- package/test/failover.test.ts +494 -0
- package/test/features.test.ts +228 -0
- package/test/fixtures/openrouter-models.json +15340 -0
- package/test/models-yml.test.ts +186 -0
- package/test/omp-credentials.test.ts +185 -0
- package/test/select.test.ts +538 -0
- package/test/sse-parse.test.ts +142 -0
- package/test/tier-plan.test.ts +302 -0
- package/test/toast-logic.test.ts +160 -0
- package/test/tokens.test.ts +160 -0
- package/test/trust-attribution.test.ts +175 -0
- package/test/turn.test.ts +498 -0
- package/test/wire-request.test.ts +297 -0
- package/test/wire-sink.test.ts +179 -0
- package/tools/install.ts +140 -0
- package/tools/mock-openrouter.ts +269 -0
- package/tools/smoke.ts +326 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-conversation routing memory.
|
|
3
|
+
*
|
|
4
|
+
* Persisted rather than in-memory so hysteresis and cache-warmth tracking
|
|
5
|
+
* survive a restart: an omp session outlives this process, and forgetting
|
|
6
|
+
* which model is warm would cold-start a paid prompt cache for no reason.
|
|
7
|
+
*
|
|
8
|
+
* The `conversations` table is created by `util/sqlite.ts`, the single
|
|
9
|
+
* migration path.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Database, Statement } from "bun:sqlite";
|
|
13
|
+
|
|
14
|
+
import type { ConversationState, ConversationStore, Tier } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
/** Row shape as stored; column names are snake_case per the schema. */
|
|
17
|
+
interface Row {
|
|
18
|
+
key: string;
|
|
19
|
+
session_id: string;
|
|
20
|
+
turn: number;
|
|
21
|
+
current_slug: string | null;
|
|
22
|
+
current_tier: string | null;
|
|
23
|
+
sticky_until_turn: number;
|
|
24
|
+
escalations: number;
|
|
25
|
+
spent_usd: number;
|
|
26
|
+
last_prompt_tokens: number;
|
|
27
|
+
cache_warm_slug: string | null;
|
|
28
|
+
cache_warm_at_ms: number;
|
|
29
|
+
updated_at_ms: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function toState(row: Row): ConversationState {
|
|
33
|
+
return {
|
|
34
|
+
key: row.key,
|
|
35
|
+
sessionId: row.session_id,
|
|
36
|
+
turn: row.turn,
|
|
37
|
+
currentSlug: row.current_slug,
|
|
38
|
+
// Stored as free text; the column is only ever written from a Tier.
|
|
39
|
+
currentTier: row.current_tier as Tier | null,
|
|
40
|
+
stickyUntilTurn: row.sticky_until_turn,
|
|
41
|
+
escalations: row.escalations,
|
|
42
|
+
spentUsd: row.spent_usd,
|
|
43
|
+
lastPromptTokens: row.last_prompt_tokens,
|
|
44
|
+
cacheWarmSlug: row.cache_warm_slug,
|
|
45
|
+
cacheWarmAtMs: row.cache_warm_at_ms,
|
|
46
|
+
updatedAtMs: row.updated_at_ms,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createConversationStore(db: Database): ConversationStore {
|
|
51
|
+
// Hoisted: this runs on every turn, twice when an escalation retries.
|
|
52
|
+
const selectOne: Statement<Row, [string]> = db.query("SELECT * FROM conversations WHERE key = ?");
|
|
53
|
+
const insertOne: Statement<unknown, [string, string, number]> = db.query(
|
|
54
|
+
"INSERT INTO conversations (key, session_id, updated_at_ms) VALUES (?, ?, ?)",
|
|
55
|
+
);
|
|
56
|
+
const upsert = db.query(`
|
|
57
|
+
INSERT INTO conversations (
|
|
58
|
+
key, session_id, turn, current_slug, current_tier, sticky_until_turn,
|
|
59
|
+
escalations, spent_usd, last_prompt_tokens, cache_warm_slug, cache_warm_at_ms, updated_at_ms
|
|
60
|
+
) VALUES ($key, $sessionId, $turn, $currentSlug, $currentTier, $stickyUntilTurn,
|
|
61
|
+
$escalations, $spentUsd, $lastPromptTokens, $cacheWarmSlug, $cacheWarmAtMs, $updatedAtMs)
|
|
62
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
63
|
+
session_id = excluded.session_id,
|
|
64
|
+
turn = excluded.turn,
|
|
65
|
+
current_slug = excluded.current_slug,
|
|
66
|
+
current_tier = excluded.current_tier,
|
|
67
|
+
sticky_until_turn = excluded.sticky_until_turn,
|
|
68
|
+
escalations = excluded.escalations,
|
|
69
|
+
spent_usd = excluded.spent_usd,
|
|
70
|
+
last_prompt_tokens = excluded.last_prompt_tokens,
|
|
71
|
+
cache_warm_slug = excluded.cache_warm_slug,
|
|
72
|
+
cache_warm_at_ms = excluded.cache_warm_at_ms,
|
|
73
|
+
updated_at_ms = excluded.updated_at_ms
|
|
74
|
+
`);
|
|
75
|
+
const deleteStale: Statement<unknown, [number]> = db.query("DELETE FROM conversations WHERE updated_at_ms < ?");
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
get(key) {
|
|
79
|
+
const row = selectOne.get(key);
|
|
80
|
+
return row === null ? null : toState(row);
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
load(key) {
|
|
84
|
+
const existing = selectOne.get(key);
|
|
85
|
+
if (existing !== null) return toState(existing);
|
|
86
|
+
// Session id is derived, not random, so it stays stable if this row is
|
|
87
|
+
// ever pruned and the same conversation continues afterwards.
|
|
88
|
+
const sessionId = `omp-${key}`;
|
|
89
|
+
insertOne.run(key, sessionId, Date.now());
|
|
90
|
+
const inserted = selectOne.get(key);
|
|
91
|
+
if (inserted === null) throw new Error(`conversation row vanished immediately after insert: ${key}`);
|
|
92
|
+
return toState(inserted);
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
save(state) {
|
|
96
|
+
// bun:sqlite matches named parameters by their literal `$name` key;
|
|
97
|
+
// bare keys bind nothing at all and every column silently lands NULL.
|
|
98
|
+
upsert.run({
|
|
99
|
+
$key: state.key,
|
|
100
|
+
$sessionId: state.sessionId,
|
|
101
|
+
$turn: state.turn,
|
|
102
|
+
$currentSlug: state.currentSlug,
|
|
103
|
+
$currentTier: state.currentTier,
|
|
104
|
+
$stickyUntilTurn: state.stickyUntilTurn,
|
|
105
|
+
$escalations: state.escalations,
|
|
106
|
+
$spentUsd: state.spentUsd,
|
|
107
|
+
$lastPromptTokens: state.lastPromptTokens,
|
|
108
|
+
$cacheWarmSlug: state.cacheWarmSlug,
|
|
109
|
+
$cacheWarmAtMs: state.cacheWarmAtMs,
|
|
110
|
+
$updatedAtMs: Date.now(),
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
prune(maxAgeMs) {
|
|
115
|
+
return deleteStale.run(Date.now() - maxAgeMs).changes;
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptive tier floors: derive each tier's quality floor from the models that
|
|
3
|
+
* are ACTUALLY available, recomputed whenever a catalog refresh installs a new
|
|
4
|
+
* snapshot.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists. The configured floors (trivial 0, simple 40, moderate 60,
|
|
7
|
+
* hard 72) are absolute points on the Artificial Analysis index, tuned against
|
|
8
|
+
* the full ~420-model catalog. An OpenRouter key's guardrail can narrow the
|
|
9
|
+
* available set to a handful of models that all sit below those points, and an
|
|
10
|
+
* absolute floor then admits NOTHING: every tier above `trivial` goes
|
|
11
|
+
* permanently empty, selection widens down, and the router is trapped serving
|
|
12
|
+
* the cheapest model for every turn regardless of how hard the work is.
|
|
13
|
+
*
|
|
14
|
+
* The fix is to treat the floors as relative when the absolute ones cannot be
|
|
15
|
+
* met. Rank the available scored models, split them into four quantile bands,
|
|
16
|
+
* and take each band's lower bound as that tier's adaptive floor. The effective
|
|
17
|
+
* floor is then `min(configured, adaptive)`:
|
|
18
|
+
*
|
|
19
|
+
* - A healthy catalog keeps the configured floors verbatim (the adaptive floor
|
|
20
|
+
* sits above them, so `min` picks the configured value) — no behaviour change.
|
|
21
|
+
* - A narrowed catalog falls back to the adaptive floor, so `hard` still gets
|
|
22
|
+
* the best quartile of what is available instead of nothing at all.
|
|
23
|
+
*
|
|
24
|
+
* `min` is deliberate: adaptive floors may only RELAX a floor, never tighten
|
|
25
|
+
* one. Tightening would let a rich catalog silently price us out of a tier the
|
|
26
|
+
* operator explicitly configured.
|
|
27
|
+
*
|
|
28
|
+
* Unscored models are never imputed a score (see `candidates.ts`), so a catalog
|
|
29
|
+
* with no benchmarks at all yields all-zero floors: every tier admits every
|
|
30
|
+
* model and the price ceiling plus `qualityExponent` do the differentiating.
|
|
31
|
+
* That is the honest degradation — there is genuinely no measured quality
|
|
32
|
+
* spread to rank on.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { CatalogModel, CatalogSnapshot } from "../catalog/types.ts";
|
|
36
|
+
import type { QualityAxis, RouterConfig } from "../config/types.ts";
|
|
37
|
+
import { TIER_ORDER, type Tier } from "./types.ts";
|
|
38
|
+
|
|
39
|
+
const AXES: readonly QualityAxis[] = ["coding", "agentic", "intelligence"];
|
|
40
|
+
|
|
41
|
+
/** Adaptive floor per tier, for one quality axis. */
|
|
42
|
+
export type AxisFloors = Record<Tier, number>;
|
|
43
|
+
|
|
44
|
+
export interface TierPlan {
|
|
45
|
+
/** Adaptive floor per axis per tier. */
|
|
46
|
+
floors: Record<QualityAxis, AxisFloors>;
|
|
47
|
+
/** How many available models carried a score on each axis. */
|
|
48
|
+
scoredCount: Record<QualityAxis, number>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Models that could plausibly serve a turn, for ranking purposes: the built-in
|
|
53
|
+
* denials (floating aliases, batch endpoints, stealth, meta-routers) and free
|
|
54
|
+
* models would otherwise skew the quantiles with entries selection can never
|
|
55
|
+
* pick. This mirrors the built-in denials in `buildCandidates`; per-request
|
|
56
|
+
* filters (tools, context, images) are deliberately NOT applied, because the
|
|
57
|
+
* plan is computed once per refresh, not once per request.
|
|
58
|
+
*/
|
|
59
|
+
function isRankable(model: CatalogModel, includeFree: boolean): boolean {
|
|
60
|
+
const slug = model.slug;
|
|
61
|
+
if (slug.startsWith("~") || slug.endsWith(":batch") || slug.startsWith("stealth/")) return false;
|
|
62
|
+
if (model.author === "openrouter") return false;
|
|
63
|
+
if (model.price.prompt < 0 || model.price.completion < 0) return false;
|
|
64
|
+
if (model.isFree && !includeFree) return false;
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Quantile band lower bounds over an ascending score list, one per tier.
|
|
70
|
+
*
|
|
71
|
+
* Tier k of n takes the score at index `floor(len * k / n)`. With four tiers
|
|
72
|
+
* that is the min, p25, p50 and p75, so each tier's floor admits roughly the
|
|
73
|
+
* top `(n-k)/n` of the available models — every tier non-empty by construction
|
|
74
|
+
* whenever at least one model is scored.
|
|
75
|
+
*/
|
|
76
|
+
function bandFloors(ascending: readonly number[]): AxisFloors {
|
|
77
|
+
const floors: Record<string, number> = {};
|
|
78
|
+
const len = ascending.length;
|
|
79
|
+
for (let k = 0; k < TIER_ORDER.length; k++) {
|
|
80
|
+
const tier = TIER_ORDER[k];
|
|
81
|
+
if (tier === undefined) continue;
|
|
82
|
+
if (len === 0) {
|
|
83
|
+
floors[tier] = 0;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const index = Math.min(len - 1, Math.floor((len * k) / TIER_ORDER.length));
|
|
87
|
+
floors[tier] = ascending[index] ?? 0;
|
|
88
|
+
}
|
|
89
|
+
return floors as AxisFloors;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Computes the adaptive plan for a set of available models. */
|
|
93
|
+
export function computeTierPlan(models: readonly CatalogModel[], cfg: RouterConfig): TierPlan {
|
|
94
|
+
const floors: Record<string, AxisFloors> = {};
|
|
95
|
+
const scoredCount: Record<string, number> = {};
|
|
96
|
+
const includeFree = cfg.filters.includeFree;
|
|
97
|
+
|
|
98
|
+
for (const axis of AXES) {
|
|
99
|
+
const scores: number[] = [];
|
|
100
|
+
for (const model of models) {
|
|
101
|
+
if (!isRankable(model, includeFree)) continue;
|
|
102
|
+
const score = model.quality[axis];
|
|
103
|
+
if (score === undefined) continue;
|
|
104
|
+
scores.push(score);
|
|
105
|
+
}
|
|
106
|
+
scores.sort((a, b) => a - b);
|
|
107
|
+
floors[axis] = bandFloors(scores);
|
|
108
|
+
scoredCount[axis] = scores.length;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
floors: floors as Record<QualityAxis, AxisFloors>,
|
|
113
|
+
scoredCount: scoredCount as Record<QualityAxis, number>,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Memoized per snapshot object AND config object. A catalog refresh installs a
|
|
119
|
+
* fresh `CatalogSnapshot`, which misses the cache and recomputes the plan — so
|
|
120
|
+
* the plan tracks availability on exactly the polling interval, with no timer
|
|
121
|
+
* of its own. Keyed by config too so separate routers (and tests) never share.
|
|
122
|
+
*/
|
|
123
|
+
const planCache = new WeakMap<CatalogSnapshot, WeakMap<RouterConfig, TierPlan>>();
|
|
124
|
+
|
|
125
|
+
export function tierPlanFor(snapshot: CatalogSnapshot, cfg: RouterConfig): TierPlan {
|
|
126
|
+
let perConfig = planCache.get(snapshot);
|
|
127
|
+
if (perConfig === undefined) {
|
|
128
|
+
perConfig = new WeakMap<RouterConfig, TierPlan>();
|
|
129
|
+
planCache.set(snapshot, perConfig);
|
|
130
|
+
}
|
|
131
|
+
let plan = perConfig.get(cfg);
|
|
132
|
+
if (plan === undefined) {
|
|
133
|
+
plan = computeTierPlan(snapshot.models, cfg);
|
|
134
|
+
perConfig.set(cfg, plan);
|
|
135
|
+
}
|
|
136
|
+
return plan;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The floor to actually enforce for a tier on an axis. Never tightens the
|
|
141
|
+
* configured floor; only relaxes it when the available catalog cannot meet it.
|
|
142
|
+
*/
|
|
143
|
+
export function effectiveQualityFloor(
|
|
144
|
+
configured: number,
|
|
145
|
+
tier: Tier,
|
|
146
|
+
axis: QualityAxis,
|
|
147
|
+
plan: TierPlan,
|
|
148
|
+
): number {
|
|
149
|
+
const adaptive = plan.floors[axis][tier];
|
|
150
|
+
return Math.min(configured, adaptive);
|
|
151
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routing brain contracts: features -> complexity tier -> concrete model.
|
|
3
|
+
*
|
|
4
|
+
* Every stage is a pure function of explicit inputs so decisions are
|
|
5
|
+
* reproducible and `auto-model-router explain` can replay them offline.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CatalogModel } from "../catalog/types.ts";
|
|
9
|
+
import type { CostForecast } from "../cost/types.ts";
|
|
10
|
+
import type { NormRequest, ReasoningLevel } from "../wire/types.ts";
|
|
11
|
+
|
|
12
|
+
export type Tier = "trivial" | "simple" | "moderate" | "hard";
|
|
13
|
+
|
|
14
|
+
export const TIER_ORDER: readonly Tier[] = ["trivial", "simple", "moderate", "hard"] as const;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Task type: the KIND of work, orthogonal to complexity tier. A vision task
|
|
18
|
+
* routes to the best vision-capable model even if the tier would otherwise
|
|
19
|
+
* pick a cheaper one; a documentation task stays cheap. Classified from
|
|
20
|
+
* Features with no tokenizer or model call.
|
|
21
|
+
*/
|
|
22
|
+
export type TaskType = "coding" | "vision" | "documentation" | "data" | "chat";
|
|
23
|
+
|
|
24
|
+
export const TASK_ORDER: readonly TaskType[] = ["coding", "vision", "documentation", "data", "chat"] as const;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Signals extracted from a request. Deliberately cheap: no tokenizer, no
|
|
28
|
+
* network, no model call. Field names are stable because they are logged
|
|
29
|
+
* verbatim into the ledger for later calibration.
|
|
30
|
+
*/
|
|
31
|
+
export interface Features {
|
|
32
|
+
/** Estimated prompt tokens (see `tokens/estimate.ts`). */
|
|
33
|
+
promptTokens: number;
|
|
34
|
+
/** Estimated tokens in the newest user-authored content only. */
|
|
35
|
+
newContentTokens: number;
|
|
36
|
+
/** Assistant + user turns in history (excludes system). */
|
|
37
|
+
turnDepth: number;
|
|
38
|
+
/** Tools offered. omp exposes ~15-25; a bare chat request offers none. */
|
|
39
|
+
toolCount: number;
|
|
40
|
+
/** Total bytes of tool JSON schemas — usually the largest prompt component. */
|
|
41
|
+
toolSchemaBytes: number;
|
|
42
|
+
/**
|
|
43
|
+
* The last message is a tool result, i.e. this is a mechanical continuation
|
|
44
|
+
* of an agent loop rather than fresh human intent. The single strongest
|
|
45
|
+
* cheap-routing signal in agent traffic.
|
|
46
|
+
*/
|
|
47
|
+
isToolResultContinuation: boolean;
|
|
48
|
+
/** Consecutive tool-result messages at the tail — loop depth. */
|
|
49
|
+
toolLoopDepth: number;
|
|
50
|
+
/** Distinct tool names used across the conversation. */
|
|
51
|
+
distinctToolsUsed: number;
|
|
52
|
+
/** A tool result at the tail reports an error or non-zero exit. */
|
|
53
|
+
lastToolFailed: boolean;
|
|
54
|
+
/** The same tool was called with identical arguments twice in a row. */
|
|
55
|
+
repeatedToolCall: boolean;
|
|
56
|
+
hasImages: boolean;
|
|
57
|
+
/** Fenced code blocks in the newest user content. */
|
|
58
|
+
codeBlocks: number;
|
|
59
|
+
/** Bytes inside fenced code blocks in the newest user content. */
|
|
60
|
+
codeBytes: number;
|
|
61
|
+
/** Diff/patch markers in the newest user content. */
|
|
62
|
+
looksLikeDiff: boolean;
|
|
63
|
+
/** Matched complexity keywords (architecture, debug, why, race, optimize, ...). */
|
|
64
|
+
complexityKeywords: string[];
|
|
65
|
+
/** Matched triviality keywords (rename, format, typo, bump, ...). */
|
|
66
|
+
trivialityKeywords: string[];
|
|
67
|
+
/** Client asked for reasoning, a direct statement of expected difficulty. */
|
|
68
|
+
requestedReasoning: ReasoningLevel | undefined;
|
|
69
|
+
/** Question marks in the newest user content. */
|
|
70
|
+
questionCount: number;
|
|
71
|
+
/** Newest user content is a single short imperative sentence. */
|
|
72
|
+
isTerseInstruction: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type ClassificationSource = "heuristic" | "llm" | "sticky" | "forced" | "escalation";
|
|
76
|
+
|
|
77
|
+
export interface Classification {
|
|
78
|
+
tier: Tier;
|
|
79
|
+
/** Task type: the kind of work, orthogonal to complexity. */
|
|
80
|
+
task: TaskType;
|
|
81
|
+
/** 0-1. Below the config's ambiguity band, the LLM adjudicator is consulted. */
|
|
82
|
+
confidence: number;
|
|
83
|
+
source: ClassificationSource;
|
|
84
|
+
/** Ordered, human-readable justification. Logged and surfaced by `explain`. */
|
|
85
|
+
reasons: string[];
|
|
86
|
+
/** Raw heuristic score before tier bucketing, 0-1. */
|
|
87
|
+
score: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A model that survived capability filtering, with its economics attached. */
|
|
91
|
+
export interface Candidate {
|
|
92
|
+
model: CatalogModel;
|
|
93
|
+
forecast: CostForecast;
|
|
94
|
+
/** Quality on the axis chosen for this request (coding/agentic/intelligence), 0-100. */
|
|
95
|
+
qualityScore: number;
|
|
96
|
+
/** Laplace-smoothed success rate from our ledger, 0-1. Defaults to a neutral prior. */
|
|
97
|
+
trustScore: number;
|
|
98
|
+
/** Final ranking score. Higher wins. */
|
|
99
|
+
score: number;
|
|
100
|
+
/** Why this candidate ranked where it did. */
|
|
101
|
+
reasons: string[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type RejectionReason =
|
|
105
|
+
| "no_tool_support"
|
|
106
|
+
| "context_too_small"
|
|
107
|
+
| "no_image_support"
|
|
108
|
+
| "below_quality_floor"
|
|
109
|
+
| "over_price_ceiling"
|
|
110
|
+
| "over_budget"
|
|
111
|
+
| "denylisted"
|
|
112
|
+
| "not_allowlisted"
|
|
113
|
+
| "free_tier_excluded"
|
|
114
|
+
| "reasoning_mandatory"
|
|
115
|
+
| "untrusted"
|
|
116
|
+
/** Already failed on this turn; excluded so failover picks a different model. */
|
|
117
|
+
| "failed_this_turn";
|
|
118
|
+
|
|
119
|
+
export interface Rejection {
|
|
120
|
+
slug: string;
|
|
121
|
+
reason: RejectionReason;
|
|
122
|
+
detail?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Per-conversation routing memory. Persisted so restarts do not reset hysteresis. */
|
|
126
|
+
export interface ConversationState {
|
|
127
|
+
key: string;
|
|
128
|
+
/** Forwarded to OpenRouter as `session_id`. */
|
|
129
|
+
sessionId: string;
|
|
130
|
+
turn: number;
|
|
131
|
+
/** Slug that served the previous committed turn. */
|
|
132
|
+
currentSlug: string | null;
|
|
133
|
+
currentTier: Tier | null;
|
|
134
|
+
/**
|
|
135
|
+
* Hold the current tier until this turn index, to stop per-turn flapping
|
|
136
|
+
* that would repeatedly cold-start prompt caches.
|
|
137
|
+
*/
|
|
138
|
+
stickyUntilTurn: number;
|
|
139
|
+
escalations: number;
|
|
140
|
+
spentUsd: number;
|
|
141
|
+
/** Prompt tokens on the previous turn, for cache-warmth arithmetic. */
|
|
142
|
+
lastPromptTokens: number;
|
|
143
|
+
/** Model whose prompt cache we believe is still warm. */
|
|
144
|
+
cacheWarmSlug: string | null;
|
|
145
|
+
/** When that cache was last touched; OpenRouter sticky sessions expire in 5-10 min. */
|
|
146
|
+
cacheWarmAtMs: number;
|
|
147
|
+
updatedAtMs: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface ConversationStore {
|
|
151
|
+
get(key: string): ConversationState | null;
|
|
152
|
+
/** Loads existing state or creates a fresh record. */
|
|
153
|
+
load(key: string): ConversationState;
|
|
154
|
+
save(state: ConversationState): void;
|
|
155
|
+
/** Drops records untouched for longer than `maxAgeMs`. */
|
|
156
|
+
prune(maxAgeMs: number): number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Guarded-probe configuration for one dispatch. */
|
|
160
|
+
export interface ProbePlan {
|
|
161
|
+
enabled: boolean;
|
|
162
|
+
/** Hold output until this many text tokens have arrived. */
|
|
163
|
+
maxTokens: number;
|
|
164
|
+
/** Hard ceiling on hold time so a slow model cannot stall the client. */
|
|
165
|
+
maxHoldMs: number;
|
|
166
|
+
/** Tier to escalate to when the probe rejects the attempt. */
|
|
167
|
+
escalateTo: Tier | null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The routing decision for one turn. */
|
|
171
|
+
export interface Decision {
|
|
172
|
+
slug: string;
|
|
173
|
+
/** Same-tier fallbacks for OpenRouter's `models[]` array; transient-error only. */
|
|
174
|
+
fallbacks: string[];
|
|
175
|
+
tier: Tier;
|
|
176
|
+
classification: Classification;
|
|
177
|
+
/** The extracted feature vector, retained verbatim for `explain`. */
|
|
178
|
+
features: Features;
|
|
179
|
+
forecast: CostForecast;
|
|
180
|
+
sessionId: string;
|
|
181
|
+
/** Reused the previous turn's model because switching was not worth the cache loss. */
|
|
182
|
+
sticky: boolean;
|
|
183
|
+
/** Message indices to mark with cache breakpoints. */
|
|
184
|
+
cacheBreakpointMessageIndices: number[];
|
|
185
|
+
reasoning: ReasoningLevel | undefined;
|
|
186
|
+
maxTokens: number | undefined;
|
|
187
|
+
stripAssistantReasoning: boolean;
|
|
188
|
+
probe: ProbePlan;
|
|
189
|
+
/** Candidates considered, ranked. Retained for `explain`. */
|
|
190
|
+
considered: Candidate[];
|
|
191
|
+
/** Filtered-out models with cause. Retained for `explain`. */
|
|
192
|
+
rejected: Rejection[];
|
|
193
|
+
reasons: string[];
|
|
194
|
+
/** Budget guard forced a cheaper tier than the classifier asked for. */
|
|
195
|
+
budgetDowngraded: boolean;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Why a guarded probe rejected an attempt. */
|
|
199
|
+
export type EscalationSignal =
|
|
200
|
+
| "malformed_tool_args"
|
|
201
|
+
| "refusal"
|
|
202
|
+
| "empty_completion"
|
|
203
|
+
| "repeat_tool_call"
|
|
204
|
+
| "length_stop"
|
|
205
|
+
| "missing_expected_tool_call"
|
|
206
|
+
| "upstream_error";
|
|
207
|
+
|
|
208
|
+
export type ProbeVerdict =
|
|
209
|
+
| { action: "commit"; reason: string }
|
|
210
|
+
| { action: "escalate"; signal: EscalationSignal; reason: string };
|
|
211
|
+
|
|
212
|
+
export interface Router {
|
|
213
|
+
/**
|
|
214
|
+
* Chooses a model for a request. Pure w.r.t. everything except the stores it
|
|
215
|
+
* reads. `excludeSlugs` removes models that already failed on this turn, so
|
|
216
|
+
* a failover retry cannot re-pick the slug that just errored.
|
|
217
|
+
*/
|
|
218
|
+
route(
|
|
219
|
+
req: NormRequest,
|
|
220
|
+
opts: { attempt: number; escalateFrom?: Tier; excludeSlugs?: readonly string[] },
|
|
221
|
+
): Promise<Decision>;
|
|
222
|
+
}
|