@formio/uag-sdk 0.0.0-stage → 2.0.0-rc.10

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/protocol.js ADDED
@@ -0,0 +1,269 @@
1
+ 'use strict';
2
+ const { ENGINE_NAME } = require('./define');
3
+
4
+ /**
5
+ * The remote engine protocol, version 1: JSON over HTTP between the UAG's
6
+ * `remote` engine type and an engine server.
7
+ *
8
+ * GET /v1/engines -> { protocol, engines: [{ name, kind, model, maxOptions?, toolUse? }] }
9
+ * POST /v1/engines/:name/decide { protocol, state, questions } -> { protocol, model, answers, usage? }
10
+ * POST /v1/engines/:name/extract { protocol, instructions, state, fields, schema, tools?, maxIterations? }
11
+ * -> { protocol, model, values, toolCalls?, usage? }
12
+ * Authorization: Bearer <engine server key>
13
+ * X-UAG-Scoped-Token: <scoped token> research extracts only
14
+ * X-Request-Id: <request id> optional, on decide and extract
15
+ * errors: { protocol, error, expose } with a 4xx or 5xx status
16
+ *
17
+ * The version is in both the path and the body: the path routes future
18
+ * versions side by side, and the body catches a proxy that mixes them up.
19
+ *
20
+ * The request id is the Agent API request's, and the engine server sends it on
21
+ * with research's calls to the UAG's `/mcp`, so logs can group every leg of a
22
+ * run. Neither side sends or logs one that is not a short token (`safeRequestId`).
23
+ */
24
+ const PROTOCOL_VERSION = 1;
25
+ const SCOPED_TOKEN_HEADER = 'x-uag-scoped-token';
26
+ const REQUEST_ID_HEADER = 'x-request-id';
27
+ const REQUEST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
28
+
29
+ /**
30
+ * The most PDF bytes one generate request carries: the provider request cap
31
+ * the UAG's documents.maxTotalBytes stays within (32 MB).
32
+ */
33
+ const MAX_DOCUMENT_BYTES = 32 * 1024 * 1024;
34
+ const DOCUMENT_KINDS = ['text', 'pdf'];
35
+ const TEXT_FORMATS = ['plain', 'markdown', 'csv'];
36
+
37
+ /** A request id that is safe to send and log: up to 128 letters, digits, `.`, `_`, `:` or `-`. Anything else is undefined. */
38
+ function safeRequestId(value) {
39
+ return typeof value === 'string' && REQUEST_ID.test(value) ? value : undefined;
40
+ }
41
+
42
+ /** A message that breaks the protocol: the wrong version, or the wrong shape. */
43
+ class ProtocolError extends Error {
44
+ constructor(message, status = 400) {
45
+ super(message);
46
+ this.name = 'ProtocolError';
47
+ this.status = status;
48
+ }
49
+ }
50
+
51
+ const isObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
52
+
53
+ function check(condition, message, status) {
54
+ if (!condition) throw new ProtocolError(message, status);
55
+ }
56
+
57
+ /** Refuse a message in another protocol version, naming both versions. */
58
+ function checkVersion(body, side = 'request') {
59
+ check(isObject(body), `The ${side} is not a JSON object.`);
60
+ const version = body.protocol;
61
+ if (version !== PROTOCOL_VERSION) {
62
+ throw new ProtocolError(
63
+ `This side speaks remote engine protocol ${PROTOCOL_VERSION}; the ${side} used ${version === undefined ? 'no version' : `protocol ${JSON.stringify(version)}`}.`,
64
+ 400
65
+ );
66
+ }
67
+ }
68
+
69
+ function checkState(state) {
70
+ check(isObject(state) && isObject(state.input) && typeof state.today === 'string', '"state" must be an object with "input" and "today".');
71
+ }
72
+
73
+ /** A decide request, as an engine server receives it. */
74
+ function checkDecideRequest(body) {
75
+ checkVersion(body);
76
+ checkState(body.state);
77
+ check(isObject(body.questions), '"questions" must be an object.');
78
+ for (const [id, question] of Object.entries(body.questions)) {
79
+ check(isObject(question) && ['choice', 'score', 'noul'].includes(question.type) && typeof question.instructions === 'string',
80
+ `The question "${id}" is not a choice, score or noul question.`);
81
+ }
82
+ return { state: body.state, questions: body.questions };
83
+ }
84
+
85
+ /** An extract request, as an engine server receives it. */
86
+ function checkExtractRequest(body) {
87
+ checkVersion(body);
88
+ checkState(body.state);
89
+ check(typeof body.instructions === 'string', '"instructions" must be a string.');
90
+ check(Array.isArray(body.fields) && body.fields.every((field) => isObject(field) && typeof field.path === 'string'), '"fields" must be a list of fields with paths.');
91
+ check(isObject(body.schema), '"schema" must be an object.');
92
+ const request = { instructions: body.instructions, state: body.state, fields: body.fields, schema: body.schema };
93
+ if (body.tools !== undefined) {
94
+ check(Array.isArray(body.tools) && body.tools.every((tool) => isObject(tool) && typeof tool.name === 'string' && typeof tool.description === 'string' && isObject(tool.inputSchema)),
95
+ '"tools" must be a list of tool definitions.');
96
+ check(Number.isInteger(body.maxIterations) && body.maxIterations > 0, '"maxIterations" must be a positive whole number with "tools".');
97
+ Object.assign(request, { tools: body.tools, maxIterations: body.maxIterations });
98
+ }
99
+ return request;
100
+ }
101
+
102
+ /** A describe response, as the UAG receives it. */
103
+ function checkDescription(body) {
104
+ checkVersion(body, 'response');
105
+ check(Array.isArray(body.engines), '"engines" must be a list.');
106
+ return body.engines.map((engine) => {
107
+ check(isObject(engine) && typeof engine.name === 'string' && ENGINE_NAME.test(engine.name), 'Each engine needs a valid name.');
108
+ check(['decision', 'agent'].includes(engine.kind), `The engine "${engine.name}" must be a decision or an agent engine.`);
109
+ check(typeof engine.model === 'string' && engine.model, `The engine "${engine.name}" does not name its model.`);
110
+ if (engine.kind === 'decision') {
111
+ check(Number.isInteger(engine.maxOptions) && engine.maxOptions >= 2, `The decision engine "${engine.name}" needs maxOptions.`);
112
+ }
113
+ if (engine.kind === 'agent' && engine.accepts !== undefined) {
114
+ check(Array.isArray(engine.accepts) && engine.accepts.length && engine.accepts.every((kind) => DOCUMENT_KINDS.includes(kind)),
115
+ `The engine "${engine.name}" must list "text" and "pdf" only in "accepts".`);
116
+ }
117
+ return {
118
+ name: engine.name,
119
+ kind: engine.kind,
120
+ model: engine.model,
121
+ ...(engine.kind === 'decision' && { maxOptions: engine.maxOptions }),
122
+ ...(engine.kind === 'agent' && {
123
+ toolUse: engine.toolUse === true,
124
+ accepts: engine.accepts || ['text'],
125
+ generate: engine.generate === true,
126
+ }),
127
+ };
128
+ });
129
+ }
130
+
131
+ /**
132
+ * A generate request, as an engine server receives it. PDF attachments arrive
133
+ * as base64 and are returned as Buffers; their decoded total may not exceed
134
+ * `maxBytes`.
135
+ */
136
+ function checkGenerateRequest(body, maxBytes = MAX_DOCUMENT_BYTES) {
137
+ checkVersion(body);
138
+ check(typeof body.instructions === 'string', '"instructions" must be a string.');
139
+ check(body.system === undefined || typeof body.system === 'string', '"system" must be a string.');
140
+ check(['markdown', 'text'].includes(body.format), '"format" must be "markdown" or "text".');
141
+ check(body.maxTokens === undefined || (Number.isInteger(body.maxTokens) && body.maxTokens > 0), '"maxTokens" must be a positive whole number.');
142
+ check(body.attachments === undefined || Array.isArray(body.attachments), '"attachments" must be a list.');
143
+ let pdfBytes = 0;
144
+ const attachments = (body.attachments || []).map((attachment, i) => {
145
+ const where = `Attachment ${i + 1}`;
146
+ check(isObject(attachment) && typeof attachment.label === 'string', `${where} needs a label.`);
147
+ if (attachment.kind === 'pdf') {
148
+ check(typeof attachment.data === 'string', `${where} must carry its PDF as base64 "data".`);
149
+ check(attachment.pages === undefined || (Number.isInteger(attachment.pages) && attachment.pages >= 0), `${where} "pages" must be a whole number.`);
150
+ const data = Buffer.from(attachment.data, 'base64');
151
+ pdfBytes += data.length;
152
+ check(pdfBytes <= maxBytes, `The attached PDFs exceed ${maxBytes} bytes.`, 413);
153
+ return { kind: 'pdf', data, label: attachment.label, ...(attachment.pages !== undefined && { pages: attachment.pages }) };
154
+ }
155
+ check(attachment.kind === 'text' && typeof attachment.text === 'string', `${where} must be a "pdf" or "text" attachment.`);
156
+ check(attachment.textFormat === undefined || TEXT_FORMATS.includes(attachment.textFormat), `${where} "textFormat" must be plain, markdown or csv.`);
157
+ return { kind: 'text', text: attachment.text, label: attachment.label, ...(attachment.textFormat && { textFormat: attachment.textFormat }) };
158
+ });
159
+ return {
160
+ instructions: body.instructions,
161
+ ...(body.system !== undefined && { system: body.system }),
162
+ attachments,
163
+ format: body.format,
164
+ ...(body.maxTokens !== undefined && { maxTokens: body.maxTokens }),
165
+ };
166
+ }
167
+
168
+ /** Attachments for the wire: PDF bytes as base64. Never logged. */
169
+ function encodeAttachments(attachments = []) {
170
+ return attachments.map((attachment) => attachment.kind === 'pdf'
171
+ ? { kind: 'pdf', data: Buffer.from(attachment.data).toString('base64'), label: attachment.label, ...(attachment.pages !== undefined && { pages: attachment.pages }) }
172
+ : { kind: 'text', text: attachment.text, label: attachment.label, ...(attachment.textFormat && { textFormat: attachment.textFormat }) });
173
+ }
174
+
175
+ /** The decoded size of the PDFs among attachments. */
176
+ function pdfByteLength(attachments = []) {
177
+ return attachments.reduce((total, attachment) => total + (attachment.kind === 'pdf' ? Buffer.byteLength(attachment.data) : 0), 0);
178
+ }
179
+
180
+ /** A generate response, as the UAG receives it. */
181
+ function checkGenerateResponse(body) {
182
+ checkVersion(body, 'response');
183
+ check(typeof body.text === 'string', 'The response has no "text".');
184
+ check(typeof body.model === 'string', 'The response does not name its model.');
185
+ check(['end', 'max_tokens'].includes(body.stopReason), '"stopReason" must be "end" or "max_tokens".');
186
+ const usage = checkUsage(body.usage);
187
+ return { text: body.text, model: body.model, stopReason: body.stopReason, ...(usage && { usage }) };
188
+ }
189
+
190
+ /** A token count: a whole number, or null when not reported. */
191
+ function tokenCount(value, key) {
192
+ if (value === undefined || value === null) return null;
193
+ check(Number.isInteger(value) && value >= 0, `"usage.${key}" must be a whole number or null.`);
194
+ return value;
195
+ }
196
+
197
+ function checkUsage(usage) {
198
+ if (usage === undefined) return undefined;
199
+ check(isObject(usage), '"usage" must be an object.');
200
+ return { inputTokens: tokenCount(usage.inputTokens, 'inputTokens'), outputTokens: tokenCount(usage.outputTokens, 'outputTokens') };
201
+ }
202
+
203
+ const isProbability = (value) => typeof value === 'number' && value >= 0 && value <= 1;
204
+
205
+ /** One answer's shape (which options and levels were asked is the UAG's to check). */
206
+ function checkAnswer(id, answer) {
207
+ const where = `The answer to "${id}"`;
208
+ check(isObject(answer) && ['choice', 'score', 'noul'].includes(answer.type), `${where} must be a choice, score or noul answer.`);
209
+ if (answer.type === 'noul') {
210
+ check(isProbability(answer.probability), `${where} needs a probability from 0 to 1.`);
211
+ return;
212
+ }
213
+ check(isObject(answer.probabilities) && Object.values(answer.probabilities).every(isProbability), `${where} needs probabilities from 0 to 1.`);
214
+ check(isProbability(answer.confidence), `${where} needs a confidence from 0 to 1.`);
215
+ if (answer.type === 'choice') check(typeof answer.choice === 'string', `${where} needs its choice.`);
216
+ else check(Number.isFinite(answer.score), `${where} needs its score.`);
217
+ }
218
+
219
+ /** A decide response, as the UAG receives it. */
220
+ function checkDecisionResponse(body) {
221
+ checkVersion(body, 'response');
222
+ check(typeof body.model === 'string', 'The response does not name its model.');
223
+ check(isObject(body.answers), '"answers" must be an object.');
224
+ for (const [id, answer] of Object.entries(body.answers)) checkAnswer(id, answer);
225
+ const usage = checkUsage(body.usage);
226
+ return { model: body.model, answers: body.answers, ...(usage && { usage }) };
227
+ }
228
+
229
+ /** An extract response, as the UAG receives it. */
230
+ function checkExtractResponse(body) {
231
+ checkVersion(body, 'response');
232
+ check(typeof body.model === 'string', 'The response does not name its model.');
233
+ check(isObject(body.values), '"values" must be an object.');
234
+ if (body.toolCalls !== undefined) {
235
+ check(Array.isArray(body.toolCalls) && body.toolCalls.every((call) => isObject(call) && typeof call.name === 'string'), '"toolCalls" must be a list of calls.');
236
+ }
237
+ const usage = checkUsage(body.usage);
238
+ return {
239
+ model: body.model,
240
+ values: body.values,
241
+ ...(body.toolCalls && { toolCalls: body.toolCalls.map((call) => ({ name: call.name, isError: call.isError === true })) }),
242
+ ...(usage && { usage }),
243
+ };
244
+ }
245
+
246
+ /** An error response body. `expose` says whether the message may be shown to API callers. */
247
+ function errorBody(message, expose = false) {
248
+ return { protocol: PROTOCOL_VERSION, error: String(message), expose: expose === true };
249
+ }
250
+
251
+ module.exports = {
252
+ PROTOCOL_VERSION,
253
+ SCOPED_TOKEN_HEADER,
254
+ REQUEST_ID_HEADER,
255
+ safeRequestId,
256
+ ProtocolError,
257
+ checkVersion,
258
+ checkDecideRequest,
259
+ checkExtractRequest,
260
+ checkDescription,
261
+ checkDecisionResponse,
262
+ checkExtractResponse,
263
+ checkGenerateRequest,
264
+ checkGenerateResponse,
265
+ encodeAttachments,
266
+ pdfByteLength,
267
+ MAX_DOCUMENT_BYTES,
268
+ errorBody,
269
+ };
package/settings.js ADDED
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+ const { EngineConfigError } = require('./errors');
3
+
4
+ /** `apiKey` → `API_KEY`, `baseURL` → `BASE_URL`, `fast-lane` → `FAST_LANE`. */
5
+ function upperSnake(name) {
6
+ return String(name)
7
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
8
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1_$2')
9
+ .replace(/-/g, '_')
10
+ .toUpperCase();
11
+ }
12
+
13
+ /** The instance's own variable for a setting: `UAG_ENGINE_<NAME>_<SETTING>`. */
14
+ function settingVariable(instance, setting) {
15
+ return `UAG_ENGINE_${upperSnake(instance)}_${upperSnake(setting)}`;
16
+ }
17
+
18
+ /**
19
+ * The variables that set a setting, in the order they are read. `env` may be one
20
+ * name or a list: definitions not made with `defineEngine` are not normalised.
21
+ */
22
+ function settingVariables(instance, setting, spec) {
23
+ return [settingVariable(instance, setting), ...[].concat(spec.env || [])];
24
+ }
25
+
26
+ function given(value) {
27
+ return value !== undefined && value !== null && value !== '';
28
+ }
29
+
30
+ function listNames(names) {
31
+ return names.length > 1 ? `${names.slice(0, -1).join(', ')} or ${names[names.length - 1]}` : names[0];
32
+ }
33
+
34
+ /** Coerce a value read from the environment (always a string) to the setting's type. */
35
+ function coerce(value, spec, describe) {
36
+ const type = spec.type || 'string';
37
+ if (type === 'number') {
38
+ const number = typeof value === 'number' ? value : Number(String(value).trim());
39
+ if (String(value).trim() === '' || !Number.isFinite(number)) {
40
+ throw new EngineConfigError(`${describe()} must be a number${spec.secret ? '' : `, not ${JSON.stringify(value)}`}.`);
41
+ }
42
+ return number;
43
+ }
44
+ if (type === 'boolean') {
45
+ if (typeof value === 'boolean') return value;
46
+ const text = String(value).trim().toLowerCase();
47
+ if (['true', '1', 'yes'].includes(text)) return true;
48
+ if (['false', '0', 'no'].includes(text)) return false;
49
+ throw new EngineConfigError(`${describe()} must be true or false${spec.secret ? '' : `, not ${JSON.stringify(value)}`}.`);
50
+ }
51
+ return typeof value === 'string' ? value : String(value);
52
+ }
53
+
54
+ /**
55
+ * An instance's value for each setting its type declares, from the first of:
56
+ * the instance's options, `UAG_ENGINE_<NAME>_<SETTING>`, the type's own
57
+ * variables, and the default. A required setting with no value is an error
58
+ * naming the instance, the type, the setting and every variable that sets it.
59
+ */
60
+ function resolveSettings(definition, name, options = {}, env = process.env) {
61
+ const settings = {};
62
+ for (const [key, spec] of Object.entries(definition.settings || {})) {
63
+ const describe = () => `The "${name}" engine's ${key} setting`;
64
+ let value = options[key];
65
+ if (!given(value)) {
66
+ const variable = settingVariables(name, key, spec).find((candidate) => given(env[candidate]));
67
+ value = variable ? env[variable] : spec.default;
68
+ }
69
+ if (!given(value)) {
70
+ if (spec.required) {
71
+ throw missingSetting(definition, name, key);
72
+ }
73
+ continue;
74
+ }
75
+ settings[key] = coerce(value, spec, describe);
76
+ }
77
+ return settings;
78
+ }
79
+
80
+ /** The error for a required setting with no value. */
81
+ function missingSetting(definition, name, key) {
82
+ const spec = (definition.settings || {})[key] || {};
83
+ return new EngineConfigError(
84
+ `The "${name}" engine (type "${definition.name}") needs its ${key} setting: set ${listNames(settingVariables(name, key, spec))}.`
85
+ );
86
+ }
87
+
88
+ /** The resolved values of the settings a type marks secret. */
89
+ function secretValues(definition, settings, options = {}) {
90
+ const secrets = [];
91
+ for (const [key, spec] of Object.entries(definition.settings || {})) {
92
+ if (!spec.secret) continue;
93
+ if (given(settings[key])) secrets.push(settings[key]);
94
+ if (given(options[key])) secrets.push(options[key]);
95
+ }
96
+ return secrets;
97
+ }
98
+
99
+ module.exports = { upperSnake, settingVariable, settingVariables, resolveSettings, missingSetting, secretValues };