decision-api 1.3.0 → 2.0.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/README.md CHANGED
@@ -18,7 +18,8 @@ const decide = createDecisionApi({
18
18
  },
19
19
  defaultModel: 'jev',
20
20
  registerUsage: (usage, context: { customerId: string }) => {
21
- // usage: model, provider, inputTokens, outputTokens, costUsd, latencyMs
21
+ // usage: model, resolvedModel, provider, inputTokens, outputTokens,
22
+ // costUsd, latencyMs, attempts
22
23
  },
23
24
  onError: (error, context) => console.warn(error.kind, error.message)
24
25
  })
@@ -49,7 +50,50 @@ TypeScript enforces the setup:
49
50
  - **The model is optional only with a default.** Without `defaultModel`, every call must name one.
50
51
  - **Usage context is required when typed.** The type of `registerUsage`'s second parameter is required on every call and passed back to `registerUsage` and `onError`.
51
52
 
52
- A call answers `null` instead of throwing, and `onError` says why: `timeout`, `aborted` (the call's own signal), `unauthorized` (401/403, a bad key), `out-of-credits` (402), `rate-limited` (429 after the retry), `request-failed` (any other status, or a transport error), `state-too-large`, `images-unsupported`, `image-detached` (see [Images](#images)), `unreadable-response` or `answer-mismatch`. Every error carries its `model` and `provider`, and `status` when the provider answered; a throwing `registerUsage` reports `usage-failed` without failing the call. Retryable statuses (429 and 5xx) are retried once, after the provider's `Retry-After` when it asks for 2 s or less, and not at all when it asks for more. `fetch` replaces the transport, for example to route calls through your own rate limits, and `defaultSignal` supplies the signal for calls that pass none. A call with no questions answers `{}` without a request. The one throw is a call with no model and no `defaultModel`, which TypeScript already rejects.
53
+ A call answers `null` instead of throwing, and `onError` says why: `timeout`, `aborted` (the call's own signal: your own doing, so a logging `onError` may skip it), `unauthorized` (401/403, a bad key), `out-of-credits` (402), `rate-limited` (429 after the retry), `request-failed` (any other status, or a transport error), `request-too-large` (see below), `images-unsupported`, `image-detached` (see [Images](#images)), `unreadable-response` or `answer-mismatch`. Every error carries its `model` and `provider`, `status` when the provider answered, `latencyMs` and `attempts`; a throwing `registerUsage` reports `usage-failed` without failing the call. `fetch` replaces the transport, for example to route calls through your own rate limits, and `defaultSignal` supplies the signal for calls that pass none. A call with no questions answers `{}` without a request. The one throw is a call with no model and no `defaultModel`, which TypeScript already rejects.
54
+
55
+ **Timeouts and retries.** `timeoutMs` (default 4 s, per client or per call) bounds the whole call: every attempt, the pause between them and the body read. A status of 429, 500, 502, 503, 504 or 529 and a transport error are retried once, after the provider's `Retry-After` when it asks for 2 s or less and the deadline leaves time for it; otherwise the call fails at once. `attemptTimeoutMs` (off by default) abandons a request that has not answered by then and sends one more while time remains, so a voice turn can set `timeoutMs: 1_200, attemptTimeoutMs: 500` and still get a second try. An abandoned request may still be billed.
56
+
57
+ **Size.** The state and questions together are measured before sending: past `maxRequestChars` (default `DECISION_MAX_REQUEST_CHARS`, 90k characters, under Jev's ~32.5k-token wall) the call fails with `request-too-large` and nothing is sent. So does a call over its model's `maxQuestions`, `maxChoices` (options in one choice) or `maxLevels` (levels in one score) in `DECISION_MODELS`, and a provider's own token-limit refusal maps to the same kind. Extra questions add next to nothing to latency, but every question's instructions and criteria are billed as input tokens.
58
+
59
+ ## Usage and errors per call
60
+
61
+ `decide.result()` takes the same arguments as `decide()` and answers what happened, so a caller can store the model, tokens and latency next to its own record, or the reason there is no answer, without a side channel through the usage context:
62
+
63
+ ```ts
64
+ const outcome = await decide.result({ state, questions, customerId: 'c_1' })
65
+ if (outcome.ok) {
66
+ outcome.answers // as decide() answers them
67
+ outcome.usage // DecisionUsage; null for a call with no questions
68
+ } else {
69
+ outcome.error // DecisionError: kind, status, latencyMs, attempts
70
+ }
71
+ ```
72
+
73
+ `registerUsage` and `onError` still run. `usage.resolvedModel` is the model the provider says answered, when it says: TypeSafe resolves `jev` (sent as `jev-latest`) to a release there, so a new Jev release shows up in your rows.
74
+
75
+ ## One question per item
76
+
77
+ `perItem` asks the same question about each item of a list under keys `prefix_0`, `prefix_1`… and reads the answers back typed, with no key helpers and no `undefined` to default:
78
+
79
+ ```ts
80
+ import { perItem } from 'decision-api'
81
+
82
+ const perBlock = perItem(blocks, 'block', (block, index) => ({
83
+ type: 'noul',
84
+ instructions: `Does \`blocks[${index}]\` hold private material?`
85
+ }))
86
+ const answers = await decide({
87
+ state: { blocks },
88
+ questions: { ...perBlock.questions, rules },
89
+ customerId: 'c_1'
90
+ })
91
+ for (const { item, answer } of answers ? perBlock.read(answers) : []) {
92
+ if (answer.noul >= 0.35) flagged.push(item.id)
93
+ }
94
+ ```
95
+
96
+ The single answer types are exported too: `DecisionNoulAnswer`, `DecisionChoiceAnswer`, `DecisionScoreAnswer` and their union `DecisionAnswer`.
53
97
 
54
98
  ## Images
55
99
 
@@ -86,11 +130,43 @@ Each image is sent once, base64-encoded under a unique reference, however often
86
130
  - **A plain template loses the image.** `` `Same as ${badge}?` `` writes only the reference; the call answers `null` with `image-detached` instead of asking about a bare string.
87
131
  - **Prepare images first.** `image()` throws for anything but PNG or JPEG. Jev rejects images, and never resizes them, outside 280 to 1568 px a side or over 16 MiB, and ignores EXIF orientation, so scale and rotate before sending ([limits](https://docs.typesafe.ai/images#limits)). TypeSafe calls images a preview: the format and accuracy may change between Jev releases.
88
132
 
133
+ ## Testing
134
+
135
+ `decision-api/testing` has a fake client typed as the real one, so a test needs no cast. A number answers a noul or a score, a string a choice, or give the full answer. An answer that does not fit its question, or a missing one, throws in the test, since the real client would never return it:
136
+
137
+ ```ts
138
+ import { fakeDecide } from 'decision-api/testing'
139
+
140
+ const ask = fakeDecide<typeof decide>(() => ({ agreed: 0.9, team: 'billing' }))
141
+ const down = fakeDecide<typeof decide>(() => null) // fails as request-failed
142
+ ask.calls[0]?.questions // what the code under test sent
143
+ ```
144
+
145
+ ## Evaluating thresholds
146
+
147
+ `decision-api/eval` runs labelled cases against a real client and reports each question at the threshold your code ships. The app brings the cases; a case is a call plus the answers a careful human would give:
148
+
149
+ ```ts
150
+ import { report, runCases } from 'decision-api/eval'
151
+
152
+ const logs = await runCases({
153
+ decide,
154
+ cases: [{ name: 'salary slip', state, questions, expected: { private: true } }],
155
+ runs: 3,
156
+ models: ['jev'], // the client's default when left out
157
+ context: { customerId: 'eval' } // when registerUsage types a context
158
+ })
159
+ const summary = report(logs, { thresholds: { private: 0.35 } })
160
+ if (!summary.ok) process.exitCode = 1
161
+ ```
162
+
163
+ Per model it reports failed calls, latency p50/p90, input tokens and cost, and per question its accuracy at the threshold (0.5 when unset; a noul is positive at or above it). For nouls it adds misses and false positives there and at every threshold from 0.05 to 0.95, the margin between the lowest positive and the highest negative (below zero, no threshold separates them), the Brier score and a 10-bin reliability table. `summary.ok` is false when any call failed or any answer was wrong.
164
+
89
165
  ## Benchmark
90
166
 
91
- `bun run bench` asks every model with a key the fixed questions in [`bench/cases.ts`](bench/cases.ts) and writes `.artifacts/bench/report.html`: accuracy against the expected answers, probability on the right answer, latency p50/p90 and cost per model, every answer side by side, and a closing leaderboard that scores each model out of 100 (one point per right answer, a failed call scores zero). Keys come from the environment or `.env`: copy [`.env.example`](.env.example) to `.env` and fill in the providers you have; `--runs=5` and `--models=jev,clef` narrow or widen a run.
167
+ `bun run bench` asks every model with a key the fixed questions in [`bench/cases.ts`](bench/cases.ts) and writes `.artifacts/bench/report.html`: accuracy against the expected answers, probability on the right answer, latency p50/p90 and cost per model, every answer side by side, and a closing leaderboard; the console also prints each model's Brier score on the nouls that scores each model out of 100 (one point per right answer, a failed call scores zero). Keys come from the environment or `.env`: copy [`.env.example`](.env.example) to `.env` and fill in the providers you have; `--runs=5` and `--models=jev,clef` narrow or widen a run.
92
168
 
93
- Results from 2026-10-10 (12 cases, 21 questions, 3 runs per case):
169
+ Results from 2026-10-10 (12 cases, 21 questions, 3 runs per case; `typesafe/jev-1.13` was added after this run):
94
170
 
95
171
  | # | Model | Score | Points | p(correct) | Latency p50 / p90 | Cost / 1k calls |
96
172
  | --- | --- | --- | --- | --- | --- | --- |
@@ -3,29 +3,47 @@ import { type DecisionImageModel, type DecisionModel, type DecisionProvider, typ
3
3
  import { type DecisionAnswers, type DecisionQuestion } from './questions.ts';
4
4
  /**
5
5
  * Jev answers `400 max_tokens_exceeded` past roughly 32.5k input tokens (96k
6
- * chars passed, 128k did not). A state over this is refused before it leaves
7
- * the process; keep well under the wall, since tokenization varies. Clef takes
8
- * 64k tokens but silently truncates long state, so the same guard keeps it
9
- * from judging half a corpus.
6
+ * chars of state passed, 128k did not). Every question's instructions and
7
+ * criteria count too, so a request whose state and questions together are over
8
+ * this is refused before it leaves the process; keep well under the wall,
9
+ * since tokenization varies. Clef takes 64k tokens but silently truncates long
10
+ * state, so the same guard keeps it from judging half a corpus.
10
11
  */
11
- export declare const DECISION_MAX_STATE_CHARS = 90000;
12
+ export declare const DECISION_MAX_REQUEST_CHARS = 90000;
12
13
  /** What one answered call cost; `costUsd` is priced from `DECISION_MODELS`. */
13
14
  export type DecisionUsage<Model extends DecisionModel = DecisionModel> = {
14
15
  model: Model;
16
+ /**
17
+ * The model the provider says answered, when it says: an alias such as
18
+ * `jev` (sent as `jev-latest`) resolves to a release here.
19
+ */
20
+ resolvedModel?: string;
15
21
  provider: DecisionProvider;
16
22
  inputTokens: number;
17
23
  outputTokens: number;
18
24
  costUsd: number;
25
+ /** From the first request to the answer, retries included. */
19
26
  latencyMs: number;
27
+ /** Requests sent: 2 when the first was retried. */
28
+ attempts: number;
20
29
  };
21
- export type DecisionErrorKind = 'state-too-large'
30
+ export type DecisionErrorKind =
31
+ /**
32
+ * The state and questions together are over `maxRequestChars`, the call is
33
+ * over the model's question, option or level limit (`DECISION_MODELS`), or
34
+ * the provider refused it as too many tokens.
35
+ */
36
+ 'request-too-large'
22
37
  /** The call holds an image and the model reads none (`images: false`). */
23
38
  | 'images-unsupported'
24
39
  /** An image's reference is in the text without the image: build that text with `withImages`. */
25
40
  | 'image-detached'
26
- /** No answer within `timeoutMs`. */
41
+ /** No answer within `timeoutMs`, retries included. */
27
42
  | 'timeout'
28
- /** The call's own signal (or `defaultSignal`) aborted it. */
43
+ /**
44
+ * The call's own signal (or `defaultSignal`) aborted it: the caller's own
45
+ * doing, which an `onError` that logs failures may want to skip.
46
+ */
29
47
  | 'aborted'
30
48
  /** 401 or 403: the provider rejected the key. Retrying will not help. */
31
49
  | 'unauthorized'
@@ -41,6 +59,10 @@ export declare class DecisionError extends Error {
41
59
  /** The HTTP status, when the provider answered. */
42
60
  readonly status?: number | undefined;
43
61
  readonly provider: DecisionProvider;
62
+ /** From the start of the call to this failure. Set by the client. */
63
+ latencyMs: number;
64
+ /** Requests sent before this failure: 0 when it failed before sending. */
65
+ attempts: number;
44
66
  constructor(kind: DecisionErrorKind, model: DecisionModel, message: string,
45
67
  /** The HTTP status, when the provider answered. */
46
68
  status?: number | undefined);
@@ -64,7 +86,10 @@ type DecisionCall<Q extends Record<string, DecisionQuestion>, State> = {
64
86
  state: State;
65
87
  questions: Q;
66
88
  signal?: AbortSignal;
89
+ /** The whole call, retries and pauses included. */
67
90
  timeoutMs?: number;
91
+ /** One request; see `attemptTimeoutMs` on `createDecisionApi`. */
92
+ attemptTimeoutMs?: number;
68
93
  };
69
94
  type DecisionApiOptions<Keys extends DecisionProviderKeys, Default, Context> = {
70
95
  keys: Keys;
@@ -80,15 +105,34 @@ type DecisionApiOptions<Keys extends DecisionProviderKeys, Default, Context> = {
80
105
  /** The signal of a call that passes none. */
81
106
  defaultSignal?: () => AbortSignal | undefined;
82
107
  fetch?: DecisionFetch;
108
+ /** The whole call, retries and pauses included. Defaults to 4 s. */
83
109
  timeoutMs?: number;
84
- maxStateChars?: number;
110
+ /**
111
+ * Abandon a request that has not answered by then and send one more, while
112
+ * `timeoutMs` leaves time. Off by default; the abandoned request may still
113
+ * be billed.
114
+ */
115
+ attemptTimeoutMs?: number;
116
+ /** State plus questions, in characters. Defaults to `DECISION_MAX_REQUEST_CHARS`. */
117
+ maxRequestChars?: number;
118
+ };
119
+ /** What `decide.result()` answers: the answers and their usage, or why there are none. */
120
+ export type DecisionResult<Q extends Record<string, DecisionQuestion>, Model extends DecisionModel = DecisionModel> = {
121
+ ok: true;
122
+ answers: DecisionAnswers<Q>;
123
+ /** `null` only for a call with no questions, which sends nothing. */
124
+ usage: DecisionUsage<Model> | null;
125
+ } | {
126
+ ok: false;
127
+ error: DecisionError;
85
128
  };
86
129
  /**
87
130
  * Ask one or more independent judgments about the same state.
88
131
  *
89
132
  * **Send every independent question in ONE call**: questions are evaluated
90
- * together, so extra ones are close to free, and latency tracks the size of
91
- * `state`, not the number of questions.
133
+ * together, so extra ones add next to nothing to latency, which tracks the
134
+ * size of `state`. They are not free: every question's instructions and
135
+ * criteria are billed as input tokens.
92
136
  *
93
137
  * **Answers `null` rather than throwing** - on a timeout, an abort, a
94
138
  * transport error, a rejected request or an answer that does not match its
@@ -96,8 +140,15 @@ type DecisionApiOptions<Keys extends DecisionProviderKeys, Default, Context> = {
96
140
  * tagging job treats `null` as "no tags this time", a policy guard as "refuse".
97
141
  * It throws only when there is no model at all - a setup bug TypeScript
98
142
  * already rejects, which a silent `null` would hide.
143
+ *
144
+ * `decide.result()` takes the same arguments and answers the usage with the
145
+ * answers, or the error instead of `null`.
99
146
  */
100
- export type DecisionApi<Model extends DecisionModel, Default, Context> = <Q extends Record<string, DecisionQuestion>, State = unknown>(input: DecisionCall<Q, State> & Context, ...model: ModelArgument<HasImage<State> extends true ? Extract<Model, DecisionImageModel> : HasImage<Q> extends true ? Extract<Model, DecisionImageModel> : Model, Default>) => Promise<DecisionAnswers<Q> | null>;
147
+ export type DecisionApi<Model extends DecisionModel, Default, Context> = {
148
+ <Q extends Record<string, DecisionQuestion>, State = unknown>(input: DecisionCall<Q, State> & Context, ...model: DecisionModelArgument<Model, Default, Q, State>): Promise<DecisionAnswers<Q> | null>;
149
+ result<Q extends Record<string, DecisionQuestion>, State = unknown>(input: DecisionCall<Q, State> & Context, ...model: DecisionModelArgument<Model, Default, Q, State>): Promise<DecisionResult<Q, Model>>;
150
+ };
151
+ type DecisionModelArgument<Model extends DecisionModel, Default, Q, State> = ModelArgument<HasImage<State> extends true ? Extract<Model, DecisionImageModel> : HasImage<Q> extends true ? Extract<Model, DecisionImageModel> : Model, Default>;
101
152
  type ModelArgument<Model, Default> = [Default] extends [Model] ? [model?: Model] : [model: Model];
102
153
  /**
103
154
  * Whether a call's state or questions hold an image, as far as their types
@@ -1,21 +1,22 @@
1
1
  import { collectImages, detachedReference } from "./images.js";
2
- import { buildDecisionRequest, DECISION_MODELS, readDecisionResponse } from "./models.js";
2
+ import { buildDecisionRequest, DECISION_MODELS, overModelLimit, readDecisionResponse } from "./models.js";
3
3
  import { matchesQuestion } from "./questions.js";
4
4
  /**
5
5
  * Jev answers `400 max_tokens_exceeded` past roughly 32.5k input tokens (96k
6
- * chars passed, 128k did not). A state over this is refused before it leaves
7
- * the process; keep well under the wall, since tokenization varies. Clef takes
8
- * 64k tokens but silently truncates long state, so the same guard keeps it
9
- * from judging half a corpus.
6
+ * chars of state passed, 128k did not). Every question's instructions and
7
+ * criteria count too, so a request whose state and questions together are over
8
+ * this is refused before it leaves the process; keep well under the wall,
9
+ * since tokenization varies. Clef takes 64k tokens but silently truncates long
10
+ * state, so the same guard keeps it from judging half a corpus.
10
11
  */
11
- export const DECISION_MAX_STATE_CHARS = 90_000;
12
+ export const DECISION_MAX_REQUEST_CHARS = 90_000;
12
13
  /**
13
14
  * A generous ceiling rather than a target (Jev's p90 is ~280 ms for ten
14
15
  * questions): past it the caller is better served by its own fallback.
15
16
  */
16
17
  const DEFAULT_TIMEOUT_MS = 4_000;
17
18
  /** Retried once: the providers' own guidance for these is to back off and retry. */
18
- const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 529]);
19
+ const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504, 529]);
19
20
  const RETRY_DELAY_MS = 250;
20
21
  /**
21
22
  * A provider asking for a longer pause (`Retry-After`) will not be ready within
@@ -27,6 +28,10 @@ export class DecisionError extends Error {
27
28
  model;
28
29
  status;
29
30
  provider;
31
+ /** From the start of the call to this failure. Set by the client. */
32
+ latencyMs = 0;
33
+ /** Requests sent before this failure: 0 when it failed before sending. */
34
+ attempts = 0;
30
35
  constructor(kind, model, message,
31
36
  /** The HTTP status, when the provider answered. */
32
37
  status) {
@@ -38,7 +43,6 @@ export class DecisionError extends Error {
38
43
  this.provider = DECISION_MODELS[model].provider;
39
44
  }
40
45
  }
41
- /** The kind a refused response maps to; anything unlisted is `request-failed`. */
42
46
  const KIND_BY_STATUS = {
43
47
  401: 'unauthorized',
44
48
  402: 'out-of-credits',
@@ -47,29 +51,35 @@ const KIND_BY_STATUS = {
47
51
  };
48
52
  export function createDecisionApi(options) {
49
53
  const send = options.fetch ?? ((request) => fetch(request.url, request.init));
50
- const maxStateChars = options.maxStateChars ?? DECISION_MAX_STATE_CHARS;
51
- return async (input, ...[requestedModel]) => {
52
- const { state, questions, signal, timeoutMs, ...rest } = input;
54
+ const maxRequestChars = options.maxRequestChars ?? DECISION_MAX_REQUEST_CHARS;
55
+ const result = async (input, requestedModel) => {
56
+ const calledAt = Date.now();
57
+ const { state, questions, signal, timeoutMs, attemptTimeoutMs, ...rest } = input;
53
58
  const context = rest;
54
59
  const model = requestedModel ?? options.defaultModel;
55
60
  if (!model)
56
61
  throw new Error('No model given and no defaultModel set');
57
62
  const report = (error) => {
63
+ error.latencyMs = Date.now() - calledAt;
58
64
  options.onError?.(error, context);
59
- return null;
65
+ return { ok: false, error };
60
66
  };
61
67
  const fail = (kind, message) => report(new DecisionError(kind, model, message));
62
68
  if (Object.keys(questions).length === 0) {
63
- return {};
69
+ return { ok: true, answers: {}, usage: null };
64
70
  }
65
71
  // An image counts as its reference here: its tokens are the provider's to count.
66
72
  const stateText = typeof state === 'string' ? state : JSON.stringify(state);
67
- const stateChars = stateText.length;
68
- if (stateChars > maxStateChars) {
69
- return fail('state-too-large', `State is ${stateChars} chars, over ${maxStateChars}`);
73
+ const questionsText = JSON.stringify(questions);
74
+ const requestChars = stateText.length + questionsText.length;
75
+ if (requestChars > maxRequestChars) {
76
+ return fail('request-too-large', `State and questions are ${requestChars} chars, over ${maxRequestChars}`);
70
77
  }
78
+ const overLimit = overModelLimit(model, questions);
79
+ if (overLimit)
80
+ return fail('request-too-large', overLimit);
71
81
  const images = collectImages(questions, collectImages(state));
72
- const detached = detachedReference(stateText + JSON.stringify(questions), images);
82
+ const detached = detachedReference(stateText + questionsText, images);
73
83
  if (detached) {
74
84
  return fail('image-detached', `${detached} is in the text without its image; write that text with withImages`);
75
85
  }
@@ -77,14 +87,15 @@ export function createDecisionApi(options) {
77
87
  return fail('images-unsupported', `${model} does not read images`);
78
88
  }
79
89
  const startedAt = Date.now();
80
- let text;
90
+ let sent;
81
91
  try {
82
- text = await postWithRetry({
92
+ sent = await postWithRetry({
83
93
  send,
84
94
  model,
85
95
  request: buildDecisionRequest(model, { state, questions, images: [...images.values()] }, options.keys),
86
96
  signal: signal ?? options.defaultSignal?.(),
87
- timeoutMs: timeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS
97
+ timeoutMs: timeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
98
+ attemptTimeoutMs: attemptTimeoutMs ?? options.attemptTimeoutMs
88
99
  });
89
100
  }
90
101
  catch (error) {
@@ -92,39 +103,58 @@ export function createDecisionApi(options) {
92
103
  return report(error);
93
104
  return fail('request-failed', messageOf(error));
94
105
  }
106
+ const failAfterSend = (kind, message) => {
107
+ const error = new DecisionError(kind, model, message);
108
+ error.attempts = sent.attempts;
109
+ return report(error);
110
+ };
95
111
  let json;
96
112
  try {
97
- json = JSON.parse(text);
113
+ json = JSON.parse(sent.text);
98
114
  }
99
115
  catch (error) {
100
- return fail('unreadable-response', messageOf(error));
116
+ return failAfterSend('unreadable-response', messageOf(error));
101
117
  }
102
118
  const parsed = readDecisionResponse(model, json);
103
119
  if (!parsed.success) {
104
- return fail('unreadable-response', parsed.error.issues[0]?.message ?? 'unknown');
120
+ return failAfterSend('unreadable-response', parsed.error.issues[0]?.message ?? 'unknown');
105
121
  }
106
- const { usage, answers } = parsed.data;
107
- reportUsage({
122
+ const { usage: tokens, answers, model: resolvedModel } = parsed.data;
123
+ const usage = {
108
124
  model,
125
+ ...(resolvedModel !== undefined && { resolvedModel }),
109
126
  provider: DECISION_MODELS[model].provider,
110
- inputTokens: usage.input_tokens,
111
- outputTokens: usage.output_tokens,
112
- costUsd: (usage.input_tokens * DECISION_MODELS[model].inputUsdPerMTok) /
127
+ inputTokens: tokens.input_tokens,
128
+ outputTokens: tokens.output_tokens,
129
+ costUsd: (tokens.input_tokens * DECISION_MODELS[model].inputUsdPerMTok) /
113
130
  1_000_000,
114
- latencyMs: Date.now() - startedAt
115
- }, context);
131
+ latencyMs: Date.now() - startedAt,
132
+ attempts: sent.attempts
133
+ };
134
+ reportUsage(usage, context);
116
135
  for (const [key, question] of Object.entries(questions)) {
117
136
  if (!matchesQuestion(question, answers[key])) {
118
- return fail('answer-mismatch', `Answer to "${key}" does not fit it`);
137
+ return failAfterSend('answer-mismatch', `Answer to "${key}" does not fit it`);
119
138
  }
120
139
  }
121
140
  // Every requested key, kind and option set was validated above.
122
- return answers;
141
+ return {
142
+ ok: true,
143
+ answers: answers,
144
+ usage
145
+ };
123
146
  };
147
+ const decide = async (input, model) => {
148
+ const outcome = await result(input, model);
149
+ return outcome.ok ? outcome.answers : null;
150
+ };
151
+ // The call signatures above are checked per call; one implementation serves them all.
152
+ return Object.assign(decide, { result });
124
153
  /**
125
- * Only a call that reached the provider is reported - a state over the
154
+ * Only a call that reached the provider is reported - a request over the
126
155
  * wall, a timeout or an unreadable body reports nothing, so a 0 ms row can
127
- * never flatter a latency percentile. A failing hook never fails the call.
156
+ * never flatter a latency percentile (`onError` carries `latencyMs` for
157
+ * those). A failing hook never fails the call.
128
158
  */
129
159
  function reportUsage(usage, context) {
130
160
  const { registerUsage } = options;
@@ -141,16 +171,34 @@ export function createDecisionApi(options) {
141
171
  });
142
172
  }
143
173
  }
144
- /** The body of the first OK response, read under the same timeout as the request. */
174
+ /**
175
+ * The body of the first OK response and how many requests it took. One
176
+ * deadline (`timeoutMs`) bounds every attempt, the pause between them and the
177
+ * body read. A retryable status, a transport error or an abandoned attempt
178
+ * (`attemptTimeoutMs`) is retried once, when the deadline leaves time for it.
179
+ */
145
180
  async function postWithRetry(input) {
146
181
  const provider = DECISION_MODELS[input.model].provider;
147
- const failure = (kind, message) => new DecisionError(kind, input.model, message);
182
+ const endsAt = Date.now() + input.timeoutMs;
183
+ let attempts = 0;
184
+ const failure = (kind, message, status) => {
185
+ const error = new DecisionError(kind, input.model, message, status);
186
+ error.attempts = attempts;
187
+ return error;
188
+ };
189
+ const timedOut = () => failure('timeout', `No answer within ${input.timeoutMs} ms`);
148
190
  for (let attempt = 0;; attempt++) {
149
191
  if (input.signal?.aborted)
150
192
  throw failure('aborted', 'Aborted by the caller');
151
- // A fresh timeout per attempt, so a retry gets its own budget.
152
- const timeout = AbortSignal.timeout(input.timeoutMs);
193
+ const remainingMs = endsAt - Date.now();
194
+ if (remainingMs <= 0)
195
+ throw timedOut();
196
+ const canRetry = attempt === 0;
197
+ const attemptMs = Math.min(input.attemptTimeoutMs ?? remainingMs, remainingMs);
198
+ const timeout = AbortSignal.timeout(attemptMs);
153
199
  const { signal, release } = linkSignals(timeout, input.signal);
200
+ attempts += 1;
201
+ let delayMs = RETRY_DELAY_MS;
154
202
  try {
155
203
  const response = await input.send({
156
204
  url: input.request.url,
@@ -163,17 +211,17 @@ async function postWithRetry(input) {
163
211
  }
164
212
  }, { model: input.model, provider, attempt, signal });
165
213
  if (response.ok)
166
- return await response.text();
167
- const delayMs = retryDelayMs(response);
168
- if (attempt > 0 ||
214
+ return { text: await response.text(), attempts };
215
+ delayMs = retryDelayMs(response);
216
+ if (!canRetry ||
169
217
  !RETRYABLE_STATUSES.has(response.status) ||
170
- delayMs > MAX_RETRY_DELAY_MS) {
218
+ delayMs > MAX_RETRY_DELAY_MS ||
219
+ delayMs >= endsAt - Date.now()) {
171
220
  const body = (await response.text()).slice(0, 200);
172
- throw new DecisionError(KIND_BY_STATUS[response.status] ?? 'request-failed', input.model, `${input.model} ${response.status}: ${body}`, response.status);
221
+ throw failure(kindOf(response.status, body), `${input.model} ${response.status}: ${body}`, response.status);
173
222
  }
174
223
  // An unread body holds its connection until garbage collection.
175
224
  await response.body?.cancel().catch(() => { });
176
- await new Promise((resolve) => setTimeout(resolve, delayMs));
177
225
  }
178
226
  catch (error) {
179
227
  if (error instanceof DecisionError)
@@ -181,14 +229,46 @@ async function postWithRetry(input) {
181
229
  if (input.signal?.aborted)
182
230
  throw failure('aborted', messageOf(error));
183
231
  if (timeout.aborted) {
184
- throw failure('timeout', `No answer within ${input.timeoutMs} ms`);
232
+ // The deadline itself, or the last attempt: nothing left to try.
233
+ if (attemptMs >= remainingMs || !canRetry)
234
+ throw timedOut();
235
+ continue;
236
+ }
237
+ if (!canRetry || RETRY_DELAY_MS >= endsAt - Date.now()) {
238
+ throw failure('request-failed', messageOf(error));
185
239
  }
186
- throw error;
187
240
  }
188
241
  finally {
189
242
  release();
190
243
  }
244
+ await pause(delayMs, input.signal).catch(() => {
245
+ throw failure('aborted', 'Aborted by the caller');
246
+ });
247
+ }
248
+ }
249
+ /** The kind a refused response maps to; anything unlisted is `request-failed`. */
250
+ function kindOf(status, body) {
251
+ if (status === 413 || (status === 400 && /max_tokens_exceeded/.test(body))) {
252
+ return 'request-too-large';
191
253
  }
254
+ return KIND_BY_STATUS[status] ?? 'request-failed';
255
+ }
256
+ /** Waits `ms`, or rejects as soon as `signal` aborts. */
257
+ function pause(ms, signal) {
258
+ return new Promise((resolve, reject) => {
259
+ if (signal?.aborted)
260
+ return reject(signal.reason);
261
+ const timer = setTimeout(done, ms);
262
+ signal?.addEventListener('abort', done, { once: true });
263
+ function done() {
264
+ clearTimeout(timer);
265
+ signal?.removeEventListener('abort', done);
266
+ if (signal?.aborted)
267
+ reject(signal.reason);
268
+ else
269
+ resolve();
270
+ }
271
+ });
192
272
  }
193
273
  /** The pause a response asks for, in seconds or as a date; our own without one. */
194
274
  function retryDelayMs(response) {
package/dist/eval.d.ts ADDED
@@ -0,0 +1,115 @@
1
+ import type { DecisionResult } from './create-decision-api.ts';
2
+ import type { DecisionModel } from './models.ts';
3
+ import type { DecisionAnswer, DecisionQuestion } from './questions.ts';
4
+ /** One call and the answers a careful human would give to its questions. */
5
+ export type DecisionCase = {
6
+ name: string;
7
+ state: unknown;
8
+ questions: Record<string, DecisionQuestion>;
9
+ /** By question key: a noul's truth, a choice's option, a score's level. */
10
+ expected: Record<string, boolean | string | number>;
11
+ };
12
+ export type GradedAnswer = {
13
+ expected: boolean | string | number;
14
+ value: string;
15
+ /** At 0.5 for a noul; `report` regrades nouls at your thresholds. */
16
+ correct: boolean;
17
+ /** The probability the model put on the expected answer. */
18
+ pCorrect: number;
19
+ /** A noul's probability, kept for the threshold sweep and calibration. */
20
+ noul?: number;
21
+ };
22
+ export type CaseLog = {
23
+ model: DecisionModel;
24
+ caseName: string;
25
+ run: number;
26
+ latencyMs: number;
27
+ inputTokens?: number;
28
+ costUsd?: number;
29
+ /** `kind: message` of a failed call. */
30
+ error?: string;
31
+ answers?: Record<string, GradedAnswer>;
32
+ };
33
+ /** Right or wrong, plus the probability the model put on the right answer. */
34
+ export declare function grade(answer: DecisionAnswer, expected: boolean | string | number): GradedAnswer;
35
+ /** What `runCases` needs of a client: `decide` from `createDecisionApi` fits. */
36
+ type CaseClient = {
37
+ result(input: {
38
+ state: unknown;
39
+ questions: Record<string, DecisionQuestion>;
40
+ }, model?: DecisionModel): Promise<DecisionResult<Record<string, DecisionQuestion>>>;
41
+ };
42
+ /**
43
+ * Asks every case `runs` times on each of `models` (the client's default
44
+ * model when left out). Models run side by side; each model's calls run one
45
+ * at a time, so they never queue behind each other. `context` is the usage
46
+ * context the client requires, if it requires one.
47
+ */
48
+ export declare function runCases(input: {
49
+ decide: CaseClient;
50
+ cases: DecisionCase[];
51
+ runs?: number;
52
+ models?: DecisionModel[];
53
+ context?: object;
54
+ onCall?: (log: CaseLog) => void;
55
+ }): Promise<CaseLog[]>;
56
+ export declare function percentile(values: number[], p: number): number;
57
+ export declare const mean: (values: number[]) => number;
58
+ /**
59
+ * Per model and question: accuracy at its threshold (`thresholds[key]`, 0.5
60
+ * when unset; a noul is positive at or above it), and for nouls the misses
61
+ * and false positives there and at every threshold from 0.05 to 0.95, the
62
+ * margin between the lowest positive and the highest negative (below zero
63
+ * means no threshold separates them), the Brier score and a 10-bin
64
+ * reliability table. `ok` is false when any call failed or any answer was
65
+ * wrong: `if (!summary.ok) process.exitCode = 1`.
66
+ */
67
+ export declare function report(logs: CaseLog[], options?: {
68
+ thresholds?: Record<string, number>;
69
+ }): {
70
+ ok: boolean;
71
+ models: {
72
+ model: "jev" | "clef" | "clef-flash" | "gpt-6-luna" | "microsoft/microsoft-decision-1" | "typesafe/jev-1.13";
73
+ calls: number;
74
+ failed: number;
75
+ wrong: number;
76
+ brier: number;
77
+ latencyP50: number;
78
+ latencyP90: number;
79
+ inputTokens: number;
80
+ costUsd: number;
81
+ questions: ({
82
+ key: string;
83
+ answers: number;
84
+ threshold: undefined;
85
+ wrong: number;
86
+ accuracy: number;
87
+ noul: undefined;
88
+ } | {
89
+ key: string;
90
+ answers: number;
91
+ threshold: number;
92
+ wrong: number;
93
+ accuracy: number;
94
+ noul: {
95
+ misses: number;
96
+ falsePositives: number;
97
+ margin: number;
98
+ brier: number;
99
+ sweep: {
100
+ threshold: number;
101
+ misses: number;
102
+ falsePositives: number;
103
+ }[];
104
+ reliability: {
105
+ from: number;
106
+ to: number;
107
+ answers: number;
108
+ meanP: number;
109
+ observed: number;
110
+ }[];
111
+ };
112
+ })[];
113
+ }[];
114
+ };
115
+ export {};
package/dist/eval.js ADDED
@@ -0,0 +1,182 @@
1
+ // Labelled cases against a live client: run them, then read accuracy at the
2
+ // thresholds the app ships, a threshold sweep and calibration per question.
3
+ // The app brings the cases; nothing here knows its domain.
4
+ /** Right or wrong, plus the probability the model put on the right answer. */
5
+ export function grade(answer, expected) {
6
+ if (answer.type === 'noul') {
7
+ const pCorrect = expected ? answer.noul : 1 - answer.noul;
8
+ return {
9
+ expected,
10
+ value: answer.noul.toFixed(2),
11
+ correct: pCorrect >= 0.5,
12
+ pCorrect,
13
+ noul: answer.noul
14
+ };
15
+ }
16
+ const pCorrect = answer.probabilities[String(expected)] ?? 0;
17
+ if (answer.type === 'choice') {
18
+ return {
19
+ expected,
20
+ value: answer.choice,
21
+ correct: answer.choice === expected,
22
+ pCorrect
23
+ };
24
+ }
25
+ return {
26
+ expected,
27
+ value: answer.score.toFixed(2),
28
+ correct: Math.round(answer.score) === expected,
29
+ pCorrect
30
+ };
31
+ }
32
+ /**
33
+ * Asks every case `runs` times on each of `models` (the client's default
34
+ * model when left out). Models run side by side; each model's calls run one
35
+ * at a time, so they never queue behind each other. `context` is the usage
36
+ * context the client requires, if it requires one.
37
+ */
38
+ export async function runCases(input) {
39
+ const runs = input.runs ?? 1;
40
+ const perModel = await Promise.all((input.models ?? [undefined]).map(async (model) => {
41
+ const logs = [];
42
+ for (let run = 1; run <= runs; run++) {
43
+ for (const decisionCase of input.cases) {
44
+ const outcome = await input.decide.result({
45
+ ...input.context,
46
+ state: decisionCase.state,
47
+ questions: decisionCase.questions
48
+ }, model);
49
+ const log = outcome.ok
50
+ ? {
51
+ model: outcome.usage?.model ?? model ?? 'jev',
52
+ caseName: decisionCase.name,
53
+ run,
54
+ latencyMs: outcome.usage?.latencyMs ?? 0,
55
+ inputTokens: outcome.usage?.inputTokens,
56
+ costUsd: outcome.usage?.costUsd,
57
+ answers: Object.fromEntries(Object.entries(decisionCase.expected).map(([key, expected]) => [
58
+ key,
59
+ grade(outcome.answers[key], expected)
60
+ ]))
61
+ }
62
+ : {
63
+ model: outcome.error.model,
64
+ caseName: decisionCase.name,
65
+ run,
66
+ latencyMs: outcome.error.latencyMs,
67
+ error: `${outcome.error.kind}: ${outcome.error.message}`
68
+ };
69
+ input.onCall?.(log);
70
+ logs.push(log);
71
+ }
72
+ }
73
+ return logs;
74
+ }));
75
+ return perModel.flat();
76
+ }
77
+ export function percentile(values, p) {
78
+ if (values.length === 0)
79
+ return Number.NaN;
80
+ const sorted = [...values].sort((a, b) => a - b);
81
+ return sorted[Math.max(0, Math.ceil(p * sorted.length) - 1)];
82
+ }
83
+ export const mean = (values) => values.length === 0
84
+ ? Number.NaN
85
+ : values.reduce((sum, value) => sum + value, 0) / values.length;
86
+ const SWEEP = Array.from({ length: 19 }, (_, index) => (index + 1) / 20);
87
+ /**
88
+ * Per model and question: accuracy at its threshold (`thresholds[key]`, 0.5
89
+ * when unset; a noul is positive at or above it), and for nouls the misses
90
+ * and false positives there and at every threshold from 0.05 to 0.95, the
91
+ * margin between the lowest positive and the highest negative (below zero
92
+ * means no threshold separates them), the Brier score and a 10-bin
93
+ * reliability table. `ok` is false when any call failed or any answer was
94
+ * wrong: `if (!summary.ok) process.exitCode = 1`.
95
+ */
96
+ export function report(logs, options = {}) {
97
+ const models = [...new Set(logs.map((log) => log.model))];
98
+ const perModel = models.map((model) => {
99
+ const own = logs.filter((log) => log.model === model);
100
+ const answered = own.filter((log) => log.answers);
101
+ const keys = [
102
+ ...new Set(answered.flatMap((log) => Object.keys(log.answers ?? {})))
103
+ ];
104
+ const questions = keys.map((key) => summarizeQuestion(key, answered.flatMap((log) => log.answers?.[key] ?? []), options.thresholds?.[key] ?? 0.5));
105
+ const nouls = answered.flatMap((log) => Object.values(log.answers ?? {}).filter((a) => a.noul !== undefined));
106
+ const latencies = answered.map((log) => log.latencyMs);
107
+ return {
108
+ model,
109
+ calls: own.length,
110
+ failed: own.length - answered.length,
111
+ wrong: questions.reduce((sum, question) => sum + question.wrong, 0),
112
+ brier: brier(nouls),
113
+ latencyP50: percentile(latencies, 0.5),
114
+ latencyP90: percentile(latencies, 0.9),
115
+ inputTokens: sum(answered.map((log) => log.inputTokens ?? 0)),
116
+ costUsd: sum(answered.map((log) => log.costUsd ?? 0)),
117
+ questions
118
+ };
119
+ });
120
+ return {
121
+ ok: perModel.every((row) => row.failed === 0 && row.wrong === 0),
122
+ models: perModel
123
+ };
124
+ }
125
+ function summarizeQuestion(key, graded, threshold) {
126
+ const nouls = graded.filter((answer) => answer.noul !== undefined);
127
+ if (nouls.length === 0 || nouls.length !== graded.length) {
128
+ const wrong = graded.filter((answer) => !answer.correct).length;
129
+ return {
130
+ key,
131
+ answers: graded.length,
132
+ threshold: undefined,
133
+ wrong,
134
+ accuracy: 1 - wrong / graded.length,
135
+ noul: undefined
136
+ };
137
+ }
138
+ const errorsAt = (at) => ({
139
+ threshold: at,
140
+ misses: nouls.filter((a) => a.expected && a.noul < at).length,
141
+ falsePositives: nouls.filter((a) => !a.expected && a.noul >= at).length
142
+ });
143
+ const at = errorsAt(threshold);
144
+ const positives = nouls.filter((a) => a.expected).map((a) => a.noul);
145
+ const negatives = nouls.filter((a) => !a.expected).map((a) => a.noul);
146
+ const wrong = at.misses + at.falsePositives;
147
+ return {
148
+ key,
149
+ answers: graded.length,
150
+ threshold,
151
+ wrong,
152
+ accuracy: 1 - wrong / graded.length,
153
+ noul: {
154
+ misses: at.misses,
155
+ falsePositives: at.falsePositives,
156
+ margin: positives.length && negatives.length
157
+ ? Math.min(...positives) - Math.max(...negatives)
158
+ : Number.NaN,
159
+ brier: brier(nouls),
160
+ sweep: SWEEP.map(errorsAt),
161
+ reliability: reliability(nouls)
162
+ }
163
+ };
164
+ }
165
+ const sum = (values) => values.reduce((total, v) => total + v, 0);
166
+ /** Mean squared distance from the truth: 0 is perfect, 0.25 is a coin. */
167
+ function brier(nouls) {
168
+ return mean(nouls.map((a) => ((a.noul ?? 0) - (a.expected ? 1 : 0)) ** 2));
169
+ }
170
+ /** Answers binned by probability: a calibrated model's `observed` tracks `meanP`. */
171
+ function reliability(nouls) {
172
+ return Array.from({ length: 10 }, (_, bin) => {
173
+ const inBin = nouls.filter((a) => Math.min(9, Math.floor(a.noul * 10)) === bin);
174
+ return {
175
+ from: bin / 10,
176
+ to: (bin + 1) / 10,
177
+ answers: inBin.length,
178
+ meanP: mean(inBin.map((a) => a.noul)),
179
+ observed: mean(inBin.map((a) => (a.expected ? 1 : 0)))
180
+ };
181
+ }).filter((row) => row.answers > 0);
182
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { createDecisionApi, DECISION_MAX_STATE_CHARS, type DecisionApi, DecisionError, type DecisionErrorKind, type DecisionFetch, type DecisionUsage } from './create-decision-api.ts';
1
+ export { createDecisionApi, DECISION_MAX_REQUEST_CHARS, type DecisionApi, DecisionError, type DecisionErrorKind, type DecisionFetch, type DecisionResult, type DecisionUsage } from './create-decision-api.ts';
2
2
  export { type DecisionImage, type DecisionImageFormat, type DecisionImageText, type DecisionText, image, withImages } from './images.ts';
3
3
  export { DECISION_MODELS, type DecisionImageModel, type DecisionModel, type DecisionProvider, type DecisionProviderKeys, type ModelsFor } from './models.ts';
4
- export type { DecisionAnswers, DecisionChoice, DecisionNoul, DecisionQuestion, DecisionScore } from './questions.ts';
4
+ export { type DecisionAnswer, type DecisionAnswers, type DecisionChoice, type DecisionChoiceAnswer, type DecisionNoul, type DecisionNoulAnswer, type DecisionQuestion, type DecisionScore, type DecisionScoreAnswer, perItem } from './questions.ts';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
- export { createDecisionApi, DECISION_MAX_STATE_CHARS, DecisionError } from "./create-decision-api.js";
1
+ export { createDecisionApi, DECISION_MAX_REQUEST_CHARS, DecisionError } from "./create-decision-api.js";
2
2
  export { image, withImages } from "./images.js";
3
3
  export { DECISION_MODELS } from "./models.js";
4
+ export { perItem } from "./questions.js";
package/dist/models.d.ts CHANGED
@@ -24,17 +24,26 @@ export type DecisionProvider = keyof DecisionProviderKeys;
24
24
  * anywhere in the state, instructions and criteria (1.13 and later:
25
25
  * https://docs.typesafe.ai/images). Clef and gpt-6-luna read images too, but
26
26
  * in wire formats this client does not speak yet.
27
+ *
28
+ * `maxQuestions`, `maxChoices` (options in one choice) and `maxLevels` (levels
29
+ * in one score) are the limits a call is checked against before it is sent,
30
+ * where a provider documents or was measured to enforce one. Jev's come from
31
+ * its API schema (https://docs.typesafe.ai/api); Clef's question limit was
32
+ * measured (it refuses more than 64).
27
33
  */
28
34
  export declare const DECISION_MODELS: {
29
35
  readonly jev: {
30
36
  readonly provider: "typesafe";
31
37
  readonly inputUsdPerMTok: 0.042;
32
38
  readonly images: true;
39
+ readonly maxChoices: 255;
40
+ readonly maxLevels: 10;
33
41
  };
34
42
  readonly clef: {
35
43
  readonly provider: "cloudflare";
36
44
  readonly inputUsdPerMTok: 0.24;
37
45
  readonly images: false;
46
+ readonly maxQuestions: 64;
38
47
  };
39
48
  readonly 'clef-flash': {
40
49
  readonly provider: "cloudflare";
@@ -55,6 +64,8 @@ export declare const DECISION_MODELS: {
55
64
  readonly provider: "openrouter";
56
65
  readonly inputUsdPerMTok: 0.042;
57
66
  readonly images: true;
67
+ readonly maxChoices: 255;
68
+ readonly maxLevels: 10;
58
69
  };
59
70
  };
60
71
  export type DecisionModel = keyof typeof DECISION_MODELS;
@@ -69,6 +80,8 @@ type ModelsOf<Provider> = {
69
80
  export type ModelsFor<Keys extends DecisionProviderKeys> = ModelsOf<{
70
81
  [Provider in keyof Keys]-?: undefined extends Keys[Provider] ? never : Provider;
71
82
  }[keyof Keys]>;
83
+ /** Why a model would refuse these questions, or undefined when it takes them. */
84
+ export declare function overModelLimit(model: DecisionModel, questions: Record<string, DecisionQuestion>): string | undefined;
72
85
  type RequestInput = {
73
86
  state: unknown;
74
87
  questions: Record<string, DecisionQuestion>;
@@ -104,5 +117,6 @@ export declare function readDecisionResponse(model: DecisionModel, json: unknown
104
117
  input_tokens: number;
105
118
  output_tokens: number;
106
119
  };
120
+ model?: string | undefined;
107
121
  }>;
108
122
  export {};
package/dist/models.js CHANGED
@@ -15,10 +15,27 @@ import { DecisionResponseSchema } from "./questions.js";
15
15
  * anywhere in the state, instructions and criteria (1.13 and later:
16
16
  * https://docs.typesafe.ai/images). Clef and gpt-6-luna read images too, but
17
17
  * in wire formats this client does not speak yet.
18
+ *
19
+ * `maxQuestions`, `maxChoices` (options in one choice) and `maxLevels` (levels
20
+ * in one score) are the limits a call is checked against before it is sent,
21
+ * where a provider documents or was measured to enforce one. Jev's come from
22
+ * its API schema (https://docs.typesafe.ai/api); Clef's question limit was
23
+ * measured (it refuses more than 64).
18
24
  */
19
25
  export const DECISION_MODELS = {
20
- jev: { provider: 'typesafe', inputUsdPerMTok: 0.042, images: true },
21
- clef: { provider: 'cloudflare', inputUsdPerMTok: 0.24, images: false },
26
+ jev: {
27
+ provider: 'typesafe',
28
+ inputUsdPerMTok: 0.042,
29
+ images: true,
30
+ maxChoices: 255,
31
+ maxLevels: 10
32
+ },
33
+ clef: {
34
+ provider: 'cloudflare',
35
+ inputUsdPerMTok: 0.24,
36
+ images: false,
37
+ maxQuestions: 64
38
+ },
22
39
  'clef-flash': {
23
40
  provider: 'cloudflare',
24
41
  inputUsdPerMTok: 0.09,
@@ -38,9 +55,30 @@ export const DECISION_MODELS = {
38
55
  'typesafe/jev-1.13': {
39
56
  provider: 'openrouter',
40
57
  inputUsdPerMTok: 0.042,
41
- images: true
58
+ images: true,
59
+ maxChoices: 255,
60
+ maxLevels: 10
42
61
  }
43
62
  };
63
+ /** Why a model would refuse these questions, or undefined when it takes them. */
64
+ export function overModelLimit(model, questions) {
65
+ const limits = DECISION_MODELS[model];
66
+ const count = Object.keys(questions).length;
67
+ if (limits.maxQuestions !== undefined && count > limits.maxQuestions) {
68
+ return `${count} questions, over ${model}'s ${limits.maxQuestions}`;
69
+ }
70
+ for (const [key, question] of Object.entries(questions)) {
71
+ if (question.type === 'noul')
72
+ continue;
73
+ const [size, limit, what] = question.type === 'choice'
74
+ ? [Object.keys(question.criteria).length, limits.maxChoices, 'options']
75
+ : [question.criteria.length, limits.maxLevels, 'levels'];
76
+ if (limit !== undefined && size > limit) {
77
+ return `"${key}" has ${size} ${what}, over ${model}'s ${limit}`;
78
+ }
79
+ }
80
+ return undefined;
81
+ }
44
82
  /** One POST: where it goes, and the headers and body it carries. */
