@nola-lang/providers 0.1.11 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/combinators.d.ts +4 -3
- package/dist/combinators.js +63 -56
- package/dist/decisions.d.ts +60 -0
- package/dist/decisions.js +231 -0
- package/dist/dialect.d.ts +12 -0
- package/dist/dialect.js +25 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/mock.d.ts +10 -1
- package/dist/mock.js +3 -1
- package/dist/record-replay.js +13 -4
- package/dist/typesafe.d.ts +19 -5
- package/dist/typesafe.js +26 -17
- package/package.json +3 -3
- package/dist/typesafe-questions.d.ts +0 -36
- package/dist/typesafe-questions.js +0 -124
package/dist/combinators.d.ts
CHANGED
|
@@ -18,13 +18,14 @@ export declare function exponential(opts: {
|
|
|
18
18
|
/** Definitive errors must not be retried: explicit flag, or HTTP 4xx except 408/429. */
|
|
19
19
|
export declare function isDefinitiveProviderError(error: unknown): boolean;
|
|
20
20
|
/**
|
|
21
|
-
* Wire-level retry: re-attempts the single
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* Wire-level retry: re-attempts the single provider call with backoff,
|
|
22
|
+
* fail-fasting on definitive errors. Honors the provider's `retryAfterMs`
|
|
23
|
+
* (Retry-After) when it exceeds the scheduled delay, capped at
|
|
24
24
|
* `policy.maxDelayMs` — so a policy whose maxDelayMs is 0 (e.g. `constant()`
|
|
25
25
|
* with no delay) ignores the header entirely. Distinct from the intent method
|
|
26
26
|
* `.withRetry(n)`, which flat-retries the entire ask (composition, provider
|
|
27
27
|
* call, parse, validation) with no backoff and no definitive-error check.
|
|
28
|
+
* Mirrors the inner's dialect and decision brand.
|
|
28
29
|
*/
|
|
29
30
|
export declare function withRetry(provider: LanguageModel, policy: RetryPolicy): LanguageModel;
|
|
30
31
|
export declare function fallback(providers: LanguageModel[]): LanguageModel;
|
package/dist/combinators.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Codes } from "@nola-lang/ast";
|
|
2
|
-
import { isPlatformModel, NolaConfigError, NolaProviderError } from "@nola-lang/core";
|
|
2
|
+
import { DECISION_MODEL, isDecisionModel, isInferModel, isPlatformModel, NolaConfigError, NolaProviderError } from "@nola-lang/core";
|
|
3
|
+
import { callModel, isDecisionRequest } from "./dialect.js";
|
|
3
4
|
export function constant(opts) {
|
|
4
5
|
const delayMs = opts.delayMs ?? 0;
|
|
5
6
|
return { maxRetries: opts.maxRetries, delayMs, multiplier: 1, maxDelayMs: delayMs };
|
|
@@ -45,79 +46,85 @@ function describeError(error) {
|
|
|
45
46
|
return error instanceof Error ? error.message : String(error);
|
|
46
47
|
}
|
|
47
48
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
49
|
+
* Build the outer model of a combinator (decision types spec 2026-09-18
|
|
50
|
+
* §6.1–6.2): infer-dialect iff any inner is (chat inners get the rendering
|
|
51
|
+
* through callModel), branded a decision model iff any inner is. `run`
|
|
52
|
+
* receives the request in the outer dialect.
|
|
53
|
+
*/
|
|
54
|
+
function outer(name, inners, run) {
|
|
55
|
+
const brand = inners.some((m) => isDecisionModel(m)) ? { [DECISION_MODEL]: true } : {};
|
|
56
|
+
const model = inners.some((m) => isInferModel(m))
|
|
57
|
+
? { name, ...brand, infer: (req) => run(req) }
|
|
58
|
+
: { name, ...brand, complete: (req) => run(req) };
|
|
59
|
+
return model;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Wire-level retry: re-attempts the single provider call with backoff,
|
|
63
|
+
* fail-fasting on definitive errors. Honors the provider's `retryAfterMs`
|
|
64
|
+
* (Retry-After) when it exceeds the scheduled delay, capped at
|
|
51
65
|
* `policy.maxDelayMs` — so a policy whose maxDelayMs is 0 (e.g. `constant()`
|
|
52
66
|
* with no delay) ignores the header entirely. Distinct from the intent method
|
|
53
67
|
* `.withRetry(n)`, which flat-retries the entire ask (composition, provider
|
|
54
68
|
* call, parse, validation) with no backoff and no definitive-error check.
|
|
69
|
+
* Mirrors the inner's dialect and decision brand.
|
|
55
70
|
*/
|
|
56
71
|
export function withRetry(provider, policy) {
|
|
57
72
|
rejectPlatformModel([provider], "withRetry");
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return await provider.complete(req);
|
|
66
|
-
}
|
|
67
|
-
catch (error) {
|
|
68
|
-
lastError = error;
|
|
69
|
-
if (isDefinitiveProviderError(error) || attempt === policy.maxRetries)
|
|
70
|
-
throw error;
|
|
71
|
-
const retryAfterMs = error instanceof NolaProviderError ? (error.retryAfterMs ?? 0) : 0;
|
|
72
|
-
await sleep(Math.min(Math.max(delay, retryAfterMs), policy.maxDelayMs));
|
|
73
|
-
delay = Math.min(delay * policy.multiplier, policy.maxDelayMs);
|
|
74
|
-
}
|
|
73
|
+
const inner = provider;
|
|
74
|
+
return outer(`retry(${provider.name})`, [inner], async (req) => {
|
|
75
|
+
let delay = policy.delayMs;
|
|
76
|
+
let lastError;
|
|
77
|
+
for (let attempt = 0; attempt <= policy.maxRetries; attempt++) {
|
|
78
|
+
try {
|
|
79
|
+
return await callModel(inner, req);
|
|
75
80
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
81
|
+
catch (error) {
|
|
82
|
+
lastError = error;
|
|
83
|
+
if (isDefinitiveProviderError(error) || attempt === policy.maxRetries)
|
|
84
|
+
throw error;
|
|
85
|
+
const retryAfterMs = error instanceof NolaProviderError ? (error.retryAfterMs ?? 0) : 0;
|
|
86
|
+
await sleep(Math.min(Math.max(delay, retryAfterMs), policy.maxDelayMs));
|
|
87
|
+
delay = Math.min(delay * policy.multiplier, policy.maxDelayMs);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
throw lastError; // unreachable; satisfies control-flow analysis
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** Try each inner in order; on a decision request, unbranded inners are skipped (they would fabricate). */
|
|
94
|
+
async function tryInOrder(name, ordered, req) {
|
|
95
|
+
const decision = isDecisionRequest(req);
|
|
96
|
+
const failures = [];
|
|
97
|
+
for (const p of ordered) {
|
|
98
|
+
if (decision && !isDecisionModel(p)) {
|
|
99
|
+
failures.push(`${p.name}: not a decision model`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
return await callModel(p, req);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
failures.push(`${p.name}: ${describeError(error)}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
throw new NolaProviderError(`${name}: all providers failed —\n ${failures.join("\n ")}`);
|
|
79
110
|
}
|
|
80
111
|
export function fallback(providers) {
|
|
81
112
|
requireModels(providers, "fallback");
|
|
82
113
|
rejectPlatformModel(providers, "fallback");
|
|
114
|
+
const inners = providers;
|
|
83
115
|
const name = `fallback(${providers.map((p) => p.name).join(", ")})`;
|
|
84
|
-
return
|
|
85
|
-
name,
|
|
86
|
-
async complete(req) {
|
|
87
|
-
const failures = [];
|
|
88
|
-
for (const p of providers) {
|
|
89
|
-
try {
|
|
90
|
-
return await p.complete(req);
|
|
91
|
-
}
|
|
92
|
-
catch (error) {
|
|
93
|
-
failures.push(`${p.name}: ${describeError(error)}`);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
throw new NolaProviderError(`${name}: all providers failed —\n ${failures.join("\n ")}`);
|
|
97
|
-
},
|
|
98
|
-
};
|
|
116
|
+
return outer(name, inners, (req) => tryInOrder(name, inners, req));
|
|
99
117
|
}
|
|
100
118
|
export function roundRobin(providers) {
|
|
101
119
|
requireModels(providers, "roundRobin");
|
|
102
120
|
rejectPlatformModel(providers, "roundRobin");
|
|
121
|
+
const inners = providers;
|
|
103
122
|
const name = `roundRobin(${providers.map((p) => p.name).join(", ")})`;
|
|
104
123
|
let nextStart = 0;
|
|
105
|
-
return {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
for (let i = 0; i < providers.length; i++) {
|
|
111
|
-
const p = providers[(start + i) % providers.length];
|
|
112
|
-
try {
|
|
113
|
-
return await p.complete(req);
|
|
114
|
-
}
|
|
115
|
-
catch (error) {
|
|
116
|
-
failures.push(`${p.name}: ${describeError(error)}`);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
throw new NolaProviderError(`${name}: all providers failed —\n ${failures.join("\n ")}`);
|
|
120
|
-
},
|
|
121
|
-
};
|
|
124
|
+
return outer(name, inners, (req) => {
|
|
125
|
+
const start = nextStart++ % inners.length;
|
|
126
|
+
const ordered = inners.map((_, i) => inners[(start + i) % inners.length]);
|
|
127
|
+
return tryInOrder(name, ordered, req);
|
|
128
|
+
});
|
|
122
129
|
}
|
|
123
130
|
//# sourceMappingURL=combinators.js.map
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { InferenceModel } from "@nola-lang/core";
|
|
2
|
+
/**
|
|
3
|
+
* The decisions wire dialect (spec 2026-09-18 §6.2–6.3): typesafe.ai's
|
|
4
|
+
* System One and OpenRouter's Decisions API take one JSON `state` and a map
|
|
5
|
+
* of named typed questions — choice / score / noul — and answer every one
|
|
6
|
+
* with a probability distribution. This module turns an InferenceModel into
|
|
7
|
+
* that request and its answers back into the values the ask's carrier
|
|
8
|
+
* validates. Pure: no fetch, no vendor URL, so every wire over the dialect
|
|
9
|
+
* shares it.
|
|
10
|
+
*/
|
|
11
|
+
/** `instructions` may be a string or a JSON object on the wire (UNVERIFIED against the live API: the object form). */
|
|
12
|
+
export type Instructions = string | {
|
|
13
|
+
context: string;
|
|
14
|
+
question: string;
|
|
15
|
+
};
|
|
16
|
+
export type DecisionWireQuestion = {
|
|
17
|
+
type: "choice";
|
|
18
|
+
instructions: Instructions;
|
|
19
|
+
criteria: Record<string, string | null>;
|
|
20
|
+
} | {
|
|
21
|
+
type: "score";
|
|
22
|
+
instructions: Instructions;
|
|
23
|
+
criteria: string[];
|
|
24
|
+
} | {
|
|
25
|
+
type: "noul";
|
|
26
|
+
instructions: Instructions;
|
|
27
|
+
criteria?: {
|
|
28
|
+
true: string;
|
|
29
|
+
false: string;
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
export type DecodeResult = {
|
|
33
|
+
ok: true;
|
|
34
|
+
value: unknown;
|
|
35
|
+
} | {
|
|
36
|
+
ok: false;
|
|
37
|
+
reason: string;
|
|
38
|
+
};
|
|
39
|
+
/** Turns the wire answer for one question into the JSON value the ask expects. */
|
|
40
|
+
export type Decoder = (answer: unknown) => DecodeResult;
|
|
41
|
+
export interface DecisionPlan {
|
|
42
|
+
/** JSON: the contextual values by name (innermost wins), or the ask's instruction text when there are none */
|
|
43
|
+
state: unknown;
|
|
44
|
+
questions: Record<string, DecisionWireQuestion>;
|
|
45
|
+
decode: Record<string, Decoder>;
|
|
46
|
+
/** true ⇔ the reply is the bare "value" answer, not an object of answers */
|
|
47
|
+
scalar: boolean;
|
|
48
|
+
}
|
|
49
|
+
export type PlanResult = {
|
|
50
|
+
ok: true;
|
|
51
|
+
plan: DecisionPlan;
|
|
52
|
+
} | {
|
|
53
|
+
ok: false;
|
|
54
|
+
reason: string;
|
|
55
|
+
};
|
|
56
|
+
/** The request for an ask, or why the dialect cannot serve it. */
|
|
57
|
+
export declare function planFor(model: InferenceModel, options: {
|
|
58
|
+
threshold: number;
|
|
59
|
+
}): PlanResult;
|
|
60
|
+
//# sourceMappingURL=decisions.d.ts.map
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { joinBlocks } from "@nola-lang/core";
|
|
2
|
+
const SERVES = "typesafe() serves Choice, Scale and Prob, literal unions and booleans";
|
|
3
|
+
const SCALAR_QUESTION = "Determine the value the request asks for.";
|
|
4
|
+
function fail(reason) {
|
|
5
|
+
return { ok: false, reason };
|
|
6
|
+
}
|
|
7
|
+
// ---- state and context ----
|
|
8
|
+
/** Scopes outer→inner (the model lists them innermost first through `parent`). */
|
|
9
|
+
function scopesOuterFirst(scope) {
|
|
10
|
+
const out = [];
|
|
11
|
+
for (let s = scope; s; s = s.parent)
|
|
12
|
+
out.unshift(s);
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
/** The contextual values by name, outer→inner so an inner binding shadows an outer one; undefined when there are none. */
|
|
16
|
+
function contextualState(model) {
|
|
17
|
+
const state = {};
|
|
18
|
+
let any = false;
|
|
19
|
+
for (const scope of scopesOuterFirst(model.scope)) {
|
|
20
|
+
for (const arg of scope.args) {
|
|
21
|
+
if (arg.contextual && "value" in arg) {
|
|
22
|
+
state[arg.name] = arg.value;
|
|
23
|
+
any = true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return any ? state : undefined;
|
|
28
|
+
}
|
|
29
|
+
const askText = (model) => model.input.text ?? model.input.instruction;
|
|
30
|
+
/** What the model says about the ask, outer→inner: system, each scope's text, then (optionally) the ask text. */
|
|
31
|
+
function contextText(model, includeAskText) {
|
|
32
|
+
const parts = [
|
|
33
|
+
model.system ?? "",
|
|
34
|
+
...scopesOuterFirst(model.scope).map((s) => s.text ?? s.instruction),
|
|
35
|
+
includeAskText ? askText(model) : "",
|
|
36
|
+
];
|
|
37
|
+
return parts.filter((p) => p.trim() !== "").reduce((acc, p) => joinBlocks(acc, p), "");
|
|
38
|
+
}
|
|
39
|
+
/** Follow a `$ref` chain through the root `$defs`; the FIRST description seen along the chain wins. */
|
|
40
|
+
function resolve(node, defs, path) {
|
|
41
|
+
let current = node;
|
|
42
|
+
let description = node.description;
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
while ("$ref" in current) {
|
|
45
|
+
const ref = current.$ref;
|
|
46
|
+
if (seen.has(ref))
|
|
47
|
+
return fail(`${path} is a cyclic reference ${JSON.stringify(ref)}; ${SERVES}`);
|
|
48
|
+
seen.add(ref);
|
|
49
|
+
const name = /^#\/\$defs\/(.+)$/.exec(ref)?.[1];
|
|
50
|
+
const next = name ? defs?.[name] : undefined;
|
|
51
|
+
if (!next)
|
|
52
|
+
return fail(`${path} is an unresolved reference ${JSON.stringify(ref)}; ${SERVES}`);
|
|
53
|
+
current = next;
|
|
54
|
+
description ??= current.description;
|
|
55
|
+
}
|
|
56
|
+
return description === undefined ? { ok: true, node: current } : { ok: true, node: current, description };
|
|
57
|
+
}
|
|
58
|
+
/** What an unsupported node is, in the words of the failure reason. */
|
|
59
|
+
function kindOf(node) {
|
|
60
|
+
if ("const" in node)
|
|
61
|
+
return "a single literal";
|
|
62
|
+
if ("anyOf" in node)
|
|
63
|
+
return "a union that is not all string literals or all number literals";
|
|
64
|
+
if ("type" in node) {
|
|
65
|
+
switch (node.type) {
|
|
66
|
+
case "string":
|
|
67
|
+
return node.format === "date-time" ? "a date-time string" : "a free-form string";
|
|
68
|
+
case "number":
|
|
69
|
+
case "integer":
|
|
70
|
+
return "a number";
|
|
71
|
+
case "array":
|
|
72
|
+
return "an array";
|
|
73
|
+
case "object":
|
|
74
|
+
return "properties" in node ? "a nested object" : "a record";
|
|
75
|
+
case "null":
|
|
76
|
+
return "null";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return "an unsupported shape";
|
|
80
|
+
}
|
|
81
|
+
const missing = (key) => fail(`answer "${key}" is missing from the reply`);
|
|
82
|
+
function finiteOrUndefined(v) {
|
|
83
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
84
|
+
}
|
|
85
|
+
/** A plain literal union: labels are the literals; the decoder returns the JSON value the label stands for. */
|
|
86
|
+
function plainChoice(key, instructions, labels) {
|
|
87
|
+
const criteria = {};
|
|
88
|
+
for (const label of labels.keys())
|
|
89
|
+
criteria[label] = null;
|
|
90
|
+
const decode = (answer) => {
|
|
91
|
+
if (answer === undefined || answer === null)
|
|
92
|
+
return missing(key);
|
|
93
|
+
const choice = answer.choice;
|
|
94
|
+
if (typeof choice !== "string" || !labels.has(choice)) {
|
|
95
|
+
return fail(`answer "${key}" chose ${JSON.stringify(choice)}, which is not one of the options sent`);
|
|
96
|
+
}
|
|
97
|
+
return { ok: true, value: labels.get(choice) };
|
|
98
|
+
};
|
|
99
|
+
return { ok: true, question: { type: "choice", instructions, criteria }, decode };
|
|
100
|
+
}
|
|
101
|
+
function plainNoul(key, instructions, threshold) {
|
|
102
|
+
const decode = (answer) => {
|
|
103
|
+
if (answer === undefined || answer === null)
|
|
104
|
+
return missing(key);
|
|
105
|
+
const noul = finiteOrUndefined(answer.noul);
|
|
106
|
+
if (noul === undefined)
|
|
107
|
+
return fail(`answer "${key}" has no numeric noul`);
|
|
108
|
+
return { ok: true, value: noul > threshold };
|
|
109
|
+
};
|
|
110
|
+
return { ok: true, question: { type: "noul", instructions }, decode };
|
|
111
|
+
}
|
|
112
|
+
/** A decision node: the question is the node's own; the decoder builds the answer shape the carrier validates. */
|
|
113
|
+
function decisionQuestion(key, instructions, q) {
|
|
114
|
+
switch (q.kind) {
|
|
115
|
+
case "choice": {
|
|
116
|
+
const labels = Object.keys(q.criteria);
|
|
117
|
+
const decode = (answer) => {
|
|
118
|
+
if (answer === undefined || answer === null)
|
|
119
|
+
return missing(key);
|
|
120
|
+
const a = answer;
|
|
121
|
+
if (typeof a.choice !== "string" || !labels.includes(a.choice)) {
|
|
122
|
+
return fail(`answer "${key}" chose ${JSON.stringify(a.choice)}, which is not one of the options sent`);
|
|
123
|
+
}
|
|
124
|
+
if (a.probabilities === null || typeof a.probabilities !== "object") {
|
|
125
|
+
return fail(`answer "${key}" has no probabilities object`);
|
|
126
|
+
}
|
|
127
|
+
const confidence = finiteOrUndefined(a.confidence);
|
|
128
|
+
// the wire's label is its text; a label written as a number comes back as that number
|
|
129
|
+
const choice = q.numeric?.includes(a.choice) ? Number(a.choice) : a.choice;
|
|
130
|
+
return {
|
|
131
|
+
ok: true,
|
|
132
|
+
value: { choice, probabilities: a.probabilities, ...(confidence !== undefined ? { confidence } : {}) },
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
return { ok: true, question: { type: "choice", instructions, criteria: { ...q.criteria } }, decode };
|
|
136
|
+
}
|
|
137
|
+
case "scale": {
|
|
138
|
+
const levels = [...q.levels];
|
|
139
|
+
const decode = (answer) => {
|
|
140
|
+
if (answer === undefined || answer === null)
|
|
141
|
+
return missing(key);
|
|
142
|
+
const a = answer;
|
|
143
|
+
const score = finiteOrUndefined(a.score);
|
|
144
|
+
if (score === undefined)
|
|
145
|
+
return fail(`answer "${key}" has no numeric score`);
|
|
146
|
+
const byIndex = (a.probabilities ?? {});
|
|
147
|
+
const probabilities = levels.map((_, i) => finiteOrUndefined(byIndex[String(i)]) ?? 0);
|
|
148
|
+
const confidence = finiteOrUndefined(a.confidence);
|
|
149
|
+
return { ok: true, value: { score, probabilities, levels, ...(confidence !== undefined ? { confidence } : {}) } };
|
|
150
|
+
};
|
|
151
|
+
return { ok: true, question: { type: "score", instructions, criteria: levels }, decode };
|
|
152
|
+
}
|
|
153
|
+
case "prob": {
|
|
154
|
+
const decode = (answer) => {
|
|
155
|
+
if (answer === undefined || answer === null)
|
|
156
|
+
return missing(key);
|
|
157
|
+
const noul = finiteOrUndefined(answer.noul);
|
|
158
|
+
if (noul === undefined)
|
|
159
|
+
return fail(`answer "${key}" has no numeric noul`);
|
|
160
|
+
return { ok: true, value: noul };
|
|
161
|
+
};
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
question: q.criteria ? { type: "noul", instructions, criteria: q.criteria } : { type: "noul", instructions },
|
|
165
|
+
decode,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Map one schema node to a question. `path` names the node in failure reasons; `key` is the answer key. */
|
|
171
|
+
function questionFor(raw, defs, path, key, context, threshold) {
|
|
172
|
+
const resolved = resolve(raw, defs, path);
|
|
173
|
+
if (!resolved.ok)
|
|
174
|
+
return resolved;
|
|
175
|
+
const { node, description } = resolved;
|
|
176
|
+
const question = description ?? (key === "value" ? SCALAR_QUESTION : `Determine "${key}".`);
|
|
177
|
+
const instructions = context === "" ? question : { context, question };
|
|
178
|
+
const decision = node["x-nola-decision"];
|
|
179
|
+
if (decision)
|
|
180
|
+
return decisionQuestion(key, instructions, decision);
|
|
181
|
+
if ("type" in node && node.type === "boolean")
|
|
182
|
+
return plainNoul(key, instructions, threshold);
|
|
183
|
+
if ("type" in node && node.type === "string" && node.enum) {
|
|
184
|
+
return plainChoice(key, instructions, new Map(node.enum.map((label) => [label, label])));
|
|
185
|
+
}
|
|
186
|
+
if ("anyOf" in node && node.anyOf.length >= 2) {
|
|
187
|
+
const consts = node.anyOf.map((branch) => ("const" in branch ? branch.const : undefined));
|
|
188
|
+
if (consts.every((c) => typeof c === "string")) {
|
|
189
|
+
return plainChoice(key, instructions, new Map(consts.map((c) => [c, c])));
|
|
190
|
+
}
|
|
191
|
+
if (consts.every((c) => typeof c === "number")) {
|
|
192
|
+
return plainChoice(key, instructions, new Map(consts.map((c) => [String(c), c])));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return fail(`${path} is ${kindOf(node)}; ${SERVES}`);
|
|
196
|
+
}
|
|
197
|
+
/** The request for an ask, or why the dialect cannot serve it. */
|
|
198
|
+
export function planFor(model, options) {
|
|
199
|
+
const schema = model.output.syntax === "json" ? model.output.schema : undefined;
|
|
200
|
+
if (!schema)
|
|
201
|
+
return fail(`the ask has no output schema (free text); ${SERVES}`);
|
|
202
|
+
const values = contextualState(model);
|
|
203
|
+
const state = values ?? askText(model);
|
|
204
|
+
const context = contextText(model, values !== undefined);
|
|
205
|
+
const defs = "$defs" in schema ? schema.$defs : undefined;
|
|
206
|
+
const root = resolve(schema, defs, "the output type");
|
|
207
|
+
if (!root.ok)
|
|
208
|
+
return root;
|
|
209
|
+
const node = root.node;
|
|
210
|
+
const decisionRoot = node["x-nola-decision"];
|
|
211
|
+
if (!decisionRoot && "type" in node && node.type === "object" && "properties" in node) {
|
|
212
|
+
const questions = {};
|
|
213
|
+
const decode = {};
|
|
214
|
+
for (const [name, prop] of Object.entries(node.properties)) {
|
|
215
|
+
const mapped = questionFor(prop, defs, `output property ${JSON.stringify(name)}`, name, context, options.threshold);
|
|
216
|
+
if (!mapped.ok)
|
|
217
|
+
return mapped;
|
|
218
|
+
questions[name] = mapped.question;
|
|
219
|
+
decode[name] = mapped.decode;
|
|
220
|
+
}
|
|
221
|
+
return { ok: true, plan: { state, questions, decode, scalar: false } };
|
|
222
|
+
}
|
|
223
|
+
const mapped = questionFor(schema, defs, "the output type", "value", context, options.threshold);
|
|
224
|
+
if (!mapped.ok)
|
|
225
|
+
return mapped;
|
|
226
|
+
return {
|
|
227
|
+
ok: true,
|
|
228
|
+
plan: { state, questions: { value: mapped.question }, decode: { value: mapped.decode }, scalar: true },
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
//# sourceMappingURL=decisions.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ChatModel, InferModel, InferRequest, ProviderRequest, ProviderResponse } from "@nola-lang/core";
|
|
2
|
+
/**
|
|
3
|
+
* The dialect bridge (decision types spec 2026-09-18 §6.2): a combinator is
|
|
4
|
+
* infer-dialect iff any inner is, and renders for its chat inners. `callModel`
|
|
5
|
+
* is the one place that decides which method an inner gets.
|
|
6
|
+
*/
|
|
7
|
+
export declare function callModel(model: ChatModel | InferModel, req: InferRequest | ProviderRequest): Promise<ProviderResponse>;
|
|
8
|
+
/** An infer request rendered for a chat inner: the rendering replaces the model; everything else rides along. */
|
|
9
|
+
export declare function toClassic(req: InferRequest): ProviderRequest;
|
|
10
|
+
/** True when the request's output schema carries a decision question (either request shape). */
|
|
11
|
+
export declare function isDecisionRequest(req: InferRequest | ProviderRequest): boolean;
|
|
12
|
+
//# sourceMappingURL=dialect.d.ts.map
|
package/dist/dialect.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { findDecisionQuestions, isInferModel, NolaProviderError, renderClassic } from "@nola-lang/core";
|
|
2
|
+
/**
|
|
3
|
+
* The dialect bridge (decision types spec 2026-09-18 §6.2): a combinator is
|
|
4
|
+
* infer-dialect iff any inner is, and renders for its chat inners. `callModel`
|
|
5
|
+
* is the one place that decides which method an inner gets.
|
|
6
|
+
*/
|
|
7
|
+
export async function callModel(model, req) {
|
|
8
|
+
if (isInferModel(model)) {
|
|
9
|
+
if ("model" in req)
|
|
10
|
+
return model.infer(req);
|
|
11
|
+
throw new NolaProviderError(`infer-dialect model "${model.name}" received a classic request — a combinator over it must expose infer()`, { definitive: true });
|
|
12
|
+
}
|
|
13
|
+
return model.complete("model" in req ? toClassic(req) : req);
|
|
14
|
+
}
|
|
15
|
+
/** An infer request rendered for a chat inner: the rendering replaces the model; everything else rides along. */
|
|
16
|
+
export function toClassic(req) {
|
|
17
|
+
const { model, project: _project, ...rest } = req;
|
|
18
|
+
return { payload: renderClassic(model), ...rest };
|
|
19
|
+
}
|
|
20
|
+
/** True when the request's output schema carries a decision question (either request shape). */
|
|
21
|
+
export function isDecisionRequest(req) {
|
|
22
|
+
const output = "model" in req ? req.model.output : req.payload.output;
|
|
23
|
+
return output.syntax === "json" && findDecisionQuestions(output.schema).length > 0;
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=dialect.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -5,8 +5,9 @@ import { openai } from "./openai.js";
|
|
|
5
5
|
import { typesafe } from "./typesafe.js";
|
|
6
6
|
export { type AnthropicOptions, anthropic } from "./anthropic.js";
|
|
7
7
|
export { constant, exponential, fallback, isDefinitiveProviderError, type RetryPolicy, roundRobin, withRetry, } from "./combinators.js";
|
|
8
|
+
export { callModel, isDecisionRequest, toClassic } from "./dialect.js";
|
|
8
9
|
export { type GoogleOptions, google } from "./google.js";
|
|
9
|
-
export { type MockRequest, mockProvider } from "./mock.js";
|
|
10
|
+
export { type MockOptions, type MockRequest, mockProvider } from "./mock.js";
|
|
10
11
|
export { type OpenAiOptions, openai } from "./openai.js";
|
|
11
12
|
export { record, replay } from "./record-replay.js";
|
|
12
13
|
export { type TypesafeOptions, typesafe } from "./typesafe.js";
|
|
@@ -22,4 +23,5 @@ export declare const providers: {
|
|
|
22
23
|
readonly typesafe: typeof typesafe;
|
|
23
24
|
readonly mock: typeof mockProvider;
|
|
24
25
|
};
|
|
26
|
+
export { type DecisionPlan, type DecisionWireQuestion, type DecodeResult, type Decoder, type Instructions, type PlanResult, planFor, } from "./decisions.js";
|
|
25
27
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { openai } from "./openai.js";
|
|
|
5
5
|
import { typesafe } from "./typesafe.js";
|
|
6
6
|
export { anthropic } from "./anthropic.js";
|
|
7
7
|
export { constant, exponential, fallback, isDefinitiveProviderError, roundRobin, withRetry, } from "./combinators.js";
|
|
8
|
+
export { callModel, isDecisionRequest, toClassic } from "./dialect.js";
|
|
8
9
|
export { google } from "./google.js";
|
|
9
10
|
export { mockProvider } from "./mock.js";
|
|
10
11
|
export { openai } from "./openai.js";
|
|
@@ -22,4 +23,5 @@ export const providers = {
|
|
|
22
23
|
typesafe,
|
|
23
24
|
mock: mockProvider,
|
|
24
25
|
};
|
|
26
|
+
export { planFor, } from "./decisions.js";
|
|
25
27
|
//# sourceMappingURL=index.js.map
|
package/dist/mock.d.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import type { LanguageModel, ProviderRequest } from "@nola-lang/core";
|
|
2
2
|
/** What a mock callback sees: the classic request — `payload` IS the rendering (reshape 2026-09-01). */
|
|
3
3
|
export type MockRequest = ProviderRequest;
|
|
4
|
-
export
|
|
4
|
+
export interface MockOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Brand the mock a decision model (decision types spec 2026-09-18 §6.1): a
|
|
7
|
+
* test routing a Choice / Scale / Prob ask through a mock says so
|
|
8
|
+
* explicitly — without it the runtime refuses, as it would for any
|
|
9
|
+
* unbranded model.
|
|
10
|
+
*/
|
|
11
|
+
decisions?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function mockProvider(source: unknown[] | ((req: MockRequest) => unknown), options?: MockOptions): LanguageModel;
|
|
5
14
|
//# sourceMappingURL=mock.d.ts.map
|
package/dist/mock.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
import { DECISION_MODEL } from "@nola-lang/core";
|
|
2
|
+
export function mockProvider(source, options = {}) {
|
|
2
3
|
const queue = Array.isArray(source) ? [...source] : null;
|
|
3
4
|
return {
|
|
5
|
+
...(options.decisions ? { [DECISION_MODEL]: true } : {}),
|
|
4
6
|
name: "mock",
|
|
5
7
|
async complete(req) {
|
|
6
8
|
let value;
|
package/dist/record-replay.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { appendFileSync, readFileSync } from "node:fs";
|
|
2
2
|
import { Codes } from "@nola-lang/ast";
|
|
3
|
-
import { fingerprintRequest, isPlatformModel, NolaConfigError, NolaProviderError, PLATFORM_MODEL, redactDeep, redactSecrets, } from "@nola-lang/core";
|
|
3
|
+
import { DECISION_MODEL, fingerprintRequest, isDecisionModel, isInferModel, isPlatformModel, NolaConfigError, NolaProviderError, PLATFORM_MODEL, redactDeep, redactSecrets, } from "@nola-lang/core";
|
|
4
4
|
export function record(inner, ledgerPath) {
|
|
5
|
-
|
|
5
|
+
// the brands ride along: the platform's (its own rules) and the decision capability
|
|
6
|
+
const brands = {
|
|
7
|
+
...(isPlatformModel(inner) ? { [PLATFORM_MODEL]: true } : {}),
|
|
8
|
+
...(isDecisionModel(inner) ? { [DECISION_MODEL]: true } : {}),
|
|
9
|
+
};
|
|
10
|
+
if (isInferModel(inner)) {
|
|
6
11
|
return {
|
|
7
|
-
|
|
12
|
+
...brands,
|
|
8
13
|
name: `record(${inner.name})`,
|
|
9
14
|
async infer(req) {
|
|
10
15
|
const res = await inner.infer(req);
|
|
@@ -22,10 +27,12 @@ export function record(inner, ledgerPath) {
|
|
|
22
27
|
},
|
|
23
28
|
};
|
|
24
29
|
}
|
|
30
|
+
const chat = inner;
|
|
25
31
|
return {
|
|
32
|
+
...brands,
|
|
26
33
|
name: `record(${inner.name})`,
|
|
27
34
|
async complete(req) {
|
|
28
|
-
const res = await
|
|
35
|
+
const res = await chat.complete(req);
|
|
29
36
|
const entry = {
|
|
30
37
|
fingerprint: fingerprintRequest(req),
|
|
31
38
|
request: { payload: redactDeep(req.payload), ...(req.params ? { params: req.params } : {}) },
|
|
@@ -83,6 +90,8 @@ export function replay(ledgerPath) {
|
|
|
83
90
|
entries.set(e.fingerprint, [hit]);
|
|
84
91
|
});
|
|
85
92
|
return {
|
|
93
|
+
// a ledger serves whatever it holds — a replayed decision ask needs no live capability
|
|
94
|
+
...{ [DECISION_MODEL]: true },
|
|
86
95
|
name: "replay",
|
|
87
96
|
async complete(req) {
|
|
88
97
|
const fingerprint = fingerprintRequest(req);
|
package/dist/typesafe.d.ts
CHANGED
|
@@ -8,14 +8,28 @@ export interface TypesafeOptions {
|
|
|
8
8
|
/** Default: "https://api.typesafe.ai" */
|
|
9
9
|
baseUrl?: string;
|
|
10
10
|
fetch?: typeof globalThis.fetch;
|
|
11
|
+
/**
|
|
12
|
+
* A plain `boolean` is `true` when the probability of yes is STRICTLY above
|
|
13
|
+
* this (default 0.5, so a coin flip is `false`). `params.providerOptions.threshold`
|
|
14
|
+
* on an ask overrides it (the `.withParams` channel — no new syntax).
|
|
15
|
+
*/
|
|
16
|
+
threshold?: number;
|
|
11
17
|
}
|
|
12
18
|
/**
|
|
13
19
|
* typesafe.ai's System One API (model Jev): not a chat model. One request
|
|
14
|
-
* answers named choice / noul questions about a `state`, so this
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
20
|
+
* answers named choice / score / noul questions about a JSON `state`, so this
|
|
21
|
+
* factory is an INFER-dialect decision model (spec 2026-09-18 §6.2): it
|
|
22
|
+
* receives the InferenceModel, sends the contextual values as the state and
|
|
23
|
+
* each property's JSDoc as its question (through the shared `planFor`), serves
|
|
24
|
+
* `Choice` / `Scale` / `Prob` natively beside literal unions and booleans, and
|
|
25
|
+
* fails definitively — before the network — on anything else, which is what
|
|
26
|
+
* lets `fallback([typesafe(), openai("…")])` escalate. A bare string is
|
|
27
|
+
* shorthand for `{ model }`.
|
|
28
|
+
*
|
|
29
|
+
* UNVERIFIED against the live API (no key at implementation time): option
|
|
30
|
+
* labels with spaces/punctuation as `criteria` keys, and the structured
|
|
31
|
+
* `instructions` object. If the API rejects either, the change is confined to
|
|
32
|
+
* `decisions.ts` (`opt_<n>` keys with a decoder map; a joined string).
|
|
19
33
|
*/
|
|
20
34
|
export declare function typesafe(optionsOrModel?: TypesafeOptions | string): LanguageModel;
|
|
21
35
|
//# sourceMappingURL=typesafe.d.ts.map
|
package/dist/typesafe.js
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { DECISION_MODEL, NolaProviderError, parseRetryAfter } from "@nola-lang/core";
|
|
2
|
+
import { planFor } from "./decisions.js";
|
|
3
3
|
const DEFAULT_MODEL = "jev-latest";
|
|
4
4
|
const DEFAULT_BASE_URL = "https://api.typesafe.ai";
|
|
5
|
+
const DEFAULT_THRESHOLD = 0.5;
|
|
5
6
|
/**
|
|
6
7
|
* typesafe.ai's System One API (model Jev): not a chat model. One request
|
|
7
|
-
* answers named choice / noul questions about a `state`, so this
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* answers named choice / score / noul questions about a JSON `state`, so this
|
|
9
|
+
* factory is an INFER-dialect decision model (spec 2026-09-18 §6.2): it
|
|
10
|
+
* receives the InferenceModel, sends the contextual values as the state and
|
|
11
|
+
* each property's JSDoc as its question (through the shared `planFor`), serves
|
|
12
|
+
* `Choice` / `Scale` / `Prob` natively beside literal unions and booleans, and
|
|
13
|
+
* fails definitively — before the network — on anything else, which is what
|
|
14
|
+
* lets `fallback([typesafe(), openai("…")])` escalate. A bare string is
|
|
15
|
+
* shorthand for `{ model }`.
|
|
16
|
+
*
|
|
17
|
+
* UNVERIFIED against the live API (no key at implementation time): option
|
|
18
|
+
* labels with spaces/punctuation as `criteria` keys, and the structured
|
|
19
|
+
* `instructions` object. If the API rejects either, the change is confined to
|
|
20
|
+
* `decisions.ts` (`opt_<n>` keys with a decoder map; a joined string).
|
|
12
21
|
*/
|
|
13
22
|
export function typesafe(optionsOrModel) {
|
|
14
23
|
const options = typeof optionsOrModel === "string" ? { model: optionsOrModel } : (optionsOrModel ?? {});
|
|
@@ -16,29 +25,28 @@ export function typesafe(optionsOrModel) {
|
|
|
16
25
|
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
17
26
|
const model = options.model ?? DEFAULT_MODEL;
|
|
18
27
|
return {
|
|
28
|
+
...{ [DECISION_MODEL]: true },
|
|
19
29
|
name: "typesafe",
|
|
20
|
-
async
|
|
30
|
+
async infer(req) {
|
|
21
31
|
const requestedAt = Date.now();
|
|
22
32
|
const envName = options.apiKeyEnv ?? "TYPESAFE_API_KEY";
|
|
23
33
|
const apiKey = options.apiKey ?? process.env[envName];
|
|
24
34
|
if (!apiKey) {
|
|
25
35
|
throw new NolaProviderError(`TypeSafe API key not found: environment variable ${envName} is not set (checked process.env, including the project .env applied by the Nola loader) and no \`apiKey\` was passed to typesafe(). Fix: set ${envName}, or pass typesafe({ apiKeyEnv: "MY_VAR" }) or typesafe({ apiKey }) in nola.config.ts.`, { definitive: true });
|
|
26
36
|
}
|
|
27
|
-
const
|
|
28
|
-
const
|
|
37
|
+
const askThreshold = req.params?.providerOptions?.threshold;
|
|
38
|
+
const threshold = typeof askThreshold === "number" ? askThreshold : (options.threshold ?? DEFAULT_THRESHOLD);
|
|
39
|
+
// Jev has no conversation: a correction turn (req.model.correction) has
|
|
40
|
+
// nothing to say to it, so the ask is re-answered from the same state.
|
|
41
|
+
const mapped = planFor(req.model, { threshold });
|
|
29
42
|
if (!mapped.ok) {
|
|
30
43
|
throw new NolaProviderError(`TypeSafe cannot serve this ask: ${mapped.reason}`, { definitive: true });
|
|
31
44
|
}
|
|
32
45
|
const { plan } = mapped;
|
|
33
|
-
// Jev has no conversation: the state is the rendering's system text and
|
|
34
|
-
// its first user turn. A correction turn cannot occur in practice (the
|
|
35
|
-
// synthesized reply is schema-valid by construction), so any further
|
|
36
|
-
// messages are ignored and the ask is re-answered from the first turn.
|
|
37
|
-
const state = joinBlocks(system, messages[0]?.content ?? "");
|
|
38
46
|
const res = await doFetch(`${baseUrl}/v1/systemone`, {
|
|
39
47
|
method: "POST",
|
|
40
48
|
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
41
|
-
body: JSON.stringify({ model, state, questions: plan.questions }),
|
|
49
|
+
body: JSON.stringify({ model, state: plan.state, questions: plan.questions }),
|
|
42
50
|
signal: req.signal ?? null,
|
|
43
51
|
});
|
|
44
52
|
if (!res.ok) {
|
|
@@ -59,8 +67,9 @@ export function typesafe(optionsOrModel) {
|
|
|
59
67
|
const value = {};
|
|
60
68
|
for (const [key, decode] of Object.entries(plan.decode)) {
|
|
61
69
|
const decoded = decode(answers[key]);
|
|
62
|
-
if (!decoded.ok)
|
|
70
|
+
if (!decoded.ok) {
|
|
63
71
|
throw new NolaProviderError(`TypeSafe reply is malformed: ${decoded.reason}`, { definitive: true });
|
|
72
|
+
}
|
|
64
73
|
value[key] = decoded.value;
|
|
65
74
|
}
|
|
66
75
|
const durationMs = Date.now() - requestedAt;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nola-lang/providers",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "Nola bring-your-own LLM providers: openai, anthropic, google, typesafe, mock, resilience combinators, record/replay",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nola",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"!dist/**/*.map"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@nola-lang/ast": "0.1.
|
|
41
|
-
"@nola-lang/core": "0.1.
|
|
40
|
+
"@nola-lang/ast": "0.1.12",
|
|
41
|
+
"@nola-lang/core": "0.1.12"
|
|
42
42
|
},
|
|
43
43
|
"engines": {
|
|
44
44
|
"node": ">=22"
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import type { JsonSchema } from "@nola-lang/core";
|
|
2
|
-
/** One question as `POST /v1/systemone` takes it. Only the two primitives Nola output types can express. */
|
|
3
|
-
export type TypesafeQuestion = {
|
|
4
|
-
type: "choice";
|
|
5
|
-
instructions: string;
|
|
6
|
-
criteria: Record<string, string>;
|
|
7
|
-
} | {
|
|
8
|
-
type: "noul";
|
|
9
|
-
instructions: string;
|
|
10
|
-
};
|
|
11
|
-
export type DecodeResult = {
|
|
12
|
-
ok: true;
|
|
13
|
-
value: unknown;
|
|
14
|
-
} | {
|
|
15
|
-
ok: false;
|
|
16
|
-
reason: string;
|
|
17
|
-
};
|
|
18
|
-
/** Turns the wire answer for one question into the JSON value the ask expects. */
|
|
19
|
-
export type Decoder = (answer: unknown) => DecodeResult;
|
|
20
|
-
export interface QuestionPlan {
|
|
21
|
-
/** keyed by property name, or "value" for a scalar root */
|
|
22
|
-
questions: Record<string, TypesafeQuestion>;
|
|
23
|
-
decode: Record<string, Decoder>;
|
|
24
|
-
/** true ⇔ the reply is the bare "value" answer, not an object of answers */
|
|
25
|
-
scalar: boolean;
|
|
26
|
-
}
|
|
27
|
-
export type MappingResult = {
|
|
28
|
-
ok: true;
|
|
29
|
-
plan: QuestionPlan;
|
|
30
|
-
} | {
|
|
31
|
-
ok: false;
|
|
32
|
-
reason: string;
|
|
33
|
-
};
|
|
34
|
-
/** The questions and decoders for an ask's output schema, or why Jev cannot serve it. */
|
|
35
|
-
export declare function questionsFor(schema: JsonSchema | undefined): MappingResult;
|
|
36
|
-
//# sourceMappingURL=typesafe-questions.d.ts.map
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
const SERVES = "typesafe() serves only literal unions and booleans";
|
|
2
|
-
const SCALAR_INSTRUCTIONS = "Determine the value the request asks for.";
|
|
3
|
-
function fail(reason) {
|
|
4
|
-
return { ok: false, reason };
|
|
5
|
-
}
|
|
6
|
-
/** `labels` maps each wire label to the JSON value it stands for (the literal itself, or its number). */
|
|
7
|
-
function choiceQuestion(key, instructions, labels) {
|
|
8
|
-
const criteria = {};
|
|
9
|
-
for (const label of labels.keys())
|
|
10
|
-
criteria[label] = label;
|
|
11
|
-
const decode = (answer) => {
|
|
12
|
-
if (answer === undefined || answer === null)
|
|
13
|
-
return fail(`answer "${key}" is missing from the reply`);
|
|
14
|
-
const choice = answer.choice;
|
|
15
|
-
if (typeof choice !== "string" || !labels.has(choice)) {
|
|
16
|
-
return fail(`answer "${key}" chose ${JSON.stringify(choice)}, which is not one of the options sent`);
|
|
17
|
-
}
|
|
18
|
-
return { ok: true, value: labels.get(choice) };
|
|
19
|
-
};
|
|
20
|
-
return { ok: true, question: { type: "choice", instructions, criteria }, decode };
|
|
21
|
-
}
|
|
22
|
-
function noulQuestion(key, instructions) {
|
|
23
|
-
const decode = (answer) => {
|
|
24
|
-
if (answer === undefined || answer === null)
|
|
25
|
-
return fail(`answer "${key}" is missing from the reply`);
|
|
26
|
-
const noul = answer.noul;
|
|
27
|
-
if (typeof noul !== "number" || Number.isNaN(noul))
|
|
28
|
-
return fail(`answer "${key}" has no numeric noul`);
|
|
29
|
-
return { ok: true, value: noul >= 0.5 };
|
|
30
|
-
};
|
|
31
|
-
return { ok: true, question: { type: "noul", instructions }, decode };
|
|
32
|
-
}
|
|
33
|
-
/** Follow a `$ref` chain through the root `$defs`; the FIRST description seen along the chain wins. */
|
|
34
|
-
function resolve(node, defs, path) {
|
|
35
|
-
let current = node;
|
|
36
|
-
let description = node.description;
|
|
37
|
-
const seen = new Set();
|
|
38
|
-
while ("$ref" in current) {
|
|
39
|
-
const ref = current.$ref;
|
|
40
|
-
if (seen.has(ref))
|
|
41
|
-
return fail(`${path} is a cyclic reference ${JSON.stringify(ref)}; ${SERVES}`);
|
|
42
|
-
seen.add(ref);
|
|
43
|
-
const name = /^#\/\$defs\/(.+)$/.exec(ref)?.[1];
|
|
44
|
-
const next = name ? defs?.[name] : undefined;
|
|
45
|
-
if (!next)
|
|
46
|
-
return fail(`${path} is an unresolved reference ${JSON.stringify(ref)}; ${SERVES}`);
|
|
47
|
-
current = next;
|
|
48
|
-
description ??= current.description;
|
|
49
|
-
}
|
|
50
|
-
return description === undefined ? { ok: true, node: current } : { ok: true, node: current, description };
|
|
51
|
-
}
|
|
52
|
-
/** What an unsupported node is, in the words of the failure reason. */
|
|
53
|
-
function kindOf(node) {
|
|
54
|
-
if ("const" in node)
|
|
55
|
-
return "a single literal";
|
|
56
|
-
if ("anyOf" in node)
|
|
57
|
-
return "a union that is not all string literals or all number literals";
|
|
58
|
-
if ("type" in node) {
|
|
59
|
-
switch (node.type) {
|
|
60
|
-
case "string":
|
|
61
|
-
return node.format === "date-time" ? "a date-time string" : "a free-form string";
|
|
62
|
-
case "number":
|
|
63
|
-
case "integer":
|
|
64
|
-
return "a number";
|
|
65
|
-
case "array":
|
|
66
|
-
return "an array";
|
|
67
|
-
case "object":
|
|
68
|
-
return "properties" in node ? "a nested object" : "a record";
|
|
69
|
-
case "null":
|
|
70
|
-
return "null";
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return "an unsupported shape";
|
|
74
|
-
}
|
|
75
|
-
/** Map one schema node to a question. `path` names the node in failure reasons; `key` is the answer key. */
|
|
76
|
-
function questionFor(raw, defs, path, key) {
|
|
77
|
-
const resolved = resolve(raw, defs, path);
|
|
78
|
-
if (!resolved.ok)
|
|
79
|
-
return resolved;
|
|
80
|
-
const { node, description } = resolved;
|
|
81
|
-
const instructions = description ?? (key === "value" ? SCALAR_INSTRUCTIONS : `Determine "${key}".`);
|
|
82
|
-
if ("type" in node && node.type === "boolean")
|
|
83
|
-
return noulQuestion(key, instructions);
|
|
84
|
-
if ("type" in node && node.type === "string" && node.enum) {
|
|
85
|
-
return choiceQuestion(key, instructions, new Map(node.enum.map((label) => [label, label])));
|
|
86
|
-
}
|
|
87
|
-
if ("anyOf" in node && node.anyOf.length >= 2) {
|
|
88
|
-
const consts = node.anyOf.map((branch) => ("const" in branch ? branch.const : undefined));
|
|
89
|
-
if (consts.every((c) => typeof c === "string")) {
|
|
90
|
-
return choiceQuestion(key, instructions, new Map(consts.map((c) => [c, c])));
|
|
91
|
-
}
|
|
92
|
-
if (consts.every((c) => typeof c === "number")) {
|
|
93
|
-
return choiceQuestion(key, instructions, new Map(consts.map((c) => [String(c), c])));
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return fail(`${path} is ${kindOf(node)}; ${SERVES}`);
|
|
97
|
-
}
|
|
98
|
-
/** The questions and decoders for an ask's output schema, or why Jev cannot serve it. */
|
|
99
|
-
export function questionsFor(schema) {
|
|
100
|
-
if (!schema)
|
|
101
|
-
return fail(`the ask has no output schema (free text); ${SERVES}`);
|
|
102
|
-
const defs = "$defs" in schema ? schema.$defs : undefined;
|
|
103
|
-
const root = resolve(schema, defs, "the output type");
|
|
104
|
-
if (!root.ok)
|
|
105
|
-
return root;
|
|
106
|
-
const node = root.node;
|
|
107
|
-
if ("type" in node && node.type === "object" && "properties" in node) {
|
|
108
|
-
const questions = {};
|
|
109
|
-
const decode = {};
|
|
110
|
-
for (const [name, prop] of Object.entries(node.properties)) {
|
|
111
|
-
const mapped = questionFor(prop, defs, `output property ${JSON.stringify(name)}`, name);
|
|
112
|
-
if (!mapped.ok)
|
|
113
|
-
return mapped;
|
|
114
|
-
questions[name] = mapped.question;
|
|
115
|
-
decode[name] = mapped.decode;
|
|
116
|
-
}
|
|
117
|
-
return { ok: true, plan: { questions, decode, scalar: false } };
|
|
118
|
-
}
|
|
119
|
-
const mapped = questionFor(schema, defs, "the output type", "value");
|
|
120
|
-
if (!mapped.ok)
|
|
121
|
-
return mapped;
|
|
122
|
-
return { ok: true, plan: { questions: { value: mapped.question }, decode: { value: mapped.decode }, scalar: true } };
|
|
123
|
-
}
|
|
124
|
-
//# sourceMappingURL=typesafe-questions.js.map
|