@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,37 @@
1
+ import { optionsObject } from '../options.mjs';
2
+ import { requireCondition as check } from '../errors.mjs';
3
+
4
+ export const JEV_REQUEST_LIMIT = 32 * 1024 * 1024;
5
+ export const JEV_RESULT_OPTIONS = Object.freeze({ native: true, nullableUsage: true });
6
+
7
+ /** Experimental features are explicit provider policy, never inferred from input. */
8
+ export function jevDangerZone(value = {}) {
9
+ const options = optionsObject(value, ['images', 'longContext'], 'Jev dangerZone');
10
+ const images = Object.hasOwn(options, 'images') ? options.images : false;
11
+ const longContext = Object.hasOwn(options, 'longContext') ? options.longContext : false;
12
+ check([images, longContext].every(value => value === undefined || typeof value === 'boolean'),
13
+ 'Jev dangerZone flags must be boolean.', 'CONFIGURATION_ERROR');
14
+ return Object.freeze({ images: images ?? false, longContext: longContext ?? false });
15
+ }
16
+
17
+ /** Moving stable aliases stay conservative; pin a supported version for boxes. */
18
+ export function jevFeatures(model, dangerZone = {}) {
19
+ const version = /^jev-(\d+)\.(\d+)\.(\d+)$/.exec(model ?? '');
20
+ const boxes = model === 'jev-preview' || (version !== null &&
21
+ (Number(version[1]) > 1 || (Number(version[1]) === 1 && Number(version[2]) >= 14)));
22
+ const images = Object.hasOwn(dangerZone, 'images') && dangerZone.images === true;
23
+ return { orderedInput: true, images, boxes: images && boxes, maxImages: null };
24
+ }
25
+
26
+ export function jevHeaders(dangerZone) {
27
+ return {
28
+ ...(dangerZone.images ? { 'TypeSafe-Danger-Zone-Images': 'true' } : {}),
29
+ ...(dangerZone.longContext ? { 'TypeSafe-Danger-Zone-Long-Context': 'true' } : {}),
30
+ };
31
+ }
32
+
33
+ export function jevRequestLimit(value = JEV_REQUEST_LIMIT) {
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
+ return value;
37
+ }
@@ -1,9 +1,11 @@
1
1
  import { optionsObject, TRANSPORT_FIELDS } from '../options.mjs';
2
2
  import { withResponseMeta, requireCondition as check } from '../errors.mjs';
3
- import { isRecord, validateRequest, validateResult } from '../validation.mjs';
3
+ import { freezeJson, isRecord, validateResult } from '../validation.mjs';
4
4
  import { JsonTransport, parseBaseURL, validateApiKey } from '../transport.mjs';
5
5
  import { environmentValue } from '../runtime-env.mjs';
6
- import { assertCapabilities, LEGACY_CAPABILITIES } from '../capabilities.mjs';
6
+ import { builtInCapabilities } from '../capabilities.mjs';
7
+ import { jevDangerZone, jevHeaders, jevRequestLimit, JEV_RESULT_OPTIONS } from './jev-profile.mjs';
8
+ import { prepareCommonJevDecision, prepareJevDecision } from './jev-preflight.mjs';
7
9
 
8
10
  /** Native TypeSafe HTTP provider: one shared-state request, no chat emulation. */
