decision-api 1.2.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
@@ -1,6 +1,6 @@
1
1
  # decision-api
2
2
 
3
- One client for decision models: models that answer typed questions with calibrated probabilities instead of text. It speaks TypeSafe's Jev, Cloudflare's Clef and Clef-flash (Workers AI), OpenAI's gpt-6-luna and the decision models on OpenRouter (such as `microsoft/microsoft-decision-1` and `typesafe/jev-1.13`), and returns the same answer shape from all of them. It depends only on `zod`.
3
+ One client for decision models: models that answer typed questions with calibrated probabilities instead of text. It speaks TypeSafe's Jev, Cloudflare's Clef and Clef-flash (Workers AI), OpenAI's gpt-6-luna and the decision models on OpenRouter (such as `microsoft/microsoft-decision-1` and `typesafe/jev-1.13`), and returns the same answer shape from all of them. Jev also reads images. It depends only on `zod`.
4
4
 
5
5
  ```sh
6
6
  npm install decision-api
@@ -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,13 +50,123 @@ 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`, `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`.
97
+
98
+ ## Images
99
+
100
+ `jev` and `typesafe/jev-1.13` read images (`images: true` in `DECISION_MODELS`). Make one with `image()` from PNG or JPEG bytes or base64, and put it anywhere text can go: in the state, as a question's instructions or as a criterion. Inside a sentence, write it into a `withImages` template:
101
+
102
+ ```ts
103
+ import { image, withImages } from 'decision-api'
104
+ import { readFile } from 'node:fs/promises'
105
+
106
+ const door = image(await readFile('door-camera.jpg'))
107
+ const badge = image(await readFile('badge.jpg'))
108
+ const thumbsUp = image(await readFile('thumbs-up.png'))
109
+
110
+ const answers = await decide({
111
+ state: { door },
112
+ questions: {
113
+ same: {
114
+ type: 'noul',
115
+ instructions: withImages`Is the person in \`door\` the one on this badge: ${badge}?`
116
+ },
117
+ gesture: {
118
+ type: 'choice',
119
+ instructions: 'Which gesture does the hand in `door` make?',
120
+ criteria: { thumbs_up: thumbsUp, none: 'No hand, or another gesture' }
121
+ }
122
+ },
123
+ customerId: 'c_1'
124
+ })
125
+ ```
126
+
127
+ Each image is sent once, base64-encoded under a unique reference, however often it is used; every use still counts as input tokens. TypeSafe's image preview header is added for you. Put an image several questions use in the state, not in each question's instructions.
128
+
129
+ - **TypeScript rejects an image on a model that reads none**, when the image's type is visible in the call. Questions typed as `Record<string, DecisionQuestion>` don't show it, so the call checks as well and answers `null` with `images-unsupported`.
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.
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.
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.
53
164
 
54
165
  ## Benchmark
55
166
 
56
- `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.
57
168
 
58
- 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):
59
170
 
60
171
  | # | Model | Score | Points | p(correct) | Latency p50 / p90 | Cost / 1k calls |
61
172
  | --- | --- | --- | --- | --- | --- | --- |
@@ -1,26 +1,49 @@
1
- import { type DecisionModel, type DecisionProvider, type DecisionProviderKeys, type ModelsFor } from './models.ts';
1
+ import { type DecisionImage, type DecisionImageText, type DecisionText } from './images.ts';
2
+ import { type DecisionImageModel, type DecisionModel, type DecisionProvider, type DecisionProviderKeys, type ModelsFor } from './models.ts';
2
3
  import { type DecisionAnswers, type DecisionQuestion } from './questions.ts';
3
4
  /**
4
5
  * Jev answers `400 max_tokens_exceeded` past roughly 32.5k input tokens (96k
5
- * chars passed, 128k did not). A state over this is refused before it leaves
6
- * the process; keep well under the wall, since tokenization varies. Clef takes
7
- * 64k tokens but silently truncates long state, so the same guard keeps it
8
- * 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.
9
11
  */
