@parcha/agentrun-jev 0.1.0-beta.2 → 0.1.0-beta.21

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 CHANGED
@@ -2,14 +2,62 @@
2
2
 
3
3
  Optional [TypeSafe Jev](https://docs.typesafe.ai/) support for AgentRun DSL. Jev answers focused questions with typed values and probability distributions. The workflow decides how to use them.
4
4
 
5
- Published beta: `0.1.0-beta.2`. Requires Node.js 22.19 or later.
5
+ Beta, on npm under the `beta` dist-tag (`npm view @parcha/agentrun-jev dist-tags`). Requires Node.js 22.19 or later.
6
6
 
7
7
  ```sh
8
8
  npm install @parcha/agentrun-dsl@beta @parcha/agentrun-jev@beta
9
+ ```
10
+
11
+ ## Offline tests
12
+
13
+ To exercise the real adapter with no key and no network request, inject a client. After installing the packages, save this as `offline-jev.mjs` and run `node offline-jev.mjs`:
14
+
15
+ ```js
16
+ import assert from 'node:assert/strict';
17
+ import { createJevRunner } from '@parcha/agentrun-jev';
18
+
19
+ const runJudge = createJevRunner({
20
+ maxAttempts: 1,
21
+ client: {
22
+ async systemOne(request, options) {
23
+ options.signal?.throwIfAborted();
24
+ assert.deepEqual(Object.keys(request.questions), ['answersQuestion']);
25
+ return {
26
+ answers: { answersQuestion: { type: 'noul', noul: 0.95 } },
27
+ };
28
+ },
29
+ },
30
+ });
31
+
32
+ const result = await runJudge({
33
+ label: 'screen-evidence',
34
+ kind: 'judge',
35
+ state: {
36
+ question: 'Can emergency documentation fixes bypass prior approval?',
37
+ passage: 'During an incident, the on-call engineer may merge a fix immediately; a second person reviews it the next working day.',
38
+ },
39
+ questions: {
40
+ answersQuestion: {
41
+ type: 'noul',
42
+ instructions: 'Does the passage contain a specific rule or exception answering the question? Retain conditional and contrary evidence; reject topic mentions and promotional claims.',
43
+ },
44
+ },
45
+ });
46
+ assert.equal(result.answers.answersQuestion.noul, 0.95);
47
+ console.log(result.answers.answersQuestion);
48
+ ```
49
+
50
+ This prints `{ type: 'noul', noul: 0.95 }`. The answer is **scripted**; it does not evaluate the passage or adapt to prompt changes. Answer IDs must match the requested question IDs. Noul returns a probability, not a boolean. Reported model and token usage may be omitted; the adapter preserves missing usage/cost as unknown instead of inventing measurements.
51
+
52
+ ## Call Jev
53
+
54
+ A live call needs a key. Set it, then run the sample below as `live-jev.mjs` (`node live-jev.mjs`). The samples that follow are written in TypeScript; run them with a TypeScript runner such as `npx tsx`, or drop the `type` imports and annotations to run them as `.mjs`.
55
+
56
+ ```sh
9
57
  export TYPESAFE_API_KEY="your-key"
10
58
  ```
11
59
 
12
- ```ts
60
+ ```js
13
61
  import { createJevRunner } from '@parcha/agentrun-jev';
14
62
 
15
63
  const runJudge = createJevRunner();
@@ -29,11 +77,13 @@ const result = await runJudge({
29
77
  console.log(result.answers.team);
30
78
  ```
31
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`.
81
+
32
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.
33
83
 
34
84
  `System One` is TypeSafe's typed-question interface, exposed by the SDK as `systemOne`. A Noul answer is a truth probability from 0 to 1; the DSL converts probabilities of at least 0.5 to `true` and retains the probability beside that value. Choice and Score confidence comes from the response. Jev does not return a separate confidence for Noul; the DSL derives its own gate-strength value from the distance from 0.5, scaled to the range 0 to 1. These numbers are useful gate inputs, not calibrated guarantees.
35
85
 
36
- The public adapter API includes `createJevRunner`, `JevError`, `isJevResponseReason`, and the types `JevOptions`, `JevClient`, `JevErrorCode`, `JevResponseReason`, and `JevResponseDiagnostic`. The shared question, answer and distribution helpers live in `@parcha/agentrun-dsl`.
86
+ The public adapter API is `createJevRunner`, `jevProviderFromEnv`, `JevError`, `isJevResponseReason`, and the types `JevOptions`, `JevClient`, `JevProvider`, `JevErrorCode`, `JevResponseReason`, `JevResponseDiagnostic`, `JevRequestDiagnostic`, `JevImage`, `JevJudge`, `JevJudgeParams` and `JevJudgeResult`. The shared question, answer and distribution helpers live in `@parcha/agentrun-dsl`.
37
87
 
38
88
  ## Configuration
39
89
 
@@ -48,15 +98,30 @@ const runJudge = createJevRunner({
48
98
  });
49
99
  ```
50
100
 
101
+ ### Through OpenRouter
102
+
103
+ Jev is also served by OpenRouter's System One path, on the same request shape. Choose it with `provider`:
104
+
105
+ ```ts
106
+ import { createJevRunner, jevProviderFromEnv } from "@parcha/agentrun-jev";
107
+
108
+ const runJudge = createJevRunner({ provider: "openrouter" }); // key: apiKey, else OPENROUTER_API_KEY
109
+ const byEnv = createJevRunner({ provider: jevProviderFromEnv(process.env) }); // OpenRouter only when only OPENROUTER_API_KEY is set
110
+ ```
111
+
112
+ `provider: "openrouter"` sends to `https://openrouter.ai/api/v1/systemone` (an explicit `baseURL` wins) and defaults the model to `~typesafe/jev-latest`; pin `typesafe/jev-1.14` when a result must be reproducible. It never falls back to `TYPESAFE_API_KEY`: with no OpenRouter key it is a configuration error. The default provider is `typesafe` and nothing changes for it. `provider` cannot be combined with `client`.
113
+
114
+ Cost: when the response reports `usage.cost` (USD) it is `cost_usd`; otherwise a configured `pricing` estimate is used; otherwise `cost_usd` is `null`, never 0. A `usage` with a cost and no token counts is accepted and returns `usage: null`. Requests over about 32 KB of state run in the provider's slower long-context mode.
115
+
51
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.
52
117
 
53
- The adapter retries transient HTTP 408, 429, 5xx responses and connection failures with bounded exponential delays. 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. Cancellation bounds when the adapter returns; an uncooperative custom client can still continue its own work.
54
119
 
55
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.
56
121
 
57
122
  The core allows up to 240 options per choice and 2–10 levels per score rubric. Adapter timeouts are positive integer milliseconds up to 2,147,483,647; retry delays may also be zero. `maxAttempts` is 1–10, with a default of 3.
58
123
 
59
- `JevError` provides a stable `code`, `attempts`, and optional HTTP `status`. Errors deliberately omit raw service bodies, headers, input state, and causes. SDK logging is disabled. Custom clients and transports own their own logging. Do not expose credentials in browser code.
124
+ `JevError` provides a stable `code`, `attempts`, and optional HTTP `status`. A request the runner refuses before sending it has code `invalid_request`, `attempts` 0 and a `requestDiagnostic` (`JevRequestDiagnostic`): a `reason` (`invalid_state`, `state_too_large`, `invalid_questions`, `invalid_assets`, `unsupported_model` or `unsupported_provider`), the call's `label` (at most 200 characters) when it has one, and for a state over `maxStateBytes` its `stateBytes` and `maxStateBytes`. Errors deliberately omit raw service bodies, headers, input state, and causes. SDK logging is disabled. Custom clients and transports own their own logging. Do not expose credentials in browser code.
60
125
 
61
126
  Optional `responseDiagnostic.reason` identifies a fixed response invariant, such
62
127
  as `answer_keys`, `probability_mass`, or `token_usage`. An explicitly structured
@@ -64,48 +129,39 @@ capacity rejection can report `max_tokens_exceeded`; arbitrary provider messages
64
129
  are never parsed for a reason. These details do not change validation, retry
65
130
  policy, or the submitted evidence. Unknown causes remain unspecified.
66
131
 
67
- Successful results retain `answers`, reported `model`, reported token `usage`, and a SHA-256 of the submitted request. `cost_usd` is `null` unless both usage and explicit `pricing: { inputUsdPerMillionTokens, outputUsdPerMillionTokens }` are available. Configured prices produce an estimate for the successful response; costs from unsuccessful or interrupted attempts are unknown.
68
-
69
- ## Offline tests
132
+ Successful results retain `answers`, reported `model`, reported token `usage`, and a SHA-256 of the submitted request. `cost_usd` is the response's reported `usage.cost` when it has one, else an estimate from the counted tokens and an explicit `pricing: { inputUsdPerMillionTokens, outputUsdPerMillionTokens }`, else `null`. Configured prices produce an estimate for the successful response; costs from unsuccessful or interrupted attempts are unknown.
70
133
 
71
- Inject a client to exercise the real adapter without a key or network request. After installing the DSL and Jev packages, save this as `offline-jev.mjs` and run `node offline-jev.mjs`:
134
+ ## Images and Box questions
72
135
 
73
- ```js
74
- import assert from 'node:assert/strict';
75
- import { createJevRunner } from '@parcha/agentrun-jev';
136
+ Jev 1.13 and 1.14 read still images, and Jev 1.14 can answer a Box question: where in an image something is. TypeSafe leaves both off unless a request says it carries one, and the runner says so for you: it sends `TypeSafe-Danger-Zone-Images: true` on every request that carries an image or a Box question and on no other.
76
137
 
77
- const runJudge = createJevRunner({
78
- maxAttempts: 1,
79
- client: {
80
- async systemOne(request, options) {
81
- options.signal?.throwIfAborted();
82
- assert.deepEqual(Object.keys(request.questions), ['answersQuestion']);
83
- return {
84
- answers: { answersQuestion: { type: 'noul', noul: 0.95 } },
85
- };
86
- },
87
- },
88
- });
138
+ ```ts
139
+ import { createJevRunner, type JevJudge } from '@parcha/agentrun-jev';
89
140
 
90
- const result = await runJudge({
91
- label: 'screen-evidence',
92
- kind: 'judge',
93
- state: {
94
- question: 'Can emergency documentation fixes bypass prior approval?',
95
- passage: 'During an incident, the on-call engineer may merge a fix immediately; a second person reviews it the next working day.',
96
- },
141
+ const judge: JevJudge = createJevRunner({ model: 'jev-1.14.0' });
142
+ const { answers, usage, cost_usd } = await judge({
143
+ label: 'door', kind: 'judge',
144
+ state: 'The camera shows <photo>.',
97
145
  questions: {
98
- answersQuestion: {
99
- type: 'noul',
100
- instructions: 'Does the passage contain a specific rule or exception answering the question? Retain conditional and contrary evidence; reject topic mentions and promotional claims.',
101
- },
146
+ open: { type: 'noul', instructions: 'Is the door in <photo> open?' },
147
+ door: { type: 'box', instructions: 'the door in <photo>' },
102
148
  },
149
+ assets: { '<photo>': { bytes: pngOrJpegFileBytes, mime: 'image/png' } }, // PNG or JPEG file bytes
103
150
  });
104
- assert.equal(result.answers.answersQuestion.noul, 0.95);
105
- console.log(result.answers.answersQuestion);
151
+ // answers.door = { type: 'box', box: { top, bottom, left, right }, confidence } — edges are fractions of the image's height and width
106
152
  ```
107
153
 
108
- This prints `{ type: 'noul', noul: 0.95 }`. The answer is **scripted**; it does not evaluate the passage or adapt to prompt changes. Answer IDs must match the requested question IDs. Noul returns a probability, not a boolean. Reported model and token usage may be omitted; the adapter preserves missing usage/cost as unknown instead of inventing measurements.
154
+ - `assets` maps each reference, a string that appears literally in `state` or in a question's `instructions` or `criteria`, to the image. The runner builds the SDK's image from the bytes and sends it as a multipart file part. Each reference must appear inside a string value of the state, an instruction or a criterion (a question id or an option name does not count), or the request is refused.
155
+ - A Box question needs a model named for it: `model: 'jev-1.14.0'`, a later version, or `'jev-preview'`. The runner checks the name, so it refuses a Box on `jev-latest` whatever version that alias points at. A Box on any other model, or a Box with no image, is refused with `JevError` code `invalid_request` before any request. `model` can also be named per call.
156
+ - Before any request, the runner also refuses what Jev would reject: a file that is not PNG or JPEG (the bytes are checked against `mime`), an empty file, a file over 16 MiB, a reference nothing uses. It does not check pixel dimensions (each side 280 to 1568, total 78,400 to 2,458,624 pixels) or the 32 MiB request limit; Jev rejects those with a 400 or 413, which surfaces as a typed `http` error.
157
+ - Images are billed as input tokens at the text price, and `usage.input_tokens` counts them. `cost_usd` is as for text: the provider's reported cost, else your `pricing` applied to the counted tokens, else `null`. `request_sha256` names each image by its own SHA-256, so the same request hashes the same and a different image does not.
158
+ - A Box answer is always a box (there is no "not found"): ask a noul question in the same request to check presence. Edges and confidence are checked to lie in [0, 1] and the box to run forward (`top <= bottom`, `left <= right`); a confidence the provider omits is `null`.
159
+ - `JevJudge`, `JevJudgeParams`, `JevJudgeResult` and `JevImage` are the types of this call. A package that asks Jev about images takes a `JevJudge` from its host and never imports the SDK.
160
+ - Images and Box go to TypeSafe's own API only. The OpenRouter path is not verified for them, so a request that carries either is refused with `provider: "openrouter"`.
161
+ - Request bodies are sent uncompressed; the SDK's default compresses them with zstd.
162
+ - Box is a preview: Jev's page says its request format, answers and limits can change between releases.
163
+
164
+ ## Testing your own host
109
165
 
110
166
  The [standalone TypeScript starter](https://github.com/Parcha-ai/agentrun/tree/main/examples/starter#readme) includes npm installation, a complete search-and-screen workflow, and tests for missing evidence and the screening step alone. Its injected client answers every named question generated by `sift`.
111
167
 
package/dist/index.d.ts CHANGED
@@ -4,7 +4,32 @@ import type { WorkflowDeps, SystemOneResponseReason } from "@parcha/agentrun-dsl
4
4
  export interface JevClient {
5
5
  systemOne(request: SystemOneRequest, options: RequestOptions): PromiseLike<unknown>;
6
6
  }
7
+ /** Where Jev is reached: TypeSafe's own API (default) or OpenRouter's System One path. */
8
+ export type JevProvider = "typesafe" | "openrouter";
9
+ /** 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. */
10
+ export declare function jevProviderFromEnv(env?: Record<string, string | undefined>): JevProvider;
11
+ /** A still image a request carries: PNG or JPEG file bytes and the type they are. The runner builds the SDK's image from it and sends it as a multipart file part. */
12
+ export interface JevImage {
13
+ bytes: Uint8Array;
14
+ mime: "image/png" | "image/jpeg";
15
+ }
16
+ type RunJudge = NonNullable<WorkflowDeps["runJudge"]>;
17
+ /** What one call to the runner takes: the interpreter's `runJudge` parameters, plus the images the request carries and an optional model for this call.
18
+ * `assets` maps each reference, a string that appears literally in `state` or in a question's `instructions` or `criteria` (`"<photo>"`), to its image;
19
+ * every reference must appear, and a Box question needs at least one image. */
20
+ export type JevJudgeParams = Parameters<RunJudge>[0] & {
21
+ assets?: Record<string, JevImage>;
22
+ /** Names the model for this call, over the runner's `model`. A Box question needs `jev-1.14.0` (or later) or `jev-preview` by name; the default `jev-latest` is refused for it whatever version that alias points at. */
23
+ model?: string;
24
+ };
25
+ /** The typed answers (choice, noul, score with their probabilities; box with its edges and confidence), the model that answered, its token usage, and
26
+ * the cost: the provider's reported cost, else the host's configured price applied to the counted tokens, else null. */
27
+ export type JevJudgeResult = Awaited<ReturnType<RunJudge>>;
28
+ /** The judge `createJevRunner` returns, and the port a host passes to a package that asks Jev about images without importing the SDK. */
29
+ export type JevJudge = (params: JevJudgeParams) => Promise<JevJudgeResult>;
7
30
  export interface JevOptions {
31
+ /** `typesafe` (default) or `openrouter`. OpenRouter sets the API root and the default model (`~typesafe/jev-latest`); its key is `apiKey` or `OPENROUTER_API_KEY`. Cannot be combined with `client`. */
32
+ provider?: JevProvider;
8
33
  apiKey?: string;
9
34
  /** API root, without /v1/systemone; useful for an explicitly configured gateway. */
10
35
  baseURL?: string;
@@ -20,7 +45,7 @@ export interface JevOptions {
20
45
  maxAttempts?: number;
21
46
  retryBaseMs?: number;
22
47
  retryMaxMs?: number;
23
- /** Explicit estimate; unknown prices are never silently treated as free. */
48
+ /** Explicit estimate, used when the provider reports no cost; unknown prices are never silently treated as free. */
24
49
  pricing?: {
25
50
  inputUsdPerMillionTokens: number;
26
51
  outputUsdPerMillionTokens: number;
@@ -34,7 +59,7 @@ export type JevResponseDiagnostic = {
34
59
  reason: JevResponseReason;
35
60
  };
36
61
  export type JevRequestDiagnostic = {
37
- reason: "invalid_state" | "state_too_large" | "invalid_questions";
62
+ reason: "invalid_state" | "state_too_large" | "invalid_questions" | "invalid_assets" | "unsupported_model" | "unsupported_provider";
38
63
  label?: string;
39
64
  stateBytes?: number;
40
65
  maxStateBytes?: number;
@@ -48,5 +73,5 @@ export declare class JevError extends Error {
48
73
  readonly responseDiagnostic?: JevResponseDiagnostic;
49
74
  constructor(code: JevErrorCode, message: string, attempts?: number, status?: number | undefined, requestDiagnostic?: JevRequestDiagnostic | undefined, responseDiagnostic?: JevResponseDiagnostic);
50
75
  }
51
- export declare function createJevRunner(options?: JevOptions): NonNullable<WorkflowDeps["runJudge"]>;
76
+ export declare function createJevRunner(options?: JevOptions): JevJudge;
52
77
  export {};
package/dist/index.js CHANGED
@@ -1,6 +1,41 @@
1
1
  import { createHash } from "node:crypto";
2
- import { APIConnectionError, APIError, TypeSafeClient } from "@typesafe-ai/sdk";
2
+ import { setTimeout as sleep } from "node:timers/promises";
3
+ import { APIConnectionError, APIError, Image, TypeSafeClient } from "@typesafe-ai/sdk";
3
4
  import { validateAnswers, SystemOneError, isSystemOneResponseReason } from "@parcha/agentrun-dsl";
5
+ const keyVariable = { typesafe: "TYPESAFE_API_KEY", openrouter: "OPENROUTER_API_KEY" };
6
+ const OPENROUTER_BASE_URL = "https://openrouter.ai/api";
7
+ const OPENROUTER_DEFAULT_MODEL = "~typesafe/jev-latest";
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
+ export function jevProviderFromEnv(env = process.env) {
10
+ const set = (name) => typeof env[name] === "string" && env[name].trim().length > 0;
11
+ return set(keyVariable.openrouter) && !set(keyVariable.typesafe) ? "openrouter" : "typesafe";
12
+ }
13
+ const IMAGE_HEADER = "TypeSafe-Danger-Zone-Images";
14
+ const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
15
+ const MAX_REFERENCE_BYTES = 1024;
16
+ /** The longest delay a Node timer accepts. */
17
+ const MAX_TIMER_MS = 2_147_483_647;
18
+ const BOX_MODEL = "jev-1.14.0";
19
+ /** Models that answer a Box question: Jev 1.14 and later by name, and the preview alias. The check is by name, so `jev-latest` is refused whatever version it points at. */
20
+ function answersBox(model) {
21
+ if (model === "jev-preview")
22
+ return true;
23
+ const version = /^jev-(\d+)\.(\d+)(?:\.\d+)?$/.exec(model);
24
+ return version !== null && (Number(version[1]) > 1 || (Number(version[1]) === 1 && Number(version[2]) >= 14));
25
+ }
26
+ /** Every string value of a JSON value, not its keys. */
27
+ function* stringValues(value) {
28
+ if (typeof value === "string")
29
+ yield value;
30
+ else if (Array.isArray(value))
31
+ for (const each of value)
32
+ yield* stringValues(each);
33
+ else if (record(value))
34
+ for (const each of Object.values(value))
35
+ yield* stringValues(each);
36
+ }
37
+ const startsWith = (bytes, prefix) => prefix.every((byte, index) => bytes[index] === byte);
38
+ const imageMagic = { "image/png": [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], "image/jpeg": [0xff, 0xd8, 0xff] };
4
39
  const adapterResponseReasons = ["response_shape", "token_usage", "model_identifier", "cost_range", "answer_validation", "max_tokens_exceeded"];
5
40
  export function isJevResponseReason(value) {
6
41
  return isSystemOneResponseReason(value) || typeof value === "string" && adapterResponseReasons.includes(value);
@@ -27,7 +62,7 @@ const configuration = (condition, message) => {
27
62
  if (!condition)
28
63
  throw new JevError("configuration", message);
29
64
  };
30
- const milliseconds = (n, allowZero = false) => Number.isSafeInteger(n) && n >= (allowZero ? 0 : 1) && n <= 2_147_483_647;
65
+ const milliseconds = (n, allowZero = false) => Number.isSafeInteger(n) && n >= (allowZero ? 0 : 1) && n <= MAX_TIMER_MS;
31
66
  const record = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
32
67
  const tokens = (n) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
33
68
  function abortable(promise, signal, error) {
@@ -42,14 +77,23 @@ function abortable(promise, signal, error) {
42
77
  abort();
43
78
  });
44
79
  }
45
- function wait(ms, signal, error) {
46
- return new Promise((resolve, reject) => {
47
- const abort = () => { clearTimeout(timer); signal.removeEventListener("abort", abort); reject(error()); };
48
- const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve(); }, ms);
49
- signal.addEventListener("abort", abort, { once: true });
50
- if (signal.aborted)
51
- abort();
52
- });
80
+ async function wait(ms, signal, error) {
81
+ try {
82
+ await sleep(ms, undefined, { signal });
83
+ }
84
+ catch {
85
+ throw error();
86
+ }
87
+ }
88
+ /** A server's Retry-After in milliseconds from now, given as delta-seconds or an HTTP date; undefined when absent or unparseable. */
89
+ function retryAfterMs(headers) {
90
+ const value = headers instanceof Headers ? headers.get("retry-after")?.trim() : undefined;
91
+ if (!value)
92
+ return undefined;
93
+ if (/^\d+$/.test(value))
94
+ return Number(value) * 1000;
95
+ const date = Date.parse(value);
96
+ return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
53
97
  }
54
98
  export function createJevRunner(options = {}) {
55
99
  const timeoutMs = options.timeoutMs === null ? null : (options.timeoutMs ?? 30_000);
@@ -57,26 +101,34 @@ export function createJevRunner(options = {}) {
57
101
  const maxAttempts = options.maxAttempts ?? 3;
58
102
  const retryBaseMs = options.retryBaseMs ?? 500;
59
103
  const retryMaxMs = options.retryMaxMs ?? 5_000;
60
- configuration(timeoutMs === null || milliseconds(timeoutMs), "timeoutMs must be null or a positive integer of at most 2147483647.");
104
+ configuration(timeoutMs === null || milliseconds(timeoutMs), `timeoutMs must be null or a positive integer of at most ${MAX_TIMER_MS}.`);
61
105
  configuration(maxStateBytes === null || (Number.isSafeInteger(maxStateBytes) && maxStateBytes > 0), "maxStateBytes must be null or a positive safe integer.");
62
106
  configuration(timeoutMs !== null || options.client !== undefined, "The TypeSafe SDK requires a finite timeout. For timeoutMs: null, supply a cancellation-only host client.");
63
107
  configuration(Number.isInteger(maxAttempts) && maxAttempts >= 1 && maxAttempts <= 10, "maxAttempts must be an integer from 1 to 10.");
64
108
  configuration(milliseconds(retryBaseMs, true) && milliseconds(retryMaxMs, true) && retryBaseMs <= retryMaxMs, "Retry delays must be nonnegative integers; retryBaseMs cannot exceed retryMaxMs.");
65
- configuration(!options.client || (options.apiKey === undefined && options.baseURL === undefined && options.fetch === undefined), "Supply either client or transport options, not both.");
109
+ configuration(!options.client || (options.apiKey === undefined && options.baseURL === undefined && options.fetch === undefined && options.provider === undefined), "Supply either client or transport options, not both.");
110
+ const provider = options.provider ?? "typesafe";
111
+ configuration(provider === "typesafe" || provider === "openrouter", "provider must be typesafe or openrouter.");
66
112
  const pricing = options.pricing ? { ...options.pricing } : undefined;
67
113
  if (pricing)
68
114
  configuration([pricing.inputUsdPerMillionTokens, pricing.outputUsdPerMillionTokens].every(n => Number.isFinite(n) && n >= 0), "Configured token prices must be finite nonnegative numbers.");
69
- const model = options.model ?? "jev-latest";
115
+ const model = options.model ?? (provider === "openrouter" ? OPENROUTER_DEFAULT_MODEL : "jev-latest");
70
116
  configuration(typeof model === "string" && model.trim().length > 0, "model must be a nonempty string.");
71
117
  let client;
72
118
  try {
119
+ const key = options.apiKey ?? (provider === "openrouter" ? process.env[keyVariable.openrouter] : undefined);
120
+ // The SDK falls back to TYPESAFE_API_KEY when given no key: for OpenRouter that would send one provider's key to another.
121
+ if (provider === "openrouter" && (typeof key !== "string" || key.trim().length === 0))
122
+ throw new Error("no key");
73
123
  client = options.client ?? new TypeSafeClient({
74
- apiKey: options.apiKey, baseURL: options.baseURL, fetch: options.fetch,
124
+ apiKey: key, baseURL: options.baseURL ?? (provider === "openrouter" ? OPENROUTER_BASE_URL : undefined), fetch: options.fetch,
75
125
  defaultModel: model, retry: { maxRetries: 0 }, timeout: timeoutMs ?? undefined, logLevel: "off",
126
+ // Bodies go as they always have: uncompressed JSON, which a host's own fetch, a gateway and OpenRouter all read.
127
+ compression: false,
76
128
  });
77
129
  }
78
130
  catch {
79
- throw new JevError("configuration", "Could not configure Jev. Supply apiKey or set TYPESAFE_API_KEY, and check transport options.");
131
+ throw new JevError("configuration", `Could not configure Jev. Supply apiKey or set ${keyVariable[provider]}, and check transport options.`);
80
132
  }
81
133
  const runnerSignal = options.signal;
82
134
  return async (params) => {
@@ -87,6 +139,7 @@ export function createJevRunner(options = {}) {
87
139
  const abortError = () => (runnerSignal?.aborted || params.signal?.aborted)
88
140
  ? new JevError("aborted", "Jev request was cancelled.", attempt)
89
141
  : new JevError("timeout", "Jev request exceeded its total deadline.", attempt);
142
+ const invalidResponse = (reason, message) => new JevError("invalid_response", message, attempt, undefined, undefined, { reason });
90
143
  if (signal.aborted)
91
144
  throw abortError();
92
145
  const timer = timeoutMs === null ? undefined : setTimeout(() => deadline.abort(), timeoutMs);
@@ -112,8 +165,11 @@ export function createJevRunner(options = {}) {
112
165
  const stateBytes = Buffer.byteLength(serializedState, "utf8");
113
166
  if (maxStateBytes !== null && stateBytes > maxStateBytes)
114
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.");
115
171
  try {
116
- body = JSON.stringify({ state: JSON.parse(serializedState), model, questions: params.questions });
172
+ body = JSON.stringify({ state: JSON.parse(serializedState), model: callModel, questions: params.questions });
117
173
  request = JSON.parse(body);
118
174
  if (!record(request.questions) || Object.keys(request.questions).length === 0)
119
175
  throw new Error();
@@ -121,13 +177,52 @@ export function createJevRunner(options = {}) {
121
177
  catch {
122
178
  throw invalid("invalid_questions", "Jev requires a nonempty JSON question map; no request was sent.");
123
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;
217
+ // 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
+ let billable;
124
219
  for (attempt = 1;; attempt++) {
125
220
  if (signal.aborted)
126
221
  throw abortError();
127
222
  let result;
128
223
  try {
129
224
  result = await abortable(client.systemOne(request, {
130
- signal, ...(timeoutMs === null ? {} : { timeout: Math.max(1, timeoutMs - (Date.now() - started)) }), retry: { maxRetries: 0 },
225
+ signal, ...(timeoutMs === null ? {} : { timeout: Math.max(1, timeoutMs - (Date.now() - started)) }), retry: { maxRetries: 0 }, ...(headers ? { headers } : {}),
131
226
  }), signal, abortError);
132
227
  }
133
228
  catch (error) {
@@ -135,40 +230,64 @@ export function createJevRunner(options = {}) {
135
230
  throw abortError();
136
231
  const status = error instanceof APIError ? error.status : undefined;
137
232
  const retryable = status === 408 || status === 429 || (status !== undefined && status >= 500 && status <= 599) || error instanceof APIConnectionError;
233
+ if (retryable && status !== 429 && billable === undefined)
234
+ billable = status === undefined ? {} : { status };
138
235
  if (retryable && attempt < maxAttempts) {
139
- await wait(Math.min(retryMaxMs, retryBaseMs * 2 ** (attempt - 1)), signal, abortError);
140
- continue;
236
+ const backoff = Math.min(retryMaxMs, retryBaseMs * 2 ** (attempt - 1));
237
+ const asked = error instanceof APIError && (status === 429 || status === 503) ? retryAfterMs(error.headers) : undefined;
238
+ const delay = asked === undefined ? backoff : Math.max(asked, backoff);
239
+ // A server's Retry-After wait is kept in full or the loop gives up now: a wait that would not end before the deadline (or within the longest
240
+ // timer, when there is none) is never started, since the retry after it would have no time left to answer (a request that times out has a
241
+ // cost no one knows). A backoff wait is not checked here: the deadline ends it.
242
+ const left = timeoutMs === null ? MAX_TIMER_MS : timeoutMs - (Date.now() - started);
243
+ if (asked === undefined || delay < left) {
244
+ await wait(delay, signal, abortError);
245
+ continue;
246
+ }
141
247
  }
248
+ // HTTP 429 means every attempt was refused unanswered; after one that may have been billed, the give-up names that one instead.
249
+ if (status === 429 && billable)
250
+ throw billable.status === undefined ? new JevError("connection", "Jev transport failed.", attempt) : new JevError("http", `Jev returned HTTP ${billable.status}.`, attempt, billable.status);
142
251
  if (status !== undefined) {
143
252
  const safeStatus = Number.isInteger(status) && status >= 100 && status <= 599 ? status : undefined;
144
253
  const capacity = error instanceof APIError && record(error.body) && record(error.body.detail)
145
254
  && error.body.detail.error_type === "max_tokens_exceeded";
146
- throw new JevError("http", safeStatus === undefined ? "Jev returned an HTTP failure." : `Jev returned HTTP ${safeStatus}.`, attempt, safeStatus, undefined, capacity ? { reason: "max_tokens_exceeded" } : undefined);
255
+ // Only the structured reason is named; the provider's body text never reaches the message.
256
+ const returned = safeStatus === undefined ? "Jev returned an HTTP failure" : `Jev returned HTTP ${safeStatus}`;
257
+ throw new JevError("http", capacity
258
+ ? `${returned}: the request exceeds the model's token budget (max_tokens_exceeded). Send fewer or shorter items per request.`
259
+ : `${returned}.`, attempt, safeStatus, undefined, capacity ? { reason: "max_tokens_exceeded" } : undefined);
147
260
  }
148
261
  if (error instanceof APIConnectionError)
149
262
  throw new JevError("connection", "Jev transport failed.", attempt);
150
263
  throw new JevError("invalid_request", "Jev could not process the request. Check question and transport configuration.", attempt);
151
264
  }
152
265
  if (!record(result))
153
- throw new JevError("invalid_response", "Jev returned an invalid response.", attempt, undefined, undefined, { reason: "response_shape" });
266
+ throw invalidResponse("response_shape", "Jev returned an invalid response.");
154
267
  const answers = result.answers;
155
268
  try {
156
269
  validateAnswers(params.questions, answers);
157
270
  }
158
271
  catch (error) {
159
- throw new JevError("invalid_response", "Jev returned invalid answers or probability distributions.", attempt, undefined, undefined, { reason: error instanceof SystemOneError && isSystemOneResponseReason(error.responseReason) ? error.responseReason : "answer_validation" });
272
+ throw invalidResponse(error instanceof SystemOneError && isSystemOneResponseReason(error.responseReason) ? error.responseReason : "answer_validation", "Jev returned invalid answers or probability distributions.");
160
273
  }
161
274
  const usage = result.usage;
162
- if (usage != null && (!record(usage) || !tokens(usage.input_tokens) || !tokens(usage.output_tokens))) {
163
- throw new JevError("invalid_response", "Jev returned invalid token usage.", attempt, undefined, undefined, { reason: "token_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.");
164
282
  }
165
283
  if (result.model != null && (typeof result.model !== "string" || !result.model.trim())) {
166
- throw new JevError("invalid_response", "Jev returned an invalid model identifier.", attempt, undefined, undefined, { reason: "model_identifier" });
284
+ throw invalidResponse("model_identifier", "Jev returned an invalid model identifier.");
167
285
  }
168
- const metering = record(usage) ? { input_tokens: usage.input_tokens, output_tokens: usage.output_tokens } : null;
169
- const cost = pricing && metering ? (metering.input_tokens * pricing.inputUsdPerMillionTokens + metering.output_tokens * pricing.outputUsdPerMillionTokens) / 1_000_000 : null;
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;
170
289
  if (cost !== null && !Number.isFinite(cost))
171
- throw new JevError("invalid_response", "Jev cost estimate exceeded the numeric range.", attempt, undefined, undefined, { reason: "cost_range" });
290
+ throw invalidResponse("cost_range", "Jev cost estimate exceeded the numeric range.");
172
291
  return {
173
292
  answers, model: typeof result.model === "string" ? result.model : null,
174
293
  usage: metering, cost_usd: cost,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parcha/agentrun-jev",
3
- "version": "0.1.0-beta.2",
3
+ "version": "0.1.0-beta.21",
4
4
  "description": "Optional Jev system one decisions for AgentRun workflows",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -27,8 +27,8 @@
27
27
  "test": "node --test test/*.test.mjs"
28
28
  },
29
29
  "dependencies": {
30
- "@parcha/agentrun-dsl": "0.1.0-beta.2",
31
- "@typesafe-ai/sdk": "0.6.0"
30
+ "@parcha/agentrun-dsl": "0.1.0-beta.21",
31
+ "@typesafe-ai/sdk": "0.7.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",