auto-model-router 0.4.11 → 0.4.13
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 +20 -1
- package/package.json +1 -1
- package/src/cost/report.ts +15 -7
- package/src/cost/summary.ts +5 -4
- package/src/lib.ts +2 -0
- package/src/router/index.ts +45 -5
- package/src/wire/openai/request.ts +33 -0
- package/src/wire/types.ts +20 -0
- package/test/policy.test.ts +56 -0
- package/test/report.test.ts +14 -0
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
|
|
10
|
-
"version": "0.4.
|
|
10
|
+
"version": "0.4.13",
|
|
11
11
|
"pluginRoot": "."
|
|
12
12
|
},
|
|
13
13
|
"plugins": [
|
|
14
14
|
{
|
|
15
15
|
"name": "auto-model-router",
|
|
16
16
|
"description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
|
|
17
|
-
"version": "0.4.
|
|
17
|
+
"version": "0.4.13",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -706,7 +706,8 @@ escalation signal, error. Three views aggregate it, all from the same
|
|
|
706
706
|
transcript. Falls back to reading the ledger directly if the router is
|
|
707
707
|
unreachable.
|
|
708
708
|
- `auto-model-router report --days 7 [--harness <id>] [--json]` on the terminal.
|
|
709
|
-
- `GET /v1/router/report?days=7&harness=<id>` for dashboards
|
|
709
|
+
- `GET /v1/router/report?days=7&harness=<id>` for dashboards (`harness` may be
|
|
710
|
+
a comma-separated set of ids, for a group).
|
|
710
711
|
- `GET /v1/router/summary?harness=<id>` — the daily summary as JSON (`auto=1`
|
|
711
712
|
applies the once-a-day gate and returns `due: false` when nothing is due).
|
|
712
713
|
|
|
@@ -1177,6 +1178,24 @@ text, not the conversation, so a hard task that only becomes hard three tool
|
|
|
1177
1178
|
calls in stays on the router (the router's own escalation still applies
|
|
1178
1179
|
there); and the switch happens at prompt boundaries, never mid-turn.
|
|
1179
1180
|
|
|
1181
|
+
## Per-request routing policy
|
|
1182
|
+
|
|
1183
|
+
A front door in front of the router (the team edition, or any proxy that
|
|
1184
|
+
knows who is calling) can constrain one turn with an `X-Omp-Policy` header
|
|
1185
|
+
carrying JSON:
|
|
1186
|
+
|
|
1187
|
+
```json
|
|
1188
|
+
{ "allow": ["anthropic/*", "google/*"], "deny": ["openai/gpt-5-pro"], "minTier": "simple", "maxTier": "moderate", "pin": "anthropic/claude-sonnet-5" }
|
|
1189
|
+
```
|
|
1190
|
+
|
|
1191
|
+
`allow` and `deny` are slug globs like `filters.allow`/`filters.deny`: a
|
|
1192
|
+
request allow list replaces the configured one, a deny list adds to it.
|
|
1193
|
+
`minTier`/`maxTier` narrow the requested profile's tier envelope and never
|
|
1194
|
+
widen it. `pin` forces one model the way `/router pin` does, unless a session
|
|
1195
|
+
override already pinned one. Every field is optional; a malformed header is
|
|
1196
|
+
ignored rather than failing the turn. The decision trail records what the
|
|
1197
|
+
policy changed (`policy: …`).
|
|
1198
|
+
|
|
1180
1199
|
## Multiple coding harnesses, one router
|
|
1181
1200
|
|
|
1182
1201
|
**One router process for everything.** omp's embed extension binds a private
|
package/package.json
CHANGED
package/src/cost/report.ts
CHANGED
|
@@ -187,9 +187,20 @@ function toRow(r: RawRow, windowSpend: number): ReportRow {
|
|
|
187
187
|
};
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
/** A harness filter: one id, or several comma-separated (a team's members); empty ⇒ everything. */
|
|
191
|
+
export function harnessFilter(harnessId: string, param = "$harness"): { sql: string[]; bind: Record<string, string> } {
|
|
192
|
+
const ids = harnessId.split(",").map((s) => s.trim()).filter((s) => s !== "");
|
|
193
|
+
if (ids.length === 0) return { sql: [], bind: {} };
|
|
194
|
+
if (ids.length === 1) return { sql: [`harness_id = ${param}`], bind: { [param]: ids[0]! } };
|
|
195
|
+
const bind: Record<string, string> = {};
|
|
196
|
+
ids.forEach((id, i) => (bind[`${param}${i}`] = id));
|
|
197
|
+
return { sql: [`harness_id IN (${ids.map((_, i) => `${param}${i}`).join(", ")})`], bind };
|
|
198
|
+
}
|
|
199
|
+
|
|
190
200
|
/**
|
|
191
201
|
* Builds the report for the last `windowDays`. `harnessId` narrows to one
|
|
192
|
-
* harness (the `X-Omp-Harness` header)
|
|
202
|
+
* harness (the `X-Omp-Harness` header) or a comma-separated set of them (a
|
|
203
|
+
* team edition group); empty means everything.
|
|
193
204
|
*/
|
|
194
205
|
export function buildUsageReport(
|
|
195
206
|
db: Database,
|
|
@@ -200,12 +211,9 @@ export function buildUsageReport(
|
|
|
200
211
|
const sinceMs = nowMs - windowDays * 86_400_000;
|
|
201
212
|
const harnessId = opts.harnessId ?? "";
|
|
202
213
|
const untilMs = opts.untilMs;
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
...(harnessId === "" ? [] : ["harness_id = $harness"]),
|
|
207
|
-
].join(" AND ");
|
|
208
|
-
const bind = { $since: sinceMs, ...(untilMs === undefined ? {} : { $until: untilMs }), ...(harnessId === "" ? {} : { $harness: harnessId }) };
|
|
214
|
+
const hf = harnessFilter(harnessId);
|
|
215
|
+
const where = ["created_at_ms >= $since", ...(untilMs === undefined ? [] : ["created_at_ms < $until"]), ...hf.sql].join(" AND ");
|
|
216
|
+
const bind = { $since: sinceMs, ...(untilMs === undefined ? {} : { $until: untilMs }), ...hf.bind };
|
|
209
217
|
|
|
210
218
|
const t = db
|
|
211
219
|
.query(
|
package/src/cost/summary.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type { Database } from "bun:sqlite";
|
|
15
15
|
import { TIER_ORDER } from "../router/types.ts";
|
|
16
|
-
import { buildUsageReport, type BaselinePrice, type BaselineRow, type UsageReport } from "./report.ts";
|
|
16
|
+
import { buildUsageReport, harnessFilter, type BaselinePrice, type BaselineRow, type UsageReport } from "./report.ts";
|
|
17
17
|
import type { SoftFailureSpike } from "./types.ts";
|
|
18
18
|
|
|
19
19
|
/** One 24-hour window's headline numbers. */
|
|
@@ -89,8 +89,9 @@ function windowOf(r: UsageReport): SummaryWindow {
|
|
|
89
89
|
|
|
90
90
|
/** Counts tier moves up and down between consecutive kept turns of each conversation since `sinceMs`. */
|
|
91
91
|
export function countTierChanges(db: Database, sinceMs: number, harnessId: string): { up: number; down: number } {
|
|
92
|
-
const
|
|
93
|
-
const
|
|
92
|
+
const hf = harnessFilter(harnessId);
|
|
93
|
+
const where = ["created_at_ms >= $since", ...hf.sql].join(" AND ");
|
|
94
|
+
const bind = { $since: sinceMs, ...hf.bind };
|
|
94
95
|
const seq = db
|
|
95
96
|
.query(`SELECT conversation_key AS ck, tier FROM ledger WHERE ${where} AND wasted = 0 AND requested_model <> 'digest' ORDER BY conversation_key, created_at_ms`)
|
|
96
97
|
.all(bind) as { ck: string; tier: string }[];
|
|
@@ -155,7 +156,7 @@ function delta(current: number, previous: number): string {
|
|
|
155
156
|
|
|
156
157
|
/** Renders the summary as a few plain lines for the transcript. */
|
|
157
158
|
export function renderDailySummary(s: DailySummary): string {
|
|
158
|
-
const scope = s.harnessId === "" ? "all harnesses" : `harness ${s.harnessId}`;
|
|
159
|
+
const scope = s.harnessId === "" ? "all harnesses" : s.harnessId.includes(",") ? `${s.harnessId.split(",").length} harnesses` : `harness ${s.harnessId}`;
|
|
159
160
|
const out: string[] = [`auto-model-router daily summary — last 24h (${scope})`];
|
|
160
161
|
const c = s.current;
|
|
161
162
|
if (c.dispatches === 0) {
|
package/src/lib.ts
CHANGED
|
@@ -20,4 +20,6 @@ export { buildUsageReport, renderUsageReport, type UsageReport, type ReportTotal
|
|
|
20
20
|
export { buildDailySummary, renderDailySummary, type DailySummary } from "./cost/summary.ts";
|
|
21
21
|
export { openDb } from "./util/sqlite.ts";
|
|
22
22
|
export { createLedger } from "./cost/ledger.ts";
|
|
23
|
+
export { createFeedbackStore, type FeedbackStore, type FeedbackRecord } from "./cost/feedback.ts";
|
|
24
|
+
export type { RequestPolicy } from "./wire/types.ts";
|
|
23
25
|
export type { Ledger, LedgerEntry } from "./cost/types.ts";
|
package/src/router/index.ts
CHANGED
|
@@ -15,7 +15,7 @@ import type { ProfileConfig, RouterConfig } from "../config/types.ts";
|
|
|
15
15
|
import type { Ledger } from "../cost/types.ts";
|
|
16
16
|
import { estimatePromptTokens } from "../tokens/estimate.ts";
|
|
17
17
|
import type { UpstreamClient } from "../upstream/types.ts";
|
|
18
|
-
import type { NormRequest } from "../wire/types.ts";
|
|
18
|
+
import type { NormRequest, RequestPolicy } from "../wire/types.ts";
|
|
19
19
|
import { classify, classifyTask } from "./classify.ts";
|
|
20
20
|
import { extractFeatures } from "./features.ts";
|
|
21
21
|
import { select } from "./select.ts";
|
|
@@ -38,6 +38,43 @@ export interface RouterDeps {
|
|
|
38
38
|
*/
|
|
39
39
|
const NEUTRAL_TOKENIZER = "gpt";
|
|
40
40
|
|
|
41
|
+
const TIER_RANK: Record<string, number> = { trivial: 0, simple: 1, moderate: 2, hard: 3 };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Applies a request policy on top of the resolved profile and config: the
|
|
45
|
+
* tier envelope is narrowed (never widened), a request allow list replaces
|
|
46
|
+
* the configured one, a request deny list adds to it, and a pin becomes a
|
|
47
|
+
* forced slug unless a session override already forced one.
|
|
48
|
+
*/
|
|
49
|
+
export function applyRequestPolicy(
|
|
50
|
+
profile: ProfileConfig,
|
|
51
|
+
cfg: RouterConfig,
|
|
52
|
+
policy: RequestPolicy | undefined,
|
|
53
|
+
forceSlug: string | undefined,
|
|
54
|
+
): { profile: ProfileConfig; cfg: RouterConfig; forceSlug: string | undefined; reasons: string[] } {
|
|
55
|
+
if (policy === undefined) return { profile, cfg, forceSlug, reasons: [] };
|
|
56
|
+
const reasons: string[] = [];
|
|
57
|
+
let minTier = profile.minTier;
|
|
58
|
+
let maxTier = profile.maxTier;
|
|
59
|
+
if (policy.minTier !== undefined && TIER_RANK[policy.minTier]! > TIER_RANK[minTier]!) minTier = policy.minTier;
|
|
60
|
+
if (policy.maxTier !== undefined && TIER_RANK[policy.maxTier]! < TIER_RANK[maxTier]!) maxTier = policy.maxTier;
|
|
61
|
+
if (TIER_RANK[minTier]! > TIER_RANK[maxTier]!) minTier = maxTier;
|
|
62
|
+
const narrowed = minTier !== profile.minTier || maxTier !== profile.maxTier;
|
|
63
|
+
const outProfile = narrowed ? { ...profile, id: `${profile.id}+policy`, minTier, maxTier } : profile;
|
|
64
|
+
if (narrowed) reasons.push(`policy: tiers narrowed to [${minTier}..${maxTier}]`);
|
|
65
|
+
let outCfg = cfg;
|
|
66
|
+
if (policy.allow !== undefined || policy.deny !== undefined) {
|
|
67
|
+
outCfg = { ...cfg, filters: { ...cfg.filters, ...(policy.allow === undefined ? {} : { allow: policy.allow }), ...(policy.deny === undefined ? {} : { deny: [...cfg.filters.deny, ...policy.deny] }) } };
|
|
68
|
+
reasons.push(`policy: ${policy.allow === undefined ? "" : `allow ${policy.allow.join("|")} `}${policy.deny === undefined ? "" : `deny ${policy.deny.join("|")}`}`.trim());
|
|
69
|
+
}
|
|
70
|
+
let outForce = forceSlug;
|
|
71
|
+
if (forceSlug === undefined && policy.pin !== undefined) {
|
|
72
|
+
outForce = policy.pin;
|
|
73
|
+
reasons.push(`policy: pinned to ${policy.pin}`);
|
|
74
|
+
}
|
|
75
|
+
return { profile: outProfile, cfg: outCfg, forceSlug: outForce, reasons };
|
|
76
|
+
}
|
|
77
|
+
|
|
41
78
|
export function resolveProfile(cfg: RouterConfig, requestedModel: string, isSubagent = false): ProfileConfig {
|
|
42
79
|
const fallback = cfg.profiles[0];
|
|
43
80
|
if (fallback === undefined) throw new Error("no router profiles configured");
|
|
@@ -99,19 +136,22 @@ export function createRouter(deps: RouterDeps): Router {
|
|
|
99
136
|
classification = await classify(req, features, config, { upstream, ledger, catalog });
|
|
100
137
|
}
|
|
101
138
|
|
|
102
|
-
|
|
139
|
+
const policed = applyRequestPolicy(resolveProfile(config, req.requestedModel, req.isSubagent), config, req.policy, opts.forceSlug);
|
|
140
|
+
const decision = select({
|
|
103
141
|
req,
|
|
104
142
|
features,
|
|
105
143
|
classification,
|
|
106
|
-
profile:
|
|
144
|
+
profile: policed.profile,
|
|
107
145
|
state,
|
|
108
146
|
snapshot,
|
|
109
147
|
ledger,
|
|
110
|
-
cfg:
|
|
148
|
+
cfg: policed.cfg,
|
|
111
149
|
nowMs: Date.now(),
|
|
112
150
|
...(opts.excludeSlugs === undefined ? {} : { excludeSlugs: opts.excludeSlugs }),
|
|
113
|
-
...(
|
|
151
|
+
...(policed.forceSlug === undefined ? {} : { forceSlug: policed.forceSlug }),
|
|
114
152
|
});
|
|
153
|
+
if (policed.reasons.length > 0) decision.reasons.unshift(...policed.reasons);
|
|
154
|
+
return decision;
|
|
115
155
|
},
|
|
116
156
|
};
|
|
117
157
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
RequestPolicy,
|
|
2
3
|
CompactionEdit,
|
|
3
4
|
NormMessage,
|
|
4
5
|
NormRequest,
|
|
@@ -285,6 +286,34 @@ function renderUpstreamBody(
|
|
|
285
286
|
return body;
|
|
286
287
|
}
|
|
287
288
|
|
|
289
|
+
const TIER_NAMES = new Set(["trivial", "simple", "moderate", "hard"]);
|
|
290
|
+
|
|
291
|
+
/** Parses the X-Omp-Policy header; malformed or empty ⇒ no policy (never a rejected turn). */
|
|
292
|
+
export function parsePolicyHeader(raw: string | null): RequestPolicy | undefined {
|
|
293
|
+
if (raw === null || raw.trim() === "") return undefined;
|
|
294
|
+
let parsed: unknown;
|
|
295
|
+
try {
|
|
296
|
+
parsed = JSON.parse(raw);
|
|
297
|
+
} catch {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
301
|
+
const p = parsed as Record<string, unknown>;
|
|
302
|
+
const strs = (v: unknown): string[] | undefined => (Array.isArray(v) ? v.filter((s): s is string => typeof s === "string" && s.trim() !== "").map((s) => s.trim()) : undefined);
|
|
303
|
+
const tier = (v: unknown): RequestPolicy["minTier"] => (typeof v === "string" && TIER_NAMES.has(v) ? (v as RequestPolicy["minTier"]) : undefined);
|
|
304
|
+
const out: RequestPolicy = {};
|
|
305
|
+
const allow = strs(p.allow);
|
|
306
|
+
const deny = strs(p.deny);
|
|
307
|
+
if (allow !== undefined && allow.length > 0) out.allow = allow;
|
|
308
|
+
if (deny !== undefined && deny.length > 0) out.deny = deny;
|
|
309
|
+
const min = tier(p.minTier);
|
|
310
|
+
const max = tier(p.maxTier);
|
|
311
|
+
if (min !== undefined) out.minTier = min;
|
|
312
|
+
if (max !== undefined) out.maxTier = max;
|
|
313
|
+
if (typeof p.pin === "string" && p.pin.trim() !== "") out.pin = p.pin.trim();
|
|
314
|
+
return Object.keys(out).length === 0 ? undefined : out;
|
|
315
|
+
}
|
|
316
|
+
|
|
288
317
|
export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
|
|
289
318
|
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
290
319
|
throw invalidRequest("Request body must be a JSON object");
|
|
@@ -306,6 +335,9 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
|
|
|
306
335
|
// Subagent marker from the embed extension (sessions without a UI).
|
|
307
336
|
const isSubagent = (headers.get("x-omp-subagent") ?? "").trim() === "1";
|
|
308
337
|
|
|
338
|
+
// Per-request routing policy (team edition): JSON in X-Omp-Policy.
|
|
339
|
+
const policy = parsePolicyHeader(headers.get("x-omp-policy"));
|
|
340
|
+
|
|
309
341
|
if (typeof b.model !== "string" || b.model.length === 0) {
|
|
310
342
|
throw invalidRequest("model must be a non-empty string");
|
|
311
343
|
}
|
|
@@ -367,6 +399,7 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
|
|
|
367
399
|
ompSessionId,
|
|
368
400
|
agentdoxScope,
|
|
369
401
|
isSubagent,
|
|
402
|
+
...(policy === undefined ? {} : { policy }),
|
|
370
403
|
requestedModel,
|
|
371
404
|
messages,
|
|
372
405
|
tools,
|
package/src/wire/types.ts
CHANGED
|
@@ -12,6 +12,20 @@ import type { UsageCounts } from "../cost/types.ts";
|
|
|
12
12
|
|
|
13
13
|
export type WireProtocol = "openai-chat" | "openai-responses" | "pi-native";
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* A routing policy attached to one request. `allow`/`deny` are slug globs
|
|
17
|
+
* like `filters.allow`/`filters.deny` (a request allow list replaces the
|
|
18
|
+
* configured one; a deny list adds to it); `minTier`/`maxTier` narrow the
|
|
19
|
+
* profile's tier envelope; `pin` forces one slug, like `/router pin`.
|
|
20
|
+
*/
|
|
21
|
+
export interface RequestPolicy {
|
|
22
|
+
allow?: string[];
|
|
23
|
+
deny?: string[];
|
|
24
|
+
minTier?: "trivial" | "simple" | "moderate" | "hard";
|
|
25
|
+
maxTier?: "trivial" | "simple" | "moderate" | "hard";
|
|
26
|
+
pin?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
15
29
|
export type Role = "system" | "developer" | "user" | "assistant" | "tool";
|
|
16
30
|
|
|
17
31
|
/** One tool call requested by an assistant turn. */
|
|
@@ -84,6 +98,12 @@ export interface NormRequest {
|
|
|
84
98
|
agentdoxScope: string;
|
|
85
99
|
/** `X-Omp-Subagent: 1`: the caller is an omp subagent (a session without a UI). */
|
|
86
100
|
isSubagent: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Per-request routing policy from the `X-Omp-Policy` header (JSON), set by
|
|
103
|
+
* a front door such as the team edition: narrows what this turn may route
|
|
104
|
+
* to. Absent ⇒ the configured profile and filters alone.
|
|
105
|
+
*/
|
|
106
|
+
policy?: RequestPolicy;
|
|
87
107
|
/** Virtual model the client selected, e.g. `auto`, `auto-cheap`, `auto-max`. */
|
|
88
108
|
requestedModel: string;
|
|
89
109
|
messages: NormMessage[];
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
4
|
+
import { applyRequestPolicy, resolveProfile } from "../src/router/index.ts";
|
|
5
|
+
import { parseChatRequest, parsePolicyHeader } from "../src/wire/openai/request.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The per-request routing policy (X-Omp-Policy): parsed defensively from the
|
|
9
|
+
* header, then applied on top of the profile and filters — tiers only narrow,
|
|
10
|
+
* an allow list replaces the configured one, a deny list adds to it, and a
|
|
11
|
+
* pin forces a slug unless a session override already did.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
describe("parsePolicyHeader", () => {
|
|
15
|
+
test("accepts the documented fields, drops junk, and never rejects a turn", () => {
|
|
16
|
+
expect(parsePolicyHeader(null)).toBeUndefined();
|
|
17
|
+
expect(parsePolicyHeader("not json")).toBeUndefined();
|
|
18
|
+
expect(parsePolicyHeader("[]")).toBeUndefined();
|
|
19
|
+
expect(parsePolicyHeader("{}")).toBeUndefined();
|
|
20
|
+
expect(parsePolicyHeader(JSON.stringify({ allow: ["anthropic/*", " x/y "], deny: [1, "", "openai/*"], minTier: "simple", maxTier: "nope", pin: " z/w " }))).toEqual({ allow: ["anthropic/*", "x/y"], deny: ["openai/*"], minTier: "simple", pin: "z/w" });
|
|
21
|
+
const req = parseChatRequest({ model: "auto", messages: [{ role: "user", content: "hi" }] }, new Headers({ "X-Omp-Policy": '{"maxTier":"moderate"}' }));
|
|
22
|
+
expect(req.policy).toEqual({ maxTier: "moderate" });
|
|
23
|
+
expect("policy" in parseChatRequest({ model: "auto", messages: [{ role: "user", content: "hi" }] }, new Headers())).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("applyRequestPolicy", () => {
|
|
28
|
+
const cfg = DEFAULT_CONFIG;
|
|
29
|
+
const profile = resolveProfile(cfg, "auto");
|
|
30
|
+
|
|
31
|
+
test("narrows the tier envelope, never widens it", () => {
|
|
32
|
+
const r = applyRequestPolicy(profile, cfg, { maxTier: "moderate", minTier: "trivial" }, undefined);
|
|
33
|
+
expect(r.profile.maxTier).toBe("moderate");
|
|
34
|
+
expect(r.profile.minTier).toBe(profile.minTier);
|
|
35
|
+
expect(r.profile.id).toBe(`${profile.id}+policy`);
|
|
36
|
+
expect(r.reasons[0]).toContain("tiers narrowed");
|
|
37
|
+
// A cheap profile cannot be raised past its own ceiling.
|
|
38
|
+
const cheap = resolveProfile(cfg, "auto-cheap");
|
|
39
|
+
const up = applyRequestPolicy(cheap, cfg, { minTier: "hard" }, undefined);
|
|
40
|
+
expect(up.profile.minTier).toBe(cheap.maxTier);
|
|
41
|
+
expect(up.profile.maxTier).toBe(cheap.maxTier);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("allow replaces, deny adds, and a pin forces unless a session override already did", () => {
|
|
45
|
+
const base = { ...cfg, filters: { ...cfg.filters, allow: ["x/*"], deny: ["bad/*"] } };
|
|
46
|
+
const r = applyRequestPolicy(profile, base, { allow: ["anthropic/*"], deny: ["openai/*"], pin: "anthropic/claude-sonnet-5" }, undefined);
|
|
47
|
+
expect(r.cfg.filters.allow).toEqual(["anthropic/*"]);
|
|
48
|
+
expect(r.cfg.filters.deny).toEqual(["bad/*", "openai/*"]);
|
|
49
|
+
expect(r.forceSlug).toBe("anthropic/claude-sonnet-5");
|
|
50
|
+
expect(r.profile).toBe(profile); // tiers untouched ⇒ same object
|
|
51
|
+
expect(applyRequestPolicy(profile, base, { pin: "a/b" }, "session/pin").forceSlug).toBe("session/pin");
|
|
52
|
+
expect(applyRequestPolicy(profile, base, undefined, undefined)).toEqual({ profile, cfg: base, forceSlug: undefined, reasons: [] });
|
|
53
|
+
// Untouched config object when the policy carries no filters.
|
|
54
|
+
expect(applyRequestPolicy(profile, base, { maxTier: "hard" }, undefined).cfg).toBe(base);
|
|
55
|
+
});
|
|
56
|
+
});
|
package/test/report.test.ts
CHANGED
|
@@ -156,6 +156,20 @@ describe("buildUsageReport", () => {
|
|
|
156
156
|
db.close();
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
+
test("a comma-separated harness list reports the union (a team group)", () => {
|
|
160
|
+
const { db, ledger } = seeded();
|
|
161
|
+
try {
|
|
162
|
+
ledger.record(entry({ harnessId: "u_a", reportedUsd: 1 }));
|
|
163
|
+
ledger.record(entry({ harnessId: "u_b", reportedUsd: 2 }));
|
|
164
|
+
ledger.record(entry({ harnessId: "u_c", reportedUsd: 4 }));
|
|
165
|
+
expect(buildUsageReport(db, { windowDays: 1, nowMs: NOW, harnessId: "u_a,u_b" }).totals.spendUsd).toBeCloseTo(3, 6);
|
|
166
|
+
expect(buildUsageReport(db, { windowDays: 1, nowMs: NOW, harnessId: " u_c , u_a " }).totals.spendUsd).toBeCloseTo(5, 6);
|
|
167
|
+
expect(buildUsageReport(db, { windowDays: 1, nowMs: NOW, harnessId: "u_b" }).totals.spendUsd).toBeCloseTo(2, 6);
|
|
168
|
+
} finally {
|
|
169
|
+
db.close();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
159
173
|
test("prompt anatomy averages the recorded byte shares", () => {
|
|
160
174
|
const { db, ledger } = seeded();
|
|
161
175
|
const feat = (tool: number, older: number, stale: number) => ({ toolSchemaBytes: 1000, anatomy: { messages: 30, systemBytes: 1000, userBytes: 500, assistantBytes: 500, toolBytes: tool, olderHalfBytes: older, staleToolBytes: stale } });
|