10
- export declare const DECISION_MAX_STATE_CHARS = 90000;
12
+ export declare const DECISION_MAX_REQUEST_CHARS = 90000;
11
13
  /** What one answered call cost; `costUsd` is priced from `DECISION_MODELS`. */
12
14
  export type DecisionUsage<Model extends DecisionModel = DecisionModel> = {
13
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;
14
21
  provider: DecisionProvider;
15
22
  inputTokens: number;
16
23
  outputTokens: number;
17
24
  costUsd: number;
25
+ /** From the first request to the answer, retries included. */
18
26
  latencyMs: number;
27
+ /** Requests sent: 2 when the first was retried. */
28
+ attempts: number;
19
29
  };
20
- export type DecisionErrorKind = 'state-too-large'
21
- /** No answer within `timeoutMs`. */
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'
37
+ /** The call holds an image and the model reads none (`images: false`). */
38
+ | 'images-unsupported'
39
+ /** An image's reference is in the text without the image: build that text with `withImages`. */
40
+ | 'image-detached'
41
+ /** No answer within `timeoutMs`, retries included. */
22
42
  | 'timeout'
23
- /** 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
+ */
24
47
  | 'aborted'
25
48
  /** 401 or 403: the provider rejected the key. Retrying will not help. */
26
49
  | 'unauthorized'
