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/dist/models.js CHANGED
@@ -2,27 +2,83 @@
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.
18
+ *
19
+ * `maxQuestions`, `maxChoices` (options in one choice) and `maxLevels` (levels
20
+ * in one score) are the limits a call is checked against before it is sent,
21
+ * where a provider documents or was measured to enforce one. Jev's come from
22
+ * its API schema (https://docs.typesafe.ai/api); Clef's question limit was
23
+ * measured (it refuses more than 64).
11
24
  */
12
25
  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 },
26
+ jev: {
27
+ provider: 'typesafe',
28
+ inputUsdPerMTok: 0.042,
29
+ images: true,
30
+ maxChoices: 255,
31
+ maxLevels: 10
32
+ },
33
+ clef: {
34
+ provider: 'cloudflare',
35
+ inputUsdPerMTok: 0.24,
36
+ images: false,
37
+ maxQuestions: 64
38
+ },
39
+ 'clef-flash': {
40
+ provider: 'cloudflare',
41
+ inputUsdPerMTok: 0.09,
42
+ images: false
43
+ },
44
+ 'gpt-6-luna': { provider: 'openai', inputUsdPerMTok: 0.1, images: false },
17
45
  // OpenRouter models are named by their OpenRouter slug, so adding one is
18
46
  // one line here: https://openrouter.ai/rankings/decisions
19
47
  'microsoft/microsoft-decision-1': {
20
48
  provider: 'openrouter',
21
- inputUsdPerMTok: 0.042
49
+ inputUsdPerMTok: 0.042,
50
+ images: false
22
51
  },
23
- // TypeSafe's Jev 1.13 on OpenRouter, served on OpenRouter's zero-data-retention list
24
- 'typesafe/jev-1.13': { provider: 'openrouter', inputUsdPerMTok: 0.042 }
52
+ // TypeSafe's Jev 1.13 on OpenRouter, served on OpenRouter's
53
+ // zero-data-retention list. OpenRouter forwards `assets` to Jev as written:
54
+ // https://openrouter.ai/docs/guides/community/multimodal-decisions
55
+ 'typesafe/jev-1.13': {
56
+ provider: 'openrouter',
57
+ inputUsdPerMTok: 0.042,
58
+ images: true,
59
+ maxChoices: 255,
60
+ maxLevels: 10
61
+ }
25
62
  };
63
+ /** Why a model would refuse these questions, or undefined when it takes them. */
64
+ export function overModelLimit(model, questions) {
65
+ const limits = DECISION_MODELS[model];
66
+ const count = Object.keys(questions).length;
67
+ if (limits.maxQuestions !== undefined && count > limits.maxQuestions) {
68
+ return `${count} questions, over ${model}'s ${limits.maxQuestions}`;
69
+ }
70
+ for (const [key, question] of Object.entries(questions)) {
71
+ if (question.type === 'noul')
72
+ continue;
73
+ const [size, limit, what] = question.type === 'choice'
74
+ ? [Object.keys(question.criteria).length, limits.maxChoices, 'options']
75
+ : [question.criteria.length, limits.maxLevels, 'levels'];
76
+ if (limit !== undefined && size > limit) {
77
+ return `"${key}" has ${size} ${what}, over ${model}'s ${limit}`;
78
+ }
79
+ }
80
+ return undefined;
81
+ }
26
82
  /** One POST: where it goes, and the headers and body it carries. */
27
83
  export function buildDecisionRequest(model, input, keys) {
28
84
  const provider = DECISION_MODELS[model].provider;
@@ -34,18 +90,30 @@ export function buildDecisionRequest(model, input, keys) {
34
90
  'content-type': 'application/json'
35
91
  };
36
92
  };
93
+ const { state, questions, images } = input;
94
+ // A text-only request goes out exactly as it did before images existed.
95
+ const assets = images.length > 0 ? { assets: toAssets(images) } : {};
37
96
  if (provider === 'typesafe') {
38
97
  return {
39
98
  url: 'https://api.typesafe.ai/v1/systemone',
40
- headers: headers(keys.typesafe),
41
- body: JSON.stringify({ ...input, model: 'jev-latest' })
99
+ headers: {
100
+ ...headers(keys.typesafe),
101
+ // Images are opt-in per request while TypeSafe calls them a preview.
102
+ ...(images.length > 0 && { 'typesafe-danger-zone-images': 'true' })
103
+ },
104
+ body: JSON.stringify({
105
+ state,
106
+ questions,
107
+ ...assets,
108
+ model: 'jev-latest'
109
+ })
42
110
  };
43
111
  }
