decision-api 1.1.0 → 1.3.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 +37 -2
- package/dist/create-decision-api.d.ts +24 -5
- package/dist/create-decision-api.js +11 -1
- package/dist/images.d.ts +58 -0
- package/dist/images.js +160 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/models.d.ts +27 -4
- package/dist/models.js +46 -14
- package/dist/questions.d.ts +9 -8
- package/dist/questions.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# decision-api
|
|
2
2
|
|
|
3
|
-
One client for decision models: models that answer typed questions with calibrated probabilities instead of text. It speaks TypeSafe's Jev, Cloudflare's Clef and Clef-flash (Workers AI), OpenAI's gpt-6-luna and the decision models on OpenRouter (such as `microsoft/microsoft-decision-1`), and returns the same answer shape from all of them. It depends only on `zod`.
|
|
3
|
+
One client for decision models: models that answer typed questions with calibrated probabilities instead of text. It speaks TypeSafe's Jev, Cloudflare's Clef and Clef-flash (Workers AI), OpenAI's gpt-6-luna and the decision models on OpenRouter (such as `microsoft/microsoft-decision-1` and `typesafe/jev-1.13`), and returns the same answer shape from all of them. Jev also reads images. It depends only on `zod`.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npm install decision-api
|
|
@@ -49,7 +49,42 @@ TypeScript enforces the setup:
|
|
|
49
49
|
- **The model is optional only with a default.** Without `defaultModel`, every call must name one.
|
|
50
50
|
- **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
51
|
|
|
52
|
-
A call answers `null` instead of throwing, and `onError` says why: `timeout`, `aborted` (the call's own signal), `unauthorized` (401/403, a bad key), `out-of-credits` (402), `rate-limited` (429 after the retry), `request-failed` (any other status, or a transport error), `state-too-large`, `unreadable-response` or `answer-mismatch`. Every error carries its `model` and `provider`, and `status` when the provider answered; a throwing `registerUsage` reports `usage-failed` without failing the call. Retryable statuses (429 and 5xx) are retried once, after the provider's `Retry-After` when it asks for 2 s or less, and not at all when it asks for more. `fetch` replaces the transport, for example to route calls through your own rate limits, and `defaultSignal` supplies the signal for calls that pass none. A call with no questions answers `{}` without a request. The one throw is a call with no model and no `defaultModel`, which TypeScript already rejects.
|
|
52
|
+
A call answers `null` instead of throwing, and `onError` says why: `timeout`, `aborted` (the call's own signal), `unauthorized` (401/403, a bad key), `out-of-credits` (402), `rate-limited` (429 after the retry), `request-failed` (any other status, or a transport error), `state-too-large`, `images-unsupported`, `image-detached` (see [Images](#images)), `unreadable-response` or `answer-mismatch`. Every error carries its `model` and `provider`, and `status` when the provider answered; a throwing `registerUsage` reports `usage-failed` without failing the call. Retryable statuses (429 and 5xx) are retried once, after the provider's `Retry-After` when it asks for 2 s or less, and not at all when it asks for more. `fetch` replaces the transport, for example to route calls through your own rate limits, and `defaultSignal` supplies the signal for calls that pass none. A call with no questions answers `{}` without a request. The one throw is a call with no model and no `defaultModel`, which TypeScript already rejects.
|
|
53
|
+
|
|
54
|
+
## Images
|
|
55
|
+
|
|
56
|
+
`jev` and `typesafe/jev-1.13` read images (`images: true` in `DECISION_MODELS`). Make one with `image()` from PNG or JPEG bytes or base64, and put it anywhere text can go: in the state, as a question's instructions or as a criterion. Inside a sentence, write it into a `withImages` template:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { image, withImages } from 'decision-api'
|
|
60
|
+
import { readFile } from 'node:fs/promises'
|
|
61
|
+
|
|
62
|
+
const door = image(await readFile('door-camera.jpg'))
|
|
63
|
+
const badge = image(await readFile('badge.jpg'))
|
|
64
|
+
const thumbsUp = image(await readFile('thumbs-up.png'))
|
|
65
|
+
|
|
66
|
+
const answers = await decide({
|
|
67
|
+
state: { door },
|
|
68
|
+
questions: {
|
|
69
|
+
same: {
|
|
70
|
+
type: 'noul',
|
|
71
|
+
instructions: withImages`Is the person in \`door\` the one on this badge: ${badge}?`
|
|
72
|
+
},
|
|
73
|
+
gesture: {
|
|
74
|
+
type: 'choice',
|
|
75
|
+
instructions: 'Which gesture does the hand in `door` make?',
|
|
76
|
+
criteria: { thumbs_up: thumbsUp, none: 'No hand, or another gesture' }
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
customerId: 'c_1'
|
|
80
|
+
})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Each image is sent once, base64-encoded under a unique reference, however often it is used; every use still counts as input tokens. TypeSafe's image preview header is added for you. Put an image several questions use in the state, not in each question's instructions.
|
|
84
|
+
|
|
85
|
+
- **TypeScript rejects an image on a model that reads none**, when the image's type is visible in the call. Questions typed as `Record<string, DecisionQuestion>` don't show it, so the call checks as well and answers `null` with `images-unsupported`.
|
|
86
|
+
- **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
|
+
- **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.
|
|
53
88
|
|
|
54
89
|
## Benchmark
|
|
55
90
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type DecisionImage, type DecisionImageText, type DecisionText } from './images.ts';
|
|
2
|
+
import { type DecisionImageModel, type DecisionModel, type DecisionProvider, type DecisionProviderKeys, type ModelsFor } from './models.ts';
|
|
2
3
|
import { type DecisionAnswers, type DecisionQuestion } from './questions.ts';
|
|
3
4
|
/**
|
|
4
5
|
* Jev answers `400 max_tokens_exceeded` past roughly 32.5k input tokens (96k
|
|
@@ -18,6 +19,10 @@ export type DecisionUsage<Model extends DecisionModel = DecisionModel> = {
|
|
|
18
19
|
latencyMs: number;
|
|
19
20
|
};
|
|
20
21
|
export type DecisionErrorKind = 'state-too-large'
|
|
22
|
+
/** The call holds an image and the model reads none (`images: false`). */
|
|
23
|
+
| 'images-unsupported'
|
|
24
|
+
/** An image's reference is in the text without the image: build that text with `withImages`. */
|
|
25
|
+
| 'image-detached'
|
|
21
26
|
/** No answer within `timeoutMs`. */
|
|
22
27
|
| 'timeout'
|
|
23
28
|
/** The call's own signal (or `defaultSignal`) aborted it. */
|
|
@@ -51,9 +56,12 @@ export type DecisionFetch = (request: {
|
|
|
51
56
|
/** The same signal `init` carries: the call's own, plus its timeout. */
|
|
52
57
|
signal: AbortSignal;
|
|
53
58
|
}) => Promise<Response>;
|
|
54
|
-
type DecisionCall<Q extends Record<string, DecisionQuestion
|
|
55
|
-
/**
|
|
56
|
-
|
|
59
|
+
type DecisionCall<Q extends Record<string, DecisionQuestion>, State> = {
|
|
60
|
+
/**
|
|
61
|
+
* A string, or named fields - name them when the state has several parts.
|
|
62
|
+
* Images go in as values, on models that read them.
|
|
63
|
+
*/
|
|
64
|
+
state: State;
|
|
57
65
|
questions: Q;
|
|
58
66
|
signal?: AbortSignal;
|
|
59
67
|
timeoutMs?: number;
|
|
@@ -89,6 +97,17 @@ type DecisionApiOptions<Keys extends DecisionProviderKeys, Default, Context> = {
|
|
|
89
97
|
* It throws only when there is no model at all - a setup bug TypeScript
|
|
90
98
|
* already rejects, which a silent `null` would hide.
|
|
91
99
|
*/
|
|
92
|
-
export type DecisionApi<Model extends DecisionModel, Default, Context> = <Q extends Record<string, DecisionQuestion
|
|
100
|
+
export type DecisionApi<Model extends DecisionModel, Default, Context> = <Q extends Record<string, DecisionQuestion>, State = unknown>(input: DecisionCall<Q, State> & Context, ...model: ModelArgument<HasImage<State> extends true ? Extract<Model, DecisionImageModel> : HasImage<Q> extends true ? Extract<Model, DecisionImageModel> : Model, Default>) => Promise<DecisionAnswers<Q> | null>;
|
|
101
|
+
type ModelArgument<Model, Default> = [Default] extends [Model] ? [model?: Model] : [model: Model];
|
|
102
|
+
/**
|
|
103
|
+
* Whether a call's state or questions hold an image, as far as their types
|
|
104
|
+
* tell: a value typed as any text (`DecisionText`, `unknown`, `any`) does not
|
|
105
|
+
* say, and the call checks it instead. Stops at a few levels deep, so a
|
|
106
|
+
* recursive JSON type cannot recurse forever.
|
|
107
|
+
*/
|
|
108
|
+
type HasImage<T, Depth extends unknown[] = []> = 0 extends 1 & T ? false : [DecisionText] extends [T] ? false : Depth['length'] extends 6 ? false : true extends ImageIn<T, [...Depth, unknown]> ? true : false;
|
|
109
|
+
type ImageIn<T, Depth extends unknown[]> = T extends DecisionImage | DecisionImageText ? true : T extends readonly (infer Item)[] ? HasImage<Item, Depth> : T extends object ? {
|
|
110
|
+
[K in keyof T]-?: HasImage<T[K], Depth>;
|
|
111
|
+
}[keyof T] : false;
|
|
93
112
|
export declare function createDecisionApi<const Keys extends DecisionProviderKeys, Default extends ModelsFor<Keys> | undefined = undefined, Context extends object = Record<never, never>>(options: DecisionApiOptions<Keys, Default, Context>): DecisionApi<ModelsFor<Keys>, Default, Context>;
|
|
94
113
|
export {};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { collectImages, detachedReference } from "./images.js";
|
|
1
2
|
import { buildDecisionRequest, DECISION_MODELS, readDecisionResponse } from "./models.js";
|
|
2
3
|
import { matchesQuestion } from "./questions.js";
|
|
3
4
|
/**
|
|
@@ -61,18 +62,27 @@ export function createDecisionApi(options) {
|
|
|
61
62
|
if (Object.keys(questions).length === 0) {
|
|
62
63
|
return {};
|
|
63
64
|
}
|
|
65
|
+
// An image counts as its reference here: its tokens are the provider's to count.
|
|
64
66
|
const stateText = typeof state === 'string' ? state : JSON.stringify(state);
|
|
65
67
|
const stateChars = stateText.length;
|
|
66
68
|
if (stateChars > maxStateChars) {
|
|
67
69
|
return fail('state-too-large', `State is ${stateChars} chars, over ${maxStateChars}`);
|
|
68
70
|
}
|
|
71
|
+
const images = collectImages(questions, collectImages(state));
|
|
72
|
+
const detached = detachedReference(stateText + JSON.stringify(questions), images);
|
|
73
|
+
if (detached) {
|
|
74
|
+
return fail('image-detached', `${detached} is in the text without its image; write that text with withImages`);
|
|
75
|
+
}
|
|
76
|
+
if (images.size > 0 && !DECISION_MODELS[model].images) {
|
|
77
|
+
return fail('images-unsupported', `${model} does not read images`);
|
|
78
|
+
}
|
|
69
79
|
const startedAt = Date.now();
|
|
70
80
|
let text;
|
|
71
81
|
try {
|
|
72
82
|
text = await postWithRetry({
|
|
73
83
|
send,
|
|
74
84
|
model,
|
|
75
|
-
request: buildDecisionRequest(model, { state, questions }, options.keys),
|
|
85
|
+
request: buildDecisionRequest(model, { state, questions, images: [...images.values()] }, options.keys),
|
|
76
86
|
signal: signal ?? options.defaultSignal?.(),
|
|
77
87
|
timeoutMs: timeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
78
88
|
});
|
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
1
|
export { createDecisionApi, DECISION_MAX_STATE_CHARS, type DecisionApi, DecisionError, type DecisionErrorKind, type DecisionFetch, type DecisionUsage } from './create-decision-api.ts';
|
|
2
|
-
export {
|
|
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';
|
|
3
4
|
export type { DecisionAnswers, DecisionChoice, DecisionNoul, DecisionQuestion, DecisionScore } from './questions.ts';
|
package/dist/index.js
CHANGED
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,34 +14,54 @@ 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.
|
|
20
27
|
*/
|
|
21
28
|
export declare const DECISION_MODELS: {
|
|
22
29
|
readonly jev: {
|
|
23
30
|
readonly provider: "typesafe";
|
|
24
31
|
readonly inputUsdPerMTok: 0.042;
|
|
32
|
+
readonly images: true;
|
|
25
33
|
};
|
|
26
34
|
readonly clef: {
|
|
27
35
|
readonly provider: "cloudflare";
|
|
28
36
|
readonly inputUsdPerMTok: 0.24;
|
|
37
|
+
readonly images: false;
|
|
29
38
|
};
|
|
30
39
|
readonly 'clef-flash': {
|
|
31
40
|
readonly provider: "cloudflare";
|
|
32
41
|
readonly inputUsdPerMTok: 0.09;
|
|
42
|
+
readonly images: false;
|
|
33
43
|
};
|
|
34
44
|
readonly 'gpt-6-luna': {
|
|
35
45
|
readonly provider: "openai";
|
|
36
46
|
readonly inputUsdPerMTok: 0.1;
|
|
47
|
+
readonly images: false;
|
|
37
48
|
};
|
|
38
49
|
readonly 'microsoft/microsoft-decision-1': {
|
|
39
50
|
readonly provider: "openrouter";
|
|
40
51
|
readonly inputUsdPerMTok: 0.042;
|
|
52
|
+
readonly images: false;
|
|
53
|
+
};
|
|
54
|
+
readonly 'typesafe/jev-1.13': {
|
|
55
|
+
readonly provider: "openrouter";
|
|
56
|
+
readonly inputUsdPerMTok: 0.042;
|
|
57
|
+
readonly images: true;
|
|
41
58
|
};
|
|
42
59
|
};
|
|
43
60
|
export type DecisionModel = keyof typeof DECISION_MODELS;
|
|
61
|
+
/** The models that read images. */
|
|
62
|
+
export type DecisionImageModel = {
|
|
63
|
+
[Model in DecisionModel]: (typeof DECISION_MODELS)[Model]['images'] extends true ? Model : never;
|
|
64
|
+
}[DecisionModel];
|
|
44
65
|
type ModelsOf<Provider> = {
|
|
45
66
|
[Model in DecisionModel]: (typeof DECISION_MODELS)[Model]['provider'] extends Provider ? Model : never;
|
|
46
67
|
}[DecisionModel];
|
|
@@ -51,6 +72,8 @@ export type ModelsFor<Keys extends DecisionProviderKeys> = ModelsOf<{
|
|
|
51
72
|
type RequestInput = {
|
|
52
73
|
state: unknown;
|
|
53
74
|
questions: Record<string, DecisionQuestion>;
|
|
75
|
+
/** Sent only to models with `images: true`; the caller checks. */
|
|
76
|
+
images: DecisionImage[];
|
|
54
77
|
};
|
|
55
78
|
/** One POST: where it goes, and the headers and body it carries. */
|
|
56
79
|
export declare function buildDecisionRequest(model: DecisionModel, input: RequestInput, keys: DecisionProviderKeys): {
|
package/dist/models.js
CHANGED
|
@@ -2,23 +2,43 @@
|
|
|
2
2
|
// that provider's wire format maps onto the shared question shapes
|
|
3
3
|
// (`questions.ts`).
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
+
import { toAssets } from "./images.js";
|
|
5
6
|
import { DecisionResponseSchema } from "./questions.js";
|
|
6
7
|
/**
|
|
7
|
-
* USD per million input tokens; output is free on every
|
|
8
|
-
* answer is a few tokens of enum rather than generated
|
|
9
|
-
* from TypeSafe's launch material (the docs publish no
|
|
10
|
-
* are the providers' list prices (2026-10).
|
|
8
|
+
* `inputUsdPerMTok` is USD per million input tokens; output is free on every
|
|
9
|
+
* provider, because an answer is a few tokens of enum rather than generated
|
|
10
|
+
* prose. Jev's rate is from TypeSafe's launch material (the docs publish no
|
|
11
|
+
* price sheet); the rest are the providers' list prices (2026-10). Images are
|
|
12
|
+
* billed as input tokens at the same rate.
|
|
13
|
+
*
|
|
14
|
+
* `images` marks the models this client sends images to. Jev reads them
|
|
15
|
+
* anywhere in the state, instructions and criteria (1.13 and later:
|
|
16
|
+
* https://docs.typesafe.ai/images). Clef and gpt-6-luna read images too, but
|
|
17
|
+
* in wire formats this client does not speak yet.
|
|
11
18
|
*/
|
|
12
19
|
export const DECISION_MODELS = {
|
|
13
|
-
jev: { provider: 'typesafe', inputUsdPerMTok: 0.042 },
|
|
14
|
-
clef: { provider: 'cloudflare', inputUsdPerMTok: 0.24 },
|
|
15
|
-
'clef-flash': {
|
|
16
|
-
|
|
20
|
+
jev: { provider: 'typesafe', inputUsdPerMTok: 0.042, images: true },
|
|
21
|
+
clef: { provider: 'cloudflare', inputUsdPerMTok: 0.24, images: false },
|
|
22
|
+
'clef-flash': {
|
|
23
|
+
provider: 'cloudflare',
|
|
24
|
+
inputUsdPerMTok: 0.09,
|
|
25
|
+
images: false
|
|
26
|
+
},
|
|
27
|
+
'gpt-6-luna': { provider: 'openai', inputUsdPerMTok: 0.1, images: false },
|
|
17
28
|
// OpenRouter models are named by their OpenRouter slug, so adding one is
|
|
18
29
|
// one line here: https://openrouter.ai/rankings/decisions
|
|
19
30
|
'microsoft/microsoft-decision-1': {
|
|
20
31
|
provider: 'openrouter',
|
|
21
|
-
inputUsdPerMTok: 0.042
|
|
32
|
+
inputUsdPerMTok: 0.042,
|
|
33
|
+
images: false
|
|
34
|
+
},
|
|
35
|
+
// TypeSafe's Jev 1.13 on OpenRouter, served on OpenRouter's
|
|
36
|
+
// zero-data-retention list. OpenRouter forwards `assets` to Jev as written:
|
|
37
|
+
// https://openrouter.ai/docs/guides/community/multimodal-decisions
|
|
38
|
+
'typesafe/jev-1.13': {
|
|
39
|
+
provider: 'openrouter',
|
|
40
|
+
inputUsdPerMTok: 0.042,
|
|
41
|
+
images: true
|
|
22
42
|
}
|
|
23
43
|
};
|
|
24
44
|
/** One POST: where it goes, and the headers and body it carries. */
|
|
@@ -32,18 +52,30 @@ export function buildDecisionRequest(model, input, keys) {
|
|
|
32
52
|
'content-type': 'application/json'
|
|
33
53
|
};
|
|
34
54
|
};
|
|
55
|
+
const { state, questions, images } = input;
|
|
56
|
+
// A text-only request goes out exactly as it did before images existed.
|
|
57
|
+
const assets = images.length > 0 ? { assets: toAssets(images) } : {};
|
|
35
58
|
if (provider === 'typesafe') {
|
|
36
59
|
return {
|
|
37
60
|
url: 'https://api.typesafe.ai/v1/systemone',
|
|
38
|
-
headers:
|
|
39
|
-
|
|
61
|
+
headers: {
|
|
62
|
+
...headers(keys.typesafe),
|
|
63
|
+
// Images are opt-in per request while TypeSafe calls them a preview.
|
|
64
|
+
...(images.length > 0 && { 'typesafe-danger-zone-images': 'true' })
|
|
65
|
+
},
|
|
66
|
+
body: JSON.stringify({
|
|
67
|
+
state,
|
|
68
|
+
questions,
|
|
69
|
+
...assets,
|
|
70
|
+
model: 'jev-latest'
|
|
71
|
+
})
|
|
40
72
|
};
|
|
41
73
|
}
|
|
42
74
|
if (provider === 'cloudflare') {
|
|
43
75
|
return {
|
|
44
76
|
url: `https://api.cloudflare.com/client/v4/accounts/${keys.cloudflare?.accountId}/ai/run/@cf/cloudflare/${model}`,
|
|
45
77
|
headers: headers(keys.cloudflare?.apiToken),
|
|
46
|
-
body: JSON.stringify({
|
|
78
|
+
body: JSON.stringify({ state, questions, model })
|
|
47
79
|
};
|
|
48
80
|
}
|
|
49
81
|
if (provider === 'openrouter') {
|
|
@@ -51,13 +83,13 @@ export function buildDecisionRequest(model, input, keys) {
|
|
|
51
83
|
return {
|
|
52
84
|
url: 'https://openrouter.ai/api/alpha/decisions',
|
|
53
85
|
headers: headers(keys.openrouter),
|
|
54
|
-
body: JSON.stringify({ ...
|
|
86
|
+
body: JSON.stringify({ state, questions, ...assets, model })
|
|
55
87
|
};
|
|
56
88
|
}
|
|
57
89
|
return {
|
|
58
90
|
url: 'https://api.openai.com/v1/decisions',
|
|
59
91
|
headers: headers(keys.openai),
|
|
60
|
-
body: JSON.stringify(toOpenAiRequest(model,
|
|
92
|
+
body: JSON.stringify(toOpenAiRequest(model, { state, questions }))
|
|
61
93
|
};
|
|
62
94
|
}
|
|
63
95
|
/** The provider's response read into the shared shape, unvalidated against the questions. */
|
package/dist/questions.d.ts
CHANGED
|
@@ -1,27 +1,28 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import type { DecisionText } from './images.ts';
|
|
2
3
|
/** Probability that the condition holds. No separate confidence: this IS it. */
|
|
3
4
|
export type DecisionNoul = {
|
|
4
5
|
type: 'noul';
|
|
5
|
-
instructions:
|
|
6
|
+
instructions: DecisionText;
|
|
6
7
|
/** Optional, but a `true`/`false` gloss is what keeps a noul from drifting. */
|
|
7
8
|
criteria?: {
|
|
8
|
-
true:
|
|
9
|
-
false:
|
|
9
|
+
true: DecisionText;
|
|
10
|
+
false: DecisionText;
|
|
10
11
|
};
|
|
11
12
|
};
|
|
12
13
|
/** One option from a defined set, with the distribution across all of them. */
|
|
13
14
|
export type DecisionChoice<Option extends string = string> = {
|
|
14
15
|
type: 'choice';
|
|
15
|
-
instructions:
|
|
16
|
+
instructions: DecisionText;
|
|
16
17
|
/** Option -> what that option means. Include a no-match option when one may fit nothing. */
|
|
17
|
-
criteria: Record<Option,
|
|
18
|
+
criteria: Record<Option, DecisionText>;
|
|
18
19
|
};
|
|
19
20
|
/** A position on ordered, concretely described levels. */
|
|
20
21
|
export type DecisionScore = {
|
|
21
22
|
type: 'score';
|
|
22
|
-
instructions:
|
|
23
|
+
instructions: DecisionText;
|
|
23
24
|
/** Two or more levels, lowest first; each must stand on its own. */
|
|
24
|
-
criteria:
|
|
25
|
+
criteria: DecisionText[];
|
|
25
26
|
};
|
|
26
27
|
export type DecisionQuestion = DecisionNoul | DecisionChoice | DecisionScore;
|
|
27
28
|
type DecisionNoulAnswer = {
|
|
@@ -45,7 +46,7 @@ type AnswerFor<Q> = Q extends {
|
|
|
45
46
|
type: 'noul';
|
|
46
47
|
} ? DecisionNoulAnswer : Q extends {
|
|
47
48
|
type: 'choice';
|
|
48
|
-
criteria: Record<infer Option extends string,
|
|
49
|
+
criteria: Record<infer Option extends string, DecisionText>;
|
|
49
50
|
} ? DecisionChoiceAnswer<Option> : Q extends {
|
|
50
51
|
type: 'score';
|
|
51
52
|
} ? DecisionScoreAnswer : never;
|
package/dist/questions.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The question and answer shapes every call speaks. They are System One's own
|
|
2
2
|
// (TypeSafe's Jev, Cloudflare's Clef); a provider with another wire format
|
|
3
|
-
// translates to and from them in `models.ts`.
|
|
3
|
+
// translates to and from them in `models.ts`. Instructions and criteria take
|
|
4
|
+
// images (`DecisionText`) on the models that read them.
|
|
4
5
|
import { z } from 'zod';
|
|
5
6
|
const ProbabilitySchema = z.number().min(0).max(1);
|
|
6
7
|
const AnswerSchema = z.discriminatedUnion('type', [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "decision-api",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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",
|