@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
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseOpenRouterAnalytics,
|
|
3
|
+
parseOpenRouterGeneration,
|
|
4
|
+
parseOpenRouterKey,
|
|
5
|
+
parseOpenRouterModels,
|
|
6
|
+
type OpenRouterAnalyticsResult,
|
|
7
|
+
type OpenRouterGeneration,
|
|
8
|
+
type OpenRouterKeySnapshot,
|
|
9
|
+
type OpenRouterModel,
|
|
10
|
+
} from "./openrouter-contracts.ts";
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
openRouterUsageMetrics,
|
|
14
|
+
parseOpenRouterUsage,
|
|
15
|
+
type OpenRouterAnalyticsResult,
|
|
16
|
+
type OpenRouterGeneration,
|
|
17
|
+
type OpenRouterKeySnapshot,
|
|
18
|
+
type OpenRouterModel,
|
|
19
|
+
type OpenRouterUsage,
|
|
20
|
+
type OpenRouterUsageContext,
|
|
21
|
+
} from "./openrouter-contracts.ts";
|
|
22
|
+
|
|
23
|
+
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
|
24
|
+
export type OpenRouterTransport = (request: Request) => Promise<Response>;
|
|
25
|
+
|
|
26
|
+
export class OpenRouterTelemetryAdapter {
|
|
27
|
+
private managementCapability: boolean | undefined;
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
private readonly apiKey: string,
|
|
31
|
+
private readonly transport: OpenRouterTransport = fetch,
|
|
32
|
+
private readonly baseUrl = OPENROUTER_BASE_URL,
|
|
33
|
+
) {
|
|
34
|
+
if (apiKey.length === 0) throw new Error("OpenRouter API key is required");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async readKey(observedAt = Date.now()): Promise<OpenRouterKeySnapshot> {
|
|
38
|
+
const snapshot = parseOpenRouterKey(await this.request("/key"), observedAt);
|
|
39
|
+
this.managementCapability = snapshot.management;
|
|
40
|
+
return snapshot;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async listModels(): Promise<OpenRouterModel[]> {
|
|
44
|
+
return parseOpenRouterModels(await this.request("/models"));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async getGeneration(id: string): Promise<OpenRouterGeneration> {
|
|
48
|
+
if (id.length === 0) throw new Error("generation id is required");
|
|
49
|
+
return parseOpenRouterGeneration(await this.request(`/generation?id=${encodeURIComponent(id)}`));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async queryAnalytics(query: Record<string, unknown>): Promise<OpenRouterAnalyticsResult> {
|
|
53
|
+
if (this.managementCapability !== true) throw new Error("OpenRouter analytics requires a detected management key");
|
|
54
|
+
return parseOpenRouterAnalytics(await this.request("/analytics/query", {
|
|
55
|
+
method: "POST",
|
|
56
|
+
body: JSON.stringify(query),
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private async request(path: string, init: RequestInit = {}): Promise<unknown> {
|
|
61
|
+
const response = await this.transport(new Request(`${this.baseUrl}${path}`, {
|
|
62
|
+
...init,
|
|
63
|
+
headers: { authorization: `Bearer ${this.apiKey}`, "content-type": "application/json", ...init.headers },
|
|
64
|
+
}));
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
const retryAfter = response.headers.get("retry-after");
|
|
67
|
+
throw new Error(`OpenRouter ${path.split("?")[0]} failed with HTTP ${response.status}${retryAfter ? `; retry after ${retryAfter}` : ""}`);
|
|
68
|
+
}
|
|
69
|
+
return response.json();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { BudgetWindow } from "../policy.ts";
|
|
2
|
+
import type { TelemetryBatch, TelemetrySource } from "../ports/telemetry-source.ts";
|
|
3
|
+
import { CodexSubscriptionTelemetryAdapter, loadCodexFileCredentials, type CodexRateLimitSnapshot, type CodexWindow, type CodexTransport } from "./codex.ts";
|
|
4
|
+
import { OpenRouterTelemetryAdapter, type OpenRouterTransport } from "./openrouter.ts";
|
|
5
|
+
|
|
6
|
+
function budgetWindow(
|
|
7
|
+
limit: CodexRateLimitSnapshot,
|
|
8
|
+
name: "primary" | "secondary",
|
|
9
|
+
window: CodexWindow | null,
|
|
10
|
+
): BudgetWindow | null {
|
|
11
|
+
if (!window || window.windowSeconds === null || window.resetsAt === null) return null;
|
|
12
|
+
return {
|
|
13
|
+
id: `${limit.limitId}:${name}@${limit.observedAt}`,
|
|
14
|
+
source: "codex-subscription",
|
|
15
|
+
scope: limit.limitId === "codex" ? `codex:${name}` : `${limit.limitId}:${name}`,
|
|
16
|
+
usedFraction: window.usedPercent / 100,
|
|
17
|
+
windowSeconds: window.windowSeconds,
|
|
18
|
+
resetsAt: window.resetsAt * 1_000,
|
|
19
|
+
observedAt: limit.observedAt,
|
|
20
|
+
freshness: "fresh",
|
|
21
|
+
confidence: 0.8,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function windowsFromLimit(limit: CodexRateLimitSnapshot): BudgetWindow[] {
|
|
26
|
+
return [budgetWindow(limit, "primary", limit.primary), budgetWindow(limit, "secondary", limit.secondary)]
|
|
27
|
+
.filter((window): window is BudgetWindow => window !== null);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class CodexTelemetrySource implements TelemetrySource {
|
|
31
|
+
readonly id = "codex-subscription";
|
|
32
|
+
readonly provider = "openai-codex";
|
|
33
|
+
readonly required = true;
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
private readonly authFile: string,
|
|
37
|
+
private readonly transport: CodexTransport = fetch,
|
|
38
|
+
private readonly clock: () => number = Date.now,
|
|
39
|
+
) {}
|
|
40
|
+
|
|
41
|
+
async poll(): Promise<TelemetryBatch> {
|
|
42
|
+
const observedAt = this.clock();
|
|
43
|
+
const adapter = new CodexSubscriptionTelemetryAdapter(loadCodexFileCredentials(this.authFile), this.transport);
|
|
44
|
+
const snapshot = await adapter.readUsage(observedAt);
|
|
45
|
+
return {
|
|
46
|
+
observedAt,
|
|
47
|
+
metrics: snapshot.metrics,
|
|
48
|
+
windows: [snapshot.defaultLimit, ...snapshot.additionalLimits].flatMap(windowsFromLimit),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class OpenRouterTelemetrySource implements TelemetrySource {
|
|
54
|
+
readonly id = "openrouter";
|
|
55
|
+
readonly provider = "openrouter";
|
|
56
|
+
readonly required = false;
|
|
57
|
+
|
|
58
|
+
constructor(
|
|
59
|
+
private readonly apiKey: string,
|
|
60
|
+
private readonly transport: OpenRouterTransport = fetch,
|
|
61
|
+
private readonly clock: () => number = Date.now,
|
|
62
|
+
) {}
|
|
63
|
+
|
|
64
|
+
async poll(): Promise<TelemetryBatch> {
|
|
65
|
+
const observedAt = this.clock();
|
|
66
|
+
const snapshot = await new OpenRouterTelemetryAdapter(this.apiKey, this.transport).readKey(observedAt);
|
|
67
|
+
return { observedAt, metrics: snapshot.metrics, windows: [] };
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { MetricStore } from "./ports/metric-store.ts";
|
|
2
|
+
import type { RouteOverride, RouterController, RouterStatus, TelemetryPollResult, TelemetrySourceStatus } from "./ports/router-controller.ts";
|
|
3
|
+
import type { TelemetrySource } from "./ports/telemetry-source.ts";
|
|
4
|
+
import {
|
|
5
|
+
evaluateRoutingPolicy,
|
|
6
|
+
type BudgetWindow,
|
|
7
|
+
type PolicyConfig,
|
|
8
|
+
type PolicyDecision,
|
|
9
|
+
type Route,
|
|
10
|
+
} from "./policy.ts";
|
|
11
|
+
|
|
12
|
+
export interface JittorRouterOptions {
|
|
13
|
+
metrics: MetricStore;
|
|
14
|
+
sources: TelemetrySource[];
|
|
15
|
+
policy: PolicyConfig;
|
|
16
|
+
routes: Route[];
|
|
17
|
+
currentRoute: Route;
|
|
18
|
+
clock?: () => number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sameRoute(left: Route, right: Route): boolean {
|
|
22
|
+
return left.provider === right.provider && left.model === right.model && left.thinking === right.thinking;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class JittorRouter implements RouterController {
|
|
26
|
+
private readonly clock: () => number;
|
|
27
|
+
private readonly windows = new Map<string, BudgetWindow[]>();
|
|
28
|
+
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
|
+
private inFlightPoll: Promise<TelemetryPollResult> | null = null;
|
|
34
|
+
private currentRoute: Route;
|
|
35
|
+
private availableRoutes: Route[];
|
|
36
|
+
|
|
37
|
+
constructor(private readonly options: JittorRouterOptions) {
|
|
38
|
+
this.clock = options.clock ?? Date.now;
|
|
39
|
+
this.currentRoute = options.currentRoute;
|
|
40
|
+
this.availableRoutes = structuredClone(options.routes);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
poll(): Promise<TelemetryPollResult> {
|
|
44
|
+
this.inFlightPoll ??= this.runPoll().finally(() => { this.inFlightPoll = null; });
|
|
45
|
+
return this.inFlightPoll;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
status(): RouterStatus {
|
|
49
|
+
this.expireOverride();
|
|
50
|
+
return {
|
|
51
|
+
ready: this.isReady(),
|
|
52
|
+
paused: this.paused,
|
|
53
|
+
sources: structuredClone(this.sourceStatuses),
|
|
54
|
+
lastDecision: this.lastDecision ? structuredClone(this.lastDecision) : null,
|
|
55
|
+
override: this.override ? structuredClone(this.override) : null,
|
|
56
|
+
currentRoute: structuredClone(this.currentRoute),
|
|
57
|
+
availableRoutes: structuredClone(this.availableRoutes),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
decide(): PolicyDecision {
|
|
62
|
+
const now = this.clock();
|
|
63
|
+
this.expireOverride();
|
|
64
|
+
if (this.paused) return this.remember({ action: "halt", pressure: Number.POSITIVE_INFINITY, reason: "Jittor is paused", decidedAt: now, trace: ["manual pause"] });
|
|
65
|
+
if (this.override) {
|
|
66
|
+
const route = this.override.route;
|
|
67
|
+
const action = route.provider !== this.currentRoute.provider
|
|
68
|
+
? "switch-provider"
|
|
69
|
+
: route.model !== this.currentRoute.model
|
|
70
|
+
? "switch-model"
|
|
71
|
+
: route.thinking !== this.currentRoute.thinking ? "lower-thinking" : "continue";
|
|
72
|
+
return this.remember({ action, route, pressure: 0, reason: "manual route override", decidedAt: now, trace: ["manual override"] });
|
|
73
|
+
}
|
|
74
|
+
if (!this.isReady()) return this.remember({ action: "halt", pressure: Number.POSITIVE_INFINITY, reason: "required telemetry is not ready", decidedAt: now, trace: ["fail closed"] });
|
|
75
|
+
const activeSourceIds = new Set(this.options.sources.filter((source) => source.provider === this.currentRoute.provider).map((source) => source.id));
|
|
76
|
+
return this.rememberPolicy(evaluateRoutingPolicy({
|
|
77
|
+
now,
|
|
78
|
+
windows: [...this.windows.entries()].filter(([sourceId]) => activeSourceIds.has(sourceId)).flatMap(([, windows]) => windows),
|
|
79
|
+
currentRoute: this.currentRoute,
|
|
80
|
+
routes: this.availableRoutes,
|
|
81
|
+
config: this.options.policy,
|
|
82
|
+
previousDecision: this.previousPolicyDecision ?? undefined,
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
pause(): RouterStatus {
|
|
87
|
+
this.paused = true;
|
|
88
|
+
return this.status();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
resume(): RouterStatus {
|
|
92
|
+
this.paused = false;
|
|
93
|
+
return this.status();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setOverride(override?: RouteOverride): RouterStatus {
|
|
97
|
+
if (!override || !this.availableRoutes.some((route) => sameRoute(route, override.route))) throw new Error("override route is not available in Pi");
|
|
98
|
+
if (override.expiresAt !== null && override.expiresAt <= this.clock()) throw new Error("override expiry must be in the future");
|
|
99
|
+
this.override = structuredClone(override);
|
|
100
|
+
return this.status();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
clearOverride(): RouterStatus {
|
|
104
|
+
this.override = null;
|
|
105
|
+
return this.status();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
setCurrentRoute(route: Route): RouterStatus {
|
|
109
|
+
if (!route.provider || !route.model || !route.thinking) throw new Error("current route is incomplete");
|
|
110
|
+
this.currentRoute = structuredClone(route);
|
|
111
|
+
return this.status();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
setAvailableRoutes(routes: Route[]): RouterStatus {
|
|
115
|
+
if (!Array.isArray(routes)) throw new Error("available routes must be an array");
|
|
116
|
+
const valid = routes.filter((route) => typeof route?.provider === "string" && route.provider.length > 0
|
|
117
|
+
&& typeof route.model === "string" && route.model.length > 0
|
|
118
|
+
&& 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();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async runPoll(): Promise<TelemetryPollResult> {
|
|
124
|
+
const statuses = await Promise.all(this.options.sources.map(async (source): Promise<TelemetrySourceStatus> => {
|
|
125
|
+
try {
|
|
126
|
+
const batch = await source.poll();
|
|
127
|
+
for (const observation of batch.metrics) this.options.metrics.record(observation);
|
|
128
|
+
this.windows.set(source.id, batch.windows);
|
|
129
|
+
return { id: source.id, provider: source.provider, ok: true, metrics: batch.metrics.length, observedAt: batch.observedAt };
|
|
130
|
+
} catch {
|
|
131
|
+
this.windows.delete(source.id);
|
|
132
|
+
return { id: source.id, provider: source.provider, ok: false, metrics: 0, observedAt: this.clock(), error: "poll failed" };
|
|
133
|
+
}
|
|
134
|
+
}));
|
|
135
|
+
this.sourceStatuses = statuses;
|
|
136
|
+
return { sources: structuredClone(statuses), observedAt: this.clock() };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private isReady(): boolean {
|
|
140
|
+
const active = this.options.sources.filter((source) => source.provider === this.currentRoute.provider);
|
|
141
|
+
if (active.length === 0) return false;
|
|
142
|
+
return active.every((source) => this.sourceStatuses.some((status) => status.id === source.id && status.ok));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private expireOverride(): void {
|
|
146
|
+
if (this.override?.expiresAt !== null && this.override && this.override.expiresAt <= this.clock()) this.override = null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private remember(decision: PolicyDecision): PolicyDecision {
|
|
150
|
+
this.lastDecision = decision;
|
|
151
|
+
return structuredClone(decision);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private rememberPolicy(decision: PolicyDecision): PolicyDecision {
|
|
155
|
+
this.previousPolicyDecision = decision;
|
|
156
|
+
return this.remember(decision);
|
|
157
|
+
}
|
|
158
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { SERVICE_MAX_BODY_BYTES, VERSION } from "./constants.ts";
|
|
2
|
+
import { validateMetricObservation, type MetricObservation, type MetricQuery, type StoredMetricObservation } from "./domain/metric.ts";
|
|
3
|
+
import type { MetricStore } from "./ports/metric-store.ts";
|
|
4
|
+
import type { RouteOverride, RouterController, RouterStatus, TelemetryPollResult } from "./ports/router-controller.ts";
|
|
5
|
+
import type { PolicyDecision, Route } from "./policy.ts";
|
|
6
|
+
|
|
7
|
+
export const EXPECTED_OPERATION_NAMES = [
|
|
8
|
+
"metrics.record",
|
|
9
|
+
"metrics.query",
|
|
10
|
+
"metrics.prune",
|
|
11
|
+
"service.checkpoint",
|
|
12
|
+
"telemetry.poll",
|
|
13
|
+
"router.status",
|
|
14
|
+
"router.decide",
|
|
15
|
+
"router.pause",
|
|
16
|
+
"router.resume",
|
|
17
|
+
"router.override",
|
|
18
|
+
"router.clear_override",
|
|
19
|
+
"router.current_route",
|
|
20
|
+
"router.available_routes",
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
export type OperationName = typeof EXPECTED_OPERATION_NAMES[number];
|
|
24
|
+
export interface OperationInputs {
|
|
25
|
+
"metrics.record": MetricObservation;
|
|
26
|
+
"metrics.query": MetricQuery;
|
|
27
|
+
"metrics.prune": { before: number };
|
|
28
|
+
"service.checkpoint": Record<string, never>;
|
|
29
|
+
"telemetry.poll": Record<string, never>;
|
|
30
|
+
"router.status": Record<string, never>;
|
|
31
|
+
"router.decide": Record<string, never>;
|
|
32
|
+
"router.pause": Record<string, never>;
|
|
33
|
+
"router.resume": Record<string, never>;
|
|
34
|
+
"router.override": RouteOverride;
|
|
35
|
+
"router.clear_override": Record<string, never>;
|
|
36
|
+
"router.current_route": Route;
|
|
37
|
+
"router.available_routes": { routes: Route[] };
|
|
38
|
+
}
|
|
39
|
+
export interface OperationOutputs {
|
|
40
|
+
"metrics.record": StoredMetricObservation;
|
|
41
|
+
"metrics.query": StoredMetricObservation[];
|
|
42
|
+
"metrics.prune": { deleted: number };
|
|
43
|
+
"service.checkpoint": { ok: true };
|
|
44
|
+
"telemetry.poll": TelemetryPollResult;
|
|
45
|
+
"router.status": RouterStatus;
|
|
46
|
+
"router.decide": PolicyDecision;
|
|
47
|
+
"router.pause": RouterStatus;
|
|
48
|
+
"router.resume": RouterStatus;
|
|
49
|
+
"router.override": RouterStatus;
|
|
50
|
+
"router.clear_override": RouterStatus;
|
|
51
|
+
"router.current_route": RouterStatus;
|
|
52
|
+
"router.available_routes": RouterStatus;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class UnknownOperationError extends Error {}
|
|
56
|
+
|
|
57
|
+
class UnavailableRouter implements RouterController {
|
|
58
|
+
private readonly unavailable: RouterStatus = { ready: false, paused: false, sources: [], lastDecision: null, override: null, currentRoute: null, availableRoutes: [] };
|
|
59
|
+
async poll(): Promise<TelemetryPollResult> { return { sources: [], observedAt: Date.now() }; }
|
|
60
|
+
status(): RouterStatus { return structuredClone(this.unavailable); }
|
|
61
|
+
decide(): PolicyDecision { return { action: "halt", pressure: Number.POSITIVE_INFINITY, reason: "router is not configured", decidedAt: Date.now(), trace: ["fail closed"] }; }
|
|
62
|
+
pause(): RouterStatus { return this.status(); }
|
|
63
|
+
resume(): RouterStatus { return this.status(); }
|
|
64
|
+
setOverride(): RouterStatus { return this.status(); }
|
|
65
|
+
clearOverride(): RouterStatus { return this.status(); }
|
|
66
|
+
setCurrentRoute(): RouterStatus { return this.status(); }
|
|
67
|
+
setAvailableRoutes(): RouterStatus { return this.status(); }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class JittorService {
|
|
71
|
+
constructor(
|
|
72
|
+
private readonly metrics: MetricStore,
|
|
73
|
+
private readonly router: RouterController = new UnavailableRouter(),
|
|
74
|
+
) {}
|
|
75
|
+
|
|
76
|
+
operationNames(): OperationName[] {
|
|
77
|
+
return [...EXPECTED_OPERATION_NAMES];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async execute<Name extends OperationName>(operation: Name, input: OperationInputs[Name]): Promise<OperationOutputs[Name]>;
|
|
81
|
+
async execute(operation: string, input: Record<string, unknown>): Promise<unknown>;
|
|
82
|
+
async execute(operation: string, input: Record<string, unknown> = {}): Promise<unknown> {
|
|
83
|
+
switch (operation) {
|
|
84
|
+
case "metrics.record": return this.metrics.record(validateMetricObservation(input));
|
|
85
|
+
case "metrics.query": return this.metrics.query(input as MetricQuery);
|
|
86
|
+
case "metrics.prune": {
|
|
87
|
+
const before = input["before"];
|
|
88
|
+
if (typeof before !== "number") throw new Error("before is required");
|
|
89
|
+
return { deleted: this.metrics.pruneBefore(before) };
|
|
90
|
+
}
|
|
91
|
+
case "service.checkpoint": this.metrics.checkpoint(); return { ok: true };
|
|
92
|
+
case "telemetry.poll": return this.router.poll();
|
|
93
|
+
case "router.status": return this.router.status();
|
|
94
|
+
case "router.decide": return this.router.decide();
|
|
95
|
+
case "router.pause": return this.router.pause();
|
|
96
|
+
case "router.resume": return this.router.resume();
|
|
97
|
+
case "router.override": return this.router.setOverride(input as unknown as RouteOverride);
|
|
98
|
+
case "router.clear_override": return this.router.clearOverride();
|
|
99
|
+
case "router.current_route": return this.router.setCurrentRoute(input as unknown as Route);
|
|
100
|
+
case "router.available_routes": return this.router.setAvailableRoutes(Array.isArray(input["routes"]) ? input["routes"] as Route[] : []);
|
|
101
|
+
default: throw new UnknownOperationError(`unknown operation: ${operation}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
ready(): boolean {
|
|
106
|
+
return this.router.status().ready;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
close(): void {
|
|
110
|
+
this.metrics.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface JittorAppOptions {
|
|
115
|
+
service: JittorService;
|
|
116
|
+
token: string;
|
|
117
|
+
maxBodyBytes?: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function authorized(request: Request, token: string): boolean {
|
|
121
|
+
return request.headers.get("authorization") === `Bearer ${token}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function json(value: unknown, status = 200): Response {
|
|
125
|
+
return Response.json(value, { status });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function createApp(options: JittorAppOptions): { fetch(request: Request): Promise<Response> } {
|
|
129
|
+
const maxBodyBytes = options.maxBodyBytes ?? SERVICE_MAX_BODY_BYTES;
|
|
130
|
+
return {
|
|
131
|
+
async fetch(request: Request): Promise<Response> {
|
|
132
|
+
if (!authorized(request, options.token)) return json({ error: "unauthorized" }, 401);
|
|
133
|
+
const url = new URL(request.url);
|
|
134
|
+
if (request.method === "GET" && url.pathname === "/health") return json({ ok: true, version: VERSION });
|
|
135
|
+
if (request.method === "GET" && url.pathname === "/ready") {
|
|
136
|
+
const ready = options.service.ready();
|
|
137
|
+
return json({ ready }, ready ? 200 : 503);
|
|
138
|
+
}
|
|
139
|
+
if (request.method === "GET" && url.pathname === "/api/v1/ops") return json({ operations: options.service.operationNames() });
|
|
140
|
+
if (request.method !== "POST" || url.pathname !== "/api/v1/ops") return json({ error: "not found" }, 404);
|
|
141
|
+
const contentLength = Number(request.headers.get("content-length") ?? 0);
|
|
142
|
+
if (contentLength > maxBodyBytes) return json({ error: "payload too large" }, 413);
|
|
143
|
+
const text = await request.text();
|
|
144
|
+
if (new TextEncoder().encode(text).byteLength > maxBodyBytes) return json({ error: "payload too large" }, 413);
|
|
145
|
+
try {
|
|
146
|
+
const body = JSON.parse(text) as { op?: unknown; input?: unknown };
|
|
147
|
+
if (typeof body.op !== "string") throw new Error("op is required");
|
|
148
|
+
const input = typeof body.input === "object" && body.input !== null && !Array.isArray(body.input)
|
|
149
|
+
? body.input as Record<string, unknown>
|
|
150
|
+
: {};
|
|
151
|
+
return json({ result: await options.service.execute(body.op, input) });
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error instanceof UnknownOperationError) return json({ error: error.message }, 404);
|
|
154
|
+
return json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import {
|
|
6
|
+
DATABASE_FILENAME,
|
|
7
|
+
HANDLE_FILENAME,
|
|
8
|
+
JITTOR_STATE_DIRECTORY,
|
|
9
|
+
LOOPBACK_HOST,
|
|
10
|
+
SYSTEMD_UNIT_NAME,
|
|
11
|
+
TOKEN_FILENAME,
|
|
12
|
+
} from "./constants.ts";
|
|
13
|
+
|
|
14
|
+
export interface JittorPaths {
|
|
15
|
+
database: string;
|
|
16
|
+
token: string;
|
|
17
|
+
handle: string;
|
|
18
|
+
systemdUnit: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DaemonHandle {
|
|
22
|
+
host: typeof LOOPBACK_HOST;
|
|
23
|
+
port: number;
|
|
24
|
+
pid: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PathEnvironment {
|
|
28
|
+
env?: Record<string, string | undefined>;
|
|
29
|
+
home?: string;
|
|
30
|
+
uid?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveJittorPaths(options: PathEnvironment = {}): JittorPaths {
|
|
34
|
+
const env = options.env ?? process.env;
|
|
35
|
+
const home = options.home ?? homedir();
|
|
36
|
+
const uid = options.uid ?? process.getuid?.() ?? 0;
|
|
37
|
+
const dataHome = env["XDG_DATA_HOME"] ?? join(home, ".local", "share");
|
|
38
|
+
const stateHome = env["XDG_STATE_HOME"] ?? join(home, ".local", "state");
|
|
39
|
+
const runtimeHome = env["XDG_RUNTIME_DIR"] ?? join("/run", "user", String(uid));
|
|
40
|
+
const configHome = env["XDG_CONFIG_HOME"] ?? join(home, ".config");
|
|
41
|
+
return {
|
|
42
|
+
database: join(dataHome, JITTOR_STATE_DIRECTORY, DATABASE_FILENAME),
|
|
43
|
+
token: join(stateHome, JITTOR_STATE_DIRECTORY, TOKEN_FILENAME),
|
|
44
|
+
handle: join(runtimeHome, JITTOR_STATE_DIRECTORY, HANDLE_FILENAME),
|
|
45
|
+
systemdUnit: join(configHome, "systemd", "user", SYSTEMD_UNIT_NAME),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function ensureAuthToken(paths: JittorPaths = resolveJittorPaths()): string {
|
|
50
|
+
mkdirSync(dirname(paths.token), { recursive: true, mode: 0o700 });
|
|
51
|
+
if (existsSync(paths.token)) {
|
|
52
|
+
chmodSync(paths.token, 0o600);
|
|
53
|
+
const token = readFileSync(paths.token, "utf8").trim();
|
|
54
|
+
if (!/^[a-f0-9]{64}$/.test(token)) throw new Error("invalid Jittor authentication token");
|
|
55
|
+
return token;
|
|
56
|
+
}
|
|
57
|
+
const token = randomBytes(32).toString("hex");
|
|
58
|
+
writeFileSync(paths.token, `${token}\n`, { mode: 0o600 });
|
|
59
|
+
return token;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function writeDaemonHandle(paths: JittorPaths, handle: DaemonHandle): void {
|
|
63
|
+
mkdirSync(dirname(paths.handle), { recursive: true, mode: 0o700 });
|
|
64
|
+
const temporary = `${paths.handle}.${process.pid}.tmp`;
|
|
65
|
+
writeFileSync(temporary, `${JSON.stringify(handle)}\n`, { mode: 0o600 });
|
|
66
|
+
renameSync(temporary, paths.handle);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function readDaemonHandle(paths: JittorPaths = resolveJittorPaths()): DaemonHandle | null {
|
|
70
|
+
try {
|
|
71
|
+
const value = JSON.parse(readFileSync(paths.handle, "utf8")) as Partial<DaemonHandle>;
|
|
72
|
+
if (value.host !== LOOPBACK_HOST || !Number.isInteger(value.port) || value.port! < 1 || value.port! > 65_535 || !Number.isInteger(value.pid)) return null;
|
|
73
|
+
return value as DaemonHandle;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function removeDaemonHandle(paths: JittorPaths = resolveJittorPaths()): void {
|
|
80
|
+
rmSync(paths.handle, { force: true });
|
|
81
|
+
}
|