@fayegram-ai/decision-bridge 0.1.1 → 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.
Files changed (79) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/CONTRIBUTING.md +5 -3
  3. package/README.md +26 -10
  4. package/SECURITY.md +18 -11
  5. package/docs/README.md +19 -5
  6. package/docs/development/ARCHITECTURE.md +36 -7
  7. package/docs/development/DESIGN.md +21 -3
  8. package/docs/development/RELEASING.md +7 -3
  9. package/docs/development/REPOSITORY_LAYOUT.md +2 -0
  10. package/docs/development/TESTING.md +31 -3
  11. package/docs/guides/BROWSER.md +8 -0
  12. package/docs/guides/CLOUDFLARE_DECISIONS.md +24 -46
  13. package/docs/guides/CUSTOMIZATION.md +8 -2
  14. package/docs/guides/CUSTOM_GENERATIVE.md +5 -3
  15. package/docs/guides/CUSTOM_PROVIDERS.md +5 -3
  16. package/docs/guides/EXTRACTION.md +125 -0
  17. package/docs/guides/GETTING_STARTED.md +8 -4
  18. package/docs/guides/JEV.md +167 -0
  19. package/docs/guides/LIVE_DECISIONS.md +163 -0
  20. package/docs/guides/OPENAI_DECISIONS.md +52 -24
  21. package/docs/guides/PLATFORMS.md +3 -3
  22. package/docs/guides/SWITCHING_MODELS.md +6 -2
  23. package/docs/guides/TROUBLESHOOTING.md +25 -0
  24. package/docs/reference/CLI.md +60 -11
  25. package/docs/reference/CONFIGURATION.md +34 -6
  26. package/docs/reference/ERRORS.md +41 -12
  27. package/docs/reference/PROVIDERS.md +31 -8
  28. package/docs/reference/SDK.md +72 -26
  29. package/docs/reference/SOURCES.md +11 -0
  30. package/examples/extraction.mjs +16 -0
  31. package/examples/live-decisions.mjs +24 -0
  32. package/package.json +11 -1
  33. package/schemas/decision.config.schema.json +102 -11
  34. package/src/bridge.mjs +9 -6
  35. package/src/capabilities.mjs +34 -4
  36. package/src/cli/config-validation.mjs +13 -5
  37. package/src/cli/config.mjs +19 -4
  38. package/src/cli/decisions.mjs +8 -0
  39. package/src/cli/initialize.mjs +8 -4
  40. package/src/cli/live.mjs +63 -0
  41. package/src/cli/main.mjs +50 -16
  42. package/src/cli/provider-loader.mjs +3 -2
  43. package/src/cloudflare-decisions.d.mts +12 -5
  44. package/src/decisions.d.mts +28 -4
  45. package/src/errors.mjs +7 -1
  46. package/src/extraction/index.d.mts +66 -0
  47. package/src/extraction/index.mjs +78 -0
  48. package/src/extraction/validation.mjs +105 -0
  49. package/src/index.d.mts +42 -9
  50. package/src/index.mjs +1 -1
  51. package/src/input.mjs +21 -3
  52. package/src/jev.d.mts +16 -0
  53. package/src/live/index.d.mts +73 -0
  54. package/src/live/index.mjs +170 -0
  55. package/src/live/state.mjs +125 -0
  56. package/src/live/validation.mjs +122 -0
  57. package/src/probabilities.mjs +6 -3
  58. package/src/providers/cloudflare-images.mjs +5 -67
  59. package/src/providers/cloudflare-media.mjs +5 -10
  60. package/src/providers/cloudflare-preflight.mjs +4 -3
  61. package/src/providers/cloudflare-profile.mjs +10 -14
  62. package/src/providers/cloudflare-transport.mjs +12 -5
  63. package/src/providers/cloudflare-validation.mjs +3 -1
  64. package/src/providers/decisions-codec.mjs +10 -5
  65. package/src/providers/decisions-input.mjs +120 -0
  66. package/src/providers/decisions-validation.mjs +37 -69
  67. package/src/providers/image-header.mjs +59 -0
  68. package/src/providers/jev-images.mjs +84 -0
  69. package/src/providers/jev-preflight.mjs +62 -0
  70. package/src/providers/jev-profile.mjs +37 -0
  71. package/src/providers/jev.mjs +32 -13
  72. package/src/providers/media-binary.mjs +0 -7
  73. package/src/providers/openai-decisions.mjs +2 -1
  74. package/src/providers/openai-errors.mjs +23 -0
  75. package/src/providers/openai-profile.mjs +2 -1
  76. package/src/questions.mjs +2 -0
  77. package/src/response-transport.mjs +84 -17
  78. package/src/validation.mjs +39 -20
  79. package/src/version.mjs +1 -1
