@formio/uag-openai 0.0.0-stage → 2.0.0-rc.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/LICENSE.txt +19 -0
- package/README.md +107 -2
- package/compatible.js +479 -0
- package/decisions.js +149 -0
- package/index.js +74 -0
- package/package.json +50 -4
package/LICENSE.txt
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
Copyright (c) 2025 Form.io
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
5
|
+
this software and associated documentation files (the "Software"), to deal in the
|
|
6
|
+
Software without restriction, including without limitation the rights to use,
|
|
7
|
+
copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
|
8
|
+
Software, and to permit persons to whom the Software is furnished to do so,
|
|
9
|
+
subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all
|
|
12
|
+
copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
|
15
|
+
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
16
|
+
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
17
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
18
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
19
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,108 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @formio/uag-openai
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<sub>[Form.io UAG](https://github.com/formio/uag/blob/main/README.md) › [Engines](https://github.com/formio/uag/blob/main/README.md#engines-and-routing) › this page</sub>
|
|
4
|
+
|
|
5
|
+
The OpenAI engine for the [UAG Agent API](https://github.com/formio/uag/blob/main/README.md#agent-api). An instance is one of two kinds:
|
|
6
|
+
|
|
7
|
+
- **agent** (the default): extracts and generates open values on OpenAI's Responses API, or, through Chat Completions, on any server that implements that API: local models (Ollama, vLLM, LM Studio) and routers (OpenRouter). When a task lists read-only tools in `uagTools`, it researches with them first. Grok and Gemini have their own packages built on it: [`grok`](https://github.com/formio/uag/tree/main/engines/grok) and [`gemini`](https://github.com/formio/uag/tree/main/engines/gemini).
|
|
8
|
+
- **decision**: decides closed-set fields with a probability for each answer, on OpenAI's Decisions API, like the [`jev`](https://github.com/formio/uag/tree/main/engines/jev) engine. See [Decisions](#decisions).
|
|
9
|
+
|
|
10
|
+
It is included in the `formio/uag` Docker image. Enable it by naming it, and set its model and key:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
UAG_ENGINES=openai
|
|
14
|
+
OPENAI_API_KEY=...
|
|
15
|
+
OPENAI_MODEL=<a dated model ID>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Its tasks are Agentic Formflow, so the UAG also needs a `LICENSE_KEY` that includes the UAG (see [License](https://github.com/formio/uag/blob/main/README.md#license)).
|
|
19
|
+
|
|
20
|
+
## Settings
|
|
21
|
+
|
|
22
|
+
| Setting | Variables | Default |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| `kind` | `UAG_ENGINE_<NAME>_KIND`, `OPENAI_KIND` | `agent`; `decision` for the Decisions API |
|
|
25
|
+
| `model` | `UAG_ENGINE_<NAME>_MODEL`, `OPENAI_MODEL` | required; there is no default |
|
|
26
|
+
| `apiKey` | `UAG_ENGINE_<NAME>_API_KEY`; `OPENAI_API_KEY` for OpenAI itself only | required for OpenAI itself (secret) |
|
|
27
|
+
| `baseURL` | `UAG_ENGINE_<NAME>_BASE_URL`, `OPENAI_BASE_URL` | `https://api.openai.com/v1` |
|
|
28
|
+
| `structuredOutput` | `UAG_ENGINE_<NAME>_STRUCTURED_OUTPUT`, `OPENAI_STRUCTURED_OUTPUT` | `json_schema` |
|
|
29
|
+
| `reasoningEffort` | `UAG_ENGINE_<NAME>_REASONING_EFFORT`, `OPENAI_REASONING_EFFORT` | not sent |
|
|
30
|
+
| `api` | `UAG_ENGINE_<NAME>_API`, `OPENAI_API` | `responses` for OpenAI itself; `chat` for any other server |
|
|
31
|
+
| `maxOptions` | `UAG_ENGINE_<NAME>_MAX_OPTIONS` | `255`, the Decisions API's limit and the most it takes; decision kind only |
|
|
32
|
+
|
|
33
|
+
`<NAME>` is the instance name, upper-cased with `-` as `_`. An instance's own variable wins over the shared one.
|
|
34
|
+
|
|
35
|
+
OpenAI's own variables, `OPENAI_API_KEY`, `OPENAI_ORG_ID`, `OPENAI_PROJECT_ID` and `OPENAI_CUSTOM_HEADERS`, are sent only to OpenAI itself (`api.openai.com`). An instance whose `baseURL` is any other server, including a proxy in front of OpenAI, gets none of them: give it its own key with `UAG_ENGINE_<NAME>_API_KEY`, or none for a server that needs no key. Grok, Gemini and the other engines built on this one never get them either.
|
|
36
|
+
|
|
37
|
+
`structuredOutput` says how the server is asked for the answer:
|
|
38
|
+
|
|
39
|
+
| Mode | Use when |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `json_schema` | The server supports strict JSON schema response formats: OpenAI, and recent vLLM, Ollama and OpenRouter models. |
|
|
42
|
+
| `json_object` | The server supports JSON mode but not schemas. The schema goes in the prompt. |
|
|
43
|
+
| `prompt` | The server supports neither. The schema goes in the prompt, and the answer is read from the text. |
|
|
44
|
+
|
|
45
|
+
In every mode, an answer that is not a JSON object fails as an engine failure, and the UAG routes the fields to the next engine.
|
|
46
|
+
|
|
47
|
+
## Instances
|
|
48
|
+
|
|
49
|
+
A local model through Ollama, next to Claude, with no key:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
UAG_ENGINES=jev,local:openai,claude
|
|
53
|
+
UAG_ENGINE_LOCAL_BASE_URL=http://ollama:11434/v1
|
|
54
|
+
UAG_ENGINE_LOCAL_MODEL=llama4:16x17b
|
|
55
|
+
CLAUDE_API_KEY=...
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
For Grok and Gemini, use their own engines ([`grok`](https://github.com/formio/uag/tree/main/engines/grok), [`gemini`](https://github.com/formio/uag/tree/main/engines/gemini)), which have the right endpoints and keys built in. Any other OpenAI-compatible server works with `baseURL`; check its documentation for model IDs and for which `structuredOutput` mode it supports.
|
|
59
|
+
|
|
60
|
+
## Decisions
|
|
61
|
+
|
|
62
|
+
With `kind` set to `decision`, an instance is a decision engine on OpenAI's [Decisions API](https://developers.openai.com/api/docs/guides/decisions). It answers the same questions `jev` does, with all of a task's decision fields in one request:
|
|
63
|
+
|
|
64
|
+
| UAG question | Decisions API question | Answer |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| yes/no (a field addressed, affirmed or denied) | `predicate` | the probability it holds |
|
|
67
|
+
| choice (radio, select, checkbox) | `choice` | the option, a probability for each, and a confidence |
|
|
68
|
+
| score (an ordered scale) | `score` | the position on the scale, a probability for each level, and a confidence |
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
UAG_ENGINES=decide:openai,writer:openai
|
|
72
|
+
OPENAI_API_KEY=...
|
|
73
|
+
UAG_ENGINE_DECIDE_KIND=decision
|
|
74
|
+
UAG_ENGINE_DECIDE_MODEL=gpt-6-luna
|
|
75
|
+
UAG_ENGINE_WRITER_MODEL=<a dated model ID>
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Its answers are reported `calibrated: true`, like `jev`'s, and the same thresholds (`uagThreshold`, `uagUncertainBand`) apply. OpenAI makes no calibration guarantee for these probabilities, and neither does any provider: measure with `uag-eval --engine decide` on your own labeled examples before relying on the default thresholds, and again when you change the model.
|
|
79
|
+
|
|
80
|
+
- A question the API declines is left undecided, so its field is reported `missing` or decided by the next engine in the routing list.
|
|
81
|
+
- A request with more than 400 questions is split across calls.
|
|
82
|
+
- `maxOptions` caps the options one choice question may have. Closed sets with more options go to an agent engine.
|
|
83
|
+
- Both `jev` and an `openai` decision instance can be enabled; list both in `routing.decision` to fall back from one to the other.
|
|
84
|
+
|
|
85
|
+
## Choosing a model
|
|
86
|
+
|
|
87
|
+
There is no default model: set `model` to a specific, dated model ID, not an alias that moves. Answers differ between models, so compare them on your own labeled examples before switching (`uag-eval` measures decision engines only).
|
|
88
|
+
|
|
89
|
+
## How it works
|
|
90
|
+
|
|
91
|
+
- **Which API.** With OpenAI itself, the engine uses the Responses API, which lets current models reason while they use tools (Chat Completions refuses tools on a reasoning model). Nothing is stored at OpenAI (`store: false`): the model's reasoning carries across research turns as encrypted reasoning items, sent back with the history. With any other server (`baseURL`), it uses Chat Completions, which local servers and routers implement. Set `api` to `responses` or `chat` to choose yourself, for example for a gateway in front of OpenAI.
|
|
92
|
+
- **Dates with OpenAI.** OpenAI's strict mode corrupts dates when the schema constrains them (the year comes back as 1000, 1010, 3362 …), so with OpenAI itself the engine writes each field's format (such as `date`) and pattern into its description instead. Measured October 2026; other servers keep the constraints, and the UAG validates every value either way.
|
|
93
|
+
- **Extraction** makes one request, constrained as `structuredOutput` says. Values from this engine are always reported as `suggested`, and only the requested fields are returned.
|
|
94
|
+
- **Research:** when a task lists read-only tools, the model may call them first through function calling, up to `agentApi.research.maxIterations` turns. Every call goes back through the UAG with the caller's identity, and tool errors are returned to the model. The model then answers with tools turned off. It never writes: the UAG validates the values and saves them itself. Tasks that research need a server that supports function calling; on one that does not, the request fails as an engine failure.
|
|
95
|
+
- **Failures:** a refusal, a content filter, a truncated answer or an answer that is not JSON fails as an engine failure, and the UAG falls back to the next engine.
|
|
96
|
+
- **Aborts:** the routing timeout (for research, `agentApi.research.timeout`) aborts the request, and research stops before its next call.
|
|
97
|
+
|
|
98
|
+
## Using it in your own server
|
|
99
|
+
|
|
100
|
+
Name it in configuration instead of `UAG_ENGINES`, with options that take precedence over the environment:
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
const server = new UAGServer({
|
|
104
|
+
engines: [{ name: 'local', type: 'openai', options: { baseURL: 'http://localhost:11434/v1', model: 'llama4:16x17b' } }],
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
License: MIT.
|
package/compatible.js
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OpenAI-compatible core of the UAG's agent engines: structured extraction
|
|
3
|
+
* and research over a chat completions API, shared by `@formio/uag-openai` and
|
|
4
|
+
* the provider packages built on it (Grok and Gemini; MAI, not yet released).
|
|
5
|
+
*
|
|
6
|
+
* const { openAICompatible } = require('@formio/uag-openai/compatible');
|
|
7
|
+
* module.exports = openAICompatible({
|
|
8
|
+
* type: 'acme',
|
|
9
|
+
* description: 'Acme models, through their OpenAI-compatible API.',
|
|
10
|
+
* baseURL: 'https://api.acme.example/v1',
|
|
11
|
+
* apiKey: { env: 'ACME_API_KEY' },
|
|
12
|
+
* model: { env: 'ACME_MODEL' },
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* How an answer is constrained depends on what the server supports
|
|
16
|
+
* (`structuredOutput`):
|
|
17
|
+
*
|
|
18
|
+
* json_schema a strict JSON schema response format
|
|
19
|
+
* json_object a JSON object response format, with the schema in the prompt
|
|
20
|
+
* prompt the schema in the prompt only
|
|
21
|
+
*
|
|
22
|
+
* When the UAG passes read-only tools (a task that enables research), the
|
|
23
|
+
* engine first runs a function-calling loop over them - calls go through the
|
|
24
|
+
* UAG's `callTool`, which enforces the task's tool list and the caller's
|
|
25
|
+
* permissions - and then asks for the same answer with tools turned off.
|
|
26
|
+
*/
|
|
27
|
+
const { defineEngine, EngineConfigError, EngineError, ENGINE_API_VERSION, parseJsonObject, requiredSchema, toolResultText } = require('@formio/uag-sdk');
|
|
28
|
+
|
|
29
|
+
const MODES = ['json_schema', 'json_object', 'prompt'];
|
|
30
|
+
|
|
31
|
+
function isEmpty(value) {
|
|
32
|
+
return value === '' || value === null || value === undefined || (Array.isArray(value) && !value.length);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Refuse an unknown structured-output mode at startup. */
|
|
36
|
+
function checkMode(name, mode) {
|
|
37
|
+
if (!MODES.includes(mode)) {
|
|
38
|
+
throw new EngineConfigError(`The "${name}" engine's structuredOutput setting must be one of ${MODES.join(', ')}, not ${JSON.stringify(mode)}.`);
|
|
39
|
+
}
|
|
40
|
+
return mode;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** OpenAI's own API, the only server that gets OpenAI's own settings from the environment. */
|
|
44
|
+
function isOpenAI(baseURL) {
|
|
45
|
+
try {
|
|
46
|
+
return new URL(baseURL).hostname === 'api.openai.com';
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The header names in OPENAI_CUSTOM_HEADERS (`Name: value` lines), as the SDK reads them. */
|
|
54
|
+
function customHeaderNames(value) {
|
|
55
|
+
return String(value || '').split('\n').filter((line) => line.includes(':')).map((line) => line.slice(0, line.indexOf(':')).trim()).filter(Boolean);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* An OpenAI SDK client. Retries stay small: the UAG's routing timeout aborts the
|
|
60
|
+
* request (through the signal) and falls back to the next engine before retries
|
|
61
|
+
* pile up.
|
|
62
|
+
*
|
|
63
|
+
* The SDK reads OpenAI's own settings from the environment (OPENAI_ORG_ID,
|
|
64
|
+
* OPENAI_PROJECT_ID, OPENAI_CUSTOM_HEADERS) and sends them with every request.
|
|
65
|
+
* They are OpenAI's, so any other server (Grok, Gemini, a local model) gets
|
|
66
|
+
* none of them. `apiKey: null` sends no Authorization header, for a server
|
|
67
|
+
* that needs no key or takes it in another header.
|
|
68
|
+
*/
|
|
69
|
+
function openAIClient({ apiKey, baseURL, defaultHeaders, fetch }) {
|
|
70
|
+
const { OpenAI } = require('openai');
|
|
71
|
+
const headers = { ...(defaultHeaders || {}) };
|
|
72
|
+
const openAI = isOpenAI(baseURL);
|
|
73
|
+
if (!openAI) {
|
|
74
|
+
const own = new Set(Object.keys(headers).map((header) => header.toLowerCase()));
|
|
75
|
+
for (const header of customHeaderNames(process.env.OPENAI_CUSTOM_HEADERS)) {
|
|
76
|
+
if (!own.has(header.toLowerCase())) headers[header] = null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!apiKey) headers.Authorization = null;
|
|
80
|
+
return new OpenAI({
|
|
81
|
+
// The SDK will not start without a key, even when none is sent.
|
|
82
|
+
apiKey: apiKey || 'unused',
|
|
83
|
+
// Always explicit, so the SDK never falls back to OPENAI_BASE_URL for an
|
|
84
|
+
// instance that was configured without it.
|
|
85
|
+
baseURL,
|
|
86
|
+
...(!openAI && { organization: null, project: null }),
|
|
87
|
+
// Never read from the environment: these engines only use `apiKey`.
|
|
88
|
+
adminAPIKey: null,
|
|
89
|
+
webhookSecret: null,
|
|
90
|
+
...(Object.keys(headers).length && { defaultHeaders: headers }),
|
|
91
|
+
// Tests pass a fetch to see exactly what is sent, without the network.
|
|
92
|
+
...(fetch && { fetch }),
|
|
93
|
+
maxRetries: 2,
|
|
94
|
+
// The SDK refuses to start in a "browser-like environment". Inside the
|
|
95
|
+
// UAG, Form.io's libraries install `window`/`document` shims, so allow it
|
|
96
|
+
// only when actually running on Node; a real browser is still refused.
|
|
97
|
+
dangerouslyAllowBrowser: typeof process !== 'undefined' && !!(process.versions && process.versions.node)
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** End the attempt as an engine failure when the answer is unusable, so routing can fall back. */
|
|
102
|
+
function checkChoice(choice) {
|
|
103
|
+
if (!choice || !choice.message) {
|
|
104
|
+
throw new EngineError('The model returned no answer.');
|
|
105
|
+
}
|
|
106
|
+
if (choice.message.refusal) {
|
|
107
|
+
throw new EngineError('The model declined the request.');
|
|
108
|
+
}
|
|
109
|
+
if (choice.finish_reason === 'length') {
|
|
110
|
+
throw new EngineError('The model stopped at the token limit before finishing the answer.');
|
|
111
|
+
}
|
|
112
|
+
if (choice.finish_reason === 'content_filter') {
|
|
113
|
+
throw new EngineError('The model\'s content filter stopped the answer.');
|
|
114
|
+
}
|
|
115
|
+
return choice.message;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** How a string format reads in a field's description, instead of as a schema constraint. */
|
|
119
|
+
const FORMAT_WORDING = {
|
|
120
|
+
date: 'Write it as a date, YYYY-MM-DD.',
|
|
121
|
+
'date-time': 'Write it as an ISO 8601 date and time, such as 2026-10-22T14:30:00Z.',
|
|
122
|
+
time: 'Write it as a time, HH:MM:SS.',
|
|
123
|
+
email: 'Write it as an email address.',
|
|
124
|
+
uri: 'Write it as a URL.',
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The schema with every string `format` and `pattern` moved into the property's
|
|
129
|
+
* description. OpenAI's strict mode enforces those constraints in a way that
|
|
130
|
+
* corrupts dates (the year comes out as 1000, 1010, 3362 ...). A server that
|
|
131
|
+
* enforces them also rules out the empty "not stated" answer every field is
|
|
132
|
+
* allowed, so the model must invent a value (Grok answers a policy number
|
|
133
|
+
* pattern with POL-000000). The same requirement in words is followed, and the
|
|
134
|
+
* UAG validates every value afterwards.
|
|
135
|
+
*/
|
|
136
|
+
function describeFormats(schema) {
|
|
137
|
+
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema;
|
|
138
|
+
const out = { ...schema };
|
|
139
|
+
const notes = [];
|
|
140
|
+
if (typeof out.format === 'string') {
|
|
141
|
+
notes.push(FORMAT_WORDING[out.format] || `Write it in the "${out.format}" format.`);
|
|
142
|
+
delete out.format;
|
|
143
|
+
}
|
|
144
|
+
if (typeof out.pattern === 'string') {
|
|
145
|
+
notes.push(`It must match the regular expression ${out.pattern}.`);
|
|
146
|
+
delete out.pattern;
|
|
147
|
+
}
|
|
148
|
+
if (notes.length) {
|
|
149
|
+
out.description = [out.description, ...notes].filter(Boolean).join(' ');
|
|
150
|
+
}
|
|
151
|
+
if (out.properties) {
|
|
152
|
+
out.properties = Object.fromEntries(Object.entries(out.properties).map(([key, value]) => [key, describeFormats(value)]));
|
|
153
|
+
}
|
|
154
|
+
if (out.items) {
|
|
155
|
+
out.items = describeFormats(out.items);
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The prompt describing the information and the fields, and how to answer in each mode. */
|
|
161
|
+
function prompt(request, mode) {
|
|
162
|
+
const lines = [
|
|
163
|
+
'Information:',
|
|
164
|
+
JSON.stringify(request.state, null, 2),
|
|
165
|
+
'',
|
|
166
|
+
'Fields to fill in (path, label, rule, format and allowed options):',
|
|
167
|
+
JSON.stringify(request.fields, null, 2),
|
|
168
|
+
'',
|
|
169
|
+
];
|
|
170
|
+
if (mode === 'json_schema') {
|
|
171
|
+
lines.push('Return one JSON object keyed by field path. For a field the information does not support, give an empty string, or an empty list for a list.');
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
lines.push(
|
|
175
|
+
'Respond with only one JSON object keyed by field path, matching this JSON schema, and nothing else. Leave out any field the information does not support.',
|
|
176
|
+
JSON.stringify(request.schema)
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return lines.join('\n');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Run one tool call through the UAG's `callTool`. A call that throws, other
|
|
184
|
+
* than because the request was aborted, goes back to the model as a tool error
|
|
185
|
+
* instead of failing the extraction.
|
|
186
|
+
*/
|
|
187
|
+
async function runTool(request, name, args) {
|
|
188
|
+
try {
|
|
189
|
+
return await request.callTool(name, args);
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
if (request.signal && request.signal.aborted) throw err;
|
|
193
|
+
return { content: [{ type: 'text', text: 'The tool call failed.' }], isError: true };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* An agent engine over a chat completions client. It never sends `max_tokens`,
|
|
199
|
+
* which reasoning models and some providers refuse; the server's own limit
|
|
200
|
+
* applies.
|
|
201
|
+
*/
|
|
202
|
+
function agentEngine({ name, model, client, mode, reasoningEffort, formatsInWords = false }) {
|
|
203
|
+
/** One chat completion, counted into `usage`. */
|
|
204
|
+
async function complete(params, signal, usage) {
|
|
205
|
+
if (signal) signal.throwIfAborted();
|
|
206
|
+
const completion = await client.chat.completions.create({
|
|
207
|
+
model,
|
|
208
|
+
...(reasoningEffort && { reasoning_effort: reasoningEffort }),
|
|
209
|
+
...params
|
|
210
|
+
}, { signal });
|
|
211
|
+
if (completion && completion.usage) {
|
|
212
|
+
usage.counted = true;
|
|
213
|
+
usage.inputTokens += completion.usage.prompt_tokens || 0;
|
|
214
|
+
usage.outputTokens += completion.usage.completion_tokens || 0;
|
|
215
|
+
}
|
|
216
|
+
return completion;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Look up what the fields need with the read-only tools, appending every
|
|
221
|
+
* turn to `messages`. Stops when the model stops calling tools, or at the
|
|
222
|
+
* iteration limit.
|
|
223
|
+
*/
|
|
224
|
+
async function research(request, tools, messages, usage) {
|
|
225
|
+
const toolCalls = [];
|
|
226
|
+
messages[1].content += '\n\nYou may first call the listed tools to look up information the fields need. The tools only read; nothing you do is saved.';
|
|
227
|
+
for (let iteration = 0; iteration < (request.maxIterations || 10); iteration++) {
|
|
228
|
+
const completion = await complete({ messages, tools }, request.signal, usage);
|
|
229
|
+
const message = checkChoice(completion.choices && completion.choices[0]);
|
|
230
|
+
const calls = (message.tool_calls || []).filter((call) => call.function);
|
|
231
|
+
messages.push({ role: 'assistant', content: message.content ?? null, ...(calls.length && { tool_calls: calls }) });
|
|
232
|
+
if (!calls.length) {
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
// Run the calls of one turn concurrently, and return every result,
|
|
236
|
+
// recording the calls in the order the model made them.
|
|
237
|
+
const results = await Promise.all(calls.map(async (call) => {
|
|
238
|
+
let args;
|
|
239
|
+
try {
|
|
240
|
+
args = JSON.parse(call.function.arguments || '{}');
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
args = undefined;
|
|
244
|
+
}
|
|
245
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
246
|
+
return { message: { role: 'tool', tool_call_id: call.id, content: 'Error: the arguments were not a JSON object. Call the tool again with a JSON object.' } };
|
|
247
|
+
}
|
|
248
|
+
const result = await runTool(request, call.function.name, args);
|
|
249
|
+
const text = toolResultText(result);
|
|
250
|
+
return {
|
|
251
|
+
call: { name: call.function.name, isError: !!result.isError },
|
|
252
|
+
message: { role: 'tool', tool_call_id: call.id, content: result.isError ? `Error: ${text}` : text }
|
|
253
|
+
};
|
|
254
|
+
}));
|
|
255
|
+
for (const { call, message: result } of results) {
|
|
256
|
+
if (call) toolCalls.push(call);
|
|
257
|
+
messages.push(result);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
messages.push({ role: 'user', content: 'Now give the answer: one JSON object keyed by field path.' });
|
|
261
|
+
return toolCalls;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
name,
|
|
266
|
+
kind: 'agent',
|
|
267
|
+
apiVersion: ENGINE_API_VERSION,
|
|
268
|
+
model,
|
|
269
|
+
toolUse: true,
|
|
270
|
+
|
|
271
|
+
async extract(request) {
|
|
272
|
+
const strict = requiredSchema(request.schema);
|
|
273
|
+
const messages = [
|
|
274
|
+
{ role: 'system', content: request.instructions },
|
|
275
|
+
{ role: 'user', content: prompt(request, mode) },
|
|
276
|
+
];
|
|
277
|
+
const usage = { inputTokens: 0, outputTokens: 0, counted: false };
|
|
278
|
+
const tools = (request.tools || []).map((tool) => ({
|
|
279
|
+
type: 'function',
|
|
280
|
+
function: { name: tool.name, description: tool.description, parameters: tool.inputSchema }
|
|
281
|
+
}));
|
|
282
|
+
const toolCalls = tools.length ? await research(request, tools, messages, usage) : undefined;
|
|
283
|
+
const completion = await complete({
|
|
284
|
+
messages,
|
|
285
|
+
// The tools stay declared because the history may hold tool calls.
|
|
286
|
+
...(tools.length && { tools, tool_choice: 'none' }),
|
|
287
|
+
...(mode === 'json_schema' && { response_format: { type: 'json_schema', json_schema: { name: 'fields', strict: true, schema: formatsInWords ? describeFormats(strict.schema) : strict.schema } } }),
|
|
288
|
+
...(mode === 'json_object' && { response_format: { type: 'json_object' } })
|
|
289
|
+
}, request.signal, usage);
|
|
290
|
+
const message = checkChoice(completion.choices && completion.choices[0]);
|
|
291
|
+
const answer = parseJsonObject(message.content || '');
|
|
292
|
+
// A strict answer is in the encoded schema; a prompted one in the UAG's own types.
|
|
293
|
+
const decoded = mode === 'json_schema' ? strict.decode(answer) : answer;
|
|
294
|
+
// Only the fields asked, whatever the model answered.
|
|
295
|
+
const asked = new Set(Object.keys((request.schema && request.schema.properties) || {}));
|
|
296
|
+
const values = Object.fromEntries(Object.entries(decoded).filter(([path, value]) => asked.has(path) && !isEmpty(value)));
|
|
297
|
+
return {
|
|
298
|
+
model: completion.model || model,
|
|
299
|
+
values,
|
|
300
|
+
...(usage.counted && { usage: { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens } }),
|
|
301
|
+
...(toolCalls && { toolCalls })
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The text of a Responses API answer, or an engine failure when there is none. */
|
|
308
|
+
function responseText(response) {
|
|
309
|
+
if (response.status === 'incomplete') {
|
|
310
|
+
const reason = response.incomplete_details && response.incomplete_details.reason;
|
|
311
|
+
throw new EngineError(reason === 'max_output_tokens'
|
|
312
|
+
? 'The model stopped at the token limit before finishing the answer.'
|
|
313
|
+
: reason === 'content_filter'
|
|
314
|
+
? 'The model\'s content filter stopped the answer.'
|
|
315
|
+
: 'The model did not finish the answer.');
|
|
316
|
+
}
|
|
317
|
+
const parts = (response.output || []).filter((item) => item.type === 'message').flatMap((item) => item.content || []);
|
|
318
|
+
if (parts.some((part) => part.type === 'refusal')) {
|
|
319
|
+
throw new EngineError('The model declined the request.');
|
|
320
|
+
}
|
|
321
|
+
return parts.filter((part) => part.type === 'output_text').map((part) => part.text).join('');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* An agent engine on OpenAI's Responses API, which allows function tools while
|
|
326
|
+
* a model reasons (Chat Completions does not). Nothing is stored at OpenAI
|
|
327
|
+
* (`store: false`); the model's reasoning carries across research turns as
|
|
328
|
+
* encrypted reasoning items in the history.
|
|
329
|
+
*/
|
|
330
|
+
function responsesAgentEngine({ name, model, client, mode, reasoningEffort, formatsInWords = false }) {
|
|
331
|
+
/** One response, counted into `usage`. */
|
|
332
|
+
async function respond(params, signal, usage) {
|
|
333
|
+
if (signal) signal.throwIfAborted();
|
|
334
|
+
const response = await client.responses.create({
|
|
335
|
+
model,
|
|
336
|
+
store: false,
|
|
337
|
+
include: ['reasoning.encrypted_content'],
|
|
338
|
+
...(reasoningEffort && { reasoning: { effort: reasoningEffort } }),
|
|
339
|
+
...params
|
|
340
|
+
}, { signal });
|
|
341
|
+
if (response && response.usage) {
|
|
342
|
+
usage.counted = true;
|
|
343
|
+
usage.inputTokens += response.usage.input_tokens || 0;
|
|
344
|
+
usage.outputTokens += response.usage.output_tokens || 0;
|
|
345
|
+
}
|
|
346
|
+
return response;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
name,
|
|
351
|
+
kind: 'agent',
|
|
352
|
+
apiVersion: ENGINE_API_VERSION,
|
|
353
|
+
model,
|
|
354
|
+
toolUse: true,
|
|
355
|
+
|
|
356
|
+
async extract(request) {
|
|
357
|
+
const strict = requiredSchema(request.schema);
|
|
358
|
+
const tools = (request.tools || []).map((tool) => ({
|
|
359
|
+
type: 'function',
|
|
360
|
+
name: tool.name,
|
|
361
|
+
description: tool.description,
|
|
362
|
+
parameters: tool.inputSchema,
|
|
363
|
+
// The UAG's tool schemas are not written for strict mode.
|
|
364
|
+
strict: false,
|
|
365
|
+
}));
|
|
366
|
+
const input = [{ role: 'user', content: prompt(request, mode) }];
|
|
367
|
+
const usage = { inputTokens: 0, outputTokens: 0, counted: false };
|
|
368
|
+
const toolCalls = [];
|
|
369
|
+
if (tools.length) {
|
|
370
|
+
input[0].content += '\n\nYou may first call the listed tools to look up information the fields need. The tools only read; nothing you do is saved.';
|
|
371
|
+
for (let iteration = 0; iteration < (request.maxIterations || 10); iteration++) {
|
|
372
|
+
const response = await respond({ instructions: request.instructions, input, tools }, request.signal, usage);
|
|
373
|
+
responseText(response);
|
|
374
|
+
// The whole output goes back: reasoning items, messages and calls.
|
|
375
|
+
input.push(...(response.output || []));
|
|
376
|
+
const calls = (response.output || []).filter((item) => item.type === 'function_call');
|
|
377
|
+
if (!calls.length) {
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
// Run the calls of one turn concurrently, and return every result,
|
|
381
|
+
// recording the calls in the order the model made them.
|
|
382
|
+
const results = await Promise.all(calls.map(async (call) => {
|
|
383
|
+
let args;
|
|
384
|
+
try {
|
|
385
|
+
args = JSON.parse(call.arguments || '{}');
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
args = undefined;
|
|
389
|
+
}
|
|
390
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
391
|
+
return { output: { type: 'function_call_output', call_id: call.call_id, output: 'Error: the arguments were not a JSON object. Call the tool again with a JSON object.' } };
|
|
392
|
+
}
|
|
393
|
+
const result = await runTool(request, call.name, args);
|
|
394
|
+
const text = toolResultText(result);
|
|
395
|
+
return {
|
|
396
|
+
call: { name: call.name, isError: !!result.isError },
|
|
397
|
+
output: { type: 'function_call_output', call_id: call.call_id, output: result.isError ? `Error: ${text}` : text }
|
|
398
|
+
};
|
|
399
|
+
}));
|
|
400
|
+
for (const { call, output } of results) {
|
|
401
|
+
if (call) toolCalls.push(call);
|
|
402
|
+
input.push(output);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
input.push({ role: 'user', content: 'Now give the answer: one JSON object keyed by field path.' });
|
|
406
|
+
}
|
|
407
|
+
const response = await respond({
|
|
408
|
+
instructions: request.instructions,
|
|
409
|
+
input,
|
|
410
|
+
// The tools stay declared because the history may hold tool calls.
|
|
411
|
+
...(tools.length && { tools, tool_choice: 'none' }),
|
|
412
|
+
...(mode === 'json_schema' && { text: { format: { type: 'json_schema', name: 'fields', strict: true, schema: formatsInWords ? describeFormats(strict.schema) : strict.schema } } }),
|
|
413
|
+
...(mode === 'json_object' && { text: { format: { type: 'json_object' } } })
|
|
414
|
+
}, request.signal, usage);
|
|
415
|
+
const answer = parseJsonObject(responseText(response));
|
|
416
|
+
// A strict answer is in the encoded schema; a prompted one in the UAG's own types.
|
|
417
|
+
const decoded = mode === 'json_schema' ? strict.decode(answer) : answer;
|
|
418
|
+
// Only the fields asked, whatever the model answered.
|
|
419
|
+
const asked = new Set(Object.keys((request.schema && request.schema.properties) || {}));
|
|
420
|
+
const values = Object.fromEntries(Object.entries(decoded).filter(([path, value]) => asked.has(path) && !isEmpty(value)));
|
|
421
|
+
return {
|
|
422
|
+
model: response.model || model,
|
|
423
|
+
values,
|
|
424
|
+
...(usage.counted && { usage: { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens } }),
|
|
425
|
+
...(tools.length && { toolCalls })
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* An agent engine type for a provider with an OpenAI-compatible API.
|
|
433
|
+
*
|
|
434
|
+
* - `type`, `description`: the type's name and what it is.
|
|
435
|
+
* - `baseURL`: the provider's API, or a function `(settings, requireSetting)`
|
|
436
|
+
* that returns it. Every instance can override it with its `baseURL` setting.
|
|
437
|
+
* - `apiKey`, `model`: the variables (`env`) for the key and the model, which
|
|
438
|
+
* are both required and have no default.
|
|
439
|
+
* - `apiKeyHeader`: send the key in this header instead of as a bearer token.
|
|
440
|
+
* - `settings`: more settings the type needs, such as an endpoint.
|
|
441
|
+
* - `structuredOutput`: the default structured-output mode.
|
|
442
|
+
* - `reasoningEffort`: the variable for `reasoning_effort`, for providers that take it.
|
|
443
|
+
* - `formatsInWords`: false to send string formats and patterns as schema
|
|
444
|
+
* constraints rather than in words (see `describeFormats`). Default true.
|
|
445
|
+
*/
|
|
446
|
+
function openAICompatible(provider) {
|
|
447
|
+
const settings = {
|
|
448
|
+
...(provider.settings || {}),
|
|
449
|
+
apiKey: { env: provider.apiKey.env, required: true, secret: true },
|
|
450
|
+
model: { env: provider.model.env, required: true, description: provider.model.description || 'A dated model version. There is no default.' },
|
|
451
|
+
baseURL: { env: provider.baseURLEnv, description: 'Override the provider\'s API URL, for a proxy or a regional endpoint.' },
|
|
452
|
+
structuredOutput: { env: provider.structuredOutputEnv, default: provider.structuredOutput || 'json_schema', description: 'json_schema, json_object or prompt.' },
|
|
453
|
+
...(provider.reasoningEffort && { reasoningEffort: { env: provider.reasoningEffort.env, description: 'Sent as reasoning_effort, for models that take it.' } }),
|
|
454
|
+
};
|
|
455
|
+
for (const spec of Object.values(settings)) {
|
|
456
|
+
if (spec.env === undefined) delete spec.env;
|
|
457
|
+
}
|
|
458
|
+
return defineEngine({
|
|
459
|
+
type: provider.type,
|
|
460
|
+
kinds: ['agent'],
|
|
461
|
+
apiVersion: ENGINE_API_VERSION,
|
|
462
|
+
description: provider.description,
|
|
463
|
+
settings,
|
|
464
|
+
create({ name, settings: resolved, options, requireSetting }) {
|
|
465
|
+
const mode = checkMode(name, resolved.structuredOutput);
|
|
466
|
+
const baseURL = resolved.baseURL || (typeof provider.baseURL === 'function' ? provider.baseURL(resolved, requireSetting) : provider.baseURL);
|
|
467
|
+
const client = options.client || openAIClient({
|
|
468
|
+
// With a custom header, no bearer token is sent.
|
|
469
|
+
apiKey: provider.apiKeyHeader ? null : resolved.apiKey,
|
|
470
|
+
baseURL,
|
|
471
|
+
...(provider.apiKeyHeader && { defaultHeaders: { [provider.apiKeyHeader]: resolved.apiKey } }),
|
|
472
|
+
fetch: options.fetch,
|
|
473
|
+
});
|
|
474
|
+
return agentEngine({ name, model: resolved.model, client, mode, reasoningEffort: resolved.reasoningEffort, formatsInWords: provider.formatsInWords !== false });
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
module.exports = { MODES, checkMode, isOpenAI, openAIClient, agentEngine, responsesAgentEngine, describeFormats, openAICompatible };
|
package/decisions.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decision kind of the `openai` engine type: the UAG's decision questions,
|
|
3
|
+
* answered by OpenAI's Decisions API (`POST /v1/decisions`).
|
|
4
|
+
*
|
|
5
|
+
* UAG UAG question Decisions API question answer
|
|
6
|
+
* noul (yes/no) predicate probability
|
|
7
|
+
* choice choice choice, probabilities, confidence
|
|
8
|
+
* score score (levels, lowest first) score, probabilities, confidence
|
|
9
|
+
*
|
|
10
|
+
* Question ids are mapped to generated names and back, so the API's naming
|
|
11
|
+
* rules never matter. A question the API declines is left unanswered, which
|
|
12
|
+
* the UAG treats as an undecided field.
|
|
13
|
+
*/
|
|
14
|
+
const { EngineError, ENGINE_API_VERSION } = require('@formio/uag-sdk');
|
|
15
|
+
|
|
16
|
+
/** The most questions the Decisions API accepts in one request. */
|
|
17
|
+
const MAX_QUESTIONS = 400;
|
|
18
|
+
|
|
19
|
+
/** A yes/no question as a predicate, with its true and false criteria in the instructions. */
|
|
20
|
+
function predicate(question) {
|
|
21
|
+
const criteria = question.criteria || {};
|
|
22
|
+
const notes = [
|
|
23
|
+
criteria.true && `It holds when: ${criteria.true}`,
|
|
24
|
+
criteria.false && `It does not hold when: ${criteria.false}`,
|
|
25
|
+
].filter(Boolean);
|
|
26
|
+
return { type: 'predicate', instructions: [question.instructions, ...notes].join('\n') };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toApiQuestion(question, name) {
|
|
30
|
+
switch (question.type) {
|
|
31
|
+
case 'noul':
|
|
32
|
+
return { ...predicate(question), name };
|
|
33
|
+
case 'choice':
|
|
34
|
+
return {
|
|
35
|
+
type: 'choice',
|
|
36
|
+
name,
|
|
37
|
+
instructions: question.instructions,
|
|
38
|
+
choices: Object.entries(question.options).map(([label, description]) => ({ value: label, ...(description && { description }) })),
|
|
39
|
+
};
|
|
40
|
+
case 'score':
|
|
41
|
+
return { type: 'score', name, instructions: question.instructions, levels: question.levels.map((level) => ({ label: level })) };
|
|
42
|
+
default:
|
|
43
|
+
throw new EngineError(`Unsupported question type "${question.type}".`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A probability or confidence: a number from 0 to 1. */
|
|
48
|
+
function isUnit(value) {
|
|
49
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One API answer in the UAG's shape, or undefined when it does not fit the question asked. */
|
|
53
|
+
function fromApiAnswer(answer, question) {
|
|
54
|
+
if (!answer || answer.type === 'refusal') return undefined;
|
|
55
|
+
if (question.type === 'noul' && answer.type === 'predicate') {
|
|
56
|
+
return isUnit(answer.probability) ? { type: 'noul', probability: answer.probability } : undefined;
|
|
57
|
+
}
|
|
58
|
+
if (question.type === 'choice' && answer.type === 'choice') {
|
|
59
|
+
const labels = Object.keys(question.options);
|
|
60
|
+
const choice = String(answer.choice);
|
|
61
|
+
if (!labels.includes(choice) || !isUnit(answer.confidence)) return undefined;
|
|
62
|
+
const probabilities = {};
|
|
63
|
+
for (const entry of answer.probabilities || []) {
|
|
64
|
+
if (entry && labels.includes(String(entry.value)) && isUnit(entry.probability)) probabilities[String(entry.value)] = entry.probability;
|
|
65
|
+
}
|
|
66
|
+
return { type: 'choice', choice, probabilities, confidence: answer.confidence };
|
|
67
|
+
}
|
|
68
|
+
if (question.type === 'score' && answer.type === 'score') {
|
|
69
|
+
const top = question.levels.length - 1;
|
|
70
|
+
if (typeof answer.score !== 'number' || !Number.isFinite(answer.score) || answer.score < 0 || answer.score > top || !isUnit(answer.confidence)) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
const probabilities = {};
|
|
74
|
+
for (const entry of answer.probabilities || []) {
|
|
75
|
+
if (entry && Number.isInteger(entry.value) && entry.value >= 0 && entry.value <= top && isUnit(entry.probability)) {
|
|
76
|
+
probabilities[String(entry.value)] = entry.probability;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { type: 'score', score: answer.score, probabilities, confidence: answer.confidence };
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A decision engine over the Decisions API. `maxOptions` bounds the options
|
|
86
|
+
* the planner sends one choice question; larger closed sets go to an agent.
|
|
87
|
+
*/
|
|
88
|
+
function decisionEngine({ name, model, client, maxOptions }) {
|
|
89
|
+
async function decideBatch(input, batch, signal) {
|
|
90
|
+
signal?.throwIfAborted();
|
|
91
|
+
const response = await client.decisions.create({
|
|
92
|
+
model,
|
|
93
|
+
input,
|
|
94
|
+
questions: batch.map(({ question, name: apiName }) => toApiQuestion(question, apiName)),
|
|
95
|
+
}, { signal });
|
|
96
|
+
if (!response || !Array.isArray(response.answers)) {
|
|
97
|
+
throw new EngineError('The Decisions API returned no answers.');
|
|
98
|
+
}
|
|
99
|
+
return response;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
name,
|
|
104
|
+
kind: 'decision',
|
|
105
|
+
apiVersion: ENGINE_API_VERSION,
|
|
106
|
+
model,
|
|
107
|
+
maxOptions,
|
|
108
|
+
|
|
109
|
+
async decide({ state, questions, signal }) {
|
|
110
|
+
signal?.throwIfAborted();
|
|
111
|
+
// A choice the API cannot take (fewer than 2 options, or more than
|
|
112
|
+
// maxOptions) is not sent, and is left undecided, so it fails alone
|
|
113
|
+
// instead of failing the whole request.
|
|
114
|
+
const entries = Object.entries(questions)
|
|
115
|
+
.filter(([, question]) => question.type !== 'choice' || (Object.keys(question.options || {}).length >= 2 && Object.keys(question.options).length <= maxOptions))
|
|
116
|
+
.map(([id, question], index) => ({ id, question, name: `q${index}` }));
|
|
117
|
+
const byName = new Map(entries.map((entry) => [entry.name, entry]));
|
|
118
|
+
const input = JSON.stringify(state);
|
|
119
|
+
const batches = [];
|
|
120
|
+
for (let start = 0; start < entries.length; start += MAX_QUESTIONS) {
|
|
121
|
+
batches.push(entries.slice(start, start + MAX_QUESTIONS));
|
|
122
|
+
}
|
|
123
|
+
const responses = await Promise.all(batches.map((batch) => decideBatch(input, batch, signal)));
|
|
124
|
+
const answers = {};
|
|
125
|
+
const usage = { inputTokens: 0, outputTokens: 0 };
|
|
126
|
+
let reported = false;
|
|
127
|
+
for (const response of responses) {
|
|
128
|
+
for (const answer of response.answers) {
|
|
129
|
+
const entry = byName.get(answer && answer.name);
|
|
130
|
+
// Only the questions asked: anything else is dropped here.
|
|
131
|
+
const mapped = entry && fromApiAnswer(answer, entry.question);
|
|
132
|
+
if (mapped) answers[entry.id] = mapped;
|
|
133
|
+
}
|
|
134
|
+
if (response.usage) {
|
|
135
|
+
reported = true;
|
|
136
|
+
usage.inputTokens += response.usage.input_tokens || 0;
|
|
137
|
+
usage.outputTokens += response.usage.output_tokens || 0;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
model: (responses[0] && responses[0].model) || model,
|
|
142
|
+
answers,
|
|
143
|
+
...(reported && { usage }),
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { MAX_QUESTIONS, decisionEngine, toApiQuestion, fromApiAnswer };
|
package/index.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI engine for the UAG Agent API: OpenAI itself, or any server that
|
|
3
|
+
* implements its chat completions API (Ollama, vLLM, LM Studio, OpenRouter).
|
|
4
|
+
*
|
|
5
|
+
* UAG_ENGINES=openai OPENAI_API_KEY=... OPENAI_MODEL=<a dated model version>
|
|
6
|
+
* UAG_ENGINES=local:openai UAG_ENGINE_LOCAL_BASE_URL=http://ollama:11434/v1
|
|
7
|
+
* UAG_ENGINE_LOCAL_MODEL=llama4:16x17b
|
|
8
|
+
* UAG_ENGINES=decide:openai UAG_ENGINE_DECIDE_KIND=decision UAG_ENGINE_DECIDE_MODEL=gpt-6-luna
|
|
9
|
+
*
|
|
10
|
+
* An instance is an agent engine (the default: structured extraction, with
|
|
11
|
+
* research over read-only tools; see compatible.js), or, with `kind` set to
|
|
12
|
+
* `decision`, a decision engine on OpenAI's Decisions API (see decisions.js).
|
|
13
|
+
*
|
|
14
|
+
* An agent instance talks to OpenAI through the Responses API, which allows
|
|
15
|
+
* tools while a model reasons, and to any other server (`baseURL`) through
|
|
16
|
+
* Chat Completions. The `api` setting overrides the choice.
|
|
17
|
+
*/
|
|
18
|
+
const { defineEngine, EngineConfigError, ENGINE_API_VERSION, settingVariable } = require('@formio/uag-sdk');
|
|
19
|
+
const { checkMode, isOpenAI, openAIClient, agentEngine, responsesAgentEngine } = require('./compatible');
|
|
20
|
+
const { decisionEngine } = require('./decisions');
|
|
21
|
+
|
|
22
|
+
const KINDS = ['agent', 'decision'];
|
|
23
|
+
const APIS = ['responses', 'chat'];
|
|
24
|
+
const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
|
|
25
|
+
/** The Decisions API accepts at most 255 choices per question (measured October 2026), as Jev does. */
|
|
26
|
+
const MAX_OPTIONS = 255;
|
|
27
|
+
|
|
28
|
+
module.exports = defineEngine({
|
|
29
|
+
type: 'openai',
|
|
30
|
+
kinds: ['agent', 'decision'],
|
|
31
|
+
apiVersion: ENGINE_API_VERSION,
|
|
32
|
+
description: 'OpenAI: structured extraction with research over read-only tools, on OpenAI or any server with its chat completions API; or decisions with probabilities, on OpenAI\'s Decisions API.',
|
|
33
|
+
settings: {
|
|
34
|
+
kind: { env: 'OPENAI_KIND', default: 'agent', description: 'agent, or decision for the Decisions API.' },
|
|
35
|
+
// The instance's own key, for any server. OpenAI's (OPENAI_API_KEY) is a
|
|
36
|
+
// setting of its own, so it is only ever sent to OpenAI itself.
|
|
37
|
+
apiKey: { secret: true, description: 'This instance\'s key, sent to its server. Required for OpenAI itself unless OPENAI_API_KEY is set.' },
|
|
38
|
+
openaiApiKey: { env: 'OPENAI_API_KEY', secret: true, description: 'OpenAI\'s key, sent only to OpenAI itself, never to a baseURL server.' },
|
|
39
|
+
baseURL: { env: 'OPENAI_BASE_URL', description: 'An OpenAI-compatible server, such as http://ollama:11434/v1.' },
|
|
40
|
+
model: { env: 'OPENAI_MODEL', required: true, description: 'A dated model version. There is no default.' },
|
|
41
|
+
structuredOutput: { env: 'OPENAI_STRUCTURED_OUTPUT', default: 'json_schema', description: 'json_schema, json_object or prompt.' },
|
|
42
|
+
api: { env: 'OPENAI_API', description: 'responses or chat. Default: responses for OpenAI itself, chat for any other server.' },
|
|
43
|
+
reasoningEffort: { env: 'OPENAI_REASONING_EFFORT', description: 'Sent as reasoning_effort, for models that take it.' },
|
|
44
|
+
maxOptions: { type: 'number', default: MAX_OPTIONS, description: 'Decision kind: the most options one choice question may carry, up to 255.' },
|
|
45
|
+
},
|
|
46
|
+
create({ name, settings, options }) {
|
|
47
|
+
const { kind, model } = settings;
|
|
48
|
+
if (!KINDS.includes(kind)) {
|
|
49
|
+
throw new EngineConfigError(`The "${name}" engine's kind setting must be one of ${KINDS.join(', ')}, not ${JSON.stringify(kind)}.`);
|
|
50
|
+
}
|
|
51
|
+
const baseURL = settings.baseURL || DEFAULT_BASE_URL;
|
|
52
|
+
// A server other than OpenAI gets only the instance's own key, or none.
|
|
53
|
+
const apiKey = isOpenAI(baseURL) ? settings.apiKey || settings.openaiApiKey : settings.apiKey;
|
|
54
|
+
if (!apiKey && isOpenAI(baseURL)) {
|
|
55
|
+
throw new EngineConfigError(`The "${name}" engine (type "openai") needs its apiKey setting: set ${settingVariable(name, 'apiKey')} or OPENAI_API_KEY.`);
|
|
56
|
+
}
|
|
57
|
+
const client = options.client || openAIClient({ apiKey, baseURL, fetch: options.fetch });
|
|
58
|
+
if (kind === 'decision') {
|
|
59
|
+
if (!Number.isInteger(settings.maxOptions) || settings.maxOptions < 2 || settings.maxOptions > MAX_OPTIONS) {
|
|
60
|
+
throw new EngineConfigError(`The "${name}" engine's maxOptions setting must be a whole number from 2 to ${MAX_OPTIONS}.`);
|
|
61
|
+
}
|
|
62
|
+
return decisionEngine({ name, model, client, maxOptions: settings.maxOptions });
|
|
63
|
+
}
|
|
64
|
+
const mode = checkMode(name, settings.structuredOutput);
|
|
65
|
+
const own = isOpenAI(baseURL);
|
|
66
|
+
const api = settings.api || (own ? 'responses' : 'chat');
|
|
67
|
+
if (!APIS.includes(api)) {
|
|
68
|
+
throw new EngineConfigError(`The "${name}" engine's api setting must be one of ${APIS.join(', ')}, not ${JSON.stringify(api)}.`);
|
|
69
|
+
}
|
|
70
|
+
const engine = api === 'responses' ? responsesAgentEngine : agentEngine;
|
|
71
|
+
// OpenAI's strict mode corrupts constrained dates, so it gets them in words.
|
|
72
|
+
return engine({ name, model, client, mode, reasoningEffort: settings.reasoningEffort, formatsInWords: own });
|
|
73
|
+
},
|
|
74
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@formio/uag-openai",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "2.0.0-rc.1",
|
|
4
|
+
"description": "OpenAI engine for the Form.io Universal Agent Gateway (UAG): structured extraction, with research over read-only tools, on OpenAI or any server with its chat completions API (Ollama, vLLM, LM Studio, OpenRouter); and decisions with probabilities on OpenAI's Decisions API.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"formio",
|
|
7
|
+
"uag",
|
|
8
|
+
"universal-agent-gateway",
|
|
9
|
+
"ai",
|
|
10
|
+
"agents",
|
|
11
|
+
"engine",
|
|
12
|
+
"openai",
|
|
13
|
+
"ollama",
|
|
14
|
+
"vllm",
|
|
15
|
+
"openrouter"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"main": "index.js",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./index.js",
|
|
21
|
+
"./compatible": "./compatible.js",
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"index.js",
|
|
26
|
+
"compatible.js",
|
|
27
|
+
"decisions.js",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "mocha"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22.0.0"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/formio/uag.git",
|
|
39
|
+
"directory": "engines/openai"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@formio/uag-sdk": "^2.0.0-rc.1",
|
|
46
|
+
"openai": "7.43.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"chai": "^6.3.0",
|
|
50
|
+
"mocha": "^12.0.3"
|
|
51
|
+
}
|
|
52
|
+
}
|