@tangleai/models 0.21.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/structured.js CHANGED
@@ -1,4 +1,3 @@
1
- //@ts-check
2
1
  /**
3
2
  * Structured output: schema in, validated JSON value out — on every
4
3
  * provider tier.
@@ -13,14 +12,11 @@
13
12
  * accelerator, not an authority. On failure the instancePath'd errors
14
13
  * go back to the model for a bounded number of repair rounds.
15
14
  */
16
-
17
15
  import { JarenValidator } from '@jarenjs/validate';
18
16
  import { checkOutcome, composeChecks } from '@jarenjs/core/check';
19
- import { PROVIDERS } from './providers.js';
20
-
17
+ import { PROVIDERS } from "./providers.js";
21
18
  /** Validation errors reported per failed generation: enough to repair. */
22
19
  const MAX_ERRORS = 8;
23
-
24
20
  /**
25
21
  * Normalize an injected check's errors into the compact records a
26
22
  * repair prompt carries. The default `JarenValidator` check reports
@@ -35,81 +31,64 @@ const MAX_ERRORS = 8;
35
31
  * `message` so the record never carries the code and the path twice —
36
32
  * the composed `message` already contains both, and the fields beside
37
33
  * it are the structured copies.
38
- * @param {any[]} raw
39
34
  */
40
35
  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
- });
36
+ return raw.slice(0, MAX_ERRORS).map((e) => {
37
+ const out = {
38
+ instancePath: e.docPath ?? e.instancePath ?? '',
39
+ keyword: e.code ?? e.keyword ?? '',
40
+ message: e.reason ?? e.message ?? 'invalid',
41
+ };
42
+ if (e.code !== undefined)
43
+ out.code = e.code;
44
+ if (e.docPath !== undefined)
45
+ out.docPath = e.docPath;
46
+ return out;
47
+ });
52
48
  }
53
-
54
49
  /**
55
50
  * The system instruction for providers that cannot (fully) constrain
56
51
  * decoding: the schema travels in the prompt and the reply must be the
57
52
  * bare JSON value.
58
- * @param {any} schema
59
53
  */
60
54
  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');
55
+ return [
56
+ 'Reply with a single JSON value that validates against this JSON Schema.',
57
+ 'Output ONLY the JSON — no prose, no code fences.',
58
+ '',
59
+ JSON.stringify(schema),
60
+ ].join('\n');
67
61
  }
68
-
69
62
  /**
70
63
  * Strip an accidental markdown fence from a reply ("```json … ```") —
71
64
  * the classic prompt-embedded-schema failure mode.
72
65
  *
73
66
  * Exported because every place this package parses a model's JSON has to
74
67
  * 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
68
+ * fences" is two answers to one question: `program.ts`'s sub-calls parse
76
69
  * replies the same way structured generation does.
77
- * @param {string} text
78
70
  */
79
71
  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];
72
+ const trimmed = text.trim();
73
+ const match = /^```(?:json)?\s*([\s\S]*?)\s*```$/.exec(trimmed);
74
+ return match === null ? trimmed : match[1];
83
75
  }
84
-
85
76
  /**
86
77
  * Compile a schema that may reference others by `$id`, registering the
87
78
  * referenced schemas first. Every Jaren engine-document grammar
88
79
  * (fsm/dag/app/JSLT) composes the query grammar by `$ref`, so a plain
89
80
  * `.compile(schema)` on one of them throws "Can not resolve schema".
90
- * @param {any} schema
91
- * @param {any[]} [refs]
92
- * @returns {(value: any) => any}
81
+ * @param [refs]
93
82
  */
94
83
  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);
84
+ const v = new JarenValidator({ skipErrors: false, collectErrors: true });
85
+ for (const ref of refs ?? [])
86
+ v.addSchema(ref);
87
+ return v.compile(schema);
98
88
  }