44
112
  if (provider === 'cloudflare') {
45
113
  return {
46
114
  url: `https://api.cloudflare.com/client/v4/accounts/${keys.cloudflare?.accountId}/ai/run/@cf/cloudflare/${model}`,
47
115
  headers: headers(keys.cloudflare?.apiToken),
48
- body: JSON.stringify({ ...input, model })
116
+ body: JSON.stringify({ state, questions, model })
49
117
  };
50
118
  }
51
119
  if (provider === 'openrouter') {
@@ -53,13 +121,13 @@ export function buildDecisionRequest(model, input, keys) {
53
121
  return {
54
122
  url: 'https://openrouter.ai/api/alpha/decisions',
55
123
  headers: headers(keys.openrouter),
56
- body: JSON.stringify({ ...input, model })
124
+ body: JSON.stringify({ state, questions, ...assets, model })
57
125
  };
58
126
  }
59
127
  return {
60
128
  url: 'https://api.openai.com/v1/decisions',
61
129
  headers: headers(keys.openai),
62
- body: JSON.stringify(toOpenAiRequest(model, input))
130
+ body: JSON.stringify(toOpenAiRequest(model, { state, questions }))
63
131
  };
64
132
  }
65
133
  /** The provider's response read into the shared shape, unvalidated against the questions. */
@@ -114,6 +182,7 @@ const OpenAiProbabilitiesSchema = z.array(z.object({
114
182
  probability: z.number()
115
183
  }));
