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 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 DecisionModel, type DecisionProvider, type DecisionProviderKeys, type ModelsFor } from './models.ts';
1
+ import { type DecisionImage, type DecisionImageText, type DecisionText } from './images.ts';
2
+ import { type DecisionImageModel, type DecisionModel, type DecisionProvider, type DecisionProviderKeys, type ModelsFor } from './models.ts';
2
3
  import { type DecisionAnswers, type DecisionQuestion } from './questions.ts';
3
4
  /**
4
5
  * Jev answers `400 max_tokens_exceeded` past roughly 32.5k input tokens (96k
@@ -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
- /** A string, or named fields - name them when the state has several parts. */
56
- state: unknown;
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>>(input: DecisionCall<Q> & Context, ...model: [Default] extends [Model] ? [model?: Model] : [model: Model]) => Promise<DecisionAnswers<Q> | null>;
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
  });
@@ -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 { DECISION_MODELS, type DecisionModel, type DecisionProvider, type DecisionProviderKeys, type ModelsFor } from './models.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';
3
4
  export type { DecisionAnswers, DecisionChoice, DecisionNoul, DecisionQuestion, DecisionScore } from './questions.ts';
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { createDecisionApi, DECISION_MAX_STATE_CHARS, DecisionError } from "./create-decision-api.js";
2
+ export { image, withImages } from "./images.js";
2
3
  export { DECISION_MODELS } from "./models.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,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 provider, because an
17
- * answer is a few tokens of enum rather than generated prose. Jev's rate is
18
- * from TypeSafe's launch material (the docs publish no price sheet); the rest
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 provider, because an
8
- * answer is a few tokens of enum rather than generated prose. Jev's rate is
9
- * from TypeSafe's launch material (the docs publish no price sheet); the rest
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': { provider: 'cloudflare', inputUsdPerMTok: 0.09 },
16
- 'gpt-6-luna': { provider: 'openai', inputUsdPerMTok: 0.1 },
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: headers(keys.typesafe),
39
- body: JSON.stringify({ ...input, model: 'jev-latest' })
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({ ...input, model })
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({ ...input, model })
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, input))
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. */
@@ -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: string;
6
+ instructions: DecisionText;
6
7
  /** Optional, but a `true`/`false` gloss is what keeps a noul from drifting. */
7
8
  criteria?: {
8
- true: string;
9
- false: string;
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: string;
16
+ instructions: DecisionText;
16
17
  /** Option -> what that option means. Include a no-match option when one may fit nothing. */
17
- criteria: Record<Option, string>;
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: string;
23
+ instructions: DecisionText;
23
24
  /** Two or more levels, lowest first; each must stand on its own. */
24
- criteria: string[];
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, 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.1.0",
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",