@@ -36,6 +59,10 @@ export declare class DecisionError extends Error {
36
59
  /** The HTTP status, when the provider answered. */
37
60
  readonly status?: number | undefined;
38
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;
39
66
  constructor(kind: DecisionErrorKind, model: DecisionModel, message: string,
40
67
  /** The HTTP status, when the provider answered. */
41
68
  status?: number | undefined);
@@ -51,12 +78,18 @@ export type DecisionFetch = (request: {
51
78
  /** The same signal `init` carries: the call's own, plus its timeout. */
52
79
  signal: AbortSignal;
53
80
  }) => Promise<Response>;
54
- type DecisionCall<Q extends Record<string, DecisionQuestion>> = {
55
- /** A string, or named fields - name them when the state has several parts. */
56
- state: unknown;
81
+ type DecisionCall<Q extends Record<string, DecisionQuestion>, State> = {
82
+ /**
83
+ * A string, or named fields - name them when the state has several parts.
84
+ * Images go in as values, on models that read them.
85
+ */
86
+ state: State;
57
87
  questions: Q;
58
88
  signal?: AbortSignal;
89
+ /** The whole call, retries and pauses included. */
59
90
  timeoutMs?: number;
91
+ /** One request; see `attemptTimeoutMs` on `createDecisionApi`. */
92
+ attemptTimeoutMs?: number;
60
93
  };
61
94
  type DecisionApiOptions<Keys extends DecisionProviderKeys, Default, Context> = {
62
95
  keys: Keys;
@@ -72,15 +105,34 @@ type DecisionApiOptions<Keys extends DecisionProviderKeys, Default, Context> = {
72
105
  /** The signal of a call that passes none. */
73
106
  defaultSignal?: () => AbortSignal | undefined;
74
107
  fetch?: DecisionFetch;
108
+ /** The whole call, retries and pauses included. Defaults to 4 s. */
75
109
  timeoutMs?: number;
76
- 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;
77
128
  };
78
129
  /**
79
130
  * Ask one or more independent judgments about the same state.
80
131
  *
81
132
  * **Send every independent question in ONE call**: questions are evaluated
82
- * together, so extra ones are close to free, and latency tracks the size of
83
- * `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.
84
136
  *
85
137
  * **Answers `null` rather than throwing** - on a timeout, an abort, a
86
138
  * transport error, a rejected request or an answer that does not match its
@@ -88,7 +140,25 @@ type DecisionApiOptions<Keys extends DecisionProviderKeys, Default, Context> = {
88
140
  * tagging job treats `null` as "no tags this time", a policy guard as "refuse".
89
141
  * It throws only when there is no model at all - a setup bug TypeScript
90
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`.
146
+ */
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>;
152
+ type ModelArgument<Model, Default> = [Default] extends [Model] ? [model?: Model] : [model: Model];
153
+ /**
154
+ * Whether a call's state or questions hold an image, as far as their types
155
+ * tell: a value typed as any text (`DecisionText`, `unknown`, `any`) does not
156
+ * say, and the call checks it instead. Stops at a few levels deep, so a
157
+ * recursive JSON type cannot recurse forever.
91
158
  */
92
- export type DecisionApi<Model extends DecisionModel, Default, Context> = <Q extends Record<string, DecisionQuestion>>(input: DecisionCall<Q> & Context, ...model: [Default] extends [Model] ? [model?: Model] : [model: Model]) => Promise<DecisionAnswers<Q> | null>;
159
+ type HasImage<T, Depth extends unknown[] = []> = 0 extends 1 & T ? false : [DecisionText] extends [T] ? false : Depth['length'] extends 6 ? false : true extends ImageIn<T, [...Depth, unknown]> ? true : false;
160
+ type ImageIn<T, Depth extends unknown[]> = T extends DecisionImage | DecisionImageText ? true : T extends readonly (infer Item)[] ? HasImage<Item, Depth> : T extends object ? {
161
+ [K in keyof T]-?: HasImage<T[K], Depth>;
162
+ }[keyof T] : false;
93
163
  export declare function createDecisionApi<const Keys extends DecisionProviderKeys, Default extends ModelsFor<Keys> | undefined = undefined, Context extends object = Record<never, never>>(options: DecisionApiOptions<Keys, Default, Context>): DecisionApi<ModelsFor<Keys>, Default, Context>;
94
164
  export {};
@@ -1,20 +1,22 @@
1
- import { buildDecisionRequest, DECISION_MODELS, readDecisionResponse } from "./models.js";
1
+ import { collectImages, detachedReference } from "./images.js";
2
+ import { buildDecisionRequest, DECISION_MODELS, overModelLimit, readDecisionResponse } from "./models.js";
2
3
  import { matchesQuestion } from "./questions.js";
3
4
  /**
4
5
  * Jev answers `400 max_tokens_exceeded` past roughly 32.5k input tokens (96k
5
- * chars passed, 128k did not). A state over this is refused before it leaves
6
- * the process; keep well under the wall, since tokenization varies. Clef takes
7
- * 64k tokens but silently truncates long state, so the same guard keeps it
8
- * 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.
9
11
  */
10
- export const DECISION_MAX_STATE_CHARS = 90_000;
12
+ export const DECISION_MAX_REQUEST_CHARS = 90_000;
11
13
  /**
12
14
  * A generous ceiling rather than a target (Jev's p90 is ~280 ms for ten
13
15
  * questions): past it the caller is better served by its own fallback.
14
16
  */
15
17
  const DEFAULT_TIMEOUT_MS = 4_000;
16
18
  /** Retried once: the providers' own guidance for these is to back off and retry. */
17
- const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 529]);
19
+ const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504, 529]);
18
20
  const RETRY_DELAY_MS = 250;
19
21
  /**
20
22
  * A provider asking for a longer pause (`Retry-After`) will not be ready within
@@ -26,6 +28,10 @@ export class DecisionError extends Error {
26
28
  model;
27
29
  status;
28
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;
29
35
  constructor(kind, model, message,
30
36
  /** The HTTP status, when the provider answered. */
31
37
  status) {
@@ -37,7 +43,6 @@ export class DecisionError extends Error {
37
43
  this.provider = DECISION_MODELS[model].provider;
38
44
  }
39
45
  }
