@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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +253 -0
- package/package.json +92 -0
- package/src/check.d.ts +29 -0
- package/src/check.js +37 -0
- package/src/client.d.ts +185 -0
- package/src/client.js +433 -0
- package/src/embed.d.ts +129 -0
- package/src/embed.js +362 -0
- package/src/embedding-vector.d.ts +9 -0
- package/src/embedding-vector.js +30 -0
- package/src/errors.d.ts +22 -0
- package/src/errors.js +45 -0
- package/src/grammar.d.ts +29 -0
- package/src/grammar.js +35 -0
- package/src/index.d.ts +9 -0
- package/src/index.js +11 -0
- package/src/providers.d.ts +69 -0
- package/src/providers.js +156 -0
- package/src/replay.d.ts +76 -0
- package/src/replay.js +141 -0
- package/src/retry.d.ts +141 -0
- package/src/retry.js +182 -0
- package/src/routing.d.ts +32 -0
- package/src/routing.js +87 -0
- package/src/sse.d.ts +1 -0
- package/src/sse.js +12 -0
- package/src/structured.d.ts +89 -0
- package/src/structured.js +219 -0
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
|
+
}
|
package/src/routing.d.ts
ADDED
|
@@ -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
|
+
}
|