@danypops/jittor 0.10.0 → 0.11.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 +8 -5
- package/extension/src/benchmark-tui.ts +4 -0
- package/extension/src/capabilities/codex-recovery.ts +127 -0
- package/extension/src/capabilities/http-headers.ts +5 -0
- package/extension/src/capabilities/local-run-telemetry.ts +104 -0
- package/extension/src/capabilities/provider-response-telemetry.ts +96 -0
- package/extension/src/footer.ts +10 -53
- package/extension/src/index.ts +92 -272
- package/extension/src/session-identity.ts +20 -0
- package/extension/src/tui.ts +20 -11
- package/package.json +1 -1
- package/src/adapters/sqlite-metric-store.ts +11 -2
- package/src/adapters/sqlite-session-identity-store.ts +45 -0
- package/src/cli-commands/benchmarks.ts +140 -0
- package/src/cli-commands/compaction.ts +17 -0
- package/src/cli-commands/context.ts +49 -0
- package/src/cli-commands/metrics.ts +296 -0
- package/src/cli-commands/op.ts +40 -0
- package/src/cli-commands/route-args.ts +15 -0
- package/src/cli-commands/router.ts +207 -0
- package/src/cli-commands/service-daemon.ts +72 -0
- package/src/cli-commands/session.ts +42 -0
- package/src/cli-commands/support.ts +33 -0
- package/src/cli.ts +42 -769
- package/src/constants.ts +7 -0
- package/src/daemon.ts +13 -3
- package/src/db.ts +15 -1
- package/src/operations/benchmark-operations.ts +12 -0
- package/src/operations/context-operations.ts +30 -0
- package/src/operations/metrics-operations.ts +77 -0
- package/src/operations/model-ranking-operations.ts +16 -0
- package/src/operations/router-operations.ts +19 -0
- package/src/operations/session-identity-operations.ts +15 -0
- package/src/operations/session-scope.ts +31 -0
- package/src/operations/types.ts +3 -0
- package/src/ports/metric-store.ts +2 -0
- package/src/ports/router-controller.ts +9 -9
- package/src/ports/session-identity-store.ts +5 -0
- package/src/providers/telemetry-sources.ts +2 -1
- package/src/router.ts +124 -67
- package/src/service.ts +60 -118
- package/src/session-identity-service.ts +55 -0
package/src/constants.ts
CHANGED
|
@@ -91,6 +91,13 @@ export const MAX_USAGE_BUCKETS = 120;
|
|
|
91
91
|
/** Defense-in-depth cap on the SQL-side usage aggregation result: (scopes x metrics x buckets) is already small by construction, but this bounds it explicitly rather than trusting that alone. */
|
|
92
92
|
export const USAGE_AGGREGATE_MAX_ROWS = 25_000;
|
|
93
93
|
export const MAX_DYNAMIC_ROUTES = 100;
|
|
94
|
+
/** Bounds concurrent in-memory Pi router scopes; the least recently used non-global scope is evicted. */
|
|
95
|
+
export const ROUTER_MAX_SESSION_SCOPES = 500;
|
|
96
|
+
export const ROUTER_SESSION_ID_MAX_CHARACTERS = 128;
|
|
97
|
+
/** Hard cap on registered session_identities rows; oldest-seen identity is evicted beyond this. */
|
|
98
|
+
export const SESSION_IDENTITY_MAX_ROWS = 2_000;
|
|
99
|
+
/** Bounds one metrics.record_batch call; a real per-turn event batch (usage, headers, local-run metrics) is a handful of rows, never thousands. */
|
|
100
|
+
export const METRIC_BATCH_MAX_OBSERVATIONS = 100;
|
|
94
101
|
export const CODEX_ERROR_MESSAGE_LIMIT = 160;
|
|
95
102
|
export const CODEX_RETRY_AFTER_MAX_MS = 5 * MILLISECONDS_PER_MINUTE;
|
|
96
103
|
export const CODEX_RECOVERY_BASE_DELAY_MS = 2 * MILLISECONDS_PER_SECOND;
|
package/src/daemon.ts
CHANGED
|
@@ -12,6 +12,8 @@ import { BenchmarkCatalog } from "./domain/benchmark.ts";
|
|
|
12
12
|
import { EvidenceModelRanker } from "./domain/model-ranking-service.ts";
|
|
13
13
|
import { createApp, JittorService } from "./service.ts";
|
|
14
14
|
import { JittorRouter } from "./router.ts";
|
|
15
|
+
import { SQLiteSessionIdentityStore } from "./adapters/sqlite-session-identity-store.ts";
|
|
16
|
+
import { SessionIdentity } from "./session-identity-service.ts";
|
|
15
17
|
import type { BenchmarkSource } from "./ports/benchmark-source.ts";
|
|
16
18
|
import type { TelemetrySource } from "./ports/telemetry-source.ts";
|
|
17
19
|
import { CodexTelemetrySource, GoogleVertexBudgetTelemetrySource, OpenRouterTelemetrySource } from "./providers/telemetry-sources.ts";
|
|
@@ -39,6 +41,12 @@ export function benchmarkSourcesFromEnvironment(env: Record<string, string | und
|
|
|
39
41
|
return sources;
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
function googleVertexMetricSource(value: string | undefined): GoogleVertexMetricSource {
|
|
45
|
+
if (value === undefined || value === "google-vertex") return "google-vertex";
|
|
46
|
+
if (value === "anthropic-vertex") return "anthropic-vertex";
|
|
47
|
+
throw new Error("JITTOR_GOOGLE_VERTEX_BUDGET_SOURCE must be google-vertex or anthropic-vertex");
|
|
48
|
+
}
|
|
49
|
+
|
|
42
50
|
export function telemetrySourcesFromEnvironment(env: Record<string, string | undefined> = process.env): TelemetrySource[] {
|
|
43
51
|
const sources: TelemetrySource[] = [];
|
|
44
52
|
const codexAuthFile = env["JITTOR_CODEX_AUTH_FILE"];
|
|
@@ -49,7 +57,7 @@ export function telemetrySourcesFromEnvironment(env: Record<string, string | und
|
|
|
49
57
|
// docs/PROVIDER_RESEARCH.md), so its absence must never attempt ADC discovery or a network call.
|
|
50
58
|
const vertexBudgetSubscription = env["JITTOR_GOOGLE_VERTEX_BUDGET_SUBSCRIPTION"];
|
|
51
59
|
if (vertexBudgetSubscription) {
|
|
52
|
-
const source = (env["JITTOR_GOOGLE_VERTEX_BUDGET_SOURCE"]
|
|
60
|
+
const source = googleVertexMetricSource(env["JITTOR_GOOGLE_VERTEX_BUDGET_SOURCE"]);
|
|
53
61
|
const tokenProvider = createGoogleAdcTokenProvider([GOOGLE_PUBSUB_READONLY_SCOPE]);
|
|
54
62
|
sources.push(new GoogleVertexBudgetTelemetrySource(vertexBudgetSubscription, tokenProvider, Date.now, fetch, source));
|
|
55
63
|
}
|
|
@@ -71,7 +79,9 @@ export function startDaemon(
|
|
|
71
79
|
env: Record<string, string | undefined> = process.env,
|
|
72
80
|
): RunningDaemon {
|
|
73
81
|
const token = ensureAuthToken(paths);
|
|
74
|
-
const
|
|
82
|
+
const db = openJittorDb(paths.database);
|
|
83
|
+
const metrics = new SQLiteMetricStore(db);
|
|
84
|
+
const sessionIdentity = new SessionIdentity(new SQLiteSessionIdentityStore(db));
|
|
75
85
|
const sources = telemetrySourcesFromEnvironment(env);
|
|
76
86
|
const benchmarkSources = benchmarkSourcesFromEnvironment(env);
|
|
77
87
|
const benchmarkStore = new MetricBenchmarkStore(metrics);
|
|
@@ -84,7 +94,7 @@ export function startDaemon(
|
|
|
84
94
|
routes: [],
|
|
85
95
|
currentRoute: UNCONFIGURED_ROUTE,
|
|
86
96
|
});
|
|
87
|
-
const service = new JittorService(metrics, router, benchmarks, modelRanker);
|
|
97
|
+
const service = new JittorService(metrics, router, benchmarks, modelRanker, sessionIdentity);
|
|
88
98
|
|
|
89
99
|
const daemon = startDaemonKit({
|
|
90
100
|
daemonLabel: "Jittor",
|
package/src/db.ts
CHANGED
|
@@ -19,6 +19,17 @@ CREATE INDEX metric_observations_time_idx
|
|
|
19
19
|
ON metric_observations(observed_at);
|
|
20
20
|
`;
|
|
21
21
|
|
|
22
|
+
const SESSION_IDENTITY_SCHEMA = `
|
|
23
|
+
CREATE TABLE session_identities (
|
|
24
|
+
session_id TEXT PRIMARY KEY,
|
|
25
|
+
secret_hash TEXT NOT NULL,
|
|
26
|
+
registered_at TEXT NOT NULL,
|
|
27
|
+
last_seen_at TEXT NOT NULL
|
|
28
|
+
);
|
|
29
|
+
CREATE INDEX session_identities_last_seen_idx
|
|
30
|
+
ON session_identities(last_seen_at);
|
|
31
|
+
`;
|
|
32
|
+
|
|
22
33
|
/**
|
|
23
34
|
* Delegates bootstrap (pragmas, migration engine) to `@danypops/daemon-kit/storage`, which
|
|
24
35
|
* generalizes the byte-identical pragma/PRAGMA-user_version skeleton jittor's own db.ts used to
|
|
@@ -28,6 +39,9 @@ export function openJittorDb(path: string): Database {
|
|
|
28
39
|
return openSqliteWithPragmas(path, {
|
|
29
40
|
databaseOptions: { create: true, strict: true },
|
|
30
41
|
busyTimeoutMs: SQLITE_BUSY_TIMEOUT_MS,
|
|
31
|
-
migrations: [
|
|
42
|
+
migrations: [
|
|
43
|
+
{ version: 1, up: (db) => db.exec(INITIAL_SCHEMA) },
|
|
44
|
+
{ version: 2, up: (db) => db.exec(SESSION_IDENTITY_SCHEMA) },
|
|
45
|
+
],
|
|
32
46
|
});
|
|
33
47
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BenchmarkQuery } from "../domain/benchmark.ts";
|
|
2
|
+
import type { BenchmarkController } from "../ports/benchmark-controller.ts";
|
|
3
|
+
import type { OperationHandlerMap } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/** benchmark.* -- every operation whose only collaborator is the benchmark-controller port. */
|
|
6
|
+
export function benchmarkOperations(benchmarks: BenchmarkController): OperationHandlerMap {
|
|
7
|
+
return {
|
|
8
|
+
"benchmark.refresh": (input) => benchmarks.refresh(input["force"] === true),
|
|
9
|
+
"benchmark.status": () => benchmarks.status(),
|
|
10
|
+
"benchmark.query": (input) => benchmarks.query(input as unknown as BenchmarkQuery),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { COMPACTION_DURATION_ESTIMATE_MAX_SAMPLES, CONTEXT_ASSESSMENT_DEFAULT_WINDOW_MS, CONTEXT_ASSESSMENT_QUERY_LIMIT } from "../constants.ts";
|
|
2
|
+
import { assessContextTelemetry, estimateCompactionDuration } from "../domain/context-telemetry.ts";
|
|
3
|
+
import type { MetricStore } from "../ports/metric-store.ts";
|
|
4
|
+
import type { OperationHandlerMap } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
/** context.assess and compaction.estimate -- both read-only projections of recorded metrics, no router/session involvement. */
|
|
7
|
+
export function contextOperations(metrics: MetricStore): OperationHandlerMap {
|
|
8
|
+
return {
|
|
9
|
+
"context.assess": (input) => {
|
|
10
|
+
const until = input["until"] === undefined ? Date.now() : input["until"];
|
|
11
|
+
const since = input["since"] === undefined && typeof until === "number" ? Math.max(0, until - CONTEXT_ASSESSMENT_DEFAULT_WINDOW_MS) : input["since"];
|
|
12
|
+
if (!Number.isSafeInteger(since) || !Number.isSafeInteger(until) || (since as number) < 0 || (until as number) < (since as number)) throw new Error("context assessment requires non-negative ordered integer bounds");
|
|
13
|
+
const query = { since: since as number, until: until as number, order: "asc" as const, limit: CONTEXT_ASSESSMENT_QUERY_LIMIT };
|
|
14
|
+
const injections = metrics.query({ ...query, source: "papyrus-context", metric: "injected-characters" });
|
|
15
|
+
const compactions = metrics.query({ ...query, source: "pi-context" });
|
|
16
|
+
return assessContextTelemetry(injections, compactions, {
|
|
17
|
+
since: since as number,
|
|
18
|
+
until: until as number,
|
|
19
|
+
truncated: injections.length >= CONTEXT_ASSESSMENT_QUERY_LIMIT || compactions.length >= CONTEXT_ASSESSMENT_QUERY_LIMIT,
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
"compaction.estimate": () => {
|
|
23
|
+
const rows = metrics.query({
|
|
24
|
+
source: "pi-context", scope: "compaction", metric: "compaction-duration",
|
|
25
|
+
order: "desc", limit: COMPACTION_DURATION_ESTIMATE_MAX_SAMPLES,
|
|
26
|
+
});
|
|
27
|
+
return estimateCompactionDuration(rows);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { MAX_USAGE_BUCKETS, METRIC_BATCH_MAX_OBSERVATIONS, PRUNE_MIN_AGE_MS, TASK_COST_QUERY_LIMIT, USAGE_MAX_DISTINCT_SCOPES } from "../constants.ts";
|
|
2
|
+
import { validateMetricObservation, type MetricQuery } from "../domain/metric.ts";
|
|
3
|
+
import { buildTaskCostSummary } from "../domain/task-cost.ts";
|
|
4
|
+
import type { MetricStore } from "../ports/metric-store.ts";
|
|
5
|
+
import type { OperationHandlerMap } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
/** metrics.* and service.checkpoint -- every operation whose only collaborator is the metric-store port. */
|
|
8
|
+
export function metricsOperations(metrics: MetricStore): OperationHandlerMap {
|
|
9
|
+
return {
|
|
10
|
+
"metrics.record": (input) => metrics.record(validateMetricObservation(input)),
|
|
11
|
+
"metrics.record_batch": (input) => {
|
|
12
|
+
const observations = input["observations"];
|
|
13
|
+
if (!Array.isArray(observations) || observations.length === 0) throw new Error("observations must be a non-empty array");
|
|
14
|
+
if (observations.length > METRIC_BATCH_MAX_OBSERVATIONS) throw new Error(`observations must contain at most ${METRIC_BATCH_MAX_OBSERVATIONS} entries`);
|
|
15
|
+
return metrics.recordBatch(observations.map((observation) => validateMetricObservation(observation)));
|
|
16
|
+
},
|
|
17
|
+
"metrics.query": (input) => metrics.query(input as MetricQuery),
|
|
18
|
+
"metrics.distinct_scopes": (input) => {
|
|
19
|
+
const source = input["source"];
|
|
20
|
+
const since = input["since"];
|
|
21
|
+
const until = input["until"];
|
|
22
|
+
if (typeof source !== "string" || source.length === 0) throw new Error("source is required");
|
|
23
|
+
if (!Number.isSafeInteger(since) || !Number.isSafeInteger(until) || (since as number) < 0 || (until as number) < (since as number)) {
|
|
24
|
+
throw new Error("distinct scopes requires non-negative ordered integer bounds");
|
|
25
|
+
}
|
|
26
|
+
const requestedLimit = input["limit"];
|
|
27
|
+
const limit = Number.isFinite(requestedLimit) ? Math.max(1, Math.min(USAGE_MAX_DISTINCT_SCOPES, Math.floor(requestedLimit as number))) : USAGE_MAX_DISTINCT_SCOPES;
|
|
28
|
+
return metrics.distinctScopes({ source, since: since as number, until: until as number, limit });
|
|
29
|
+
},
|
|
30
|
+
"metrics.usage_series": (input) => {
|
|
31
|
+
const source = input["source"];
|
|
32
|
+
const since = input["since"];
|
|
33
|
+
const until = input["until"];
|
|
34
|
+
const bucketSizeMs = input["bucketSizeMs"];
|
|
35
|
+
const bucketCount = input["bucketCount"];
|
|
36
|
+
if (typeof source !== "string" || source.length === 0) throw new Error("source is required");
|
|
37
|
+
if (!Number.isSafeInteger(since) || !Number.isSafeInteger(until) || (since as number) < 0 || (until as number) < (since as number)) {
|
|
38
|
+
throw new Error("usage series requires non-negative ordered integer bounds");
|
|
39
|
+
}
|
|
40
|
+
if (typeof bucketSizeMs !== "number" || !Number.isFinite(bucketSizeMs) || bucketSizeMs <= 0) throw new Error("bucketSizeMs must be a positive number");
|
|
41
|
+
if (!Number.isInteger(bucketCount) || (bucketCount as number) <= 0 || (bucketCount as number) > MAX_USAGE_BUCKETS) {
|
|
42
|
+
throw new Error(`bucketCount must be a positive integer up to ${MAX_USAGE_BUCKETS}`);
|
|
43
|
+
}
|
|
44
|
+
const requestedScopeLimit = input["scopeLimit"];
|
|
45
|
+
const scopeLimit = Number.isFinite(requestedScopeLimit) ? Math.max(1, Math.min(USAGE_MAX_DISTINCT_SCOPES, Math.floor(requestedScopeLimit as number))) : USAGE_MAX_DISTINCT_SCOPES;
|
|
46
|
+
const scopes = metrics.distinctScopes({ source, since: since as number, until: until as number, limit: scopeLimit });
|
|
47
|
+
// More distinct scopes may exist beyond this bounded list -- that is the only remaining
|
|
48
|
+
// truncation risk once aggregation replaces a per-scope raw-row fetch (see aggregateUsage's
|
|
49
|
+
// own doc comment for the incident this was built to stop repeating).
|
|
50
|
+
const truncated = scopes.length >= scopeLimit;
|
|
51
|
+
const rows = scopes.length === 0 ? [] : metrics.aggregateUsage({
|
|
52
|
+
source, scopes, since: since as number, until: until as number, bucketSizeMs, bucketCount: bucketCount as number,
|
|
53
|
+
});
|
|
54
|
+
return { rows, truncated };
|
|
55
|
+
},
|
|
56
|
+
"metrics.cost_by_task": (input) => {
|
|
57
|
+
const since = input["since"];
|
|
58
|
+
const until = input["until"];
|
|
59
|
+
if (!Number.isSafeInteger(since) || !Number.isSafeInteger(until) || (since as number) < 0 || (until as number) < (since as number)) {
|
|
60
|
+
throw new Error("cost by task requires non-negative ordered integer bounds");
|
|
61
|
+
}
|
|
62
|
+
const rows = metrics.query({ source: "pi", since: since as number, until: until as number, order: "desc", limit: TASK_COST_QUERY_LIMIT });
|
|
63
|
+
return buildTaskCostSummary(rows, { since: since as number, until: until as number, truncated: rows.length >= TASK_COST_QUERY_LIMIT });
|
|
64
|
+
},
|
|
65
|
+
"metrics.prune": (input) => {
|
|
66
|
+
const before = input["before"];
|
|
67
|
+
if (typeof before !== "number") throw new Error("before is required");
|
|
68
|
+
const force = input["force"] === true;
|
|
69
|
+
const minCutoff = Date.now() - PRUNE_MIN_AGE_MS;
|
|
70
|
+
if (!force && before > minCutoff) {
|
|
71
|
+
throw new Error(`refusing to prune metrics newer than ${new Date(minCutoff).toISOString()} without force: true (this looked like it could delete recent or live data)`);
|
|
72
|
+
}
|
|
73
|
+
return { deleted: metrics.pruneBefore(before) };
|
|
74
|
+
},
|
|
75
|
+
"service.checkpoint": () => { metrics.checkpoint(); return { ok: true }; },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ModelRanker, ModelRecommendationInput } from "../domain/model-ranking-service.ts";
|
|
2
|
+
import type { RouterController } from "../ports/router-controller.ts";
|
|
3
|
+
import type { OperationHandlerMap } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/** models.rank -- scores candidates via the model ranker, then, only for an automatic selection, applies it as a router mutation (so it shares the same session-identity authorization as every other router mutation). */
|
|
6
|
+
export function modelRankingOperations(modelRanker: ModelRanker, router: RouterController, authorize: (input: Record<string, unknown>) => string | undefined): OperationHandlerMap {
|
|
7
|
+
return {
|
|
8
|
+
"models.rank": (input) => {
|
|
9
|
+
const result = modelRanker.rank(input as unknown as ModelRecommendationInput);
|
|
10
|
+
if (result.automaticSelection && router.applyModelRanking) {
|
|
11
|
+
router.applyModelRanking(result.ranked.map((item) => item.candidate), authorize(input));
|
|
12
|
+
}
|
|
13
|
+
return result;
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { RouteOverride, RouterController } from "../ports/router-controller.ts";
|
|
2
|
+
import type { Route } from "../policy.ts";
|
|
3
|
+
import type { OperationHandlerMap } from "./types.ts";
|
|
4
|
+
import { routerSessionId } from "./session-scope.ts";
|
|
5
|
+
|
|
6
|
+
/** telemetry.poll and every router.* operation -- reads pass a bare session_id through; mutations run authorize first, matching the opt-in session-identity armor. */
|
|
7
|
+
export function routerOperations(router: RouterController, authorize: (input: Record<string, unknown>) => string | undefined): OperationHandlerMap {
|
|
8
|
+
return {
|
|
9
|
+
"telemetry.poll": () => router.poll(),
|
|
10
|
+
"router.status": (input) => router.status(routerSessionId(input)),
|
|
11
|
+
"router.decide": (input) => router.decide(routerSessionId(input)),
|
|
12
|
+
"router.pause": (input) => router.pause(authorize(input)),
|
|
13
|
+
"router.resume": (input) => router.resume(authorize(input)),
|
|
14
|
+
"router.override": (input) => router.setOverride(input as unknown as RouteOverride, authorize(input)),
|
|
15
|
+
"router.clear_override": (input) => router.clearOverride(authorize(input)),
|
|
16
|
+
"router.current_route": (input) => router.setCurrentRoute(input as unknown as Route, authorize(input)),
|
|
17
|
+
"router.available_routes": (input) => router.setAvailableRoutes(Array.isArray(input["routes"]) ? input["routes"] as Route[] : [], authorize(input)),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { SessionIdentity } from "../session-identity-service.ts";
|
|
2
|
+
import type { OperationHandlerMap } from "./types.ts";
|
|
3
|
+
import { requiredString, routerSessionSecret } from "./session-scope.ts";
|
|
4
|
+
|
|
5
|
+
/** session.register and session.release -- the only two operations that mutate SessionIdentity itself, distinct from the router mutations it later authorizes. */
|
|
6
|
+
export function sessionIdentityOperations(sessionIdentity: SessionIdentity | undefined): OperationHandlerMap {
|
|
7
|
+
const require = (): SessionIdentity => {
|
|
8
|
+
if (!sessionIdentity) throw new Error("session identity is not configured");
|
|
9
|
+
return sessionIdentity;
|
|
10
|
+
};
|
|
11
|
+
return {
|
|
12
|
+
"session.register": (input) => require().register(requiredString(input, "session_id")),
|
|
13
|
+
"session.release": (input) => require().release(requiredString(input, "session_id"), routerSessionSecret(input)),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { SessionIdentity } from "../session-identity-service.ts";
|
|
2
|
+
|
|
3
|
+
/** Shared input parsing for every operation that accepts an optional session_id/session_secret pair. */
|
|
4
|
+
export function routerSessionId(input: Record<string, unknown>): string | undefined {
|
|
5
|
+
const value = input["session_id"];
|
|
6
|
+
if (value === undefined) return undefined;
|
|
7
|
+
if (typeof value !== "string") throw new Error("session_id must be a string");
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function routerSessionSecret(input: Record<string, unknown>): string | undefined {
|
|
12
|
+
const value = input["session_secret"];
|
|
13
|
+
if (value === undefined) return undefined;
|
|
14
|
+
if (typeof value !== "string") throw new Error("session_secret must be a string");
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function requiredString(input: Record<string, unknown>, key: string): string {
|
|
19
|
+
const value = input[key];
|
|
20
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${key} is required`);
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Opt-in armor: a session_id never registered via session.register mutates exactly as before. Bound once per JittorService instance and shared by every router-mutating operation module. */
|
|
25
|
+
export function routerMutationAuthorizer(sessionIdentity: SessionIdentity | undefined): (input: Record<string, unknown>) => string | undefined {
|
|
26
|
+
return (input) => {
|
|
27
|
+
const sessionId = routerSessionId(input);
|
|
28
|
+
sessionIdentity?.assertAuthorized(sessionId, routerSessionSecret(input));
|
|
29
|
+
return sessionId;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** One capability module's contribution to the operation dispatch table: a bounded set of operation names, each backed by a handler that only needs the collaborators its own factory was given. */
|
|
2
|
+
export type OperationHandler = (input: Record<string, unknown>) => unknown | Promise<unknown>;
|
|
3
|
+
export type OperationHandlerMap = Partial<Record<string, OperationHandler>>;
|
|
@@ -20,6 +20,8 @@ export interface UsageAggregateFilter {
|
|
|
20
20
|
|
|
21
21
|
export interface MetricStore {
|
|
22
22
|
record(observation: MetricObservation): StoredMetricObservation;
|
|
23
|
+
/** Validates and writes every observation in one atomic transaction: either all rows land, or none do -- a single-event RPC loop can otherwise leave a partially-persisted event when a later observation in the same event fails validation or the connection drops mid-loop. */
|
|
24
|
+
recordBatch(observations: MetricObservation[]): StoredMetricObservation[];
|
|
23
25
|
query(filter?: MetricQuery): StoredMetricObservation[];
|
|
24
26
|
/** Bounded distinct scope values for a source within a time window, so callers can fetch a fair share per scope instead of one flat query a single heavy scope could monopolize. */
|
|
25
27
|
distinctScopes(filter: DistinctScopesFilter): string[];
|
|
@@ -31,13 +31,13 @@ export interface RouterStatus {
|
|
|
31
31
|
|
|
32
32
|
export interface RouterController {
|
|
33
33
|
poll(): Promise<TelemetryPollResult>;
|
|
34
|
-
status(): RouterStatus;
|
|
35
|
-
decide(): PolicyDecision;
|
|
36
|
-
pause(): RouterStatus;
|
|
37
|
-
resume(): RouterStatus;
|
|
38
|
-
setOverride(override?:
|
|
39
|
-
clearOverride(): RouterStatus;
|
|
40
|
-
setCurrentRoute(route: Route): RouterStatus;
|
|
41
|
-
setAvailableRoutes(routes: Route[]): RouterStatus;
|
|
42
|
-
applyModelRanking?(candidates: Route[]): RouterStatus;
|
|
34
|
+
status(sessionId?: string): RouterStatus;
|
|
35
|
+
decide(sessionId?: string): PolicyDecision;
|
|
36
|
+
pause(sessionId?: string): RouterStatus;
|
|
37
|
+
resume(sessionId?: string): RouterStatus;
|
|
38
|
+
setOverride(override: RouteOverride | undefined, sessionId?: string): RouterStatus;
|
|
39
|
+
clearOverride(sessionId?: string): RouterStatus;
|
|
40
|
+
setCurrentRoute(route: Route, sessionId?: string): RouterStatus;
|
|
41
|
+
setAvailableRoutes(routes: Route[], sessionId?: string): RouterStatus;
|
|
42
|
+
applyModelRanking?(candidates: Route[], sessionId?: string): RouterStatus;
|
|
43
43
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { SessionIdentityRecord, SessionIdentityStore as DaemonKitSessionIdentityStore } from "@danypops/daemon-kit/session-identity";
|
|
2
|
+
|
|
3
|
+
/** Jittor's persistence port for daemon-kit's storage-agnostic session-identity primitive. */
|
|
4
|
+
export type SessionIdentityStore = DaemonKitSessionIdentityStore;
|
|
5
|
+
export type { SessionIdentityRecord };
|
|
@@ -79,7 +79,7 @@ export class OpenRouterTelemetrySource implements TelemetrySource {
|
|
|
79
79
|
*/
|
|
80
80
|
export class GoogleVertexBudgetTelemetrySource implements TelemetrySource {
|
|
81
81
|
readonly id: string;
|
|
82
|
-
readonly provider
|
|
82
|
+
readonly provider: GoogleVertexMetricSource;
|
|
83
83
|
readonly required = false;
|
|
84
84
|
|
|
85
85
|
private readonly adapter: GoogleVertexBudgetTelemetryAdapter;
|
|
@@ -92,6 +92,7 @@ export class GoogleVertexBudgetTelemetrySource implements TelemetrySource {
|
|
|
92
92
|
source: GoogleVertexMetricSource = "google-vertex",
|
|
93
93
|
) {
|
|
94
94
|
this.id = `google-vertex-budget:${source}`;
|
|
95
|
+
this.provider = source;
|
|
95
96
|
this.adapter = new GoogleVertexBudgetTelemetryAdapter(subscription, tokenProvider, transport, source);
|
|
96
97
|
}
|
|
97
98
|
|
package/src/router.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ROUTER_MAX_SESSION_SCOPES, ROUTER_SESSION_ID_MAX_CHARACTERS } from "./constants.ts";
|
|
1
2
|
import type { MetricStore } from "./ports/metric-store.ts";
|
|
2
3
|
import type { RouteOverride, RouterController, RouterStatus, TelemetryPollResult, TelemetrySourceStatus } from "./ports/router-controller.ts";
|
|
3
4
|
import type { TelemetrySource } from "./ports/telemetry-source.ts";
|
|
@@ -22,22 +23,37 @@ function sameRoute(left: Route, right: Route): boolean {
|
|
|
22
23
|
return left.provider === right.provider && left.model === right.model && left.thinking === right.thinking;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
const GLOBAL_ROUTER_SCOPE = "global";
|
|
27
|
+
|
|
28
|
+
interface RouterSessionState {
|
|
29
|
+
currentRoute: Route;
|
|
30
|
+
availableRoutes: Route[];
|
|
31
|
+
lastDecision: PolicyDecision | null;
|
|
32
|
+
previousPolicyDecision: PolicyDecision | null;
|
|
33
|
+
paused: boolean;
|
|
34
|
+
override: RouteOverride | null;
|
|
35
|
+
lastAccess: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function routerScope(sessionId: string | undefined): string {
|
|
39
|
+
if (sessionId === undefined) return GLOBAL_ROUTER_SCOPE;
|
|
40
|
+
if (sessionId.length === 0 || sessionId.length > ROUTER_SESSION_ID_MAX_CHARACTERS) {
|
|
41
|
+
throw new Error(`session_id must contain 1-${ROUTER_SESSION_ID_MAX_CHARACTERS} characters`);
|
|
42
|
+
}
|
|
43
|
+
return sessionId;
|
|
44
|
+
}
|
|
45
|
+
|
|
25
46
|
export class JittorRouter implements RouterController {
|
|
26
47
|
private readonly clock: () => number;
|
|
27
48
|
private readonly windows = new Map<string, BudgetWindow[]>();
|
|
49
|
+
private readonly sessions = new Map<string, RouterSessionState>();
|
|
28
50
|
private sourceStatuses: TelemetrySourceStatus[] = [];
|
|
29
|
-
private lastDecision: PolicyDecision | null = null;
|
|
30
|
-
private previousPolicyDecision: PolicyDecision | null = null;
|
|
31
|
-
private paused = false;
|
|
32
|
-
private override: RouteOverride | null = null;
|
|
33
51
|
private inFlightPoll: Promise<TelemetryPollResult> | null = null;
|
|
34
|
-
private
|
|
35
|
-
private availableRoutes: Route[];
|
|
52
|
+
private accessSequence = 0;
|
|
36
53
|
|
|
37
54
|
constructor(private readonly options: JittorRouterOptions) {
|
|
38
55
|
this.clock = options.clock ?? Date.now;
|
|
39
|
-
this.
|
|
40
|
-
this.availableRoutes = structuredClone(options.routes);
|
|
56
|
+
this.sessions.set(GLOBAL_ROUTER_SCOPE, this.newSessionState());
|
|
41
57
|
}
|
|
42
58
|
|
|
43
59
|
poll(): Promise<TelemetryPollResult> {
|
|
@@ -45,91 +61,102 @@ export class JittorRouter implements RouterController {
|
|
|
45
61
|
return this.inFlightPoll;
|
|
46
62
|
}
|
|
47
63
|
|
|
48
|
-
status(): RouterStatus {
|
|
49
|
-
this.
|
|
64
|
+
status(sessionId?: string): RouterStatus {
|
|
65
|
+
const state = this.sessionState(sessionId);
|
|
66
|
+
this.expireOverride(state);
|
|
50
67
|
return {
|
|
51
|
-
ready: this.isReady(),
|
|
52
|
-
paused:
|
|
68
|
+
ready: this.isReady(state),
|
|
69
|
+
paused: state.paused,
|
|
53
70
|
sources: structuredClone(this.sourceStatuses),
|
|
54
|
-
lastDecision:
|
|
55
|
-
override:
|
|
56
|
-
currentRoute: structuredClone(
|
|
57
|
-
availableRoutes: structuredClone(
|
|
71
|
+
lastDecision: state.lastDecision ? structuredClone(state.lastDecision) : null,
|
|
72
|
+
override: state.override ? structuredClone(state.override) : null,
|
|
73
|
+
currentRoute: structuredClone(state.currentRoute),
|
|
74
|
+
availableRoutes: structuredClone(state.availableRoutes),
|
|
58
75
|
};
|
|
59
76
|
}
|
|
60
77
|
|
|
61
|
-
decide(): PolicyDecision {
|
|
78
|
+
decide(sessionId?: string): PolicyDecision {
|
|
62
79
|
const now = this.clock();
|
|
63
|
-
this.
|
|
64
|
-
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
const
|
|
80
|
+
const state = this.sessionState(sessionId);
|
|
81
|
+
this.expireOverride(state);
|
|
82
|
+
if (state.paused) return this.remember(state, { action: "halt", pressure: Number.POSITIVE_INFINITY, reason: "Jittor is paused", decidedAt: now, trace: ["manual pause"] });
|
|
83
|
+
if (state.override) {
|
|
84
|
+
const route = state.override.route;
|
|
85
|
+
const action = route.provider !== state.currentRoute.provider
|
|
68
86
|
? "switch-provider"
|
|
69
|
-
: route.model !==
|
|
87
|
+
: route.model !== state.currentRoute.model
|
|
70
88
|
? "switch-model"
|
|
71
|
-
: route.thinking !==
|
|
72
|
-
return this.remember({ action, route, pressure: 0, reason: "manual route override", decidedAt: now, trace: ["manual override"] });
|
|
89
|
+
: route.thinking !== state.currentRoute.thinking ? "lower-thinking" : "continue";
|
|
90
|
+
return this.remember(state, { action, route, pressure: 0, reason: "manual route override", decidedAt: now, trace: ["manual override"] });
|
|
91
|
+
}
|
|
92
|
+
if (!this.isReady(state)) return this.remember(state, { action: "halt", pressure: Number.POSITIVE_INFINITY, reason: "required telemetry is not ready", decidedAt: now, trace: ["fail closed"] });
|
|
93
|
+
const activeSources = this.options.sources.filter((source) => source.provider === state.currentRoute.provider);
|
|
94
|
+
const activeSourceIds = new Set(activeSources.map((source) => source.id));
|
|
95
|
+
const requiredSourceIds = new Set(activeSources.filter((source) => source.required).map((source) => source.id));
|
|
96
|
+
const activeWindows = [...this.windows.entries()].filter(([sourceId]) => activeSourceIds.has(sourceId));
|
|
97
|
+
const requiredWindows = activeWindows.filter(([sourceId]) => requiredSourceIds.has(sourceId)).flatMap(([, windows]) => windows);
|
|
98
|
+
if (requiredSourceIds.size === 0 && activeWindows.every(([, windows]) => windows.length === 0)) {
|
|
99
|
+
return this.rememberPolicy(state, { action: "continue", pressure: 0, reason: "provider has no enforceable budget window; monitor-only", decidedAt: now, trace: ["monitor-only"] });
|
|
73
100
|
}
|
|
74
|
-
|
|
75
|
-
const activeSourceIds = new Set(this.options.sources.filter((source) => source.provider === this.currentRoute.provider).map((source) => source.id));
|
|
76
|
-
return this.rememberPolicy(evaluateRoutingPolicy({
|
|
101
|
+
return this.rememberPolicy(state, evaluateRoutingPolicy({
|
|
77
102
|
now,
|
|
78
|
-
windows:
|
|
79
|
-
currentRoute:
|
|
80
|
-
routes:
|
|
103
|
+
windows: requiredSourceIds.size > 0 && requiredWindows.length === 0 ? [] : activeWindows.flatMap(([, windows]) => windows),
|
|
104
|
+
currentRoute: state.currentRoute,
|
|
105
|
+
routes: state.availableRoutes,
|
|
81
106
|
config: this.options.policy,
|
|
82
|
-
previousDecision:
|
|
107
|
+
previousDecision: state.previousPolicyDecision ?? undefined,
|
|
83
108
|
}));
|
|
84
109
|
}
|
|
85
110
|
|
|
86
|
-
pause(): RouterStatus {
|
|
87
|
-
this.paused = true;
|
|
88
|
-
return this.status();
|
|
111
|
+
pause(sessionId?: string): RouterStatus {
|
|
112
|
+
this.sessionState(sessionId).paused = true;
|
|
113
|
+
return this.status(sessionId);
|
|
89
114
|
}
|
|
90
115
|
|
|
91
|
-
resume(): RouterStatus {
|
|
92
|
-
this.paused = false;
|
|
93
|
-
return this.status();
|
|
116
|
+
resume(sessionId?: string): RouterStatus {
|
|
117
|
+
this.sessionState(sessionId).paused = false;
|
|
118
|
+
return this.status(sessionId);
|
|
94
119
|
}
|
|
95
120
|
|
|
96
|
-
setOverride(override?:
|
|
97
|
-
|
|
121
|
+
setOverride(override: RouteOverride | undefined, sessionId?: string): RouterStatus {
|
|
122
|
+
const state = this.sessionState(sessionId);
|
|
123
|
+
if (!override || !state.availableRoutes.some((route) => sameRoute(route, override.route))) throw new Error("override route is not available in Pi");
|
|
98
124
|
if (override.expiresAt !== null && override.expiresAt <= this.clock()) throw new Error("override expiry must be in the future");
|
|
99
|
-
|
|
100
|
-
return this.status();
|
|
125
|
+
state.override = structuredClone(override);
|
|
126
|
+
return this.status(sessionId);
|
|
101
127
|
}
|
|
102
128
|
|
|
103
|
-
clearOverride(): RouterStatus {
|
|
104
|
-
this.override = null;
|
|
105
|
-
return this.status();
|
|
129
|
+
clearOverride(sessionId?: string): RouterStatus {
|
|
130
|
+
this.sessionState(sessionId).override = null;
|
|
131
|
+
return this.status(sessionId);
|
|
106
132
|
}
|
|
107
133
|
|
|
108
|
-
setCurrentRoute(route: Route): RouterStatus {
|
|
134
|
+
setCurrentRoute(route: Route, sessionId?: string): RouterStatus {
|
|
109
135
|
if (!route.provider || !route.model || !route.thinking) throw new Error("current route is incomplete");
|
|
110
|
-
this.currentRoute = structuredClone(route);
|
|
111
|
-
return this.status();
|
|
136
|
+
this.sessionState(sessionId).currentRoute = structuredClone(route);
|
|
137
|
+
return this.status(sessionId);
|
|
112
138
|
}
|
|
113
139
|
|
|
114
|
-
setAvailableRoutes(routes: Route[]): RouterStatus {
|
|
140
|
+
setAvailableRoutes(routes: Route[], sessionId?: string): RouterStatus {
|
|
115
141
|
if (!Array.isArray(routes)) throw new Error("available routes must be an array");
|
|
116
142
|
const valid = routes.filter((route) => typeof route?.provider === "string" && route.provider.length > 0
|
|
117
143
|
&& typeof route.model === "string" && route.model.length > 0
|
|
118
144
|
&& typeof route.thinking === "string" && route.thinking.length > 0);
|
|
119
|
-
this.availableRoutes = valid.filter((route, index) => valid.findIndex((candidate) => sameRoute(candidate, route)) === index).map((route) => structuredClone(route));
|
|
120
|
-
return this.status();
|
|
145
|
+
this.sessionState(sessionId).availableRoutes = valid.filter((route, index) => valid.findIndex((candidate) => sameRoute(candidate, route)) === index).map((route) => structuredClone(route));
|
|
146
|
+
return this.status(sessionId);
|
|
121
147
|
}
|
|
122
148
|
|
|
123
|
-
applyModelRanking(candidates: Route[]): RouterStatus {
|
|
149
|
+
applyModelRanking(candidates: Route[], sessionId?: string): RouterStatus {
|
|
124
150
|
if (!Array.isArray(candidates) || candidates.length === 0) throw new Error("model ranking must contain candidates");
|
|
151
|
+
const state = this.sessionState(sessionId);
|
|
125
152
|
const ranked = candidates
|
|
126
153
|
.filter((candidate, index) => candidates.findIndex((other) => sameRoute(other, candidate)) === index)
|
|
127
|
-
.map((candidate) =>
|
|
154
|
+
.map((candidate) => state.availableRoutes.find((route) => sameRoute(route, candidate)))
|
|
128
155
|
.filter((route): route is Route => route !== undefined);
|
|
129
|
-
const current = ranked.find((route) => sameRoute(route,
|
|
156
|
+
const current = ranked.find((route) => sameRoute(route, state.currentRoute));
|
|
130
157
|
if (!current) throw new Error("model ranking does not contain the current available route");
|
|
131
|
-
|
|
132
|
-
return this.status();
|
|
158
|
+
state.availableRoutes = [structuredClone(current), ...ranked.filter((route) => !sameRoute(route, current)).map((route) => structuredClone(route))];
|
|
159
|
+
return this.status(sessionId);
|
|
133
160
|
}
|
|
134
161
|
|
|
135
162
|
private async runPoll(): Promise<TelemetryPollResult> {
|
|
@@ -148,23 +175,53 @@ export class JittorRouter implements RouterController {
|
|
|
148
175
|
return { sources: structuredClone(statuses), observedAt: this.clock() };
|
|
149
176
|
}
|
|
150
177
|
|
|
151
|
-
private
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
178
|
+
private newSessionState(): RouterSessionState {
|
|
179
|
+
return {
|
|
180
|
+
currentRoute: structuredClone(this.options.currentRoute),
|
|
181
|
+
availableRoutes: structuredClone(this.options.routes),
|
|
182
|
+
lastDecision: null,
|
|
183
|
+
previousPolicyDecision: null,
|
|
184
|
+
paused: false,
|
|
185
|
+
override: null,
|
|
186
|
+
lastAccess: ++this.accessSequence,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private sessionState(sessionId?: string): RouterSessionState {
|
|
191
|
+
const scope = routerScope(sessionId);
|
|
192
|
+
const existing = this.sessions.get(scope);
|
|
193
|
+
if (existing) {
|
|
194
|
+
existing.lastAccess = ++this.accessSequence;
|
|
195
|
+
return existing;
|
|
196
|
+
}
|
|
197
|
+
if (this.sessions.size >= ROUTER_MAX_SESSION_SCOPES) {
|
|
198
|
+
const oldest = [...this.sessions.entries()]
|
|
199
|
+
.filter(([key]) => key !== GLOBAL_ROUTER_SCOPE)
|
|
200
|
+
.sort((left, right) => left[1].lastAccess - right[1].lastAccess)[0];
|
|
201
|
+
if (oldest) this.sessions.delete(oldest[0]);
|
|
202
|
+
}
|
|
203
|
+
const created = this.newSessionState();
|
|
204
|
+
this.sessions.set(scope, created);
|
|
205
|
+
return created;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private isReady(state: RouterSessionState): boolean {
|
|
209
|
+
if (state.availableRoutes.length === 0) return false;
|
|
210
|
+
const required = this.options.sources.filter((source) => source.provider === state.currentRoute.provider && source.required);
|
|
211
|
+
return required.every((source) => this.sourceStatuses.some((status) => status.id === source.id && status.ok));
|
|
155
212
|
}
|
|
156
213
|
|
|
157
|
-
private expireOverride(): void {
|
|
158
|
-
if (
|
|
214
|
+
private expireOverride(state: RouterSessionState): void {
|
|
215
|
+
if (state.override?.expiresAt !== null && state.override && state.override.expiresAt <= this.clock()) state.override = null;
|
|
159
216
|
}
|
|
160
217
|
|
|
161
|
-
private remember(decision: PolicyDecision): PolicyDecision {
|
|
162
|
-
|
|
218
|
+
private remember(state: RouterSessionState, decision: PolicyDecision): PolicyDecision {
|
|
219
|
+
state.lastDecision = decision;
|
|
163
220
|
return structuredClone(decision);
|
|
164
221
|
}
|
|
165
222
|
|
|
166
|
-
private rememberPolicy(decision: PolicyDecision): PolicyDecision {
|
|
167
|
-
|
|
168
|
-
return this.remember(decision);
|
|
223
|
+
private rememberPolicy(state: RouterSessionState, decision: PolicyDecision): PolicyDecision {
|
|
224
|
+
state.previousPolicyDecision = decision;
|
|
225
|
+
return this.remember(state, decision);
|
|
169
226
|
}
|
|
170
227
|
}
|