@pi-in-go/pigpen-pi-typesafe 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CREDITS.md +14 -0
- package/LICENSE +22 -0
- package/README.md +45 -0
- package/extensions/pi-typesafe/branches_test.go +185 -0
- package/extensions/pi-typesafe/command.go +319 -0
- package/extensions/pi-typesafe/export_test.go +9 -0
- package/extensions/pi-typesafe/extension.go +188 -0
- package/extensions/pi-typesafe/extension_test.go +321 -0
- package/extensions/pi-typesafe/fakehost_test.go +548 -0
- package/extensions/pi-typesafe/format.go +191 -0
- package/extensions/pi-typesafe/format_test.go +75 -0
- package/extensions/pi-typesafe/go.mod +10 -0
- package/extensions/pi-typesafe/go.sum +2 -0
- package/extensions/pi-typesafe/go.work +11 -0
- package/extensions/pi-typesafe/harness_test.go +200 -0
- package/extensions/pi-typesafe/ownmodel_test.go +100 -0
- package/extensions/pi-typesafe/review_test.go +134 -0
- package/extensions/pi-typesafe/tool.go +193 -0
- package/extensions/pi-typesafe/twin_test.go +28 -0
- package/libs/pi-typesafe-api/CREDITS.md +14 -0
- package/libs/pi-typesafe-api/LICENSE +22 -0
- package/libs/pi-typesafe-api/README.md +30 -0
- package/libs/pi-typesafe-api/ask.go +62 -0
- package/libs/pi-typesafe-api/ask_test.go +76 -0
- package/libs/pi-typesafe-api/auth.go +249 -0
- package/libs/pi-typesafe-api/auth_test.go +131 -0
- package/libs/pi-typesafe-api/backends.go +336 -0
- package/libs/pi-typesafe-api/backends_test.go +404 -0
- package/libs/pi-typesafe-api/batch.go +202 -0
- package/libs/pi-typesafe-api/batch_test.go +202 -0
- package/libs/pi-typesafe-api/battery_test.go +41 -0
- package/libs/pi-typesafe-api/calibrate.go +354 -0
- package/libs/pi-typesafe-api/calibrate_test.go +186 -0
- package/libs/pi-typesafe-api/client.go +615 -0
- package/libs/pi-typesafe-api/client_test.go +490 -0
- package/libs/pi-typesafe-api/credentials.go +252 -0
- package/libs/pi-typesafe-api/credentials_test.go +216 -0
- package/libs/pi-typesafe-api/doc.go +14 -0
- package/libs/pi-typesafe-api/errors.go +143 -0
- package/libs/pi-typesafe-api/evaluation.go +86 -0
- package/libs/pi-typesafe-api/evaluation_schema.json +264 -0
- package/libs/pi-typesafe-api/gaps_test.go +77 -0
- package/libs/pi-typesafe-api/go.mod +9 -0
- package/libs/pi-typesafe-api/go.sum +2 -0
- package/libs/pi-typesafe-api/helpers_test.go +169 -0
- package/libs/pi-typesafe-api/hostmodel/hostmodel.go +87 -0
- package/libs/pi-typesafe-api/json.go +299 -0
- package/libs/pi-typesafe-api/json_test.go +92 -0
- package/libs/pi-typesafe-api/ownmodel_test.go +79 -0
- package/libs/pi-typesafe-api/package.json +40 -0
- package/libs/pi-typesafe-api/provenance.json +18 -0
- package/libs/pi-typesafe-api/review_test.go +23 -0
- package/libs/pi-typesafe-api/schema.go +473 -0
- package/libs/pi-typesafe-api/schema_test.go +262 -0
- package/libs/pi-typesafe-api/testdata/tools/typebox-messages.mts +5 -0
- package/libs/pi-typesafe-api/testdata/typebox-messages.json +285 -0
- package/libs/pi-typesafe-api/twin_test.go +28 -0
- package/libs/pi-typesafe-api/ui/fakehost_test.go +548 -0
- package/libs/pi-typesafe-api/ui/keyprompt.go +115 -0
- package/libs/pi-typesafe-api/ui/login.go +106 -0
- package/libs/pi-typesafe-api/ui/twin_test.go +28 -0
- package/libs/pi-typesafe-api/ui/ui_test.go +285 -0
- package/libs/pi-typesafe-api/usage.go +366 -0
- package/libs/pi-typesafe-api/usage_test.go +139 -0
- package/libs/typesafe/CONTRACT.md +125 -0
- package/libs/typesafe/CREDITS.md +37 -0
- package/libs/typesafe/LICENSE +23 -0
- package/libs/typesafe/README.md +19 -0
- package/libs/typesafe/go.mod +3 -0
- package/libs/typesafe/libraries/ownmodel/backend_test.go +496 -0
- package/libs/typesafe/libraries/ownmodel/canon.go +190 -0
- package/libs/typesafe/libraries/ownmodel/convert.go +199 -0
- package/libs/typesafe/libraries/ownmodel/doc.go +15 -0
- package/libs/typesafe/libraries/ownmodel/equivalence_test.go +199 -0
- package/libs/typesafe/libraries/ownmodel/helpers_test.go +155 -0
- package/libs/typesafe/libraries/ownmodel/mutation_test.go +31 -0
- package/libs/typesafe/libraries/ownmodel/ownmodel.go +225 -0
- package/libs/typesafe/libraries/ownmodel/plan.go +442 -0
- package/libs/typesafe/libraries/ownmodel/run.go +288 -0
- package/libs/typesafe/libraries/ownmodel/schema_test.go +254 -0
- package/libs/typesafe/libraries/ownmodel/twins_test.go +169 -0
- package/libs/typesafe/libraries/ownmodel/utils_test.go +125 -0
- package/libs/typesafe/libraries/pigmodel/pigmodel.go +264 -0
- package/libs/typesafe/libraries/pigmodel/pigmodel_test.go +410 -0
- package/libs/typesafe/libraries/typesafe/answers.go +268 -0
- package/libs/typesafe/libraries/typesafe/api_response_test.go +113 -0
- package/libs/typesafe/libraries/typesafe/batch.go +80 -0
- package/libs/typesafe/libraries/typesafe/batch_test.go +133 -0
- package/libs/typesafe/libraries/typesafe/bench_test.go +71 -0
- package/libs/typesafe/libraries/typesafe/client.go +561 -0
- package/libs/typesafe/libraries/typesafe/client_test.go +495 -0
- package/libs/typesafe/libraries/typesafe/crosscheck_test.go +464 -0
- package/libs/typesafe/libraries/typesafe/crosscheck_workflowevals_test.go +219 -0
- package/libs/typesafe/libraries/typesafe/doc.go +27 -0
- package/libs/typesafe/libraries/typesafe/entry.go +142 -0
- package/libs/typesafe/libraries/typesafe/env.go +11 -0
- package/libs/typesafe/libraries/typesafe/errors.go +310 -0
- package/libs/typesafe/libraries/typesafe/errors_test.go +175 -0
- package/libs/typesafe/libraries/typesafe/helpers_test.go +294 -0
- package/libs/typesafe/libraries/typesafe/live_test.go +96 -0
- package/libs/typesafe/libraries/typesafe/logging.go +160 -0
- package/libs/typesafe/libraries/typesafe/logging_test.go +259 -0
- package/libs/typesafe/libraries/typesafe/marshal_test.go +112 -0
- package/libs/typesafe/libraries/typesafe/mutation_test.go +39 -0
- package/libs/typesafe/libraries/typesafe/questions.go +490 -0
- package/libs/typesafe/libraries/typesafe/questions_test.go +166 -0
- package/libs/typesafe/libraries/typesafe/regressions_test.go +159 -0
- package/libs/typesafe/libraries/typesafe/reliability_test.go +649 -0
- package/libs/typesafe/libraries/typesafe/retry.go +350 -0
- package/libs/typesafe/libraries/typesafe/retry_test.go +297 -0
- package/libs/typesafe/libraries/typesafe/runtime_test.go +26 -0
- package/libs/typesafe/libraries/typesafe/transport_test.go +163 -0
- package/libs/typesafe/libraries/typesafe/twins_test.go +127 -0
- package/libs/typesafe/libraries/typesafe/types_test.go +165 -0
- package/libs/typesafe/libraries/typesafe/version.go +10 -0
- package/libs/typesafe/package.json +37 -0
- package/libs/typesafe/provenance.json +49 -0
- package/package.json +42 -0
- package/port/PORT.md +98 -0
- package/port/accepted-gaps.json +3 -0
- package/port/golden/enable-confirm.jsonl +11 -0
- package/port/golden/enable-decline.jsonl +20 -0
- package/port/golden/enable-missing-key.jsonl +4 -0
- package/port/golden/login-shadow.jsonl +4 -0
- package/port/golden/logout-env-key.jsonl +6 -0
- package/port/golden/playground-cancel.jsonl +4 -0
- package/port/golden/playground-invalid-json.jsonl +5 -0
- package/port/golden/playground-invalid-questions.jsonl +5 -0
- package/port/golden/status-env-key.jsonl +6 -0
- package/port/golden/status-no-key.jsonl +6 -0
- package/port/golden/tool-disabled.jsonl +18 -0
- package/port/golden/trailing-words.jsonl +10 -0
- package/port/library-mutations.py +44 -0
- package/port/mutations.json +302 -0
- package/port/oracle/.env.example +4 -0
- package/port/oracle/CHANGELOG.md +91 -0
- package/port/oracle/CONTRIBUTING.md +35 -0
- package/port/oracle/LICENSE +21 -0
- package/port/oracle/README.md +159 -0
- package/port/oracle/docs/api.md +143 -0
- package/port/oracle/docs/ci-cd.md +97 -0
- package/port/oracle/examples/decision-extension.ts +41 -0
- package/port/oracle/extensions/index.js +2 -0
- package/port/oracle/package.json +89 -0
- package/port/oracle/scripts/dev-pi.mjs +23 -0
- package/port/oracle/scripts/live-smoke.mjs +35 -0
- package/port/oracle/src/ask.ts +42 -0
- package/port/oracle/src/auth.ts +171 -0
- package/port/oracle/src/backends.ts +196 -0
- package/port/oracle/src/batch.ts +170 -0
- package/port/oracle/src/calibrate.ts +237 -0
- package/port/oracle/src/client.ts +310 -0
- package/port/oracle/src/credentials.ts +136 -0
- package/port/oracle/src/errors.ts +53 -0
- package/port/oracle/src/extension.ts +204 -0
- package/port/oracle/src/index.ts +31 -0
- package/port/oracle/src/key-prompt.ts +51 -0
- package/port/oracle/src/login.ts +60 -0
- package/port/oracle/src/schema.ts +158 -0
- package/port/oracle/src/ui.ts +4 -0
- package/port/oracle/src/usage.ts +258 -0
- package/port/oracle/tests/ask.test.ts +63 -0
- package/port/oracle/tests/auth.test.ts +141 -0
- package/port/oracle/tests/backends.test.ts +380 -0
- package/port/oracle/tests/batch.test.ts +156 -0
- package/port/oracle/tests/calibrate.test.ts +144 -0
- package/port/oracle/tests/client.test.ts +499 -0
- package/port/oracle/tests/credentials.test.ts +144 -0
- package/port/oracle/tests/extension.test.ts +276 -0
- package/port/oracle/tests/key-prompt.test.ts +47 -0
- package/port/oracle/tests/login.test.ts +101 -0
- package/port/oracle/tests/schema.test.ts +85 -0
- package/port/oracle/tests/usage.test.ts +106 -0
- package/port/oracle/tsconfig.build.json +10 -0
- package/port/oracle/tsconfig.json +14 -0
- package/port/scenarios/enable-confirm.json +5 -0
- package/port/scenarios/enable-decline.json +3 -0
- package/port/scenarios/enable-missing-key.json +2 -0
- package/port/scenarios/login-shadow.json +2 -0
- package/port/scenarios/logout-env-key.json +3 -0
- package/port/scenarios/playground-cancel.json +2 -0
- package/port/scenarios/playground-invalid-json.json +2 -0
- package/port/scenarios/playground-invalid-questions.json +2 -0
- package/port/scenarios/status-env-key.json +3 -0
- package/port/scenarios/status-no-key.json +3 -0
- package/port/scenarios/tool-disabled.json +2 -0
- package/port/scenarios/trailing-words.json +5 -0
- package/port/upstream-tests.json +160 -0
- package/provenance.json +18 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { after, before, beforeEach, test } from "node:test";
|
|
6
|
+
import {
|
|
7
|
+
authState, backendHost, clearAuthState, clearStoredApiKey, createTypeSafe, keySituation,
|
|
8
|
+
noul, resolveBackend, storeApiKey, TypeSafeIntegrationError,
|
|
9
|
+
} from "../src/index.js";
|
|
10
|
+
import type { BackendEndpoint, BackendSpec, Questions } from "../src/index.js";
|
|
11
|
+
import { safeError } from "../src/errors.js";
|
|
12
|
+
import { APIError } from "@typesafe-ai/sdk";
|
|
13
|
+
|
|
14
|
+
function responseFor(questions: Questions): Response {
|
|
15
|
+
const answers = Object.fromEntries(Object.entries(questions).map(([id, q]) => {
|
|
16
|
+
if (q.type === "noul") return [id, { type: "noul", noul: 0.9 }];
|
|
17
|
+
if (q.type === "choice") {
|
|
18
|
+
const keys = Object.keys(q.criteria);
|
|
19
|
+
return [id, { type: "choice", choice: keys[0], confidence: 1, probabilities: Object.fromEntries(keys.map((key, i) => [key, i === 0 ? 1 : 0])) }];
|
|
20
|
+
}
|
|
21
|
+
return [id, { type: "score", score: 0, confidence: 1, probabilities: Object.fromEntries(q.criteria.map((_, i) => [i, i === 0 ? 1 : 0])), legend: Object.fromEntries(q.criteria.map((level, i) => [i, level])) }];
|
|
22
|
+
}));
|
|
23
|
+
return Response.json({ model: "jev-test", answers, usage: { input_tokens: 42, output_tokens: 0 } });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const sample = () => ({ state: "synthetic", questions: { yes: noul("Is this synthetic?") } });
|
|
27
|
+
const gateway: BackendEndpoint = { label: "Gateway", host: "https://gw.example.com", path: "/jev/v1/systemone", keyEnv: "GATEWAY_JEV_KEY", defaultModel: "jev-latest" };
|
|
28
|
+
// Obviously fake keys only; nothing here ever reaches a network.
|
|
29
|
+
const ccKey = "cc_fake_key_0123456789abcdef";
|
|
30
|
+
const gwKey = "gw_fake_key_0123456789abcdef";
|
|
31
|
+
const tsKey = "ts_fake_key_0123456789abcdef";
|
|
32
|
+
const storedKey = "st_fake_key_0123456789abcdef";
|
|
33
|
+
|
|
34
|
+
const savedAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
35
|
+
const savedEnv = new Map<string, string | undefined>(["TYPESAFE_API_KEY", "COMMANDCODE_API_KEY", "GATEWAY_JEV_KEY"].map(name => [name, process.env[name]]));
|
|
36
|
+
before(() => {
|
|
37
|
+
process.env.PI_CODING_AGENT_DIR = mkdtempSync(join(tmpdir(), "pi-typesafe-backends-"));
|
|
38
|
+
});
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
for (const name of ["TYPESAFE_API_KEY", "COMMANDCODE_API_KEY", "GATEWAY_JEV_KEY"]) delete process.env[name];
|
|
41
|
+
clearStoredApiKey();
|
|
42
|
+
clearAuthState();
|
|
43
|
+
});
|
|
44
|
+
after(() => {
|
|
45
|
+
if (savedAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = savedAgentDir;
|
|
46
|
+
for (const [name, value] of savedEnv) {
|
|
47
|
+
if (value === undefined) delete process.env[name]; else process.env[name] = value;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function assertRefuses(backend: unknown, message: string, options: { apiKey?: string; model?: string } = {}): void {
|
|
52
|
+
let fetchCalls = 0;
|
|
53
|
+
const fetch = async () => { fetchCalls += 1; return new Response("{}"); };
|
|
54
|
+
assert.throws(() => createTypeSafe({
|
|
55
|
+
backend: backend as BackendSpec,
|
|
56
|
+
fetch,
|
|
57
|
+
...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }),
|
|
58
|
+
...(options.model === undefined ? {} : { model: options.model }),
|
|
59
|
+
}), (error: unknown) => {
|
|
60
|
+
assert.ok(error instanceof TypeSafeIntegrationError);
|
|
61
|
+
assert.equal(error.code, "configuration");
|
|
62
|
+
assert.equal(error.message, message);
|
|
63
|
+
assert.ok(!error.message.includes("user:pw"));
|
|
64
|
+
return true;
|
|
65
|
+
});
|
|
66
|
+
assert.equal(fetchCalls, 0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 1. Command Code request.
|
|
70
|
+
|
|
71
|
+
test("a commandcode request goes to the Command Code path with its own key and model", async () => {
|
|
72
|
+
process.env.COMMANDCODE_API_KEY = ccKey;
|
|
73
|
+
const questions = sample().questions;
|
|
74
|
+
let calls = 0;
|
|
75
|
+
const client = createTypeSafe({ backend: "commandcode", fetch: async (url, init) => {
|
|
76
|
+
calls += 1;
|
|
77
|
+
assert.equal(String(url), "https://api.commandcode.ai/provider/v1/systemone");
|
|
78
|
+
assert.equal(new Headers(init?.headers).get("authorization"), `Bearer ${ccKey}`);
|
|
79
|
+
assert.equal((JSON.parse(String(init?.body)) as { model: string }).model, "typesafe/jev");
|
|
80
|
+
return responseFor(questions);
|
|
81
|
+
} });
|
|
82
|
+
await client.evaluate(sample());
|
|
83
|
+
assert.equal(calls, 1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a per-request model on commandcode is sent unchanged", async () => {
|
|
87
|
+
process.env.COMMANDCODE_API_KEY = ccKey;
|
|
88
|
+
const questions = sample().questions;
|
|
89
|
+
const client = createTypeSafe({ backend: "commandcode", fetch: async (_url, init) => {
|
|
90
|
+
assert.equal((JSON.parse(String(init?.body)) as { model: string }).model, "jev-2.0");
|
|
91
|
+
return responseFor(questions);
|
|
92
|
+
} });
|
|
93
|
+
await client.evaluate({ ...sample(), model: "jev-2.0" });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// 2. Custom object.
|
|
97
|
+
|
|
98
|
+
test("a caller-supplied endpoint sends to its own path with its own key and unmapped model", async () => {
|
|
99
|
+
process.env.GATEWAY_JEV_KEY = gwKey;
|
|
100
|
+
const questions = sample().questions;
|
|
101
|
+
let calls = 0;
|
|
102
|
+
const client = createTypeSafe({ backend: gateway, fetch: async (url, init) => {
|
|
103
|
+
calls += 1;
|
|
104
|
+
assert.equal(String(url), "https://gw.example.com/jev/v1/systemone");
|
|
105
|
+
assert.equal(new Headers(init?.headers).get("authorization"), `Bearer ${gwKey}`);
|
|
106
|
+
assert.equal((JSON.parse(String(init?.body)) as { model: string }).model, "jev-latest");
|
|
107
|
+
return responseFor(questions);
|
|
108
|
+
} });
|
|
109
|
+
await client.evaluate(sample());
|
|
110
|
+
assert.equal(calls, 1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("an endpoint without a path uses the SDK's own /v1/systemone", async () => {
|
|
114
|
+
process.env.GATEWAY_JEV_KEY = gwKey;
|
|
115
|
+
const questions = sample().questions;
|
|
116
|
+
let sentUrl = "";
|
|
117
|
+
const client = createTypeSafe({ backend: { label: "Gateway", host: "https://gw.example.com", keyEnv: "GATEWAY_JEV_KEY", defaultModel: "jev-latest" }, fetch: async (url) => {
|
|
118
|
+
sentUrl = String(url);
|
|
119
|
+
return responseFor(questions);
|
|
120
|
+
} });
|
|
121
|
+
await client.evaluate(sample());
|
|
122
|
+
assert.equal(sentUrl, "https://gw.example.com/v1/systemone");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("an endpoint may use a loopback http: host", async () => {
|
|
126
|
+
process.env.GATEWAY_JEV_KEY = gwKey;
|
|
127
|
+
const questions = sample().questions;
|
|
128
|
+
let sentUrl = "";
|
|
129
|
+
const client = createTypeSafe({ backend: { label: "Local", host: "http://127.0.0.1:8787", keyEnv: "GATEWAY_JEV_KEY", defaultModel: "jev-latest" }, fetch: async (url) => {
|
|
130
|
+
sentUrl = String(url);
|
|
131
|
+
return responseFor(questions);
|
|
132
|
+
} });
|
|
133
|
+
await client.evaluate(sample());
|
|
134
|
+
assert.equal(sentUrl, "http://127.0.0.1:8787/v1/systemone");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// 3. Every refusal in the validation table.
|
|
138
|
+
|
|
139
|
+
const hostMessage = "Backend host must be an absolute https: URL with no user info, path, query, or fragment (http: is allowed only for localhost, 127.0.0.0/8, and [::1]).";
|
|
140
|
+
|
|
141
|
+
test("an endpoint label must be a nonempty string of at most 60 characters", () => {
|
|
142
|
+
for (const label of [7, "", " ", "x".repeat(61)]) {
|
|
143
|
+
assertRefuses({ label, host: "https://gw.example.com", keyEnv: "GATEWAY_JEV_KEY", defaultModel: "jev-latest" }, "Backend label must be a nonempty string of at most 60 characters.");
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("an endpoint host must be an absolute https: URL with no user info, path, query, or fragment", () => {
|
|
148
|
+
for (const host of ["http://gw.example.com", "https://user:pw@gw.example.com", "https://gw.example.com/prefix", "https://gw.example.com?x=1", "https://gw.example.com#f", "ftp://gw.example.com", "not a url"]) {
|
|
149
|
+
assertRefuses({ label: "Gateway", host, keyEnv: "GATEWAY_JEV_KEY", defaultModel: "jev-latest" }, hostMessage);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('an endpoint path must be a string that starts with "/"', () => {
|
|
154
|
+
for (const path of ["jev", "x?q", "x#f", 7]) {
|
|
155
|
+
assertRefuses({ label: "Gateway", host: "https://gw.example.com", keyEnv: "GATEWAY_JEV_KEY", defaultModel: "jev-latest", path }, 'Backend path must be a string that starts with "/".');
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('an endpoint modelsPath must be a string that starts with "/"', () => {
|
|
160
|
+
for (const modelsPath of ["models", "x?q", 7]) {
|
|
161
|
+
assertRefuses({ label: "Gateway", host: "https://gw.example.com", keyEnv: "GATEWAY_JEV_KEY", defaultModel: "jev-latest", modelsPath }, 'Backend modelsPath must be a string that starts with "/".');
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("an endpoint modelsField must be a nonempty string", () => {
|
|
166
|
+
for (const modelsField of ["", 7]) {
|
|
167
|
+
assertRefuses({ label: "Gateway", host: "https://gw.example.com", keyEnv: "GATEWAY_JEV_KEY", defaultModel: "jev-latest", modelsField }, "Backend modelsField must be a nonempty string.");
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("an endpoint modelsIdField must be a nonempty string", () => {
|
|
172
|
+
for (const modelsIdField of ["", 7]) {
|
|
173
|
+
assertRefuses({ label: "Gateway", host: "https://gw.example.com", keyEnv: "GATEWAY_JEV_KEY", defaultModel: "jev-latest", modelsIdField }, "Backend modelsIdField must be a nonempty string.");
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("an endpoint modelsVerifyKey must be a boolean", () => {
|
|
178
|
+
for (const modelsVerifyKey of ["yes", 1]) {
|
|
179
|
+
assertRefuses({ label: "Gateway", host: "https://gw.example.com", keyEnv: "GATEWAY_JEV_KEY", defaultModel: "jev-latest", modelsVerifyKey }, "Backend modelsVerifyKey must be a boolean.");
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("an endpoint keyEnv must name an environment variable", () => {
|
|
184
|
+
for (const keyEnv of ["1BAD", "GATEWAY KEY", ""]) {
|
|
185
|
+
assertRefuses({ label: "Gateway", host: "https://gw.example.com", keyEnv, defaultModel: "jev-latest" }, "Backend keyEnv must name an environment variable: letters, digits, and underscores, not starting with a digit.");
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("an endpoint keyEnv must not be TYPESAFE_API_KEY", () => {
|
|
190
|
+
const message = "Backend keyEnv must not be TYPESAFE_API_KEY: the TypeSafe key is only sent to the typesafe backend. Give this endpoint its own variable.";
|
|
191
|
+
for (const keyEnv of ["TYPESAFE_API_KEY", "typesafe_api_key"]) {
|
|
192
|
+
assertRefuses({ label: "Gateway", host: "https://gw.example.com", keyEnv, defaultModel: "jev-latest" }, message);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("an endpoint defaultModel must be a nonempty string of at most 100 characters", () => {
|
|
197
|
+
for (const defaultModel of ["", " ", "x".repeat(101), 7]) {
|
|
198
|
+
assertRefuses({ label: "Gateway", host: "https://gw.example.com", keyEnv: "GATEWAY_JEV_KEY", defaultModel }, "Backend defaultModel must be a nonempty string of at most 100 characters.");
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("an endpoint with no defaultModel needs model on createTypeSafe", () => {
|
|
203
|
+
assertRefuses({ label: "Gateway", host: "https://gw.example.com", keyEnv: "GATEWAY_JEV_KEY" }, 'Backend "Gateway" names no defaultModel; pass model to createTypeSafe.', { apiKey: "fake_key_0123456789abcdef" });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("backend must be a registry name or a backend object", () => {
|
|
207
|
+
for (const backend of [42, null, true]) {
|
|
208
|
+
assertRefuses(backend, "backend must be a registry name or a backend object.");
|
|
209
|
+
}
|
|
210
|
+
// An unknown name keeps the registry's own error.
|
|
211
|
+
assertRefuses("unknown-backend", 'Unknown judgment backend "unknown-backend". Valid backends: typesafe, openrouter, commandcode.');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("authState refuses an invalid backend instead of reporting a status", () => {
|
|
215
|
+
assert.throws(() => authState({ backend: { label: "x", host: "http://evil.example", keyEnv: "K" } }), (error: unknown) => {
|
|
216
|
+
assert.ok(error instanceof TypeSafeIntegrationError);
|
|
217
|
+
assert.equal(error.code, "configuration");
|
|
218
|
+
assert.equal(error.message, "Backend host must be an absolute https: URL with no user info, path, query, or fragment (http: is allowed only for localhost, 127.0.0.0/8, and [::1]).");
|
|
219
|
+
return true;
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// 4. Key isolation.
|
|
224
|
+
|
|
225
|
+
test("an endpoint never reads TYPESAFE_API_KEY or the login store", () => {
|
|
226
|
+
process.env.TYPESAFE_API_KEY = tsKey;
|
|
227
|
+
storeApiKey(storedKey);
|
|
228
|
+
let fetchCalls = 0;
|
|
229
|
+
const fetch = async () => { fetchCalls += 1; return new Response("{}"); };
|
|
230
|
+
assert.throws(() => createTypeSafe({ backend: { label: "Gateway", host: "https://gw.example.com", keyEnv: "GATEWAY_JEV_KEY", defaultModel: "jev-latest" }, fetch }), (error: unknown) => {
|
|
231
|
+
assert.ok(error instanceof TypeSafeIntegrationError);
|
|
232
|
+
assert.equal(error.code, "configuration");
|
|
233
|
+
assert.equal(error.message, "No API key. Set GATEWAY_JEV_KEY in the environment.");
|
|
234
|
+
return true;
|
|
235
|
+
});
|
|
236
|
+
assert.equal(fetchCalls, 0);
|
|
237
|
+
assert.equal(keySituation(gateway).kind, "missing");
|
|
238
|
+
assert.equal(authState({ backend: gateway }).usable, false);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("commandcode with no COMMANDCODE_API_KEY is missing and unusable", () => {
|
|
242
|
+
process.env.TYPESAFE_API_KEY = tsKey;
|
|
243
|
+
storeApiKey(storedKey);
|
|
244
|
+
let fetchCalls = 0;
|
|
245
|
+
const fetch = async () => { fetchCalls += 1; return new Response("{}"); };
|
|
246
|
+
assert.throws(() => createTypeSafe({ backend: "commandcode", fetch }), (error: unknown) => {
|
|
247
|
+
assert.ok(error instanceof TypeSafeIntegrationError);
|
|
248
|
+
assert.equal(error.code, "configuration");
|
|
249
|
+
assert.equal(error.message, "No API key. Set COMMANDCODE_API_KEY in the environment.");
|
|
250
|
+
return true;
|
|
251
|
+
});
|
|
252
|
+
assert.equal(fetchCalls, 0);
|
|
253
|
+
assert.equal(keySituation("commandcode").kind, "missing");
|
|
254
|
+
assert.equal(authState({ backend: "commandcode" }).usable, false);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("a request to an endpoint carries its own key, never the TypeSafe key", async () => {
|
|
258
|
+
process.env.TYPESAFE_API_KEY = tsKey;
|
|
259
|
+
process.env.GATEWAY_JEV_KEY = gwKey;
|
|
260
|
+
storeApiKey(storedKey);
|
|
261
|
+
const questions = sample().questions;
|
|
262
|
+
let calls = 0;
|
|
263
|
+
const client = createTypeSafe({ backend: gateway, fetch: async (_url, init) => {
|
|
264
|
+
calls += 1;
|
|
265
|
+
const headers = new Headers(init?.headers);
|
|
266
|
+
for (const value of headers.values()) {
|
|
267
|
+
assert.ok(!value.includes(tsKey), "the TypeSafe key must never reach another backend");
|
|
268
|
+
assert.ok(!value.includes(storedKey), "the stored login key must never reach another backend");
|
|
269
|
+
}
|
|
270
|
+
assert.equal(headers.get("authorization"), `Bearer ${gwKey}`);
|
|
271
|
+
return responseFor(questions);
|
|
272
|
+
} });
|
|
273
|
+
await client.evaluate(sample());
|
|
274
|
+
assert.equal(calls, 1);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// 5. A model list does not verify the key.
|
|
278
|
+
|
|
279
|
+
test("a public model list on commandcode leaves the auth state unverified", async () => {
|
|
280
|
+
process.env.COMMANDCODE_API_KEY = ccKey;
|
|
281
|
+
const client = createTypeSafe({ backend: "commandcode", fetch: async () => Response.json({ data: [{ id: "typesafe/jev" }] }) });
|
|
282
|
+
assert.deepEqual(await client.listModels(), ["typesafe/jev"]);
|
|
283
|
+
const state = authState({ backend: "commandcode" });
|
|
284
|
+
assert.equal(state.verified, false);
|
|
285
|
+
assert.equal(state.verifiedAt, undefined);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("a model list on an endpoint without modelsVerifyKey leaves the auth state unverified", async () => {
|
|
289
|
+
process.env.GATEWAY_JEV_KEY = gwKey;
|
|
290
|
+
const client = createTypeSafe({ backend: { label: "Gateway", host: "https://gw.example.com", keyEnv: "GATEWAY_JEV_KEY", defaultModel: "jev-latest" }, fetch: async () => Response.json({ models: [{ name: "jev-latest" }] }) });
|
|
291
|
+
assert.deepEqual(await client.listModels(), ["jev-latest"]);
|
|
292
|
+
const state = authState({ backend: gateway });
|
|
293
|
+
assert.equal(state.verified, false);
|
|
294
|
+
assert.equal(state.verifiedAt, undefined);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("an endpoint with modelsVerifyKey true records verification", async () => {
|
|
298
|
+
process.env.GATEWAY_JEV_KEY = gwKey;
|
|
299
|
+
const endpoint: BackendEndpoint = { label: "Gateway", host: "https://gw.example.com", keyEnv: "GATEWAY_JEV_KEY", defaultModel: "jev-latest", modelsVerifyKey: true };
|
|
300
|
+
const client = createTypeSafe({ backend: endpoint, fetch: async () => Response.json({ models: [{ name: "jev-latest" }] }) });
|
|
301
|
+
await client.listModels();
|
|
302
|
+
const state = authState({ backend: endpoint });
|
|
303
|
+
assert.equal(state.verified, true);
|
|
304
|
+
assert.ok(state.verifiedAt);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("a successful evaluate records verification even after a public model list", async () => {
|
|
308
|
+
process.env.COMMANDCODE_API_KEY = ccKey;
|
|
309
|
+
const questions = sample().questions;
|
|
310
|
+
const client = createTypeSafe({ backend: "commandcode", fetch: async (url) => String(url).endsWith("/models")
|
|
311
|
+
? Response.json({ data: [{ id: "typesafe/jev" }] })
|
|
312
|
+
: responseFor(questions) });
|
|
313
|
+
await client.listModels();
|
|
314
|
+
assert.equal(authState({ backend: "commandcode" }).verified, false);
|
|
315
|
+
await client.evaluate(sample());
|
|
316
|
+
assert.equal(authState({ backend: "commandcode" }).verified, true);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// 6. Malformed replies stay `response` errors.
|
|
320
|
+
|
|
321
|
+
test("a malformed reply on commandcode is a response error", async () => {
|
|
322
|
+
process.env.COMMANDCODE_API_KEY = ccKey;
|
|
323
|
+
const client = createTypeSafe({ backend: "commandcode", fetch: async () => Response.json({ model: "jev-test", answers: {}, usage: { input_tokens: 1, output_tokens: 0 } }) });
|
|
324
|
+
await assert.rejects(client.evaluate(sample()), (error: unknown) => {
|
|
325
|
+
assert.ok(error instanceof TypeSafeIntegrationError);
|
|
326
|
+
assert.equal(error.code, "response");
|
|
327
|
+
return true;
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("a malformed reply on an endpoint is a response error", async () => {
|
|
332
|
+
process.env.GATEWAY_JEV_KEY = gwKey;
|
|
333
|
+
const client = createTypeSafe({ backend: gateway, fetch: async () => Response.json({ model: "jev-test", answers: { yes: { type: "noul", noul: 2 } }, usage: { input_tokens: 1, output_tokens: 0 } }) });
|
|
334
|
+
await assert.rejects(client.evaluate(sample()), (error: unknown) => {
|
|
335
|
+
assert.ok(error instanceof TypeSafeIntegrationError);
|
|
336
|
+
assert.equal(error.code, "response");
|
|
337
|
+
return true;
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// 7. 401 advice names each backend's own key variable.
|
|
342
|
+
|
|
343
|
+
test("401 advice names each backend's own key variable", () => {
|
|
344
|
+
assert.equal(safeError(new APIError(401, undefined, new Headers()), "commandcode").message, "TypeSafe returned HTTP 401. Check COMMANDCODE_API_KEY. No automatic retry was made.");
|
|
345
|
+
assert.equal(safeError(new APIError(401, undefined, new Headers()), gateway).message, "TypeSafe returned HTTP 401. Check GATEWAY_JEV_KEY. No automatic retry was made.");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// 8. resolveBackend and backendHost.
|
|
349
|
+
|
|
350
|
+
test("resolveBackend resolves registry names to the registry entries", () => {
|
|
351
|
+
const typesafe = resolveBackend("typesafe");
|
|
352
|
+
assert.equal(typesafe.name, "typesafe");
|
|
353
|
+
assert.equal(typesafe.label, "TypeSafe");
|
|
354
|
+
assert.equal(typesafe.host, "https://api.typesafe.ai");
|
|
355
|
+
assert.equal(typesafe.keyEnv, "TYPESAFE_API_KEY");
|
|
356
|
+
assert.equal(typesafe.defaultModel, "jev-latest");
|
|
357
|
+
assert.equal(typesafe.modelsVerifyKey, true);
|
|
358
|
+
assert.equal(typesafe.path, undefined);
|
|
359
|
+
const commandcode = resolveBackend("commandcode");
|
|
360
|
+
assert.equal(commandcode.name, "commandcode");
|
|
361
|
+
assert.equal(commandcode.label, "Command Code");
|
|
362
|
+
assert.equal(commandcode.host, "https://api.commandcode.ai");
|
|
363
|
+
assert.equal(commandcode.keyEnv, "COMMANDCODE_API_KEY");
|
|
364
|
+
assert.equal(commandcode.path, "/provider/v1/systemone");
|
|
365
|
+
assert.equal(commandcode.modelsPath, "/provider/v1/models");
|
|
366
|
+
assert.equal(commandcode.modelsField, "data");
|
|
367
|
+
assert.equal(commandcode.modelsIdField, "id");
|
|
368
|
+
assert.equal(commandcode.defaultModel, "typesafe/jev");
|
|
369
|
+
assert.equal(commandcode.modelsVerifyKey, false);
|
|
370
|
+
assert.deepEqual(resolveBackend("openrouter").defaultModel, "typesafe/jev-1.13");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("backendHost reports the destination host", () => {
|
|
374
|
+
assert.equal(backendHost("commandcode"), "api.commandcode.ai");
|
|
375
|
+
assert.equal(backendHost("typesafe"), "api.typesafe.ai");
|
|
376
|
+
assert.equal(backendHost("openrouter"), "openrouter.ai");
|
|
377
|
+
assert.equal(backendHost(gateway), "gw.example.com");
|
|
378
|
+
assert.equal(backendHost({ label: "Gateway", host: "https://gw.example.com:8443", keyEnv: "GATEWAY_JEV_KEY" }), "gw.example.com:8443");
|
|
379
|
+
assert.equal(backendHost({ label: "Local", host: "http://127.0.0.1:8787", keyEnv: "GATEWAY_JEV_KEY" }), "127.0.0.1:8787");
|
|
380
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { after, before, test } from "node:test";
|
|
6
|
+
import { chunkEvaluationRequest, fanOut } from "../src/batch.js";
|
|
7
|
+
import { createTypeSafe } from "../src/client.js";
|
|
8
|
+
import { TypeSafeIntegrationError } from "../src/errors.js";
|
|
9
|
+
import { openUsageLedger } from "../src/usage.js";
|
|
10
|
+
import { DEFAULT_MAX_QUESTIONS } from "../src/schema.js";
|
|
11
|
+
import { noul } from "../src/index.js";
|
|
12
|
+
import type { Questions, SystemOneRequest } from "../src/index.js";
|
|
13
|
+
|
|
14
|
+
const sample = { state: "synthetic", questions: { yes: noul("Is this synthetic?") } };
|
|
15
|
+
const manyQuestions = (count: number) => Object.fromEntries(Array.from({ length: count }, (_, index) => [`q${index}`, noul(`Question ${index}?`)]));
|
|
16
|
+
|
|
17
|
+
const workspace = mkdtempSync(join(tmpdir(), "pi-typesafe-batch-"));
|
|
18
|
+
const savedAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
19
|
+
const savedKey = process.env.TYPESAFE_API_KEY;
|
|
20
|
+
before(() => {
|
|
21
|
+
process.env.PI_CODING_AGENT_DIR = workspace;
|
|
22
|
+
process.env.TYPESAFE_API_KEY = "batch-test-key";
|
|
23
|
+
});
|
|
24
|
+
after(() => {
|
|
25
|
+
if (savedAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = savedAgentDir;
|
|
26
|
+
if (savedKey === undefined) delete process.env.TYPESAFE_API_KEY; else process.env.TYPESAFE_API_KEY = savedKey;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const ledgerFor = (name: string) => openUsageLedger({ path: join(workspace, `${name}.json`) });
|
|
30
|
+
|
|
31
|
+
/** Answers every question with a fixed Noul probability, so merging is observable per question id. */
|
|
32
|
+
function answeringFetch(counter: { calls: number }) {
|
|
33
|
+
return async (_url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
34
|
+
counter.calls++;
|
|
35
|
+
const request = JSON.parse(String(init?.body)) as SystemOneRequest<Questions>;
|
|
36
|
+
const answers = Object.fromEntries(Object.keys(request.questions).map(id => [id, { type: "noul", noul: 0.75 }]));
|
|
37
|
+
return Response.json({ model: "jev-test", answers, usage: { input_tokens: 10, output_tokens: 0 } });
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test("fan-out bounds concurrency, preserves order, and never throws", async () => {
|
|
42
|
+
let inFlight = 0;
|
|
43
|
+
let peak = 0;
|
|
44
|
+
const results = await fanOut([1, 2, 3, 4, 5, 6, 7], async value => {
|
|
45
|
+
inFlight++;
|
|
46
|
+
peak = Math.max(peak, inFlight);
|
|
47
|
+
await new Promise(resolve => setTimeout(resolve, 5));
|
|
48
|
+
inFlight--;
|
|
49
|
+
if (value === 4) throw new Error(`bad ${value}`);
|
|
50
|
+
return value * 2;
|
|
51
|
+
}, { concurrency: 3 });
|
|
52
|
+
assert.equal(peak, 3);
|
|
53
|
+
assert.deepEqual(results.map(result => result.ok ? result.value : "failed"), [2, 4, 6, "failed", 10, 12, 14]);
|
|
54
|
+
assert.deepEqual(results.map(result => result.index), [0, 1, 2, 3, 4, 5, 6]);
|
|
55
|
+
const failure = results[3];
|
|
56
|
+
assert.ok(failure && !failure.ok);
|
|
57
|
+
assert.equal(failure.skipped, false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("fan-out stops launching after a stop rule and marks the rest skipped", async () => {
|
|
61
|
+
const started: number[] = [];
|
|
62
|
+
const results = await fanOut([0, 1, 2, 3, 4, 5], async value => {
|
|
63
|
+
started.push(value);
|
|
64
|
+
if (value === 2) throw new TypeSafeIntegrationError("budget", "cap reached");
|
|
65
|
+
return value;
|
|
66
|
+
}, { concurrency: 1, stopOn: error => error instanceof TypeSafeIntegrationError && error.code === "budget" });
|
|
67
|
+
assert.deepEqual(started, [0, 1, 2]);
|
|
68
|
+
assert.equal(results.filter(result => !result.ok && result.skipped).length, 3);
|
|
69
|
+
const stopped = results[2];
|
|
70
|
+
assert.ok(stopped && !stopped.ok);
|
|
71
|
+
assert.equal(stopped.skipped, false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("fan-out stops launching once the caller's signal aborts", async () => {
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const results = await fanOut([0, 1, 2, 3], async value => {
|
|
77
|
+
if (value === 1) controller.abort();
|
|
78
|
+
return value;
|
|
79
|
+
}, { concurrency: 1, signal: controller.signal });
|
|
80
|
+
assert.equal(results[0]?.ok, true);
|
|
81
|
+
assert.equal(results[1]?.ok, true);
|
|
82
|
+
const abandoned = results[2];
|
|
83
|
+
assert.ok(abandoned && !abandoned.ok);
|
|
84
|
+
assert.equal(abandoned.skipped, true);
|
|
85
|
+
assert.ok(abandoned.error instanceof TypeSafeIntegrationError && abandoned.error.code === "aborted");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("the splitter is pure: a request that already fits is one chunk, unchanged", () => {
|
|
89
|
+
assert.deepEqual(chunkEvaluationRequest(sample), [sample]);
|
|
90
|
+
// No validation here: an invalid request is passed through and fails once, per chunk, at admission.
|
|
91
|
+
const invalid = { state: "synthetic", questions: {} };
|
|
92
|
+
assert.deepEqual(chunkEvaluationRequest(invalid), [invalid]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("an invalid request is one settled failure, not a thrown error", async () => {
|
|
96
|
+
const counter = { calls: 0 };
|
|
97
|
+
const client = createTypeSafe({ ledger: ledgerFor("invalid"), fetch: answeringFetch(counter) });
|
|
98
|
+
const batch = await client.evaluateMany([{ state: "synthetic", questions: {} }]);
|
|
99
|
+
assert.equal(counter.calls, 0);
|
|
100
|
+
assert.equal(batch.ok, false);
|
|
101
|
+
const failed = batch.results[0];
|
|
102
|
+
assert.ok(failed && !failed.ok);
|
|
103
|
+
assert.ok(failed.error instanceof TypeSafeIntegrationError && failed.error.code === "validation");
|
|
104
|
+
assert.equal(failed.skipped, false);
|
|
105
|
+
await assert.rejects(client.evaluate({ state: "synthetic", questions: {} }), (error: unknown) => error instanceof TypeSafeIntegrationError && error.code === "validation");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("more questions than one request may carry are split in order", () => {
|
|
109
|
+
const questions = manyQuestions(DEFAULT_MAX_QUESTIONS + 3);
|
|
110
|
+
const chunks = chunkEvaluationRequest({ state: "many", questions });
|
|
111
|
+
assert.deepEqual(chunks.map(chunk => Object.keys(chunk.questions).length), [DEFAULT_MAX_QUESTIONS, 3]);
|
|
112
|
+
const ids = chunks.flatMap(chunk => Object.keys(chunk.questions));
|
|
113
|
+
assert.deepEqual(ids, Object.keys(questions));
|
|
114
|
+
assert.equal(chunks.every(chunk => chunk.state === "many"), true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("evaluateAll spends one request per chunk and merges answers, usage, and model", async () => {
|
|
118
|
+
const counter = { calls: 0 };
|
|
119
|
+
const client = createTypeSafe({ ledger: ledgerFor("many"), fetch: answeringFetch(counter) });
|
|
120
|
+
const questions = manyQuestions(DEFAULT_MAX_QUESTIONS + 2);
|
|
121
|
+
const batch = await client.evaluateAll({ state: "many", questions }, { concurrency: 2 });
|
|
122
|
+
assert.equal(counter.calls, 2);
|
|
123
|
+
assert.equal(batch.ok, true);
|
|
124
|
+
assert.equal(batch.failures, 0);
|
|
125
|
+
assert.equal(batch.skipped, 0);
|
|
126
|
+
assert.equal(Object.keys(batch.answers).length, DEFAULT_MAX_QUESTIONS + 2);
|
|
127
|
+
assert.equal(batch.model, "jev-test");
|
|
128
|
+
assert.equal(batch.usage.input_tokens, 20);
|
|
129
|
+
assert.equal(client.getUsage().requestsSucceeded, 2);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("evaluateMany reports per-request failures and stops submitting after a budget error", async () => {
|
|
133
|
+
const counter = { calls: 0 };
|
|
134
|
+
const client = createTypeSafe({ maxRequests: 1, ledger: ledgerFor("budget"), fetch: answeringFetch(counter) });
|
|
135
|
+
const batch = await client.evaluateMany([sample, sample, sample], { concurrency: 1 });
|
|
136
|
+
assert.equal(counter.calls, 1);
|
|
137
|
+
assert.equal(batch.ok, false);
|
|
138
|
+
// One request succeeded, one failed on the session cap, and the third was never submitted.
|
|
139
|
+
assert.equal(batch.failures, 2);
|
|
140
|
+
assert.equal(batch.skipped, 1);
|
|
141
|
+
const first = batch.results[0];
|
|
142
|
+
assert.equal(first?.ok, true);
|
|
143
|
+
assert.equal(Object.keys(batch.answers).length, 1);
|
|
144
|
+
assert.ok(batch.elapsedMs >= 0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("a daily cap stops the batch with the cap named", async () => {
|
|
148
|
+
const counter = { calls: 0 };
|
|
149
|
+
const client = createTypeSafe({ maxRequestsPerDay: 1, ledger: ledgerFor("day-cap"), fetch: answeringFetch(counter) });
|
|
150
|
+
const batch = await client.evaluateMany([sample, sample]);
|
|
151
|
+
assert.equal(counter.calls, 1);
|
|
152
|
+
const second = batch.results[1];
|
|
153
|
+
assert.ok(second && !second.ok);
|
|
154
|
+
assert.ok(second.error instanceof TypeSafeIntegrationError && second.error.code === "budget");
|
|
155
|
+
assert.ok(second.error.message.includes("daily request cap"));
|
|
156
|
+
});
|