@itsoltech/basal-client 0.0.0-stage → 0.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 +202 -0
- package/NOTICE +39 -0
- package/README.md +77 -2
- package/dist/cjs/index.cjs +215 -0
- package/dist/cjs/index.d.cts +134 -0
- package/dist/cjs/questions.cjs +179 -0
- package/dist/cjs/questions.d.cts +78 -0
- package/dist/esm/index.d.ts +134 -0
- package/dist/esm/index.js +192 -0
- package/dist/esm/questions.d.ts +78 -0
- package/dist/esm/questions.js +164 -0
- package/package.json +45 -4
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ActQuestion = exports.MultiQuestion = exports.ScoreQuestion = exports.NoulQuestion = exports.ChoiceQuestion = exports.QuestionSpec = void 0;
|
|
4
|
+
exports.choice = choice;
|
|
5
|
+
exports.noul = noul;
|
|
6
|
+
exports.score = score;
|
|
7
|
+
exports.multi = multi;
|
|
8
|
+
exports.act = act;
|
|
9
|
+
exports.buildRequest = buildRequest;
|
|
10
|
+
function text(value, name) {
|
|
11
|
+
if (typeof value !== 'string' || !value.trim())
|
|
12
|
+
throw new TypeError(`${name} must be a nonempty string`);
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
function criteria(values, min, max = Infinity) {
|
|
16
|
+
if (!values || typeof values !== 'object' || Array.isArray(values))
|
|
17
|
+
throw new TypeError('criteria must be an object');
|
|
18
|
+
const entries = Object.entries(values);
|
|
19
|
+
if (entries.length < min || entries.length > max)
|
|
20
|
+
throw new TypeError('invalid number of criteria');
|
|
21
|
+
for (const [key, value] of entries) {
|
|
22
|
+
text(key, 'criterion key');
|
|
23
|
+
text(value, 'criterion description');
|
|
24
|
+
}
|
|
25
|
+
return Object.fromEntries(entries);
|
|
26
|
+
}
|
|
27
|
+
function number(value, name, probability = false) {
|
|
28
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || (probability && (value < 0 || value > 1))) {
|
|
29
|
+
throw new TypeError(`invalid ${name}`);
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
function evidence(options) {
|
|
34
|
+
if (options.evidence !== undefined && typeof options.evidence !== 'boolean')
|
|
35
|
+
throw new TypeError('evidence must be boolean');
|
|
36
|
+
return options.evidence ? { evidence: true } : {};
|
|
37
|
+
}
|
|
38
|
+
/** Immutable snapshot of a typed question. Use factories or the concrete constructors. */
|
|
39
|
+
class QuestionSpec {
|
|
40
|
+
wire;
|
|
41
|
+
constructor(kind, instructions, fields) {
|
|
42
|
+
this.wire = structuredClone({ ...fields, type: kind, instructions: text(instructions, 'instructions') });
|
|
43
|
+
}
|
|
44
|
+
toJSON() { return structuredClone(this.wire); }
|
|
45
|
+
/** Validate and retrieve an answer with this question's result type. */
|
|
46
|
+
read(response, id) {
|
|
47
|
+
const answer = response.answers?.[id];
|
|
48
|
+
if (!answer || answer.type !== this.wire.type || !answer.probabilities || typeof answer.probabilities !== 'object' || Array.isArray(answer.probabilities)) {
|
|
49
|
+
throw new TypeError('Basal returned a missing or mismatched answer');
|
|
50
|
+
}
|
|
51
|
+
const fields = {
|
|
52
|
+
choice: { choice: 'string', confidence: 'number' }, noul: { noul: 'number', confidence: 'number' },
|
|
53
|
+
score: { score: 'number', legend: 'object', confidence: 'number' },
|
|
54
|
+
multi: { selected: 'object', threshold: 'number', set_confidence: 'number' },
|
|
55
|
+
act: { action: 'string', answer: 'string', expected_costs: 'object', confidence: 'number', calibration: 'string' },
|
|
56
|
+
};
|
|
57
|
+
const data = answer;
|
|
58
|
+
for (const [field, kind] of Object.entries(fields[answer.type])) {
|
|
59
|
+
if (typeof data[field] !== kind || data[field] === null || (kind === 'number' && !Number.isFinite(data[field]))) {
|
|
60
|
+
throw new TypeError('Basal returned an invalid answer field');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (const value of Object.values(answer.probabilities))
|
|
64
|
+
number(value, 'probability', true);
|
|
65
|
+
const expectedKeys = this.wire.type === 'noul' || (this.wire.type === 'act' && this.wire.criteria === undefined)
|
|
66
|
+
? ['true', 'false'] : Array.isArray(this.wire.criteria)
|
|
67
|
+
? this.wire.criteria.map((_, index) => String(index)) : Object.keys(this.wire.criteria ?? {});
|
|
68
|
+
if (expectedKeys.some(key => !Object.hasOwn(answer.probabilities, key)))
|
|
69
|
+
throw new TypeError('missing outcome probability');
|
|
70
|
+
if (answer.evidence !== undefined) {
|
|
71
|
+
if (!Array.isArray(answer.evidence))
|
|
72
|
+
throw new TypeError('invalid evidence');
|
|
73
|
+
for (const span of answer.evidence) {
|
|
74
|
+
if (!span || typeof span.text !== 'string' || !Number.isSafeInteger(span.start) || !Number.isSafeInteger(span.end)
|
|
75
|
+
|| span.start < 0 || span.end < 0)
|
|
76
|
+
throw new TypeError('invalid evidence span');
|
|
77
|
+
number(span.probability, 'evidence probability', true);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (answer.type === 'choice' && !Object.hasOwn(this.wire.criteria ?? {}, answer.choice))
|
|
81
|
+
throw new TypeError('unknown choice');
|
|
82
|
+
if (answer.type === 'multi' && (!Array.isArray(answer.selected) || answer.selected.some(k => typeof k !== 'string' || !Object.hasOwn(this.wire.criteria ?? {}, k))))
|
|
83
|
+
throw new TypeError('invalid selected labels');
|
|
84
|
+
if (answer.type === 'score' && (Array.isArray(answer.legend) || Object.values(answer.legend).some(v => typeof v !== 'string')))
|
|
85
|
+
throw new TypeError('invalid legend');
|
|
86
|
+
if (answer.type === 'act') {
|
|
87
|
+
if (!['validated', 'unvalidated'].includes(answer.calibration) || !Object.hasOwn(this.wire.costs ?? {}, answer.action) || !expectedKeys.includes(answer.answer))
|
|
88
|
+
throw new TypeError('invalid action');
|
|
89
|
+
if (Array.isArray(answer.expected_costs) || Object.keys(this.wire.costs ?? {}).some(key => !Object.hasOwn(answer.expected_costs, key)))
|
|
90
|
+
throw new TypeError('invalid expected costs');
|
|
91
|
+
for (const value of Object.values(answer.expected_costs))
|
|
92
|
+
number(value, 'expected cost');
|
|
93
|
+
}
|
|
94
|
+
return { ...answer, ...(answer.type === 'multi' ? {} : { evidence: answer.evidence ?? [] }) };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
exports.QuestionSpec = QuestionSpec;
|
|
98
|
+
class ChoiceQuestion extends QuestionSpec {
|
|
99
|
+
constructor(instructions, values, options = {}) {
|
|
100
|
+
if (options.option_keys !== undefined && !['show', 'hide'].includes(options.option_keys))
|
|
101
|
+
throw new TypeError('invalid option_keys');
|
|
102
|
+
super('choice', instructions, { criteria: criteria(values, 2, 10), ...evidence(options), ...(options.option_keys === 'hide' ? { option_keys: 'hide' } : {}) });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
exports.ChoiceQuestion = ChoiceQuestion;
|
|
106
|
+
class NoulQuestion extends QuestionSpec {
|
|
107
|
+
constructor(instructions, options = {}) { super('noul', instructions, evidence(options)); }
|
|
108
|
+
}
|
|
109
|
+
exports.NoulQuestion = NoulQuestion;
|
|
110
|
+
class ScoreQuestion extends QuestionSpec {
|
|
111
|
+
constructor(instructions, levels, options = {}) {
|
|
112
|
+
if (!Array.isArray(levels) || levels.length < 2 || levels.length > 10)
|
|
113
|
+
throw new TypeError('score requires 2–10 ordered levels');
|
|
114
|
+
super('score', instructions, { criteria: levels.map(v => text(v, 'level')), ...evidence(options) });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
exports.ScoreQuestion = ScoreQuestion;
|
|
118
|
+
class MultiQuestion extends QuestionSpec {
|
|
119
|
+
constructor(instructions, values, options = {}) {
|
|
120
|
+
if ('evidence' in options)
|
|
121
|
+
throw new TypeError('multi does not support evidence');
|
|
122
|
+
const fields = { criteria: criteria(values, 1) };
|
|
123
|
+
if (options.threshold !== undefined)
|
|
124
|
+
fields.threshold = number(options.threshold, 'threshold', true);
|
|
125
|
+
for (const key of ['min', 'max']) {
|
|
126
|
+
const value = options[key];
|
|
127
|
+
if (value !== undefined) {
|
|
128
|
+
if (!Number.isInteger(value) || value < 0 || value > Object.keys(values).length)
|
|
129
|
+
throw new TypeError(`invalid ${key}`);
|
|
130
|
+
fields[key] = value;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (options.min !== undefined && options.max !== undefined && options.min > options.max)
|
|
134
|
+
throw new TypeError('min exceeds max');
|
|
135
|
+
super('multi', instructions, fields);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
exports.MultiQuestion = MultiQuestion;
|
|
139
|
+
class ActQuestion extends QuestionSpec {
|
|
140
|
+
constructor(instructions, costs, options = {}) {
|
|
141
|
+
const optionsMap = options.criteria === undefined ? undefined : criteria(options.criteria, 2, 10);
|
|
142
|
+
const outcomes = Object.keys(optionsMap ?? { 'true': 'Yes', 'false': 'No' });
|
|
143
|
+
if (!costs || typeof costs !== 'object' || Array.isArray(costs) || !Object.keys(costs).length)
|
|
144
|
+
throw new TypeError('act requires a cost matrix');
|
|
145
|
+
const matrix = {};
|
|
146
|
+
for (const [action, row] of Object.entries(costs)) {
|
|
147
|
+
text(action, 'action');
|
|
148
|
+
if (!row || typeof row !== 'object' || Array.isArray(row) || Object.keys(row).length !== outcomes.length || outcomes.some(k => !Object.hasOwn(row, k)))
|
|
149
|
+
throw new TypeError('each cost row must cover every outcome exactly');
|
|
150
|
+
Object.defineProperty(matrix, action, { value: Object.fromEntries(Object.entries(row).map(([k, v]) => [k, number(v, 'cost')])), enumerable: true });
|
|
151
|
+
}
|
|
152
|
+
if (options.defer_action !== undefined && !Object.hasOwn(matrix, options.defer_action))
|
|
153
|
+
throw new TypeError('unknown defer_action');
|
|
154
|
+
super('act', instructions, { costs: matrix, ...evidence(options), ...(optionsMap ? { criteria: optionsMap } : {}),
|
|
155
|
+
...(options.defer_action === undefined ? {} : { defer_action: options.defer_action }),
|
|
156
|
+
...(options.max_error === undefined ? {} : { max_error: number(options.max_error, 'max_error', true) }) });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
exports.ActQuestion = ActQuestion;
|
|
160
|
+
function choice(instructions, values, options) { return new ChoiceQuestion(instructions, values, options); }
|
|
161
|
+
function noul(instructions, options) { return new NoulQuestion(instructions, options); }
|
|
162
|
+
function score(instructions, levels, options) { return new ScoreQuestion(instructions, levels, options); }
|
|
163
|
+
function multi(instructions, values, options) { return new MultiQuestion(instructions, values, options); }
|
|
164
|
+
function act(instructions, costs, options) { return new ActQuestion(instructions, costs, options); }
|
|
165
|
+
function buildRequest(state, questions, options) {
|
|
166
|
+
if (state === null || state === undefined)
|
|
167
|
+
throw new TypeError('state must not be null/undefined');
|
|
168
|
+
if (!questions || !Object.keys(questions).length)
|
|
169
|
+
throw new TypeError('questions must not be empty');
|
|
170
|
+
if (Object.getOwnPropertySymbols(questions).length)
|
|
171
|
+
throw new TypeError('question keys must be strings or numbers');
|
|
172
|
+
const entries = Object.entries(questions).map(([id, q]) => { text(id, 'question ID'); if (!(q instanceof QuestionSpec))
|
|
173
|
+
throw new TypeError('expected typed question'); return [id, q.toJSON()]; });
|
|
174
|
+
if (options.model !== undefined)
|
|
175
|
+
text(options.model, 'model');
|
|
176
|
+
if (options.facts !== undefined && !['off', 'auto'].includes(options.facts))
|
|
177
|
+
throw new TypeError('invalid facts');
|
|
178
|
+
return { state, questions: Object.fromEntries(entries), ...(options.model === undefined ? {} : { model: options.model }), ...(options.facts === undefined ? {} : { facts: options.facts }) };
|
|
179
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ActAnswer, Answer, ChoiceAnswer, DecisionResponse, EvidenceSpan, JsonValue, MultiAnswer, NoulAnswer, Question, ScoreAnswer } from './index.cjs';
|
|
2
|
+
/** Required result metadata on the typed path; missing spans become an empty array. */
|
|
3
|
+
export type TypedChoiceAnswer<K extends string = string> = ChoiceAnswer<K> & {
|
|
4
|
+
evidence: EvidenceSpan[];
|
|
5
|
+
};
|
|
6
|
+
export type TypedNoulAnswer = NoulAnswer & {
|
|
7
|
+
evidence: EvidenceSpan[];
|
|
8
|
+
};
|
|
9
|
+
export type TypedScoreAnswer = ScoreAnswer & {
|
|
10
|
+
evidence: EvidenceSpan[];
|
|
11
|
+
};
|
|
12
|
+
export type TypedMultiAnswer<K extends string = string> = Omit<MultiAnswer<K>, 'evidence'>;
|
|
13
|
+
export type TypedActAnswer<K extends string = string, A extends string = string> = ActAnswer<K, A> & {
|
|
14
|
+
evidence: EvidenceSpan[];
|
|
15
|
+
};
|
|
16
|
+
export interface EvidenceOptions {
|
|
17
|
+
evidence?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface ChoiceOptions extends EvidenceOptions {
|
|
20
|
+
option_keys?: 'show' | 'hide';
|
|
21
|
+
}
|
|
22
|
+
export interface MultiOptions {
|
|
23
|
+
threshold?: number;
|
|
24
|
+
min?: number;
|
|
25
|
+
max?: number;
|
|
26
|
+
}
|
|
27
|
+
export interface ActOptions<K extends string = string, A extends string = string> extends EvidenceOptions {
|
|
28
|
+
criteria?: Record<K, string>;
|
|
29
|
+
defer_action?: A;
|
|
30
|
+
max_error?: number;
|
|
31
|
+
}
|
|
32
|
+
export interface DecisionOptions {
|
|
33
|
+
model?: string;
|
|
34
|
+
facts?: 'off' | 'auto';
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
}
|
|
37
|
+
/** Immutable snapshot of a typed question. Use factories or the concrete constructors. */
|
|
38
|
+
export declare abstract class QuestionSpec<A extends Answer = Answer> {
|
|
39
|
+
private readonly wire;
|
|
40
|
+
protected constructor(kind: A['type'], instructions: string, fields: Question);
|
|
41
|
+
toJSON(): Question;
|
|
42
|
+
/** Validate and retrieve an answer with this question's result type. */
|
|
43
|
+
read(response: DecisionResponse, id: string): A;
|
|
44
|
+
}
|
|
45
|
+
export declare class ChoiceQuestion<K extends string = string> extends QuestionSpec<TypedChoiceAnswer<K>> {
|
|
46
|
+
constructor(instructions: string, values: Record<K, string>, options?: ChoiceOptions);
|
|
47
|
+
}
|
|
48
|
+
export declare class NoulQuestion extends QuestionSpec<TypedNoulAnswer> {
|
|
49
|
+
constructor(instructions: string, options?: EvidenceOptions);
|
|
50
|
+
}
|
|
51
|
+
export declare class ScoreQuestion extends QuestionSpec<TypedScoreAnswer> {
|
|
52
|
+
constructor(instructions: string, levels: readonly string[], options?: EvidenceOptions);
|
|
53
|
+
}
|
|
54
|
+
export declare class MultiQuestion<K extends string = string> extends QuestionSpec<TypedMultiAnswer<K>> {
|
|
55
|
+
constructor(instructions: string, values: Record<K, string>, options?: MultiOptions);
|
|
56
|
+
}
|
|
57
|
+
export declare class ActQuestion<K extends string = 'true' | 'false', A extends string = string> extends QuestionSpec<TypedActAnswer<K, A>> {
|
|
58
|
+
constructor(instructions: string, costs: Record<A, Record<NoInfer<K>, number>>, options?: ActOptions<K, NoInfer<A>>);
|
|
59
|
+
}
|
|
60
|
+
export declare function choice<const K extends string>(instructions: string, values: Record<K, string>, options?: ChoiceOptions): ChoiceQuestion<K>;
|
|
61
|
+
export declare function noul(instructions: string, options?: EvidenceOptions): NoulQuestion;
|
|
62
|
+
export declare function score(instructions: string, levels: readonly string[], options?: EvidenceOptions): ScoreQuestion;
|
|
63
|
+
export declare function multi<const K extends string>(instructions: string, values: Record<K, string>, options?: MultiOptions): MultiQuestion<K>;
|
|
64
|
+
export declare function act<const K extends string = 'true' | 'false', const A extends string = string>(instructions: string, costs: Record<A, Record<NoInfer<K>, number>>, options?: ActOptions<K, NoInfer<A>>): ActQuestion<K, A>;
|
|
65
|
+
export type QuestionSet = Record<string, QuestionSpec>;
|
|
66
|
+
export type TypedDecisionResponse<Q extends QuestionSet> = Omit<DecisionResponse, 'answers'> & {
|
|
67
|
+
answers: {
|
|
68
|
+
[K in keyof Q]: Q[K] extends QuestionSpec<infer A> ? A : never;
|
|
69
|
+
};
|
|
70
|
+
answer<K extends keyof Q & (string | number)>(key: K): Q[K] extends QuestionSpec<infer A> ? A : never;
|
|
71
|
+
raw: DecisionResponse;
|
|
72
|
+
};
|
|
73
|
+
export declare function buildRequest(state: JsonValue, questions: QuestionSet, options: DecisionOptions): {
|
|
74
|
+
state: JsonValue;
|
|
75
|
+
questions: Record<string, Question>;
|
|
76
|
+
model?: string;
|
|
77
|
+
facts?: 'off' | 'auto';
|
|
78
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { type QuestionSet, type TypedDecisionResponse, type DecisionOptions } from "./questions.js";
|
|
2
|
+
export * from "./questions.js";
|
|
3
|
+
/** Native Basal wire types. Criterion order is significant; do not sort keys. */
|
|
4
|
+
export type JsonValue = string | number | boolean | null | JsonValue[] | {
|
|
5
|
+
[key: string]: JsonValue;
|
|
6
|
+
};
|
|
7
|
+
export interface Question {
|
|
8
|
+
type?: "choice" | "noul" | "score" | "multi" | "act";
|
|
9
|
+
instructions?: JsonValue;
|
|
10
|
+
criteria?: Record<string, JsonValue> | JsonValue[];
|
|
11
|
+
levels?: Record<string, JsonValue> | JsonValue[];
|
|
12
|
+
option_keys?: "show" | "hide";
|
|
13
|
+
evidence?: boolean;
|
|
14
|
+
threshold?: number;
|
|
15
|
+
min?: number;
|
|
16
|
+
max?: number;
|
|
17
|
+
base?: "noul" | "choice" | "score";
|
|
18
|
+
costs?: Record<string, number> | Record<string, Record<string, number>>;
|
|
19
|
+
defer_action?: string;
|
|
20
|
+
max_error?: number;
|
|
21
|
+
}
|
|
22
|
+
export interface DecisionRequest {
|
|
23
|
+
state: JsonValue;
|
|
24
|
+
questions: Record<string, Question>;
|
|
25
|
+
model?: string;
|
|
26
|
+
facts?: "off" | "auto";
|
|
27
|
+
}
|
|
28
|
+
export interface EvidenceSpan {
|
|
29
|
+
text: string;
|
|
30
|
+
start: number;
|
|
31
|
+
end: number;
|
|
32
|
+
probability: number;
|
|
33
|
+
}
|
|
34
|
+
interface BaseAnswer<K extends string = string> {
|
|
35
|
+
probabilities: Record<K, number>;
|
|
36
|
+
evidence?: EvidenceSpan[];
|
|
37
|
+
}
|
|
38
|
+
export interface ChoiceAnswer<K extends string = string> extends BaseAnswer<K> {
|
|
39
|
+
type: "choice";
|
|
40
|
+
choice: K;
|
|
41
|
+
confidence: number;
|
|
42
|
+
}
|
|
43
|
+
export interface NoulAnswer extends BaseAnswer<"true" | "false"> {
|
|
44
|
+
type: "noul";
|
|
45
|
+
noul: number;
|
|
46
|
+
confidence: number;
|
|
47
|
+
}
|
|
48
|
+
export interface ScoreAnswer extends BaseAnswer {
|
|
49
|
+
type: "score";
|
|
50
|
+
score: number;
|
|
51
|
+
legend: Record<string, string>;
|
|
52
|
+
confidence: number;
|
|
53
|
+
}
|
|
54
|
+
export interface MultiAnswer<K extends string = string> extends BaseAnswer<K> {
|
|
55
|
+
type: "multi";
|
|
56
|
+
selected: K[];
|
|
57
|
+
threshold: number;
|
|
58
|
+
set_confidence: number;
|
|
59
|
+
}
|
|
60
|
+
export interface ActAnswer<K extends string = string, A extends string = string> extends BaseAnswer<K> {
|
|
61
|
+
type: "act";
|
|
62
|
+
action: A;
|
|
63
|
+
answer: K;
|
|
64
|
+
expected_costs: Record<A, number>;
|
|
65
|
+
confidence: number;
|
|
66
|
+
calibration: "validated" | "unvalidated";
|
|
67
|
+
refused?: string;
|
|
68
|
+
max_error?: string;
|
|
69
|
+
}
|
|
70
|
+
export type Answer = ChoiceAnswer | NoulAnswer | ScoreAnswer | MultiAnswer | ActAnswer;
|
|
71
|
+
export interface Usage {
|
|
72
|
+
input_tokens: number;
|
|
73
|
+
output_tokens: number;
|
|
74
|
+
questions?: number;
|
|
75
|
+
branches?: number;
|
|
76
|
+
latency_ms?: number;
|
|
77
|
+
}
|
|
78
|
+
export interface DecisionResponse {
|
|
79
|
+
model: string;
|
|
80
|
+
answers: Record<string, Answer>;
|
|
81
|
+
usage: Usage;
|
|
82
|
+
}
|
|
83
|
+
export interface ModelMetadata {
|
|
84
|
+
name: string;
|
|
85
|
+
description?: string;
|
|
86
|
+
release_date?: string;
|
|
87
|
+
mode?: string;
|
|
88
|
+
early_exit?: JsonValue[];
|
|
89
|
+
}
|
|
90
|
+
export interface ModelsResponse {
|
|
91
|
+
models: ModelMetadata[];
|
|
92
|
+
}
|
|
93
|
+
export declare class BasalError extends Error {
|
|
94
|
+
}
|
|
95
|
+
/** Non-2xx response. Server body and Retry-After are available without logging them. */
|
|
96
|
+
export declare class BasalHTTPError extends BasalError {
|
|
97
|
+
readonly statusCode: number;
|
|
98
|
+
readonly body: unknown;
|
|
99
|
+
readonly retryAfter: string | null;
|
|
100
|
+
constructor(statusCode: number, body: unknown, retryAfter: string | null);
|
|
101
|
+
}
|
|
102
|
+
export declare class BasalTransportError extends BasalError {
|
|
103
|
+
}
|
|
104
|
+
export declare class BasalProtocolError extends BasalError {
|
|
105
|
+
}
|
|
106
|
+
export declare class BasalResponseTooLargeError extends BasalError {
|
|
107
|
+
}
|
|
108
|
+
/** Web Fetch input accepted by browsers and server runtimes. */
|
|
109
|
+
export type RequestInput = string | URL | Request;
|
|
110
|
+
/** A transport adapter must honor signal/redirect and return a standard Web Response. */
|
|
111
|
+
export type Fetch = (input: RequestInput, init?: RequestInit) => Promise<Response>;
|
|
112
|
+
export interface BasalClientOptions {
|
|
113
|
+
baseUrl?: string;
|
|
114
|
+
apiKey?: string;
|
|
115
|
+
timeoutMs?: number;
|
|
116
|
+
maxResponseBytes?: number;
|
|
117
|
+
/** Defaults to the runtime's native fetch; custom HTTP clients can supply a Fetch adapter. */
|
|
118
|
+
fetch?: Fetch;
|
|
119
|
+
}
|
|
120
|
+
/** Reusable fetch client. Server root URL, optional proxy prefix, without /v1.
|
|
121
|
+
* No automatic retries. AbortSignal cancels a request; timeout includes body reading.
|
|
122
|
+
*/
|
|
123
|
+
export declare class BasalClient {
|
|
124
|
+
private readonly baseUrl;
|
|
125
|
+
private readonly headers;
|
|
126
|
+
private readonly timeoutMs;
|
|
127
|
+
private readonly maxResponseBytes;
|
|
128
|
+
private readonly fetch;
|
|
129
|
+
constructor(options?: BasalClientOptions);
|
|
130
|
+
private request;
|
|
131
|
+
decide(request: DecisionRequest, signal?: AbortSignal): Promise<DecisionResponse>;
|
|
132
|
+
decide<const Q extends QuestionSet>(state: JsonValue, questions: Q, options?: DecisionOptions): Promise<TypedDecisionResponse<Q>>;
|
|
133
|
+
models(signal?: AbortSignal): Promise<ModelsResponse>;
|
|
134
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { buildRequest } from "./questions.js";
|
|
2
|
+
export * from "./questions.js";
|
|
3
|
+
export class BasalError extends Error {
|
|
4
|
+
}
|
|
5
|
+
/** Non-2xx response. Server body and Retry-After are available without logging them. */
|
|
6
|
+
export class BasalHTTPError extends BasalError {
|
|
7
|
+
statusCode;
|
|
8
|
+
body;
|
|
9
|
+
retryAfter;
|
|
10
|
+
constructor(statusCode, body, retryAfter) {
|
|
11
|
+
super(`Basal HTTP ${statusCode}`);
|
|
12
|
+
this.statusCode = statusCode;
|
|
13
|
+
this.body = body;
|
|
14
|
+
this.retryAfter = retryAfter;
|
|
15
|
+
this.name = "BasalHTTPError";
|
|
16
|
+
// Node's default inspection and JSON.stringify must not print reflected state or credentials.
|
|
17
|
+
Object.defineProperty(this, "body", { enumerable: false });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class BasalTransportError extends BasalError {
|
|
21
|
+
}
|
|
22
|
+
export class BasalProtocolError extends BasalError {
|
|
23
|
+
}
|
|
24
|
+
export class BasalResponseTooLargeError extends BasalError {
|
|
25
|
+
}
|
|
26
|
+
async function readBody(response, limit, signal) {
|
|
27
|
+
// Count bytes from the actual stream, including decompressed responses; never trust Content-Length alone.
|
|
28
|
+
const reader = response.body?.getReader();
|
|
29
|
+
if (!reader)
|
|
30
|
+
return "";
|
|
31
|
+
const cancel = () => { void reader.cancel().catch(() => { }); };
|
|
32
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
33
|
+
if (signal.aborted)
|
|
34
|
+
cancel();
|
|
35
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
36
|
+
const parts = [];
|
|
37
|
+
let bytes = 0;
|
|
38
|
+
let complete = false;
|
|
39
|
+
try {
|
|
40
|
+
for (;;) {
|
|
41
|
+
const { done, value } = await reader.read();
|
|
42
|
+
if (done) {
|
|
43
|
+
complete = true;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
bytes += value.byteLength;
|
|
47
|
+
if (bytes > limit) {
|
|
48
|
+
throw new BasalResponseTooLargeError(`Basal response exceeds ${limit} bytes`);
|
|
49
|
+
}
|
|
50
|
+
parts.push(decoder.decode(value, { stream: true }));
|
|
51
|
+
}
|
|
52
|
+
parts.push(decoder.decode());
|
|
53
|
+
return parts.join("");
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
signal.removeEventListener("abort", cancel);
|
|
57
|
+
// A decoder/read failure must also stop the underlying HTTP body, before releasing ownership.
|
|
58
|
+
if (!complete)
|
|
59
|
+
cancel();
|
|
60
|
+
reader.releaseLock();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Reusable fetch client. Server root URL, optional proxy prefix, without /v1.
|
|
64
|
+
* No automatic retries. AbortSignal cancels a request; timeout includes body reading.
|
|
65
|
+
*/
|
|
66
|
+
export class BasalClient {
|
|
67
|
+
baseUrl;
|
|
68
|
+
headers;
|
|
69
|
+
timeoutMs;
|
|
70
|
+
maxResponseBytes;
|
|
71
|
+
fetch;
|
|
72
|
+
constructor(options = {}) {
|
|
73
|
+
const url = new URL(options.baseUrl ?? "http://127.0.0.1:8000");
|
|
74
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.href.includes("?") || url.href.includes("#")) {
|
|
75
|
+
throw new TypeError("baseUrl must be an HTTP(S) server URL without credentials, query or fragment");
|
|
76
|
+
}
|
|
77
|
+
if (url.protocol !== "https:" && !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) {
|
|
78
|
+
throw new TypeError("baseUrl requires HTTPS or loopback HTTP (e.g. an SSH tunnel)");
|
|
79
|
+
}
|
|
80
|
+
this.baseUrl = url.href.replace(/\/+$/, "") + "/";
|
|
81
|
+
this.timeoutMs = options.timeoutMs ?? 120_000;
|
|
82
|
+
if (!Number.isFinite(this.timeoutMs) || this.timeoutMs <= 0 || this.timeoutMs > 2_147_483_647) {
|
|
83
|
+
throw new TypeError("timeoutMs must be positive and at most 2147483647");
|
|
84
|
+
}
|
|
85
|
+
this.headers = { Accept: "application/json" };
|
|
86
|
+
this.maxResponseBytes = options.maxResponseBytes ?? 8 * 1024 * 1024;
|
|
87
|
+
if (!Number.isSafeInteger(this.maxResponseBytes) || this.maxResponseBytes <= 0) {
|
|
88
|
+
throw new TypeError("maxResponseBytes must be a positive safe integer");
|
|
89
|
+
}
|
|
90
|
+
this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
91
|
+
if (options.apiKey !== undefined) {
|
|
92
|
+
if (!options.apiKey || /[\r\n]/.test(options.apiKey))
|
|
93
|
+
throw new TypeError("apiKey must be a nonempty, single-line token");
|
|
94
|
+
this.headers.Authorization = `Bearer ${options.apiKey}`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async request(method, path, body, signal) {
|
|
98
|
+
// Serialization errors are local input errors, not failures of the HTTP transport.
|
|
99
|
+
const encoded = body === undefined ? undefined : JSON.stringify(body);
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
const abort = () => controller.abort(signal?.reason);
|
|
102
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
103
|
+
if (signal?.aborted)
|
|
104
|
+
abort();
|
|
105
|
+
const timer = setTimeout(() => controller.abort(new Error("Basal request timed out")), this.timeoutMs);
|
|
106
|
+
let onAbort = () => { };
|
|
107
|
+
const aborted = new Promise((_, reject) => {
|
|
108
|
+
onAbort = () => reject(new BasalTransportError("Basal HTTP request aborted", { cause: controller.signal.reason }));
|
|
109
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
110
|
+
if (controller.signal.aborted)
|
|
111
|
+
onAbort();
|
|
112
|
+
});
|
|
113
|
+
const work = async () => {
|
|
114
|
+
let response;
|
|
115
|
+
let raw;
|
|
116
|
+
try {
|
|
117
|
+
response = await this.fetch(this.baseUrl + path, {
|
|
118
|
+
method, headers: body === undefined ? this.headers : { ...this.headers, "Content-Type": "application/json" },
|
|
119
|
+
...(encoded === undefined ? {} : { body: encoded }),
|
|
120
|
+
signal: controller.signal, redirect: "error", credentials: "omit", cache: "no-store",
|
|
121
|
+
});
|
|
122
|
+
if (response.redirected || ([301, 302, 303, 307, 308].includes(response.status) && response.headers.has("Location"))) {
|
|
123
|
+
await response.body?.cancel().catch(() => { });
|
|
124
|
+
throw new BasalTransportError("Basal transport returned a redirect");
|
|
125
|
+
}
|
|
126
|
+
raw = await readBody(response, this.maxResponseBytes, controller.signal);
|
|
127
|
+
}
|
|
128
|
+
catch (cause) {
|
|
129
|
+
if (cause instanceof BasalError)
|
|
130
|
+
throw cause;
|
|
131
|
+
throw new BasalTransportError("Basal HTTP transport failed", { cause });
|
|
132
|
+
}
|
|
133
|
+
let data;
|
|
134
|
+
try {
|
|
135
|
+
data = JSON.parse(raw);
|
|
136
|
+
}
|
|
137
|
+
catch (cause) {
|
|
138
|
+
if (response.ok)
|
|
139
|
+
throw new BasalProtocolError("Basal returned invalid JSON", { cause });
|
|
140
|
+
data = raw;
|
|
141
|
+
}
|
|
142
|
+
if (!response.ok)
|
|
143
|
+
throw new BasalHTTPError(response.status, data, response.headers.get("Retry-After"));
|
|
144
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
145
|
+
throw new BasalProtocolError("Basal returned a JSON value other than an object");
|
|
146
|
+
}
|
|
147
|
+
return data;
|
|
148
|
+
};
|
|
149
|
+
try {
|
|
150
|
+
if (controller.signal.aborted)
|
|
151
|
+
return await aborted;
|
|
152
|
+
return await Promise.race([work(), aborted]);
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
signal?.removeEventListener("abort", abort);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async decide(state, questionsOrSignal, options = {}) {
|
|
161
|
+
if (questionsOrSignal === undefined || ("aborted" in questionsOrSignal && typeof questionsOrSignal.addEventListener === "function")) {
|
|
162
|
+
return this.request("POST", "v1/basal", state, questionsOrSignal);
|
|
163
|
+
}
|
|
164
|
+
const questions = { ...questionsOrSignal };
|
|
165
|
+
const response = await this.request("POST", "v1/basal", buildRequest(state, questions, options), options.signal);
|
|
166
|
+
const parsed = new Map();
|
|
167
|
+
try {
|
|
168
|
+
if (typeof response.model !== "string" || !response.model || !response.usage || Array.isArray(response.usage)
|
|
169
|
+
|| !Number.isSafeInteger(response.usage.input_tokens) || response.usage.input_tokens < 0
|
|
170
|
+
|| !Number.isSafeInteger(response.usage.output_tokens) || response.usage.output_tokens < 0
|
|
171
|
+
|| !response.answers || Array.isArray(response.answers))
|
|
172
|
+
throw new TypeError("invalid decision envelope");
|
|
173
|
+
for (const [id, question] of Object.entries(questions))
|
|
174
|
+
parsed.set(id, question.read(response, id));
|
|
175
|
+
}
|
|
176
|
+
catch (cause) {
|
|
177
|
+
throw new BasalProtocolError("Invalid typed Basal answer", { cause });
|
|
178
|
+
}
|
|
179
|
+
const typed = { ...response, answers: Object.fromEntries(parsed) };
|
|
180
|
+
Object.defineProperty(typed, "raw", { value: response });
|
|
181
|
+
Object.defineProperty(typed, "answer", { value: (key) => {
|
|
182
|
+
if (typeof key !== "string" && typeof key !== "number")
|
|
183
|
+
throw new TypeError("answer key must be a string or number");
|
|
184
|
+
const answer = parsed.get(String(key));
|
|
185
|
+
if (answer === undefined)
|
|
186
|
+
throw new TypeError("answer key was not part of this response");
|
|
187
|
+
return answer;
|
|
188
|
+
} });
|
|
189
|
+
return typed;
|
|
190
|
+
}
|
|
191
|
+
models(signal) { return this.request("GET", "v1/models", undefined, signal); }
|
|
192
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ActAnswer, Answer, ChoiceAnswer, DecisionResponse, EvidenceSpan, JsonValue, MultiAnswer, NoulAnswer, Question, ScoreAnswer } from './index.js';
|
|
2
|
+
/** Required result metadata on the typed path; missing spans become an empty array. */
|
|
3
|
+
export type TypedChoiceAnswer<K extends string = string> = ChoiceAnswer<K> & {
|
|
4
|
+
evidence: EvidenceSpan[];
|
|
5
|
+
};
|
|
6
|
+
export type TypedNoulAnswer = NoulAnswer & {
|
|
7
|
+
evidence: EvidenceSpan[];
|
|
8
|
+
};
|
|
9
|
+
export type TypedScoreAnswer = ScoreAnswer & {
|
|
10
|
+
evidence: EvidenceSpan[];
|
|
11
|
+
};
|
|
12
|
+
export type TypedMultiAnswer<K extends string = string> = Omit<MultiAnswer<K>, 'evidence'>;
|
|
13
|
+
export type TypedActAnswer<K extends string = string, A extends string = string> = ActAnswer<K, A> & {
|
|
14
|
+
evidence: EvidenceSpan[];
|
|
15
|
+
};
|
|
16
|
+
export interface EvidenceOptions {
|
|
17
|
+
evidence?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface ChoiceOptions extends EvidenceOptions {
|
|
20
|
+
option_keys?: 'show' | 'hide';
|
|
21
|
+
}
|
|
22
|
+
export interface MultiOptions {
|
|
23
|
+
threshold?: number;
|
|
24
|
+
min?: number;
|
|
25
|
+
max?: number;
|
|
26
|
+
}
|
|
27
|
+
export interface ActOptions<K extends string = string, A extends string = string> extends EvidenceOptions {
|
|
28
|
+
criteria?: Record<K, string>;
|
|
29
|
+
defer_action?: A;
|
|
30
|
+
max_error?: number;
|
|
31
|
+
}
|
|
32
|
+
export interface DecisionOptions {
|
|
33
|
+
model?: string;
|
|
34
|
+
facts?: 'off' | 'auto';
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
}
|
|
37
|
+
/** Immutable snapshot of a typed question. Use factories or the concrete constructors. */
|
|
38
|
+
export declare abstract class QuestionSpec<A extends Answer = Answer> {
|
|
39
|
+
private readonly wire;
|
|
40
|
+
protected constructor(kind: A['type'], instructions: string, fields: Question);
|
|
41
|
+
toJSON(): Question;
|
|
42
|
+
/** Validate and retrieve an answer with this question's result type. */
|
|
43
|
+
read(response: DecisionResponse, id: string): A;
|
|
44
|
+
}
|
|
45
|
+
export declare class ChoiceQuestion<K extends string = string> extends QuestionSpec<TypedChoiceAnswer<K>> {
|
|
46
|
+
constructor(instructions: string, values: Record<K, string>, options?: ChoiceOptions);
|
|
47
|
+
}
|
|
48
|
+
export declare class NoulQuestion extends QuestionSpec<TypedNoulAnswer> {
|
|
49
|
+
constructor(instructions: string, options?: EvidenceOptions);
|
|
50
|
+
}
|
|
51
|
+
export declare class ScoreQuestion extends QuestionSpec<TypedScoreAnswer> {
|
|
52
|
+
constructor(instructions: string, levels: readonly string[], options?: EvidenceOptions);
|
|
53
|
+
}
|
|
54
|
+
export declare class MultiQuestion<K extends string = string> extends QuestionSpec<TypedMultiAnswer<K>> {
|
|
55
|
+
constructor(instructions: string, values: Record<K, string>, options?: MultiOptions);
|
|
56
|
+
}
|
|
57
|
+
export declare class ActQuestion<K extends string = 'true' | 'false', A extends string = string> extends QuestionSpec<TypedActAnswer<K, A>> {
|
|
58
|
+
constructor(instructions: string, costs: Record<A, Record<NoInfer<K>, number>>, options?: ActOptions<K, NoInfer<A>>);
|
|
59
|
+
}
|
|
60
|
+
export declare function choice<const K extends string>(instructions: string, values: Record<K, string>, options?: ChoiceOptions): ChoiceQuestion<K>;
|
|
61
|
+
export declare function noul(instructions: string, options?: EvidenceOptions): NoulQuestion;
|
|
62
|
+
export declare function score(instructions: string, levels: readonly string[], options?: EvidenceOptions): ScoreQuestion;
|
|
63
|
+
export declare function multi<const K extends string>(instructions: string, values: Record<K, string>, options?: MultiOptions): MultiQuestion<K>;
|
|
64
|
+
export declare function act<const K extends string = 'true' | 'false', const A extends string = string>(instructions: string, costs: Record<A, Record<NoInfer<K>, number>>, options?: ActOptions<K, NoInfer<A>>): ActQuestion<K, A>;
|
|
65
|
+
export type QuestionSet = Record<string, QuestionSpec>;
|
|
66
|
+
export type TypedDecisionResponse<Q extends QuestionSet> = Omit<DecisionResponse, 'answers'> & {
|
|
67
|
+
answers: {
|
|
68
|
+
[K in keyof Q]: Q[K] extends QuestionSpec<infer A> ? A : never;
|
|
69
|
+
};
|
|
70
|
+
answer<K extends keyof Q & (string | number)>(key: K): Q[K] extends QuestionSpec<infer A> ? A : never;
|
|
71
|
+
raw: DecisionResponse;
|
|
72
|
+
};
|
|
73
|
+
export declare function buildRequest(state: JsonValue, questions: QuestionSet, options: DecisionOptions): {
|
|
74
|
+
state: JsonValue;
|
|
75
|
+
questions: Record<string, Question>;
|
|
76
|
+
model?: string;
|
|
77
|
+
facts?: 'off' | 'auto';
|
|
78
|
+
};
|