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 +80 -4
- package/dist/create-decision-api.d.ts +63 -12
- package/dist/create-decision-api.js +126 -46
- package/dist/eval.d.ts +115 -0
- package/dist/eval.js +182 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/dist/models.d.ts +14 -0
- package/dist/models.js +43 -3
- package/dist/questions.d.ts +21 -20
- package/dist/questions.js +19 -0
- package/dist/testing.d.ts +27 -0
- package/dist/testing.js +74 -0
- package/package.json +9 -1
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,
|
|
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), `
|
|
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).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
91
|
-
* `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.
|
|
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> =
|
|
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).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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
|
|
51
|
-
|
|
52
|
-
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;
|
|
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
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
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 +
|
|
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
|
|
90
|
+
let sent;
|
|
81
91
|
try {
|
|
82
|
-
|
|
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
|
|
116
|
+
return failAfterSend('unreadable-response', messageOf(error));
|
|
101
117
|
}
|
|
102
118
|
const parsed = readDecisionResponse(model, json);
|
|
103
119
|
if (!parsed.success) {
|
|
104
|
-
return
|
|
120
|
+
return failAfterSend('unreadable-response', parsed.error.issues[0]?.message ?? 'unknown');
|
|
105
121
|
}
|
|
106
|
-
const { usage, answers } = parsed.data;
|
|
107
|
-
|
|
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:
|
|
111
|
-
outputTokens:
|
|
112
|
-
costUsd: (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
168
|
-
if (
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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: {
|
|
21
|
-
|
|
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,
|
package/dist/questions.d.ts
CHANGED
|
@@ -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:
|
|
94
|
-
|
|
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 {};
|
package/dist/testing.js
ADDED
|
@@ -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": "
|
|
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": [
|