@parcha/agentrun-jev 0.1.0-beta.21 → 0.1.0-beta.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/index.js +142 -123
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -77,7 +77,7 @@ const result = await runJudge({
|
|
|
77
77
|
console.log(result.answers.team);
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
-
Without a key, `createJevRunner()` throws `JevError` (code `configuration`: "Could not configure Jev. Supply apiKey or set TYPESAFE_API_KEY, and check transport options.") on that line, before any request is made. The same holds for `provider: "openrouter"` without `OPENROUTER_API_KEY`.
|
|
80
|
+
Without a key, or with an `apiKey` that is blank, `createJevRunner()` throws `JevError` (code `configuration`: "Could not configure Jev. Supply apiKey or set TYPESAFE_API_KEY, and check transport options.") on that line, before any request is made. The same holds for `provider: "openrouter"` without `OPENROUTER_API_KEY`.
|
|
81
81
|
|
|
82
82
|
`judge`, `pick`, `sift`, `route`, semantic predicates, and judgment verification use the same `runJudge` adapter. Choice, Noul, and Score answers retain their raw probabilities. The core validates answer IDs, probability keys and normalization, score rubrics, and value ranges before consuming a result. A typed answer records the model's decision. It does not prove that a real-world claim is true.
|
|
83
83
|
|
|
@@ -115,7 +115,7 @@ Cost: when the response reports `usage.cost` (USD) it is `cost_usd`; otherwise a
|
|
|
115
115
|
|
|
116
116
|
The official `@typesafe-ai/sdk` resolves `TYPESAFE_API_KEY` and `TYPESAFE_BASE_URL`. Explicit `apiKey` and `baseURL` options take precedence. An API root ends before `/v1/systemone`. Set `fetch` for a custom transport, or supply a `client` with a `systemOne(request, options)` method. A custom client must honor the request signal and disable its own retries; it owns its credentials and cannot be combined with transport options.
|
|
117
117
|
|
|
118
|
-
The adapter retries transient HTTP 408, 429, 5xx responses and connection failures with bounded exponential delays. On a 429 or 503 with a `Retry-After` (seconds or an HTTP date), it waits the larger of that and its own delay, and gives up at once, with attempts left, when that wait would not end before the timeout. When the attempts end on a 429 after an earlier 408, 5xx or lost connection, the error reports the first of those failures (its HTTP `status`, or code `connection`), since that attempt may have been billed; an `http` error with status 429 means every attempt was refused. SDK retries are disabled. Validation failures and other HTTP errors do not retry. The deadline and either the runner or per-call signal interrupt both requests and backoff. Cancellation bounds when the adapter returns; an uncooperative custom client can still continue its own work.
|
|
118
|
+
The adapter retries transient HTTP 408, 429, 5xx responses and connection failures with bounded exponential delays. On a 429 or 503 with a `Retry-After` (seconds or an HTTP date), it waits the larger of that and its own delay, and gives up at once, with attempts left, when that wait would not end before the timeout. When the attempts end on a 429 after an earlier 408, 5xx or lost connection, the error reports the first of those failures (its HTTP `status`, or code `connection`), since that attempt may have been billed; an `http` error with status 429 means every attempt was refused. SDK retries are disabled. Validation failures and other HTTP errors do not retry. The deadline and either the runner or per-call signal interrupt both requests and backoff. A call the deadline ends is `timeout`, whether the runner's timer or the SDK's per-attempt timer fired first, and is not retried. Cancellation bounds when the adapter returns; an uncooperative custom client can still continue its own work.
|
|
119
119
|
|
|
120
120
|
Request state has no local byte cap by default. Set `maxStateBytes` to a positive safe integer to reject serialized UTF-8 JSON state exceeding that host-owned limit before transport; omission or `null` disables this guard. This is a byte guard, not a token estimate. [Provider model context limits](https://docs.typesafe.ai/models.md) still apply. The adapter never truncates or splits state or questions.
|
|
121
121
|
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
3
|
-
import { APIConnectionError, APIError, Image, TypeSafeClient } from "@typesafe-ai/sdk";
|
|
3
|
+
import { APIConnectionError, APIError, APITimeoutError, Image, TypeSafeClient } from "@typesafe-ai/sdk";
|
|
4
4
|
import { validateAnswers, SystemOneError, isSystemOneResponseReason } from "@parcha/agentrun-dsl";
|
|
5
5
|
const keyVariable = { typesafe: "TYPESAFE_API_KEY", openrouter: "OPENROUTER_API_KEY" };
|
|
6
6
|
const OPENROUTER_BASE_URL = "https://openrouter.ai/api";
|
|
7
7
|
const OPENROUTER_DEFAULT_MODEL = "~typesafe/jev-latest";
|
|
8
8
|
/** The provider an environment's keys select: OpenRouter only when `OPENROUTER_API_KEY` is set and `TYPESAFE_API_KEY` is not. The runner itself never reads this. */
|
|
9
9
|
export function jevProviderFromEnv(env = process.env) {
|
|
10
|
-
const
|
|
11
|
-
return
|
|
10
|
+
const isSet = (name) => typeof env[name] === "string" && env[name].trim().length > 0;
|
|
11
|
+
return isSet(keyVariable.openrouter) && !isSet(keyVariable.typesafe) ? "openrouter" : "typesafe";
|
|
12
12
|
}
|
|
13
13
|
const IMAGE_HEADER = "TypeSafe-Danger-Zone-Images";
|
|
14
14
|
const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
|
|
@@ -30,7 +30,7 @@ function* stringValues(value) {
|
|
|
30
30
|
else if (Array.isArray(value))
|
|
31
31
|
for (const each of value)
|
|
32
32
|
yield* stringValues(each);
|
|
33
|
-
else if (
|
|
33
|
+
else if (isRecord(value))
|
|
34
34
|
for (const each of Object.values(value))
|
|
35
35
|
yield* stringValues(each);
|
|
36
36
|
}
|
|
@@ -58,13 +58,13 @@ export class JevError extends Error {
|
|
|
58
58
|
this.responseDiagnostic = { reason: responseDiagnostic.reason };
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
-
const
|
|
61
|
+
const requireConfig = (condition, message) => {
|
|
62
62
|
if (!condition)
|
|
63
63
|
throw new JevError("configuration", message);
|
|
64
64
|
};
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
const
|
|
65
|
+
const isTimerMs = (n, allowZero = false) => Number.isSafeInteger(n) && n >= (allowZero ? 0 : 1) && n <= MAX_TIMER_MS;
|
|
66
|
+
const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
67
|
+
const isTokenCount = (n) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
|
|
68
68
|
function abortable(promise, signal, error) {
|
|
69
69
|
return new Promise((resolve, reject) => {
|
|
70
70
|
const abort = () => { signal.removeEventListener("abort", abort); reject(error()); };
|
|
@@ -95,32 +95,148 @@ function retryAfterMs(headers) {
|
|
|
95
95
|
const date = Date.parse(value);
|
|
96
96
|
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
|
|
97
97
|
}
|
|
98
|
+
/** What one call sends: the request, the body its hash is taken over, and the image header when it carries an image or a Box. All of it is
|
|
99
|
+
* checked before anything is sent: a request that cannot be valid throws the `invalid_request` JevError that names why. */
|
|
100
|
+
function prepareRequest(params, model, maxStateBytes, provider) {
|
|
101
|
+
let body;
|
|
102
|
+
let request;
|
|
103
|
+
const invalid = (reason, message, stateBytes) => new JevError("invalid_request", message, 0, undefined, { reason,
|
|
104
|
+
...(typeof params.label === "string" ? { label: params.label.slice(0, 200) } : {}),
|
|
105
|
+
...(stateBytes === undefined || maxStateBytes === null ? {} : { stateBytes, maxStateBytes }) });
|
|
106
|
+
const state = params.state;
|
|
107
|
+
let serializedState;
|
|
108
|
+
try {
|
|
109
|
+
// The SDK refuses a request with no state, so a null or missing state is refused here, before anything is sent.
|
|
110
|
+
if (typeof state !== "string" && (typeof state !== "object" || state === null))
|
|
111
|
+
throw new Error();
|
|
112
|
+
serializedState = JSON.stringify(state);
|
|
113
|
+
if (typeof serializedState !== "string")
|
|
114
|
+
throw new Error();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
throw invalid("invalid_state", "Jev state must be JSON-serializable text, an object or an array.");
|
|
118
|
+
}
|
|
119
|
+
const stateBytes = Buffer.byteLength(serializedState, "utf8");
|
|
120
|
+
if (maxStateBytes !== null && stateBytes > maxStateBytes)
|
|
121
|
+
throw invalid("state_too_large", `Jev assembled state is ${stateBytes} UTF-8 bytes; configured host limit is ${maxStateBytes} bytes. No request was sent.`, stateBytes);
|
|
122
|
+
const callModel = params.model ?? model;
|
|
123
|
+
if (typeof callModel !== "string" || callModel.trim().length === 0)
|
|
124
|
+
throw invalid("unsupported_model", "model must be a nonempty string; no request was sent.");
|
|
125
|
+
try {
|
|
126
|
+
body = JSON.stringify({ state: JSON.parse(serializedState), model: callModel, questions: params.questions });
|
|
127
|
+
request = JSON.parse(body);
|
|
128
|
+
if (!isRecord(request.questions) || Object.keys(request.questions).length === 0)
|
|
129
|
+
throw new Error();
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
throw invalid("invalid_questions", "Jev requires a nonempty JSON question map; no request was sent.");
|
|
133
|
+
}
|
|
134
|
+
// Images and Box questions: validated whole before any send; the header goes with exactly these requests.
|
|
135
|
+
const hasBox = Object.values(request.questions).some(question => isRecord(question) && question.type === "box");
|
|
136
|
+
const given = params.assets ?? {};
|
|
137
|
+
if (!isRecord(given))
|
|
138
|
+
throw invalid("invalid_assets", "assets must map each reference to an image; no request was sent.");
|
|
139
|
+
const references = Object.keys(given);
|
|
140
|
+
const carriesImage = references.length > 0;
|
|
141
|
+
if ((carriesImage || hasBox) && provider !== "typesafe")
|
|
142
|
+
throw invalid("unsupported_provider", "Images and Box questions are sent to TypeSafe's own API only; the OpenRouter path is not verified for them. No request was sent.");
|
|
143
|
+
if (hasBox && !answersBox(callModel))
|
|
144
|
+
throw invalid("unsupported_model", `A Box question needs a model that answers it, by name: ${BOX_MODEL} or later, or jev-preview; jev-latest is refused whatever version it points at. No request was sent.`);
|
|
145
|
+
if (hasBox && !carriesImage)
|
|
146
|
+
throw invalid("invalid_assets", "A Box question locates something in an image, and the request carries none. No request was sent.");
|
|
147
|
+
let assets;
|
|
148
|
+
let assetDigests;
|
|
149
|
+
if (carriesImage) {
|
|
150
|
+
// A reference is replaced where it sits inside a string value of the state, an instruction or a criterion; keys (question ids, option names) are never replaced.
|
|
151
|
+
const texts = [...stringValues(request.state), ...Object.values(request.questions).flatMap(question => isRecord(question) ? [...stringValues(question.instructions), ...stringValues(question.criteria)] : [])];
|
|
152
|
+
assets = Object.create(null);
|
|
153
|
+
assetDigests = Object.create(null);
|
|
154
|
+
for (const reference of references) {
|
|
155
|
+
const image = given[reference];
|
|
156
|
+
const sound = reference.length > 0 && Buffer.byteLength(reference, "utf8") <= MAX_REFERENCE_BYTES && !/["\\\u0000-\u001f]/.test(reference)
|
|
157
|
+
&& isRecord(image) && image.bytes instanceof Uint8Array && (image.mime === "image/png" || image.mime === "image/jpeg")
|
|
158
|
+
&& image.bytes.byteLength > 0 && image.bytes.byteLength <= MAX_IMAGE_BYTES && startsWith(image.bytes, imageMagic[image.mime]);
|
|
159
|
+
if (!sound)
|
|
160
|
+
throw invalid("invalid_assets", `Each image is a PNG or JPEG file of at most ${MAX_IMAGE_BYTES / 1024 / 1024} MiB, with bytes that are that type and a plain reference. No request was sent.`);
|
|
161
|
+
if (!texts.some(text => text.includes(reference)))
|
|
162
|
+
throw invalid("invalid_assets", "Every image reference must appear in the state or a question. No request was sent.");
|
|
163
|
+
assets[reference] = new Image(image.bytes);
|
|
164
|
+
assetDigests[reference] = { sha256: createHash("sha256").update(image.bytes).digest("hex"), mime: image.mime, bytes: image.bytes.byteLength };
|
|
165
|
+
}
|
|
166
|
+
// The hash names the images by their own digests, so it identifies the request that was sent without holding the bytes.
|
|
167
|
+
body = JSON.stringify({ ...request, assets: assetDigests });
|
|
168
|
+
request = { ...request, assets };
|
|
169
|
+
}
|
|
170
|
+
const headers = carriesImage || hasBox ? { [IMAGE_HEADER]: "true" } : undefined;
|
|
171
|
+
return { request, body, ...(headers ? { headers } : {}) };
|
|
172
|
+
}
|
|
173
|
+
/** The runner's result from what the client returned at `attempt`, or the `invalid_response` JevError for the first rule it breaks.
|
|
174
|
+
* `questions` are the questions as they were sent. */
|
|
175
|
+
function readResponse(result, questions, body, pricing, attempt) {
|
|
176
|
+
const invalid = (reason, message) => new JevError("invalid_response", message, attempt, undefined, undefined, { reason });
|
|
177
|
+
if (!isRecord(result))
|
|
178
|
+
throw invalid("response_shape", "Jev returned an invalid response.");
|
|
179
|
+
const answers = result.answers;
|
|
180
|
+
// Checked against the questions as they were sent: a value JSON cannot carry (an undefined criterion) was never asked.
|
|
181
|
+
try {
|
|
182
|
+
validateAnswers(questions, answers);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
throw invalid(error instanceof SystemOneError && isSystemOneResponseReason(error.responseReason) ? error.responseReason : "answer_validation", "Jev returned invalid answers or probability distributions.");
|
|
186
|
+
}
|
|
187
|
+
const usage = result.usage;
|
|
188
|
+
// Usage carries token counts, a provider-reported cost (USD), or both; a half pair of counts or a bad cost is invalid.
|
|
189
|
+
const counted = isRecord(usage) && isTokenCount(usage.input_tokens) && isTokenCount(usage.output_tokens);
|
|
190
|
+
const reported = isRecord(usage) && usage.cost !== undefined && usage.cost !== null;
|
|
191
|
+
const reportedCost = reported && typeof usage.cost === "number" && Number.isFinite(usage.cost) && usage.cost >= 0 ? usage.cost : undefined;
|
|
192
|
+
const noCounts = isRecord(usage) && usage.input_tokens === undefined && usage.output_tokens === undefined;
|
|
193
|
+
// Accepted: no usage; both counts; or a valid cost with both counts or with neither.
|
|
194
|
+
const usageValid = usage == null || (isRecord(usage) && (reported ? reportedCost !== undefined && (counted || noCounts) : counted));
|
|
195
|
+
if (!usageValid)
|
|
196
|
+
throw invalid("token_usage", "Jev returned invalid token usage.");
|
|
197
|
+
if (result.model != null && (typeof result.model !== "string" || !result.model.trim())) {
|
|
198
|
+
throw invalid("model_identifier", "Jev returned an invalid model identifier.");
|
|
199
|
+
}
|
|
200
|
+
const metering = counted ? { input_tokens: usage.input_tokens, output_tokens: usage.output_tokens } : null;
|
|
201
|
+
// The provider's own cost wins; else the configured estimate; else unknown (null), never 0.
|
|
202
|
+
const cost = reportedCost !== undefined ? reportedCost : pricing && metering ? (metering.input_tokens * pricing.inputUsdPerMillionTokens + metering.output_tokens * pricing.outputUsdPerMillionTokens) / 1_000_000 : null;
|
|
203
|
+
if (cost !== null && !Number.isFinite(cost))
|
|
204
|
+
throw invalid("cost_range", "Jev cost estimate exceeded the numeric range.");
|
|
205
|
+
return {
|
|
206
|
+
answers, model: typeof result.model === "string" ? result.model : null,
|
|
207
|
+
usage: metering, cost_usd: cost,
|
|
208
|
+
request_sha256: createHash("sha256").update(body).digest("hex"),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
98
211
|
export function createJevRunner(options = {}) {
|
|
99
212
|
const timeoutMs = options.timeoutMs === null ? null : (options.timeoutMs ?? 30_000);
|
|
100
213
|
const maxStateBytes = options.maxStateBytes ?? null;
|
|
101
214
|
const maxAttempts = options.maxAttempts ?? 3;
|
|
102
215
|
const retryBaseMs = options.retryBaseMs ?? 500;
|
|
103
216
|
const retryMaxMs = options.retryMaxMs ?? 5_000;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
217
|
+
// A JavaScript caller's `client: null` is no client, the same as an absent one.
|
|
218
|
+
const hostClient = options.client ?? undefined;
|
|
219
|
+
requireConfig(timeoutMs === null || isTimerMs(timeoutMs), `timeoutMs must be null or a positive integer of at most ${MAX_TIMER_MS}.`);
|
|
220
|
+
requireConfig(maxStateBytes === null || (Number.isSafeInteger(maxStateBytes) && maxStateBytes > 0), "maxStateBytes must be null or a positive safe integer.");
|
|
221
|
+
requireConfig(timeoutMs !== null || hostClient !== undefined, "The TypeSafe SDK requires a finite timeout. For timeoutMs: null, supply a cancellation-only host client.");
|
|
222
|
+
requireConfig(Number.isInteger(maxAttempts) && maxAttempts >= 1 && maxAttempts <= 10, "maxAttempts must be an integer from 1 to 10.");
|
|
223
|
+
requireConfig(isTimerMs(retryBaseMs, true) && isTimerMs(retryMaxMs, true) && retryBaseMs <= retryMaxMs, "Retry delays must be nonnegative integers; retryBaseMs cannot exceed retryMaxMs.");
|
|
224
|
+
requireConfig(hostClient === undefined || (options.apiKey === undefined && options.baseURL === undefined && options.fetch === undefined && options.provider === undefined), "Supply either client or transport options, not both.");
|
|
110
225
|
const provider = options.provider ?? "typesafe";
|
|
111
|
-
|
|
226
|
+
requireConfig(provider === "typesafe" || provider === "openrouter", "provider must be typesafe or openrouter.");
|
|
112
227
|
const pricing = options.pricing ? { ...options.pricing } : undefined;
|
|
113
228
|
if (pricing)
|
|
114
|
-
|
|
229
|
+
requireConfig([pricing.inputUsdPerMillionTokens, pricing.outputUsdPerMillionTokens].every(n => Number.isFinite(n) && n >= 0), "Configured token prices must be finite nonnegative numbers.");
|
|
115
230
|
const model = options.model ?? (provider === "openrouter" ? OPENROUTER_DEFAULT_MODEL : "jev-latest");
|
|
116
|
-
|
|
231
|
+
requireConfig(typeof model === "string" && model.trim().length > 0, "model must be a nonempty string.");
|
|
117
232
|
let client;
|
|
118
233
|
try {
|
|
119
234
|
const key = options.apiKey ?? (provider === "openrouter" ? process.env[keyVariable.openrouter] : undefined);
|
|
120
|
-
//
|
|
121
|
-
|
|
235
|
+
// A blank key is refused on either provider: the SDK would send it as an empty bearer token. Given no key, the SDK falls back to
|
|
236
|
+
// TYPESAFE_API_KEY, so for OpenRouter an absent key is refused too: that would send one provider's key to another.
|
|
237
|
+
if ((key !== undefined || provider === "openrouter") && (typeof key !== "string" || key.trim().length === 0))
|
|
122
238
|
throw new Error("no key");
|
|
123
|
-
client =
|
|
239
|
+
client = hostClient ?? new TypeSafeClient({
|
|
124
240
|
apiKey: key, baseURL: options.baseURL ?? (provider === "openrouter" ? OPENROUTER_BASE_URL : undefined), fetch: options.fetch,
|
|
125
241
|
defaultModel: model, retry: { maxRetries: 0 }, timeout: timeoutMs ?? undefined, logLevel: "off",
|
|
126
242
|
// Bodies go as they always have: uncompressed JSON, which a host's own fetch, a gateway and OpenRouter all read.
|
|
@@ -139,81 +255,12 @@ export function createJevRunner(options = {}) {
|
|
|
139
255
|
const abortError = () => (runnerSignal?.aborted || params.signal?.aborted)
|
|
140
256
|
? new JevError("aborted", "Jev request was cancelled.", attempt)
|
|
141
257
|
: new JevError("timeout", "Jev request exceeded its total deadline.", attempt);
|
|
142
|
-
const invalidResponse = (reason, message) => new JevError("invalid_response", message, attempt, undefined, undefined, { reason });
|
|
143
258
|
if (signal.aborted)
|
|
144
259
|
throw abortError();
|
|
145
260
|
const timer = timeoutMs === null ? undefined : setTimeout(() => deadline.abort(), timeoutMs);
|
|
146
261
|
const started = Date.now();
|
|
147
262
|
try {
|
|
148
|
-
|
|
149
|
-
let request;
|
|
150
|
-
const invalid = (reason, message, stateBytes) => new JevError("invalid_request", message, 0, undefined, { reason,
|
|
151
|
-
...(typeof params.label === "string" ? { label: params.label.slice(0, 200) } : {}),
|
|
152
|
-
...(stateBytes === undefined || maxStateBytes === null ? {} : { stateBytes, maxStateBytes }) });
|
|
153
|
-
const state = params.state ?? null;
|
|
154
|
-
let serializedState;
|
|
155
|
-
try {
|
|
156
|
-
if (typeof state !== "string" && state !== null && typeof state !== "object")
|
|
157
|
-
throw new Error();
|
|
158
|
-
serializedState = JSON.stringify(state);
|
|
159
|
-
if (typeof serializedState !== "string")
|
|
160
|
-
throw new Error();
|
|
161
|
-
}
|
|
162
|
-
catch {
|
|
163
|
-
throw invalid("invalid_state", "Jev state must be JSON-serializable text, an object, an array or null.");
|
|
164
|
-
}
|
|
165
|
-
const stateBytes = Buffer.byteLength(serializedState, "utf8");
|
|
166
|
-
if (maxStateBytes !== null && stateBytes > maxStateBytes)
|
|
167
|
-
throw invalid("state_too_large", `Jev assembled state is ${stateBytes} UTF-8 bytes; configured host limit is ${maxStateBytes} bytes. No request was sent.`, stateBytes);
|
|
168
|
-
const callModel = params.model ?? model;
|
|
169
|
-
if (typeof callModel !== "string" || callModel.trim().length === 0)
|
|
170
|
-
throw invalid("invalid_questions", "model must be a nonempty string; no request was sent.");
|
|
171
|
-
try {
|
|
172
|
-
body = JSON.stringify({ state: JSON.parse(serializedState), model: callModel, questions: params.questions });
|
|
173
|
-
request = JSON.parse(body);
|
|
174
|
-
if (!record(request.questions) || Object.keys(request.questions).length === 0)
|
|
175
|
-
throw new Error();
|
|
176
|
-
}
|
|
177
|
-
catch {
|
|
178
|
-
throw invalid("invalid_questions", "Jev requires a nonempty JSON question map; no request was sent.");
|
|
179
|
-
}
|
|
180
|
-
// Images and Box questions: validated whole before any send; the header goes with exactly these requests.
|
|
181
|
-
const hasBox = Object.values(request.questions).some(question => record(question) && question.type === "box");
|
|
182
|
-
const given = params.assets ?? {};
|
|
183
|
-
if (!record(given))
|
|
184
|
-
throw invalid("invalid_assets", "assets must map each reference to an image; no request was sent.");
|
|
185
|
-
const references = Object.keys(given);
|
|
186
|
-
const carriesImage = references.length > 0;
|
|
187
|
-
if ((carriesImage || hasBox) && provider !== "typesafe" && options.client === undefined)
|
|
188
|
-
throw invalid("unsupported_provider", "Images and Box questions are sent to TypeSafe's own API only; the OpenRouter path is not verified for them. No request was sent.");
|
|
189
|
-
if (hasBox && !answersBox(callModel))
|
|
190
|
-
throw invalid("unsupported_model", `A Box question needs a model that answers it, by name: ${BOX_MODEL} or later, or jev-preview; jev-latest is refused whatever version it points at. No request was sent.`);
|
|
191
|
-
if (hasBox && !carriesImage)
|
|
192
|
-
throw invalid("invalid_assets", "A Box question locates something in an image, and the request carries none. No request was sent.");
|
|
193
|
-
let assets;
|
|
194
|
-
let assetDigests;
|
|
195
|
-
if (carriesImage) {
|
|
196
|
-
// A reference is replaced where it sits inside a string value of the state, an instruction or a criterion; keys (question ids, option names) are never replaced.
|
|
197
|
-
const texts = [...stringValues(JSON.parse(serializedState)), ...Object.values(request.questions).flatMap(question => record(question) ? [...stringValues(question.instructions), ...stringValues(question.criteria)] : [])];
|
|
198
|
-
assets = Object.create(null);
|
|
199
|
-
assetDigests = Object.create(null);
|
|
200
|
-
for (const reference of references) {
|
|
201
|
-
const image = given[reference];
|
|
202
|
-
const sound = reference.length > 0 && Buffer.byteLength(reference, "utf8") <= MAX_REFERENCE_BYTES && !/["\\\u0000-\u001f]/.test(reference)
|
|
203
|
-
&& record(image) && image.bytes instanceof Uint8Array && (image.mime === "image/png" || image.mime === "image/jpeg")
|
|
204
|
-
&& image.bytes.byteLength > 0 && image.bytes.byteLength <= MAX_IMAGE_BYTES && startsWith(image.bytes, imageMagic[image.mime]);
|
|
205
|
-
if (!sound)
|
|
206
|
-
throw invalid("invalid_assets", `Each image is a PNG or JPEG file of at most ${MAX_IMAGE_BYTES / 1024 / 1024} MiB, with bytes that are that type and a plain reference. No request was sent.`);
|
|
207
|
-
if (!texts.some(text => text.includes(reference)))
|
|
208
|
-
throw invalid("invalid_assets", "Every image reference must appear in the state or a question. No request was sent.");
|
|
209
|
-
assets[reference] = new Image(image.bytes);
|
|
210
|
-
assetDigests[reference] = { sha256: createHash("sha256").update(image.bytes).digest("hex"), mime: image.mime, bytes: image.bytes.byteLength };
|
|
211
|
-
}
|
|
212
|
-
request = { ...request, assets };
|
|
213
|
-
// The hash names the images by their own digests, so it identifies the request that was sent without holding the bytes.
|
|
214
|
-
body = JSON.stringify({ ...JSON.parse(body), assets: assetDigests });
|
|
215
|
-
}
|
|
216
|
-
const headers = carriesImage || hasBox ? { [IMAGE_HEADER]: "true" } : undefined;
|
|
263
|
+
const { request, body, headers } = prepareRequest(params, model, maxStateBytes, provider);
|
|
217
264
|
// The first attempt that failed in a way the server may have billed (a lost answer, a 408, a 5xx). A later 429 does not make the call free: the give-up reports this one.
|
|
218
265
|
let billable;
|
|
219
266
|
for (attempt = 1;; attempt++) {
|
|
@@ -226,7 +273,9 @@ export function createJevRunner(options = {}) {
|
|
|
226
273
|
}), signal, abortError);
|
|
227
274
|
}
|
|
228
275
|
catch (error) {
|
|
229
|
-
|
|
276
|
+
// Each attempt's timeout is the call's remaining budget, so the client's timer and the deadline end together: whichever fires first,
|
|
277
|
+
// the deadline ended the call. A timed-out request may have been billed, and its code is what says so.
|
|
278
|
+
if (signal.aborted || (timeoutMs !== null && error instanceof APITimeoutError))
|
|
230
279
|
throw abortError();
|
|
231
280
|
const status = error instanceof APIError ? error.status : undefined;
|
|
232
281
|
const retryable = status === 408 || status === 429 || (status !== undefined && status >= 500 && status <= 599) || error instanceof APIConnectionError;
|
|
@@ -250,7 +299,7 @@ export function createJevRunner(options = {}) {
|
|
|
250
299
|
throw billable.status === undefined ? new JevError("connection", "Jev transport failed.", attempt) : new JevError("http", `Jev returned HTTP ${billable.status}.`, attempt, billable.status);
|
|
251
300
|
if (status !== undefined) {
|
|
252
301
|
const safeStatus = Number.isInteger(status) && status >= 100 && status <= 599 ? status : undefined;
|
|
253
|
-
const capacity = error instanceof APIError &&
|
|
302
|
+
const capacity = error instanceof APIError && isRecord(error.body) && isRecord(error.body.detail)
|
|
254
303
|
&& error.body.detail.error_type === "max_tokens_exceeded";
|
|
255
304
|
// Only the structured reason is named; the provider's body text never reaches the message.
|
|
256
305
|
const returned = safeStatus === undefined ? "Jev returned an HTTP failure" : `Jev returned HTTP ${safeStatus}`;
|
|
@@ -262,37 +311,7 @@ export function createJevRunner(options = {}) {
|
|
|
262
311
|
throw new JevError("connection", "Jev transport failed.", attempt);
|
|
263
312
|
throw new JevError("invalid_request", "Jev could not process the request. Check question and transport configuration.", attempt);
|
|
264
313
|
}
|
|
265
|
-
|
|
266
|
-
throw invalidResponse("response_shape", "Jev returned an invalid response.");
|
|
267
|
-
const answers = result.answers;
|
|
268
|
-
try {
|
|
269
|
-
validateAnswers(params.questions, answers);
|
|
270
|
-
}
|
|
271
|
-
catch (error) {
|
|
272
|
-
throw invalidResponse(error instanceof SystemOneError && isSystemOneResponseReason(error.responseReason) ? error.responseReason : "answer_validation", "Jev returned invalid answers or probability distributions.");
|
|
273
|
-
}
|
|
274
|
-
const usage = result.usage;
|
|
275
|
-
// Usage carries token counts, a provider-reported cost (USD), or both; a half pair of counts or a bad cost is invalid.
|
|
276
|
-
const counted = record(usage) && tokens(usage.input_tokens) && tokens(usage.output_tokens);
|
|
277
|
-
const reported = record(usage) && usage.cost !== undefined && usage.cost !== null;
|
|
278
|
-
const reportedCost = reported && typeof usage.cost === "number" && Number.isFinite(usage.cost) && usage.cost >= 0 ? usage.cost : undefined;
|
|
279
|
-
const noCounts = record(usage) && usage.input_tokens === undefined && usage.output_tokens === undefined;
|
|
280
|
-
if (usage != null && (!record(usage) || (reported ? reportedCost === undefined || !(counted || noCounts) : !counted))) {
|
|
281
|
-
throw invalidResponse("token_usage", "Jev returned invalid token usage.");
|
|
282
|
-
}
|
|
283
|
-
if (result.model != null && (typeof result.model !== "string" || !result.model.trim())) {
|
|
284
|
-
throw invalidResponse("model_identifier", "Jev returned an invalid model identifier.");
|
|
285
|
-
}
|
|
286
|
-
const metering = counted ? { input_tokens: usage.input_tokens, output_tokens: usage.output_tokens } : null;
|
|
287
|
-
// The provider's own cost wins; else the configured estimate; else unknown (null), never 0.
|
|
288
|
-
const cost = reportedCost !== undefined ? reportedCost : pricing && metering ? (metering.input_tokens * pricing.inputUsdPerMillionTokens + metering.output_tokens * pricing.outputUsdPerMillionTokens) / 1_000_000 : null;
|
|
289
|
-
if (cost !== null && !Number.isFinite(cost))
|
|
290
|
-
throw invalidResponse("cost_range", "Jev cost estimate exceeded the numeric range.");
|
|
291
|
-
return {
|
|
292
|
-
answers, model: typeof result.model === "string" ? result.model : null,
|
|
293
|
-
usage: metering, cost_usd: cost,
|
|
294
|
-
request_sha256: createHash("sha256").update(body).digest("hex"),
|
|
295
|
-
};
|
|
314
|
+
return readResponse(result, request.questions, body, pricing, attempt);
|
|
296
315
|
}
|
|
297
316
|
}
|
|
298
317
|
finally {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parcha/agentrun-jev",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.26",
|
|
4
4
|
"description": "Optional Jev system one decisions for AgentRun workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"test": "node --test test/*.test.mjs"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@parcha/agentrun-dsl": "0.1.0-beta.
|
|
30
|
+
"@parcha/agentrun-dsl": "0.1.0-beta.26",
|
|
31
31
|
"@typesafe-ai/sdk": "0.7.0"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|