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 +116 -5
- package/dist/create-decision-api.d.ts +86 -16
- package/dist/create-decision-api.js +136 -46
- package/dist/eval.d.ts +115 -0
- package/dist/eval.js +182 -0
- package/dist/images.d.ts +58 -0
- package/dist/images.js +160 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +3 -1
- package/dist/models.d.ts +37 -4
- package/dist/models.js +86 -16
- package/dist/questions.d.ts +30 -28
- package/dist/questions.js +21 -1
- package/dist/testing.d.ts +27 -0
- package/dist/testing.js +74 -0
- package/package.json +9 -1
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,
|
|
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), `
|
|
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
|
|
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).
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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 =
|
|
21
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
56
|
-
|
|
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
|
-
|
|
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
|
|
83
|
-
* `state
|
|
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
|
-
|
|
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 {
|
|
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).
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
|
50
|
-
|
|
51
|
-
const
|
|
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
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
90
|
+
let sent;
|
|
71
91
|
try {
|
|
72
|
-
|
|
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
|
|
116
|
+
return failAfterSend('unreadable-response', messageOf(error));
|
|
91
117
|
}
|
|
92
118
|
const parsed = readDecisionResponse(model, json);
|
|
93
119
|
if (!parsed.success) {
|
|
94
|
-
return
|
|
120
|
+
return failAfterSend('unreadable-response', parsed.error.issues[0]?.message ?? 'unknown');
|
|
95
121
|
}
|
|
96
|
-
const { usage, answers } = parsed.data;
|
|
97
|
-
|
|
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:
|
|
101
|
-
outputTokens:
|
|
102
|
-
costUsd: (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
158
|
-
if (
|
|
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
|
|
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
|
-
|
|
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();
|