99
-
100
89
  /**
101
90
  * Create a structured-output generator over a chat client.
102
91
  *
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
92
  * - `validator` overrides the internally compiled check (any
114
93
  * function returning a boolean or `{ valid, errors }`).
115
94
  * - `refs` are other JSON Schemas the `schema` references by `$id`,
@@ -138,82 +117,74 @@ function compileWithRefs(schema, refs) {
138
117
  * callers who never asked for one.
139
118
  * - `maxRepairs` is how many failed rounds may go back to the model
140
119
  * 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
120
  */
145
121
  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 };
122
+ const { client, schema } = options;
123
+ if (schema === null || typeof schema !== 'object')
124
+ throw new TypeError('createStructuredOutput needs a JSON Schema object');
125
+ const name = options.name ?? 'result';
126
+ const maxRepairs = options.maxRepairs ?? 1;
127
+ // Non-streaming stays the DEFAULT, and the reason is a behaviour change
128
+ // rather than a preference: a caller reading `raw` or counting attempts
129
+ // gets the same answer either way, but a caller that passed a client
130
+ // with `onDelta` wired sees deltas start arriving where none did
131
+ // before. The measurement that made this an option: the campaign's
132
+ // authoring timeouts were on STREAMED calls, so streaming is not the
133
+ // cure for a slow authoring turn — but it is how a long turn stays
134
+ // observable, and a caller who wants that should not have to wrap the
135
+ // client to get it.
136
+ const stream = options.stream ?? false;
137
+ const base = options.validator ?? compileWithRefs(schema, options.refs);
138
+ const gates = options.gate === undefined ? [] : [].concat(options.gate);
139
+ const check = composeChecks(...gates);
140
+ const tier = PROVIDERS[client.endpoint.provider]?.structured ?? null;
141
+ /**
142
+ * @param messages - wire-shape conversation to answer
143
+ * @param [hooks]
144
+ */
145
+ async function generate(messages, hooks = {}) {
146
+ const request = { stream, signal: hooks.signal };
147
+ let turn = [...messages];
148
+ if (tier === 'json_schema') {
149
+ request.responseFormat = { name, schema, strict: options.strict ?? true };
150
+ }
151
+ else {
152
+ // JSON mode (or nothing): the schema travels in the prompt;
153
+ // local validation makes the weaker tiers safe
154
+ if (tier === 'json')
155
+ request.responseFormat = { type: 'json' };
156
+ turn = [{ role: 'system', content: schemaInstruction(schema) }, ...turn];
157
+ }
158
+ let raw = '';
159
+ let errors = [];
160
+ for (let attempt = 1; attempt <= 1 + maxRepairs; attempt++) {
161
+ const result = await client.complete({ ...request, messages: turn });
162
+ raw = result.message.content;
163
+ let value;
164
+ try {
165
+ value = JSON.parse(unfence(raw));
166
+ }
167
+ catch (err) {
168
+ errors = [{ instancePath: '', keyword: 'parse', message: `the reply is not JSON: ${err.message}` }];
169
+ options.onAttempt?.({ attempt, outcome: 'schema', errors });
170
+ turn = [...turn,
171
+ { role: 'assistant', content: raw },
172
+ { role: 'user', content: 'That reply was not parseable JSON. Reply again with ONLY the JSON value.' }];
173
+ continue;
174
+ }
175
+ const shape = checkOutcome(base(value));
176
+ const outcome = shape.valid ? checkOutcome(check(value)) : shape;
177
+ if (outcome.valid) {
178
+ options.onAttempt?.({ attempt, outcome: 'valid', errors: [] });
179
+ return { value, raw, attempts: attempt };
180
+ }
181
+ errors = normalizeErrors(outcome.errors);
182
+ options.onAttempt?.({ attempt, outcome: shape.valid ? 'gate' : 'schema', errors });
183
+ turn = [...turn,
184
+ { role: 'assistant', content: raw },
185
+ { 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)}` }];
186
+ }
187
+ return { errors, raw, attempts: 1 + maxRepairs };
176
188
  }
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 };
189
+ return { generate };
219
190
  }