40
- /** The kind a refused response maps to; anything unlisted is `request-failed`. */
41
46
  const KIND_BY_STATUS = {
42
47
  401: 'unauthorized',
43
48
  402: 'out-of-credits',
@@ -46,35 +51,51 @@ const KIND_BY_STATUS = {
46
51
  };
47
52
  export function createDecisionApi(options) {
48
53
  const send = options.fetch ?? ((request) => fetch(request.url, request.init));
49
- const maxStateChars = options.maxStateChars ?? DECISION_MAX_STATE_CHARS;
50
- return async (input, ...[requestedModel]) => {
51
- 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;
52
58
  const context = rest;
53
59
  const model = requestedModel ?? options.defaultModel;
54
60
  if (!model)
55
61
  throw new Error('No model given and no defaultModel set');
56
62
  const report = (error) => {
63
+ error.latencyMs = Date.now() - calledAt;
57
64
  options.onError?.(error, context);
58
- return null;
65
+ return { ok: false, error };
59
66
  };
60
67
  const fail = (kind, message) => report(new DecisionError(kind, model, message));
61
68
  if (Object.keys(questions).length === 0) {
62
- return {};
69
+ return { ok: true, answers: {}, usage: null };
63
70
  }
71
+ // An image counts as its reference here: its tokens are the provider's to count.
64
72
  const stateText = typeof state === 'string' ? state : JSON.stringify(state);
65
- const stateChars = stateText.length;
66
- if (stateChars > maxStateChars) {
67
- 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}`);
77
+ }
78
+ const overLimit = overModelLimit(model, questions);
79
+ if (overLimit)
80
+ return fail('request-too-large', overLimit);
81
+ const images = collectImages(questions, collectImages(state));
82
+ const detached = detachedReference(stateText + questionsText, images);
83
+ if (detached) {
84
+ return fail('image-detached', `${detached} is in the text without its image; write that text with withImages`);
85
+ }
86
+ if (images.size > 0 && !DECISION_MODELS[model].images) {
87
+ return fail('images-unsupported', `${model} does not read images`);
68
88
  }
69
89
  const startedAt = Date.now();
70
- let text;
90
+ let sent;
71
91
  try {
72
- text = await postWithRetry({
92
+ sent = await postWithRetry({
73
93
  send,
74
94
  model,
75
- request: buildDecisionRequest(model, { state, questions }, options.keys),
95
+ request: buildDecisionRequest(model, { state, questions, images: [...images.values()] }, options.keys),
76
96
  signal: signal ?? options.defaultSignal?.(),
77
- timeoutMs: timeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS
97
+ timeoutMs: timeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
98
+ attemptTimeoutMs: attemptTimeoutMs ?? options.attemptTimeoutMs
78
99
  });
79
100
  }
80
101
  catch (error) {
@@ -82,39 +103,58 @@ export function createDecisionApi(options) {
82
103
  return report(error);
83
104
  return fail('request-failed', messageOf(error));
84
105
  }
106
+ const failAfterSend = (kind, message) => {
107
+ const error = new DecisionError(kind, model, message);
108
+ error.attempts = sent.attempts;
109
+ return report(error);
110
+ };
85
111
  let json;
86
112
  try {
87
- json = JSON.parse(text);
113
+ json = JSON.parse(sent.text);
88
114
  }
89
115
  catch (error) {
90
- return fail('unreadable-response', messageOf(error));
116
+ return failAfterSend('unreadable-response', messageOf(error));
91
117
  }
92
118
  const parsed = readDecisionResponse(model, json);
93
119
  if (!parsed.success) {
94
- return fail('unreadable-response', parsed.error.issues[0]?.message ?? 'unknown');
120
+ return failAfterSend('unreadable-response', parsed.error.issues[0]?.message ?? 'unknown');
95
121
  }
96
- const { usage, answers } = parsed.data;
97
- reportUsage({
122
+ const { usage: tokens, answers, model: resolvedModel } = parsed.data;
123
+ const usage = {
98
124
  model,
125
+ ...(resolvedModel !== undefined && { resolvedModel }),
99
126
  provider: DECISION_MODELS[model].provider,
100
- inputTokens: usage.input_tokens,
101
- outputTokens: usage.output_tokens,
102
- 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) /
103
130
  1_000_000,
104
- latencyMs: Date.now() - startedAt
105
- }, context);
131
+ latencyMs: Date.now() - startedAt,
132
+ attempts: sent.attempts
133
+ };
134
+ reportUsage(usage, context);
106
135
  for (const [key, question] of Object.entries(questions)) {
107
136
  if (!matchesQuestion(question, answers[key])) {
108
- return fail('answer-mismatch', `Answer to "${key}" does not fit it`);
137
+ return failAfterSend('answer-mismatch', `Answer to "${key}" does not fit it`);
109
138
  }
110
139
  }
111
140
  // Every requested key, kind and option set was validated above.
112
- return answers;
141
+ return {
142
+ ok: true,
143
+ answers: answers,
144
+ usage
145
+ };
146
+ };
147
+ const decide = async (input, model) => {
148
+ const outcome = await result(input, model);
149
+ return outcome.ok ? outcome.answers : null;
113
150
  };
151
+ // The call signatures above are checked per call; one implementation serves them all.
152
+ return Object.assign(decide, { result });
114
153
  /**
115
- * 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
116
155
  * wall, a timeout or an unreadable body reports nothing, so a 0 ms row can
117
- * 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.
118
158
  */
119
159
  function reportUsage(usage, context) {
120
160
  const { registerUsage } = options;
@@ -131,16 +171,34 @@ export function createDecisionApi(options) {
131
171
  });
132
172
  }
133
173
  }
134
- /** 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
+ */
135
180
  async function postWithRetry(input) {
136
181
  const provider = DECISION_MODELS[input.model].provider;
137
- 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`);
138
190
  for (let attempt = 0;; attempt++) {
139
191
  if (input.signal?.aborted)
140
192
  throw failure('aborted', 'Aborted by the caller');
141
- // A fresh timeout per attempt, so a retry gets its own budget.
142
- 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);
143
199
  const { signal, release } = linkSignals(timeout, input.signal);
200
+ attempts += 1;
201
+ let delayMs = RETRY_DELAY_MS;
144
202
  try {
145
203
  const response = await input.send({
146
204
  url: input.request.url,
@@ -153,17 +211,17 @@ async function postWithRetry(input) {
153
211
  }
154
212
  }, { model: input.model, provider, attempt, signal });
155
213
  if (response.ok)
156
- return await response.text();
157
- const delayMs = retryDelayMs(response);
158
- if (attempt > 0 ||
214
+ return { text: await response.text(), attempts };
215
+ delayMs = retryDelayMs(response);
216
+ if (!canRetry ||
159
217
  !RETRYABLE_STATUSES.has(response.status) ||
160
- delayMs > MAX_RETRY_DELAY_MS) {
218
+ delayMs > MAX_RETRY_DELAY_MS ||
219
+ delayMs >= endsAt - Date.now()) {
161
220
  const body = (await response.text()).slice(0, 200);
162
- 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);
163
222
  }
164
223
  // An unread body holds its connection until garbage collection.
165
224
  await response.body?.cancel().catch(() => { });
166
- await new Promise((resolve) => setTimeout(resolve, delayMs));
167
225
  }
168
226
  catch (error) {
169
227
  if (error instanceof DecisionError)
@@ -171,15 +229,47 @@ async function postWithRetry(input) {
171
229
  if (input.signal?.aborted)
172
230
  throw failure('aborted', messageOf(error));
173
231
  if (timeout.aborted) {
174
- 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));
175
239
  }
176
- throw error;
177
240
  }
178
241
  finally {
179
242
  release();
180
243
  }
244
+ await pause(delayMs, input.signal).catch(() => {
245
+ throw failure('aborted', 'Aborted by the caller');
246
+ });
181
247
  }
182
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';
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
+ });
272
+ }
183
273
  /** The pause a response asks for, in seconds or as a date; our own without one. */
184
274
  function retryDelayMs(response) {
185
275
  const header = response.headers.get('retry-after')?.trim();