45
83
  export function buildDecisionRequest(model, input, keys) {
46
84
  const provider = DECISION_MODELS[model].provider;
@@ -144,6 +182,7 @@ const OpenAiProbabilitiesSchema = z.array(z.object({
144
182
  probability: z.number()
145
183
  }));
146
184
  const OpenAiResponseSchema = z.object({
185
+ model: z.string().optional(),
147
186
  answers: z.array(z.discriminatedUnion('type', [
148
187
  z.object({
149
188
  type: z.literal('predicate'),
@@ -189,6 +228,7 @@ function fromOpenAiResponse(response) {
189
228
  ];
190
229
  });
191
230
  return {
231
+ model: response.model,
192
232
  answers: Object.fromEntries(answers),
193
233
  usage: {
194
234
  input_tokens: response.usage.input_tokens,
@@ -25,24 +25,25 @@ export type DecisionScore = {
25
25
  criteria: DecisionText[];
26
26
  };
27
27
  export type DecisionQuestion = DecisionNoul | DecisionChoice | DecisionScore;
28
- type DecisionNoulAnswer = {
28
+ export type DecisionNoulAnswer = {
29
29
  type: 'noul';
30
30
  noul: number;
31
31
  };
32
- type DecisionChoiceAnswer<Option extends string = string> = {
32
+ export type DecisionChoiceAnswer<Option extends string = string> = {
33
33
  type: 'choice';
34
34
  choice: Option;
35
35
  confidence: number;
36
36
  probabilities: Record<Option, number>;
37
37
  };
38
- type DecisionScoreAnswer = {
38
+ export type DecisionScoreAnswer = {
39
39
  type: 'score';
40
40
  score: number;
41
41
  confidence: number;
42
42
  probabilities: Record<string, number>;
43
43
  };
44
+ export type DecisionAnswer = DecisionNoulAnswer | DecisionChoiceAnswer | DecisionScoreAnswer;
44
45
  /** The answer a given question shape produces, so call sites need no casts. */
45
- type AnswerFor<Q> = Q extends {
46
+ export type AnswerFor<Q> = Q extends {
46
47
  type: 'noul';
47
48
  } ? DecisionNoulAnswer : Q extends {
48
49
  type: 'choice';
@@ -53,22 +54,9 @@ type AnswerFor<Q> = Q extends {
53
54
  export type DecisionAnswers<Q extends Record<string, DecisionQuestion>> = {
54
55
  [K in keyof Q]: AnswerFor<Q[K]>;
55
56
  };
56
- declare const AnswerSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
57
- type: z.ZodLiteral<"noul">;
58
- noul: z.ZodNumber;
59
- }, z.core.$strip>, z.ZodObject<{
60
- type: z.ZodLiteral<"choice">;
61
- choice: z.ZodString;
62
- confidence: z.ZodNumber;
63
- probabilities: z.ZodRecord<z.ZodString, z.ZodNumber>;
64
- }, z.core.$strip>, z.ZodObject<{
65
- type: z.ZodLiteral<"score">;
66
- score: z.ZodNumber;
67
- confidence: z.ZodNumber;
68
- probabilities: z.ZodRecord<z.ZodString, z.ZodNumber>;
69
- }, z.core.$strip>], "type">;
70
57
  /** What every vendor's response is read into. */
71
58
  export declare const DecisionResponseSchema: z.ZodObject<{
59
+ model: z.ZodOptional<z.ZodString>;
72
60
  answers: z.ZodRecord<z.ZodString, z.ZodDiscriminatedUnion<[z.ZodObject<{
73
61
  type: z.ZodLiteral<"noul">;
74
62
  noul: z.ZodNumber;
@@ -90,5 +78,18 @@ export declare const DecisionResponseSchema: z.ZodObject<{
90
78
  }, z.core.$strip>;
91
79
  export type DecisionResponse = z.infer<typeof DecisionResponseSchema>;
92
80
  /** The contract depends on the question, not just the answer's own tag. */
93
- export declare function matchesQuestion(question: DecisionQuestion, answer: z.infer<typeof AnswerSchema> | undefined): boolean;
94
- export {};
81
+ export declare function matchesQuestion(question: DecisionQuestion, answer: DecisionAnswer | undefined): boolean;
82
+ /**
83
+ * The same question about each item of a list, under keys `${prefix}_${index}`.
84
+ * Spread `questions` into a call (next to other questions if you like) and
85
+ * pass its answers to `read`, which pairs each item with its typed answer.
86
+ */
87
+ export declare function perItem<Item, const Q extends DecisionQuestion, const Prefix extends string>(items: readonly Item[], prefix: Prefix, ask: (item: Item, index: number) => Q): {
88
+ questions: Record<`${Prefix}_${number}`, Q>;
89
+ /** Every key is there: a call answers all its questions or `null`. */
90
+ read: (answers: { readonly [K in `${Prefix}_${number}`]: AnswerFor<Q>; }) => {
91
+ item: Item;
92
+ index: number;
93
+ answer: AnswerFor<Q>;
94
+ }[];
95
+ };
package/dist/questions.js CHANGED
@@ -21,6 +21,8 @@ const AnswerSchema = z.discriminatedUnion('type', [
21
21
  ]);
22
22
  /** What every vendor's response is read into. */
23
23
  export const DecisionResponseSchema = z.object({
24
+ /** The model that answered, when the provider says (it may resolve an alias). */
25
+ model: z.string().optional(),
24
26
  answers: z.record(z.string(), AnswerSchema),
25
27
  usage: z.object({
26
28
  input_tokens: z.number().int().nonnegative(),
@@ -50,3 +52,20 @@ function matchesOptions(options, probabilities) {
50
52
  return (Object.keys(probabilities).length === options.length &&
51
53
  options.every((option) => Object.hasOwn(probabilities, option)));
52
54
  }
55
+ /**
56
+ * The same question about each item of a list, under keys `${prefix}_${index}`.
57
+ * Spread `questions` into a call (next to other questions if you like) and
58
+ * pass its answers to `read`, which pairs each item with its typed answer.
59
+ */
60
+ export function perItem(items, prefix, ask) {
61
+ const questions = Object.fromEntries(items.map((item, index) => [`${prefix}_${index}`, ask(item, index)]));
62
+ return {
63
+ questions,
64
+ /** Every key is there: a call answers all its questions or `null`. */
65
+ read: (answers) => items.map((item, index) => ({
66
+ item,
67
+ index,
68
+ answer: answers[`${prefix}_${index}`]
69
+ }))
70
+ };
71
+ }
@@ -0,0 +1,27 @@
1
+ import { DecisionError } from './create-decision-api.ts';
2
+ import { type DecisionModel } from './models.ts';
3
+ import { type DecisionAnswer, type DecisionQuestion } from './questions.ts';
4
+ /** One call the code under test made: its input (context included) and model. */
5
+ export type FakeDecisionCall = {
6
+ state: unknown;
7
+ questions: Record<string, DecisionQuestion>;
8
+ model: DecisionModel | undefined;
9
+ [field: string]: unknown;
10
+ };
11
+ /**
12
+ * Answers by question key: a number is a noul's probability or a score, a
13
+ * string is the choice, or give the full answer.
14
+ */
15
+ export type FakeAnswers = Record<string, number | string | DecisionAnswer>;
16
+ type Respond = (call: FakeDecisionCall) => FakeAnswers | DecisionError | null | Promise<FakeAnswers | DecisionError | null>;
17
+ /**
18
+ * A fake `decide`, typed as the real one: `fakeDecide<typeof decide>(...)`.
19
+ * `respond` answers each call; `null` fails it as `request-failed`, and a
20
+ * `DecisionError` fails it with that error. An answer that does not fit its
21
+ * question, or a missing one, throws in the test, since the real client would
22
+ * never return it. `calls` records every call.
23
+ */
24
+ export declare function fakeDecide<Api>(respond: Respond): Api & {
25
+ calls: FakeDecisionCall[];
26
+ };
27
+ export {};
@@ -0,0 +1,74 @@
1
+ // A stand-in for `createDecisionApi`'s client in tests: no network, typed as
2
+ // the real one, and as strict about answers as the real one.
3
+ import { DecisionError } from "./create-decision-api.js";
4
+ import { DECISION_MODELS } from "./models.js";
5
+ import { matchesQuestion } from "./questions.js";
6
+ /**
7
+ * A fake `decide`, typed as the real one: `fakeDecide<typeof decide>(...)`.
8
+ * `respond` answers each call; `null` fails it as `request-failed`, and a
9
+ * `DecisionError` fails it with that error. An answer that does not fit its
10
+ * question, or a missing one, throws in the test, since the real client would
11
+ * never return it. `calls` records every call.
12
+ */
13
+ export function fakeDecide(respond) {
14
+ const calls = [];
15
+ const result = async (input, model) => {
16
+ const call = { ...input, model };
17
+ calls.push(call);
18
+ const used = model ?? 'jev';
19
+ const response = await respond(call);
20
+ if (response === null || response instanceof DecisionError) {
21
+ const error = response ?? new DecisionError('request-failed', used, 'fakeDecide');
22
+ return { ok: false, error };
23
+ }
24
+ const usage = {
25
+ model: used,
26
+ provider: DECISION_MODELS[used].provider,
27
+ inputTokens: 0,
28
+ outputTokens: 0,
29
+ costUsd: 0,
30
+ latencyMs: 0,
31
+ attempts: 1
32
+ };
33
+ return {
34
+ ok: true,
35
+ answers: expand(call.questions, response),
36
+ usage
37
+ };
38
+ };
39
+ const decide = async (input, model) => {
40
+ const outcome = await result(input, model);
41
+ return outcome.ok ? outcome.answers : null;
42
+ };
43
+ return Object.assign(decide, { result, calls });
44
+ }
45
+ function expand(questions, given) {
46
+ return Object.fromEntries(Object.entries(questions).map(([key, question]) => {
47
+ const answer = toAnswer(question, given[key]);
48
+ if (!matchesQuestion(question, answer)) {
49
+ throw new Error(`fakeDecide: the answer to "${key}" (${JSON.stringify(given[key])}) does not fit its ${question.type} question`);
50
+ }
51
+ return [key, answer];
52
+ }));
53
+ }
54
+ /** Shorthand to a full answer: all the probability on the given value. */
55
+ function toAnswer(question, value) {
56
+ if (value === undefined || typeof value === 'object')
57
+ return value;
58
+ if (question.type === 'noul') {
59
+ return typeof value === 'number' ? { type: 'noul', noul: value } : undefined;
60
+ }
61
+ const options = question.type === 'choice'
62
+ ? Object.keys(question.criteria)
63
+ : question.criteria.map((_, index) => String(index));
64
+ const picked = String(question.type === 'score' ? Math.round(Number(value)) : value);
65
+ const probabilities = Object.fromEntries(options.map((option) => [option, option === picked ? 1 : 0]));
66
+ if (question.type === 'choice') {
67
+ return typeof value === 'string'
68
+ ? { type: 'choice', choice: value, confidence: 1, probabilities }
69
+ : undefined;
70
+ }
71
+ return typeof value === 'number'
72
+ ? { type: 'score', score: value, confidence: 1, probabilities }
73
+ : undefined;
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "decision-api",
3
- "version": "1.3.0",
3
+ "version": "2.0.0",
4
4
  "description": "One typed client for decision models (TypeSafe Jev, Cloudflare Clef, OpenAI gpt-6-luna, OpenRouter models such as Microsoft-Decision-1) that answer questions with calibrated probabilities",
5
5
  "license": "MIT",
6
6
  "author": "Kristoffer Hansen",
@@ -22,6 +22,14 @@
22
22
  ".": {
23
23
  "types": "./dist/index.d.ts",
24
24
  "default": "./dist/index.js"
25
+ },
26
+ "./eval": {
27
+ "types": "./dist/eval.d.ts",
28
+ "default": "./dist/eval.js"
29
+ },
30
+ "./testing": {
31
+ "types": "./dist/testing.d.ts",
32
+ "default": "./dist/testing.js"
25
33
  }
26
34
  },
27
35
  "files": [