@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
@@ -24,7 +24,7 @@ export function cloudflareModel(value = 'clef') {
24
24
 
25
25
  export function cloudflareRequestLimit(model, ceiling) {
26
26
  const profile = CLOUDFLARE_MODELS[cloudflareModel(model)];
27
- return ceiling === undefined ? profile.requestBytes : Math.min(profile.maxRequestBytes, ceiling);
27
+ return ceiling === undefined ? profile.requestBytes : ceiling;
28
28
  }
29
29
 
30
30
  export function validateCloudflareLimits({ maxRequestBytes, maxRetries = 0,
@@ -40,10 +40,7 @@ export function validateCloudflareLimits({ maxRequestBytes, maxRetries = 0,
40
40
  return { ...(maxRequestBytes === undefined ? {} : { maxRequestBytes }), maxRetries, rejectIfBusy };
41
41
  }
42
42
 
43
- // Cloudflare's one-month TTL is 30 days in its official Gateway reference:
44
- // https://github.com/cloudflare/skills/blob/0cb6116228ad153126f474dce1dfbd05f7944604/skills/cloudflare/references/ai-gateway/README.md
45
- const GATEWAY_MAX_CACHE_TTL = 30 * 24 * 60 * 60;
46
- const headerString = (value, maxLength = 200) => typeof value === 'string' && value.trim().length > 0 &&
43
+ const headerString = (value, maxLength = Infinity) => typeof value === 'string' && value.trim().length > 0 &&
47
44
  value.length <= maxLength && !/[^\x20-\x7e]/.test(value);
48
45
 
49
46
  /** Use the documented Workers Gateway shape for both transports. */
@@ -52,14 +49,14 @@ export function cloudflareGateway(input) {
52
49
  const gateway = cloneJson(input, 'Cloudflare gateway', 'CONFIGURATION_ERROR');
53
50
  optionsObject(gateway, ['id', 'cacheKey', 'cacheTtl', 'skipCache', 'metadata', 'collectLog',
54
51
  'requestTimeoutMs', 'retries'], 'Cloudflare gateway');
55
- check(headerString(gateway.id, 64), 'Gateway id must be a printable ASCII string up to 64 characters.', 'CONFIGURATION_ERROR');
52
+ check(headerString(gateway.id), 'Gateway id must be a printable ASCII string without control characters.', 'CONFIGURATION_ERROR');
56
53
  if (gateway.cacheKey !== undefined) check(headerString(gateway.cacheKey),
57
- 'Gateway cacheKey must be a printable ASCII string up to 200 characters.', 'CONFIGURATION_ERROR');
54
+ 'Gateway cacheKey must be a printable ASCII string without control characters.', 'CONFIGURATION_ERROR');
58
55
  for (const key of ['skipCache', 'collectLog']) check(gateway[key] === undefined || typeof gateway[key] === 'boolean',
59
56
  'Gateway cache and logging flags must be boolean.', 'CONFIGURATION_ERROR');
60
57
  check(gateway.cacheTtl === undefined || (Number.isSafeInteger(gateway.cacheTtl) &&
61
- gateway.cacheTtl >= 60 && gateway.cacheTtl <= GATEWAY_MAX_CACHE_TTL),
62
- 'Gateway cacheTtl must be integer seconds from 60 to 2592000 (30 days).', 'CONFIGURATION_ERROR');
58
+ gateway.cacheTtl >= 0),
59
+ 'Gateway cacheTtl must be nonnegative integer seconds.', 'CONFIGURATION_ERROR');
63
60
  check(gateway.requestTimeoutMs === undefined || (Number.isSafeInteger(gateway.requestTimeoutMs) &&
64
61
  gateway.requestTimeoutMs > 0 && gateway.requestTimeoutMs <= 2147483647),
65
62
  'Gateway requestTimeoutMs must be a positive 32-bit integer.', 'CONFIGURATION_ERROR');
@@ -69,18 +66,17 @@ export function cloudflareGateway(input) {
69
66
  if (gateway.metadata !== undefined) {
70
67
  // Reject predictable metadata loss instead of truncating or stripping caller data.
71
68
  const keys = Object.keys(gateway.metadata);
72
- check(keys.length <= 5, 'Gateway metadata accepts at most five entries.', 'CONFIGURATION_ERROR');
73
69
  check(keys.every(key => !key.startsWith('cf.')),
74
70
  'Gateway metadata keys beginning with cf. are reserved.', 'CONFIGURATION_ERROR');
75
71
  }
76
72
  const retries = gateway.retries === undefined ? {} : gateway.retries;
77
73
  optionsObject(retries, ['maxAttempts', 'retryDelayMs', 'backoff'], 'Gateway retries');
78
74
  const maxAttempts = retries.maxAttempts === undefined ? 1 : retries.maxAttempts;
79
- check(Number.isSafeInteger(maxAttempts) && maxAttempts >= 1 && maxAttempts <= 5,
80
- 'Gateway maxAttempts must be from 1 to 5.', 'CONFIGURATION_ERROR');
75
+ check(Number.isSafeInteger(maxAttempts) && maxAttempts >= 1,
76
+ 'Gateway maxAttempts must be a positive integer.', 'CONFIGURATION_ERROR');
81
77
  check(retries.retryDelayMs === undefined || (Number.isSafeInteger(retries.retryDelayMs) &&
82
- retries.retryDelayMs >= 0 && retries.retryDelayMs <= 60000),
83
- 'Gateway retryDelayMs must be from 0 to 60000.', 'CONFIGURATION_ERROR');
78
+ retries.retryDelayMs >= 0),
79
+ 'Gateway retryDelayMs must be a nonnegative integer.', 'CONFIGURATION_ERROR');
84
80
  check(retries.backoff === undefined || ['constant', 'linear', 'exponential'].includes(retries.backoff),
85
81
  'Invalid Gateway backoff.', 'CONFIGURATION_ERROR');
86
82
  return freezeJson({ ...gateway, retries: { ...retries, maxAttempts } });
@@ -12,22 +12,27 @@ const responseMetadata = response => ({
12
12
  });
13
13
 
14
14
  /** The two dispatch mechanisms share bounded reads, cancellation, retries and errors. */
15
- export class CloudflareDecisionTransport {
15
+ export class CloudflareTransport {
16
16
  #transports = new Map();
17
17
  #ceiling;
18
18
  #kind;
19
19
  #accountId;
20
20
  #ai;
21
21
  #gateway;
22
+ #requestLimit;
23
+ #bindingBody;
22
24
 
23
- constructor(options) {
25
+ constructor(options, { requestLimit = cloudflareRequestLimit, bindingBody = cloudflareBindingBody,
26
+ models = ['clef', 'clef-omni'] } = {}) {
24
27
  const { transport = 'rest', accountId, apiKey, baseURL = 'https://api.cloudflare.com',
25
28
  ai, gateway, fetchImpl, model: _model, rejectIfBusy: _busy, ...limits } = options;
26
29
  this.#kind = transport;
27
30
  this.#gateway = gateway;
28
31
  this.#ceiling = limits.maxRequestBytes;
32
+ this.#requestLimit = model => requestLimit(model, this.#ceiling);
33
+ this.#bindingBody = bindingBody;
29
34
  const shared = { ...limits, errorDecoder: cloudflareError, responseMetadata };
30
- const byteLimits = new Set(['clef', 'clef-omni'].map(model => cloudflareRequestLimit(model, this.#ceiling)));
35
+ const byteLimits = new Set(models.map(model => this.#requestLimit(model)));
31
36
  if (transport === 'binding') {
32
37
  check(ai !== null && typeof ai === 'object' && typeof ai.run === 'function',
33
38
  'Binding transport requires an ai binding with run().', 'CONFIGURATION_ERROR');
@@ -52,12 +57,14 @@ export class CloudflareDecisionTransport {
52
57
  }
53
58
 
54
59
  request(request, options) {
55
- const transport = this.#transports.get(cloudflareRequestLimit(request.model, this.#ceiling));
60
+ const transport = this.#transports.get(this.#requestLimit(request.model));
56
61
  if (this.#kind === 'rest') return transport.request('POST',
57
62
  '/client/v4/accounts/' + encodeURIComponent(this.#accountId) + '/ai/run/@cf/cloudflare/' + request.model,
58
63
  request, options);
59
- const body = cloudflareBindingBody(request, this.#gateway);
64
+ const body = this.#bindingBody(request, this.#gateway);
60
65
  return transport.request(body, (_serialized, signal) => this.#ai.run(
61
66
  '@cf/cloudflare/' + request.model, body.inputs, { ...body.options, signal }), options);
62
67
  }
63
68
  }
69
+
70
+ export class CloudflareDecisionTransport extends CloudflareTransport {}
@@ -23,7 +23,9 @@ export function cloudflareError(data, status, meta) {
23
23
  const upstreamCode = Number.isSafeInteger(value) && value >= 0 ? value : undefined;
24
24
  const [code, message] = CODES.get(upstreamCode) ?? STATUSES.get(status) ??
25
25
  ['HTTP_ERROR', 'Cloudflare rejected the inference request.'];
26
- return new DecisionError(message, { code, status, ...meta,
26
+ return new DecisionError(message, { code, status, ...meta, source: 'provider',
27
+ retryable: !['ALLOCATION_EXCEEDED', 'AUTHENTICATION_ERROR', 'PERMISSION_DENIED', 'REQUEST_TOO_LARGE'].includes(code) &&
28
+ (code === 'CAPACITY_EXCEEDED' || code === 'RATE_LIMITED' || [502, 503, 504, 529].includes(status)),
27
29
  ...(upstreamCode === undefined ? {} : { upstreamCode }) });
28
30
  }
29
31
 
@@ -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 input = own(request, 'state') ? encodeText(request.state) : [{ role: 'user', content: request.input.map(part =>
13
- part.type === 'text' ? { type: 'input_text', text: part.text } : {
14
- type: 'input_image', image_url: part.dataUrl, ...(own(part, 'detail') ? { detail: part.detail } : {}),
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
- const own = (value, key) => Object.hasOwn(value, key);
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'], ['input', 'questions'], 'decision request');
20
- const model = request.model ?? defaultModel;
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
- if (own(request, 'model')) text(request.model, 'model');
25
- if (own(request, 'safety_identifier') && request.safety_identifier !== null) text(request.safety_identifier, 'safety_identifier', 128);
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);
@@ -0,0 +1,59 @@
1
+ // Shared bounded image-header inspection; full decoding remains the service boundary.
2
+ import { requireCondition as check } from '../errors.mjs';
3
+
4
+ const checkImage = condition => check(condition, 'Invalid embedded image header or dimensions.', 'VALIDATION_ERROR');
5
+ const ascii = (bytes, start, length) => String.fromCharCode(...bytes.subarray(start, start + length));
6
+
7
+ export function imageDimensions(bytes, format) {
8
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
9
+ if (format === 'image/png') {
10
+ checkImage(bytes.length >= 33 && [137, 80, 78, 71, 13, 10, 26, 10].every((value, i) => bytes[i] === value) &&
11
+ view.getUint32(8) === 13 && ascii(bytes, 12, 4) === 'IHDR');
12
+ return [view.getUint32(16), view.getUint32(20)];
13
+ }
14
+ if (format === 'image/jpeg') {
15
+ checkImage(bytes.length >= 4 && bytes[0] === 255 && bytes[1] === 216);
16
+ let offset = 2;
17
+ while (offset < bytes.length) {
18
+ checkImage(bytes[offset++] === 255);
19
+ while (offset < bytes.length && bytes[offset] === 255) offset++;
20
+ checkImage(offset < bytes.length);
21
+ const marker = bytes[offset++];
22
+ if (marker === 217 || marker === 218) break;
23
+ if (marker === 1 || (marker >= 208 && marker <= 215)) continue;
24
+ checkImage(offset + 2 <= bytes.length);
25
+ const length = view.getUint16(offset);
26
+ checkImage(length >= 2 && offset + length <= bytes.length);
27
+ if ([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207].includes(marker)) {
28
+ checkImage(length >= 8 && bytes[offset + 7] > 0 && length >= 8 + 3 * bytes[offset + 7]);
29
+ return [view.getUint16(offset + 5), view.getUint16(offset + 3)];
30
+ }
31
+ offset += length;
32
+ }
33
+ checkImage(false);
34
+ }
35
+ checkImage(bytes.length >= 20 && ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 4) === 'WEBP' &&
36
+ view.getUint32(4, true) + 8 === bytes.length);
37
+ const little24 = offset => bytes[offset] | bytes[offset + 1] << 8 | bytes[offset + 2] << 16;
38
+ for (let offset = 12; offset + 8 <= bytes.length;) {
39
+ const kind = ascii(bytes, offset, 4);
40
+ const length = view.getUint32(offset + 4, true);
41
+ const start = offset + 8;
42
+ checkImage(start + length <= bytes.length);
43
+ if (kind === 'VP8X') {
44
+ checkImage(length === 10);
45
+ return [little24(start + 4) + 1, little24(start + 7) + 1];
46
+ }
47
+ if (kind === 'VP8L') {
48
+ checkImage(length >= 5 && bytes[start] === 47 && (bytes[start + 4] >> 5) === 0);
49
+ return [1 + (bytes[start + 1] | (bytes[start + 2] & 63) << 8),
50
+ 1 + (bytes[start + 2] >> 6 | bytes[start + 3] << 2 | (bytes[start + 4] & 15) << 10)];
51
+ }
52
+ if (kind === 'VP8 ') {
53
+ checkImage(length >= 10 && ascii(bytes, start + 3, 3) === '\x9d\x01\x2a');
54
+ return [view.getUint16(start + 6, true) & 16383, view.getUint16(start + 8, true) & 16383];
55
+ }
56
+ offset = start + length + length % 2;
57
+ }
58
+ checkImage(false);
59
+ }
@@ -0,0 +1,84 @@
1
+ // Jev JSON assets use reference substitution in string values, never object keys.
2
+ import { requireCondition as check } from '../errors.mjs';
3
+ import { base64ByteLength } from '../embedded.mjs';
4
+ import { isRecord } from '../validation.mjs';
5
+ import { imageDimensions } from './image-header.mjs';
6
+
7
+ const encoder = new TextEncoder();
8
+
9
+ function inspectImage(asset) {
10
+ check(isRecord(asset) && Object.keys(asset).every(key => ['type', 'format', 'data_base64'].includes(key)) &&
11
+ (asset.type === undefined || asset.type === 'image') && ['png', 'jpg', 'jpeg'].includes(asset.format),
12
+ 'Jev JSON image assets require PNG/JPEG format and data_base64.');
13
+ const size = base64ByteLength(asset.data_base64);
14
+ const decoded = atob(asset.data_base64);
15
+ // A string iterator makes Uint8Array.from allocate an intermediate element
16
+ // list. Copy directly into one bounded byte buffer for multi-MiB images.
17
+ const bytes = new Uint8Array(decoded.length);
18
+ for (let i = 0; i < decoded.length; i++) bytes[i] = decoded.charCodeAt(i);
19
+ const [width, height] = imageDimensions(bytes, asset.format === 'png' ? 'image/png' : 'image/jpeg');
20
+ check(width > 0 && height > 0, 'Images require positive dimensions.');
21
+ if (asset.format === 'png') {
22
+ const view = new DataView(bytes.buffer);
23
+ for (let offset = 8; offset < bytes.length;) {
24
+ check(offset + 12 <= bytes.length, 'Invalid PNG chunk.');
25
+ const length = view.getUint32(offset);
26
+ check(offset + length + 12 <= bytes.length, 'Invalid PNG chunk length.');
27
+ check(String.fromCharCode(...bytes.subarray(offset + 4, offset + 8)) !== 'acTL',
28
+ 'Jev does not accept animated PNG images.');
29
+ offset += length + 12;
30
+ }
31
+ }
32
+ return size;
33
+ }
34
+
35
+ /** Apply TypeSafe's JSON nesting boundary and collect only replaceable values. */
36
+ function stringValues(value, output, depth = 0) {
37
+ if (typeof value === 'string') output.push(value);
38
+ else if (value !== null && typeof value === 'object') {
39
+ for (const child of Object.values(value)) stringValues(child, output, depth + 1);
40
+ }
41
+ }
42
+
43
+ /** Whole values win first; other matches are consumed left to right. Alternatives
44
+ * at the same position retain asset-map order, as on the native TypeSafe API.
45
+ */
46
+ function usedReferences(strings, references) {
47
+ const known = new Set(references);
48
+ const used = new Set();
49
+ if (!references.length) return used;
50
+ const pattern = new RegExp(references.map(reference => reference.replace(/[.*+?^$(){}|[\]\\]/g, '\\$&')).join('|'), 'g');
51
+ for (const value of strings) {
52
+ if (known.has(value)) used.add(value);
53
+ else for (const match of value.matchAll(pattern)) used.add(match[0]);
54
+ }
55
+ return used;
56
+ }
57
+
58
+ export function validateJevAssets(request) {
59
+ const strings = [];
60
+ stringValues(request.state, strings);
61
+ for (const question of Object.values(request.questions)) {
62
+ stringValues(question.instructions, strings);
63
+ if (question.criteria !== undefined) stringValues(question.criteria, strings);
64
+ }
65
+ const assets = request.assets === undefined ? {} : request.assets;
66
+ check(isRecord(assets), 'Jev assets must be an object.');
67
+ const references = Object.keys(assets);
68
+ let referenceBytes = 0;
69
+ // Bound the literal pattern before compiling it or decoding any images.
70
+ for (const reference of references) {
71
+ const size = encoder.encode(reference).length;
72
+ check(reference.trim() && size <= 1024, 'Jev asset references must be nonempty and within 1024 UTF-8 bytes.');
73
+ referenceBytes += size;
74
+ check(referenceBytes <= 65536, 'Jev asset references exceed 65536 UTF-8 bytes.', 'REQUEST_TOO_LARGE');
75
+ }
76
+ const used = usedReferences(strings, references);
77
+ for (const reference of references) {
78
+ check(used.has(reference), 'Every Jev asset must be used by reference substitution in a string value.');
79
+ inspectImage(assets[reference]);
80
+ }
81
+ // Server re-encoding, expanded references, full image decoding and token limits
82
+ // remain authoritative; original file sizes cannot predict those exactly.
83
+ return references.length;
84
+ }
@@ -0,0 +1,62 @@
1
+ // Shared pure preflight keeps offline validation and inference on the same path.
2
+ import { requireCondition as check } from '../errors.mjs';
3
+ import { cloneJson, freezeJson, isRecord, validateRequest } from '../validation.mjs';
4
+ import { assertCapabilities, builtInCapabilities } from '../capabilities.mjs';
5
+ import { embeddedDataUrl } from '../embedded.mjs';
6
+ import { serializeRequestBody } from '../transport.mjs';
7
+ import { jevDangerZone, jevRequestLimit } from './jev-profile.mjs';
8
+ import { validateJevAssets } from './jev-images.mjs';
9
+
10
+ /** Convert ordered parts to an ordered state array, without altering question text. */
11
+ export function encodeJevDecision(request) {
12
+ if (!Object.hasOwn(request, 'input')) return request;
13
+ const text = JSON.stringify(request);
14
+ // Look ahead without consuming a token: two user tokens can share their
15
+ // boundary underscores, and both must be reserved before choosing a key.
16
+ const references = new Set(Array.from(text.matchAll(/(?=(__decision_bridge_image_\d+__))/g), match => match[1]));
17
+ const assets = {};
18
+ const state = request.input.map((part, index) => {
19
+ if (part.type === 'text') return part.text;
20
+ const image = embeddedDataUrl(part.dataUrl, 'image');
21
+ check(['image/png', 'image/jpeg'].includes(image.format), 'Jev accepts only embedded PNG or JPEG images.');
22
+ check(part.detail === undefined || part.detail === null || part.detail === 'auto',
23
+ 'Jev does not support image detail controls.', 'UNSUPPORTED_CAPABILITY');
24
+ let suffix = index;
25
+ let reference = `__decision_bridge_image_${suffix}__`;
26
+ while (references.has(reference)) reference = `__decision_bridge_image_${++suffix}__`;
27
+ references.add(reference);
28
+ assets[reference] = { type: 'image', format: image.format === 'image/png' ? 'png' : 'jpg', data_base64: image.payload };
29
+ return reference;
30
+ });
31
+ return { model: request.model, state, questions: request.questions,
32
+ ...(Object.keys(assets).length ? { assets } : {}) };
33
+ }
34
+
35
+ export function prepareJevDecision(input, options = {}) {
36
+ const snapshot = cloneJson(input, 'Jev request');
37
+ check(isRecord(snapshot) && Object.keys(snapshot).every(key => ['model', 'state', 'questions', 'assets'].includes(key)),
38
+ 'Jev native requests require state, questions, optional model and assets.');
39
+ const { assets, ...common } = snapshot;
40
+ const request = validateRequest(common, options.model ?? 'jev-latest');
41
+ const dangerZone = jevDangerZone(options.dangerZone);
42
+ assertCapabilities(request, builtInCapabilities('jev', request.model, { dangerZone }));
43
+ const native = { ...request, ...(assets === undefined ? {} : { assets }) };
44
+ serializeRequestBody(native, jevRequestLimit(options.maxRequestBytes));
45
+ const imageCount = validateJevAssets(native);
46
+ if (imageCount) check(dangerZone.images, 'Enable Jev dangerZone.images to send images.', 'UNSUPPORTED_CAPABILITY');
47
+ if (Object.values(request.questions).some(question => question.type === 'box')) {
48
+ check(imageCount > 0, 'Jev box questions require at least one image asset.');
49
+ }
50
+ serializeRequestBody(native, jevRequestLimit(options.maxRequestBytes));
51
+ return { request: freezeJson(request), native: freezeJson(native) };
52
+ }
53
+
54
+ export function prepareCommonJevDecision(input, options = {}) {
55
+ const request = validateRequest(input, options.model ?? 'jev-latest');
56
+ assertCapabilities(request, builtInCapabilities('jev', request.model, { dangerZone: jevDangerZone(options.dangerZone) }));
57
+ const { native } = prepareJevDecision(encodeJevDecision(request), options);
58
+ return { request: freezeJson(request), native };
59
+ }
60
+
61
+ export const preflightJevDecision = (input, options) => prepareJevDecision(input, options).native;
62
+ export const preflightCommonJevDecision = (input, options) => prepareCommonJevDecision(input, options).request;