@evoclock/pi-agentic-driver 0.9.1 → 0.9.3
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/.agentic-driver/router.defaults.json +64 -0
- package/LICENSE +11 -8
- package/README.md +116 -32
- package/extensions/attended-authority-guard.ts +1 -1
- package/extensions/code-phage.js +1 -1
- package/extensions/herdr-communication.ts +1 -1
- package/extensions/herdr-dispatch.ts +1 -1
- package/extensions/herdr-lifecycle.ts +1 -1
- package/extensions/linux-microvm.ts +1 -1
- package/extensions/pulse.ts +33 -2
- package/extensions/task-board.ts +1 -1
- package/janus/README.draft.md +210 -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/lib/adapters/diff-scope.mjs +1 -1
- package/lib/adapters/evidence.mjs +1 -1
- package/lib/adapters/narrative.mjs +1 -1
- package/lib/adapters/review-feedback.mjs +1 -1
- package/lib/adapters/visualization.mjs +1 -1
- package/lib/code-phage-core.mjs +1 -1
- package/lib/python_ast_metrics.py +1 -1
- package/lib/typescript_ast_metrics.mjs +1 -1
- package/package.json +14 -3
- package/scripts/aidr_writing_review.js +1 -1
- package/scripts/enforcement/attended_authority_guard.js +1 -1
- package/scripts/enforcement/guest_containment_taxonomy.v1.json +1 -1
- package/scripts/enforcement/herdr_async_dispatch_pi.js +1 -1
- package/scripts/enforcement/herdr_communication_pi.js +13 -8
- package/scripts/enforcement/herdr_lifecycle_pi.js +3 -3
- package/scripts/enforcement/linux_microvm_cutover_pi.js +1 -1
- package/scripts/enforcement/native_tui_context.js +1 -1
- package/scripts/enforcement/pulse_core_pi.js +38 -14
- package/scripts/enforcement/pulse_scheduler_pi.js +188 -25
- package/scripts/enforcement/router_engine_pi.js +483 -0
- package/scripts/enforcement/router_ranking_pi.js +146 -0
- package/scripts/enforcement/router_replacement_pi.js +98 -0
- package/scripts/enforcement/router_runtime_pi.js +299 -0
- package/scripts/enforcement/router_schemas_pi.js +654 -0
- package/scripts/enforcement/router_store_pi.js +438 -0
- package/scripts/enforcement/task_board_core_pi.js +255 -44
- package/skills/pulse/SKILL.md +1 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// Budget accounting (§2, §5): the authoritative request budget is 30k tokens
|
|
5
|
+
// for state + questions + framing. Anything exceeding the budget after
|
|
6
|
+
// shaping is rejected with state_too_large — never truncated silently.
|
|
7
|
+
//
|
|
8
|
+
// Conservative token bound (review fix #3): the estimate is byte-based with
|
|
9
|
+
// an explicit worst-case floor of 1 token per 2 UTF-8 bytes. This dominates
|
|
10
|
+
// chars/4 for every encoding shape:
|
|
11
|
+
// - ASCII prose: 1 byte/char → 2 bytes/token (≥ chars/4)
|
|
12
|
+
// - CJK / emoji: 3–4 bytes/char → 1.5–2 bytes/token (chars/4 would
|
|
13
|
+
// undercount a 4-byte emoji as 0.25 tokens; this bound charges ≥ 2)
|
|
14
|
+
// - dense/base64 JSON: 1 byte/char → 2 bytes/token (chars/4 undercounts
|
|
15
|
+
// by 2×; dense JSON tokenizes at ~1–2 bytes/token in practice)
|
|
16
|
+
// The bound can over-reject (e.g. very compressive scripts) but never
|
|
17
|
+
// materially undercounts. It runs BEFORE any external call so oversized
|
|
18
|
+
// requests never reach a provider.
|
|
19
|
+
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { stateTooLarge } from "./errors.js";
|
|
22
|
+
import type { EnforcedQuestion } from "./schema.js";
|
|
23
|
+
|
|
24
|
+
/** Conservative worst-case bytes per token (finding #3): 1 token per 2 UTF-8 bytes. */
|
|
25
|
+
export const BYTES_PER_TOKEN_CONSERVATIVE = 2;
|
|
26
|
+
|
|
27
|
+
/** Canonical JSON with object keys sorted (stable digests). */
|
|
28
|
+
export function canonicalJson(value: unknown): string {
|
|
29
|
+
return JSON.stringify(canonicalize(value));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function canonicalize(value: unknown): unknown {
|
|
33
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
34
|
+
if (value !== null && typeof value === "object") {
|
|
35
|
+
const sorted: Record<string, unknown> = {};
|
|
36
|
+
for (const k of Object.keys(value).sort()) {
|
|
37
|
+
sorted[k] = canonicalize((value as Record<string, unknown>)[k]);
|
|
38
|
+
}
|
|
39
|
+
return sorted;
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Conservative token estimate for a string: UTF-8 bytes / 2, rounded up.
|
|
46
|
+
* Dominates chars/4 for CJK, emoji, and dense encoded content; can
|
|
47
|
+
* over-reject but never materially undercounts.
|
|
48
|
+
*/
|
|
49
|
+
export function estimateStringTokens(text: string): number {
|
|
50
|
+
return Math.ceil(Buffer.byteLength(text, "utf8") / BYTES_PER_TOKEN_CONSERVATIVE);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface RequestSize {
|
|
54
|
+
estimatedTokens: number;
|
|
55
|
+
stateBytes: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function estimateRequestTokens(
|
|
59
|
+
state: unknown,
|
|
60
|
+
questions: readonly EnforcedQuestion[],
|
|
61
|
+
budgetTokens: number,
|
|
62
|
+
maxStateBytes: number,
|
|
63
|
+
): RequestSize {
|
|
64
|
+
const stateJson = canonicalJson(state);
|
|
65
|
+
const stateBytes = Buffer.byteLength(stateJson, "utf8");
|
|
66
|
+
if (stateBytes > maxStateBytes) {
|
|
67
|
+
throw stateTooLarge(
|
|
68
|
+
`state is ${stateBytes} bytes; the hard byte ceiling is ${maxStateBytes} — shape to digests/summaries and retry`,
|
|
69
|
+
{ stateBytes, maxStateBytes },
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let tokens = estimateStringTokens(stateJson);
|
|
74
|
+
for (const question of questions) {
|
|
75
|
+
tokens += estimateStringTokens(question.question);
|
|
76
|
+
tokens += estimateStringTokens(question.instructions);
|
|
77
|
+
if (question.kind === "choice") {
|
|
78
|
+
tokens += question.choices.reduce((sum, c) => sum + estimateStringTokens(c), 0);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (tokens > budgetTokens) {
|
|
83
|
+
throw stateTooLarge(
|
|
84
|
+
`estimated request size ${tokens} tokens exceeds the ${budgetTokens}-token budget ` +
|
|
85
|
+
`(state + questions + framing); never truncated silently — shape the state down`,
|
|
86
|
+
{ estimatedTokens: tokens, budgetTokens },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { estimatedTokens: tokens, stateBytes };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** evalInputDigest (M9): stable digest of the exact evaluation input. */
|
|
94
|
+
export function evalInputDigest(
|
|
95
|
+
state: unknown,
|
|
96
|
+
questions: readonly EnforcedQuestion[],
|
|
97
|
+
): string {
|
|
98
|
+
const hash = createHash("sha256");
|
|
99
|
+
hash.update(canonicalJson({ state, questions }));
|
|
100
|
+
return hash.digest("hex");
|
|
101
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// Daily cost breaker (§5, M10): a daily token/cost ceiling in config; on
|
|
5
|
+
// exceed, /evaluate returns busy with budget_exhausted detail until the
|
|
6
|
+
// window resets. Windows are UTC calendar days (the audit log uses the same
|
|
7
|
+
// day key). Usage arrives from the SDK result after each evaluation; the
|
|
8
|
+
// breaker is updated by the evaluate path.
|
|
9
|
+
|
|
10
|
+
import { busyError } from "./errors.js";
|
|
11
|
+
|
|
12
|
+
export interface UsageRecord {
|
|
13
|
+
inputTokens?: number | undefined;
|
|
14
|
+
outputTokens?: number | undefined;
|
|
15
|
+
totalTokens?: number | undefined;
|
|
16
|
+
costUsd?: number | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BudgetStatus {
|
|
20
|
+
dayKey: string;
|
|
21
|
+
tokensUsed: number;
|
|
22
|
+
costUsdUsed: number;
|
|
23
|
+
tokenCeiling: number;
|
|
24
|
+
costCeilingUsd: number;
|
|
25
|
+
exhausted: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class DailyBudget {
|
|
29
|
+
private dayKey = "";
|
|
30
|
+
private tokensUsed = 0;
|
|
31
|
+
private costUsdUsed = 0;
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
private readonly tokenCeiling: number,
|
|
35
|
+
private readonly costCeilingUsd: number,
|
|
36
|
+
private readonly costPerMtokenUsd: number,
|
|
37
|
+
private readonly now: () => Date = () => new Date(),
|
|
38
|
+
) {}
|
|
39
|
+
|
|
40
|
+
static dayKeyFor(date: Date): string {
|
|
41
|
+
return date.toISOString().slice(0, 10);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private rollIfNeeded(): void {
|
|
45
|
+
const key = DailyBudget.dayKeyFor(this.now());
|
|
46
|
+
if (key !== this.dayKey) {
|
|
47
|
+
this.dayKey = key;
|
|
48
|
+
this.tokensUsed = 0;
|
|
49
|
+
this.costUsdUsed = 0;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Record usage after an evaluation completes (success or failure after the
|
|
55
|
+
* gateway accepted the call — retries consume budget too).
|
|
56
|
+
*/
|
|
57
|
+
record(usage: UsageRecord): void {
|
|
58
|
+
this.rollIfNeeded();
|
|
59
|
+
const total =
|
|
60
|
+
usage.totalTokens ??
|
|
61
|
+
(usage.inputTokens !== undefined || usage.outputTokens !== undefined
|
|
62
|
+
? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
|
|
63
|
+
: 0);
|
|
64
|
+
this.tokensUsed += total;
|
|
65
|
+
this.costUsdUsed +=
|
|
66
|
+
usage.costUsd ?? (total / 1_000_000) * this.costPerMtokenUsd;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Check before dispatch. Throws busy/budget_exhausted when over either ceiling. */
|
|
70
|
+
assertWithinBudget(): void {
|
|
71
|
+
this.rollIfNeeded();
|
|
72
|
+
if (this.tokenCeiling > 0 && this.tokensUsed >= this.tokenCeiling) {
|
|
73
|
+
throw busyError(
|
|
74
|
+
`daily token ceiling of ${this.tokenCeiling} reached; resets at 00:00 UTC`,
|
|
75
|
+
{ budget_exhausted: { kind: "tokens", dayKey: this.dayKey, used: this.tokensUsed } },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (this.costCeilingUsd > 0 && this.costUsdUsed >= this.costCeilingUsd) {
|
|
79
|
+
throw busyError(
|
|
80
|
+
`daily cost ceiling of $${this.costCeilingUsd} reached; resets at 00:00 UTC`,
|
|
81
|
+
{
|
|
82
|
+
budget_exhausted: {
|
|
83
|
+
kind: "cost",
|
|
84
|
+
dayKey: this.dayKey,
|
|
85
|
+
usedUsd: Number(this.costUsdUsed.toFixed(4)),
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
status(): BudgetStatus {
|
|
93
|
+
this.rollIfNeeded();
|
|
94
|
+
return {
|
|
95
|
+
dayKey: this.dayKey,
|
|
96
|
+
tokensUsed: this.tokensUsed,
|
|
97
|
+
costUsdUsed: Number(this.costUsdUsed.toFixed(4)),
|
|
98
|
+
tokenCeiling: this.tokenCeiling,
|
|
99
|
+
costCeilingUsd: this.costCeilingUsd,
|
|
100
|
+
exhausted:
|
|
101
|
+
(this.tokenCeiling > 0 && this.tokensUsed >= this.tokenCeiling) ||
|
|
102
|
+
(this.costCeilingUsd > 0 && this.costUsdUsed >= this.costCeilingUsd),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// janus configuration loading and validation.
|
|
5
|
+
//
|
|
6
|
+
// The config file is the single authority for the provider chain, the model
|
|
7
|
+
// allowlist, the request/concurrency/budget ceilings, and audit-log
|
|
8
|
+
// retention. Validation is CLOSED: unknown fields are rejected, invalid
|
|
9
|
+
// values are rejected — nothing falls back to a default silently.
|
|
10
|
+
//
|
|
11
|
+
// Provider architecture (authoritative owner decision): direct TypeSafe
|
|
12
|
+
// (`@typesafe-ai/sdk`, official SDK) is the SOLE provider. The Vercel AI
|
|
13
|
+
// Gateway and the merge gateway are DROPPED — they are not valid config and
|
|
14
|
+
// there is no fallback transport. When TypeSafe Direct is unconfigured the
|
|
15
|
+
// service fails closed (model_unauthorized), with no silent degradation.
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
20
|
+
|
|
21
|
+
export const JANUS_VERSION = "0.1.0";
|
|
22
|
+
export const QUESTION_SCHEMA_VERSION = "janus.question-schema.v2";
|
|
23
|
+
export const RESPONSE_SCHEMA_VERSION = "janus.evaluate-response.v2";
|
|
24
|
+
|
|
25
|
+
/** Authoritative request budget (§2): state + questions + framing, in tokens. */
|
|
26
|
+
export const REQUEST_BUDGET_TOKENS = 30_000;
|
|
27
|
+
/** Hard ceiling for the configured request budget: config cannot raise it. */
|
|
28
|
+
export const MAX_REQUEST_BUDGET_TOKENS = REQUEST_BUDGET_TOKENS;
|
|
29
|
+
/** Service-side hard cap per request (§5). Client budgets are capped at this. */
|
|
30
|
+
export const MAX_TIMEOUT_MS = 30_000;
|
|
31
|
+
/** Default per-question instructions ceiling. */
|
|
32
|
+
export const MAX_INSTRUCTIONS_CHARS = 20_000;
|
|
33
|
+
/** Maximum number of questions in one request. */
|
|
34
|
+
export const MAX_QUESTIONS = 32;
|
|
35
|
+
/** Default score vocabulary levels (§6: 0..5, one decimal). */
|
|
36
|
+
export const SCORE_LEVELS = 6;
|
|
37
|
+
/** Maximum choice options per choice question. */
|
|
38
|
+
export const MAX_CHOICES = 64;
|
|
39
|
+
/** Maximum state size accepted before token accounting, in UTF-8 bytes. */
|
|
40
|
+
export const MAX_STATE_BYTES = 4_000_000;
|
|
41
|
+
/** Maximum audit-log file size before oldest-first rotation. */
|
|
42
|
+
export const DEFAULT_AUDIT_MAX_BYTES = 10 * 1024 * 1024;
|
|
43
|
+
/** Default audit-log retention in days. */
|
|
44
|
+
export const DEFAULT_AUDIT_RETENTION_DAYS = 90;
|
|
45
|
+
/** Hard ceiling for the global concurrency cap: config cannot raise it. */
|
|
46
|
+
export const MAX_CONCURRENCY = 4;
|
|
47
|
+
/** Default global concurrency cap (M9). */
|
|
48
|
+
export const DEFAULT_MAX_CONCURRENT = MAX_CONCURRENCY;
|
|
49
|
+
/** Default daily token ceiling for the cost breaker (M10). */
|
|
50
|
+
export const DEFAULT_DAILY_TOKEN_CEILING = 2_000_000;
|
|
51
|
+
/** Default daily cost ceiling in USD for the cost breaker (M10). */
|
|
52
|
+
export const DEFAULT_DAILY_COST_CEILING_USD = 10;
|
|
53
|
+
/** Default Jev price per million tokens (input + output blended, config-overridable). */
|
|
54
|
+
export const DEFAULT_COST_PER_MTOKEN_USD = 0.5;
|
|
55
|
+
|
|
56
|
+
/** Provider identifiers on the transport contract (§7). TypeSafe Direct is the sole provider. */
|
|
57
|
+
export type ProviderId = "typesafe-direct";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A Keychain reference: the generic-password account and service names.
|
|
61
|
+
* The secret value itself is never in config — only these references.
|
|
62
|
+
*/
|
|
63
|
+
export interface KeychainRef {
|
|
64
|
+
/** Keychain account name (e.g. `typesafe-ai`). */
|
|
65
|
+
account: string;
|
|
66
|
+
/** Keychain service name (e.g. `Typesafe AI`). */
|
|
67
|
+
service: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One configured provider in the ordered failover chain. */
|
|
71
|
+
export interface ProviderConfig {
|
|
72
|
+
id: ProviderId;
|
|
73
|
+
/** Provider-specific model identifier passed to the provider adapter. */
|
|
74
|
+
model: string;
|
|
75
|
+
/** Keychain reference for this provider's credential. */
|
|
76
|
+
keychain: KeychainRef;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface JanusConfig {
|
|
80
|
+
/** Server bind address. Hard-restricted to the loopback literals. */
|
|
81
|
+
host: "127.0.0.1";
|
|
82
|
+
port: number;
|
|
83
|
+
/** Ordered provider chain: index 0 is primary; later entries are failovers. */
|
|
84
|
+
providers: ProviderConfig[];
|
|
85
|
+
/** Provider/model used when the request does not name one. */
|
|
86
|
+
defaultModel: string;
|
|
87
|
+
/** Global in-flight evaluation cap (M9). Hard ceiling 4. */
|
|
88
|
+
maxConcurrent: number;
|
|
89
|
+
/** Per-request timeout cap in ms (§5). */
|
|
90
|
+
timeoutMs: number;
|
|
91
|
+
/** Request token budget (§2). Hard ceiling 30,000. */
|
|
92
|
+
requestBudgetTokens: number;
|
|
93
|
+
/** Daily token ceiling for the cost breaker (M10). */
|
|
94
|
+
dailyTokenCeiling: number;
|
|
95
|
+
/** Daily cost ceiling in USD for the cost breaker (M10). */
|
|
96
|
+
dailyCostCeilingUsd: number;
|
|
97
|
+
/** Blended cost per million tokens used by the breaker. */
|
|
98
|
+
costPerMtokenUsd: number;
|
|
99
|
+
audit: {
|
|
100
|
+
/** Append-only audit log path. Use "stdout" for launchd/dev console logging. */
|
|
101
|
+
path: string;
|
|
102
|
+
maxBytes: number;
|
|
103
|
+
retentionDays: number;
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Redaction is ALWAYS on before any external call. The field exists as a
|
|
107
|
+
* fixed `true` for the type contract; config cannot set it to false and
|
|
108
|
+
* there is no disable knob.
|
|
109
|
+
*/
|
|
110
|
+
readonly redactSecrets: true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface ConfigLoadResult {
|
|
114
|
+
config: JanusConfig;
|
|
115
|
+
/** Non-fatal problems recorded while loading (reported on /version). */
|
|
116
|
+
warnings: string[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The sole provider chain: direct TypeSafe. */
|
|
120
|
+
export function defaultProviderChain(): ProviderConfig[] {
|
|
121
|
+
return [
|
|
122
|
+
{
|
|
123
|
+
id: "typesafe-direct",
|
|
124
|
+
model: "jev-latest",
|
|
125
|
+
keychain: { account: "typesafe-ai", service: "Typesafe AI" },
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
class ConfigError extends Error {}
|
|
131
|
+
|
|
132
|
+
function fail(field: string, why: string): never {
|
|
133
|
+
throw new ConfigError(`config invalid: ${field}: ${why}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function requireObject(value: unknown, field: string): Record<string, unknown> {
|
|
137
|
+
if (value === undefined) return {};
|
|
138
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
139
|
+
fail(field, "must be a JSON object");
|
|
140
|
+
}
|
|
141
|
+
return value as Record<string, unknown>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function optPositiveInt(raw: Record<string, unknown>, field: string, fallback: number, max?: number): number {
|
|
145
|
+
const value = raw[field];
|
|
146
|
+
if (value === undefined) return fallback;
|
|
147
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
148
|
+
fail(field, `must be a positive integer, got ${JSON.stringify(value)}`);
|
|
149
|
+
}
|
|
150
|
+
if (max !== undefined && value > max) {
|
|
151
|
+
fail(field, `${value} exceeds the hard ceiling of ${max}; config cannot raise it`);
|
|
152
|
+
}
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function optNonNegativeNumber(raw: Record<string, unknown>, field: string, fallback: number): number {
|
|
157
|
+
const value = raw[field];
|
|
158
|
+
if (value === undefined) return fallback;
|
|
159
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
160
|
+
fail(field, `must be a non-negative finite number, got ${JSON.stringify(value)}`);
|
|
161
|
+
}
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function optString(raw: Record<string, unknown>, field: string, fallback: string): string {
|
|
166
|
+
const value = raw[field];
|
|
167
|
+
if (value === undefined) return fallback;
|
|
168
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
169
|
+
fail(field, "must be a nonempty string");
|
|
170
|
+
}
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const PROVIDER_IDS: readonly ProviderId[] = ["typesafe-direct"];
|
|
175
|
+
|
|
176
|
+
function enforceKeychainRef(raw: unknown, field: string): KeychainRef {
|
|
177
|
+
const obj = requireObject(raw, field);
|
|
178
|
+
const allowed = new Set(["account", "service"]);
|
|
179
|
+
for (const key of Object.keys(obj)) {
|
|
180
|
+
if (!allowed.has(key)) fail(`${field}.${key}`, "unknown field");
|
|
181
|
+
}
|
|
182
|
+
const account = obj.account;
|
|
183
|
+
const service = obj.service;
|
|
184
|
+
if (typeof account !== "string" || account.length === 0) fail(`${field}.account`, "must be a nonempty string");
|
|
185
|
+
if (typeof service !== "string" || service.length === 0) fail(`${field}.service`, "must be a nonempty string");
|
|
186
|
+
return { account, service };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function enforceProvider(raw: unknown, index: number): ProviderConfig {
|
|
190
|
+
const obj = requireObject(raw, `providers[${index}]`);
|
|
191
|
+
const allowed = new Set(["id", "model", "keychain"]);
|
|
192
|
+
for (const key of Object.keys(obj)) {
|
|
193
|
+
if (!allowed.has(key)) fail(`providers[${index}].${key}`, "unknown field");
|
|
194
|
+
}
|
|
195
|
+
const id = obj.id;
|
|
196
|
+
if (typeof id !== "string" || !PROVIDER_IDS.includes(id as ProviderId)) {
|
|
197
|
+
fail(`providers[${index}].id`, `must be one of ${PROVIDER_IDS.join(", ")}`);
|
|
198
|
+
}
|
|
199
|
+
const model = obj.model;
|
|
200
|
+
if (typeof model !== "string" || model.length === 0) {
|
|
201
|
+
fail(`providers[${index}].model`, "must be a nonempty provider-specific model identifier");
|
|
202
|
+
}
|
|
203
|
+
const keychain = enforceKeychainRef(obj.keychain, `providers[${index}].keychain`);
|
|
204
|
+
return { id: id as ProviderId, model, keychain };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function enforceProviderChain(raw: unknown): ProviderConfig[] {
|
|
208
|
+
if (raw === undefined) return defaultProviderChain();
|
|
209
|
+
if (!Array.isArray(raw) || raw.length === 0) fail("providers", "must be a nonempty array");
|
|
210
|
+
if (raw.length > 1) {
|
|
211
|
+
fail(
|
|
212
|
+
"providers",
|
|
213
|
+
"only the typesafe-direct provider is supported; at most one provider entry is valid",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const chain = raw.map((entry, index) => enforceProvider(entry, index));
|
|
217
|
+
const seen = new Set<string>();
|
|
218
|
+
for (const provider of chain) {
|
|
219
|
+
if (seen.has(provider.id)) fail("providers", `duplicate provider id ${provider.id}`);
|
|
220
|
+
seen.add(provider.id);
|
|
221
|
+
}
|
|
222
|
+
return chain;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function resolveAuditPath(raw: unknown, configDir: string): string {
|
|
226
|
+
if (raw === undefined) return join(configDir, "janus-audit");
|
|
227
|
+
if (typeof raw !== "string" || raw.length === 0) fail("audit.path", "must be a nonempty string");
|
|
228
|
+
if (raw === "stdout") return raw;
|
|
229
|
+
const expanded = raw.startsWith("~/") ? join(homedir(), raw.slice(2)) : raw;
|
|
230
|
+
return isAbsolute(expanded) ? expanded : resolve(configDir, expanded);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function defaultConfigPath(): string {
|
|
234
|
+
// typesafe-ai/jev namespace per §7; the janus config lives alongside it.
|
|
235
|
+
return join(homedir(), ".config", "typesafe-ai", "jev", "janus-config.json");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function loadConfig(path?: string): ConfigLoadResult {
|
|
239
|
+
const warnings: string[] = [];
|
|
240
|
+
const configPath = path ?? defaultConfigPath();
|
|
241
|
+
const configDir = resolve(configPath, "..");
|
|
242
|
+
|
|
243
|
+
let raw: Record<string, unknown> = {};
|
|
244
|
+
if (existsSync(configPath)) {
|
|
245
|
+
try {
|
|
246
|
+
const parsed: unknown = JSON.parse(readFileSync(configPath, "utf8"));
|
|
247
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
248
|
+
raw = parsed as Record<string, unknown>;
|
|
249
|
+
} else {
|
|
250
|
+
throw new ConfigError("config invalid: config file is not a JSON object");
|
|
251
|
+
}
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (error instanceof ConfigError) throw error;
|
|
254
|
+
throw new ConfigError(
|
|
255
|
+
`config invalid: config file failed to parse (${configPath}): ${
|
|
256
|
+
error instanceof Error ? error.message : String(error)
|
|
257
|
+
}`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
warnings.push(`config file not found; using defaults (${configPath})`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Closed top-level validation: unknown fields are rejected, never ignored.
|
|
265
|
+
const allowedTop = new Set([
|
|
266
|
+
"port",
|
|
267
|
+
"providers",
|
|
268
|
+
"defaultModel",
|
|
269
|
+
"maxConcurrent",
|
|
270
|
+
"timeoutMs",
|
|
271
|
+
"requestBudgetTokens",
|
|
272
|
+
"dailyTokenCeiling",
|
|
273
|
+
"dailyCostCeilingUsd",
|
|
274
|
+
"costPerMtokenUsd",
|
|
275
|
+
"audit",
|
|
276
|
+
"redactSecrets",
|
|
277
|
+
]);
|
|
278
|
+
for (const key of Object.keys(raw)) {
|
|
279
|
+
if (!allowedTop.has(key)) fail(key, "unknown config field");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const auditRaw = requireObject(raw.audit, "audit");
|
|
283
|
+
const allowedAudit = new Set(["path", "maxBytes", "retentionDays"]);
|
|
284
|
+
for (const key of Object.keys(auditRaw)) {
|
|
285
|
+
if (!allowedAudit.has(key)) fail(`audit.${key}`, "unknown config field");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Redaction is immutable-on. The field may be present as true only.
|
|
289
|
+
if (raw.redactSecrets !== undefined && raw.redactSecrets !== true) {
|
|
290
|
+
fail("redactSecrets", "redaction is always on and cannot be disabled");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const providers = enforceProviderChain(raw.providers);
|
|
294
|
+
const defaultModel = optString(raw, "defaultModel", providers[0]!.model);
|
|
295
|
+
if (!providers.some((p) => p.model === defaultModel)) {
|
|
296
|
+
fail("defaultModel", `${JSON.stringify(defaultModel)} is not any configured provider's model`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const port = optPositiveInt(raw, "port", 8787);
|
|
300
|
+
if (port > 65535) fail("port", `${port} out of range`);
|
|
301
|
+
|
|
302
|
+
const auditPath = resolveAuditPath(auditRaw.path, configDir);
|
|
303
|
+
|
|
304
|
+
const config: JanusConfig = {
|
|
305
|
+
// §5 (H4): janus binds 127.0.0.1 only. There is no config knob for the
|
|
306
|
+
// bind address by design; a non-loopback bind is a separate reviewed
|
|
307
|
+
// change with shared-secret auth and an explicit bind-address policy.
|
|
308
|
+
host: "127.0.0.1",
|
|
309
|
+
port,
|
|
310
|
+
providers,
|
|
311
|
+
defaultModel,
|
|
312
|
+
maxConcurrent: optPositiveInt(raw, "maxConcurrent", DEFAULT_MAX_CONCURRENT, MAX_CONCURRENCY),
|
|
313
|
+
timeoutMs: optPositiveInt(raw, "timeoutMs", MAX_TIMEOUT_MS, MAX_TIMEOUT_MS),
|
|
314
|
+
requestBudgetTokens: optPositiveInt(
|
|
315
|
+
raw,
|
|
316
|
+
"requestBudgetTokens",
|
|
317
|
+
REQUEST_BUDGET_TOKENS,
|
|
318
|
+
MAX_REQUEST_BUDGET_TOKENS,
|
|
319
|
+
),
|
|
320
|
+
dailyTokenCeiling: optNonNegativeNumber(raw, "dailyTokenCeiling", DEFAULT_DAILY_TOKEN_CEILING),
|
|
321
|
+
dailyCostCeilingUsd: optNonNegativeNumber(raw, "dailyCostCeilingUsd", DEFAULT_DAILY_COST_CEILING_USD),
|
|
322
|
+
costPerMtokenUsd: optNonNegativeNumber(raw, "costPerMtokenUsd", DEFAULT_COST_PER_MTOKEN_USD),
|
|
323
|
+
audit: {
|
|
324
|
+
path: auditPath,
|
|
325
|
+
maxBytes: optPositiveInt(auditRaw, "maxBytes", DEFAULT_AUDIT_MAX_BYTES),
|
|
326
|
+
retentionDays: optPositiveInt(auditRaw, "retentionDays", DEFAULT_AUDIT_RETENTION_DAYS),
|
|
327
|
+
},
|
|
328
|
+
redactSecrets: true,
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
return { config, warnings };
|
|
332
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// janus error taxonomy (§5, M9).
|
|
5
|
+
//
|
|
6
|
+
// Callers must be able to distinguish:
|
|
7
|
+
// - "model returned false" → HTTP 200 with a low-probability verdict
|
|
8
|
+
// - "no verdict" → timeout / gateway_unavailable / model_unauthorized
|
|
9
|
+
// - "bad request" → validation_error (and state_too_large)
|
|
10
|
+
// - "capacity" → busy (single-flight contention or budget breaker)
|
|
11
|
+
|
|
12
|
+
export type JanusErrorCode =
|
|
13
|
+
| "validation_error"
|
|
14
|
+
| "state_too_large"
|
|
15
|
+
| "unknown_question"
|
|
16
|
+
| "model_unauthorized"
|
|
17
|
+
| "gateway_unavailable"
|
|
18
|
+
| "timeout"
|
|
19
|
+
| "busy"
|
|
20
|
+
| "degraded";
|
|
21
|
+
|
|
22
|
+
export class JanusError extends Error {
|
|
23
|
+
readonly code: JanusErrorCode;
|
|
24
|
+
/** HTTP status paired with the code by the server. */
|
|
25
|
+
readonly status: number;
|
|
26
|
+
/** Optional structured detail (e.g. budget_exhausted, field-level errors). */
|
|
27
|
+
readonly detail?: Record<string, unknown>;
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
code: JanusErrorCode,
|
|
31
|
+
message: string,
|
|
32
|
+
status: number,
|
|
33
|
+
detail?: Record<string, unknown>,
|
|
34
|
+
) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "JanusError";
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.status = status;
|
|
39
|
+
if (detail !== undefined) this.detail = detail;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Machine-readable error body: {error, message, request_id, detail?}. */
|
|
43
|
+
body(requestId: string): Record<string, unknown> {
|
|
44
|
+
const body: Record<string, unknown> = {
|
|
45
|
+
error: this.code,
|
|
46
|
+
message: this.message,
|
|
47
|
+
request_id: requestId,
|
|
48
|
+
};
|
|
49
|
+
if (this.detail !== undefined) body.detail = this.detail;
|
|
50
|
+
return body;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function validationError(
|
|
55
|
+
message: string,
|
|
56
|
+
detail?: Record<string, unknown>,
|
|
57
|
+
): JanusError {
|
|
58
|
+
return new JanusError("validation_error", message, 400, detail);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function stateTooLarge(message: string, detail?: Record<string, unknown>): JanusError {
|
|
62
|
+
return new JanusError("state_too_large", message, 413, detail);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function unknownQuestion(message: string, detail?: Record<string, unknown>): JanusError {
|
|
66
|
+
return new JanusError("unknown_question", message, 400, detail);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function modelUnauthorized(message: string, detail?: Record<string, unknown>): JanusError {
|
|
70
|
+
return new JanusError("model_unauthorized", message, 502, detail);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function gatewayUnavailable(
|
|
74
|
+
message: string,
|
|
75
|
+
detail?: Record<string, unknown>,
|
|
76
|
+
): JanusError {
|
|
77
|
+
return new JanusError("gateway_unavailable", message, 503, detail);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function timeoutError(message: string, detail?: Record<string, unknown>): JanusError {
|
|
81
|
+
return new JanusError("timeout", message, 504, detail);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function busyError(message: string, detail?: Record<string, unknown>): JanusError {
|
|
85
|
+
return new JanusError("busy", message, 429, detail);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function degraded(message: string, detail?: Record<string, unknown>): JanusError {
|
|
89
|
+
return new JanusError("degraded", message, 503, detail);
|
|
90
|
+
}
|