@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,229 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// Usage-accounting regression tests (task #44 follow-up, review fixes #11/#12):
|
|
5
|
+
//
|
|
6
|
+
// 1. N coalesced callers → one provider call, one usage charge.
|
|
7
|
+
// 2. Retry then success → failed-attempt usage + success usage each charged once.
|
|
8
|
+
// 3. All attempts fail → all accumulated usage charged despite the thrown taxonomy error.
|
|
9
|
+
// 4. Provider success followed by answer-validation failure → usage still charged.
|
|
10
|
+
// 5. Provider selection: typesafe-direct is the sole provider; unconfigured TypeSafe fails closed.
|
|
11
|
+
//
|
|
12
|
+
// Every test inspects DailyBudget.status().tokensUsed — the breaker is the
|
|
13
|
+
// single accounting sink.
|
|
14
|
+
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { Evaluator } from "../janus/evaluate.js";
|
|
18
|
+
import { SingleFlight } from "../janus/single_flight.js";
|
|
19
|
+
import { DailyBudget } from "../janus/budget_breaker.js";
|
|
20
|
+
import { AuditLog } from "../janus/audit.js";
|
|
21
|
+
import {
|
|
22
|
+
defaultAdapters,
|
|
23
|
+
ProviderChain,
|
|
24
|
+
ProviderChainError,
|
|
25
|
+
ProviderTransportError,
|
|
26
|
+
type TransportRequest,
|
|
27
|
+
} from "../janus/providers.js";
|
|
28
|
+
import { gatewayUnavailable, timeoutError } from "../janus/errors.js";
|
|
29
|
+
import {
|
|
30
|
+
testConfig,
|
|
31
|
+
testProviders,
|
|
32
|
+
testRetryProviders,
|
|
33
|
+
fakeKeyReader,
|
|
34
|
+
mockAdapter,
|
|
35
|
+
mockChain,
|
|
36
|
+
type MockChainCall,
|
|
37
|
+
} from "./helpers.js";
|
|
38
|
+
|
|
39
|
+
const BODY = {
|
|
40
|
+
state: "shared state",
|
|
41
|
+
questions: [{ type: "boolean", name: "q", question: "ok?" }],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const USAGE_120 = { inputTokens: 100, outputTokens: 20, totalTokens: 120 };
|
|
45
|
+
|
|
46
|
+
function noopAudit(): AuditLog {
|
|
47
|
+
return { append: () => {} } as unknown as AuditLog;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test("accounting: three coalesced callers → one provider call, ONE usage charge", async () => {
|
|
51
|
+
const calls: MockChainCall[] = [];
|
|
52
|
+
const budget = new DailyBudget(10_000, 0, 0.5);
|
|
53
|
+
const evaluator = new Evaluator({
|
|
54
|
+
config: testConfig(),
|
|
55
|
+
singleFlight: new SingleFlight(4),
|
|
56
|
+
budget,
|
|
57
|
+
audit: noopAudit(),
|
|
58
|
+
chain: mockChain({
|
|
59
|
+
answers: { q: { type: "boolean", probability: 0.95 } },
|
|
60
|
+
delayMs: 60,
|
|
61
|
+
calls,
|
|
62
|
+
usage: USAGE_120,
|
|
63
|
+
}) as never,
|
|
64
|
+
});
|
|
65
|
+
const [a, b, c] = await Promise.all([
|
|
66
|
+
evaluator.handle(BODY),
|
|
67
|
+
evaluator.handle(BODY),
|
|
68
|
+
evaluator.handle(BODY),
|
|
69
|
+
]);
|
|
70
|
+
assert.equal(a.status, 200);
|
|
71
|
+
assert.equal(b.status, 200);
|
|
72
|
+
assert.equal(c.status, 200);
|
|
73
|
+
assert.equal(calls.length, 1, "identical requests coalesce to one provider call");
|
|
74
|
+
assert.equal(budget.status().tokensUsed, 120, "exactly one charge: 360 would mean double accounting");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("accounting: retry then success → failed + successful attempt usage each charged once", async () => {
|
|
78
|
+
const calls: unknown[] = [];
|
|
79
|
+
let first = true;
|
|
80
|
+
const budget = new DailyBudget(10_000, 0, 0.5);
|
|
81
|
+
const chain = new ProviderChain(
|
|
82
|
+
testRetryProviders(),
|
|
83
|
+
[
|
|
84
|
+
mockAdapter({
|
|
85
|
+
id: "typesafe-direct",
|
|
86
|
+
calls: calls as never,
|
|
87
|
+
usage: USAGE_120,
|
|
88
|
+
answers: { q: { type: "boolean", probability: 0.9 } },
|
|
89
|
+
fail: () => {
|
|
90
|
+
if (first) {
|
|
91
|
+
first = false;
|
|
92
|
+
return new ProviderTransportError("timeout", timeoutError("slow"), {
|
|
93
|
+
inputTokens: 80,
|
|
94
|
+
outputTokens: 20,
|
|
95
|
+
totalTokens: 100,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
],
|
|
102
|
+
fakeKeyReader(),
|
|
103
|
+
);
|
|
104
|
+
const evaluator = new Evaluator({
|
|
105
|
+
config: testConfig(),
|
|
106
|
+
singleFlight: new SingleFlight(4),
|
|
107
|
+
budget,
|
|
108
|
+
audit: noopAudit(),
|
|
109
|
+
chain,
|
|
110
|
+
});
|
|
111
|
+
const response = await evaluator.handle(BODY);
|
|
112
|
+
assert.equal(response.status, 200);
|
|
113
|
+
assert.equal(calls.length, 2, "one failed attempt plus one successful attempt");
|
|
114
|
+
assert.equal(budget.status().tokensUsed, 100 + 120, "failed-attempt usage is charged alongside success usage");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("accounting: all attempts fail → every attempt's usage charged despite the thrown JanusError", async () => {
|
|
118
|
+
let attempt = 0;
|
|
119
|
+
const budget = new DailyBudget(10_000, 0, 0.5);
|
|
120
|
+
const usages = [
|
|
121
|
+
{ inputTokens: 80, outputTokens: 20, totalTokens: 100 },
|
|
122
|
+
{ inputTokens: 40, outputTokens: 40, totalTokens: 80 },
|
|
123
|
+
];
|
|
124
|
+
const chain = new ProviderChain(
|
|
125
|
+
testRetryProviders(),
|
|
126
|
+
[
|
|
127
|
+
mockAdapter({
|
|
128
|
+
id: "typesafe-direct",
|
|
129
|
+
fail: () => {
|
|
130
|
+
const usage = usages[Math.min(attempt, usages.length - 1)]!;
|
|
131
|
+
attempt += 1;
|
|
132
|
+
return new ProviderTransportError("gateway_unavailable", gatewayUnavailable("down"), usage);
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
],
|
|
136
|
+
fakeKeyReader(),
|
|
137
|
+
);
|
|
138
|
+
const evaluator = new Evaluator({
|
|
139
|
+
config: testConfig(),
|
|
140
|
+
singleFlight: new SingleFlight(4),
|
|
141
|
+
budget,
|
|
142
|
+
audit: noopAudit(),
|
|
143
|
+
chain,
|
|
144
|
+
});
|
|
145
|
+
const response = await evaluator.handle(BODY);
|
|
146
|
+
assert.equal(response.status, 503, "terminal chain failure surfaces the taxonomy error");
|
|
147
|
+
assert.equal(response.payload.error, "gateway_unavailable");
|
|
148
|
+
assert.equal(attempt, 2);
|
|
149
|
+
assert.equal(budget.status().tokensUsed, 100 + 80, "failed-attempt usage survives the terminal throw");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("accounting: provider success followed by answer-validation failure → usage still charged", async () => {
|
|
153
|
+
const calls: MockChainCall[] = [];
|
|
154
|
+
const budget = new DailyBudget(10_000, 0, 0.5);
|
|
155
|
+
const evaluator = new Evaluator({
|
|
156
|
+
config: testConfig(),
|
|
157
|
+
singleFlight: new SingleFlight(4),
|
|
158
|
+
budget,
|
|
159
|
+
audit: noopAudit(),
|
|
160
|
+
// Provider-level success, but no answers → enforceAnswers must fail AFTER
|
|
161
|
+
// the provider tokens are charged.
|
|
162
|
+
chain: mockChain({ answers: {}, usage: USAGE_120, calls }) as never,
|
|
163
|
+
});
|
|
164
|
+
const response = await evaluator.handle(BODY);
|
|
165
|
+
assert.equal(response.status, 400);
|
|
166
|
+
assert.equal(response.payload.error, "validation_error");
|
|
167
|
+
assert.equal(calls.length, 1);
|
|
168
|
+
assert.equal(budget.status().tokensUsed, 120, "provider usage is charged even when validation rejects the verdict");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("accounting: terminal chain error carries accumulated usage at the chain boundary", async () => {
|
|
172
|
+
const usage = { inputTokens: 50, outputTokens: 50, totalTokens: 100 };
|
|
173
|
+
const chain = new ProviderChain(
|
|
174
|
+
testRetryProviders(),
|
|
175
|
+
[
|
|
176
|
+
mockAdapter({
|
|
177
|
+
id: "typesafe-direct",
|
|
178
|
+
fail: () => new ProviderTransportError("gateway_unavailable", gatewayUnavailable("down"), usage),
|
|
179
|
+
}),
|
|
180
|
+
],
|
|
181
|
+
fakeKeyReader(),
|
|
182
|
+
);
|
|
183
|
+
const request: TransportRequest = {
|
|
184
|
+
state: "s",
|
|
185
|
+
questions: [{ name: "q", kind: "boolean", question: "ok?", instructions: "" }],
|
|
186
|
+
};
|
|
187
|
+
await assert.rejects(
|
|
188
|
+
chain.evaluate(request, new AbortController().signal),
|
|
189
|
+
(error: unknown) => {
|
|
190
|
+
assert.ok(error instanceof ProviderChainError, "terminal failures travel as ProviderChainError");
|
|
191
|
+
assert.equal(error.failure.code, "gateway_unavailable");
|
|
192
|
+
assert.equal(error.failedAttemptUsage.length, 2);
|
|
193
|
+
assert.equal(
|
|
194
|
+
error.failedAttemptUsage.reduce((sum, entry) => sum + (entry.totalTokens ?? 0), 0),
|
|
195
|
+
200,
|
|
196
|
+
);
|
|
197
|
+
return true;
|
|
198
|
+
},
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("accounting: typesafe-direct is the sole adapter — no Vercel/Merge path exists", () => {
|
|
203
|
+
assert.deepEqual(
|
|
204
|
+
defaultAdapters().map((adapter) => adapter.id),
|
|
205
|
+
["typesafe-direct"],
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("accounting: unconfigured TypeSafe fails closed with zero usage charged", async () => {
|
|
210
|
+
const calls: unknown[] = [];
|
|
211
|
+
const budget = new DailyBudget(10_000, 0, 0.5);
|
|
212
|
+
const chain = new ProviderChain(
|
|
213
|
+
testProviders(),
|
|
214
|
+
[mockAdapter({ id: "typesafe-direct", calls: calls as never })],
|
|
215
|
+
fakeKeyReader(new Set(["typesafe-ai"])),
|
|
216
|
+
);
|
|
217
|
+
const evaluator = new Evaluator({
|
|
218
|
+
config: testConfig(),
|
|
219
|
+
singleFlight: new SingleFlight(4),
|
|
220
|
+
budget,
|
|
221
|
+
audit: noopAudit(),
|
|
222
|
+
chain,
|
|
223
|
+
});
|
|
224
|
+
const response = await evaluator.handle(BODY);
|
|
225
|
+
assert.equal(response.status, 502);
|
|
226
|
+
assert.equal(response.payload.error, "model_unauthorized");
|
|
227
|
+
assert.equal(calls.length, 0, "fail-closed: no provider attempt without a credential");
|
|
228
|
+
assert.equal(budget.status().tokensUsed, 0);
|
|
229
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// Unit tests: provider selection is configuration-only (M8, review fix #9) —
|
|
5
|
+
// request-level model/provider overrides are REJECTED, not merely checked
|
|
6
|
+
// against an allowlist. Unknown request fields are rejected (closed schema).
|
|
7
|
+
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { Evaluator } from "../janus/evaluate.js";
|
|
11
|
+
import { SingleFlight } from "../janus/single_flight.js";
|
|
12
|
+
import { DailyBudget } from "../janus/budget_breaker.js";
|
|
13
|
+
import { AuditLog } from "../janus/audit.js";
|
|
14
|
+
import { enforceRequest } from "../janus/schema.js";
|
|
15
|
+
import { JanusError } from "../janus/errors.js";
|
|
16
|
+
import { testConfig, mockChain } from "./helpers.js";
|
|
17
|
+
|
|
18
|
+
function makeEvaluator() {
|
|
19
|
+
const auditEntries: unknown[] = [];
|
|
20
|
+
const audit = {
|
|
21
|
+
append: (entry: unknown) => auditEntries.push(entry),
|
|
22
|
+
} as unknown as AuditLog;
|
|
23
|
+
const evaluator = new Evaluator({
|
|
24
|
+
config: testConfig(),
|
|
25
|
+
singleFlight: new SingleFlight(4),
|
|
26
|
+
budget: new DailyBudget(0, 0, 0.5),
|
|
27
|
+
audit,
|
|
28
|
+
chain: mockChain({ answers: { q: { type: "boolean", probability: 0.95 } } }) as never,
|
|
29
|
+
});
|
|
30
|
+
return { evaluator, auditEntries };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("request without model/provider fields is accepted", async () => {
|
|
34
|
+
const { evaluator } = makeEvaluator();
|
|
35
|
+
const response = await evaluator.handle({
|
|
36
|
+
state: "s",
|
|
37
|
+
questions: [{ type: "boolean", name: "q", question: "ok?" }],
|
|
38
|
+
});
|
|
39
|
+
assert.equal(response.status, 200);
|
|
40
|
+
assert.equal(response.payload.provider, "typesafe-direct");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("request-level model override is REJECTED (validation_error, not allowlist check)", async () => {
|
|
44
|
+
const { evaluator } = makeEvaluator();
|
|
45
|
+
for (const model of ["openai/gpt-5", "jev-latest", "typesafe-ai/jev"]) {
|
|
46
|
+
const response = await evaluator.handle({
|
|
47
|
+
state: "s",
|
|
48
|
+
model,
|
|
49
|
+
questions: [{ type: "boolean", name: "q", question: "ok?" }],
|
|
50
|
+
});
|
|
51
|
+
assert.equal(response.status, 400, `model ${model} must be rejected`);
|
|
52
|
+
assert.equal(response.payload.error, "validation_error");
|
|
53
|
+
assert.match(String(response.payload.message), /unknown request field/);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("request-level provider override is REJECTED", async () => {
|
|
58
|
+
const { evaluator } = makeEvaluator();
|
|
59
|
+
const response = await evaluator.handle({
|
|
60
|
+
state: "s",
|
|
61
|
+
provider: "acme-gateway",
|
|
62
|
+
questions: [{ type: "boolean", name: "q", question: "ok?" }],
|
|
63
|
+
});
|
|
64
|
+
assert.equal(response.status, 400);
|
|
65
|
+
assert.equal(response.payload.error, "validation_error");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("any other unknown request field is rejected (closed schema, review fix #9)", async () => {
|
|
69
|
+
const { evaluator } = makeEvaluator();
|
|
70
|
+
for (const field of ["apiKey", "maxTokens", "temperature", "stream", "criteria"]) {
|
|
71
|
+
const response = await evaluator.handle({
|
|
72
|
+
state: "s",
|
|
73
|
+
[field]: "x",
|
|
74
|
+
questions: [{ type: "boolean", name: "q", question: "ok?" }],
|
|
75
|
+
});
|
|
76
|
+
assert.equal(response.status, 400);
|
|
77
|
+
assert.equal(response.payload.error, "validation_error");
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("schema enforcement rejects unknown per-question fields", () => {
|
|
82
|
+
assert.throws(
|
|
83
|
+
() =>
|
|
84
|
+
enforceRequest({
|
|
85
|
+
state: "s",
|
|
86
|
+
questions: [{ type: "boolean", name: "q", question: "ok?", apiKey: "x" }],
|
|
87
|
+
}),
|
|
88
|
+
(error: unknown) => error instanceof JanusError && error.code === "validation_error",
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("schema enforcement rejects unknown top-level fields", () => {
|
|
93
|
+
assert.throws(
|
|
94
|
+
() => enforceRequest({ state: "s", model: "m", questions: [{ type: "boolean", name: "q", question: "ok?" }] }),
|
|
95
|
+
(error: unknown) => error instanceof JanusError && error.code === "validation_error",
|
|
96
|
+
);
|
|
97
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// Unit tests: closed config validation (review fix #9) — unknown fields and
|
|
5
|
+
// invalid values are rejected, never defaulted silently; caps are enforced
|
|
6
|
+
// (request budget <= 30k, max concurrency <= 4, redaction immutable-on).
|
|
7
|
+
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { loadConfig } from "../janus/config.js";
|
|
11
|
+
import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
function configWith(overrides: Record<string, unknown>): string {
|
|
16
|
+
const dir = mkdtempSync(join(tmpdir(), "janus-config-test-"));
|
|
17
|
+
const path = join(dir, "janus-config.json");
|
|
18
|
+
writeFileSync(path, JSON.stringify(overrides));
|
|
19
|
+
return path;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function expectConfigError(path: string, fragment?: string | RegExp): void {
|
|
23
|
+
assert.throws(
|
|
24
|
+
() => loadConfig(path),
|
|
25
|
+
(error: unknown) => {
|
|
26
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
27
|
+
if (fragment) {
|
|
28
|
+
if (typeof fragment === "string") assert.match(message, new RegExp(fragment));
|
|
29
|
+
else assert.match(message, fragment);
|
|
30
|
+
}
|
|
31
|
+
return /config invalid/.test(message);
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("default config: sole typesafe-direct provider, 30k budget, redaction on", () => {
|
|
37
|
+
const dir = mkdtempSync(join(tmpdir(), "janus-config-test-"));
|
|
38
|
+
try {
|
|
39
|
+
const { config, warnings } = loadConfig(join(dir, "missing.json"));
|
|
40
|
+
assert.equal(config.providers[0]!.id, "typesafe-direct");
|
|
41
|
+
assert.deepEqual(config.providers[0]!.keychain, { account: "typesafe-ai", service: "Typesafe AI" });
|
|
42
|
+
assert.equal(config.providers.length, 1, "the fallback providers are dropped");
|
|
43
|
+
assert.equal(config.requestBudgetTokens, 30_000);
|
|
44
|
+
assert.equal(config.maxConcurrent, 4);
|
|
45
|
+
assert.equal(config.redactSecrets, true);
|
|
46
|
+
assert.equal(config.defaultModel, "jev-latest");
|
|
47
|
+
assert.ok(warnings.length > 0, "missing file is a warning, not an error");
|
|
48
|
+
} finally {
|
|
49
|
+
rmSync(dir, { recursive: true, force: true });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("unknown top-level fields are rejected (review fix #9)", () => {
|
|
54
|
+
expectConfigError(configWith({ modelAllowlist: ["x"] }), /unknown config field/);
|
|
55
|
+
expectConfigError(configWith({ keychainService: "x" }), /unknown config field/);
|
|
56
|
+
expectConfigError(configWith({ maxTokens: 5 }), /unknown config field/);
|
|
57
|
+
expectConfigError(configWith({ redaction: "off" }), /unknown config field/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("unknown audit fields are rejected", () => {
|
|
61
|
+
expectConfigError(configWith({ audit: { path: "stdout", retentionDays: 1, extra: true } }), /audit\.extra/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("invalid values are rejected, not defaulted silently (review fix #9)", () => {
|
|
65
|
+
expectConfigError(configWith({ port: "http" }));
|
|
66
|
+
expectConfigError(configWith({ port: 99_999 }));
|
|
67
|
+
expectConfigError(configWith({ timeoutMs: 0 }));
|
|
68
|
+
expectConfigError(configWith({ maxConcurrent: 1.5 }));
|
|
69
|
+
expectConfigError(configWith({ requestBudgetTokens: -5 }));
|
|
70
|
+
expectConfigError(configWith({ audit: { retentionDays: "soon" } }));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("request budget cannot be raised above 30,000 (review fix #3/#9)", () => {
|
|
74
|
+
expectConfigError(configWith({ requestBudgetTokens: 30_001 }), /hard ceiling/);
|
|
75
|
+
expectConfigError(configWith({ requestBudgetTokens: 1_000_000 }), /hard ceiling/);
|
|
76
|
+
const ok = loadConfig(configWith({ requestBudgetTokens: 29_999 }));
|
|
77
|
+
assert.equal(ok.config.requestBudgetTokens, 29_999);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("max concurrency cannot be raised above 4 (review fix #9)", () => {
|
|
81
|
+
expectConfigError(configWith({ maxConcurrent: 5 }), /hard ceiling/);
|
|
82
|
+
const ok = loadConfig(configWith({ maxConcurrent: 2 }));
|
|
83
|
+
assert.equal(ok.config.maxConcurrent, 2);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("timeout cannot be raised above 30s (review fix #9)", () => {
|
|
87
|
+
expectConfigError(configWith({ timeoutMs: 31_000 }), /hard ceiling/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("redaction is immutable-on: redactSecrets: false is rejected (review fix #4/#9)", () => {
|
|
91
|
+
expectConfigError(configWith({ redactSecrets: false }), /redaction is always on/);
|
|
92
|
+
const ok = loadConfig(configWith({ redactSecrets: true }));
|
|
93
|
+
assert.equal(ok.config.redactSecrets, true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("provider entries are validated closed (review fix #2)", () => {
|
|
97
|
+
expectConfigError(configWith({ providers: [{ id: "acme", model: "m", keychain: { account: "a", service: "s" } }] }), /providers\[0\]\.id/);
|
|
98
|
+
// The fallback gateways are DROPPED: their ids are not valid configuration.
|
|
99
|
+
expectConfigError(configWith({ providers: [{ id: "vercel-ai-gateway", model: "m", keychain: { account: "a", service: "s" } }] }), /providers\[0\]\.id/);
|
|
100
|
+
expectConfigError(configWith({ providers: [{ id: "merge-gateway", model: "m", keychain: { account: "a", service: "s" } }] }), /providers\[0\]\.id/);
|
|
101
|
+
expectConfigError(configWith({ providers: [{ id: "typesafe-direct", model: "", keychain: { account: "a", service: "s" } }] }), /model/);
|
|
102
|
+
expectConfigError(configWith({ providers: [{ id: "typesafe-direct", model: "m", keychain: { account: "a" } }] }), /keychain\.service/);
|
|
103
|
+
expectConfigError(configWith({ providers: [{ id: "typesafe-direct", model: "m", keychain: { account: "a", service: "s", apiKey: "x" } }] }), /unknown field/);
|
|
104
|
+
expectConfigError(configWith({ providers: [] }), /nonempty array/);
|
|
105
|
+
expectConfigError(
|
|
106
|
+
configWith({
|
|
107
|
+
providers: [
|
|
108
|
+
{ id: "typesafe-direct", model: "m", keychain: { account: "a", service: "s" } },
|
|
109
|
+
{ id: "typesafe-direct", model: "m2", keychain: { account: "a", service: "s" } },
|
|
110
|
+
],
|
|
111
|
+
}),
|
|
112
|
+
/at most one provider entry is valid/,
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("valid custom provider chain loads; defaultModel must match a provider model", () => {
|
|
117
|
+
const ok = loadConfig(
|
|
118
|
+
configWith({
|
|
119
|
+
providers: [
|
|
120
|
+
{ id: "typesafe-direct", model: "jev-latest", keychain: { account: "typesafe-ai", service: "Typesafe AI" } },
|
|
121
|
+
],
|
|
122
|
+
defaultModel: "jev-latest",
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
assert.equal(ok.config.defaultModel, "jev-latest");
|
|
126
|
+
expectConfigError(
|
|
127
|
+
configWith({
|
|
128
|
+
providers: [
|
|
129
|
+
{ id: "typesafe-direct", model: "jev-latest", keychain: { account: "a", service: "s" } },
|
|
130
|
+
],
|
|
131
|
+
defaultModel: "other-model",
|
|
132
|
+
}),
|
|
133
|
+
/defaultModel/,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("malformed config JSON is an error, not a silent default", () => {
|
|
138
|
+
const dir = mkdtempSync(join(tmpdir(), "janus-config-test-"));
|
|
139
|
+
const path = join(dir, "bad.json");
|
|
140
|
+
writeFileSync(path, "{not json");
|
|
141
|
+
expectConfigError(path, /failed to parse/);
|
|
142
|
+
rmSync(dir, { recursive: true, force: true });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("non-object config JSON is an error", () => {
|
|
146
|
+
const dir = mkdtempSync(join(tmpdir(), "janus-config-test-"));
|
|
147
|
+
const path = join(dir, "array.json");
|
|
148
|
+
writeFileSync(path, "[1,2,3]");
|
|
149
|
+
expectConfigError(path);
|
|
150
|
+
rmSync(dir, { recursive: true, force: true });
|
|
151
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// Unit tests: the §5 error taxonomy — callers must distinguish "model
|
|
5
|
+
// returned false" (200) from "no verdict" (timeout/gateway_unavailable) from
|
|
6
|
+
// "bad request" (validation_error) — plus provider error classification with
|
|
7
|
+
// FIXED public messages (review fix #5).
|
|
8
|
+
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import {
|
|
12
|
+
APIError,
|
|
13
|
+
APIConnectionError,
|
|
14
|
+
APITimeoutError,
|
|
15
|
+
APIUserAbortError,
|
|
16
|
+
TypeSafeError,
|
|
17
|
+
} from "@typesafe-ai/sdk";
|
|
18
|
+
import {
|
|
19
|
+
JanusError,
|
|
20
|
+
busyError,
|
|
21
|
+
gatewayUnavailable,
|
|
22
|
+
timeoutError,
|
|
23
|
+
validationError,
|
|
24
|
+
} from "../janus/errors.js";
|
|
25
|
+
import {
|
|
26
|
+
classifyGatewayError,
|
|
27
|
+
classifyTypesafeError,
|
|
28
|
+
ProviderTransportError,
|
|
29
|
+
} from "../janus/providers.js";
|
|
30
|
+
|
|
31
|
+
test("taxonomy codes are exactly the §5 set", () => {
|
|
32
|
+
const codes: JanusError["code"][] = [
|
|
33
|
+
"validation_error",
|
|
34
|
+
"state_too_large",
|
|
35
|
+
"unknown_question",
|
|
36
|
+
"model_unauthorized",
|
|
37
|
+
"gateway_unavailable",
|
|
38
|
+
"timeout",
|
|
39
|
+
"busy",
|
|
40
|
+
"degraded",
|
|
41
|
+
];
|
|
42
|
+
for (const code of codes) {
|
|
43
|
+
const error = new JanusError(code, "message", 400);
|
|
44
|
+
assert.equal(error.code, code);
|
|
45
|
+
const body = error.body("req-1");
|
|
46
|
+
assert.deepEqual(
|
|
47
|
+
{ error: body.error, message: body.message, request_id: body.request_id },
|
|
48
|
+
{ error: code, message: "message", request_id: "req-1" },
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("error body shape is {error, message, request_id} with optional detail", () => {
|
|
54
|
+
const plain = validationError("bad").body("r1");
|
|
55
|
+
assert.deepEqual(plain, { error: "validation_error", message: "bad", request_id: "r1" });
|
|
56
|
+
const detailed = busyError("cap", { budget_exhausted: { kind: "tokens" } }).body("r2");
|
|
57
|
+
assert.deepEqual(detailed.detail, { budget_exhausted: { kind: "tokens" } });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("status mapping separates classes", () => {
|
|
61
|
+
assert.equal(validationError("x").status, 400);
|
|
62
|
+
assert.equal(busyError("x").status, 429);
|
|
63
|
+
assert.equal(timeoutError("x").status, 504);
|
|
64
|
+
assert.equal(gatewayUnavailable("x").status, 503);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
function apiError(status: number, body: unknown): APIError {
|
|
68
|
+
return APIError.fromResponse(status, body, new Headers());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
test("typesafe classification: abort → timeout with fixed message", () => {
|
|
72
|
+
const failure = classifyTypesafeError(new APIUserAbortError());
|
|
73
|
+
assert.equal(failure.failure.error.code, "timeout");
|
|
74
|
+
assert.equal(failure.failure.error.message, "evaluation timed out");
|
|
75
|
+
// Timeout is a transport failure: eligible for the single failover to the
|
|
76
|
+
// NEXT provider, never retried in place on the same one.
|
|
77
|
+
assert.equal(failure.failure.failoverEligible, true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("typesafe classification: connection/timeout errors → gateway_unavailable, failover eligible", () => {
|
|
81
|
+
for (const error of [new APIConnectionError("dns broke"), new APITimeoutError(1000)]) {
|
|
82
|
+
const failure = classifyTypesafeError(error);
|
|
83
|
+
assert.equal(failure.failure.error.code, "gateway_unavailable");
|
|
84
|
+
assert.equal(failure.failure.failoverEligible, true);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("typesafe classification: 401/403 → model_unauthorized, failover eligible", () => {
|
|
89
|
+
for (const status of [401, 403]) {
|
|
90
|
+
const failure = classifyTypesafeError(apiError(status, {}));
|
|
91
|
+
assert.equal(failure.failure.error.code, "model_unauthorized");
|
|
92
|
+
assert.equal(failure.failure.failoverEligible, true);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("typesafe classification: 429/404/5xx → gateway_unavailable, failover eligible", () => {
|
|
97
|
+
for (const status of [429, 404, 500, 503]) {
|
|
98
|
+
const failure = classifyTypesafeError(apiError(status, {}));
|
|
99
|
+
assert.equal(failure.failure.error.code, "gateway_unavailable");
|
|
100
|
+
assert.equal(failure.failure.failoverEligible, true);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("typesafe classification: client-side TypeSafeError fails closed as unavailable", () => {
|
|
105
|
+
const failure = classifyTypesafeError(new TypeSafeError("bad criteria shape"));
|
|
106
|
+
assert.equal(failure.failure.error.code, "gateway_unavailable");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("gateway classification: abort → timeout; network → unavailable; auth → unauthorized", () => {
|
|
110
|
+
const abortError = new Error("Request aborted");
|
|
111
|
+
abortError.name = "AbortError";
|
|
112
|
+
assert.equal(classifyGatewayError(abortError).failure.error.code, "timeout");
|
|
113
|
+
assert.equal(
|
|
114
|
+
classifyGatewayError(new Error("fetch failed: ECONNREFUSED")).failure.error.code,
|
|
115
|
+
"gateway_unavailable",
|
|
116
|
+
);
|
|
117
|
+
assert.equal(
|
|
118
|
+
classifyGatewayError(new Error("401 Unauthorized: invalid api key")).failure.error.code,
|
|
119
|
+
"model_unauthorized",
|
|
120
|
+
);
|
|
121
|
+
assert.equal(classifyGatewayError(new Error("429 rate limit exceeded")).failure.error.code, "gateway_unavailable");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("public messages never contain provider/SDK/internal error text (review fix #5)", () => {
|
|
125
|
+
const sensitive = "sk-live-abcdef0123456789 internal stack detail";
|
|
126
|
+
const errors: unknown[] = [
|
|
127
|
+
apiError(500, { message: sensitive }),
|
|
128
|
+
new APIConnectionError(sensitive),
|
|
129
|
+
new TypeSafeError(sensitive),
|
|
130
|
+
new Error(sensitive),
|
|
131
|
+
];
|
|
132
|
+
for (const error of errors) {
|
|
133
|
+
const classified = error instanceof TypeSafeError && !(error instanceof APIError)
|
|
134
|
+
? classifyTypesafeError(error)
|
|
135
|
+
: classifyTypesafeError(error);
|
|
136
|
+
const message = classified.failure.error.message;
|
|
137
|
+
assert.ok(!message.includes(sensitive), `message must be fixed, got: ${message}`);
|
|
138
|
+
assert.ok(!/sk-/.test(message));
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("ProviderTransportError preserves classification and is idempotent", () => {
|
|
143
|
+
const first = classifyTypesafeError(apiError(429, {}));
|
|
144
|
+
assert.ok(first instanceof ProviderTransportError);
|
|
145
|
+
const again = classifyTypesafeError(first);
|
|
146
|
+
assert.equal(again, first, "re-classifying a ProviderTransportError returns it unchanged");
|
|
147
|
+
});
|