decision-api 0.0.0 → 1.2.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) and OpenAI's gpt-6-luna, 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. It depends only on `zod`.
4
4
 
5
5
  ```sh
6
6
  npm install decision-api
@@ -13,7 +13,8 @@ const decide = createDecisionApi({
13
13
  keys: {
14
14
  typesafe: '…',
15
15
  cloudflare: { apiToken: '…', accountId: '…' },
16
- openai: '…'
16
+ openai: '…',
17
+ openrouter: '…' // every OpenRouter model, named by its slug
17
18
  },
18
19
  defaultModel: 'jev',
19
20
  registerUsage: (usage, context: { customerId: string }) => {
@@ -44,11 +45,25 @@ answers?.team.choice // 'billing' | 'other'
44
45
 
45
46
  TypeScript enforces the setup:
46
47
 
47
- - **Keys select models.** Only models whose provider key you passed can be named, as `defaultModel` or per call. A key typed `string | undefined` (a raw `process.env` read) does not count; narrow it first.
48
+ - **Keys select models.** Only models whose provider key you passed can be named, as `defaultModel` or per call. A key typed `string | undefined` (a raw `process.env` read) does not count; narrow it first. One `openrouter` key opens every OpenRouter model at once; a new one is added as its slug and price in `DECISION_MODELS`.
48
49
  - **The model is optional only with a default.** Without `defaultModel`, every call must name one.
49
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`.
50
51
 
