@danypops/jittor 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/README.md +54 -0
- package/docs/CALIBRATION.md +102 -0
- package/docs/PROVIDER_RESEARCH.md +237 -0
- package/docs/USAGE_PRIOR_ART.md +64 -0
- package/extension/src/footer.ts +232 -0
- package/extension/src/index.ts +317 -0
- package/extension/src/service-client.ts +26 -0
- package/extension/src/settings.ts +59 -0
- package/extension/src/tui.ts +180 -0
- package/extension/src/usage.ts +169 -0
- package/package.json +30 -0
- package/src/adapters/sqlite-metric-store.ts +99 -0
- package/src/cli.ts +73 -0
- package/src/client.ts +57 -0
- package/src/config.ts +20 -0
- package/src/constants.ts +26 -0
- package/src/daemon.ts +88 -0
- package/src/db.ts +50 -0
- package/src/domain/metric.ts +54 -0
- package/src/domain/usage.ts +112 -0
- package/src/policy.ts +220 -0
- package/src/ports/metric-store.ts +9 -0
- package/src/ports/router-controller.ts +42 -0
- package/src/ports/telemetry-source.ts +15 -0
- package/src/providers/codex-contracts.ts +307 -0
- package/src/providers/codex.ts +102 -0
- package/src/providers/openrouter-contracts.ts +218 -0
- package/src/providers/openrouter.ts +71 -0
- package/src/providers/telemetry-sources.ts +69 -0
- package/src/router.ts +158 -0
- package/src/service.ts +158 -0
- package/src/state.ts +81 -0
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { LOOPBACK_HOST, MAINTENANCE_INTERVAL_MS, TELEMETRY_POLL_INTERVAL_MS } from "./constants.ts";
|
|
2
|
+
import { DEFAULT_POLICY, UNCONFIGURED_ROUTE } from "./config.ts";
|
|
3
|
+
import { SQLiteMetricStore } from "./adapters/sqlite-metric-store.ts";
|
|
4
|
+
import { openJittorDb } from "./db.ts";
|
|
5
|
+
import { createApp, JittorService } from "./service.ts";
|
|
6
|
+
import { JittorRouter } from "./router.ts";
|
|
7
|
+
import type { TelemetrySource } from "./ports/telemetry-source.ts";
|
|
8
|
+
import { CodexTelemetrySource, OpenRouterTelemetrySource } from "./providers/telemetry-sources.ts";
|
|
9
|
+
import {
|
|
10
|
+
ensureAuthToken,
|
|
11
|
+
removeDaemonHandle,
|
|
12
|
+
resolveJittorPaths,
|
|
13
|
+
writeDaemonHandle,
|
|
14
|
+
type JittorPaths,
|
|
15
|
+
} from "./state.ts";
|
|
16
|
+
|
|
17
|
+
export interface RunningDaemon {
|
|
18
|
+
host: typeof LOOPBACK_HOST;
|
|
19
|
+
port: number;
|
|
20
|
+
stop(): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function telemetrySourcesFromEnvironment(env: Record<string, string | undefined> = process.env): TelemetrySource[] {
|
|
24
|
+
const sources: TelemetrySource[] = [];
|
|
25
|
+
const codexAuthFile = env["JITTOR_CODEX_AUTH_FILE"];
|
|
26
|
+
if (codexAuthFile) sources.push(new CodexTelemetrySource(codexAuthFile));
|
|
27
|
+
const openRouterKey = env["OPENROUTER_API_KEY"];
|
|
28
|
+
if (openRouterKey) sources.push(new OpenRouterTelemetrySource(openRouterKey));
|
|
29
|
+
return sources;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function startDaemon(
|
|
33
|
+
paths: JittorPaths = resolveJittorPaths(),
|
|
34
|
+
env: Record<string, string | undefined> = process.env,
|
|
35
|
+
): RunningDaemon {
|
|
36
|
+
const token = ensureAuthToken(paths);
|
|
37
|
+
const metrics = new SQLiteMetricStore(openJittorDb(paths.database));
|
|
38
|
+
const sources = telemetrySourcesFromEnvironment(env);
|
|
39
|
+
const router = new JittorRouter({
|
|
40
|
+
metrics,
|
|
41
|
+
sources,
|
|
42
|
+
policy: DEFAULT_POLICY,
|
|
43
|
+
routes: [],
|
|
44
|
+
currentRoute: UNCONFIGURED_ROUTE,
|
|
45
|
+
});
|
|
46
|
+
const service = new JittorService(metrics, router);
|
|
47
|
+
const app = createApp({ service, token });
|
|
48
|
+
const server = Bun.serve({
|
|
49
|
+
hostname: LOOPBACK_HOST,
|
|
50
|
+
port: 0,
|
|
51
|
+
fetch: (request) => app.fetch(request),
|
|
52
|
+
});
|
|
53
|
+
const port = server.port;
|
|
54
|
+
if (port === undefined) {
|
|
55
|
+
server.stop(true);
|
|
56
|
+
service.close();
|
|
57
|
+
throw new Error("Jittor daemon failed to bind a loopback port");
|
|
58
|
+
}
|
|
59
|
+
writeDaemonHandle(paths, { host: LOOPBACK_HOST, port, pid: process.pid });
|
|
60
|
+
const maintenance = setInterval(() => { void service.execute("service.checkpoint", {}); }, MAINTENANCE_INTERVAL_MS);
|
|
61
|
+
const poll = setInterval(() => { void router.poll(); }, TELEMETRY_POLL_INTERVAL_MS);
|
|
62
|
+
if (sources.length > 0) void router.poll();
|
|
63
|
+
let stopped = false;
|
|
64
|
+
return {
|
|
65
|
+
host: LOOPBACK_HOST,
|
|
66
|
+
port,
|
|
67
|
+
async stop(): Promise<void> {
|
|
68
|
+
if (stopped) return;
|
|
69
|
+
stopped = true;
|
|
70
|
+
clearInterval(maintenance);
|
|
71
|
+
clearInterval(poll);
|
|
72
|
+
await server.stop(true);
|
|
73
|
+
service.close();
|
|
74
|
+
removeDaemonHandle(paths);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function serveMain(): void {
|
|
80
|
+
const daemon = startDaemon();
|
|
81
|
+
console.error(`[jittor] listening on ${daemon.host}:${daemon.port}`);
|
|
82
|
+
const stop = async (): Promise<void> => {
|
|
83
|
+
await daemon.stop();
|
|
84
|
+
process.exit(0);
|
|
85
|
+
};
|
|
86
|
+
process.once("SIGTERM", stop);
|
|
87
|
+
process.once("SIGINT", stop);
|
|
88
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { mkdirSync } from "node:fs";
|
|
4
|
+
import { SQLITE_BUSY_TIMEOUT_MS, SQLITE_SCHEMA_VERSION } from "./constants.ts";
|
|
5
|
+
|
|
6
|
+
const INITIAL_SCHEMA = `
|
|
7
|
+
CREATE TABLE metric_observations (
|
|
8
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
9
|
+
source TEXT NOT NULL,
|
|
10
|
+
scope TEXT NOT NULL,
|
|
11
|
+
metric TEXT NOT NULL,
|
|
12
|
+
value REAL,
|
|
13
|
+
unit TEXT NOT NULL,
|
|
14
|
+
observed_at INTEGER NOT NULL CHECK(observed_at >= 0),
|
|
15
|
+
attributes TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(attributes))
|
|
16
|
+
);
|
|
17
|
+
CREATE INDEX metric_observations_series_time_idx
|
|
18
|
+
ON metric_observations(source, scope, metric, observed_at);
|
|
19
|
+
CREATE INDEX metric_observations_time_idx
|
|
20
|
+
ON metric_observations(observed_at);
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
function migrate(db: Database): void {
|
|
24
|
+
const row = db.query("PRAGMA user_version").get() as { user_version: number };
|
|
25
|
+
if (row.user_version > SQLITE_SCHEMA_VERSION) {
|
|
26
|
+
throw new Error(`database schema ${row.user_version} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
|
|
27
|
+
}
|
|
28
|
+
if (row.user_version < 1) {
|
|
29
|
+
const migration = db.transaction(() => {
|
|
30
|
+
db.exec(INITIAL_SCHEMA);
|
|
31
|
+
db.exec("PRAGMA user_version = 1");
|
|
32
|
+
});
|
|
33
|
+
migration.immediate();
|
|
34
|
+
}
|
|
35
|
+
const migrated = db.query("PRAGMA user_version").get() as { user_version: number };
|
|
36
|
+
if (migrated.user_version !== SQLITE_SCHEMA_VERSION) {
|
|
37
|
+
throw new Error(`missing migration from schema ${migrated.user_version} to ${SQLITE_SCHEMA_VERSION}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function openJittorDb(path: string): Database {
|
|
42
|
+
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
|
|
43
|
+
const db = new Database(path, { create: true, strict: true });
|
|
44
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
45
|
+
db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
|
|
46
|
+
if (path !== ":memory:") db.exec("PRAGMA journal_mode = WAL");
|
|
47
|
+
migrate(db);
|
|
48
|
+
db.exec("PRAGMA optimize=0x10002");
|
|
49
|
+
return db;
|
|
50
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export const METRIC_UNITS = ["ratio", "usd", "tokens", "requests", "milliseconds", "count"] as const;
|
|
2
|
+
export type MetricUnit = typeof METRIC_UNITS[number];
|
|
3
|
+
|
|
4
|
+
export interface MetricObservation {
|
|
5
|
+
source: string;
|
|
6
|
+
scope: string;
|
|
7
|
+
metric: string;
|
|
8
|
+
value: number | null;
|
|
9
|
+
unit: MetricUnit;
|
|
10
|
+
observedAt: number;
|
|
11
|
+
attributes?: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface StoredMetricObservation extends MetricObservation {
|
|
15
|
+
id: number;
|
|
16
|
+
attributes: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MetricQuery {
|
|
20
|
+
source?: string;
|
|
21
|
+
scope?: string;
|
|
22
|
+
metric?: string;
|
|
23
|
+
since?: number;
|
|
24
|
+
until?: number;
|
|
25
|
+
limit?: number;
|
|
26
|
+
order?: "asc" | "desc";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function validateMetricObservation(value: unknown): MetricObservation {
|
|
30
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("metric observation must be an object");
|
|
31
|
+
const input = value as Record<string, unknown>;
|
|
32
|
+
for (const key of ["source", "scope", "metric", "unit"] as const) {
|
|
33
|
+
if (typeof input[key] !== "string" || input[key].trim().length === 0) throw new Error(`${key} is required`);
|
|
34
|
+
}
|
|
35
|
+
if (!METRIC_UNITS.includes(input["unit"] as MetricUnit)) throw new Error("unit is not supported");
|
|
36
|
+
if (input["value"] !== null && (typeof input["value"] !== "number" || !Number.isFinite(input["value"]))) {
|
|
37
|
+
throw new Error("value must be finite or null");
|
|
38
|
+
}
|
|
39
|
+
if (typeof input["observedAt"] !== "number" || !Number.isSafeInteger(input["observedAt"]) || input["observedAt"] < 0) {
|
|
40
|
+
throw new Error("observedAt must be a non-negative integer timestamp");
|
|
41
|
+
}
|
|
42
|
+
if (input["attributes"] !== undefined && (typeof input["attributes"] !== "object" || input["attributes"] === null || Array.isArray(input["attributes"]))) {
|
|
43
|
+
throw new Error("attributes must be an object");
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
source: input["source"] as string,
|
|
47
|
+
scope: input["scope"] as string,
|
|
48
|
+
metric: input["metric"] as string,
|
|
49
|
+
value: input["value"] as number | null,
|
|
50
|
+
unit: input["unit"] as MetricUnit,
|
|
51
|
+
observedAt: input["observedAt"] as number,
|
|
52
|
+
attributes: (input["attributes"] as Record<string, unknown> | undefined) ?? {},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { StoredMetricObservation } from "./metric.ts";
|
|
2
|
+
|
|
3
|
+
export const USAGE_RANGES = ["24h", "7d", "30d", "90d"] as const;
|
|
4
|
+
export type UsageRange = typeof USAGE_RANGES[number];
|
|
5
|
+
|
|
6
|
+
export interface UsageSeries {
|
|
7
|
+
key: string;
|
|
8
|
+
provider: string;
|
|
9
|
+
model: string;
|
|
10
|
+
total: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface UsageBucket {
|
|
14
|
+
start: number;
|
|
15
|
+
end: number;
|
|
16
|
+
total: number;
|
|
17
|
+
series: Record<string, number>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface UsageBreakdown {
|
|
21
|
+
input: number;
|
|
22
|
+
output: number;
|
|
23
|
+
cacheRead: number;
|
|
24
|
+
cacheWrite: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UsageHistogram {
|
|
28
|
+
range: UsageRange;
|
|
29
|
+
start: number;
|
|
30
|
+
end: number;
|
|
31
|
+
buckets: UsageBucket[];
|
|
32
|
+
series: UsageSeries[];
|
|
33
|
+
totalTokens: number;
|
|
34
|
+
breakdown: UsageBreakdown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UsageHistogramOptions {
|
|
38
|
+
range: UsageRange;
|
|
39
|
+
now: number;
|
|
40
|
+
bucketCount?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const RANGE_MILLISECONDS: Record<UsageRange, number> = {
|
|
44
|
+
"24h": 24 * 60 * 60 * 1_000,
|
|
45
|
+
"7d": 7 * 24 * 60 * 60 * 1_000,
|
|
46
|
+
"30d": 30 * 24 * 60 * 60 * 1_000,
|
|
47
|
+
"90d": 90 * 24 * 60 * 60 * 1_000,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const DEFAULT_BUCKETS: Record<UsageRange, number> = { "24h": 24, "7d": 28, "30d": 30, "90d": 30 };
|
|
51
|
+
|
|
52
|
+
const BREAKDOWN_KEYS = {
|
|
53
|
+
"input-tokens": "input",
|
|
54
|
+
"output-tokens": "output",
|
|
55
|
+
"cache-read-tokens": "cacheRead",
|
|
56
|
+
"cache-write-tokens": "cacheWrite",
|
|
57
|
+
} as const satisfies Record<string, keyof UsageBreakdown>;
|
|
58
|
+
|
|
59
|
+
export function usageRangeStart(range: UsageRange, now: number): number {
|
|
60
|
+
return Math.max(0, now - RANGE_MILLISECONDS[range]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function identity(row: StoredMetricObservation): { key: string; provider: string; model: string } {
|
|
64
|
+
const separator = row.scope.indexOf(":");
|
|
65
|
+
const fallbackProvider = separator >= 0 ? row.scope.slice(0, separator) : row.scope;
|
|
66
|
+
const fallbackModel = separator >= 0 ? row.scope.slice(separator + 1) : "unknown";
|
|
67
|
+
const provider = typeof row.attributes["provider"] === "string" ? row.attributes["provider"] : fallbackProvider;
|
|
68
|
+
const model = typeof row.attributes["model"] === "string" ? row.attributes["model"] : fallbackModel;
|
|
69
|
+
return { key: `${provider}/${model}`, provider, model };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function buildUsageHistogram(rows: StoredMetricObservation[], options: UsageHistogramOptions): UsageHistogram {
|
|
73
|
+
const end = options.now;
|
|
74
|
+
const start = usageRangeStart(options.range, end);
|
|
75
|
+
const requestedBuckets = options.bucketCount ?? DEFAULT_BUCKETS[options.range];
|
|
76
|
+
const bucketCount = Math.max(1, Math.min(120, Math.floor(requestedBuckets)));
|
|
77
|
+
const bucketSize = Math.max(1, (end - start) / bucketCount);
|
|
78
|
+
const buckets: UsageBucket[] = Array.from({ length: bucketCount }, (_, index) => ({
|
|
79
|
+
start: start + index * bucketSize,
|
|
80
|
+
end: index === bucketCount - 1 ? end : start + (index + 1) * bucketSize,
|
|
81
|
+
total: 0,
|
|
82
|
+
series: {},
|
|
83
|
+
}));
|
|
84
|
+
const breakdown: UsageBreakdown = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
85
|
+
const identities = new Map<string, UsageSeries>();
|
|
86
|
+
|
|
87
|
+
for (const row of rows) {
|
|
88
|
+
const breakdownKey = BREAKDOWN_KEYS[row.metric as keyof typeof BREAKDOWN_KEYS];
|
|
89
|
+
if (row.source !== "pi" || row.unit !== "tokens" || !breakdownKey || typeof row.value !== "number" || row.value < 0) continue;
|
|
90
|
+
if (row.observedAt < start || row.observedAt > end) continue;
|
|
91
|
+
const bucketIndex = Math.min(bucketCount - 1, Math.floor((row.observedAt - start) / bucketSize));
|
|
92
|
+
const bucket = buckets[bucketIndex]!;
|
|
93
|
+
const series = identity(row);
|
|
94
|
+
bucket.total += row.value;
|
|
95
|
+
bucket.series[series.key] = (bucket.series[series.key] ?? 0) + row.value;
|
|
96
|
+
breakdown[breakdownKey] += row.value;
|
|
97
|
+
const current = identities.get(series.key) ?? { ...series, total: 0 };
|
|
98
|
+
current.total += row.value;
|
|
99
|
+
identities.set(series.key, current);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const series = [...identities.values()].sort((left, right) => right.total - left.total || left.key.localeCompare(right.key));
|
|
103
|
+
return {
|
|
104
|
+
range: options.range,
|
|
105
|
+
start,
|
|
106
|
+
end,
|
|
107
|
+
buckets,
|
|
108
|
+
series,
|
|
109
|
+
totalTokens: buckets.reduce((sum, bucket) => sum + bucket.total, 0),
|
|
110
|
+
breakdown,
|
|
111
|
+
};
|
|
112
|
+
}
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
export type PolicyAction = "continue" | "throttle" | "lower-thinking" | "switch-model" | "switch-provider" | "halt";
|
|
2
|
+
export type TelemetryFreshness = "fresh" | "stale" | "failed";
|
|
3
|
+
|
|
4
|
+
export interface BudgetWindow {
|
|
5
|
+
id: string;
|
|
6
|
+
source: string;
|
|
7
|
+
scope: string;
|
|
8
|
+
usedFraction: number;
|
|
9
|
+
windowSeconds: number;
|
|
10
|
+
resetsAt: number;
|
|
11
|
+
observedAt: number;
|
|
12
|
+
freshness: TelemetryFreshness;
|
|
13
|
+
confidence: number;
|
|
14
|
+
observedBurnPerSecond?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Route {
|
|
18
|
+
provider: string;
|
|
19
|
+
model: string;
|
|
20
|
+
thinking: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PolicyThresholds {
|
|
24
|
+
throttle: number;
|
|
25
|
+
lowerThinking: number;
|
|
26
|
+
switchModel: number;
|
|
27
|
+
switchProvider: number;
|
|
28
|
+
halt: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PolicyConfig {
|
|
32
|
+
maxTelemetryAgeMs: number;
|
|
33
|
+
cooldownMs: number;
|
|
34
|
+
hysteresisFraction: number;
|
|
35
|
+
thresholds: PolicyThresholds;
|
|
36
|
+
maxThrottleMs: number;
|
|
37
|
+
hardStopUsedFraction: number;
|
|
38
|
+
minimumConfidence?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PreviousDecision {
|
|
42
|
+
action: PolicyAction;
|
|
43
|
+
decidedAt: number;
|
|
44
|
+
pressure: number;
|
|
45
|
+
route?: Route;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PolicyInput {
|
|
49
|
+
now: number;
|
|
50
|
+
windows: BudgetWindow[];
|
|
51
|
+
currentRoute: Route;
|
|
52
|
+
routes: Route[];
|
|
53
|
+
config: PolicyConfig;
|
|
54
|
+
previousDecision?: PreviousDecision;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PolicyDecision {
|
|
58
|
+
action: PolicyAction;
|
|
59
|
+
pressure: number;
|
|
60
|
+
reason: string;
|
|
61
|
+
route?: Route;
|
|
62
|
+
delayMs?: number;
|
|
63
|
+
windowId?: string;
|
|
64
|
+
decidedAt: number;
|
|
65
|
+
trace: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ACTION_SEVERITY: Record<PolicyAction, number> = {
|
|
69
|
+
continue: 0,
|
|
70
|
+
throttle: 1,
|
|
71
|
+
"lower-thinking": 2,
|
|
72
|
+
"switch-model": 3,
|
|
73
|
+
"switch-provider": 4,
|
|
74
|
+
halt: 5,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function actionThreshold(action: PolicyAction, thresholds: PolicyThresholds): number {
|
|
78
|
+
switch (action) {
|
|
79
|
+
case "continue": return 0;
|
|
80
|
+
case "throttle": return thresholds.throttle;
|
|
81
|
+
case "lower-thinking": return thresholds.lowerThinking;
|
|
82
|
+
case "switch-model": return thresholds.switchModel;
|
|
83
|
+
case "switch-provider": return thresholds.switchProvider;
|
|
84
|
+
case "halt": return thresholds.halt;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function desiredAction(pressure: number, thresholds: PolicyThresholds): PolicyAction {
|
|
89
|
+
if (pressure > thresholds.halt) return "halt";
|
|
90
|
+
if (pressure > thresholds.switchProvider) return "switch-provider";
|
|
91
|
+
if (pressure > thresholds.switchModel) return "switch-model";
|
|
92
|
+
if (pressure > thresholds.lowerThinking) return "lower-thinking";
|
|
93
|
+
if (pressure > thresholds.throttle) return "throttle";
|
|
94
|
+
return "continue";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function sameRoute(left: Route, right: Route): boolean {
|
|
98
|
+
return left.provider === right.provider && left.model === right.model && left.thinking === right.thinking;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function alternativesAfter(input: PolicyInput): Route[] {
|
|
102
|
+
const currentIndex = input.routes.findIndex((route) => sameRoute(route, input.currentRoute));
|
|
103
|
+
return input.routes.slice(currentIndex >= 0 ? currentIndex + 1 : 0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function routeFor(action: PolicyAction, input: PolicyInput): Route | undefined {
|
|
107
|
+
const alternatives = alternativesAfter(input);
|
|
108
|
+
if (action === "lower-thinking") {
|
|
109
|
+
return alternatives.find((route) => route.provider === input.currentRoute.provider && route.model === input.currentRoute.model);
|
|
110
|
+
}
|
|
111
|
+
if (action === "switch-model") {
|
|
112
|
+
return alternatives.find((route) => route.provider === input.currentRoute.provider && route.model !== input.currentRoute.model);
|
|
113
|
+
}
|
|
114
|
+
if (action === "switch-provider") return alternatives.find((route) => route.provider !== input.currentRoute.provider);
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function pressureFor(window: BudgetWindow, now: number): number {
|
|
119
|
+
const remainingSeconds = (window.resetsAt - now) / 1_000;
|
|
120
|
+
if (remainingSeconds <= 0) return Number.POSITIVE_INFINITY;
|
|
121
|
+
const remainingFraction = Math.max(0, 1 - window.usedFraction);
|
|
122
|
+
if (remainingFraction === 0) return Number.POSITIVE_INFINITY;
|
|
123
|
+
const elapsedSeconds = Math.max(0, window.windowSeconds - remainingSeconds);
|
|
124
|
+
const observedBurn = window.observedBurnPerSecond ?? (elapsedSeconds > 0 ? window.usedFraction / elapsedSeconds : 0);
|
|
125
|
+
const sustainableBurn = remainingFraction / remainingSeconds;
|
|
126
|
+
return observedBurn / sustainableBurn;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function holdPrevious(input: PolicyInput, pressure: number, trace: string[]): PolicyDecision | undefined {
|
|
130
|
+
const previous = input.previousDecision;
|
|
131
|
+
if (!previous) return undefined;
|
|
132
|
+
const candidate = desiredAction(pressure, input.config.thresholds);
|
|
133
|
+
if (ACTION_SEVERITY[candidate] >= ACTION_SEVERITY[previous.action]) return undefined;
|
|
134
|
+
const elapsed = input.now - previous.decidedAt;
|
|
135
|
+
if (elapsed < input.config.cooldownMs) {
|
|
136
|
+
trace.push(`cooldown holds ${previous.action} for ${input.config.cooldownMs - elapsed}ms`);
|
|
137
|
+
return decisionFromPrevious(previous, pressure, trace, "cooldown prevents recovery");
|
|
138
|
+
}
|
|
139
|
+
const recoveryThreshold = actionThreshold(previous.action, input.config.thresholds) * (1 - input.config.hysteresisFraction);
|
|
140
|
+
if (pressure > recoveryThreshold) {
|
|
141
|
+
trace.push(`hysteresis holds ${previous.action}: ${pressure.toFixed(3)} > ${recoveryThreshold.toFixed(3)}`);
|
|
142
|
+
return decisionFromPrevious(previous, pressure, trace, "hysteresis prevents recovery");
|
|
143
|
+
}
|
|
144
|
+
trace.push(`recovery allowed below ${recoveryThreshold.toFixed(3)} after cooldown`);
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function decisionFromPrevious(
|
|
149
|
+
previous: PreviousDecision,
|
|
150
|
+
pressure: number,
|
|
151
|
+
trace: string[],
|
|
152
|
+
reason: string,
|
|
153
|
+
): PolicyDecision {
|
|
154
|
+
return {
|
|
155
|
+
action: previous.action,
|
|
156
|
+
pressure,
|
|
157
|
+
reason,
|
|
158
|
+
...(previous.route ? { route: previous.route } : {}),
|
|
159
|
+
decidedAt: previous.decidedAt,
|
|
160
|
+
trace,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function failClosed(input: PolicyInput, reason: string, trace: string[], windowId?: string): PolicyDecision {
|
|
165
|
+
trace.push(`fail closed: ${reason}`);
|
|
166
|
+
return { action: "halt", pressure: Number.POSITIVE_INFINITY, reason, windowId, decidedAt: input.now, trace };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function evaluateRoutingPolicy(input: PolicyInput): PolicyDecision {
|
|
170
|
+
const trace: string[] = [];
|
|
171
|
+
if (input.windows.length === 0) return failClosed(input, "required budget telemetry is missing", trace);
|
|
172
|
+
for (const window of input.windows) {
|
|
173
|
+
if (window.freshness !== "fresh") return failClosed(input, `telemetry ${window.id} is ${window.freshness}`, trace, window.id);
|
|
174
|
+
if (input.now - window.observedAt > input.config.maxTelemetryAgeMs) return failClosed(input, `telemetry ${window.id} is stale`, trace, window.id);
|
|
175
|
+
if (window.confidence < (input.config.minimumConfidence ?? 0)) return failClosed(input, `telemetry ${window.id} confidence is too low`, trace, window.id);
|
|
176
|
+
if (window.usedFraction < 0 || window.usedFraction > 1) return failClosed(input, `telemetry ${window.id} has invalid utilization`, trace, window.id);
|
|
177
|
+
if (window.usedFraction >= input.config.hardStopUsedFraction) return failClosed(input, `hard stop reached for ${window.id}`, trace, window.id);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const pressures = input.windows.map((window) => ({ window, pressure: pressureFor(window, input.now) }));
|
|
181
|
+
for (const { window, pressure } of pressures) trace.push(`${window.id}: pressure=${pressure.toFixed(3)} sustainable=${pressure <= 1}`);
|
|
182
|
+
const binding = pressures.reduce((worst, candidate) => candidate.pressure > worst.pressure ? candidate : worst);
|
|
183
|
+
const held = holdPrevious(input, binding.pressure, trace);
|
|
184
|
+
if (held) return { ...held, windowId: binding.window.id };
|
|
185
|
+
|
|
186
|
+
let action = desiredAction(binding.pressure, input.config.thresholds);
|
|
187
|
+
let route = routeFor(action, input);
|
|
188
|
+
if (action === "lower-thinking" && !route) {
|
|
189
|
+
trace.push("lower-thinking route unavailable; escalating");
|
|
190
|
+
action = "switch-model";
|
|
191
|
+
route = routeFor(action, input);
|
|
192
|
+
}
|
|
193
|
+
if (action === "switch-model" && !route) {
|
|
194
|
+
trace.push("switch-model route unavailable; escalating");
|
|
195
|
+
action = "switch-provider";
|
|
196
|
+
route = routeFor(action, input);
|
|
197
|
+
}
|
|
198
|
+
if (action === "switch-provider" && !route) {
|
|
199
|
+
trace.push("switch-provider route unavailable; escalating");
|
|
200
|
+
action = "halt";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const reason = `${binding.window.id} pressure ${binding.pressure.toFixed(3)} selected ${action}`;
|
|
204
|
+
trace.push(reason);
|
|
205
|
+
const decision: PolicyDecision = {
|
|
206
|
+
action,
|
|
207
|
+
pressure: binding.pressure,
|
|
208
|
+
reason,
|
|
209
|
+
windowId: binding.window.id,
|
|
210
|
+
decidedAt: input.now,
|
|
211
|
+
trace,
|
|
212
|
+
};
|
|
213
|
+
if (route) decision.route = route;
|
|
214
|
+
if (action === "throttle") {
|
|
215
|
+
const span = input.config.thresholds.lowerThinking - input.config.thresholds.throttle;
|
|
216
|
+
const fraction = span > 0 ? (binding.pressure - input.config.thresholds.throttle) / span : 1;
|
|
217
|
+
decision.delayMs = Math.max(0, Math.min(input.config.maxThrottleMs, Math.round(fraction * input.config.maxThrottleMs)));
|
|
218
|
+
}
|
|
219
|
+
return decision;
|
|
220
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { MetricObservation, MetricQuery, StoredMetricObservation } from "../domain/metric.ts";
|
|
2
|
+
|
|
3
|
+
export interface MetricStore {
|
|
4
|
+
record(observation: MetricObservation): StoredMetricObservation;
|
|
5
|
+
query(filter?: MetricQuery): StoredMetricObservation[];
|
|
6
|
+
pruneBefore(cutoff: number): number;
|
|
7
|
+
checkpoint(): void;
|
|
8
|
+
close(): void;
|
|
9
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { PolicyDecision, Route } from "../policy.ts";
|
|
2
|
+
|
|
3
|
+
export interface TelemetrySourceStatus {
|
|
4
|
+
id: string;
|
|
5
|
+
provider: string;
|
|
6
|
+
ok: boolean;
|
|
7
|
+
metrics: number;
|
|
8
|
+
observedAt?: number;
|
|
9
|
+
error?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TelemetryPollResult {
|
|
13
|
+
sources: TelemetrySourceStatus[];
|
|
14
|
+
observedAt: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RouteOverride {
|
|
18
|
+
route: Route;
|
|
19
|
+
expiresAt: number | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RouterStatus {
|
|
23
|
+
ready: boolean;
|
|
24
|
+
paused: boolean;
|
|
25
|
+
sources: TelemetrySourceStatus[];
|
|
26
|
+
lastDecision: PolicyDecision | null;
|
|
27
|
+
override: RouteOverride | null;
|
|
28
|
+
currentRoute: Route | null;
|
|
29
|
+
availableRoutes: Route[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RouterController {
|
|
33
|
+
poll(): Promise<TelemetryPollResult>;
|
|
34
|
+
status(): RouterStatus;
|
|
35
|
+
decide(): PolicyDecision;
|
|
36
|
+
pause(): RouterStatus;
|
|
37
|
+
resume(): RouterStatus;
|
|
38
|
+
setOverride(override?: RouteOverride): RouterStatus;
|
|
39
|
+
clearOverride(): RouterStatus;
|
|
40
|
+
setCurrentRoute(route: Route): RouterStatus;
|
|
41
|
+
setAvailableRoutes(routes: Route[]): RouterStatus;
|
|
42
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MetricObservation } from "../domain/metric.ts";
|
|
2
|
+
import type { BudgetWindow } from "../policy.ts";
|
|
3
|
+
|
|
4
|
+
export interface TelemetryBatch {
|
|
5
|
+
observedAt: number;
|
|
6
|
+
metrics: MetricObservation[];
|
|
7
|
+
windows: BudgetWindow[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface TelemetrySource {
|
|
11
|
+
id: string;
|
|
12
|
+
provider: string;
|
|
13
|
+
required: boolean;
|
|
14
|
+
poll(): Promise<TelemetryBatch>;
|
|
15
|
+
}
|