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/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/images.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type DecisionImageFormat = 'png' | 'jpg';
|
|
2
|
+
/**
|
|
3
|
+
* A PNG or JPEG image, created by `image()`. Put it wherever text can go: as
|
|
4
|
+
* (part of) the state, a question's instructions or a criterion. Inside a
|
|
5
|
+
* sentence, write it into a `withImages` template.
|
|
6
|
+
*/
|
|
7
|
+
export declare class DecisionImage {
|
|
8
|
+
readonly type = "image";
|
|
9
|
+
/** The text the image is sent under; what `String(image)` and `JSON.stringify` write. */
|
|
10
|
+
readonly reference: string;
|
|
11
|
+
readonly format: DecisionImageFormat;
|
|
12
|
+
private bytes;
|
|
13
|
+
private encoded;
|
|
14
|
+
constructor(data: ArrayBuffer | ArrayBufferView | string);
|
|
15
|
+
/** The file in standard base64, encoded once on first use. */
|
|
16
|
+
get base64(): string;
|
|
17
|
+
toString(): string;
|
|
18
|
+
toJSON(): string;
|
|
19
|
+
}
|
|
20
|
+
/** Text from a `withImages` template: the images in it travel with the request. */
|
|
21
|
+
export declare class DecisionImageText {
|
|
22
|
+
readonly text: string;
|
|
23
|
+
readonly images: readonly DecisionImage[];
|
|
24
|
+
constructor(text: string, images: readonly DecisionImage[]);
|
|
25
|
+
toString(): string;
|
|
26
|
+
toJSON(): string;
|
|
27
|
+
}
|
|
28
|
+
/** Text, an image on its own, or text with images in it: whatever a model may read. */
|
|
29
|
+
export type DecisionText = string | DecisionImage | DecisionImageText;
|
|
30
|
+
/**
|
|
31
|
+
* A PNG or JPEG image to put in a call, from its file bytes or base64 (a
|
|
32
|
+
* `data:` URL works too). Only models with `images: true` in
|
|
33
|
+
* `DECISION_MODELS` read images.
|
|
34
|
+
*
|
|
35
|
+
* Throws when the data is not a PNG or JPEG file. The provider rejects, never
|
|
36
|
+
* resizes, an image outside its limits: for Jev, 280 to 1568 px a side and at
|
|
37
|
+
* most 16 MiB, with EXIF orientation ignored, so rotate and scale first.
|
|
38
|
+
*/
|
|
39
|
+
export declare function image(data: ArrayBuffer | ArrayBufferView | string): DecisionImage;
|
|
40
|
+
/**
|
|
41
|
+
* Tag a template whose text names images, so they are sent with it:
|
|
42
|
+
* withImages`Is the person in \`door\` the one on this badge: ${badge}?`
|
|
43
|
+
* A plain template would carry the reference without the image, and the call
|
|
44
|
+
* answers `null` (`image-detached`) rather than ask about a bare string.
|
|
45
|
+
*/
|
|
46
|
+
export declare function withImages(strings: TemplateStringsArray, ...values: unknown[]): DecisionImageText;
|
|
47
|
+
/** Every image in a value, once each, keyed by its reference. Object keys stay text. */
|
|
48
|
+
export declare function collectImages(value: unknown, into?: Map<string, DecisionImage>): Map<string, DecisionImage>;
|
|
49
|
+
/** A reference written into the text whose image was not sent with it, if any. */
|
|
50
|
+
export declare function detachedReference(text: string, images: ReadonlyMap<string, DecisionImage>): string | undefined;
|
|
51
|
+
/** The `assets` map TypeSafe's API (and OpenRouter's, for Jev) reads. */
|
|
52
|
+
export declare function toAssets(images: Iterable<DecisionImage>): {
|
|
53
|
+
[k: string]: {
|
|
54
|
+
type: string;
|
|
55
|
+
format: DecisionImageFormat;
|
|
56
|
+
data_base64: string;
|
|
57
|
+
};
|
|
58
|
+
};
|
package/dist/images.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Images in a decision call. Each image is sent once, base64-encoded in the
|
|
2
|
+
// request's top-level `assets` map, and stands in the state, an instruction or
|
|
3
|
+
// a criterion as its reference string:
|
|
4
|
+
// https://docs.typesafe.ai/images
|
|
5
|
+
/** Every reference starts with this, so a reference without its image can be caught. */
|
|
6
|
+
const REFERENCE_PREFIX = 'decision-api-image-';
|
|
7
|
+
const REFERENCE_PATTERN = new RegExp(`${REFERENCE_PREFIX}[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`, 'g');
|
|
8
|
+
/**
|
|
9
|
+
* A PNG or JPEG image, created by `image()`. Put it wherever text can go: as
|
|
10
|
+
* (part of) the state, a question's instructions or a criterion. Inside a
|
|
11
|
+
* sentence, write it into a `withImages` template.
|
|
12
|
+
*/
|
|
13
|
+
export class DecisionImage {
|
|
14
|
+
type = 'image';
|
|
15
|
+
/** The text the image is sent under; what `String(image)` and `JSON.stringify` write. */
|
|
16
|
+
reference = `${REFERENCE_PREFIX}${uuid()}`;
|
|
17
|
+
format;
|
|
18
|
+
bytes;
|
|
19
|
+
encoded;
|
|
20
|
+
constructor(data) {
|
|
21
|
+
if (typeof data === 'string') {
|
|
22
|
+
// A data URL's prefix is dropped; the API wants the bare base64.
|
|
23
|
+
const base64 = data.replace(/^data:image\/[a-z]+;base64,/, '');
|
|
24
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(base64) || base64.length % 4 !== 0) {
|
|
25
|
+
throw new TypeError('image() needs padded standard base64');
|
|
26
|
+
}
|
|
27
|
+
this.encoded = base64;
|
|
28
|
+
this.format = formatOfBase64(base64);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
this.bytes = ArrayBuffer.isView(data)
|
|
32
|
+
? new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
|
33
|
+
: new Uint8Array(data);
|
|
34
|
+
this.format = formatOfBytes(this.bytes);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** The file in standard base64, encoded once on first use. */
|
|
38
|
+
get base64() {
|
|
39
|
+
if (this.encoded === undefined && this.bytes) {
|
|
40
|
+
this.encoded = toBase64(this.bytes);
|
|
41
|
+
this.bytes = undefined;
|
|
42
|
+
}
|
|
43
|
+
return this.encoded ?? '';
|
|
44
|
+
}
|
|
45
|
+
toString() {
|
|
46
|
+
return this.reference;
|
|
47
|
+
}
|
|
48
|
+
toJSON() {
|
|
49
|
+
return this.reference;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Text from a `withImages` template: the images in it travel with the request. */
|
|
53
|
+
export class DecisionImageText {
|
|
54
|
+
text;
|
|
55
|
+
images;
|
|
56
|
+
constructor(text, images) {
|
|
57
|
+
this.text = text;
|
|
58
|
+
this.images = images;
|
|
59
|
+
}
|
|
60
|
+
toString() {
|
|
61
|
+
return this.text;
|
|
62
|
+
}
|
|
63
|
+
toJSON() {
|
|
64
|
+
return this.text;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A PNG or JPEG image to put in a call, from its file bytes or base64 (a
|
|
69
|
+
* `data:` URL works too). Only models with `images: true` in
|
|
70
|
+
* `DECISION_MODELS` read images.
|
|
71
|
+
*
|
|
72
|
+
* Throws when the data is not a PNG or JPEG file. The provider rejects, never
|
|
73
|
+
* resizes, an image outside its limits: for Jev, 280 to 1568 px a side and at
|
|
74
|
+
* most 16 MiB, with EXIF orientation ignored, so rotate and scale first.
|
|
75
|
+
*/
|
|
76
|
+
export function image(data) {
|
|
77
|
+
return new DecisionImage(data);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Tag a template whose text names images, so they are sent with it:
|
|
81
|
+
* withImages`Is the person in \`door\` the one on this badge: ${badge}?`
|
|
82
|
+
* A plain template would carry the reference without the image, and the call
|
|
83
|
+
* answers `null` (`image-detached`) rather than ask about a bare string.
|
|
84
|
+
*/
|
|
85
|
+
export function withImages(strings, ...values) {
|
|
86
|
+
const images = [];
|
|
87
|
+
let text = strings[0] ?? '';
|
|
88
|
+
values.forEach((value, index) => {
|
|
89
|
+
if (value instanceof DecisionImage)
|
|
90
|
+
images.push(value);
|
|
91
|
+
if (value instanceof DecisionImageText)
|
|
92
|
+
images.push(...value.images);
|
|
93
|
+
text += String(value) + (strings[index + 1] ?? '');
|
|
94
|
+
});
|
|
95
|
+
return new DecisionImageText(text, images);
|
|
96
|
+
}
|
|
97
|
+
/** Every image in a value, once each, keyed by its reference. Object keys stay text. */
|
|
98
|
+
export function collectImages(value, into = new Map()) {
|
|
99
|
+
if (value instanceof DecisionImage) {
|
|
100
|
+
into.set(value.reference, value);
|
|
101
|
+
}
|
|
102
|
+
else if (value instanceof DecisionImageText) {
|
|
103
|
+
for (const item of value.images)
|
|
104
|
+
into.set(item.reference, item);
|
|
105
|
+
}
|
|
106
|
+
else if (Array.isArray(value)) {
|
|
107
|
+
for (const item of value)
|
|
108
|
+
collectImages(item, into);
|
|
109
|
+
}
|
|
110
|
+
else if (value && typeof value === 'object') {
|
|
111
|
+
for (const item of Object.values(value))
|
|
112
|
+
collectImages(item, into);
|
|
113
|
+
}
|
|
114
|
+
return into;
|
|
115
|
+
}
|
|
116
|
+
/** A reference written into the text whose image was not sent with it, if any. */
|
|
117
|
+
export function detachedReference(text, images) {
|
|
118
|
+
if (!text.includes(REFERENCE_PREFIX))
|
|
119
|
+
return undefined;
|
|
120
|
+
return text.match(REFERENCE_PATTERN)?.find((found) => !images.has(found));
|
|
121
|
+
}
|
|
122
|
+
/** The `assets` map TypeSafe's API (and OpenRouter's, for Jev) reads. */
|
|
123
|
+
export function toAssets(images) {
|
|
124
|
+
return Object.fromEntries(Array.from(images, (item) => [
|
|
125
|
+
item.reference,
|
|
126
|
+
{ type: 'image', format: item.format, data_base64: item.base64 }
|
|
127
|
+
]));
|
|
128
|
+
}
|
|
129
|
+
function formatOfBytes(bytes) {
|
|
130
|
+
const png = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
131
|
+
if (png.every((byte, index) => bytes[index] === byte))
|
|
132
|
+
return 'png';
|
|
133
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
|
|
134
|
+
return 'jpg';
|
|
135
|
+
throw new TypeError('image() takes PNG or JPEG files only');
|
|
136
|
+
}
|
|
137
|
+
/** The same magic numbers, as they start a base64 string. */
|
|
138
|
+
function formatOfBase64(base64) {
|
|
139
|
+
if (base64.startsWith('iVBORw0KGgo'))
|
|
140
|
+
return 'png';
|
|
141
|
+
if (base64.startsWith('/9j/'))
|
|
142
|
+
return 'jpg';
|
|
143
|
+
throw new TypeError('image() takes PNG or JPEG files only');
|
|
144
|
+
}
|
|
145
|
+
function toBase64(bytes) {
|
|
146
|
+
// In slices, because spreading megabytes into one call overflows the stack.
|
|
147
|
+
let binary = '';
|
|
148
|
+
for (let start = 0; start < bytes.length; start += 0x8000) {
|
|
149
|
+
binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000));
|
|
150
|
+
}
|
|
151
|
+
return btoa(binary);
|
|
152
|
+
}
|
|
153
|
+
/** `crypto.randomUUID` where the runtime has it (Node 18 and React Native may not). */
|
|
154
|
+
function uuid() {
|
|
155
|
+
const random = globalThis.crypto;
|
|
156
|
+
if (random?.randomUUID)
|
|
157
|
+
return random.randomUUID();
|
|
158
|
+
const hex = Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
|
|
159
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20)}`;
|
|
160
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export { createDecisionApi,
|
|
2
|
-
export {
|
|
3
|
-
export
|
|
1
|
+
export { createDecisionApi, DECISION_MAX_REQUEST_CHARS, type DecisionApi, DecisionError, type DecisionErrorKind, type DecisionFetch, type DecisionResult, type DecisionUsage } from './create-decision-api.ts';
|
|
2
|
+
export { type DecisionImage, type DecisionImageFormat, type DecisionImageText, type DecisionText, image, withImages } from './images.ts';
|
|
3
|
+
export { DECISION_MODELS, type DecisionImageModel, type DecisionModel, type DecisionProvider, type DecisionProviderKeys, type ModelsFor } from './models.ts';
|
|
4
|
+
export { type DecisionAnswer, type DecisionAnswers, type DecisionChoice, type DecisionChoiceAnswer, type DecisionNoul, type DecisionNoulAnswer, type DecisionQuestion, type DecisionScore, type DecisionScoreAnswer, perItem } from './questions.ts';
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
|
-
export { createDecisionApi,
|
|
1
|
+
export { createDecisionApi, DECISION_MAX_REQUEST_CHARS, DecisionError } from "./create-decision-api.js";
|
|
2
|
+
export { image, withImages } from "./images.js";
|
|
2
3
|
export { DECISION_MODELS } from "./models.js";
|
|
4
|
+
export { perItem } from "./questions.js";
|
package/dist/models.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { type DecisionImage } from './images.ts';
|
|
2
3
|
import { type DecisionQuestion } from './questions.ts';
|
|
3
4
|
/** The credentials each provider needs; pass only the providers you use. */
|
|
4
5
|
export type DecisionProviderKeys = {
|
|
@@ -13,38 +14,65 @@ export type DecisionProviderKeys = {
|
|
|
13
14
|
};
|
|
14
15
|
export type DecisionProvider = keyof DecisionProviderKeys;
|
|
15
16
|
/**
|
|
16
|
-
* USD per million input tokens; output is free on every
|
|
17
|
-
* answer is a few tokens of enum rather than generated
|
|
18
|
-
* from TypeSafe's launch material (the docs publish no
|
|
19
|
-
* are the providers' list prices (2026-10).
|
|
17
|
+
* `inputUsdPerMTok` is USD per million input tokens; output is free on every
|
|
18
|
+
* provider, because an answer is a few tokens of enum rather than generated
|
|
19
|
+
* prose. Jev's rate is from TypeSafe's launch material (the docs publish no
|
|
20
|
+
* price sheet); the rest are the providers' list prices (2026-10). Images are
|
|
21
|
+
* billed as input tokens at the same rate.
|
|
22
|
+
*
|
|
23
|
+
* `images` marks the models this client sends images to. Jev reads them
|
|
24
|
+
* anywhere in the state, instructions and criteria (1.13 and later:
|
|
25
|
+
* https://docs.typesafe.ai/images). Clef and gpt-6-luna read images too, but
|
|
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).
|
|
20
33
|
*/
|
|
21
34
|
export declare const DECISION_MODELS: {
|
|
22
35
|
readonly jev: {
|
|
23
36
|
readonly provider: "typesafe";
|
|
24
37
|
readonly inputUsdPerMTok: 0.042;
|
|
38
|
+
readonly images: true;
|
|
39
|
+
readonly maxChoices: 255;
|
|
40
|
+
readonly maxLevels: 10;
|
|
25
41
|
};
|
|
26
42
|
readonly clef: {
|
|
27
43
|
readonly provider: "cloudflare";
|
|
28
44
|
readonly inputUsdPerMTok: 0.24;
|
|
45
|
+
readonly images: false;
|
|
46
|
+
readonly maxQuestions: 64;
|
|
29
47
|
};
|
|
30
48
|
readonly 'clef-flash': {
|
|
31
49
|
readonly provider: "cloudflare";
|
|
32
50
|
readonly inputUsdPerMTok: 0.09;
|
|
51
|
+
readonly images: false;
|
|
33
52
|
};
|
|
34
53
|
readonly 'gpt-6-luna': {
|
|
35
54
|
readonly provider: "openai";
|
|
36
55
|
readonly inputUsdPerMTok: 0.1;
|
|
56
|
+
readonly images: false;
|
|
37
57
|
};
|
|
38
58
|
readonly 'microsoft/microsoft-decision-1': {
|
|
39
59
|
readonly provider: "openrouter";
|
|
40
60
|
readonly inputUsdPerMTok: 0.042;
|
|
61
|
+
readonly images: false;
|
|
41
62
|
};
|
|
42
63
|
readonly 'typesafe/jev-1.13': {
|
|
43
64
|
readonly provider: "openrouter";
|
|
44
65
|
readonly inputUsdPerMTok: 0.042;
|
|
66
|
+
readonly images: true;
|
|
67
|
+
readonly maxChoices: 255;
|
|
68
|
+
readonly maxLevels: 10;
|
|
45
69
|
};
|
|
46
70
|
};
|
|
47
71
|
export type DecisionModel = keyof typeof DECISION_MODELS;
|
|
72
|
+
/** The models that read images. */
|
|
73
|
+
export type DecisionImageModel = {
|
|
74
|
+
[Model in DecisionModel]: (typeof DECISION_MODELS)[Model]['images'] extends true ? Model : never;
|
|
75
|
+
}[DecisionModel];
|
|
48
76
|
type ModelsOf<Provider> = {
|
|
49
77
|
[Model in DecisionModel]: (typeof DECISION_MODELS)[Model]['provider'] extends Provider ? Model : never;
|
|
50
78
|
}[DecisionModel];
|
|
@@ -52,9 +80,13 @@ type ModelsOf<Provider> = {
|
|
|
52
80
|
export type ModelsFor<Keys extends DecisionProviderKeys> = ModelsOf<{
|
|
53
81
|
[Provider in keyof Keys]-?: undefined extends Keys[Provider] ? never : Provider;
|
|
54
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;
|
|
55
85
|
type RequestInput = {
|
|
56
86
|
state: unknown;
|
|
57
87
|
questions: Record<string, DecisionQuestion>;
|
|
88
|
+
/** Sent only to models with `images: true`; the caller checks. */
|
|
89
|
+
images: DecisionImage[];
|
|
58
90
|
};
|
|
59
91
|
/** One POST: where it goes, and the headers and body it carries. */
|
|
60
92
|
export declare function buildDecisionRequest(model: DecisionModel, input: RequestInput, keys: DecisionProviderKeys): {
|
|
@@ -85,5 +117,6 @@ export declare function readDecisionResponse(model: DecisionModel, json: unknown
|
|
|
85
117
|
input_tokens: number;
|
|
86
118
|
output_tokens: number;
|
|
87
119
|
};
|
|
120
|
+
model?: string | undefined;
|
|
88
121
|
}>;
|
|
89
122
|
export {};
|