@fayegram-ai/decision-bridge 0.1.2 → 0.1.3
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 +19 -0
- package/CONTRIBUTING.md +5 -3
- package/README.md +24 -8
- package/SECURITY.md +18 -11
- package/docs/README.md +17 -4
- package/docs/development/ARCHITECTURE.md +32 -5
- package/docs/development/DESIGN.md +21 -3
- package/docs/development/RELEASING.md +7 -3
- package/docs/development/REPOSITORY_LAYOUT.md +2 -0
- package/docs/development/TESTING.md +31 -3
- package/docs/guides/BROWSER.md +5 -0
- package/docs/guides/CLOUDFLARE_DECISIONS.md +24 -46
- package/docs/guides/CUSTOMIZATION.md +8 -2
- package/docs/guides/CUSTOM_GENERATIVE.md +5 -3
- package/docs/guides/CUSTOM_PROVIDERS.md +5 -3
- package/docs/guides/EXTRACTION.md +125 -0
- package/docs/guides/GETTING_STARTED.md +8 -4
- package/docs/guides/JEV.md +13 -20
- package/docs/guides/LIVE_DECISIONS.md +163 -0
- package/docs/guides/OPENAI_DECISIONS.md +52 -24
- package/docs/guides/PLATFORMS.md +3 -3
- package/docs/guides/SWITCHING_MODELS.md +6 -2
- package/docs/guides/TROUBLESHOOTING.md +25 -0
- package/docs/reference/CLI.md +48 -10
- package/docs/reference/CONFIGURATION.md +31 -8
- package/docs/reference/ERRORS.md +41 -12
- package/docs/reference/PROVIDERS.md +19 -6
- package/docs/reference/SDK.md +62 -24
- package/docs/reference/SOURCES.md +7 -0
- package/examples/extraction.mjs +16 -0
- package/examples/live-decisions.mjs +24 -0
- package/package.json +11 -1
- package/schemas/decision.config.schema.json +88 -11
- package/src/bridge.mjs +9 -6
- package/src/capabilities.mjs +28 -4
- package/src/cli/config-validation.mjs +4 -4
- package/src/cli/config.mjs +4 -2
- package/src/cli/initialize.mjs +8 -4
- package/src/cli/live.mjs +63 -0
- package/src/cli/main.mjs +35 -12
- package/src/cli/provider-loader.mjs +3 -2
- package/src/cloudflare-decisions.d.mts +12 -5
- package/src/decisions.d.mts +28 -4
- package/src/errors.mjs +7 -1
- package/src/extraction/index.d.mts +66 -0
- package/src/extraction/index.mjs +78 -0
- package/src/extraction/validation.mjs +105 -0
- package/src/index.d.mts +18 -4
- package/src/input.mjs +21 -3
- package/src/live/index.d.mts +73 -0
- package/src/live/index.mjs +170 -0
- package/src/live/state.mjs +125 -0
- package/src/live/validation.mjs +122 -0
- package/src/providers/cloudflare-images.mjs +3 -9
- package/src/providers/cloudflare-media.mjs +5 -10
- package/src/providers/cloudflare-preflight.mjs +4 -3
- package/src/providers/cloudflare-profile.mjs +10 -14
- package/src/providers/cloudflare-transport.mjs +12 -5
- package/src/providers/cloudflare-validation.mjs +3 -1
- package/src/providers/decisions-codec.mjs +10 -5
- package/src/providers/decisions-input.mjs +120 -0
- package/src/providers/decisions-validation.mjs +37 -69
- package/src/providers/jev-images.mjs +2 -8
- package/src/providers/jev-preflight.mjs +1 -0
- package/src/providers/jev-profile.mjs +2 -2
- package/src/providers/media-binary.mjs +0 -7
- package/src/providers/openai-decisions.mjs +2 -1
- package/src/providers/openai-errors.mjs +23 -0
- package/src/providers/openai-profile.mjs +2 -1
- package/src/response-transport.mjs +84 -17
- package/src/validation.mjs +26 -14
- package/src/version.mjs +1 -1
|
@@ -9,10 +9,13 @@ const description = value => value === undefined || value === null ? {} : { desc
|
|
|
9
9
|
export function encodeCommonDecision(request) {
|
|
10
10
|
check(!request.input?.some(part => !['text', 'image'].includes(part.type)),
|
|
11
11
|
'OpenAI Decisions does not support audio or video input.', 'UNSUPPORTED_CAPABILITY');
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
const cache = part => own(part, 'promptCacheBreakpoint') ? { prompt_cache_breakpoint: part.promptCacheBreakpoint } : {};
|
|
13
|
+
const input = own(request, 'state') ? encodeText(request.state) : own(request, 'conversation') ? request.conversation
|
|
14
|
+
: [{ role: 'user', content: request.input.map(part => part.type === 'text'
|
|
15
|
+
? { type: 'input_text', text: part.text, ...cache(part) } : {
|
|
16
|
+
type: 'input_image', image_url: part.dataUrl ?? part.url, ...cache(part),
|
|
17
|
+
...(own(part, 'detail') ? { detail: part.detail } : {}),
|
|
18
|
+
}) }];
|
|
16
19
|
const questions = Object.entries(request.questions).map(([name, q]) => {
|
|
17
20
|
const base = { name, instructions: encodeText(q.instructions) };
|
|
18
21
|
if (q.type === 'noul') {
|
|
@@ -29,7 +32,9 @@ export function encodeCommonDecision(request) {
|
|
|
29
32
|
: q.criteria.map((entry, index) => ({ label: String(index), description: encodeText(entry) })) };
|
|
30
33
|
});
|
|
31
34
|
return { model: request.model, input, questions,
|
|
32
|
-
...(own(request, 'safetyIdentifier') ? { safety_identifier: request.safetyIdentifier } : {})
|
|
35
|
+
...(own(request, 'safetyIdentifier') ? { safety_identifier: request.safetyIdentifier } : {}),
|
|
36
|
+
...(own(request, 'promptCacheKey') ? { prompt_cache_key: request.promptCacheKey } : {}),
|
|
37
|
+
...(own(request, 'promptCacheOptions') ? { prompt_cache_options: request.promptCacheOptions } : {}) };
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
export function decodeCommonDecision(decision, request, meta) {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { requireCondition as check } from '../errors.mjs';
|
|
2
|
+
import { isRecord } from '../validation.mjs';
|
|
3
|
+
import { validateImageSource } from '../input.mjs';
|
|
4
|
+
|
|
5
|
+
const own = (value, key) => Object.hasOwn(value, key);
|
|
6
|
+
export function fields(value, allowed, required, label, code = 'VALIDATION_ERROR') {
|
|
7
|
+
check(isRecord(value) && Object.keys(value).every(key => allowed.includes(key)) &&
|
|
8
|
+
required.every(key => own(value, key)), `Invalid ${label} fields.`, code);
|
|
9
|
+
}
|
|
10
|
+
const text = (value, label) => check(typeof value === 'string', `${label} must be text.`);
|
|
11
|
+
|
|
12
|
+
export function validateCacheBreakpoint(value) {
|
|
13
|
+
if (value === undefined || value === null) return;
|
|
14
|
+
fields(value, ['mode'], ['mode'], 'cache breakpoint');
|
|
15
|
+
check(value.mode === 'explicit', 'Cache breakpoint mode must be explicit.');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function validateCacheOptions(value) {
|
|
19
|
+
if (value === undefined || value === null) return;
|
|
20
|
+
fields(value, ['mode', 'ttl'], [], 'cache options');
|
|
21
|
+
check(value.mode === undefined || value.mode === null || ['implicit', 'explicit'].includes(value.mode), 'Invalid cache mode.');
|
|
22
|
+
check(value.ttl === undefined || value.ttl === '30m', 'Unsupported Decisions cache TTL.');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function inputPart(part, textOnly = false) {
|
|
26
|
+
if (part?.type === 'input_text') {
|
|
27
|
+
fields(part, ['type', 'text', 'prompt_cache_breakpoint'], ['type', 'text'], 'input text');
|
|
28
|
+
text(part.text, 'Input text');
|
|
29
|
+
} else {
|
|
30
|
+
check(!textOnly && part?.type === 'input_image', 'Unsupported input content.');
|
|
31
|
+
fields(part, ['type', 'image_url', 'detail', 'prompt_cache_breakpoint'], ['type', 'image_url'], 'input image');
|
|
32
|
+
validateImageSource(part.image_url, part.detail);
|
|
33
|
+
}
|
|
34
|
+
validateCacheBreakpoint(part.prompt_cache_breakpoint);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function outputPart(part) {
|
|
38
|
+
if (part?.type === 'output_text') {
|
|
39
|
+
fields(part, ['type', 'text'], ['type', 'text'], 'output text');
|
|
40
|
+
text(part.text, 'Output text');
|
|
41
|
+
} else {
|
|
42
|
+
fields(part, ['type', 'refusal'], ['type', 'refusal'], 'refusal');
|
|
43
|
+
check(part.type === 'refusal', 'Unsupported assistant content.');
|
|
44
|
+
text(part.refusal, 'Refusal');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Keep strings, item order, explicit nulls and tool arguments verbatim. */
|
|
49
|
+
export function validateDecisionInput(input) {
|
|
50
|
+
if (typeof input === 'string') return;
|
|
51
|
+
check(Array.isArray(input) && input.length > 0, 'input must be text or a nonempty item array.');
|
|
52
|
+
const calls = new Map();
|
|
53
|
+
for (const item of input) {
|
|
54
|
+
if (isRecord(item) && own(item, 'role')) {
|
|
55
|
+
fields(item, ['type', 'role', 'content', 'phase'], ['role', 'content'], 'message');
|
|
56
|
+
check(item.type === undefined || item.type === 'message', 'Invalid message type.');
|
|
57
|
+
check(['user', 'developer', 'system', 'assistant'].includes(item.role), 'Unsupported message role.');
|
|
58
|
+
check(item.phase === undefined || item.phase === null ||
|
|
59
|
+
['commentary', 'final_answer'].includes(item.phase), 'Invalid assistant phase.');
|
|
60
|
+
if (typeof item.content !== 'string') {
|
|
61
|
+
check(Array.isArray(item.content) && item.content.length > 0, 'Message requires ordered content.');
|
|
62
|
+
for (const part of item.content) {
|
|
63
|
+
if (item.role === 'assistant') outputPart(part);
|
|
64
|
+
else inputPart(part, item.role === 'system' || item.role === 'developer');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const call = ['function_call', 'custom_tool_call'].includes(item?.type);
|
|
70
|
+
const output = ['function_call_output', 'custom_tool_call_output'].includes(item?.type);
|
|
71
|
+
check(call || output, 'Unsupported conversation item.');
|
|
72
|
+
const payload = output ? 'output' : item.type === 'function_call' ? 'arguments' : 'input';
|
|
73
|
+
fields(item, ['type', 'call_id', payload, 'id', 'status', ...(call ? ['name', 'namespace'] : [])],
|
|
74
|
+
['type', 'call_id', payload, ...(call ? ['name'] : [])], 'tool item');
|
|
75
|
+
check(typeof item.call_id === 'string' && item.call_id.length > 0, 'Tool items require a call_id.');
|
|
76
|
+
for (const key of ['id', 'namespace']) if (own(item, key) && item[key] !== null) text(item[key], key);
|
|
77
|
+
check(item.status === undefined || item.status === null ||
|
|
78
|
+
['in_progress', 'completed', 'incomplete'].includes(item.status), 'Invalid tool status.');
|
|
79
|
+
if (call) {
|
|
80
|
+
check(typeof item.name === 'string' && item.name.length > 0, 'Tool calls require a name.');
|
|
81
|
+
text(item[payload], payload);
|
|
82
|
+
calls.set(item.call_id, item.type);
|
|
83
|
+
} else {
|
|
84
|
+
check(calls.get(item.call_id) === item.type.replace('_output', ''), 'Tool output requires a matching preceding call.');
|
|
85
|
+
if (typeof item.output !== 'string') {
|
|
86
|
+
check(Array.isArray(item.output) && item.output.length > 0, 'Tool output requires text or ordered content.');
|
|
87
|
+
item.output.forEach(part => inputPart(part));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function validateDecisionQuestions(questions) {
|
|
94
|
+
check(Array.isArray(questions) && questions.length > 0, 'questions must be a nonempty array.');
|
|
95
|
+
for (const q of questions) {
|
|
96
|
+
check(isRecord(q) && ['predicate', 'choice', 'score'].includes(q.type), 'Unsupported native question type.');
|
|
97
|
+
const extra = q.type === 'choice' ? ['choices'] : q.type === 'score' ? ['levels'] : [];
|
|
98
|
+
fields(q, ['type', 'name', 'instructions', ...extra], ['type', 'instructions', ...extra], 'question');
|
|
99
|
+
text(q.instructions, 'Instructions');
|
|
100
|
+
if (own(q, 'name')) text(q.name, 'Question name');
|
|
101
|
+
if (q.type === 'choice') {
|
|
102
|
+
check(Array.isArray(q.choices) && q.choices.length >= 2, 'choices requires at least two options.');
|
|
103
|
+
const values = new Set();
|
|
104
|
+
for (const option of q.choices) {
|
|
105
|
+
fields(option, ['value', 'description'], ['value'], 'choice option');
|
|
106
|
+
check(typeof option.value === 'string' || typeof option.value === 'boolean', 'Choice values must be strings or booleans.');
|
|
107
|
+
check(!values.has(option.value), 'Choice values must be distinct.');
|
|
108
|
+
values.add(option.value);
|
|
109
|
+
if (own(option, 'description')) text(option.description, 'Choice description');
|
|
110
|
+
}
|
|
111
|
+
} else if (q.type === 'score') {
|
|
112
|
+
check(Array.isArray(q.levels) && q.levels.length > 0, 'levels must be nonempty.');
|
|
113
|
+
for (const level of q.levels) {
|
|
114
|
+
fields(level, ['label', 'description'], ['label'], 'score level');
|
|
115
|
+
text(level.label, 'Score label');
|
|
116
|
+
if (own(level, 'description')) text(level.description, 'Score description');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -1,81 +1,24 @@
|
|
|
1
1
|
import { DecisionError, requireCondition as check } from '../errors.mjs';
|
|
2
2
|
import { cloneJson, freezeJson, isRecord } from '../validation.mjs';
|
|
3
|
-
import { isTextWithinLimit, validateImage } from '../input.mjs';
|
|
4
3
|
import { builtInCapabilities } from '../capabilities.mjs';
|
|
5
4
|
import { OPENAI_DEFAULT_MODEL } from './openai-profile.mjs';
|
|
5
|
+
import { fields, validateCacheOptions, validateDecisionInput, validateDecisionQuestions } from './decisions-input.mjs';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
function fields(value, allowed, required, label, code = 'VALIDATION_ERROR') {
|
|
9
|
-
check(isRecord(value) && Object.keys(value).every(key => allowed.includes(key)) &&
|
|
10
|
-
required.every(key => own(value, key)), `Invalid ${label} fields.`, code);
|
|
11
|
-
}
|
|
12
|
-
function text(value, label, max = 1048576) {
|
|
13
|
-
check(isTextWithinLimit(value, max), `Invalid ${label}.`);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** Validate native fields while retaining the selected model for preparation and metadata. */
|
|
7
|
+
/** Structural preflight; service counts and context limits remain server-owned. */
|
|
17
8
|
export function validateDecisionRequest(input, defaultModel = OPENAI_DEFAULT_MODEL) {
|
|
18
9
|
const request = cloneJson(input, 'native decision request');
|
|
19
|
-
fields(request, ['input', 'model', 'questions', 'safety_identifier'
|
|
20
|
-
|
|
10
|
+
fields(request, ['input', 'model', 'questions', 'safety_identifier', 'prompt_cache_key', 'prompt_cache_options'],
|
|
11
|
+
['input', 'questions'], 'decision request');
|
|
12
|
+
const model = Object.hasOwn(request, 'model') ? request.model : defaultModel;
|
|
21
13
|
check(typeof model === 'string' && model.trim().length > 0, 'model must be nonempty.');
|
|
22
14
|
check(builtInCapabilities('openai-decisions', model).supported,
|
|
23
15
|
'Selected provider/model does not support this model.', 'UNSUPPORTED_CAPABILITY');
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
let images = 0;
|
|
27
|
-
if (typeof request.input === 'string') text(request.input, 'input', 10485760);
|
|
28
|
-
else {
|
|
29
|
-
check(Array.isArray(request.input) && request.input.length > 0, 'input must be text or nonempty user messages.');
|
|
30
|
-
for (const message of request.input) {
|
|
31
|
-
fields(message, ['role', 'content', 'type'], ['role', 'content'], 'input message');
|
|
32
|
-
check(message.role === 'user' && (!own(message, 'type') || message.type === 'message'),
|
|
33
|
-
'Only user input messages are supported.');
|
|
34
|
-
if (typeof message.content === 'string') text(message.content, 'message content', 10485760);
|
|
35
|
-
else {
|
|
36
|
-
check(Array.isArray(message.content) && message.content.length > 0, 'Message content must contain ordered parts.');
|
|
37
|
-
for (const part of message.content) {
|
|
38
|
-
if (part?.type === 'input_text') {
|
|
39
|
-
fields(part, ['type', 'text'], ['type', 'text'], 'text part');
|
|
40
|
-
text(part.text, 'input text', 10485760);
|
|
41
|
-
} else {
|
|
42
|
-
fields(part, ['type', 'image_url', 'detail'], ['type', 'image_url'], 'image part');
|
|
43
|
-
check(part.type === 'input_image', 'Unsupported input part.');
|
|
44
|
-
validateImage(part.image_url, part.detail);
|
|
45
|
-
images++;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
check(images <= 128, 'At most 128 images are supported.', 'UNSUPPORTED_CAPABILITY');
|
|
52
|
-
check(Array.isArray(request.questions) && request.questions.length > 0, 'questions must be a nonempty array.');
|
|
53
|
-
for (const q of request.questions) {
|
|
54
|
-
check(isRecord(q) && ['predicate', 'choice', 'score'].includes(q.type), 'Unsupported native question type.');
|
|
55
|
-
const extra = q.type === 'choice' ? ['choices'] : q.type === 'score' ? ['levels'] : [];
|
|
56
|
-
fields(q, ['type', 'name', 'instructions', ...extra], ['type', 'instructions', ...extra], 'question');
|
|
57
|
-
text(q.instructions, 'instructions');
|
|
58
|
-
if (own(q, 'name')) text(q.name, 'question name');
|
|
59
|
-
if (q.type === 'choice') {
|
|
60
|
-
check(Array.isArray(q.choices) && q.choices.length >= 2 && q.choices.length <= 255,
|
|
61
|
-
'choices must contain 2 to 255 options.');
|
|
62
|
-
const values = new Set();
|
|
63
|
-
for (const option of q.choices) {
|
|
64
|
-
fields(option, ['value', 'description'], ['value'], 'choice option');
|
|
65
|
-
check(typeof option.value === 'string' || typeof option.value === 'boolean', 'Choice values must be strings or booleans.');
|
|
66
|
-
check(!values.has(option.value), 'Choice values must be distinct.');
|
|
67
|
-
values.add(option.value);
|
|
68
|
-
if (own(option, 'description')) text(option.description, 'choice description');
|
|
69
|
-
}
|
|
70
|
-
} else if (q.type === 'score') {
|
|
71
|
-
check(Array.isArray(q.levels) && q.levels.length > 0, 'levels must be nonempty.');
|
|
72
|
-
for (const level of q.levels) {
|
|
73
|
-
fields(level, ['label', 'description'], ['label'], 'score level');
|
|
74
|
-
text(level.label, 'score label');
|
|
75
|
-
if (own(level, 'description')) text(level.description, 'score description');
|
|
76
|
-
}
|
|
77
|
-
}
|
|
16
|
+
for (const key of ['safety_identifier', 'prompt_cache_key']) {
|
|
17
|
+
check(request[key] === undefined || request[key] === null || typeof request[key] === 'string', key + ' must be text or null.');
|
|
78
18
|
}
|
|
19
|
+
validateCacheOptions(request.prompt_cache_options);
|
|
20
|
+
validateDecisionInput(request.input);
|
|
21
|
+
validateDecisionQuestions(request.questions);
|
|
79
22
|
return freezeJson({ ...request, model });
|
|
80
23
|
}
|
|
81
24
|
|
|
@@ -94,11 +37,36 @@ function validateUsage(usage) {
|
|
|
94
37
|
}
|
|
95
38
|
|
|
96
39
|
/** Refusals are failures even when valid sibling answers exist. No partial success. */
|
|
97
|
-
export function validateDecisionResponse(input, request) {
|
|
40
|
+
export function validateDecisionResponse(input, request, { allowRefusals = false } = {}) {
|
|
98
41
|
const data = cloneJson(input, 'native decision response', 'INVALID_RESPONSE');
|
|
99
42
|
fields(data, ['model', 'answers', 'usage'], ['model', 'answers', 'usage'], 'decision response', 'INVALID_RESPONSE');
|
|
100
43
|
invalid(typeof data.model === 'string' && data.model.trim().length > 0, 'Missing reported model.');
|
|
101
44
|
validateUsage(data.usage);
|
|
45
|
+
// Live can deliver a native result before its configuration is observed.
|
|
46
|
+
// Validate its self-contained distribution without inventing request correlation.
|
|
47
|
+
if (!request) {
|
|
48
|
+
invalid(Array.isArray(data.answers) && data.answers.length > 0, 'Missing decision answers.');
|
|
49
|
+
request = { questions: data.answers.map(answer => {
|
|
50
|
+
invalid(isRecord(answer) && (answer.name === null || typeof answer.name === 'string'), 'Invalid answer name.');
|
|
51
|
+
invalid(['predicate', 'choice', 'score', 'refusal'].includes(answer.type), 'Invalid answer type.');
|
|
52
|
+
const question = { type: answer.type, ...(answer.name === null ? {} : { name: answer.name }) };
|
|
53
|
+
if (answer.type === 'choice' || answer.type === 'score') {
|
|
54
|
+
invalid(Array.isArray(answer.probabilities) && answer.probabilities.length > 0, 'Missing decision distribution.');
|
|
55
|
+
if (answer.type === 'choice') question.choices = answer.probabilities.map(item => {
|
|
56
|
+
invalid(isRecord(item) && ['string', 'boolean'].includes(typeof item.value), 'Invalid choice value.');
|
|
57
|
+
return { value: item.value };
|
|
58
|
+
});
|
|
59
|
+
else {
|
|
60
|
+
const sorted = [...answer.probabilities].sort((a, b) => a?.value - b?.value);
|
|
61
|
+
question.levels = sorted.map((item, index) => {
|
|
62
|
+
invalid(isRecord(item) && item.value === index && typeof item.label === 'string', 'Invalid score level.');
|
|
63
|
+
return { label: item.label };
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return question;
|
|
68
|
+
}) };
|
|
69
|
+
}
|
|
102
70
|
invalid(Array.isArray(data.answers) && data.answers.length === request.questions.length, 'Decision answer count mismatch.');
|
|
103
71
|
const refusedQuestions = [];
|
|
104
72
|
for (let index = 0; index < data.answers.length; index++) {
|
|
@@ -144,7 +112,7 @@ export function validateDecisionResponse(input, request) {
|
|
|
144
112
|
answer.score <= supplied.length - 1 && Math.abs(answer.score - mean) <= 0.02 * supplied.length,
|
|
145
113
|
'Decision score is inconsistent with its rubric.');
|
|
146
114
|
}
|
|
147
|
-
if (refusedQuestions.length) throw new DecisionError('The model refused one or more decision questions.', {
|
|
115
|
+
if (refusedQuestions.length && !allowRefusals) throw new DecisionError('The model refused one or more decision questions.', {
|
|
148
116
|
code: 'MODEL_REFUSAL', refusedQuestions, usage: data.usage,
|
|
149
117
|
});
|
|
150
118
|
return freezeJson(data);
|
|
@@ -11,16 +11,13 @@ function inspectImage(asset) {
|
|
|
11
11
|
(asset.type === undefined || asset.type === 'image') && ['png', 'jpg', 'jpeg'].includes(asset.format),
|
|
12
12
|
'Jev JSON image assets require PNG/JPEG format and data_base64.');
|
|
13
13
|
const size = base64ByteLength(asset.data_base64);
|
|
14
|
-
check(size <= 16 * 1024 * 1024, 'Jev images must be within 16 MiB each.', 'REQUEST_TOO_LARGE');
|
|
15
14
|
const decoded = atob(asset.data_base64);
|
|
16
15
|
// A string iterator makes Uint8Array.from allocate an intermediate element
|
|
17
16
|
// list. Copy directly into one bounded byte buffer for multi-MiB images.
|
|
18
17
|
const bytes = new Uint8Array(decoded.length);
|
|
19
18
|
for (let i = 0; i < decoded.length; i++) bytes[i] = decoded.charCodeAt(i);
|
|
20
19
|
const [width, height] = imageDimensions(bytes, asset.format === 'png' ? 'image/png' : 'image/jpeg');
|
|
21
|
-
check(width
|
|
22
|
-
width * height >= 78400 && width * height <= 2458624,
|
|
23
|
-
'Jev image dimensions must be 256–1568 pixels with an area of 78400–2458624 pixels.');
|
|
20
|
+
check(width > 0 && height > 0, 'Images require positive dimensions.');
|
|
24
21
|
if (asset.format === 'png') {
|
|
25
22
|
const view = new DataView(bytes.buffer);
|
|
26
23
|
for (let offset = 8; offset < bytes.length;) {
|
|
@@ -37,7 +34,6 @@ function inspectImage(asset) {
|
|
|
37
34
|
|
|
38
35
|
/** Apply TypeSafe's JSON nesting boundary and collect only replaceable values. */
|
|
39
36
|
function stringValues(value, output, depth = 0) {
|
|
40
|
-
check(depth <= 64, 'Jev state, instructions, and criteria must not exceed 64 nested levels.');
|
|
41
37
|
if (typeof value === 'string') output.push(value);
|
|
42
38
|
else if (value !== null && typeof value === 'object') {
|
|
43
39
|
for (const child of Object.values(value)) stringValues(child, output, depth + 1);
|
|
@@ -78,11 +74,9 @@ export function validateJevAssets(request) {
|
|
|
78
74
|
check(referenceBytes <= 65536, 'Jev asset references exceed 65536 UTF-8 bytes.', 'REQUEST_TOO_LARGE');
|
|
79
75
|
}
|
|
80
76
|
const used = usedReferences(strings, references);
|
|
81
|
-
let imageBytes = 0;
|
|
82
77
|
for (const reference of references) {
|
|
83
78
|
check(used.has(reference), 'Every Jev asset must be used by reference substitution in a string value.');
|
|
84
|
-
|
|
85
|
-
check(imageBytes <= 32 * 1024 * 1024, 'Jev images exceed 32 MiB total.', 'REQUEST_TOO_LARGE');
|
|
79
|
+
inspectImage(assets[reference]);
|
|
86
80
|
}
|
|
87
81
|
// Server re-encoding, expanded references, full image decoding and token limits
|
|
88
82
|
// remain authoritative; original file sizes cannot predict those exactly.
|
|
@@ -41,6 +41,7 @@ export function prepareJevDecision(input, options = {}) {
|
|
|
41
41
|
const dangerZone = jevDangerZone(options.dangerZone);
|
|
42
42
|
assertCapabilities(request, builtInCapabilities('jev', request.model, { dangerZone }));
|
|
43
43
|
const native = { ...request, ...(assets === undefined ? {} : { assets }) };
|
|
44
|
+
serializeRequestBody(native, jevRequestLimit(options.maxRequestBytes));
|
|
44
45
|
const imageCount = validateJevAssets(native);
|
|
45
46
|
if (imageCount) check(dangerZone.images, 'Enable Jev dangerZone.images to send images.', 'UNSUPPORTED_CAPABILITY');
|
|
46
47
|
if (Object.values(request.questions).some(question => question.type === 'box')) {
|
|
@@ -31,7 +31,7 @@ export function jevHeaders(dangerZone) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
export function jevRequestLimit(value = JEV_REQUEST_LIMIT) {
|
|
34
|
-
check(Number.isSafeInteger(value) && value > 0 && value <=
|
|
35
|
-
'Jev maxRequestBytes must be within 1 to
|
|
34
|
+
check(Number.isSafeInteger(value) && value > 0 && value <= 64 * 1024 * 1024,
|
|
35
|
+
'Jev maxRequestBytes must be within the client budget of 1 to 64 MiB.', 'CONFIGURATION_ERROR');
|
|
36
36
|
return value;
|
|
37
37
|
}
|
|
@@ -42,10 +42,3 @@ export function mediaBytes(payload, size) {
|
|
|
42
42
|
},
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
-
|
|
46
|
-
/** Container metadata can reject an overlong clip; full decoding remains upstream. */
|
|
47
|
-
export function checkMediaDuration(duration, limit) {
|
|
48
|
-
if (duration === undefined) return;
|
|
49
|
-
mediaCheck(Number.isFinite(duration) && duration >= 0);
|
|
50
|
-
check(duration <= limit + 1e-9, 'Embedded media exceeds the duration limit.');
|
|
51
|
-
}
|
|
@@ -8,6 +8,7 @@ import { validateDecisionResponse } from './decisions-validation.mjs';
|
|
|
8
8
|
import { decodeCommonDecision } from './decisions-codec.mjs';
|
|
9
9
|
import { preflightCommonDecision, prepareCommonDecision, prepareDecision } from './decisions-preflight.mjs';
|
|
10
10
|
import { OPENAI_DEFAULT_MODEL } from './openai-profile.mjs';
|
|
11
|
+
import { openaiError } from './openai-errors.mjs';
|
|
11
12
|
|
|
12
13
|
/** Native Decisions transport. Common and dedicated access share a single codec. */
|
|
13
14
|
export class OpenAIDecisionsProvider {
|
|
@@ -26,7 +27,7 @@ export class OpenAIDecisionsProvider {
|
|
|
26
27
|
const key = validateApiKey(supplied, 'OPENAI_API_KEY');
|
|
27
28
|
check(typeof model === 'string' && model.trim(), 'model must be nonempty.', 'CONFIGURATION_ERROR');
|
|
28
29
|
this.defaultModel = model.trim();
|
|
29
|
-
this.#transport = new JsonTransport({ ...transportOptions, maxRetries, baseURL: endpoint.baseURL,
|
|
30
|
+
this.#transport = new JsonTransport({ ...transportOptions, maxRetries, errorDecoder: openaiError, baseURL: endpoint.baseURL,
|
|
30
31
|
headers: { authorization: `Bearer ${key}` }, secrets: [key] });
|
|
31
32
|
this.#maxRequestBytes = transportOptions.maxRequestBytes;
|
|
32
33
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { DecisionError } from '../errors.mjs';
|
|
2
|
+
import { isRecord } from '../validation.mjs';
|
|
3
|
+
|
|
4
|
+
const BILLING_CODES = new Set(['insufficient_quota', 'credit_balance_exhausted',
|
|
5
|
+
'organization_spend_limit_exceeded', 'project_spend_limit_exceeded', 'organization_usage_limit_exceeded']);
|
|
6
|
+
const machineCode = value => typeof value === 'string' && /^[a-zA-Z0-9_.-]{1,128}$/.test(value) ? value : undefined;
|
|
7
|
+
|
|
8
|
+
/** Only machine codes leave the decoder; upstream messages can contain input. */
|
|
9
|
+
export function openaiError(data, status, meta = {}) {
|
|
10
|
+
const details = isRecord(data) && isRecord(data.error) ? data.error : {};
|
|
11
|
+
const upstreamErrorCode = machineCode(details.code);
|
|
12
|
+
const upstreamErrorType = machineCode(details.type);
|
|
13
|
+
const billing = BILLING_CODES.has(upstreamErrorCode) || upstreamErrorType === 'insufficient_quota';
|
|
14
|
+
const code = billing ? 'ALLOCATION_EXCEEDED' : status === 401 ? 'AUTHENTICATION_ERROR'
|
|
15
|
+
: status === 403 ? 'PERMISSION_DENIED' : status === 429 ? 'RATE_LIMITED'
|
|
16
|
+
: status === 413 ? 'REQUEST_TOO_LARGE' : status === 400 || status === 422 ? 'INVALID_REQUEST' : 'HTTP_ERROR';
|
|
17
|
+
return new DecisionError(`OpenAI rejected the request (${code}).`, {
|
|
18
|
+
...meta, code, status, source: 'provider',
|
|
19
|
+
retryable: !billing && [429, 500, 502, 503, 504, 529].includes(status),
|
|
20
|
+
...(upstreamErrorCode ? { upstreamErrorCode } : {}),
|
|
21
|
+
...(upstreamErrorType ? { upstreamErrorType } : {}),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -8,7 +8,8 @@ const PROFILES = Object.freeze({
|
|
|
8
8
|
[LUNA_MODEL]: Object.freeze({
|
|
9
9
|
supported: true, orderedInput: true, images: true, typedChoices: true,
|
|
10
10
|
labeledScores: true, safetyIdentifier: true,
|
|
11
|
-
maxImages:
|
|
11
|
+
maxImages: null, maxChoiceOptions: null, maxScoreLevels: null,
|
|
12
|
+
conversation: true, toolHistory: true, remoteImages: true, promptCaching: true,
|
|
12
13
|
}),
|
|
13
14
|
});
|
|
14
15
|
|
|
@@ -13,8 +13,9 @@ const BYTE_VIEW_KIND = Object.getOwnPropertyDescriptor(BYTE_VIEW_PROTOTYPE, Symb
|
|
|
13
13
|
/** Shared wire-size check for offline preflight and actual HTTP dispatch. */
|
|
14
14
|
export function serializeRequestBody(body, maxRequestBytes = 8 * 1024 * 1024) {
|
|
15
15
|
const serialized = body === undefined ? undefined : JSON.stringify(body);
|
|
16
|
-
|
|
17
|
-
'
|
|
16
|
+
if (serialized !== undefined && UTF8_ENCODER.encode(serialized).byteLength > maxRequestBytes) {
|
|
17
|
+
throw new DecisionError('Request exceeds the configured client byte budget.', { code: 'REQUEST_TOO_LARGE', source: 'client' });
|
|
18
|
+
}
|
|
18
19
|
return serialized;
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -35,15 +36,52 @@ export function sanitizeId(value, secrets = []) {
|
|
|
35
36
|
return safe.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 200) || undefined;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
/** RFC 9110 HTTP dates use UTC, including the timezone-free asctime form. */
|
|
40
|
+
function httpDate(value, now) {
|
|
41
|
+
let parts = /^([A-Z][a-z]{2}), (\d{2}) ([A-Z][a-z]{2}) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/.exec(value);
|
|
42
|
+
let legacy = false;
|
|
43
|
+
if (!parts) {
|
|
44
|
+
parts = /^([A-Z][a-z]+), (\d{2})-([A-Z][a-z]{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/.exec(value);
|
|
45
|
+
legacy = !!parts;
|
|
46
|
+
}
|
|
47
|
+
if (!parts) {
|
|
48
|
+
const match = /^([A-Z][a-z]{2}) ([A-Z][a-z]{2}) (\d{2}| \d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/.exec(value);
|
|
49
|
+
if (!match) return undefined;
|
|
50
|
+
const [, weekday, month, day, hour, minute, second, year] = match;
|
|
51
|
+
parts = [value, weekday, day, month, year, hour, minute, second];
|
|
52
|
+
}
|
|
53
|
+
const [, weekday, dayText, monthText, yearText, hourText, minuteText, secondText] = parts;
|
|
54
|
+
const days = legacy ? ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
|
55
|
+
: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
56
|
+
const month = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].indexOf(monthText);
|
|
57
|
+
const [day, hour, minute, second] = [dayText, hourText, minuteText, secondText].map(Number);
|
|
58
|
+
if (!days.includes(weekday) || month < 0 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 60) return undefined;
|
|
59
|
+
const future = new Date(now);
|
|
60
|
+
future.setUTCFullYear(future.getUTCFullYear() + 50);
|
|
61
|
+
let year = Number(yearText) + (legacy ? Math.floor(future.getUTCFullYear() / 100) * 100 : 0);
|
|
62
|
+
const dateFor = selectedYear => {
|
|
63
|
+
const date = new Date(0);
|
|
64
|
+
date.setUTCFullYear(selectedYear, month, day);
|
|
65
|
+
date.setUTCHours(hour, minute, Math.min(second, 59), 0);
|
|
66
|
+
return date;
|
|
67
|
+
};
|
|
68
|
+
let date = dateFor(year);
|
|
69
|
+
// Two-digit years denote the most recent matching year no more than 50 years ahead.
|
|
70
|
+
if (legacy && date > future) { year -= 100; date = dateFor(year); }
|
|
71
|
+
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month || date.getUTCDate() !== day) return undefined;
|
|
72
|
+
return date.getTime() + (second === 60 ? 1000 : 0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function retryAfter(headers) {
|
|
76
|
+
const raw = headers.get('retry-after');
|
|
77
|
+
if (raw === null) return undefined;
|
|
78
|
+
if (/^\d+$/.test(raw)) {
|
|
79
|
+
const milliseconds = Number(raw) * 1000;
|
|
80
|
+
return Number.isFinite(milliseconds) ? milliseconds : undefined;
|
|
81
|
+
}
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const date = httpDate(raw, now);
|
|
84
|
+
return date === undefined ? undefined : Math.max(0, date - now);
|
|
47
85
|
}
|
|
48
86
|
|
|
49
87
|
async function readJson(response, maxBytes, budget) {
|
|
@@ -123,7 +161,18 @@ export class JsonResponseTransport {
|
|
|
123
161
|
for (let retry = 0; ; retry++) {
|
|
124
162
|
budget.throwIfAborted();
|
|
125
163
|
attempts++;
|
|
126
|
-
|
|
164
|
+
let response;
|
|
165
|
+
try { response = await withSignal(dispatch(serialized, budget.signal), budget.signal); }
|
|
166
|
+
catch (error) {
|
|
167
|
+
// Binding exceptions may provide structured own data, never inspect messages/getters.
|
|
168
|
+
const descriptors = error && typeof error === 'object' ? Object.getOwnPropertyDescriptors(error) : {};
|
|
169
|
+
const status = descriptors.status?.value;
|
|
170
|
+
const internalCode = descriptors.internalCode?.value;
|
|
171
|
+
if (this.#errorDecoder && Number.isInteger(status) && status >= 400 && status <= 599 &&
|
|
172
|
+
Number.isSafeInteger(internalCode) && internalCode >= 0) {
|
|
173
|
+
response = Response.json({ internalCode }, { status });
|
|
174
|
+
} else throw error;
|
|
175
|
+
}
|
|
127
176
|
check(response instanceof Response, 'Provider must return a Response.', 'INVALID_RESPONSE');
|
|
128
177
|
const extra = this.#responseMetadata?.(response) ?? {};
|
|
129
178
|
lastRequestId = sanitizeId(extra.requestId ?? response.headers.get('x-request-id') ??
|
|
@@ -135,7 +184,14 @@ export class JsonResponseTransport {
|
|
|
135
184
|
...(lastGatewayLogId ? { gatewayLogId: lastGatewayLogId } : {}),
|
|
136
185
|
};
|
|
137
186
|
if (response.ok) {
|
|
138
|
-
|
|
187
|
+
let data;
|
|
188
|
+
try { data = await readJson(response, this.#options.maxResponseBytes, budget); }
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (error instanceof DecisionError) throw new DecisionError(error.message,
|
|
191
|
+
{ ...error.toJSON(), status: response.status, ...meta,
|
|
192
|
+
source: error.code === 'RESPONSE_TOO_LARGE' ? 'client' : 'provider' });
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
139
195
|
budget.throwIfAborted();
|
|
140
196
|
meta.durationMs = Math.round((performance.now() - started) * 1000) / 1000;
|
|
141
197
|
return { data, meta };
|
|
@@ -143,15 +199,25 @@ export class JsonResponseTransport {
|
|
|
143
199
|
let data;
|
|
144
200
|
if (this.#errorDecoder) {
|
|
145
201
|
try { data = await readJson(response, this.#options.maxResponseBytes, budget); }
|
|
146
|
-
catch
|
|
202
|
+
catch {
|
|
203
|
+
// A rejection remains authoritative when its body is malformed,
|
|
204
|
+
// oversized or interrupted. Cancellation/deadlines still win.
|
|
205
|
+
budget.throwIfAborted();
|
|
206
|
+
}
|
|
147
207
|
} else response.body?.cancel().catch(() => {});
|
|
148
208
|
const error = this.#errorDecoder?.(data, response.status, meta) ??
|
|
149
209
|
new DecisionError(`Provider returned HTTP ${response.status}.`, {
|
|
150
|
-
code: 'HTTP_ERROR', status: response.status, ...meta,
|
|
210
|
+
code: 'HTTP_ERROR', status: response.status, source: 'provider', ...meta,
|
|
151
211
|
});
|
|
152
212
|
budget.throwIfAborted();
|
|
153
|
-
|
|
154
|
-
const
|
|
213
|
+
error.retryable ??= RETRY_STATUSES.has(response.status);
|
|
214
|
+
const hint = retryAfter(response.headers);
|
|
215
|
+
if (hint !== undefined) error.retryAfterMs = hint;
|
|
216
|
+
for (const key of ['upstreamErrorCode', 'upstreamErrorType']) {
|
|
217
|
+
if (error[key] !== undefined) error[key] = sanitizeId(error[key], this.#secrets);
|
|
218
|
+
}
|
|
219
|
+
if (!error.retryable || retry >= this.#options.maxRetries) throw error;
|
|
220
|
+
const delay = hint === undefined ? Math.min(8000, 250 * 2 ** retry) * (0.5 + Math.random()) : hint;
|
|
155
221
|
if (delay >= budget.remainingMs) throw new DecisionError('Retry delay exceeds the remaining request deadline.', {
|
|
156
222
|
...error.toJSON(), code: 'RETRY_BUDGET_EXCEEDED',
|
|
157
223
|
});
|
|
@@ -164,6 +230,7 @@ export class JsonResponseTransport {
|
|
|
164
230
|
if (budget.timedOut) throw new DecisionError('Provider request deadline exceeded.', { code: 'TIMEOUT', ...diagnostics });
|
|
165
231
|
if (error instanceof DecisionError) {
|
|
166
232
|
error.attempts ??= attempts;
|
|
233
|
+
error.source ??= 'client';
|
|
167
234
|
if (lastRequestId) error.requestId ??= lastRequestId;
|
|
168
235
|
if (lastGatewayLogId) error.gatewayLogId ??= lastGatewayLogId;
|
|
169
236
|
throw error;
|