@@ -0,0 +1,170 @@
1
+ import { DecisionError, requireCondition as check, publicError } from '../errors.mjs';
2
+ import { optionsObject } from '../options.mjs';
3
+ import { deadline, validateSignal, withSignal } from '../async.mjs';
4
+ import { freezeJson } from '../validation.mjs';
5
+ import { serializeRequestBody } from '../transport.mjs';
6
+ import { validateLiveCommand, validateLiveConfiguration, validateLiveEvent } from './validation.mjs';
7
+ import { createLiveState } from './state.mjs';
8
+
9
+ export { validateLiveCommand, validateLiveConfiguration, validateLiveEvent } from './validation.mjs';
10
+
11
+ /** Own Decisions commands and correlation, while the application owns its connection. */
12
+ export function createLiveDecisions(options) {
13
+ const { transport, configuration, timeoutMs = 30000, maxPending = 128, maxHistory = 4096,
14
+ maxRequestBytes = 8 * 1024 * 1024, maxResponseBytes = 8 * 1024 * 1024 } = optionsObject(options,
15
+ ['transport', 'configuration', 'timeoutMs', 'maxPending', 'maxHistory', 'maxRequestBytes', 'maxResponseBytes'], 'Live controller options');
16
+ check(transport && typeof transport.send === 'function' && typeof transport.subscribe === 'function',
17
+ 'Live requires a supplied connection adapter.', 'CONFIGURATION_ERROR');
18
+ for (const value of [maxPending, maxHistory]) check(Number.isSafeInteger(value) && value > 0,
19
+ 'Live history and pending budgets must be positive integers.', 'CONFIGURATION_ERROR');
20
+ for (const value of [maxRequestBytes, maxResponseBytes]) check(Number.isSafeInteger(value) && value > 0 && value <= 64 * 1024 * 1024,
21
+ 'Live byte budgets must be within 1 to 64 MiB.', 'CONFIGURATION_ERROR');
22
+ check(Number.isSafeInteger(timeoutMs) && timeoutMs > 0 && timeoutMs <= 2147483647, 'Invalid Live timeout.', 'CONFIGURATION_ERROR');
23
+ const state = createLiveState({ configuration, maxHistory });
24
+ const observers = new Set();
25
+ const pending = new Map();
26
+ const prefix = globalThis.crypto.randomUUID();
27
+ let sequence = 0;
28
+ let closed = false;
29
+ let closeError;
30
+ const lifetime = new AbortController();
31
+ let unsubscribe;
32
+ const failure = (code, message) => new DecisionError(message, { code, source: 'client' });
33
+
34
+ function notify(event) {
35
+ const immutable = freezeJson(event);
36
+ // Subscription changes affect later deliveries, never revisit a recipient.
37
+ for (const observer of [...observers]) {
38
+ if (!observers.has(observer)) continue;
39
+ const failed = () => {
40
+ if (event.type !== 'observer_error' && !closed) notify({ type: 'observer_error',
41
+ error: { code: 'OBSERVER_ERROR', message: 'A Live observer failed.' } });
42
+ };
43
+ try { Promise.resolve(observer(immutable)).catch(failed); } catch { failed(); }
44
+ }
45
+ }
46
+ function detach() {
47
+ const cleanup = unsubscribe;
48
+ unsubscribe = undefined;
49
+ try { cleanup?.(); } catch { /* Application cleanup cannot retain pending work. */ }
50
+ }
51
+ function close(code, message, report = true) {
52
+ if (closed) return;
53
+ closed = true;
54
+ closeError = failure(code, message);
55
+ lifetime.abort();
56
+ for (const wait of pending.values()) wait.reject(failure(code, message));
57
+ pending.clear();
58
+ detach();
59
+ state.clear();
60
+ if (report) notify({ type: 'closed', error: { code, message } });
61
+ observers.clear();
62
+ }
63
+ function receive(notification) {
64
+ if (closed) return;
65
+ if (notification?.kind === 'close' || notification?.kind === 'error') {
66
+ close(notification.kind === 'close' ? 'CONNECTION_CLOSED' : 'NETWORK_ERROR', 'The supplied Live connection ended.');
67
+ return;
68
+ }
69
+ try {
70
+ check(notification?.kind === 'event', 'Invalid connection notification.', 'INVALID_RESPONSE');
71
+ const event = validateLiveEvent(notification.event);
72
+ try { serializeRequestBody(event, maxResponseBytes); }
73
+ catch (error) {
74
+ if (error.code === 'REQUEST_TOO_LARGE') throw failure('RESPONSE_TOO_LARGE', 'Live event exceeds the client byte budget.');
75
+ throw error;
76
+ }
77
+ const normalized = state.accept(event);
78
+ const clientId = event.error?.client_event_id ?? event.client_event_id;
79
+ const wait = clientId ? pending.get(clientId) : undefined;
80
+ if (normalized?.type !== 'duplicate' && wait) {
81
+ if (event.type === 'error') wait.reject(new DecisionError(normalized.error.message, normalized.error));
82
+ else if (event.type === wait.expected) {
83
+ const mismatched = event.type === 'decision.item.deleted' && event.item_id !== wait.command.item_id ||
84
+ event.type === 'decision.item.created' && wait.command.item.id && event.item.id !== wait.command.item.id;
85
+ if (mismatched) {
86
+ const error = failure('INVALID_RESPONSE', 'Item acknowledgement does not match the command.');
87
+ wait.reject(error);
88
+ throw error;
89
+ }
90
+ wait.resolve(normalized);
91
+ }
92
+ }
93
+ if (normalized) notify(normalized);
94
+ if (event.type === 'session.closed') close('CONNECTION_CLOSED', 'The Live session closed.', false);
95
+ } catch (error) { notify({ type: 'error', error: publicError(error) }); }
96
+ }
97
+ try { unsubscribe = transport.subscribe(receive); }
98
+ catch { throw failure('NETWORK_ERROR', 'Cannot subscribe to the supplied Live connection.'); }
99
+ check(typeof unsubscribe === 'function', 'Live transport subscribe must return an unsubscribe function.', 'CONFIGURATION_ERROR');
100
+ if (closed) detach();
101
+
102
+ async function send(type, body, expected, options = {}) {
103
+ const { signal, timeoutMs: callTimeoutMs = timeoutMs } = optionsObject(options, ['signal', 'timeoutMs'], 'Live call options');
104
+ check(!closed, 'Live controller is closed.', 'CONNECTION_CLOSED');
105
+ check(pending.size < maxPending, 'Live pending operations exceed the client resource budget.', 'RESOURCE_LIMIT');
106
+ validateSignal(signal);
107
+ const budget = deadline(callTimeoutMs, signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal);
108
+ let command;
109
+ try {
110
+ budget.throwIfAborted();
111
+ command = validateLiveCommand({ ...body, type, event_id: `${prefix}_${++sequence}` });
112
+ budget.throwIfAborted();
113
+ } catch (error) {
114
+ budget.close();
115
+ if (budget.timedOut) throw failure('TIMEOUT', 'Live command deadline exceeded.');
116
+ throw error;
117
+ }
118
+ let observed = false;
119
+ let resolve;
120
+ let reject;
121
+ const acknowledged = expected ? new Promise((yes, no) => { resolve = yes; reject = no; }) : undefined;
122
+ acknowledged?.catch(() => {});
123
+ // Submission-only calls are tracked too, so disposal interrupts a stalled send.
124
+ const stopped = new Promise((_yes, no) => {
125
+ pending.set(command.event_id, { resolve, reject: no, expected, command });
126
+ });
127
+ stopped.catch(() => {});
128
+ if (expected) pending.get(command.event_id).reject = error => { reject(error); };
129
+ try {
130
+ budget.throwIfAborted();
131
+ serializeRequestBody(command, maxRequestBytes);
132
+ const dispatched = Promise.resolve().then(() => {
133
+ budget.throwIfAborted();
134
+ // Unsent updates cannot change the accepted configuration. Observe
135
+ // just before send so synchronous acknowledgements still correlate.
136
+ state.observeClient(command);
137
+ observed = true;
138
+ return transport.send(command);
139
+ });
140
+ // Acknowledgement can arrive synchronously from a supplied transport.
141
+ await withSignal(Promise.race([dispatched, expected ? acknowledged.then(() => undefined) : stopped]), budget.signal);
142
+ if (expected) return await withSignal(acknowledged, budget.signal);
143
+ budget.throwIfAborted();
144
+ return freezeJson({ eventId: command.event_id, type, status: 'submitted' });
145
+ } catch (error) {
146
+ if (observed) state.abandon(command);
147
+ if (budget.timedOut) throw failure('TIMEOUT', 'Live command deadline exceeded.');
148
+ if (signal?.aborted) throw failure('ABORTED', 'Live command cancelled.');
149
+ if (closed) throw closeError;
150
+ if (error instanceof DecisionError) throw error;
151
+ throw failure('NETWORK_ERROR', 'Live command submission failed.');
152
+ } finally { pending.delete(command.event_id); budget.close(); }
153
+ }
154
+ return Object.freeze({
155
+ configure(config, call) { return send('session.update', { session: config }, 'session.updated', call); },
156
+ createItem(item, call) { return send('decision.item.create', { item }, 'decision.item.created', call); },
157
+ deleteItem(itemId, call) { return send('decision.item.delete', { item_id: itemId }, 'decision.item.deleted', call); },
158
+ requestDecision(call) { return send('decision.create', {}, undefined, call); },
159
+ appendCommentary(content, options = {}) {
160
+ const { delegationId = null, ...call } = optionsObject(options, ['delegationId', 'signal', 'timeoutMs'], 'Commentary options');
161
+ return send('session.commentary.append', { content, delegation_id: delegationId }, undefined, call);
162
+ },
163
+ subscribe(observer) {
164
+ check(!closed && typeof observer === 'function', 'Live subscription requires an open controller and function.', 'CONFIGURATION_ERROR');
165
+ observers.add(observer);
166
+ return () => observers.delete(observer);
167
+ },
168
+ dispose() { close('DISPOSED', 'Live controller disposed.'); },
169
+ });
170
+ }
@@ -0,0 +1,125 @@
1
+ import { freezeJson } from '../validation.mjs';
2
+ import { requireCondition as check } from '../errors.mjs';
3
+ import { validateDecisionResponse } from '../providers/decisions-validation.mjs';
4
+ import { TRANSCRIPTS, configurationFromSession, validateLiveConfiguration } from './validation.mjs';
5
+ import { openaiError } from '../providers/openai-errors.mjs';
6
+
7
+ /** Bounded correlation history. Evicted sources become unmatched, never guessed. */
8
+ export function createLiveState({ configuration, maxHistory = 4096 } = {}) {
9
+ let config = configuration === undefined ? undefined : validateLiveConfiguration(configuration);
10
+ let generation = 0;
11
+ let sequence = 0;
12
+ let lostConfigurationHistory = false;
13
+ let closed = false;
14
+ const sources = new Map();
15
+ const delegations = new Map();
16
+ const seen = new Map();
17
+ const pendingConfigurations = new Map();
18
+ const latest = new Map();
19
+ function remember(map, key, value) {
20
+ map.set(key, value);
21
+ if (map.size > maxHistory) map.delete(map.keys().next().value);
22
+ }
23
+ function questionsFor(type) {
24
+ if (!config || pendingConfigurations.size || lostConfigurationHistory) return undefined;
25
+ if (type === 'delegation') return config.delegation?.decisions.questions;
26
+ const matches = config.triggers?.filter(trigger => trigger.event_type === type) ?? [];
27
+ return matches.length === 1 ? matches[0].decisions.questions : undefined;
28
+ }
29
+ function clear() {
30
+ closed = true;
31
+ config = undefined;
32
+ lostConfigurationHistory = false;
33
+ for (const map of [sources, delegations, seen, pendingConfigurations, latest]) map.clear();
34
+ }
35
+ return {
36
+ clear,
37
+ observeClient(command) {
38
+ check(!closed, 'Live session is closed.', 'CONNECTION_CLOSED');
39
+ if (command.type === 'session.update') {
40
+ // Offline logs may omit optional IDs. Without one no acknowledgement
41
+ // can prove which update completed; do not guess a source snapshot.
42
+ if (!command.event_id) { lostConfigurationHistory = true; return; }
43
+ // An evicted unresolved update can still take effect upstream. Retain
44
+ // uncertainty even after all remaining IDs have been acknowledged.
45
+ if (!pendingConfigurations.has(command.event_id) && pendingConfigurations.size >= maxHistory) lostConfigurationHistory = true;
46
+ remember(pendingConfigurations, command.event_id, true);
47
+ }
48
+ },
49
+ abandon(command) {
50
+ // Do not assume a timed-out update failed server-side. Its effect is unknown
51
+ // until a resolved snapshot arrives, so source correlation stays conservative.
52
+ // A correlated acknowledgement/rejection already removed this marker;
53
+ // known rejection leaves the previous accepted configuration intact.
54
+ if (command.type === 'session.update' && pendingConfigurations.has(command.event_id)) config = undefined;
55
+ },
56
+ accept(event) {
57
+ check(!closed, 'Live session is closed.', 'CONNECTION_CLOSED');
58
+ if (seen.has(event.event_id)) return freezeJson({ type: 'duplicate', eventId: event.event_id, eventType: event.type });
59
+ remember(seen, event.event_id, true);
60
+ const base = { eventId: event.event_id, eventType: event.type };
61
+ if (['session.created', 'session.updated'].includes(event.type)) {
62
+ config = configurationFromSession(event.session);
63
+ generation++;
64
+ if (event.client_event_id) pendingConfigurations.delete(event.client_event_id);
65
+ return freezeJson({ type: 'configuration', ...base, configuration: config, generation });
66
+ }
67
+ if (TRANSCRIPTS.includes(event.type)) {
68
+ const snapshot = { type: event.type, sequence: ++sequence, generation,
69
+ questions: questionsFor(event.type) };
70
+ remember(sources, event.event_id, snapshot);
71
+ return freezeJson({ type: 'transcript', ...base, direction: event.type === TRANSCRIPTS[0] ? 'input' : 'output',
72
+ delta: event.delta, sequence: snapshot.sequence });
73
+ }
74
+ if (event.type === 'session.delegation.created' && event.delegation.target === 'decisions') {
75
+ remember(delegations, event.delegation.id, { generation, questions: questionsFor('delegation') });
76
+ return freezeJson({ type: 'delegation', ...base, delegationId: event.delegation.id });
77
+ }
78
+ if (event.type === 'decision.event') {
79
+ // Mixed origins are observable but cannot establish one trustworthy snapshot.
80
+ const triggerOrigin = event.delegation_id === null && event.trigger_id !== null &&
81
+ event.source_event_id !== null && event.source_event_type !== null;
82
+ const delegationOrigin = event.delegation_id !== null && event.trigger_id === null &&
83
+ event.source_event_id === null && event.source_event_type === null;
84
+ const observedSource = triggerOrigin ? sources.get(event.source_event_id) : undefined;
85
+ const source = observedSource?.type === event.source_event_type ? observedSource : undefined;
86
+ const snapshot = source ?? (delegationOrigin ? delegations.get(event.delegation_id) : undefined);
87
+ let matched = !!snapshot?.questions;
88
+ let reason = 'source_or_configuration_unavailable';
89
+ const decision = validateDecisionResponse(event.event.decision, undefined, { allowRefusals: true });
90
+ if (matched) {
91
+ try { validateDecisionResponse(decision, { questions: snapshot.questions }, { allowRefusals: true }); }
92
+ catch { matched = false; reason = 'configuration_mismatch'; }
93
+ }
94
+ let stale = false;
95
+ // Transcript order is known even when the question snapshot is not.
96
+ // Only an observed ID with the same direction can advance that order.
97
+ if (source) {
98
+ stale = source.sequence <= (latest.get(source.type) ?? -1);
99
+ if (!stale) latest.set(source.type, source.sequence);
100
+ }
101
+ if (delegationOrigin) delegations.delete(event.delegation_id);
102
+ return freezeJson({ type: 'decision', ...base, decision,
103
+ correlation: { matched, stale, sourceEventId: event.source_event_id, sourceEventType: event.source_event_type,
104
+ delegationId: event.delegation_id, triggerId: event.trigger_id,
105
+ ...(matched ? { generation: snapshot.generation } : { reason }) } });
106
+ }
107
+ if (event.type === 'error') {
108
+ const hint = event.error.type === 'rate_limit_error' ? 429 : 400;
109
+ // Live supplies protocol errors, not an HTTP response/status.
110
+ const { status: _status, ...error } = openaiError({ error: event.error }, hint).toJSON();
111
+ const clientEventId = event.error.client_event_id ?? event.client_event_id;
112
+ if (clientEventId) pendingConfigurations.delete(clientEventId);
113
+ return freezeJson({ type: 'error', ...base, error, ...(clientEventId ? { clientEventId } : {}) });
114
+ }
115
+ if (event.type === 'session.closed') {
116
+ clear();
117
+ return freezeJson({ type: 'closed', ...base });
118
+ }
119
+ if (['decision.item.created', 'decision.item.deleted'].includes(event.type)) {
120
+ return freezeJson({ type: 'item', ...base, ...(event.item ? { item: event.item } : { itemId: event.item_id }) });
121
+ }
122
+ return undefined;
123
+ },
124
+ };
125
+ }
@@ -0,0 +1,122 @@
1
+ import { DecisionError, requireCondition as check } from '../errors.mjs';
2
+ import { cloneJson, freezeJson, isRecord } from '../validation.mjs';
3
+ import { fields, validateDecisionQuestions } from '../providers/decisions-input.mjs';
4
+ import { validateDecisionResponse } from '../providers/decisions-validation.mjs';
5
+ import { validateImage } from '../input.mjs';
6
+
7
+ export const TRANSCRIPTS = ['session.input_transcript.delta', 'session.output_transcript.delta'];
8
+ const text = value => typeof value === 'string' && value.length > 0;
9
+ const nullableId = value => value === null || text(value);
10
+
11
+ export function validateLiveConfiguration(input) {
12
+ const config = cloneJson(input, 'Live Decisions configuration');
13
+ fields(config, ['delegation', 'triggers'], [], 'Live configuration');
14
+ if (config.delegation !== undefined) {
15
+ fields(config.delegation, ['type', 'decisions'], ['type', 'decisions'], 'delegation');
16
+ check(config.delegation.type === 'decisions', 'This controller configures Decisions delegation.');
17
+ fields(config.delegation.decisions, ['model', 'questions'], [], 'delegation decisions');
18
+ const decisions = config.delegation.decisions;
19
+ check(decisions.model === undefined || decisions.model === 'gpt-6-luna', 'Unsupported Live Decisions model.');
20
+ if (decisions.questions !== undefined) validateDecisionQuestions(decisions.questions);
21
+ }
22
+ if (config.triggers !== undefined) {
23
+ check(Array.isArray(config.triggers), 'triggers must be an array.');
24
+ for (const trigger of config.triggers) {
25
+ fields(trigger, ['type', 'event_type', 'decisions'], ['type', 'event_type', 'decisions'], 'trigger');
26
+ check(trigger.type === 'decisions' && TRANSCRIPTS.includes(trigger.event_type), 'Invalid Decisions trigger.');
27
+ fields(trigger.decisions, ['questions'], ['questions'], 'trigger decisions');
28
+ validateDecisionQuestions(trigger.decisions.questions);
29
+ }
30
+ }
31
+ return freezeJson(config);
32
+ }
33
+
34
+ function item(input, created = false) {
35
+ fields(input, ['id', 'type', 'role', 'content'], ['role', 'content', ...(created ? ['id'] : [])], 'Decision input item');
36
+ check(input.role === 'user' && (input.type === undefined || input.type === 'message'), 'Live Decision items are user messages.');
37
+ check(created ? text(input.id) : input.id === undefined || input.id === null || text(input.id), 'Invalid item ID.');
38
+ if (typeof input.content !== 'string') {
39
+ check(Array.isArray(input.content) && input.content.length > 0, 'Live item requires content.');
40
+ for (const part of input.content) {
41
+ if (part?.type === 'input_text') {
42
+ fields(part, ['type', 'text'], ['type', 'text'], 'Live text part');
43
+ check(typeof part.text === 'string', 'Live text must be a string.');
44
+ } else {
45
+ fields(part, ['type', 'image_url', 'detail'], ['type', 'image_url'], 'Live image part');
46
+ check(part.type === 'input_image', 'Unsupported Live input part.');
47
+ validateImage(part.image_url, part.detail);
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ export function validateLiveCommand(input) {
54
+ const command = cloneJson(input, 'Live command');
55
+ check(isRecord(command) && typeof command.type === 'string', 'Live command requires a type.');
56
+ check(command.event_id === undefined || command.event_id === null || text(command.event_id), 'Invalid client event ID.');
57
+ const base = ['type', 'event_id'];
58
+ if (command.type === 'session.update') {
59
+ fields(command, [...base, 'session'], ['type', 'session'], 'session update');
60
+ validateLiveConfiguration(command.session);
61
+ } else if (command.type === 'decision.item.create') {
62
+ fields(command, [...base, 'item'], ['type', 'item'], 'item create');
63
+ item(command.item);
64
+ } else if (command.type === 'decision.item.delete') {
65
+ fields(command, [...base, 'item_id'], ['type', 'item_id'], 'item delete');
66
+ check(text(command.item_id), 'Item delete requires an ID.');
67
+ } else if (command.type === 'decision.create') {
68
+ fields(command, base, base, 'decision create');
69
+ check(text(command.event_id), 'Manual evaluation requires an event ID.');
70
+ } else {
71
+ fields(command, [...base, 'delegation_id', 'content'], ['type', 'delegation_id', 'content'], 'commentary');
72
+ check(command.type === 'session.commentary.append' && nullableId(command.delegation_id) &&
73
+ typeof command.content === 'string', 'Invalid commentary command.');
74
+ }
75
+ return freezeJson(command);
76
+ }
77
+
78
+ /** Only inspect the Decisions subset of full Live session snapshots. */
79
+ export function configurationFromSession(session) {
80
+ const result = {};
81
+ if (session.delegation?.type === 'decisions') result.delegation = session.delegation;
82
+ if (Array.isArray(session.triggers)) result.triggers = session.triggers.filter(trigger => trigger.type === 'decisions');
83
+ return validateLiveConfiguration(result);
84
+ }
85
+
86
+ export function validateLiveEvent(input) {
87
+ // Nested request validators are reused below, but server content must leave
88
+ // this boundary as a response error. Unknown exceptions never expose text.
89
+ try { return validateServerEvent(input); }
90
+ catch (error) {
91
+ if (error instanceof DecisionError) {
92
+ if (error.code === 'INVALID_RESPONSE') throw error;
93
+ throw new DecisionError(error.message, { ...error.toJSON(), code: 'INVALID_RESPONSE' });
94
+ }
95
+ throw new DecisionError('Invalid Live server event.', { code: 'INVALID_RESPONSE' });
96
+ }
97
+ }
98
+
99
+ function validateServerEvent(input) {
100
+ const event = cloneJson(input, 'Live event', 'INVALID_RESPONSE');
101
+ const invalid = (condition, message) => check(condition, message, 'INVALID_RESPONSE');
102
+ invalid(isRecord(event) && text(event.type) && text(event.event_id), 'Live server events require type and event_id.');
103
+ invalid(event.client_event_id === undefined || text(event.client_event_id), 'Invalid acknowledgement correlation ID.');
104
+ if (event.type === 'decision.event') {
105
+ invalid(nullableId(event.delegation_id) && nullableId(event.trigger_id) && nullableId(event.source_event_id) &&
106
+ (event.source_event_type === null || TRANSCRIPTS.includes(event.source_event_type)) &&
107
+ event.event?.type === 'decision.completed', 'Invalid Decision event envelope.');
108
+ validateDecisionResponse(event.event.decision, undefined, { allowRefusals: true });
109
+ } else if (TRANSCRIPTS.includes(event.type)) {
110
+ invalid(typeof event.delta === 'string' && Number.isFinite(event.start_ms) && Number.isFinite(event.end_ms) &&
111
+ event.start_ms >= 0 && event.end_ms >= event.start_ms, 'Invalid transcript delta.');
112
+ } else if (['session.created', 'session.updated', 'session.closed'].includes(event.type)) {
113
+ invalid(isRecord(event.session), 'Missing Live session snapshot.');
114
+ configurationFromSession(event.session);
115
+ } else if (event.type === 'decision.item.created') item(event.item, true);
116
+ else if (event.type === 'decision.item.deleted') invalid(text(event.item_id), 'Missing deleted item ID.');
117
+ else if (event.type === 'session.delegation.created') invalid(isRecord(event.delegation) && text(event.delegation.id) &&
118
+ ['client', 'responses', 'decisions'].includes(event.delegation.target), 'Invalid delegation metadata.');
119
+ else if (event.type === 'error') invalid(isRecord(event.error) && text(event.error.code) && text(event.error.type) &&
120
+ (event.error.client_event_id === undefined || text(event.error.client_event_id)), 'Invalid Live error.');
121
+ return freezeJson(event);
122
+ }
@@ -6,14 +6,17 @@ export function validateResultOptions(options) {
6
6
  [Object.prototype, null].includes(Object.getPrototypeOf(options)) &&
7
7
  Object.getOwnPropertySymbols(options).length === 0 &&
8
8
  Object.entries(Object.getOwnPropertyDescriptors(options)).every(([key, descriptor]) =>
9
- ['native', 'probabilityDecimalPlaces'].includes(key) && descriptor.enumerable && 'value' in descriptor),
9
+ ['native', 'nullableUsage', 'probabilityDecimalPlaces'].includes(key) && descriptor.enumerable && 'value' in descriptor),
10
10
  'Result-validation options contain an unknown field or accessor.', 'CONFIGURATION_ERROR');
11
- const { native = false, probabilityDecimalPlaces } = options;
11
+ // Only own data fields select policy; inherited values and getters are not settings.
12
+ const { native = false, nullableUsage = false, probabilityDecimalPlaces } = Object.assign(Object.create(null), options);
12
13
  check(typeof native === 'boolean', 'native must be boolean.', 'CONFIGURATION_ERROR');
14
+ check(typeof nullableUsage === 'boolean', 'nullableUsage must be boolean.', 'CONFIGURATION_ERROR');
13
15
  check(probabilityDecimalPlaces === undefined || (Number.isSafeInteger(probabilityDecimalPlaces) &&
14
16
  probabilityDecimalPlaces >= 1 && probabilityDecimalPlaces <= 15),
15
17
  'probabilityDecimalPlaces must be an integer from 1 to 15.', 'CONFIGURATION_ERROR');
16
- return { native, ...(probabilityDecimalPlaces === undefined ? {} : { probabilityDecimalPlaces }) };
18
+ // Keep even an omitted precision as an own value when callers read this policy again.
19
+ return { native, nullableUsage, probabilityDecimalPlaces };
17
20
  }
18
21
 
19
22
  /** Preserve native numbers. Only grid-aligned values receive the rounding budget. */
@@ -1,69 +1,12 @@
1
1
  import { requireCondition as check } from '../errors.mjs';
2
2
  import { validateImage } from '../input.mjs';
3
3
  import { isRecord } from '../validation.mjs';
4
+ import { imageDimensions } from './image-header.mjs';
4
5
 
5
6
  const FORMATS = ['image/png', 'image/jpeg', 'image/webp'];
6
- const checkImage = condition => check(condition, 'Invalid embedded image header or dimensions.', 'VALIDATION_ERROR');
7
- const ascii = (bytes, start, length) => String.fromCharCode(...bytes.subarray(start, start + length));
8
-
9
- function dimensions(bytes, format) {
10
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
11
- if (format === 'image/png') {
12
- checkImage(bytes.length >= 33 && [137, 80, 78, 71, 13, 10, 26, 10].every((value, i) => bytes[i] === value) &&
13
- view.getUint32(8) === 13 && ascii(bytes, 12, 4) === 'IHDR');
14
- return [view.getUint32(16), view.getUint32(20)];
15
- }
16
- if (format === 'image/jpeg') {
17
- checkImage(bytes.length >= 4 && bytes[0] === 255 && bytes[1] === 216);
18
- let offset = 2;
19
- while (offset < bytes.length) {
20
- checkImage(bytes[offset++] === 255);
21
- while (offset < bytes.length && bytes[offset] === 255) offset++;
22
- checkImage(offset < bytes.length);
23
- const marker = bytes[offset++];
24
- if (marker === 217 || marker === 218) break;
25
- if (marker === 1 || (marker >= 208 && marker <= 215)) continue;
26
- checkImage(offset + 2 <= bytes.length);
27
- const length = view.getUint16(offset);
28
- checkImage(length >= 2 && offset + length <= bytes.length);
29
- if ([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207].includes(marker)) {
30
- checkImage(length >= 8 && bytes[offset + 7] > 0 && length >= 8 + 3 * bytes[offset + 7]);
31
- return [view.getUint16(offset + 5), view.getUint16(offset + 3)];
32
- }
33
- offset += length;
34
- }
35
- checkImage(false);
36
- }
37
- checkImage(bytes.length >= 20 && ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 4) === 'WEBP' &&
38
- view.getUint32(4, true) + 8 === bytes.length);
39
- const little24 = offset => bytes[offset] | bytes[offset + 1] << 8 | bytes[offset + 2] << 16;
40
- for (let offset = 12; offset + 8 <= bytes.length;) {
41
- const kind = ascii(bytes, offset, 4);
42
- const length = view.getUint32(offset + 4, true);
43
- const start = offset + 8;
44
- checkImage(start + length <= bytes.length);
45
- if (kind === 'VP8X') {
46
- checkImage(length === 10);
47
- return [little24(start + 4) + 1, little24(start + 7) + 1];
48
- }
49
- if (kind === 'VP8L') {
50
- checkImage(length >= 5 && bytes[start] === 47 && (bytes[start + 4] >> 5) === 0);
51
- return [1 + (bytes[start + 1] | (bytes[start + 2] & 63) << 8),
52
- 1 + (bytes[start + 2] >> 6 | bytes[start + 3] << 2 | (bytes[start + 4] & 15) << 10)];
53
- }
54
- if (kind === 'VP8 ') {
55
- checkImage(length >= 10 && ascii(bytes, start + 3, 3) === '\x9d\x01\x2a');
56
- return [view.getUint16(start + 6, true) & 16383, view.getUint16(start + 8, true) & 16383];
57
- }
58
- offset = start + length + length % 2;
59
- }
60
- checkImage(false);
61
- }
62
-
63
7
  /** Inspect bounded embedded bytes; this is header validation, not full image decoding. */
64
8
  export function validateCloudflareImages(images) {
65
- check(Array.isArray(images) && images.length <= 4, 'Cloudflare accepts at most four images.');
66
- let total = 0;
9
+ check(Array.isArray(images), 'Cloudflare images must be an array.');
67
10
  for (const image of images) {
68
11
  let format, payload;
69
12
  if (typeof image === 'string') {
@@ -80,16 +23,11 @@ export function validateCloudflareImages(images) {
80
23
  payload = image.base64;
81
24
  }
82
25
  validateImage('data:' + format + ';base64,' + payload);
83
- const padding = payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0;
84
- const size = payload.length / 4 * 3 - padding;
85
- check(size <= 4 * 1024 * 1024, 'Cloudflare images must be within 4 MiB each.', 'REQUEST_TOO_LARGE');
86
- total += size;
87
- check(total <= 8 * 1024 * 1024, 'Cloudflare images exceed 8 MiB total.', 'REQUEST_TOO_LARGE');
88
26
  const decoded = atob(payload);
89
27
  const bytes = new Uint8Array(decoded.length);
90
28
  for (let i = 0; i < decoded.length; i++) bytes[i] = decoded.charCodeAt(i);
91
- const [width, height] = dimensions(bytes, format);
92
- check(width > 0 && height > 0 && width * height <= 16000000,
93
- 'Cloudflare images must have positive dimensions within 16 million pixels.');
29
+ const [width, height] = imageDimensions(bytes, format);
30
+ check(width > 0 && height > 0,
31
+ 'Cloudflare images must have positive dimensions.');
94
32
  }
95
33
  }
@@ -1,21 +1,19 @@
1
1
  import { requireCondition as check } from '../errors.mjs';
2
2
  import { embeddedDataUrl, base64ByteLength } from '../embedded.mjs';
3
3
  import { isRecord } from '../validation.mjs';
4
- import { mediaBytes, checkMediaDuration } from './media-binary.mjs';
4
+ import { mediaBytes } from './media-binary.mjs';
5
5
  import { inspectWav, inspectMp3 } from './cloudflare-audio.mjs';
6
6
  import { inspectMp4, inspectWebm } from './cloudflare-video.mjs';
7
7
 
8
8
  const INSPECTORS = { 'audio/wav': inspectWav, 'audio/x-wav': inspectWav, 'audio/mpeg': inspectMp3,
9
9
  'audio/mp3': inspectMp3, 'video/mp4': inspectMp4, 'video/webm': inspectWebm };
10
- const MIB = 1024 * 1024;
11
10
 
12
- /** Counts/decoded sizes are checked before container inspection; no runtime codec dependency. */
11
+ /** Inspect supported containers; admission counts, sizes and duration are server-owned. */
13
12
  export function validateCloudflareMedia(request) {
14
- let total = 0;
15
- for (const [field, kind, count, bytes, seconds] of [['audio', 'audio', 4, 8 * MIB, 300], ['videos', 'video', 2, 16 * MIB, 60]]) {
13
+ for (const [field, kind] of [['audio', 'audio'], ['videos', 'video']]) {
16
14
  if (!Object.hasOwn(request, field)) continue;
17
15
  const items = request[field];
18
- check(Array.isArray(items) && items.length <= count, 'Cloudflare media count exceeds its limit.');
16
+ check(Array.isArray(items), 'Cloudflare media must be an array.');
19
17
  for (const item of items) {
20
18
  let media;
21
19
  if (typeof item === 'string') media = embeddedDataUrl(item, kind);
@@ -27,10 +25,7 @@ export function validateCloudflareMedia(request) {
27
25
  }
28
26
  check(media.format.startsWith(kind + '/') && Object.hasOwn(INSPECTORS, media.format),
29
27
  'Cloudflare audio/video format is unsupported.', 'UNSUPPORTED_CAPABILITY');
30
- check(media.size <= bytes, 'Cloudflare media exceeds its decoded byte limit.', 'REQUEST_TOO_LARGE');
31
- total += media.size;
32
- check(total <= 16 * MIB, 'Cloudflare audio/video exceeds 16 MiB combined.', 'REQUEST_TOO_LARGE');
33
- checkMediaDuration(INSPECTORS[media.format](mediaBytes(media.payload, media.size)), seconds);
28
+ INSPECTORS[media.format](mediaBytes(media.payload, media.size));
34
29
  }
35
30
  }
36
31
  }
@@ -20,7 +20,9 @@ function settingsFor(options) {
20
20
 
21
21
  export function cloudflareBindingBody(request, gateway) {
22
22
  const { options, ...inputs } = request;
23
- return { inputs, options: { returnRawResponse: true, rejectIfBusy: options?.rejectIfBusy ?? false,
23
+ const { rejectIfBusy = false, ...inference } = options ?? {};
24
+ return { inputs: { ...inputs, ...(Object.keys(inference).length ? { options: inference } : {}) },
25
+ options: { returnRawResponse: true, rejectIfBusy,
24
26
  ...(gateway ? { gateway } : {}) } };
25
27
  }
26
28
 
@@ -35,9 +37,8 @@ export function preflightCloudflareDecision(input, options = {}) {
35
37
  check(model === 'clef-omni' || !['audio', 'videos'].some(key => Object.hasOwn(request, key)),
36
38
  'Audio and video require clef-omni.', 'UNSUPPORTED_CAPABILITY');
37
39
  const common = validateRequest({ model, state: request.state, questions: request.questions }, model);
38
- check(Object.keys(common.questions).length <= 64, 'Cloudflare accepts at most 64 questions.');
39
40
  for (const [id, question] of Object.entries(common.questions)) {
40
- check(/^[A-Za-z0-9_.-]{1,100}$/.test(id), 'Cloudflare question IDs must use letters, digits, _, ., or - within 100 characters.');
41
+ check(/^[A-Za-z0-9_.-]+$/.test(id), 'Cloudflare question IDs must use letters, digits, _, ., or -.');
41
42
  check(!Object.hasOwn(question, 'options') && !Object.hasOwn(question, 'levels'),
42
43
  'Cloudflare native questions use criteria; typed choices and levels belong to the common API.');
43
44
  if (question.type === 'choice') check(Object.keys(question.criteria).length >= 2,