51
- A call answers `null` instead of throwing, and `onError` says why: `timeout`, `aborted` (the call's own signal), `request-failed`, `state-too-large`, `unreadable-response` or `answer-mismatch`; 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`, `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
+ ## Benchmark
55
+
56
+ `bun run bench` asks every model with a key the fixed questions in [`bench/cases.ts`](bench/cases.ts) and writes `.artifacts/bench/report.html`: accuracy against the expected answers, probability on the right answer, latency p50/p90 and cost per model, every answer side by side, and a closing leaderboard that scores each model out of 100 (one point per right answer, a failed call scores zero). Keys come from the environment or `.env`: copy [`.env.example`](.env.example) to `.env` and fill in the providers you have; `--runs=5` and `--models=jev,clef` narrow or widen a run.
57
+
58
+ Results from 2026-10-10 (12 cases, 21 questions, 3 runs per case):
59
+
60
+ | # | Model | Score | Points | p(correct) | Latency p50 / p90 | Cost / 1k calls |
61
+ | --- | --- | --- | --- | --- | --- | --- |
62
+ | 1 | `jev` | 100 | 21 / 21 | 96% | 262 / 292 ms | $0.017 |
63
+ | 2 | `clef` | 100 | 21 / 21 | 94% | 292 / 550 ms | $0.067 |
64
+ | 3 | `microsoft/microsoft-decision-1` | 95.2 | 20 / 21 | 95% | 354 / 819 ms | $0.005 |
65
+ | 4 | `gpt-6-luna` | 95.2 | 20 / 21 | 94% | 149 / 264 ms | $0.029 |
66
+ | 5 | `clef-flash` | 95.2 | 20 / 21 | 85% | 227 / 662 ms | $0.025 |
52
67
 
53
68
  ## Releasing
54
69
 
@@ -21,11 +21,24 @@ export type DecisionErrorKind = 'state-too-large'
21
21
  /** No answer within `timeoutMs`. */
22
22
  | 'timeout'
23
23
  /** The call's own signal (or `defaultSignal`) aborted it. */
24
- | 'aborted' | 'request-failed' | 'unreadable-response' | 'answer-mismatch' | 'usage-failed';
24
+ | 'aborted'
25
+ /** 401 or 403: the provider rejected the key. Retrying will not help. */
26
+ | 'unauthorized'
27
+ /** 402: the provider's account is out of credits. Retrying will not help. */
28
+ | 'out-of-credits'
29
+ /** 429 that outlasted the retry, or asked to wait longer than a call can. */
30
+ | 'rate-limited'
31
+ /** Any other refused request (see `status`), or a transport error (no `status`). */
32
+ | 'request-failed' | 'unreadable-response' | 'answer-mismatch' | 'usage-failed';
25
33
  export declare class DecisionError extends Error {
26
34
  readonly kind: DecisionErrorKind;
27
35
  readonly model: DecisionModel;
28
- constructor(kind: DecisionErrorKind, model: DecisionModel, message: string);
36
+ /** The HTTP status, when the provider answered. */
37
+ readonly status?: number | undefined;
38
+ readonly provider: DecisionProvider;
39
+ constructor(kind: DecisionErrorKind, model: DecisionModel, message: string,
40
+ /** The HTTP status, when the provider answered. */
41
+ status?: number | undefined);
29
42
  }
30
43
  /** Sends one request. Replace it to route calls through your own budget or proxy. */
31
44
  export type DecisionFetch = (request: {
@@ -24,13 +24,26 @@ const MAX_RETRY_DELAY_MS = 2_000;
24
24
  export class DecisionError extends Error {
25
25
  kind;
26
26
  model;
27
- constructor(kind, model, message) {
27
+ status;
28
+ provider;
29
+ constructor(kind, model, message,
30
+ /** The HTTP status, when the provider answered. */
31
+ status) {
28
32
  super(message);
29
33
  this.kind = kind;
30
34
  this.model = model;
35
+ this.status = status;
31
36
  this.name = 'DecisionError';
37
+ this.provider = DECISION_MODELS[model].provider;
32
38
  }
33
39
  }
40
+ /** The kind a refused response maps to; anything unlisted is `request-failed`. */
41
+ const KIND_BY_STATUS = {
42
+ 401: 'unauthorized',
43
+ 402: 'out-of-credits',
44
+ 403: 'unauthorized',
45
+ 429: 'rate-limited'
46
+ };
34
47
  export function createDecisionApi(options) {
35
48
  const send = options.fetch ?? ((request) => fetch(request.url, request.init));
36
49
  const maxStateChars = options.maxStateChars ?? DECISION_MAX_STATE_CHARS;
@@ -40,10 +53,11 @@ export function createDecisionApi(options) {
40
53
  const model = requestedModel ?? options.defaultModel;
41
54
  if (!model)
42
55
  throw new Error('No model given and no defaultModel set');
43
- const fail = (kind, message) => {
44
- options.onError?.(new DecisionError(kind, model, message), context);
56
+ const report = (error) => {
57
+ options.onError?.(error, context);
45
58
  return null;
46
59
  };
60
+ const fail = (kind, message) => report(new DecisionError(kind, model, message));
47
61
  if (Object.keys(questions).length === 0) {
48
62
  return {};
49
63
  }
@@ -65,7 +79,7 @@ export function createDecisionApi(options) {
65
79
  }
66
80
  catch (error) {
67
81
  if (error instanceof DecisionError)
68
- return fail(error.kind, error.message);
82
+ return report(error);
69
83
  return fail('request-failed', messageOf(error));
70
84
  }
71
85
  let json;
@@ -145,7 +159,7 @@ async function postWithRetry(input) {
145
159
  !RETRYABLE_STATUSES.has(response.status) ||
146
160
  delayMs > MAX_RETRY_DELAY_MS) {
147
161
  const body = (await response.text()).slice(0, 200);
148
- throw failure('request-failed', `${input.model} ${response.status}: ${body}`);
162
+ throw new DecisionError(KIND_BY_STATUS[response.status] ?? 'request-failed', input.model, `${input.model} ${response.status}: ${body}`, response.status);
149
163
  }
150
164
  // An unread body holds its connection until garbage collection.
151
165
  await response.body?.cancel().catch(() => { });
package/dist/models.d.ts CHANGED
@@ -8,6 +8,8 @@ export type DecisionProviderKeys = {
8
8
  accountId: string;
9
9
  };
10
10
  openai?: string;
11
+ /** One key for every model OpenRouter hosts (the `vendor/model` names). */
12
+ openrouter?: string;
11
13
  };
12
14
  export type DecisionProvider = keyof DecisionProviderKeys;
13
15
  /**
@@ -33,6 +35,14 @@ export declare const DECISION_MODELS: {
33
35
  readonly provider: "openai";
34
36
  readonly inputUsdPerMTok: 0.1;
35
37
  };
38
+ readonly 'microsoft/microsoft-decision-1': {
39
+ readonly provider: "openrouter";
40
+ readonly inputUsdPerMTok: 0.042;
41
+ };
42
+ readonly 'typesafe/jev-1.13': {
43
+ readonly provider: "openrouter";
44
+ readonly inputUsdPerMTok: 0.042;
45
+ };
36
46
  };
37
47
  export type DecisionModel = keyof typeof DECISION_MODELS;
38
48
  type ModelsOf<Provider> = {
package/dist/models.js CHANGED
@@ -13,7 +13,15 @@ export const DECISION_MODELS = {
13
13
  jev: { provider: 'typesafe', inputUsdPerMTok: 0.042 },
14
14
  clef: { provider: 'cloudflare', inputUsdPerMTok: 0.24 },
15
15
  'clef-flash': { provider: 'cloudflare', inputUsdPerMTok: 0.09 },
16
- 'gpt-6-luna': { provider: 'openai', inputUsdPerMTok: 0.1 }
16
+ 'gpt-6-luna': { provider: 'openai', inputUsdPerMTok: 0.1 },
17
+ // OpenRouter models are named by their OpenRouter slug, so adding one is
18
+ // one line here: https://openrouter.ai/rankings/decisions
19
+ 'microsoft/microsoft-decision-1': {
20
+ provider: 'openrouter',
21
+ inputUsdPerMTok: 0.042
22
+ },
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 }
17
25
  };
18
26
  /** One POST: where it goes, and the headers and body it carries. */
19
27
  export function buildDecisionRequest(model, input, keys) {
@@ -40,6 +48,14 @@ export function buildDecisionRequest(model, input, keys) {
40
48
  body: JSON.stringify({ ...input, model })
41
49
  };
42
50
  }
51
+ if (provider === 'openrouter') {
52
+ // https://openrouter.ai/docs/api/api-reference/alphadecisions/submit-a-decisions-request
53
+ return {
54
+ url: 'https://openrouter.ai/api/alpha/decisions',
55
+ headers: headers(keys.openrouter),
56
+ body: JSON.stringify({ ...input, model })
57
+ };
58
+ }
43
59
  return {
44
60
  url: 'https://api.openai.com/v1/decisions',
45
61
  headers: headers(keys.openai),
@@ -152,6 +168,8 @@ function fromOpenAiResponse(response) {
152
168
  }
153
169
  const responseSchemas = {
154
170
  typesafe: DecisionResponseSchema,
171
+ // OpenRouter's Decisions API speaks System One's shape for every model.
172
+ openrouter: DecisionResponseSchema,
155
173
  // Workers AI wraps every model's output in its own envelope.
156
174
  cloudflare: z
157
175
  .object({ result: DecisionResponseSchema })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "decision-api",
3
- "version": "0.0.0",
4
- "description": "One typed client for decision models (TypeSafe Jev, Cloudflare Clef, OpenAI gpt-6-luna) that answer questions with calibrated probabilities",
3
+ "version": "1.2.0",
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",
7
7
  "repository": {
@@ -36,7 +36,8 @@
36
36
  "zod",
37
37
  "openai",
38
38
  "cloudflare",
39
- "typesafe"
39
+ "typesafe",
40
+ "openrouter"
40
41
  ],
41
42
  "scripts": {
42
43
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
@@ -46,6 +47,7 @@
46
47
  "lint:write": "bun run typecheck && biome check --write",
47
48
  "prepublishOnly": "bun run build",
48
49
  "verify:package": "node scripts/verify-package.mjs",
50
+ "bench": "bun bench/run.ts",
49
51
  "prepare": "husky"
50
52
  },
51
53
  "dependencies": {