9
11
  export class JevProvider {
@@ -11,12 +13,13 @@ export class JevProvider {
11
13
  kind = 'decision';
12
14
  probabilitySource = 'native';
13
15
  #transport;
16
+ #options;
14
17
 
15
18
  constructor(options = {}) {
16
19
  const { apiKey,
17
20
  model = environmentValue('JEV_MODEL')?.trim() || 'jev-latest',
18
- baseURL = 'https://api.typesafe.ai', ...transportOptions } = optionsObject(options,
19
- ['apiKey', 'model', 'baseURL', ...TRANSPORT_FIELDS], 'Jev options');
21
+ baseURL = 'https://api.typesafe.ai', dangerZone, ...transportOptions } = Object.assign(Object.create(null), optionsObject(options,
22
+ ['apiKey', 'model', 'baseURL', 'dangerZone', ...TRANSPORT_FIELDS], 'Jev options'));
20
23
  const endpointInfo = parseBaseURL(baseURL, { originOnly: true });
21
24
  const supplied = apiKey === undefined && endpointInfo.origin === 'https://api.typesafe.ai'
22
25
  ? environmentValue('TYPESAFE_API_KEY') : apiKey;
@@ -24,20 +27,36 @@ export class JevProvider {
24
27
  check(typeof model === 'string' && model.trim(), 'model must be nonempty.', 'CONFIGURATION_ERROR');
25
28
  const endpoint = endpointInfo.baseURL;
26
29
  this.defaultModel = model.trim();
27
- this.#transport = new JsonTransport({ ...transportOptions, baseURL: endpoint,
28
- headers: { authorization: `Bearer ${key}` }, secrets: [key] });
30
+ this.#options = { model: this.defaultModel, dangerZone: jevDangerZone(dangerZone),
31
+ maxRequestBytes: jevRequestLimit(transportOptions.maxRequestBytes) };
32
+ this.#transport = new JsonTransport({ ...transportOptions, maxRequestBytes: this.#options.maxRequestBytes, baseURL: endpoint,
33
+ headers: { authorization: `Bearer ${key}`, ...jevHeaders(this.#options.dangerZone) }, secrets: [key] });
29
34
  }
30
35
 
31
36
  async evaluate(input, options = {}) {
32
- const request = validateRequest(input, this.defaultModel);
33
- assertCapabilities(request, this.getCapabilities(request.model));
34
- const { data, meta } = await this.#transport.request('POST', '/v1/systemone', request, options);
35
- const result = withResponseMeta(meta, () => validateResult(data, request.questions, { native: true }));
36
- return { ...result, meta: { ...meta, provider: this.name, requestedModel: request.model,
37
- probabilitySource: 'native', confidenceSource: 'native', modelSource: 'reported' } };
37
+ const prepared = prepareCommonJevDecision(input, this.#options);
38
+ const { decision, meta } = await this.#perform(prepared, options);
39
+ return freezeJson({ ...decision, meta: { ...meta, provider: this.name,
40
+ probabilitySource: 'native', confidenceSource: 'native', modelSource: 'reported' } });
38
41
  }
39
42
 
40
- getCapabilities() { return LEGACY_CAPABILITIES; }
43
+ getCapabilities(model = this.defaultModel) { return builtInCapabilities('jev', model, this.#options); }
44
+
45
+ getResultValidationOptions() { return JEV_RESULT_OPTIONS; }
46
+
47
+ validateRequest(request) { prepareCommonJevDecision(request, this.#options); }
48
+
49
+ /** Native JSON assets allow images anywhere in state, instructions or criteria. */
50
+ async createDecision(input, options = {}) {
51
+ return this.#perform(prepareJevDecision(input, this.#options), options);
52
+ }
53
+
54
+ async #perform({ request, native }, options) {
55
+ const { data, meta: transport } = await this.#transport.request('POST', '/v1/systemone', native, options);
56
+ const meta = { ...transport, requestedModel: request.model };
57
+ const decision = withResponseMeta(meta, () => validateResult(data, request.questions, JEV_RESULT_OPTIONS));
58
+ return freezeJson({ decision, meta });
59
+ }
41
60
 
42
61
  async listModels(options = {}) {
43
62
  const { data, meta } = await this.#transport.request('GET', '/v1/models', undefined, options);
@@ -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: 128, maxChoiceOptions: 255, maxScoreLevels: null,
11
+ maxImages: null, maxChoiceOptions: null, maxScoreLevels: null,
12
+ conversation: true, toolHistory: true, remoteImages: true, promptCaching: true,
12
13
  }),
13
14
  });
14
15
 
package/src/questions.mjs CHANGED
@@ -13,3 +13,5 @@ export const noul = (instructions, criteria) => build({
13
13
  });
14
14
  export const score = (instructions, criteria) => build({ type: 'score', instructions, criteria });
15
15
  export const scoreLevels = (instructions, levels) => build({ type: 'score', instructions, levels });
16
+ /** One bounding box; use a separate noul to check whether the target is present. */
17
+ export const box = instructions => build({ type: 'box', instructions });
@@ -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
- check(serialized === undefined || UTF8_ENCODER.encode(serialized).byteLength <= maxRequestBytes,
17
- 'Provider request exceeds the byte limit.', 'REQUEST_TOO_LARGE');
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
- function retryDelay(headers, attempt) {
39
- const raw = headers.get('retry-after');
40
- if (raw !== null) {
41
- const seconds = Number(raw);
42
- const milliseconds = raw.trim() !== '' && Number.isFinite(seconds)
43
- ? seconds * 1000 : Date.parse(raw) - Date.now();
44
- if (Number.isFinite(milliseconds) && milliseconds >= 0) return milliseconds;
45
- }
46
- return Math.min(8000, 250 * 2 ** attempt) * (0.5 + Math.random());
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
- const response = await withSignal(dispatch(serialized, budget.signal), budget.signal);
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
- const data = await readJson(response, this.#options.maxResponseBytes, budget);
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 (error) { if (error.code !== 'INVALID_RESPONSE') throw error; }
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
- if (!RETRY_STATUSES.has(response.status) || retry >= this.#options.maxRetries) throw error;
154
- const delay = retryDelay(response.headers, retry);
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;
@@ -1,5 +1,6 @@
1
1
  import { requireCondition as check } from './errors.mjs';
2
- import { isTextWithinLimit, validateOrderedInput } from './input.mjs';
2
+ import { validateOrderedInput } from './input.mjs';
3
+ import { validateCacheOptions, validateCacheBreakpoint, validateDecisionInput } from './providers/decisions-input.mjs';
3
4
  import { validateProbabilitySum, validateResultOptions } from './probabilities.mjs';
4
5
 
5
6
  const own = (value, key) => Object.hasOwn(value, key);
@@ -81,14 +82,25 @@ function allowedKeys(value, expected, label, code = 'VALIDATION_ERROR') {
81
82
  export function validateRequest(input, defaultModel) {
82
83
  const request = cloneJson(input, 'request');
83
84
  check(isRecord(request), 'request must be an object.');
84
- allowedKeys(request, ['state', 'input', 'questions', 'model', 'safetyIdentifier'], 'request');
85
- check(own(request, 'state') !== own(request, 'input'), 'Provide exactly one of state or input.');
85
+ allowedKeys(request, ['state', 'input', 'conversation', 'questions', 'model', 'safetyIdentifier',
86
+ 'promptCacheKey', 'promptCacheOptions'], 'request');
87
+ check(['state', 'input', 'conversation'].filter(key => own(request, key)).length === 1,
88
+ 'Provide exactly one of state, input or conversation.');
86
89
  if (own(request, 'state')) check(request.state !== null && isEntry(request.state),
87
90
  'state must be text, a JSON object, or an array.');
88
- else validateOrderedInput(request.input);
91
+ else if (own(request, 'conversation')) {
92
+ check(Array.isArray(request.conversation), 'conversation must be an ordered item array.');
93
+ validateDecisionInput(request.conversation);
94
+ } else {
95
+ validateOrderedInput(request.input);
96
+ for (const part of request.input) validateCacheBreakpoint(part.promptCacheBreakpoint);
97
+ }
98
+ if (own(request, 'promptCacheKey')) check(request.promptCacheKey === null || typeof request.promptCacheKey === 'string',
99
+ 'promptCacheKey must be text or null.');
100
+ validateCacheOptions(request.promptCacheOptions);
89
101
  if (own(request, 'safetyIdentifier')) check(request.safetyIdentifier === null ||
90
- isTextWithinLimit(request.safetyIdentifier, 128),
91
- 'safetyIdentifier must be a string up to 128 characters or null.');
102
+ typeof request.safetyIdentifier === 'string',
103
+ 'safetyIdentifier must be a string or null.');
92
104
  if (own(request, 'model')) check(nonempty(request.model), 'model must be a nonempty string when provided.');
93
105
  const model = own(request, 'model') ? request.model : defaultModel;
94
106
  check(nonempty(model), 'model must be a nonempty string.');
@@ -97,9 +109,9 @@ export function validateRequest(input, defaultModel) {
97
109
  for (const [id, q] of Object.entries(request.questions)) {
98
110
  check(nonempty(id), 'Question IDs must be nonempty.');
99
111
  check(isRecord(q), 'Every question must be an object.');
100
- allowedKeys(q, q.type === 'choice' ? ['type', 'instructions', 'criteria', 'options'] :
112
+ allowedKeys(q, q.type === 'box' ? ['type', 'instructions'] : q.type === 'choice' ? ['type', 'instructions', 'criteria', 'options'] :
101
113
  q.type === 'score' ? ['type', 'instructions', 'criteria', 'levels'] : ['type', 'instructions', 'criteria'], 'question');
102
- check(['choice', 'noul', 'score'].includes(q.type), 'Question type must be choice, noul, or score.');
114
+ check(['choice', 'noul', 'score', 'box'].includes(q.type), 'Question type must be choice, noul, score, or box.');
103
115
  check(own(q, 'instructions') && q.instructions !== null && isEntry(q.instructions),
104
116
  'instructions are required and must be text, a JSON object, or an array.');
105
117
  if (typeof q.instructions === 'string') check(nonempty(q.instructions), 'instructions must not be empty.');
@@ -125,14 +137,14 @@ export function validateRequest(input, defaultModel) {
125
137
  if (own(level, 'description')) check(level.description !== null && isEntry(level.description), 'Invalid level description.');
126
138
  }
127
139
  } else if (q.type === 'choice') {
128
- check(isRecord(q.criteria) && Object.keys(q.criteria).length > 0 && Object.keys(q.criteria).length <= 255,
129
- 'choice requires a criteria map with 1 to 255 options.');
140
+ check(isRecord(q.criteria) && Object.keys(q.criteria).length > 0,
141
+ 'choice requires a nonempty criteria map.');
130
142
  for (const [label, description] of Object.entries(q.criteria)) {
131
143
  check(nonempty(label) && isEntry(description), 'choice criteria require labels and text, structured JSON, or null descriptions.');
132
144
  }
133
145
  } else if (q.type === 'score') {
134
- check(Array.isArray(q.criteria) && q.criteria.length >= 2 && q.criteria.length <= 10,
135
- 'score requires 2 to 10 ordered criteria.');
146
+ check(Array.isArray(q.criteria) && q.criteria.length >= 2,
147
+ 'score requires at least two ordered criteria.');
136
148
  check(q.criteria.every(value => value !== null && isEntry(value)),
137
149
  'score descriptions must be text or structured JSON, not null.');
138
150
  } else if (own(q, 'criteria')) {
@@ -158,7 +170,7 @@ function distribution(value, labels, decimalPlaces) {
158
170
 
159
171
  /** Validate, but do not invent, round, or normalize model results. */
160
172
  export function validateResult(input, questions, options = {}) {
161
- const { native, probabilityDecimalPlaces } = validateResultOptions(options);
173
+ const { native, nullableUsage, probabilityDecimalPlaces } = validateResultOptions(options);
162
174
  const result = cloneJson(input, 'response', 'INVALID_RESPONSE');
163
175
  // Snapshot both sides so rubric comparison uses JSON values without executing accessors.
164
176
  const questionSnapshot = cloneJson(questions, 'questions');
@@ -168,23 +180,30 @@ export function validateResult(input, questions, options = {}) {
168
180
  check(nonempty(result.model), 'Response must include model.', 'INVALID_RESPONSE');
169
181
  exactKeys(result.answers, Object.keys(questionSnapshot), 'answers', 'INVALID_RESPONSE');
170
182
  for (const [id, q] of Object.entries(questionSnapshot)) {
171
- check(isRecord(q) && ['noul', 'choice', 'score'].includes(q.type), 'A valid question type is required.');
183
+ check(isRecord(q) && ['noul', 'choice', 'score', 'box'].includes(q.type), 'A valid question type is required.');
172
184
  if (q.type === 'choice' && own(q, 'options')) check(Array.isArray(q.options) && q.options.length > 0 &&
173
185
  q.options.every(option => isRecord(option)), 'Typed choice requires nonempty option objects.');
174
186
  if (q.type === 'score' && own(q, 'levels')) check(Array.isArray(q.levels) && q.levels.length >= 2 &&
175
187
  q.levels.every(level => isRecord(level) && typeof level.label === 'string'), 'Labelled score requires valid levels.');
176
- if (q.type === 'choice' && !own(q, 'options')) check(isRecord(q.criteria) && Object.keys(q.criteria).length > 0 &&
177
- Object.keys(q.criteria).length <= 255, 'choice requires 1 to 255 criteria.');
178
- if (q.type === 'score' && !own(q, 'levels')) check(Array.isArray(q.criteria) && q.criteria.length >= 2 && q.criteria.length <= 10, 'score requires 2–10 levels.');
188
+ if (q.type === 'choice' && !own(q, 'options')) check(isRecord(q.criteria) && Object.keys(q.criteria).length > 0,
189
+ 'choice requires nonempty criteria.');
190
+ if (q.type === 'score' && !own(q, 'levels')) check(Array.isArray(q.criteria) && q.criteria.length >= 2, 'score requires at least two levels.');
179
191
  const answer = result.answers[id];
180
192
  check(isRecord(answer) && answer.type === q.type, 'Answer type must match its question.', 'INVALID_RESPONSE');
181
- allowedKeys(answer, q.type === 'noul' ? ['type', 'noul'] : q.type === 'choice'
193
+ allowedKeys(answer, q.type === 'box' ? ['type', 'box', 'confidence'] : q.type === 'noul' ? ['type', 'noul'] : q.type === 'choice'
182
194
  ? ['type', 'choice', 'probabilities', 'confidence'] : ['type', 'score', 'legend', 'probabilities', 'confidence'], 'answer', 'INVALID_RESPONSE');
183
195
  if (q.type === 'noul') {
184
196
  probability(answer.noul, 'noul');
185
197
  continue;
186
198
  }
187
199
  if (native || own(answer, 'confidence')) probability(answer.confidence, 'confidence');
200
+ if (q.type === 'box') {
201
+ exactKeys(answer.box, ['top', 'bottom', 'left', 'right'], 'box', 'INVALID_RESPONSE');
202
+ Object.values(answer.box).forEach(value => probability(value, 'box edge'));
203
+ check(answer.box.left < answer.box.right && answer.box.top < answer.box.bottom,
204
+ 'Box edges must enclose a positive area.', 'INVALID_RESPONSE');
205
+ continue;
206
+ }
188
207
  if (q.type === 'choice' && q.options) {
189
208
  validateTypedChoice(answer, q.options, probabilityDecimalPlaces);
190
209
  continue;
@@ -195,7 +214,7 @@ export function validateResult(input, questions, options = {}) {
195
214
  if (q.type === 'choice') {
196
215
  check(typeof answer.choice === 'string' && labels.includes(answer.choice), 'choice must be a supplied option.', 'INVALID_RESPONSE');
197
216
  const selected = answer.probabilities[answer.choice];
198
- check(selected + 1e-6 >= Math.max(...Object.values(answer.probabilities)),
217
+ check(selected + 1e-6 >= Object.values(answer.probabilities).reduce((max, value) => Math.max(max, value), 0),
199
218
  'choice must select a highest-probability option.', 'INVALID_RESPONSE');
200
219
  } else {
201
220
  exactKeys(answer.legend, labels, 'legend', 'INVALID_RESPONSE');
@@ -215,7 +234,7 @@ export function validateResult(input, questions, options = {}) {
215
234
  if (own(result.usage, field)) check(Number.isSafeInteger(result.usage[field]) && result.usage[field] >= 0, 'Invalid cache token count.', 'INVALID_RESPONSE');
216
235
  }
217
236
  for (const field of ['input_tokens', 'output_tokens']) {
218
- check((!native && result.usage[field] === null) ||
237
+ check(((!native || nullableUsage) && result.usage[field] === null) ||
219
238
  (Number.isSafeInteger(result.usage[field]) && result.usage[field] >= 0),
220
239
  'Token counts must be nonnegative safe integers.', 'INVALID_RESPONSE');
221
240
  }
package/src/version.mjs CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Keep in sync with package.json; scripts/check.mjs verifies the release version. */
2
- export const VERSION = '0.1.1';
2
+ export const VERSION = '0.1.3';