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 +19 -4
- package/dist/create-decision-api.d.ts +15 -2
- package/dist/create-decision-api.js +19 -5
- package/dist/models.d.ts +10 -0
- package/dist/models.js +19 -1
- package/package.json +5 -3
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)
|
|
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
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
44
|
-
options.onError?.(
|
|
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
|
|
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
|
|
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": "
|
|
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": {
|