116
184
  const OpenAiResponseSchema = z.object({
185
+ model: z.string().optional(),
117
186
  answers: z.array(z.discriminatedUnion('type', [
118
187
  z.object({
119
188
  type: z.literal('predicate'),
@@ -159,6 +228,7 @@ function fromOpenAiResponse(response) {
159
228
  ];
160
229
  });
161
230
  return {
231
+ model: response.model,
162
232
  answers: Object.fromEntries(answers),
163
233
  usage: {
164
234
  input_tokens: response.usage.input_tokens,
@@ -1,73 +1,62 @@
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
- type DecisionNoulAnswer = {
28
+ export type DecisionNoulAnswer = {
28
29
  type: 'noul';
29
30
  noul: number;
30
31
  };
31
- type DecisionChoiceAnswer<Option extends string = string> = {
32
+ export type DecisionChoiceAnswer<Option extends string = string> = {
32
33
  type: 'choice';
33
34
  choice: Option;
34
35
  confidence: number;
35
36
  probabilities: Record<Option, number>;
36
37
  };
37
- type DecisionScoreAnswer = {
38
+ export type DecisionScoreAnswer = {
38
39
  type: 'score';
39
40
  score: number;
40
41
  confidence: number;
41
42
  probabilities: Record<string, number>;
42
43
  };
44
+ export type DecisionAnswer = DecisionNoulAnswer | DecisionChoiceAnswer | DecisionScoreAnswer;
43
45
  /** The answer a given question shape produces, so call sites need no casts. */
44
- type AnswerFor<Q> = Q extends {
46
+ export type AnswerFor<Q> = Q extends {
45
47
  type: 'noul';
46
48
  } ? DecisionNoulAnswer : Q extends {
47
49
  type: 'choice';
48
- criteria: Record<infer Option extends string, string>;
50
+ criteria: Record<infer Option extends string, DecisionText>;
49
51
  } ? DecisionChoiceAnswer<Option> : Q extends {
50
52
  type: 'score';
51
53
  } ? DecisionScoreAnswer : never;
52
54
  export type DecisionAnswers<Q extends Record<string, DecisionQuestion>> = {
53
55
  [K in keyof Q]: AnswerFor<Q[K]>;
54
56
  };
55
- declare const AnswerSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
56
- type: z.ZodLiteral<"noul">;
57
- noul: z.ZodNumber;
58
- }, z.core.$strip>, z.ZodObject<{
59
- type: z.ZodLiteral<"choice">;
60
- choice: z.ZodString;
61
- confidence: z.ZodNumber;
62
- probabilities: z.ZodRecord<z.ZodString, z.ZodNumber>;
63
- }, z.core.$strip>, z.ZodObject<{
64
- type: z.ZodLiteral<"score">;
65
- score: z.ZodNumber;
66
- confidence: z.ZodNumber;
67
- probabilities: z.ZodRecord<z.ZodString, z.ZodNumber>;
68
- }, z.core.$strip>], "type">;
69
57
  /** What every vendor's response is read into. */
70
58
  export declare const DecisionResponseSchema: z.ZodObject<{
59
+ model: z.ZodOptional<z.ZodString>;
71
60
  answers: z.ZodRecord<z.ZodString, z.ZodDiscriminatedUnion<[z.ZodObject<{
72
61
  type: z.ZodLiteral<"noul">;
73
62
  noul: z.ZodNumber;
@@ -89,5 +78,18 @@ export declare const DecisionResponseSchema: z.ZodObject<{
89
78
  }, z.core.$strip>;
90
79
  export type DecisionResponse = z.infer<typeof DecisionResponseSchema>;
91
80
  /** The contract depends on the question, not just the answer's own tag. */
92
- export declare function matchesQuestion(question: DecisionQuestion, answer: z.infer<typeof AnswerSchema> | undefined): boolean;
93
- export {};
81
+ export declare function matchesQuestion(question: DecisionQuestion, answer: DecisionAnswer | undefined): boolean;
82
+ /**
83
+ * The same question about each item of a list, under keys `${prefix}_${index}`.
84
+ * Spread `questions` into a call (next to other questions if you like) and
85
+ * pass its answers to `read`, which pairs each item with its typed answer.
86
+ */
87
+ export declare function perItem<Item, const Q extends DecisionQuestion, const Prefix extends string>(items: readonly Item[], prefix: Prefix, ask: (item: Item, index: number) => Q): {
88
+ questions: Record<`${Prefix}_${number}`, Q>;
89
+ /** Every key is there: a call answers all its questions or `null`. */
90
+ read: (answers: { readonly [K in `${Prefix}_${number}`]: AnswerFor<Q>; }) => {
91
+ item: Item;
92
+ index: number;
93
+ answer: AnswerFor<Q>;
94
+ }[];
95
+ };
package/dist/questions.js CHANGED
@@ -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', [
@@ -20,6 +21,8 @@ const AnswerSchema = z.discriminatedUnion('type', [
20
21
  ]);
21
22
  /** What every vendor's response is read into. */
22
23
  export const DecisionResponseSchema = z.object({
24
+ /** The model that answered, when the provider says (it may resolve an alias). */
25
+ model: z.string().optional(),
23
26
  answers: z.record(z.string(), AnswerSchema),
24
27
  usage: z.object({
25
28
  input_tokens: z.number().int().nonnegative(),
@@ -49,3 +52,20 @@ function matchesOptions(options, probabilities) {
49
52
  return (Object.keys(probabilities).length === options.length &&
50
53
  options.every((option) => Object.hasOwn(probabilities, option)));
51
54
  }
55
+ /**
56
+ * The same question about each item of a list, under keys `${prefix}_${index}`.
57
+ * Spread `questions` into a call (next to other questions if you like) and
58
+ * pass its answers to `read`, which pairs each item with its typed answer.
59
+ */
60
+ export function perItem(items, prefix, ask) {
61
+ const questions = Object.fromEntries(items.map((item, index) => [`${prefix}_${index}`, ask(item, index)]));
62
+ return {
63
+ questions,
64
+ /** Every key is there: a call answers all its questions or `null`. */
65
+ read: (answers) => items.map((item, index) => ({
66
+ item,
67
+ index,
68
+ answer: answers[`${prefix}_${index}`]
69
+ }))
70
+ };
71
+ }
@@ -0,0 +1,27 @@
1
+ import { DecisionError } from './create-decision-api.ts';
2
+ import { type DecisionModel } from './models.ts';
3
+ import { type DecisionAnswer, type DecisionQuestion } from './questions.ts';
4
+ /** One call the code under test made: its input (context included) and model. */
5
+ export type FakeDecisionCall = {
6
+ state: unknown;
7
+ questions: Record<string, DecisionQuestion>;
8
+ model: DecisionModel | undefined;
9
+ [field: string]: unknown;
10
+ };
11
+ /**
12
+ * Answers by question key: a number is a noul's probability or a score, a
13
+ * string is the choice, or give the full answer.
14
+ */
15
+ export type FakeAnswers = Record<string, number | string | DecisionAnswer>;
16
+ type Respond = (call: FakeDecisionCall) => FakeAnswers | DecisionError | null | Promise<FakeAnswers | DecisionError | null>;
17
+ /**
18
+ * A fake `decide`, typed as the real one: `fakeDecide<typeof decide>(...)`.
19
+ * `respond` answers each call; `null` fails it as `request-failed`, and a
20
+ * `DecisionError` fails it with that error. An answer that does not fit its
21
+ * question, or a missing one, throws in the test, since the real client would
22
+ * never return it. `calls` records every call.
23
+ */
24
+ export declare function fakeDecide<Api>(respond: Respond): Api & {
25
+ calls: FakeDecisionCall[];
26
+ };
27
+ export {};
@@ -0,0 +1,74 @@
1
+ // A stand-in for `createDecisionApi`'s client in tests: no network, typed as
2
+ // the real one, and as strict about answers as the real one.
3
+ import { DecisionError } from "./create-decision-api.js";
4
+ import { DECISION_MODELS } from "./models.js";
5
+ import { matchesQuestion } from "./questions.js";
6
+ /**
7
+ * A fake `decide`, typed as the real one: `fakeDecide<typeof decide>(...)`.
8
+ * `respond` answers each call; `null` fails it as `request-failed`, and a
9
+ * `DecisionError` fails it with that error. An answer that does not fit its
10
+ * question, or a missing one, throws in the test, since the real client would
11
+ * never return it. `calls` records every call.
12
+ */
13
+ export function fakeDecide(respond) {
14
+ const calls = [];
15
+ const result = async (input, model) => {
16
+ const call = { ...input, model };
17
+ calls.push(call);
18
+ const used = model ?? 'jev';
19
+ const response = await respond(call);
20
+ if (response === null || response instanceof DecisionError) {
21
+ const error = response ?? new DecisionError('request-failed', used, 'fakeDecide');
22
+ return { ok: false, error };
23
+ }
24
+ const usage = {
25
+ model: used,
26
+ provider: DECISION_MODELS[used].provider,
27
+ inputTokens: 0,
28
+ outputTokens: 0,
29
+ costUsd: 0,
30
+ latencyMs: 0,
31
+ attempts: 1
32
+ };
33
+ return {
34
+ ok: true,
35
+ answers: expand(call.questions, response),
36
+ usage
37
+ };
38
+ };
39
+ const decide = async (input, model) => {
40
+ const outcome = await result(input, model);
41
+ return outcome.ok ? outcome.answers : null;
42
+ };
43
+ return Object.assign(decide, { result, calls });
44
+ }
45
+ function expand(questions, given) {
46
+ return Object.fromEntries(Object.entries(questions).map(([key, question]) => {
47
+ const answer = toAnswer(question, given[key]);
48
+ if (!matchesQuestion(question, answer)) {
49
+ throw new Error(`fakeDecide: the answer to "${key}" (${JSON.stringify(given[key])}) does not fit its ${question.type} question`);
50
+ }
51
+ return [key, answer];
52
+ }));
53
+ }
54
+ /** Shorthand to a full answer: all the probability on the given value. */
55
+ function toAnswer(question, value) {
56
+ if (value === undefined || typeof value === 'object')
57
+ return value;
58
+ if (question.type === 'noul') {
59
+ return typeof value === 'number' ? { type: 'noul', noul: value } : undefined;
60
+ }
61
+ const options = question.type === 'choice'
62
+ ? Object.keys(question.criteria)
63
+ : question.criteria.map((_, index) => String(index));
64
+ const picked = String(question.type === 'score' ? Math.round(Number(value)) : value);
65
+ const probabilities = Object.fromEntries(options.map((option) => [option, option === picked ? 1 : 0]));
66
+ if (question.type === 'choice') {
67
+ return typeof value === 'string'
68
+ ? { type: 'choice', choice: value, confidence: 1, probabilities }
69
+ : undefined;
70
+ }
71
+ return typeof value === 'number'
72
+ ? { type: 'score', score: value, confidence: 1, probabilities }
73
+ : undefined;
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "decision-api",
3
- "version": "1.2.0",
3
+ "version": "2.0.0",
4
4
  "description": "One typed client for decision models (TypeSafe Jev, Cloudflare Clef, OpenAI gpt-6-luna, OpenRouter models such as Microsoft-Decision-1) that answer questions with calibrated probabilities",
5
5
  "license": "MIT",
6
6
  "author": "Kristoffer Hansen",
@@ -22,6 +22,14 @@
22
22
  ".": {
23
23
  "types": "./dist/index.d.ts",
24
24
  "default": "./dist/index.js"
25
+ },
26
+ "./eval": {
27
+ "types": "./dist/eval.d.ts",
28
+ "default": "./dist/eval.js"
29
+ },
30
+ "./testing": {
31
+ "types": "./dist/testing.d.ts",
32
+ "default": "./dist/testing.js"
25
33
  }
26
34
  },
27
35
  "files": [