@tangleai/models 0.21.1

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/src/retry.js ADDED
@@ -0,0 +1,182 @@
1
+ //@ts-check
2
+ /**
3
+ * The transport policy every client in this package shares: which
4
+ * failures are transient, how long to wait before trying again, what a
5
+ * provider's `Retry-After` is worth, and how an abort cuts a wait
6
+ * short. The chat wire and the embeddings wire fail alike at the
7
+ * transport — a network error, a 408, a 429, a 5xx, a 200 whose body is
8
+ * not what the wire promised — and differ only in what a good reply
9
+ * must carry. So the loop lives here, once, and each client keeps its
10
+ * own reading of a reply: one implementation of retry, two wire shapes.
11
+ *
12
+ * Everything here is internal to the package; the public surface is
13
+ * the `retry` option each client documents.
14
+ */
15
+
16
+ import { AiError } from './errors.js';
17
+ import { backoffDelay, parseRetryAfter, sleep as defaultSleep, abortError } from '@jarenjs/core/retry';
18
+ export { abortError };
19
+
20
+ /**
21
+ * The `retry` option of every client.
22
+ * @typedef {Object} RetryOptions
23
+ * @property {number} [attempts] - the TOTAL number of tries (default 3;
24
+ * 1 disables retrying)
25
+ * @property {number} [baseMs] - the first backoff (default 500); each
26
+ * later one doubles, with full jitter
27
+ * @property {number} [maxMs] - the ceiling on any single wait (default
28
+ * 8 000) — a provider `Retry-After` included: a provider asking for a
29
+ * minute gets the cap, and the value it asked for rides the final
30
+ * error as `retryAfterMs` for the caller to honour
31
+ * @property {() => number} [random] - the jitter source, for
32
+ * deterministic tests
33
+ * @property {(ms: number, signal?: AbortSignal) => Promise<void>} [sleep]
34
+ * - the wait itself, for deterministic tests; the default is a timer
35
+ * that rejects with the abort reason the moment `signal` aborts
36
+ */
37
+
38
+ /**
39
+ * The option with its defaults filled in.
40
+ * @typedef {Object} RetryPolicy
41
+ * @property {number} attempts
42
+ * @property {number} baseMs
43
+ * @property {number} maxMs
44
+ * @property {() => number} random
45
+ * @property {(ms: number, signal?: AbortSignal) => Promise<void>} sleep
46
+ */
47
+
48
+ /**
49
+ * @param {RetryOptions | undefined} retry
50
+ * @returns {RetryPolicy}
51
+ */
52
+ export function normalizeRetry(retry) {
53
+ return {
54
+ attempts: Math.max(1, retry?.attempts ?? 3),
55
+ baseMs: retry?.baseMs ?? 500,
56
+ maxMs: retry?.maxMs ?? 8000,
57
+ random: retry?.random ?? Math.random,
58
+ sleep: retry?.sleep ?? defaultSleep,
59
+ };
60
+ }
61
+
62
+ /** Statuses worth a retry: timeout, rate limit, server-side failure. */
63
+ function isRetryableStatus(status) {
64
+ return status === 0 || status === 408 || status === 429 || status >= 500;
65
+ }
66
+
67
+ /**
68
+ * Whether a failure is the transient kind. A transport error with a
69
+ * retryable status is (a network failure before any response counts
70
+ * as status 0); so is a malformed 200 — a reply that carried none of
71
+ * what the wire promised is a provider hiccup, common on busy cheap
72
+ * tiers, and safe to retry precisely because nothing was delivered.
73
+ * Anything else — a caller error, a 401, a 404 — is final on the first
74
+ * try.
75
+ * @param {unknown} err
76
+ * @returns {boolean}
77
+ */
78
+ export function isTransientFailure(err) {
79
+ if (!(err instanceof AiError)) return false;
80
+ return err.code === 'AI0003'
81
+ || (err.code === 'AI0002' && isRetryableStatus(err.status ?? -1));
82
+ }
83
+
84
+ /**
85
+ * The wait before the next try: exponential backoff with full jitter,
86
+ * capped at `maxMs` — unless the provider named a `Retry-After`, which
87
+ * wins up to the same cap.
88
+ * @param {RetryPolicy} policy
89
+ * @param {number} attempt - the try that just failed, counted from 1
90
+ * @param {number | undefined} retryAfter - the provider's ask, in ms
91
+ * @returns {number} milliseconds
92
+ */
93
+ export function retryDelay(policy, attempt, retryAfter) {
94
+ return backoffDelay({ ...policy, policy: 'ai-compat' }, attempt, retryAfter);
95
+ }
96
+
97
+ /**
98
+ * Run `once` until it settles. A failure `retryable` accepts backs off
99
+ * and tries again while tries remain; anything else is thrown as it
100
+ * came, and a coded transport failure (`AI0002`, `AI0003`) carries the
101
+ * number of tries as `attempts`. The wait honours `signal`: an abort
102
+ * during backoff rejects with the abort reason, exactly like an abort
103
+ * during the request — nothing is ever retried past an abort.
104
+ * @template T
105
+ * @param {RetryPolicy} policy
106
+ * @param {() => Promise<T>} once - one request/response cycle
107
+ * @param {{ signal?: AbortSignal, retryable: (failure: AiError) => boolean }} options
108
+ * - `retryable` is the wire's own judgment over a coded failure (the
109
+ * chat client, for one, stops retrying once a streamed delta has
110
+ * reached the caller); it is never asked about an uncoded error
111
+ * @returns {Promise<T>}
112
+ */
113
+ export async function withRetry(policy, once, options) {
114
+ for (let attempt = 1; ; attempt++) {
115
+ try {
116
+ return await once();
117
+ }
118
+ catch (err) {
119
+ if (options.signal?.aborted) throw abortError(options.signal);
120
+ const failure = /** @type {any} */ (err);
121
+ const coded = failure instanceof AiError;
122
+ if (!(coded && options.retryable(failure) && attempt < policy.attempts)) {
123
+ if (coded && (failure.code === 'AI0002' || failure.code === 'AI0003'))
124
+ failure.attempts = attempt;
125
+ throw err;
126
+ }
127
+ await policy.sleep(retryDelay(policy, attempt, failure.retryAfterMs), options.signal);
128
+ }
129
+ }
130
+ }
131
+
132
+ /**
133
+ * The `AI0002` for a response that is not ok: the status, a short
134
+ * excerpt of the body, and the provider's `Retry-After` in ms.
135
+ * @param {any} response
136
+ * @param {string} url
137
+ * @returns {Promise<AiError>}
138
+ */
139
+ export async function httpFailure(response, url) {
140
+ const excerpt = await readErrorExcerpt(response);
141
+ return new AiError('AI0002',
142
+ `HTTP ${response.status} from ${url}${excerpt === '' ? '' : `: ${excerpt}`}`,
143
+ { status: response.status, retryAfterMs: retryAfterMs(response) });
144
+ }
145
+
146
+ /**
147
+ * What to throw when `fetch` itself threw: an abort exactly as it came
148
+ * (the caller's own signal, never retried, never rewrapped); anything
149
+ * else the `AI0002` of a failure before any response, status 0.
150
+ * @param {any} err
151
+ * @param {string} url
152
+ * @returns {any}
153
+ */
154
+ export function transportFailure(err, url) {
155
+ if (err?.name === 'AbortError') return err;
156
+ return new AiError('AI0002',
157
+ `network error calling ${url}: ${err?.message ?? err}`,
158
+ { status: 0, cause: err });
159
+ }
160
+
161
+ /**
162
+ * Parse a `Retry-After` header (delta-seconds or HTTP-date) into ms.
163
+ * @param {any} response
164
+ * @returns {number | undefined}
165
+ */
166
+ export function retryAfterMs(response) {
167
+ return parseRetryAfter(response?.headers?.get?.('retry-after'));
168
+ }
169
+
170
+ /**
171
+ * @param {any} response
172
+ * @returns {Promise<string>} a short excerpt of the error body
173
+ */
174
+ async function readErrorExcerpt(response) {
175
+ try {
176
+ const text = await response.text();
177
+ return text.length > 300 ? `${text.slice(0, 300)}…` : text;
178
+ }
179
+ catch {
180
+ return '';
181
+ }
182
+ }
@@ -0,0 +1,32 @@
1
+ /** @typedef {{ deadlineMs?: number, outputTokens?: number, reasoningTokens?: number }} ModelLimits */
2
+ /** @typedef {{ purpose: 'author'|'subcall'|'embedding'|'stylesheet', grammar?: string, depth?: number, limits?: ModelLimits }} ModelContext */
3
+ /** A selector returns a route or ordered fallback routes: `{client, identity}`.
4
+ * A route identity is host data; it never controls provider policy. Embedding callers
5
+ * use their embedding API directly: this chat wrapper refuses that purpose so a
6
+ * fallback cannot silently change an index's embedding identity.
7
+ * @param {{ client: any, selectModel?: (context: ModelContext) => any,
8
+ * limits?: ModelLimits, onRoute?: (event: any) => void, account?: any }} options
9
+ * @param {ModelContext} context */
10
+ export function createRoutedClient(options: {
11
+ client: any;
12
+ selectModel?: (context: ModelContext) => any;
13
+ limits?: ModelLimits;
14
+ onRoute?: (event: any) => void;
15
+ account?: any;
16
+ }, context: ModelContext): {
17
+ endpoint: any;
18
+ complete(request: any): Promise<any>;
19
+ };
20
+ /** Optional host routing for model-only calls. No tools execute inside this boundary. */
21
+ export const MODEL_PURPOSES: readonly string[];
22
+ export type ModelLimits = {
23
+ deadlineMs?: number;
24
+ outputTokens?: number;
25
+ reasoningTokens?: number;
26
+ };
27
+ export type ModelContext = {
28
+ purpose: "author" | "subcall" | "embedding" | "stylesheet";
29
+ grammar?: string;
30
+ depth?: number;
31
+ limits?: ModelLimits;
32
+ };
package/src/routing.js ADDED
@@ -0,0 +1,87 @@
1
+ //@ts-check
2
+ /** Optional host routing for model-only calls. No tools execute inside this boundary. */
3
+ export const MODEL_PURPOSES = Object.freeze(['author', 'subcall', 'embedding', 'stylesheet']);
4
+
5
+ /** @typedef {{ deadlineMs?: number, outputTokens?: number, reasoningTokens?: number }} ModelLimits */
6
+ /** @typedef {{ purpose: 'author'|'subcall'|'embedding'|'stylesheet', grammar?: string, depth?: number, limits?: ModelLimits }} ModelContext */
7
+
8
+ /** A selector returns a route or ordered fallback routes: `{client, identity}`.
9
+ * A route identity is host data; it never controls provider policy. Embedding callers
10
+ * use their embedding API directly: this chat wrapper refuses that purpose so a
11
+ * fallback cannot silently change an index's embedding identity.
12
+ * @param {{ client: any, selectModel?: (context: ModelContext) => any,
13
+ * limits?: ModelLimits, onRoute?: (event: any) => void, account?: any }} options
14
+ * @param {ModelContext} context */
15
+ export function createRoutedClient(options, context) {
16
+ if (!MODEL_PURPOSES.includes(context.purpose) || context.purpose === 'embedding')
17
+ throw new TypeError('chat routing requires author, stylesheet or subcall purpose');
18
+ const limits = { ...options.limits, ...context.limits };
19
+ for (const [name, value] of Object.entries(limits)) {
20
+ if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${name} must be a positive integer`);
21
+ }
22
+ return {
23
+ endpoint: options.client.endpoint,
24
+ async complete(request) {
25
+ if (request.tools?.length || request.toolChoice !== undefined)
26
+ throw new TypeError('model routing does not retry tool-bearing requests');
27
+ const choice = options.selectModel ? await options.selectModel({ ...context, limits }) : null;
28
+ const routes = choice === null ? [{ client: options.client, identity: 'default' }]
29
+ : Array.isArray(choice) ? choice : [choice];
30
+ if (routes.length === 0) throw new TypeError('selectModel returned no routes');
31
+ let last;
32
+ for (const [index, route] of routes.entries()) {
33
+ if (!route?.client?.complete || typeof route.identity !== 'string' || !route.identity)
34
+ throw new TypeError('a model route needs client and identity');
35
+ request.signal?.throwIfAborted();
36
+ const stopped = options.account?.stop();
37
+ if (stopped) throw new Error(stopped);
38
+ const controller = new AbortController();
39
+ const remaining = options.account?.remaining?.().ms;
40
+ const deadline = Math.min(limits.deadlineMs ?? Infinity, remaining ?? Infinity);
41
+ const signal = request.signal
42
+ ? Number.isFinite(deadline) ? AbortSignal.any([request.signal, controller.signal]) : request.signal
43
+ : controller.signal;
44
+ let timer;
45
+ const started = performance.now();
46
+ const event = { ...context, identity: route.identity, fallback: index, limits, outcome: 'provider', ms: 0, usage: null,
47
+ schemaBytes: request.responseFormat?.schema ? new TextEncoder().encode(JSON.stringify(request.responseFormat.schema)).length : 0,
48
+ finishReason: null, errorCode: null };
49
+ try {
50
+ options.account?.reserve();
51
+ const bounded = { ...request, signal,
52
+ ...(limits.outputTokens ? { maxTokens: Math.min(request.maxTokens ?? Infinity, limits.outputTokens) } : {}),
53
+ ...(limits.reasoningTokens ? { reasoning: { ...request.reasoning,
54
+ max_tokens: Math.min(request.reasoning?.max_tokens ?? Infinity, limits.reasoningTokens) } } : {}),
55
+ };
56
+ const pending = Promise.resolve().then(() => route.client.complete(bounded));
57
+ const reply = await Promise.race([pending, new Promise((_, reject) => {
58
+ const abort = () => reject(signal.reason ?? new Error('aborted'));
59
+ signal.addEventListener('abort', abort, { once: true });
60
+ // Remove the listener when even an abort-ignoring client eventually settles.
61
+ pending.then(() => signal.removeEventListener('abort', abort), () => signal.removeEventListener('abort', abort));
62
+ if (Number.isFinite(deadline)) timer = setTimeout(() => controller.abort(new Error('model deadline')), deadline);
63
+ })]);
64
+ event.usage = reply.usage ?? null;
65
+ event.finishReason = reply.finishReason ?? null;
66
+ options.account?.settle(reply.usage, JSON.stringify(request.messages) + String(reply.message?.content ?? ''));
67
+ const usage = reply.usage;
68
+ if ((limits.outputTokens && usage?.completion_tokens > limits.outputTokens)
69
+ || (limits.reasoningTokens && usage?.completion_tokens_details?.reasoning_tokens > limits.reasoningTokens)) {
70
+ event.outcome = 'token-limit';
71
+ throw new Error('provider exceeded model token ceiling');
72
+ }
73
+ event.outcome = 'complete';
74
+ return reply;
75
+ }
76
+ catch (error) {
77
+ last = error;
78
+ event.errorCode = error?.code ?? error?.name ?? 'Error';
79
+ if (controller.signal.aborted) event.outcome = 'timeout';
80
+ if (request.signal?.aborted) { event.outcome = 'aborted'; throw error; }
81
+ }
82
+ finally { clearTimeout(timer); event.ms = performance.now() - started; options.onRoute?.(event); }
83
+ }
84
+ throw last;
85
+ },
86
+ };
87
+ }
package/src/sse.d.ts ADDED
@@ -0,0 +1 @@
1
+ export { createSseDecoder } from "@jarenjs/core/text/sse";
package/src/sse.js ADDED
@@ -0,0 +1,12 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The `@tangleai/models/sse` subpath: the data-only Server-Sent-
4
+ * Events decoder the streaming client consumes — the one SSE codec of
5
+ * the suite, which lives in `@jarenjs/core/text/sse` and is re-exported
6
+ * here so this package's public surface is unchanged. Feed it network
7
+ * chunks in any split (mid-line, mid-event, CR/LF/CRLF); it yields the
8
+ * complete `data:` payloads in order; non-data fields and comments are
9
+ * ignored, multi-line data joins with a newline per the specification.
10
+ */
11
+
12
+ export { createSseDecoder } from '@jarenjs/core/text/sse';
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Strip an accidental markdown fence from a reply ("```json … ```") —
3
+ * the classic prompt-embedded-schema failure mode.
4
+ *
5
+ * Exported because every place this package parses a model's JSON has to
6
+ * make the same allowance, and two copies of "how forgiving are we about
7
+ * fences" is two answers to one question: `program.js`'s sub-calls parse
8
+ * replies the same way structured generation does.
9
+ * @param {string} text
10
+ */
11
+ export function unfence(text: string): string;
12
+ /**
13
+ * Create a structured-output generator over a chat client.
14
+ *
15
+ * @param {{ client: { endpoint: { provider: string }, complete: (request: any) => Promise<any> },
16
+ * schema: any,
17
+ * name?: string,
18
+ * strict?: boolean,
19
+ * validator?: (value: any) => any,
20
+ * refs?: any[],
21
+ * gate?: ((value: any) => any) | Array<(value: any) => any>,
22
+ * stream?: boolean,
23
+ * onAttempt?: (event: { attempt: number, outcome: string, errors: any[] }) => void,
24
+ * maxRepairs?: number }} options
25
+ * - `validator` overrides the internally compiled check (any
26
+ * function returning a boolean or `{ valid, errors }`).
27
+ * - `refs` are other JSON Schemas the `schema` references by `$id`,
28
+ * registered before it is compiled — every Jaren engine-document
29
+ * schema composes the published query/JSLT grammars by `$ref`, so
30
+ * authoring an fsm/dag/app document needs them here (e.g. `refs:
31
+ * [querySchema]`). Ignored when `validator` is given.
32
+ * - `gate` is one or more extra checks run AFTER schema validation
33
+ * (the schema still drives constrained decoding): the reliable way
34
+ * to author an engine document — the schema keeps the *shape*, a
35
+ * `compile` gate keeps the *semantics*. A gate is
36
+ * `(doc) => { try { compile(doc); return true; } catch (e) {
37
+ * return { valid: false, errors: [{ code: e.code, docPath:
38
+ * e.docPath, message: e.reason ?? e.message }] }; } }`; its coded, docPath'd
39
+ * errors go back to the model for repair (compile errors repair
40
+ * well — they are precise). Composes with `validator` when both are
41
+ * given (validator first). NOTE: a *quality/adequacy* gate — "needs
42
+ * ≥N of something" — repairs poorly on weaker models (they patch
43
+ * narrowly); enforce breadth in the prompt and reserve gates for
44
+ * schema/compile correctness.
45
+ * - `stream` sends the request streamed (default `false`). The parsed
46
+ * value is identical either way — the accumulator reassembles the
47
+ * reply before it is parsed — so this buys observability (a client's
48
+ * `onDelta`/`onReasoning` fire) and nothing else. It is opt-in
49
+ * because turning it on for every caller is a behaviour change for
50
+ * callers who never asked for one.
51
+ * - `maxRepairs` is how many failed rounds may go back to the model
52
+ * with the validation errors (default 1).
53
+ * @returns {{ generate: (messages: any[], hooks?: { signal?: AbortSignal }) => Promise<
54
+ * { value: any, raw: string, attempts: number } |
55
+ * { errors: any[], raw: string, attempts: number }> }}
56
+ */
57
+ export function createStructuredOutput(options: {
58
+ client: {
59
+ endpoint: {
60
+ provider: string;
61
+ };
62
+ complete: (request: any) => Promise<any>;
63
+ };
64
+ schema: any;
65
+ name?: string;
66
+ strict?: boolean;
67
+ validator?: (value: any) => any;
68
+ refs?: any[];
69
+ gate?: ((value: any) => any) | Array<(value: any) => any>;
70
+ stream?: boolean;
71
+ onAttempt?: (event: {
72
+ attempt: number;
73
+ outcome: string;
74
+ errors: any[];
75
+ }) => void;
76
+ maxRepairs?: number;
77
+ }): {
78
+ generate: (messages: any[], hooks?: {
79
+ signal?: AbortSignal;
80
+ }) => Promise<{
81
+ value: any;
82
+ raw: string;
83
+ attempts: number;
84
+ } | {
85
+ errors: any[];
86
+ raw: string;
87
+ attempts: number;
88
+ }>;
89
+ };
@@ -0,0 +1,219 @@
1
+ //@ts-check
2
+ /**
3
+ * Structured output: schema in, validated JSON value out — on every
4
+ * provider tier.
5
+ *
6
+ * The helper sends one (non-streaming) chat request constrained by a
7
+ * JSON Schema, using the strongest mechanism the provider speaks
8
+ * (`response_format: json_schema` → JSON mode → schema embedded in a
9
+ * system instruction), parses the reply, and validates it locally with
10
+ * `@jarenjs/validate` — the same validator that guards the toolbox.
11
+ * Local validation is never optional: provider structured-output
12
+ * implementations enforce varying schema subsets, so the server is an
13
+ * accelerator, not an authority. On failure the instancePath'd errors
14
+ * go back to the model for a bounded number of repair rounds.
15
+ */
16
+
17
+ import { JarenValidator } from '@jarenjs/validate';
18
+ import { checkOutcome, composeChecks } from '@jarenjs/core/check';
19
+ import { PROVIDERS } from './providers.js';
20
+
21
+ /** Validation errors reported per failed generation: enough to repair. */
22
+ const MAX_ERRORS = 8;
23
+
24
+ /**
25
+ * Normalize an injected check's errors into the compact records a
26
+ * repair prompt carries. The default `JarenValidator` check reports
27
+ * `{ instancePath, keyword, message }`; a compiler check (a Jaren
28
+ * engine caught into an outcome) reports `{ code, docPath, reason }`.
29
+ * Both are the same idea — a location and a reason — so a compile error
30
+ * keeps its `code` and its `docPath` here rather than being flattened
31
+ * into a location-less message. `docPath` (the engine's pointer into
32
+ * the offending document) wins over `instancePath` when both appear;
33
+ * `''` is a real docPath (the document root) and only `undefined`
34
+ * falls through. `reason` (the bare text of a coded error) wins over
35
+ * `message` so the record never carries the code and the path twice —
36
+ * the composed `message` already contains both, and the fields beside
37
+ * it are the structured copies.
38
+ * @param {any[]} raw
39
+ */
40
+ function normalizeErrors(raw) {
41
+ return raw.slice(0, MAX_ERRORS).map((e) => {
42
+ /** @type {any} */
43
+ const out = {
44
+ instancePath: e.docPath ?? e.instancePath ?? '',
45
+ keyword: e.code ?? e.keyword ?? '',
46
+ message: e.reason ?? e.message ?? 'invalid',
47
+ };
48
+ if (e.code !== undefined) out.code = e.code;
49
+ if (e.docPath !== undefined) out.docPath = e.docPath;
50
+ return out;
51
+ });
52
+ }
53
+
54
+ /**
55
+ * The system instruction for providers that cannot (fully) constrain
56
+ * decoding: the schema travels in the prompt and the reply must be the
57
+ * bare JSON value.
58
+ * @param {any} schema
59
+ */
60
+ function schemaInstruction(schema) {
61
+ return [
62
+ 'Reply with a single JSON value that validates against this JSON Schema.',
63
+ 'Output ONLY the JSON — no prose, no code fences.',
64
+ '',
65
+ JSON.stringify(schema),
66
+ ].join('\n');
67
+ }
68
+
69
+ /**
70
+ * Strip an accidental markdown fence from a reply ("```json … ```") —
71
+ * the classic prompt-embedded-schema failure mode.
72
+ *
73
+ * Exported because every place this package parses a model's JSON has to
74
+ * make the same allowance, and two copies of "how forgiving are we about
75
+ * fences" is two answers to one question: `program.js`'s sub-calls parse
76
+ * replies the same way structured generation does.
77
+ * @param {string} text
78
+ */
79
+ export function unfence(text) {
80
+ const trimmed = text.trim();
81
+ const match = /^```(?:json)?\s*([\s\S]*?)\s*```$/.exec(trimmed);
82
+ return match === null ? trimmed : match[1];
83
+ }
84
+
85
+ /**
86
+ * Compile a schema that may reference others by `$id`, registering the
87
+ * referenced schemas first. Every Jaren engine-document grammar
88
+ * (fsm/dag/app/JSLT) composes the query grammar by `$ref`, so a plain
89
+ * `.compile(schema)` on one of them throws "Can not resolve schema".
90
+ * @param {any} schema
91
+ * @param {any[]} [refs]
92
+ * @returns {(value: any) => any}
93
+ */
94
+ function compileWithRefs(schema, refs) {
95
+ const v = new JarenValidator({ skipErrors: false, collectErrors: true });
96
+ for (const ref of refs ?? []) v.addSchema(ref);
97
+ return v.compile(schema);
98
+ }
99
+
100
+ /**
101
+ * Create a structured-output generator over a chat client.
102
+ *
103
+ * @param {{ client: { endpoint: { provider: string }, complete: (request: any) => Promise<any> },
104
+ * schema: any,
105
+ * name?: string,
106
+ * strict?: boolean,
107
+ * validator?: (value: any) => any,
108
+ * refs?: any[],
109
+ * gate?: ((value: any) => any) | Array<(value: any) => any>,
110
+ * stream?: boolean,
111
+ * onAttempt?: (event: { attempt: number, outcome: string, errors: any[] }) => void,
112
+ * maxRepairs?: number }} options
113
+ * - `validator` overrides the internally compiled check (any
114
+ * function returning a boolean or `{ valid, errors }`).
115
+ * - `refs` are other JSON Schemas the `schema` references by `$id`,
116
+ * registered before it is compiled — every Jaren engine-document
117
+ * schema composes the published query/JSLT grammars by `$ref`, so
118
+ * authoring an fsm/dag/app document needs them here (e.g. `refs:
119
+ * [querySchema]`). Ignored when `validator` is given.
120
+ * - `gate` is one or more extra checks run AFTER schema validation
121
+ * (the schema still drives constrained decoding): the reliable way
122
+ * to author an engine document — the schema keeps the *shape*, a
123
+ * `compile` gate keeps the *semantics*. A gate is
124
+ * `(doc) => { try { compile(doc); return true; } catch (e) {
125
+ * return { valid: false, errors: [{ code: e.code, docPath:
126
+ * e.docPath, message: e.reason ?? e.message }] }; } }`; its coded, docPath'd
127
+ * errors go back to the model for repair (compile errors repair
128
+ * well — they are precise). Composes with `validator` when both are
129
+ * given (validator first). NOTE: a *quality/adequacy* gate — "needs
130
+ * ≥N of something" — repairs poorly on weaker models (they patch
131
+ * narrowly); enforce breadth in the prompt and reserve gates for
132
+ * schema/compile correctness.
133
+ * - `stream` sends the request streamed (default `false`). The parsed
134
+ * value is identical either way — the accumulator reassembles the
135
+ * reply before it is parsed — so this buys observability (a client's
136
+ * `onDelta`/`onReasoning` fire) and nothing else. It is opt-in
137
+ * because turning it on for every caller is a behaviour change for
138
+ * callers who never asked for one.
139
+ * - `maxRepairs` is how many failed rounds may go back to the model
140
+ * with the validation errors (default 1).
141
+ * @returns {{ generate: (messages: any[], hooks?: { signal?: AbortSignal }) => Promise<
142
+ * { value: any, raw: string, attempts: number } |
143
+ * { errors: any[], raw: string, attempts: number }> }}
144
+ */
145
+ export function createStructuredOutput(options) {
146
+ const { client, schema } = options;
147
+ if (schema === null || typeof schema !== 'object')
148
+ throw new TypeError('createStructuredOutput needs a JSON Schema object');
149
+ const name = options.name ?? 'result';
150
+ const maxRepairs = options.maxRepairs ?? 1;
151
+ // Non-streaming stays the DEFAULT, and the reason is a behaviour change
152
+ // rather than a preference: a caller reading `raw` or counting attempts
153
+ // gets the same answer either way, but a caller that passed a client
154
+ // with `onDelta` wired sees deltas start arriving where none did
155
+ // before. The measurement that made this an option: the campaign's
156
+ // authoring timeouts were on STREAMED calls, so streaming is not the
157
+ // cure for a slow authoring turn — but it is how a long turn stays
158
+ // observable, and a caller who wants that should not have to wrap the
159
+ // client to get it.
160
+ const stream = options.stream ?? false;
161
+ const base = options.validator ?? compileWithRefs(schema, options.refs);
162
+ const gates = options.gate === undefined ? [] : [].concat(options.gate);
163
+ const check = composeChecks(...gates);
164
+ const tier = PROVIDERS[client.endpoint.provider]?.structured ?? null;
165
+
166
+ /**
167
+ * @param {any[]} messages - wire-shape conversation to answer
168
+ * @param {{ signal?: AbortSignal }} [hooks]
169
+ */
170
+ async function generate(messages, hooks = {}) {
171
+ /** @type {any} */
172
+ const request = { stream, signal: hooks.signal };
173
+ let turn = [...messages];
174
+ if (tier === 'json_schema') {
175
+ request.responseFormat = { name, schema, strict: options.strict ?? true };
176
+ }
177
+ else {
178
+ // JSON mode (or nothing): the schema travels in the prompt;
179
+ // local validation makes the weaker tiers safe
180
+ if (tier === 'json') request.responseFormat = { type: 'json' };
181
+ turn = [{ role: 'system', content: schemaInstruction(schema) }, ...turn];
182
+ }
183
+
184
+ let raw = '';
185
+ /** @type {any[]} */
186
+ let errors = [];
187
+ for (let attempt = 1; attempt <= 1 + maxRepairs; attempt++) {
188
+ const result = await client.complete({ ...request, messages: turn });
189
+ raw = result.message.content;
190
+ /** @type {any} */
191
+ let value;
192
+ try {
193
+ value = JSON.parse(unfence(raw));
194
+ }
195
+ catch (err) {
196
+ errors = [{ instancePath: '', keyword: 'parse', message: `the reply is not JSON: ${/** @type {Error} */ (err).message}` }];
197
+ options.onAttempt?.({ attempt, outcome: 'schema', errors });
198
+ turn = [...turn,
199
+ { role: 'assistant', content: raw },
200
+ { role: 'user', content: 'That reply was not parseable JSON. Reply again with ONLY the JSON value.' }];
201
+ continue;
202
+ }
203
+ const shape = checkOutcome(base(value));
204
+ const outcome = shape.valid ? checkOutcome(check(value)) : shape;
205
+ if (outcome.valid) {
206
+ options.onAttempt?.({ attempt, outcome: 'valid', errors: [] });
207
+ return { value, raw, attempts: attempt };
208
+ }
209
+ errors = normalizeErrors(outcome.errors);
210
+ options.onAttempt?.({ attempt, outcome: shape.valid ? 'gate' : 'schema', errors });
211
+ turn = [...turn,
212
+ { role: 'assistant', content: raw },
213
+ { role: 'user', content: `That JSON does not validate against the schema. Fix exactly these and reply with ONLY the corrected JSON value:\n${JSON.stringify(errors)}` }];
214
+ }
215
+ return { errors, raw, attempts: 1 + maxRepairs };
216
+ }
217
+
218
+ return { generate };
219
+ }