decision-api 0.0.0-stage → 1.1.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/LICENSE +21 -0
- package/README.md +73 -2
- package/dist/create-decision-api.d.ts +94 -0
- package/dist/create-decision-api.js +219 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/models.d.ts +85 -0
- package/dist/models.js +176 -0
- package/dist/questions.d.ts +93 -0
- package/dist/questions.js +51 -0
- package/package.json +69 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kristoffer Hansen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,74 @@
|
|
|
1
|
-
#
|
|
1
|
+
# decision-api
|
|
2
2
|
|
|
3
|
-
|
|
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`.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install decision-api
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { createDecisionApi } from 'decision-api'
|
|
11
|
+
|
|
12
|
+
const decide = createDecisionApi({
|
|
13
|
+
keys: {
|
|
14
|
+
typesafe: '…',
|
|
15
|
+
cloudflare: { apiToken: '…', accountId: '…' },
|
|
16
|
+
openai: '…',
|
|
17
|
+
openrouter: '…' // every OpenRouter model, named by its slug
|
|
18
|
+
},
|
|
19
|
+
defaultModel: 'jev',
|
|
20
|
+
registerUsage: (usage, context: { customerId: string }) => {
|
|
21
|
+
// usage: model, provider, inputTokens, outputTokens, costUsd, latencyMs
|
|
22
|
+
},
|
|
23
|
+
onError: (error, context) => console.warn(error.kind, error.message)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const answers = await decide(
|
|
27
|
+
{
|
|
28
|
+
state: { message: 'I was charged twice' },
|
|
29
|
+
questions: {
|
|
30
|
+
refund: { type: 'noul', instructions: 'Does the customer want money back?' },
|
|
31
|
+
team: {
|
|
32
|
+
type: 'choice',
|
|
33
|
+
instructions: 'Which team handles this?',
|
|
34
|
+
criteria: { billing: 'Payments and refunds', other: 'Anything else' }
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
customerId: 'c_1'
|
|
38
|
+
},
|
|
39
|
+
'gpt-6-luna' // optional when defaultModel is set
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
answers?.refund.noul // number
|
|
43
|
+
answers?.team.choice // 'billing' | 'other'
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
TypeScript enforces the setup:
|
|
47
|
+
|
|
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`.
|
|
49
|
+
- **The model is optional only with a default.** Without `defaultModel`, every call must name one.
|
|
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
|
+
|
|
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 |
|
|
67
|
+
|
|
68
|
+
## Releasing
|
|
69
|
+
|
|
70
|
+
Squash PRs with conventional titles (`fix:`, `feat:`, or `feat!:`). Release Please keeps the version and changelog in a release PR; merge that PR to publish with release notes and npm provenance. See [release and recovery instructions](docs/releasing.md).
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
[MIT](LICENSE).
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { type DecisionModel, type DecisionProvider, type DecisionProviderKeys, type ModelsFor } from './models.ts';
|
|
2
|
+
import { type DecisionAnswers, type DecisionQuestion } from './questions.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Jev answers `400 max_tokens_exceeded` past roughly 32.5k input tokens (96k
|
|
5
|
+
* chars passed, 128k did not). A state over this is refused before it leaves
|
|
6
|
+
* the process; keep well under the wall, since tokenization varies. Clef takes
|
|
7
|
+
* 64k tokens but silently truncates long state, so the same guard keeps it
|
|
8
|
+
* from judging half a corpus.
|
|
9
|
+
*/
|
|
10
|
+
export declare const DECISION_MAX_STATE_CHARS = 90000;
|
|
11
|
+
/** What one answered call cost; `costUsd` is priced from `DECISION_MODELS`. */
|
|
12
|
+
export type DecisionUsage<Model extends DecisionModel = DecisionModel> = {
|
|
13
|
+
model: Model;
|
|
14
|
+
provider: DecisionProvider;
|
|
15
|
+
inputTokens: number;
|
|
16
|
+
outputTokens: number;
|
|
17
|
+
costUsd: number;
|
|
18
|
+
latencyMs: number;
|
|
19
|
+
};
|
|
20
|
+
export type DecisionErrorKind = 'state-too-large'
|
|
21
|
+
/** No answer within `timeoutMs`. */
|
|
22
|
+
| 'timeout'
|
|
23
|
+
/** The call's own signal (or `defaultSignal`) aborted it. */
|
|
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';
|
|
33
|
+
export declare class DecisionError extends Error {
|
|
34
|
+
readonly kind: DecisionErrorKind;
|
|
35
|
+
readonly model: DecisionModel;
|
|
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);
|
|
42
|
+
}
|
|
43
|
+
/** Sends one request. Replace it to route calls through your own budget or proxy. */
|
|
44
|
+
export type DecisionFetch = (request: {
|
|
45
|
+
url: string;
|
|
46
|
+
init: RequestInit;
|
|
47
|
+
}, info: {
|
|
48
|
+
model: DecisionModel;
|
|
49
|
+
provider: DecisionProvider;
|
|
50
|
+
attempt: number;
|
|
51
|
+
/** The same signal `init` carries: the call's own, plus its timeout. */
|
|
52
|
+
signal: AbortSignal;
|
|
53
|
+
}) => 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;
|
|
57
|
+
questions: Q;
|
|
58
|
+
signal?: AbortSignal;
|
|
59
|
+
timeoutMs?: number;
|
|
60
|
+
};
|
|
61
|
+
type DecisionApiOptions<Keys extends DecisionProviderKeys, Default, Context> = {
|
|
62
|
+
keys: Keys;
|
|
63
|
+
/** Lets a call leave out its model. */
|
|
64
|
+
defaultModel?: Default;
|
|
65
|
+
/**
|
|
66
|
+
* Called after every answered call. Whatever its second parameter is typed
|
|
67
|
+
* as becomes required on every call, so usage can be attributed.
|
|
68
|
+
*/
|
|
69
|
+
registerUsage?: (usage: DecisionUsage<ModelsFor<Keys>>, context: Context) => unknown;
|
|
70
|
+
/** Every reason a call answered `null`. */
|
|
71
|
+
onError?: (error: DecisionError, context: Context) => void;
|
|
72
|
+
/** The signal of a call that passes none. */
|
|
73
|
+
defaultSignal?: () => AbortSignal | undefined;
|
|
74
|
+
fetch?: DecisionFetch;
|
|
75
|
+
timeoutMs?: number;
|
|
76
|
+
maxStateChars?: number;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Ask one or more independent judgments about the same state.
|
|
80
|
+
*
|
|
81
|
+
* **Send every independent question in ONE call**: questions are evaluated
|
|
82
|
+
* together, so extra ones are close to free, and latency tracks the size of
|
|
83
|
+
* `state`, not the number of questions.
|
|
84
|
+
*
|
|
85
|
+
* **Answers `null` rather than throwing** - on a timeout, an abort, a
|
|
86
|
+
* transport error, a rejected request or an answer that does not match its
|
|
87
|
+
* question; `onError` says which. The right fallback belongs to the caller: a
|
|
88
|
+
* tagging job treats `null` as "no tags this time", a policy guard as "refuse".
|
|
89
|
+
* It throws only when there is no model at all - a setup bug TypeScript
|
|
90
|
+
* already rejects, which a silent `null` would hide.
|
|
91
|
+
*/
|
|
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>;
|
|
93
|
+
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
|
+
export {};
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { buildDecisionRequest, DECISION_MODELS, readDecisionResponse } from "./models.js";
|
|
2
|
+
import { matchesQuestion } from "./questions.js";
|
|
3
|
+
/**
|
|
4
|
+
* Jev answers `400 max_tokens_exceeded` past roughly 32.5k input tokens (96k
|
|
5
|
+
* chars passed, 128k did not). A state over this is refused before it leaves
|
|
6
|
+
* the process; keep well under the wall, since tokenization varies. Clef takes
|
|
7
|
+
* 64k tokens but silently truncates long state, so the same guard keeps it
|
|
8
|
+
* from judging half a corpus.
|
|
9
|
+
*/
|
|
10
|
+
export const DECISION_MAX_STATE_CHARS = 90_000;
|
|
11
|
+
/**
|
|
12
|
+
* A generous ceiling rather than a target (Jev's p90 is ~280 ms for ten
|
|
13
|
+
* questions): past it the caller is better served by its own fallback.
|
|
14
|
+
*/
|
|
15
|
+
const DEFAULT_TIMEOUT_MS = 4_000;
|
|
16
|
+
/** Retried once: the providers' own guidance for these is to back off and retry. */
|
|
17
|
+
const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 529]);
|
|
18
|
+
const RETRY_DELAY_MS = 250;
|
|
19
|
+
/**
|
|
20
|
+
* A provider asking for a longer pause (`Retry-After`) will not be ready within
|
|
21
|
+
* this call's budget: failing now beats paying for a doomed retry.
|
|
22
|
+
*/
|
|
23
|
+
const MAX_RETRY_DELAY_MS = 2_000;
|
|
24
|
+
export class DecisionError extends Error {
|
|
25
|
+
kind;
|
|
26
|
+
model;
|
|
27
|
+
status;
|
|
28
|
+
provider;
|
|
29
|
+
constructor(kind, model, message,
|
|
30
|
+
/** The HTTP status, when the provider answered. */
|
|
31
|
+
status) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.kind = kind;
|
|
34
|
+
this.model = model;
|
|
35
|
+
this.status = status;
|
|
36
|
+
this.name = 'DecisionError';
|
|
37
|
+
this.provider = DECISION_MODELS[model].provider;
|
|
38
|
+
}
|
|
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
|
+
};
|
|
47
|
+
export function createDecisionApi(options) {
|
|
48
|
+
const send = options.fetch ?? ((request) => fetch(request.url, request.init));
|
|
49
|
+
const maxStateChars = options.maxStateChars ?? DECISION_MAX_STATE_CHARS;
|
|
50
|
+
return async (input, ...[requestedModel]) => {
|
|
51
|
+
const { state, questions, signal, timeoutMs, ...rest } = input;
|
|
52
|
+
const context = rest;
|
|
53
|
+
const model = requestedModel ?? options.defaultModel;
|
|
54
|
+
if (!model)
|
|
55
|
+
throw new Error('No model given and no defaultModel set');
|
|
56
|
+
const report = (error) => {
|
|
57
|
+
options.onError?.(error, context);
|
|
58
|
+
return null;
|
|
59
|
+
};
|
|
60
|
+
const fail = (kind, message) => report(new DecisionError(kind, model, message));
|
|
61
|
+
if (Object.keys(questions).length === 0) {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
const stateText = typeof state === 'string' ? state : JSON.stringify(state);
|
|
65
|
+
const stateChars = stateText.length;
|
|
66
|
+
if (stateChars > maxStateChars) {
|
|
67
|
+
return fail('state-too-large', `State is ${stateChars} chars, over ${maxStateChars}`);
|
|
68
|
+
}
|
|
69
|
+
const startedAt = Date.now();
|
|
70
|
+
let text;
|
|
71
|
+
try {
|
|
72
|
+
text = await postWithRetry({
|
|
73
|
+
send,
|
|
74
|
+
model,
|
|
75
|
+
request: buildDecisionRequest(model, { state, questions }, options.keys),
|
|
76
|
+
signal: signal ?? options.defaultSignal?.(),
|
|
77
|
+
timeoutMs: timeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error instanceof DecisionError)
|
|
82
|
+
return report(error);
|
|
83
|
+
return fail('request-failed', messageOf(error));
|
|
84
|
+
}
|
|
85
|
+
let json;
|
|
86
|
+
try {
|
|
87
|
+
json = JSON.parse(text);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
return fail('unreadable-response', messageOf(error));
|
|
91
|
+
}
|
|
92
|
+
const parsed = readDecisionResponse(model, json);
|
|
93
|
+
if (!parsed.success) {
|
|
94
|
+
return fail('unreadable-response', parsed.error.issues[0]?.message ?? 'unknown');
|
|
95
|
+
}
|
|
96
|
+
const { usage, answers } = parsed.data;
|
|
97
|
+
reportUsage({
|
|
98
|
+
model,
|
|
99
|
+
provider: DECISION_MODELS[model].provider,
|
|
100
|
+
inputTokens: usage.input_tokens,
|
|
101
|
+
outputTokens: usage.output_tokens,
|
|
102
|
+
costUsd: (usage.input_tokens * DECISION_MODELS[model].inputUsdPerMTok) /
|
|
103
|
+
1_000_000,
|
|
104
|
+
latencyMs: Date.now() - startedAt
|
|
105
|
+
}, context);
|
|
106
|
+
for (const [key, question] of Object.entries(questions)) {
|
|
107
|
+
if (!matchesQuestion(question, answers[key])) {
|
|
108
|
+
return fail('answer-mismatch', `Answer to "${key}" does not fit it`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Every requested key, kind and option set was validated above.
|
|
112
|
+
return answers;
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Only a call that reached the provider is reported - a state over the
|
|
116
|
+
* wall, a timeout or an unreadable body reports nothing, so a 0 ms row can
|
|
117
|
+
* never flatter a latency percentile. A failing hook never fails the call.
|
|
118
|
+
*/
|
|
119
|
+
function reportUsage(usage, context) {
|
|
120
|
+
const { registerUsage } = options;
|
|
121
|
+
if (!registerUsage)
|
|
122
|
+
return;
|
|
123
|
+
if (usage.inputTokens <= 0 && usage.outputTokens <= 0)
|
|
124
|
+
return;
|
|
125
|
+
// One path for a hook that throws and one that rejects.
|
|
126
|
+
Promise.resolve()
|
|
127
|
+
.then(() => registerUsage(usage, context))
|
|
128
|
+
.catch((error) => {
|
|
129
|
+
const failure = new DecisionError('usage-failed', usage.model, messageOf(error));
|
|
130
|
+
options.onError?.(failure, context);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** The body of the first OK response, read under the same timeout as the request. */
|
|
135
|
+
async function postWithRetry(input) {
|
|
136
|
+
const provider = DECISION_MODELS[input.model].provider;
|
|
137
|
+
const failure = (kind, message) => new DecisionError(kind, input.model, message);
|
|
138
|
+
for (let attempt = 0;; attempt++) {
|
|
139
|
+
if (input.signal?.aborted)
|
|
140
|
+
throw failure('aborted', 'Aborted by the caller');
|
|
141
|
+
// A fresh timeout per attempt, so a retry gets its own budget.
|
|
142
|
+
const timeout = AbortSignal.timeout(input.timeoutMs);
|
|
143
|
+
const { signal, release } = linkSignals(timeout, input.signal);
|
|
144
|
+
try {
|
|
145
|
+
const response = await input.send({
|
|
146
|
+
url: input.request.url,
|
|
147
|
+
init: {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: input.request.headers,
|
|
150
|
+
body: input.request.body,
|
|
151
|
+
// React Native's fetch types declare an AbortSignal of their own.
|
|
152
|
+
signal: signal
|
|
153
|
+
}
|
|
154
|
+
}, { model: input.model, provider, attempt, signal });
|
|
155
|
+
if (response.ok)
|
|
156
|
+
return await response.text();
|
|
157
|
+
const delayMs = retryDelayMs(response);
|
|
158
|
+
if (attempt > 0 ||
|
|
159
|
+
!RETRYABLE_STATUSES.has(response.status) ||
|
|
160
|
+
delayMs > MAX_RETRY_DELAY_MS) {
|
|
161
|
+
const body = (await response.text()).slice(0, 200);
|
|
162
|
+
throw new DecisionError(KIND_BY_STATUS[response.status] ?? 'request-failed', input.model, `${input.model} ${response.status}: ${body}`, response.status);
|
|
163
|
+
}
|
|
164
|
+
// An unread body holds its connection until garbage collection.
|
|
165
|
+
await response.body?.cancel().catch(() => { });
|
|
166
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (error instanceof DecisionError)
|
|
170
|
+
throw error;
|
|
171
|
+
if (input.signal?.aborted)
|
|
172
|
+
throw failure('aborted', messageOf(error));
|
|
173
|
+
if (timeout.aborted) {
|
|
174
|
+
throw failure('timeout', `No answer within ${input.timeoutMs} ms`);
|
|
175
|
+
}
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
release();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** The pause a response asks for, in seconds or as a date; our own without one. */
|
|
184
|
+
function retryDelayMs(response) {
|
|
185
|
+
const header = response.headers.get('retry-after')?.trim();
|
|
186
|
+
if (!header)
|
|
187
|
+
return RETRY_DELAY_MS;
|
|
188
|
+
const seconds = Number(header);
|
|
189
|
+
const ms = Number.isFinite(seconds)
|
|
190
|
+
? seconds * 1000
|
|
191
|
+
: Date.parse(header) - Date.now();
|
|
192
|
+
return Number.isNaN(ms) ? RETRY_DELAY_MS : Math.max(0, ms);
|
|
193
|
+
}
|
|
194
|
+
function messageOf(error) {
|
|
195
|
+
return error instanceof Error ? error.message : String(error);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The timeout plus the caller's signal, whichever fires first. Combined by hand
|
|
199
|
+
* rather than with `AbortSignal.any`, which older DOM libs (React Native's
|
|
200
|
+
* among them) do not declare. `release` unhooks the caller's signal, which may
|
|
201
|
+
* outlive every call made with it.
|
|
202
|
+
*/
|
|
203
|
+
function linkSignals(timeout, signal) {
|
|
204
|
+
if (!signal)
|
|
205
|
+
return { signal: timeout, release: () => { } };
|
|
206
|
+
const controller = new AbortController();
|
|
207
|
+
const abort = () => controller.abort();
|
|
208
|
+
if (timeout.aborted || signal.aborted)
|
|
209
|
+
abort();
|
|
210
|
+
timeout.addEventListener('abort', abort, { once: true });
|
|
211
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
212
|
+
return {
|
|
213
|
+
signal: controller.signal,
|
|
214
|
+
release: () => {
|
|
215
|
+
timeout.removeEventListener('abort', abort);
|
|
216
|
+
signal.removeEventListener('abort', abort);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
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';
|
|
3
|
+
export type { DecisionAnswers, DecisionChoice, DecisionNoul, DecisionQuestion, DecisionScore } from './questions.ts';
|
package/dist/index.js
ADDED
package/dist/models.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { type DecisionQuestion } from './questions.ts';
|
|
3
|
+
/** The credentials each provider needs; pass only the providers you use. */
|
|
4
|
+
export type DecisionProviderKeys = {
|
|
5
|
+
typesafe?: string;
|
|
6
|
+
cloudflare?: {
|
|
7
|
+
apiToken: string;
|
|
8
|
+
accountId: string;
|
|
9
|
+
};
|
|
10
|
+
openai?: string;
|
|
11
|
+
/** One key for every model OpenRouter hosts (the `vendor/model` names). */
|
|
12
|
+
openrouter?: string;
|
|
13
|
+
};
|
|
14
|
+
export type DecisionProvider = keyof DecisionProviderKeys;
|
|
15
|
+
/**
|
|
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).
|
|
20
|
+
*/
|
|
21
|
+
export declare const DECISION_MODELS: {
|
|
22
|
+
readonly jev: {
|
|
23
|
+
readonly provider: "typesafe";
|
|
24
|
+
readonly inputUsdPerMTok: 0.042;
|
|
25
|
+
};
|
|
26
|
+
readonly clef: {
|
|
27
|
+
readonly provider: "cloudflare";
|
|
28
|
+
readonly inputUsdPerMTok: 0.24;
|
|
29
|
+
};
|
|
30
|
+
readonly 'clef-flash': {
|
|
31
|
+
readonly provider: "cloudflare";
|
|
32
|
+
readonly inputUsdPerMTok: 0.09;
|
|
33
|
+
};
|
|
34
|
+
readonly 'gpt-6-luna': {
|
|
35
|
+
readonly provider: "openai";
|
|
36
|
+
readonly inputUsdPerMTok: 0.1;
|
|
37
|
+
};
|
|
38
|
+
readonly 'microsoft/microsoft-decision-1': {
|
|
39
|
+
readonly provider: "openrouter";
|
|
40
|
+
readonly inputUsdPerMTok: 0.042;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
export type DecisionModel = keyof typeof DECISION_MODELS;
|
|
44
|
+
type ModelsOf<Provider> = {
|
|
45
|
+
[Model in DecisionModel]: (typeof DECISION_MODELS)[Model]['provider'] extends Provider ? Model : never;
|
|
46
|
+
}[DecisionModel];
|
|
47
|
+
/** The models a set of keys can call: a provider counts only when its key is surely there. */
|
|
48
|
+
export type ModelsFor<Keys extends DecisionProviderKeys> = ModelsOf<{
|
|
49
|
+
[Provider in keyof Keys]-?: undefined extends Keys[Provider] ? never : Provider;
|
|
50
|
+
}[keyof Keys]>;
|
|
51
|
+
type RequestInput = {
|
|
52
|
+
state: unknown;
|
|
53
|
+
questions: Record<string, DecisionQuestion>;
|
|
54
|
+
};
|
|
55
|
+
/** One POST: where it goes, and the headers and body it carries. */
|
|
56
|
+
export declare function buildDecisionRequest(model: DecisionModel, input: RequestInput, keys: DecisionProviderKeys): {
|
|
57
|
+
url: string;
|
|
58
|
+
headers: {
|
|
59
|
+
authorization: string;
|
|
60
|
+
'content-type': string;
|
|
61
|
+
};
|
|
62
|
+
body: string;
|
|
63
|
+
};
|
|
64
|
+
/** The provider's response read into the shared shape, unvalidated against the questions. */
|
|
65
|
+
export declare function readDecisionResponse(model: DecisionModel, json: unknown): z.ZodSafeParseResult<{
|
|
66
|
+
answers: Record<string, {
|
|
67
|
+
type: "noul";
|
|
68
|
+
noul: number;
|
|
69
|
+
} | {
|
|
70
|
+
type: "choice";
|
|
71
|
+
choice: string;
|
|
72
|
+
confidence: number;
|
|
73
|
+
probabilities: Record<string, number>;
|
|
74
|
+
} | {
|
|
75
|
+
type: "score";
|
|
76
|
+
score: number;
|
|
77
|
+
confidence: number;
|
|
78
|
+
probabilities: Record<string, number>;
|
|
79
|
+
}>;
|
|
80
|
+
usage: {
|
|
81
|
+
input_tokens: number;
|
|
82
|
+
output_tokens: number;
|
|
83
|
+
};
|
|
84
|
+
}>;
|
|
85
|
+
export {};
|
package/dist/models.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Every decision model, the provider that serves it, what it costs, and how
|
|
2
|
+
// that provider's wire format maps onto the shared question shapes
|
|
3
|
+
// (`questions.ts`).
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { DecisionResponseSchema } from "./questions.js";
|
|
6
|
+
/**
|
|
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).
|
|
11
|
+
*/
|
|
12
|
+
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 },
|
|
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
|
+
};
|
|
24
|
+
/** One POST: where it goes, and the headers and body it carries. */
|
|
25
|
+
export function buildDecisionRequest(model, input, keys) {
|
|
26
|
+
const provider = DECISION_MODELS[model].provider;
|
|
27
|
+
const headers = (token) => {
|
|
28
|
+
if (!token)
|
|
29
|
+
throw new Error(`No ${provider} key for ${model}`);
|
|
30
|
+
return {
|
|
31
|
+
authorization: `Bearer ${token}`,
|
|
32
|
+
'content-type': 'application/json'
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
if (provider === 'typesafe') {
|
|
36
|
+
return {
|
|
37
|
+
url: 'https://api.typesafe.ai/v1/systemone',
|
|
38
|
+
headers: headers(keys.typesafe),
|
|
39
|
+
body: JSON.stringify({ ...input, model: 'jev-latest' })
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (provider === 'cloudflare') {
|
|
43
|
+
return {
|
|
44
|
+
url: `https://api.cloudflare.com/client/v4/accounts/${keys.cloudflare?.accountId}/ai/run/@cf/cloudflare/${model}`,
|
|
45
|
+
headers: headers(keys.cloudflare?.apiToken),
|
|
46
|
+
body: JSON.stringify({ ...input, model })
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (provider === 'openrouter') {
|
|
50
|
+
// https://openrouter.ai/docs/api/api-reference/alphadecisions/submit-a-decisions-request
|
|
51
|
+
return {
|
|
52
|
+
url: 'https://openrouter.ai/api/alpha/decisions',
|
|
53
|
+
headers: headers(keys.openrouter),
|
|
54
|
+
body: JSON.stringify({ ...input, model })
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
url: 'https://api.openai.com/v1/decisions',
|
|
59
|
+
headers: headers(keys.openai),
|
|
60
|
+
body: JSON.stringify(toOpenAiRequest(model, input))
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** The provider's response read into the shared shape, unvalidated against the questions. */
|
|
64
|
+
export function readDecisionResponse(model, json) {
|
|
65
|
+
return responseSchemas[DECISION_MODELS[model].provider].safeParse(json);
|
|
66
|
+
}
|
|
67
|
+
// ───────────────────────────── OpenAI Decisions ─────────────────────────────
|
|
68
|
+
// https://developers.openai.com/api/docs/guides/decisions - the same three
|
|
69
|
+
// question kinds under other names: `predicate` for noul, choices and levels as
|
|
70
|
+
// arrays of objects, answers as an array keyed by `name`.
|
|
71
|
+
function toOpenAiQuestion(name, question) {
|
|
72
|
+
if (question.type === 'noul') {
|
|
73
|
+
// No criteria field on a predicate: the gloss rides in the instructions.
|
|
74
|
+
const gloss = question.criteria
|
|
75
|
+
? `\nTrue means: ${question.criteria.true}\nFalse means: ${question.criteria.false}`
|
|
76
|
+
: '';
|
|
77
|
+
return {
|
|
78
|
+
type: 'predicate',
|
|
79
|
+
name,
|
|
80
|
+
instructions: `${question.instructions}${gloss}`
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (question.type === 'choice') {
|
|
84
|
+
return {
|
|
85
|
+
type: 'choice',
|
|
86
|
+
name,
|
|
87
|
+
instructions: question.instructions,
|
|
88
|
+
choices: Object.entries(question.criteria).map(([value, description]) => ({ value, description }))
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
type: 'score',
|
|
93
|
+
name,
|
|
94
|
+
instructions: question.instructions,
|
|
95
|
+
levels: question.criteria.map((description, index) => ({
|
|
96
|
+
label: String(index),
|
|
97
|
+
description
|
|
98
|
+
}))
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function toOpenAiRequest(model, input) {
|
|
102
|
+
return {
|
|
103
|
+
model,
|
|
104
|
+
input: typeof input.state === 'string'
|
|
105
|
+
? input.state
|
|
106
|
+
: JSON.stringify(input.state),
|
|
107
|
+
questions: Object.entries(input.questions).map(([name, question]) => toOpenAiQuestion(name, question))
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const OpenAiProbabilitiesSchema = z.array(z.object({
|
|
111
|
+
value: z.union([z.string(), z.number()]),
|
|
112
|
+
probability: z.number()
|
|
113
|
+
}));
|
|
114
|
+
const OpenAiResponseSchema = z.object({
|
|
115
|
+
answers: z.array(z.discriminatedUnion('type', [
|
|
116
|
+
z.object({
|
|
117
|
+
type: z.literal('predicate'),
|
|
118
|
+
name: z.string(),
|
|
119
|
+
probability: z.number()
|
|
120
|
+
}),
|
|
121
|
+
z.object({
|
|
122
|
+
type: z.literal(['choice', 'score']),
|
|
123
|
+
name: z.string(),
|
|
124
|
+
choice: z.string().optional(),
|
|
125
|
+
score: z.number().optional(),
|
|
126
|
+
confidence: z.number(),
|
|
127
|
+
probabilities: OpenAiProbabilitiesSchema
|
|
128
|
+
})
|
|
129
|
+
])),
|
|
130
|
+
usage: z.object({ input_tokens: z.number(), output_tokens: z.number() })
|
|
131
|
+
});
|
|
132
|
+
function fromOpenAiResponse(response) {
|
|
133
|
+
const answers = response.answers.map((answer) => {
|
|
134
|
+
if (answer.type === 'predicate') {
|
|
135
|
+
return [answer.name, { type: 'noul', noul: answer.probability }];
|
|
136
|
+
}
|
|
137
|
+
// Score levels come back as numeric indices; the shared shape keys them as strings.
|
|
138
|
+
const probabilities = Object.fromEntries(answer.probabilities.map((option) => [
|
|
139
|
+
String(option.value),
|
|
140
|
+
option.probability
|
|
141
|
+
]));
|
|
142
|
+
return [
|
|
143
|
+
answer.name,
|
|
144
|
+
answer.type === 'choice'
|
|
145
|
+
? {
|
|
146
|
+
type: 'choice',
|
|
147
|
+
choice: answer.choice,
|
|
148
|
+
confidence: answer.confidence,
|
|
149
|
+
probabilities
|
|
150
|
+
}
|
|
151
|
+
: {
|
|
152
|
+
type: 'score',
|
|
153
|
+
score: answer.score,
|
|
154
|
+
confidence: answer.confidence,
|
|
155
|
+
probabilities
|
|
156
|
+
}
|
|
157
|
+
];
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
answers: Object.fromEntries(answers),
|
|
161
|
+
usage: {
|
|
162
|
+
input_tokens: response.usage.input_tokens,
|
|
163
|
+
output_tokens: response.usage.output_tokens
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const responseSchemas = {
|
|
168
|
+
typesafe: DecisionResponseSchema,
|
|
169
|
+
// OpenRouter's Decisions API speaks System One's shape for every model.
|
|
170
|
+
openrouter: DecisionResponseSchema,
|
|
171
|
+
// Workers AI wraps every model's output in its own envelope.
|
|
172
|
+
cloudflare: z
|
|
173
|
+
.object({ result: DecisionResponseSchema })
|
|
174
|
+
.transform((response) => response.result),
|
|
175
|
+
openai: OpenAiResponseSchema.transform(fromOpenAiResponse).pipe(DecisionResponseSchema)
|
|
176
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/** Probability that the condition holds. No separate confidence: this IS it. */
|
|
3
|
+
export type DecisionNoul = {
|
|
4
|
+
type: 'noul';
|
|
5
|
+
instructions: string;
|
|
6
|
+
/** Optional, but a `true`/`false` gloss is what keeps a noul from drifting. */
|
|
7
|
+
criteria?: {
|
|
8
|
+
true: string;
|
|
9
|
+
false: string;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
/** One option from a defined set, with the distribution across all of them. */
|
|
13
|
+
export type DecisionChoice<Option extends string = string> = {
|
|
14
|
+
type: 'choice';
|
|
15
|
+
instructions: string;
|
|
16
|
+
/** Option -> what that option means. Include a no-match option when one may fit nothing. */
|
|
17
|
+
criteria: Record<Option, string>;
|
|
18
|
+
};
|
|
19
|
+
/** A position on ordered, concretely described levels. */
|
|
20
|
+
export type DecisionScore = {
|
|
21
|
+
type: 'score';
|
|
22
|
+
instructions: string;
|
|
23
|
+
/** Two or more levels, lowest first; each must stand on its own. */
|
|
24
|
+
criteria: string[];
|
|
25
|
+
};
|
|
26
|
+
export type DecisionQuestion = DecisionNoul | DecisionChoice | DecisionScore;
|
|
27
|
+
type DecisionNoulAnswer = {
|
|
28
|
+
type: 'noul';
|
|
29
|
+
noul: number;
|
|
30
|
+
};
|
|
31
|
+
type DecisionChoiceAnswer<Option extends string = string> = {
|
|
32
|
+
type: 'choice';
|
|
33
|
+
choice: Option;
|
|
34
|
+
confidence: number;
|
|
35
|
+
probabilities: Record<Option, number>;
|
|
36
|
+
};
|
|
37
|
+
type DecisionScoreAnswer = {
|
|
38
|
+
type: 'score';
|
|
39
|
+
score: number;
|
|
40
|
+
confidence: number;
|
|
41
|
+
probabilities: Record<string, number>;
|
|
42
|
+
};
|
|
43
|
+
/** The answer a given question shape produces, so call sites need no casts. */
|
|
44
|
+
type AnswerFor<Q> = Q extends {
|
|
45
|
+
type: 'noul';
|
|
46
|
+
} ? DecisionNoulAnswer : Q extends {
|
|
47
|
+
type: 'choice';
|
|
48
|
+
criteria: Record<infer Option extends string, string>;
|
|
49
|
+
} ? DecisionChoiceAnswer<Option> : Q extends {
|
|
50
|
+
type: 'score';
|
|
51
|
+
} ? DecisionScoreAnswer : never;
|
|
52
|
+
export type DecisionAnswers<Q extends Record<string, DecisionQuestion>> = {
|
|
53
|
+
[K in keyof Q]: AnswerFor<Q[K]>;
|
|
54
|
+
};
|
|
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
|
+
/** What every vendor's response is read into. */
|
|
70
|
+
export declare const DecisionResponseSchema: z.ZodObject<{
|
|
71
|
+
answers: z.ZodRecord<z.ZodString, z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
72
|
+
type: z.ZodLiteral<"noul">;
|
|
73
|
+
noul: z.ZodNumber;
|
|
74
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
75
|
+
type: z.ZodLiteral<"choice">;
|
|
76
|
+
choice: z.ZodString;
|
|
77
|
+
confidence: z.ZodNumber;
|
|
78
|
+
probabilities: z.ZodRecord<z.ZodString, z.ZodNumber>;
|
|
79
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
80
|
+
type: z.ZodLiteral<"score">;
|
|
81
|
+
score: z.ZodNumber;
|
|
82
|
+
confidence: z.ZodNumber;
|
|
83
|
+
probabilities: z.ZodRecord<z.ZodString, z.ZodNumber>;
|
|
84
|
+
}, z.core.$strip>], "type">>;
|
|
85
|
+
usage: z.ZodObject<{
|
|
86
|
+
input_tokens: z.ZodNumber;
|
|
87
|
+
output_tokens: z.ZodNumber;
|
|
88
|
+
}, z.core.$strip>;
|
|
89
|
+
}, z.core.$strip>;
|
|
90
|
+
export type DecisionResponse = z.infer<typeof DecisionResponseSchema>;
|
|
91
|
+
/** 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 {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The question and answer shapes every call speaks. They are System One's own
|
|
2
|
+
// (TypeSafe's Jev, Cloudflare's Clef); a provider with another wire format
|
|
3
|
+
// translates to and from them in `models.ts`.
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
const ProbabilitySchema = z.number().min(0).max(1);
|
|
6
|
+
const AnswerSchema = z.discriminatedUnion('type', [
|
|
7
|
+
z.object({ type: z.literal('noul'), noul: ProbabilitySchema }),
|
|
8
|
+
z.object({
|
|
9
|
+
type: z.literal('choice'),
|
|
10
|
+
choice: z.string(),
|
|
11
|
+
confidence: ProbabilitySchema,
|
|
12
|
+
probabilities: z.record(z.string(), ProbabilitySchema)
|
|
13
|
+
}),
|
|
14
|
+
z.object({
|
|
15
|
+
type: z.literal('score'),
|
|
16
|
+
score: z.number(),
|
|
17
|
+
confidence: ProbabilitySchema,
|
|
18
|
+
probabilities: z.record(z.string(), ProbabilitySchema)
|
|
19
|
+
})
|
|
20
|
+
]);
|
|
21
|
+
/** What every vendor's response is read into. */
|
|
22
|
+
export const DecisionResponseSchema = z.object({
|
|
23
|
+
answers: z.record(z.string(), AnswerSchema),
|
|
24
|
+
usage: z.object({
|
|
25
|
+
input_tokens: z.number().int().nonnegative(),
|
|
26
|
+
output_tokens: z.number().int().nonnegative()
|
|
27
|
+
})
|
|
28
|
+
});
|
|
29
|
+
/** The contract depends on the question, not just the answer's own tag. */
|
|
30
|
+
export function matchesQuestion(question, answer) {
|
|
31
|
+
if (!answer || answer.type !== question.type)
|
|
32
|
+
return false;
|
|
33
|
+
if (question.type === 'noul')
|
|
34
|
+
return true;
|
|
35
|
+
if (question.type === 'choice' && answer.type === 'choice') {
|
|
36
|
+
return (Object.hasOwn(question.criteria, answer.choice) &&
|
|
37
|
+
matchesOptions(Object.keys(question.criteria), answer.probabilities));
|
|
38
|
+
}
|
|
39
|
+
// Scores are fractional positions on zero-based rubric levels:
|
|
40
|
+
// https://docs.typesafe.ai/api#score-answer
|
|
41
|
+
if (question.type === 'score' && answer.type === 'score') {
|
|
42
|
+
return (answer.score >= 0 &&
|
|
43
|
+
answer.score <= question.criteria.length - 1 &&
|
|
44
|
+
matchesOptions(question.criteria.map((_, index) => String(index)), answer.probabilities));
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
function matchesOptions(options, probabilities) {
|
|
49
|
+
return (Object.keys(probabilities).length === options.length &&
|
|
50
|
+
options.every((option) => Object.hasOwn(probabilities, option)));
|
|
51
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "decision-api",
|
|
3
|
+
"version": "1.1.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
|
+
"license": "MIT",
|
|
6
|
+
"author": "Kristoffer Hansen",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/HansKristoffer/decision-api.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/HansKristoffer/decision-api#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/HansKristoffer/decision-api/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"default": "./dist/index.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"!dist/**/*.test.*"
|
|
30
|
+
],
|
|
31
|
+
"sideEffects": false,
|
|
32
|
+
"keywords": [
|
|
33
|
+
"llm",
|
|
34
|
+
"classification",
|
|
35
|
+
"decision",
|
|
36
|
+
"zod",
|
|
37
|
+
"openai",
|
|
38
|
+
"cloudflare",
|
|
39
|
+
"typesafe",
|
|
40
|
+
"openrouter"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"test": "bun test",
|
|
46
|
+
"lint": "bun run typecheck && biome check",
|
|
47
|
+
"lint:write": "bun run typecheck && biome check --write",
|
|
48
|
+
"prepublishOnly": "bun run build",
|
|
49
|
+
"verify:package": "node scripts/verify-package.mjs",
|
|
50
|
+
"bench": "bun bench/run.ts",
|
|
51
|
+
"prepare": "husky"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"zod": "^4.4.3"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@biomejs/biome": "2.5.4",
|
|
58
|
+
"@types/bun": "1.4.1",
|
|
59
|
+
"typescript": "6.0.3",
|
|
60
|
+
"husky": "^9.1.7"
|
|
61
|
+
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"typescript": ">=6.0.0"
|
|
64
|
+
},
|
|
65
|
+
"peerDependenciesMeta": {
|
|
66
|
+
"typescript": {
|
|
67
|
+
"optional": true
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|