@evoclock/pi-agentic-driver 0.9.2 → 0.9.4
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/PROVENANCE.md +9 -0
- package/README.md +178 -73
- package/extensions/tasks.ts +570 -0
- package/janus/README.md +196 -0
- package/janus/janus/audit.ts +213 -0
- package/janus/janus/budget.ts +101 -0
- package/janus/janus/budget_breaker.ts +105 -0
- package/janus/janus/config.ts +332 -0
- package/janus/janus/errors.ts +90 -0
- package/janus/janus/evaluate.ts +230 -0
- package/janus/janus/keychain.ts +119 -0
- package/janus/janus/providers.ts +544 -0
- package/janus/janus/schema.ts +344 -0
- package/janus/janus/server.ts +267 -0
- package/janus/janus/single_flight.ts +99 -0
- package/janus/janus/verdict.ts +192 -0
- package/janus/package-lock.json +616 -0
- package/janus/package.json +29 -0
- package/janus/tests/accounting.test.ts +229 -0
- package/janus/tests/allowlist.test.ts +97 -0
- package/janus/tests/config.test.ts +151 -0
- package/janus/tests/errors.test.ts +147 -0
- package/janus/tests/evaluate_pipeline.test.ts +358 -0
- package/janus/tests/helpers.ts +217 -0
- package/janus/tests/providers.test.ts +292 -0
- package/janus/tests/redaction_budget.test.ts +227 -0
- package/janus/tests/schema.test.ts +356 -0
- package/janus/tests/server.test.ts +288 -0
- package/janus/tests/single_flight.test.ts +84 -0
- package/janus/tsconfig.json +19 -0
- package/janus/user.janus.plist +51 -0
- package/package.json +7 -2
- package/scripts/enforcement/router_schemas_pi.js +63 -13
- package/scripts/enforcement/session_tasks_core_pi.js +605 -0
- package/scripts/enforcement/session_tasks_validate_pi.js +235 -0
- package/skills/tasks/SKILL.md +110 -0
- package/templates/AGENTS.md +11 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// Question schema contract v2 enforcement (§6).
|
|
5
|
+
//
|
|
6
|
+
// The request is ATOMIC: any malformed, absent, or contradictory answer fails
|
|
7
|
+
// the entire request with validation_error; the caller applies its failure
|
|
8
|
+
// policy for the whole batch (fail closed for the binding gate, fail open for
|
|
9
|
+
// advisory uses).
|
|
10
|
+
//
|
|
11
|
+
// Question identity: every question carries a REQUIRED caller-supplied
|
|
12
|
+
// `name` — a stable, non-sensitive identifier used for answer keying and
|
|
13
|
+
// audit records. The question TEXT itself is separate and is always sent to
|
|
14
|
+
// the provider verbatim; it never enters the audit log.
|
|
15
|
+
//
|
|
16
|
+
// Provider selection is configuration-only: request-level `model` and
|
|
17
|
+
// `provider` fields are rejected outright (M8).
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
MAX_CHOICES,
|
|
21
|
+
MAX_INSTRUCTIONS_CHARS,
|
|
22
|
+
MAX_QUESTIONS,
|
|
23
|
+
SCORE_LEVELS,
|
|
24
|
+
} from "./config.js";
|
|
25
|
+
import { validationError } from "./errors.js";
|
|
26
|
+
|
|
27
|
+
export type RawQuestion = {
|
|
28
|
+
type: unknown;
|
|
29
|
+
name?: unknown;
|
|
30
|
+
question?: unknown;
|
|
31
|
+
choices?: unknown;
|
|
32
|
+
levels?: unknown;
|
|
33
|
+
threshold?: unknown;
|
|
34
|
+
instructions?: unknown;
|
|
35
|
+
criteria?: unknown;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export interface BooleanQuestion {
|
|
39
|
+
kind: "boolean";
|
|
40
|
+
name: string;
|
|
41
|
+
question: string;
|
|
42
|
+
threshold: number;
|
|
43
|
+
instructions: string;
|
|
44
|
+
sdkCriteria?: { true?: string | null; false?: string | null } | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ChoiceQuestion {
|
|
48
|
+
kind: "choice";
|
|
49
|
+
name: string;
|
|
50
|
+
question: string;
|
|
51
|
+
choices: string[];
|
|
52
|
+
instructions: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ScoreQuestion {
|
|
56
|
+
kind: "score";
|
|
57
|
+
name: string;
|
|
58
|
+
question: string;
|
|
59
|
+
levels: number;
|
|
60
|
+
instructions: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type EnforcedQuestion = BooleanQuestion | ChoiceQuestion | ScoreQuestion;
|
|
64
|
+
|
|
65
|
+
export interface EnforcedRequest {
|
|
66
|
+
state: unknown;
|
|
67
|
+
timeoutMs?: number | undefined;
|
|
68
|
+
questions: EnforcedQuestion[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const DEFAULT_BOOLEAN_THRESHOLD = 0.9;
|
|
72
|
+
/** Maximum characters for a caller-supplied question name. */
|
|
73
|
+
export const MAX_QUESTION_NAME_CHARS = 256;
|
|
74
|
+
/** Maximum characters for the question text itself. */
|
|
75
|
+
export const MAX_QUESTION_TEXT_CHARS = 20_000;
|
|
76
|
+
|
|
77
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
78
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Enforce the required, non-sensitive question identifier. */
|
|
82
|
+
function enforceQuestionName(raw: RawQuestion, index: number): string {
|
|
83
|
+
const name = raw.name;
|
|
84
|
+
if (typeof name !== "string" || name.trim().length === 0) {
|
|
85
|
+
throw validationError(
|
|
86
|
+
`questions[${index}]: name is required — a nonempty, non-sensitive identifier used for answer keying and audit`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (name.length > MAX_QUESTION_NAME_CHARS) {
|
|
90
|
+
throw validationError(
|
|
91
|
+
`questions[${index}]: name exceeds ${MAX_QUESTION_NAME_CHARS} characters`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return name;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Enforce the required question text. It is always sent to the provider. */
|
|
98
|
+
function enforceQuestionText(raw: RawQuestion, name: string): string {
|
|
99
|
+
const question = raw.question;
|
|
100
|
+
if (typeof question !== "string" || question.trim().length === 0) {
|
|
101
|
+
throw validationError(
|
|
102
|
+
`question ${JSON.stringify(name)}: question text is required and must be a nonempty string`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (question.length > MAX_QUESTION_TEXT_CHARS) {
|
|
106
|
+
throw validationError(
|
|
107
|
+
`question ${JSON.stringify(name)}: question text exceeds ${MAX_QUESTION_TEXT_CHARS} characters`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return question;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function enforceInstructions(raw: RawQuestion, name: string): string {
|
|
114
|
+
const instructions = raw.instructions;
|
|
115
|
+
if (instructions === undefined) return "";
|
|
116
|
+
if (typeof instructions !== "string") {
|
|
117
|
+
throw validationError(
|
|
118
|
+
`question ${JSON.stringify(name)}: instructions must be a string`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (instructions.length > MAX_INSTRUCTIONS_CHARS) {
|
|
122
|
+
throw validationError(
|
|
123
|
+
`question ${JSON.stringify(name)}: instructions exceed ${MAX_INSTRUCTIONS_CHARS} characters`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return instructions;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function enforceBoolean(raw: RawQuestion, name: string): BooleanQuestion {
|
|
130
|
+
let threshold = DEFAULT_BOOLEAN_THRESHOLD;
|
|
131
|
+
if (raw.threshold !== undefined) {
|
|
132
|
+
if (
|
|
133
|
+
typeof raw.threshold !== "number" ||
|
|
134
|
+
!Number.isFinite(raw.threshold) ||
|
|
135
|
+
raw.threshold < 0 ||
|
|
136
|
+
raw.threshold > 1
|
|
137
|
+
) {
|
|
138
|
+
throw validationError(
|
|
139
|
+
`question ${JSON.stringify(name)}: threshold must be a number in [0, 1]`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
threshold = raw.threshold;
|
|
143
|
+
}
|
|
144
|
+
const instructions = enforceInstructions(raw, name);
|
|
145
|
+
|
|
146
|
+
// Optional boolean criteria pair (SDK shape: {true?, false?}).
|
|
147
|
+
let sdkCriteria: BooleanQuestion["sdkCriteria"];
|
|
148
|
+
if (raw.criteria !== undefined) {
|
|
149
|
+
if (!isPlainObject(raw.criteria)) {
|
|
150
|
+
throw validationError(
|
|
151
|
+
`question ${JSON.stringify(name)}: criteria must be an object with optional true/false labels`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const allowed = new Set(["true", "false"]);
|
|
155
|
+
for (const key of Object.keys(raw.criteria)) {
|
|
156
|
+
if (!allowed.has(key)) {
|
|
157
|
+
throw validationError(
|
|
158
|
+
`question ${JSON.stringify(name)}: criteria.${key} is unknown; only true/false labels are accepted`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const trueLabel = raw.criteria.true;
|
|
163
|
+
const falseLabel = raw.criteria.false;
|
|
164
|
+
if (trueLabel !== undefined && typeof trueLabel !== "string" && trueLabel !== null) {
|
|
165
|
+
throw validationError(
|
|
166
|
+
`question ${JSON.stringify(name)}: criteria.true must be a string or null`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (falseLabel !== undefined && typeof falseLabel !== "string" && falseLabel !== null) {
|
|
170
|
+
throw validationError(
|
|
171
|
+
`question ${JSON.stringify(name)}: criteria.false must be a string or null`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
sdkCriteria = {};
|
|
175
|
+
if (trueLabel !== undefined) sdkCriteria.true = trueLabel;
|
|
176
|
+
if (falseLabel !== undefined) sdkCriteria.false = falseLabel;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
kind: "boolean",
|
|
181
|
+
name,
|
|
182
|
+
question: enforceQuestionText(raw, name),
|
|
183
|
+
threshold,
|
|
184
|
+
instructions,
|
|
185
|
+
sdkCriteria,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function enforceChoice(raw: RawQuestion, name: string): ChoiceQuestion {
|
|
190
|
+
const instructions = enforceInstructions(raw, name);
|
|
191
|
+
|
|
192
|
+
// §6: the candidate set is REQUIRED in the schema.
|
|
193
|
+
const choices = raw.choices;
|
|
194
|
+
if (!Array.isArray(choices) || choices.length === 0) {
|
|
195
|
+
throw validationError(
|
|
196
|
+
`question ${JSON.stringify(name)}: choice questions require a nonempty choices array`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
if (!choices.every((c) => typeof c === "string" && c.length > 0)) {
|
|
200
|
+
throw validationError(
|
|
201
|
+
`question ${JSON.stringify(name)}: every choice must be a nonempty string`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (new Set(choices).size !== choices.length) {
|
|
205
|
+
throw validationError(
|
|
206
|
+
`question ${JSON.stringify(name)}: choices must be unique`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (choices.length > MAX_CHOICES) {
|
|
210
|
+
throw validationError(
|
|
211
|
+
`question ${JSON.stringify(name)}: more than ${MAX_CHOICES} choices`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
kind: "choice",
|
|
216
|
+
name,
|
|
217
|
+
question: enforceQuestionText(raw, name),
|
|
218
|
+
choices,
|
|
219
|
+
instructions,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function enforceScore(raw: RawQuestion, name: string): ScoreQuestion {
|
|
224
|
+
const instructions = enforceInstructions(raw, name);
|
|
225
|
+
|
|
226
|
+
// §6: score is numeric 0..5, one decimal. Ordinal vocabularies must be
|
|
227
|
+
// choice questions, not scores; there is no per-question override.
|
|
228
|
+
const levels = SCORE_LEVELS;
|
|
229
|
+
if (raw.levels !== undefined || raw.choices !== undefined) {
|
|
230
|
+
throw validationError(
|
|
231
|
+
`question ${JSON.stringify(name)}: score questions have a fixed 0..5 vocabulary; ` +
|
|
232
|
+
`use a choice question for ordinal labels`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
kind: "score",
|
|
237
|
+
name,
|
|
238
|
+
question: enforceQuestionText(raw, name),
|
|
239
|
+
levels,
|
|
240
|
+
instructions,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function enforceRequest(body: unknown): EnforcedRequest {
|
|
245
|
+
if (!isPlainObject(body)) {
|
|
246
|
+
throw validationError("request body must be a JSON object");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Closed request validation: unknown top-level fields are rejected, not
|
|
250
|
+
// ignored. `model` and `provider` are deliberately absent — provider
|
|
251
|
+
// selection is configuration-only (M8).
|
|
252
|
+
const allowed = new Set(["state", "timeoutMs", "questions"]);
|
|
253
|
+
for (const key of Object.keys(body)) {
|
|
254
|
+
if (!allowed.has(key)) {
|
|
255
|
+
throw validationError(
|
|
256
|
+
`unknown request field ${JSON.stringify(key)}; allowed fields: state, timeoutMs, questions`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const state = body.state;
|
|
262
|
+
if (state === undefined) {
|
|
263
|
+
throw validationError("state is required");
|
|
264
|
+
}
|
|
265
|
+
// Strings, plain objects, and arrays (message histories pass directly).
|
|
266
|
+
if (
|
|
267
|
+
typeof state !== "string" &&
|
|
268
|
+
!isPlainObject(state) &&
|
|
269
|
+
!Array.isArray(state)
|
|
270
|
+
) {
|
|
271
|
+
throw validationError("state must be a string, object, or array");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let timeoutMs: number | undefined;
|
|
275
|
+
if (body.timeoutMs !== undefined) {
|
|
276
|
+
if (
|
|
277
|
+
typeof body.timeoutMs !== "number" ||
|
|
278
|
+
!Number.isFinite(body.timeoutMs) ||
|
|
279
|
+
!Number.isInteger(body.timeoutMs) ||
|
|
280
|
+
body.timeoutMs <= 0
|
|
281
|
+
) {
|
|
282
|
+
throw validationError("timeoutMs must be a positive integer (milliseconds)");
|
|
283
|
+
}
|
|
284
|
+
timeoutMs = body.timeoutMs;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const rawQuestions = body.questions;
|
|
288
|
+
if (!Array.isArray(rawQuestions) || rawQuestions.length === 0) {
|
|
289
|
+
throw validationError("questions must be a nonempty array");
|
|
290
|
+
}
|
|
291
|
+
if (rawQuestions.length > MAX_QUESTIONS) {
|
|
292
|
+
throw validationError(`more than ${MAX_QUESTIONS} questions in one request`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const questions: EnforcedQuestion[] = rawQuestions.map((entry, index) => {
|
|
296
|
+
if (!isPlainObject(entry)) {
|
|
297
|
+
throw validationError(`questions[${index}] must be an object`);
|
|
298
|
+
}
|
|
299
|
+
const raw = entry as RawQuestion;
|
|
300
|
+
// Closed per-question validation: unknown fields rejected, never ignored.
|
|
301
|
+
const allowedQuestionFields = new Set([
|
|
302
|
+
"type",
|
|
303
|
+
"name",
|
|
304
|
+
"question",
|
|
305
|
+
"choices",
|
|
306
|
+
"threshold",
|
|
307
|
+
"instructions",
|
|
308
|
+
"criteria",
|
|
309
|
+
]);
|
|
310
|
+
for (const key of Object.keys(raw)) {
|
|
311
|
+
if (!allowedQuestionFields.has(key)) {
|
|
312
|
+
throw validationError(
|
|
313
|
+
`questions[${index}]: unknown field ${JSON.stringify(key)}`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const name = enforceQuestionName(raw, index);
|
|
318
|
+
switch (raw.type) {
|
|
319
|
+
case "boolean":
|
|
320
|
+
return enforceBoolean(raw, name);
|
|
321
|
+
case "choice":
|
|
322
|
+
return enforceChoice(raw, name);
|
|
323
|
+
case "score":
|
|
324
|
+
return enforceScore(raw, name);
|
|
325
|
+
default:
|
|
326
|
+
throw validationError(
|
|
327
|
+
`questions[${index}]: type must be one of "boolean", "choice", "score"`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// Duplicate question names would make the answer map ambiguous.
|
|
333
|
+
const seen = new Set<string>();
|
|
334
|
+
for (const q of questions) {
|
|
335
|
+
if (seen.has(q.name)) {
|
|
336
|
+
throw validationError(
|
|
337
|
+
`duplicate question name: ${JSON.stringify(q.name)}`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
seen.add(q.name);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return { state, timeoutMs, questions };
|
|
344
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// janus HTTP surface (§5): POST /evaluate, GET /healthz, GET /readyz,
|
|
5
|
+
// GET /version. Binds 127.0.0.1 ONLY (H4) — the bind address is not
|
|
6
|
+
// configurable by design. No auth beyond loopback: it is a local tool.
|
|
7
|
+
//
|
|
8
|
+
// Review fixes #5 and #10:
|
|
9
|
+
// - Content-Type is parsed properly (RFC 9110): `application/json` with
|
|
10
|
+
// parameters such as `charset=utf-8` is accepted; a missing header,
|
|
11
|
+
// malformed type, or any non-JSON type is rejected with validation_error.
|
|
12
|
+
// - An oversized body is drained and answered with a structured JSON
|
|
13
|
+
// validation_error whenever safely possible.
|
|
14
|
+
// - Every error body uses a FIXED public message; internal error text
|
|
15
|
+
// (including `error.message` from unexpected exceptions) never reaches
|
|
16
|
+
// an HTTP caller.
|
|
17
|
+
|
|
18
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
19
|
+
import { JANUS_VERSION, QUESTION_SCHEMA_VERSION, RESPONSE_SCHEMA_VERSION, type JanusConfig } from "./config.js";
|
|
20
|
+
import { TYPESAFE_SDK_VERSION, ProviderChain } from "./providers.js";
|
|
21
|
+
import { Evaluator, buildProviderChain, type EvaluateResponse } from "./evaluate.js";
|
|
22
|
+
import { SingleFlight } from "./single_flight.js";
|
|
23
|
+
import { DailyBudget } from "./budget_breaker.js";
|
|
24
|
+
import { AuditLog } from "./audit.js";
|
|
25
|
+
|
|
26
|
+
const MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
export interface JanusService {
|
|
29
|
+
server: Server;
|
|
30
|
+
port: number;
|
|
31
|
+
stop: () => Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
|
|
35
|
+
const body = JSON.stringify(payload);
|
|
36
|
+
res.writeHead(status, {
|
|
37
|
+
"content-type": "application/json; charset=utf-8",
|
|
38
|
+
"content-length": Buffer.byteLength(body),
|
|
39
|
+
});
|
|
40
|
+
res.end(body);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function validationBody(message: string): Record<string, unknown> {
|
|
44
|
+
return { error: "validation_error", message, request_id: "n/a" };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse a Content-Type header per RFC 9110 and decide whether the body is
|
|
49
|
+
* JSON. Returns the accepted type or a fixed public rejection reason.
|
|
50
|
+
*/
|
|
51
|
+
export function parseJsonContentType(header: string | undefined):
|
|
52
|
+
| { ok: true }
|
|
53
|
+
| { ok: false; reason: "missing" | "malformed" | "not-json" } {
|
|
54
|
+
if (header === undefined) return { ok: false, reason: "missing" };
|
|
55
|
+
// type = token "/" token *( OWS ";" OWS parameter )
|
|
56
|
+
const match = /^\s*([!#$%&'*+\-.^_`|~0-9A-Za-z]+)\/([!#$%&'*+\-.^_`|~0-9A-Za-z]+)\s*(?:;.*)?$/.exec(header);
|
|
57
|
+
if (match === null) return { ok: false, reason: "malformed" };
|
|
58
|
+
const type = `${match[1]!.toLowerCase()}/${match[2]!.toLowerCase()}`;
|
|
59
|
+
if (type !== "application/json") return { ok: false, reason: "not-json" };
|
|
60
|
+
return { ok: true };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readBody(req: IncomingMessage): Promise<Buffer> {
|
|
64
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
65
|
+
const chunks: Buffer[] = [];
|
|
66
|
+
let total = 0;
|
|
67
|
+
let overLimit = false;
|
|
68
|
+
// Over-limit bodies are DRAINED (chunks discarded, stream consumed) so
|
|
69
|
+
// the structured 413 JSON response can still be delivered safely
|
|
70
|
+
// (review fix #10). A hard runaway ceiling destroys the connection.
|
|
71
|
+
const RUNAWAY_BYTES = 64 * 1024 * 1024;
|
|
72
|
+
req.on("data", (chunk: Buffer) => {
|
|
73
|
+
total += chunk.length;
|
|
74
|
+
if (total > MAX_BODY_BYTES) {
|
|
75
|
+
overLimit = true;
|
|
76
|
+
chunks.length = 0;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
chunks.push(chunk);
|
|
80
|
+
});
|
|
81
|
+
req.on("end", () => {
|
|
82
|
+
if (overLimit) rejectPromise(new BodyTooLargeError());
|
|
83
|
+
else resolvePromise(Buffer.concat(chunks));
|
|
84
|
+
});
|
|
85
|
+
req.on("error", (error) => {
|
|
86
|
+
if (total > RUNAWAY_BYTES) req.destroy();
|
|
87
|
+
if (!overLimit) rejectPromise(error);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
class BodyTooLargeError extends Error {
|
|
93
|
+
constructor() {
|
|
94
|
+
super("request body too large");
|
|
95
|
+
this.name = "BodyTooLargeError";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createJanusService(
|
|
100
|
+
config: JanusConfig,
|
|
101
|
+
evaluator: Evaluator,
|
|
102
|
+
readiness: () => Promise<{ ready: boolean; reason?: string }>,
|
|
103
|
+
): JanusService {
|
|
104
|
+
const server = createServer((req, res) => {
|
|
105
|
+
void handleRequest(req, res, config, evaluator, readiness);
|
|
106
|
+
});
|
|
107
|
+
// Loopback only, by construction (H4). There is no bind-address option.
|
|
108
|
+
server.listen(config.port, "127.0.0.1");
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
server,
|
|
112
|
+
port: config.port,
|
|
113
|
+
stop: () =>
|
|
114
|
+
new Promise((resolvePromise) => {
|
|
115
|
+
server.close(() => resolvePromise());
|
|
116
|
+
}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function handleRequest(
|
|
121
|
+
req: IncomingMessage,
|
|
122
|
+
res: ServerResponse,
|
|
123
|
+
config: JanusConfig,
|
|
124
|
+
evaluator: Evaluator,
|
|
125
|
+
readiness: () => Promise<{ ready: boolean; reason?: string }>,
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${config.port}`);
|
|
128
|
+
const route = `${req.method} ${url.pathname}`;
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
if (route === "GET /healthz") {
|
|
132
|
+
sendJson(res, 200, { status: "ok", version: JANUS_VERSION });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (route === "GET /readyz") {
|
|
137
|
+
const keyState = await readiness();
|
|
138
|
+
if (keyState.ready) {
|
|
139
|
+
sendJson(res, 200, { status: "ready", version: JANUS_VERSION });
|
|
140
|
+
} else {
|
|
141
|
+
sendJson(res, 503, {
|
|
142
|
+
status: "not_ready",
|
|
143
|
+
version: JANUS_VERSION,
|
|
144
|
+
// Fixed public reason only (review fix #5).
|
|
145
|
+
reason: keyState.reason ?? "no provider credential resolvable",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (route === "GET /version") {
|
|
152
|
+
sendJson(res, 200, {
|
|
153
|
+
janus: JANUS_VERSION,
|
|
154
|
+
typesafeSdk: TYPESAFE_SDK_VERSION,
|
|
155
|
+
questionSchema: QUESTION_SCHEMA_VERSION,
|
|
156
|
+
responseSchema: RESPONSE_SCHEMA_VERSION,
|
|
157
|
+
providers: config.providers.map((p) => p.id),
|
|
158
|
+
defaultModel: config.defaultModel,
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (route === "POST /evaluate") {
|
|
164
|
+
// Content-Type must be a properly parsed application/json (parameters
|
|
165
|
+
// such as charset are allowed) — review fix #10.
|
|
166
|
+
const contentType = parseJsonContentType(req.headers["content-type"]);
|
|
167
|
+
if (!contentType.ok) {
|
|
168
|
+
const reason =
|
|
169
|
+
contentType.reason === "missing"
|
|
170
|
+
? "content-type header is required and must be application/json"
|
|
171
|
+
: contentType.reason === "malformed"
|
|
172
|
+
? "content-type header is malformed"
|
|
173
|
+
: "content-type must be application/json";
|
|
174
|
+
sendJson(res, 400, validationBody(reason));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
let body: unknown;
|
|
178
|
+
try {
|
|
179
|
+
const raw = await readBody(req);
|
|
180
|
+
if (raw.length === 0) {
|
|
181
|
+
sendJson(res, 400, validationBody("request body is required"));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
body = JSON.parse(raw.toString("utf8"));
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error instanceof BodyTooLargeError) {
|
|
187
|
+
// Structured JSON even for oversized bodies, when safely possible.
|
|
188
|
+
sendJson(res, 413, validationBody(
|
|
189
|
+
`request body exceeds ${MAX_BODY_BYTES} bytes`,
|
|
190
|
+
));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
// Fixed message: the JSON parse error text is not echoed.
|
|
194
|
+
sendJson(res, 400, validationBody("request body is not valid JSON"));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const outcome: EvaluateResponse = await evaluator.handle(body);
|
|
198
|
+
sendJson(res, outcome.status, outcome.payload);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
sendJson(res, 404, validationBody(`unknown route ${route}; see GET /version for the endpoint contract`));
|
|
203
|
+
} catch {
|
|
204
|
+
// Internal error text never reaches the caller (review fix #5).
|
|
205
|
+
sendJson(res, 500, {
|
|
206
|
+
error: "degraded",
|
|
207
|
+
message: "internal error",
|
|
208
|
+
request_id: "n/a",
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Wire the default service from a loaded config (production path). */
|
|
214
|
+
export function buildService(config: JanusConfig): JanusService {
|
|
215
|
+
const audit = new AuditLog(config.audit.path, config.audit.maxBytes, config.audit.retentionDays);
|
|
216
|
+
audit.start();
|
|
217
|
+
const chain = buildProviderChain(config);
|
|
218
|
+
const evaluator = new Evaluator({
|
|
219
|
+
config,
|
|
220
|
+
singleFlight: new SingleFlight(config.maxConcurrent),
|
|
221
|
+
budget: new DailyBudget(
|
|
222
|
+
config.dailyTokenCeiling,
|
|
223
|
+
config.dailyCostCeilingUsd,
|
|
224
|
+
config.costPerMtokenUsd,
|
|
225
|
+
),
|
|
226
|
+
audit,
|
|
227
|
+
chain,
|
|
228
|
+
});
|
|
229
|
+
const service = createJanusService(config, evaluator, () => chain.readiness());
|
|
230
|
+
const stop = service.stop;
|
|
231
|
+
return {
|
|
232
|
+
...service,
|
|
233
|
+
stop: () => {
|
|
234
|
+
audit.stop();
|
|
235
|
+
return stop();
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Entry point: `npx tsx janus/server.ts` (dev) or launchd (label user.janus). */
|
|
241
|
+
export function main(): void {
|
|
242
|
+
const configPath = process.env.JANUS_CONFIG_PATH;
|
|
243
|
+
const { config, warnings } = loadConfig(configPath || undefined);
|
|
244
|
+
for (const warning of warnings) console.error(`janus: ${warning}`);
|
|
245
|
+
const service = buildService(config);
|
|
246
|
+
service.server.on("listening", () => {
|
|
247
|
+
console.error(`janus: listening on 127.0.0.1:${config.port} (loopback only)`);
|
|
248
|
+
console.error(`janus: audit log at ${config.audit.path === "stdout" ? "stdout" : `${config.audit.path}.jsonl`}`);
|
|
249
|
+
});
|
|
250
|
+
const shutdown = () => {
|
|
251
|
+
void service.stop().then(() => process.exit(0));
|
|
252
|
+
};
|
|
253
|
+
process.on("SIGTERM", shutdown);
|
|
254
|
+
process.on("SIGINT", shutdown);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
import { loadConfig } from "./config.js";
|
|
258
|
+
|
|
259
|
+
/** Direct-run entrypoint: `npx tsx janus/server.ts`. */
|
|
260
|
+
export function runIfMain(invokedPath: string): void {
|
|
261
|
+
if (!invokedPath.endsWith("server.ts")) return;
|
|
262
|
+
main();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (process.argv[1] && process.argv[1].endsWith("janus/server.ts")) {
|
|
266
|
+
main();
|
|
267
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// Single-flight and concurrency (§5, M9).
|
|
5
|
+
//
|
|
6
|
+
// One in-flight evaluation per evalInputDigest: identical requests coalesce
|
|
7
|
+
// and share the same eventual result. A global cap (default 4) on DISTINCT
|
|
8
|
+
// in-flight evaluations rejects new distinct requests with `busy`.
|
|
9
|
+
//
|
|
10
|
+
// Coalescing accounting (review fix #11): the number of coalesced waiters is
|
|
11
|
+
// observable per digest (waitersFor) and reported to the flight owner's
|
|
12
|
+
// onSettle callback, so the audit log can mark coalescing accurately — the
|
|
13
|
+
// provider call and its usage are charged exactly once regardless of how
|
|
14
|
+
// many callers shared the flight.
|
|
15
|
+
|
|
16
|
+
import { busyError } from "./errors.js";
|
|
17
|
+
|
|
18
|
+
interface Flight<T> {
|
|
19
|
+
promise: Promise<T>;
|
|
20
|
+
waiters: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class SingleFlight {
|
|
24
|
+
private readonly flights = new Map<string, Flight<unknown>>();
|
|
25
|
+
private inFlight = 0;
|
|
26
|
+
|
|
27
|
+
constructor(private readonly maxConcurrent: number) {}
|
|
28
|
+
|
|
29
|
+
get size(): number {
|
|
30
|
+
return this.inFlight;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How many callers are currently coalesced onto `digest` (0 = none; the
|
|
35
|
+
* flight owner is not counted as a waiter).
|
|
36
|
+
*/
|
|
37
|
+
waitersFor(digest: string): number {
|
|
38
|
+
return this.flights.get(digest)?.waiters ?? 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Run `task` keyed by `digest`. Identical concurrent digests coalesce onto
|
|
43
|
+
* one task. Distinct digests beyond the concurrency cap reject with busy.
|
|
44
|
+
* `onSettle` (optional) fires exactly once when the flight settles, with
|
|
45
|
+
* the number of coalesced waiters that joined — used to mark coalescing
|
|
46
|
+
* accurately in the audit log (review fix #11).
|
|
47
|
+
*/
|
|
48
|
+
run<T>(
|
|
49
|
+
digest: string,
|
|
50
|
+
task: () => Promise<T>,
|
|
51
|
+
onSettle?: (info: { waiters: number }) => void,
|
|
52
|
+
): Promise<T> {
|
|
53
|
+
const existing = this.flights.get(digest) as Flight<T> | undefined;
|
|
54
|
+
if (existing) {
|
|
55
|
+
existing.waiters += 1;
|
|
56
|
+
return existing.promise;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (this.inFlight >= this.maxConcurrent) {
|
|
60
|
+
throw busyError(
|
|
61
|
+
`global concurrency cap of ${this.maxConcurrent} reached; retry later`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.inFlight += 1;
|
|
66
|
+
let settledWaiters = 0;
|
|
67
|
+
let settled = false;
|
|
68
|
+
const settle = () => {
|
|
69
|
+
if (settled) return;
|
|
70
|
+
settled = true;
|
|
71
|
+
this.inFlight -= 1;
|
|
72
|
+
this.flights.delete(digest);
|
|
73
|
+
onSettle?.({ waiters: settledWaiters });
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const promise: Promise<T> = (async () => {
|
|
77
|
+
try {
|
|
78
|
+
return await task();
|
|
79
|
+
} finally {
|
|
80
|
+
settle();
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
|
|
84
|
+
// Proxy so late waiters still bump the count seen at settle time.
|
|
85
|
+
const flight: Flight<T> = {
|
|
86
|
+
get promise() {
|
|
87
|
+
return promise;
|
|
88
|
+
},
|
|
89
|
+
get waiters() {
|
|
90
|
+
return settledWaiters;
|
|
91
|
+
},
|
|
92
|
+
set waiters(value: number) {
|
|
93
|
+
settledWaiters = value;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
this.flights.set(digest, flight as Flight<unknown>);
|
|
97
|
+
return promise;
|
|
98
|
+
}
|
|
99
|
+
}
|