@fayegram-ai/decision-bridge 0.1.0 → 0.1.2

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 (49) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +20 -14
  3. package/docs/README.md +5 -4
  4. package/docs/development/ARCHITECTURE.md +26 -3
  5. package/docs/development/DESIGN.md +11 -0
  6. package/docs/development/TESTING.md +23 -1
  7. package/docs/guides/BROWSER.md +3 -0
  8. package/docs/guides/CLOUDFLARE_DECISIONS.md +2 -2
  9. package/docs/guides/CUSTOM_PROVIDERS.md +4 -2
  10. package/docs/guides/GETTING_STARTED.md +1 -1
  11. package/docs/guides/JEV.md +174 -0
  12. package/docs/guides/OPENAI_DECISIONS.md +46 -4
  13. package/docs/guides/PLATFORMS.md +2 -2
  14. package/docs/guides/SWITCHING_MODELS.md +10 -4
  15. package/docs/guides/TROUBLESHOOTING.md +14 -0
  16. package/docs/reference/CLI.md +26 -4
  17. package/docs/reference/CONFIGURATION.md +12 -5
  18. package/docs/reference/ERRORS.md +5 -2
  19. package/docs/reference/PROVIDERS.md +20 -7
  20. package/docs/reference/SDK.md +22 -8
  21. package/docs/reference/SOURCES.md +4 -0
  22. package/examples/configs/openai-decisions.json +1 -1
  23. package/examples/decisions.native.json +1 -1
  24. package/package.json +1 -1
  25. package/schemas/decision.config.schema.json +15 -1
  26. package/src/capabilities.mjs +11 -8
  27. package/src/cli/config-validation.mjs +9 -1
  28. package/src/cli/config.mjs +17 -3
  29. package/src/cli/decisions.mjs +10 -0
  30. package/src/cli/initialize.mjs +8 -2
  31. package/src/cli/main.mjs +16 -4
  32. package/src/decisions.d.mts +5 -0
  33. package/src/index.d.mts +30 -8
  34. package/src/index.mjs +1 -1
  35. package/src/jev.d.mts +16 -0
  36. package/src/probabilities.mjs +6 -3
  37. package/src/providers/cloudflare-images.mjs +2 -58
  38. package/src/providers/decisions-preflight.mjs +18 -7
  39. package/src/providers/decisions-validation.mjs +3 -2
  40. package/src/providers/image-header.mjs +59 -0
  41. package/src/providers/jev-images.mjs +90 -0
  42. package/src/providers/jev-preflight.mjs +61 -0
  43. package/src/providers/jev-profile.mjs +37 -0
  44. package/src/providers/jev.mjs +32 -13
  45. package/src/providers/openai-decisions.mjs +17 -10
  46. package/src/providers/openai-profile.mjs +24 -0
  47. package/src/questions.mjs +2 -0
  48. package/src/validation.mjs +13 -6
  49. package/src/version.mjs +1 -1
package/src/cli/main.mjs CHANGED
@@ -15,7 +15,8 @@ import { assertDistinctFiles, createWriter, readJson, readJsonLines } from './io
15
15
  import { mapOrdered } from './ordered.mjs';
16
16
 
17
17
  const SAMPLE_PATH = fileURLToPath(new URL('../../examples/request.json', import.meta.url));
18
- const BOOLS = ['help', 'version', 'pretty', 'compact', 'no-auth', 'force', 'native', 'reject-if-busy', 'no-reject-if-busy'];
18
+ const BOOLS = ['help', 'version', 'pretty', 'compact', 'no-auth', 'force', 'native', 'reject-if-busy', 'no-reject-if-busy',
19
+ 'jev-images', 'no-jev-images', 'jev-long-context', 'no-jev-long-context'];
19
20
  const STRINGS = ['config', 'env-file', 'provider', 'preset', 'model', 'base-url', 'api-key-env', 'timeout-ms',
20
21
  'retries', 'max-tokens', 'response-format', 'token-parameter', 'input', 'output', 'concurrency', 'provider-module', 'account-id', 'gateway-id'];
21
22
  const FIELD_MAP = { preset: 'preset', provider: 'provider', model: 'model', 'base-url': 'baseURL', 'api-key-env': 'apiKeyEnv',
@@ -50,6 +51,10 @@ Options:
50
51
  --timeout-ms N End-to-end evaluation/provider deadline
51
52
  --retries N HTTP retries, 0–10; Decisions default 0, others 2
52
53
  --native Selected decision provider native format (init/evaluate/validate/batch)
54
+ --jev-images Enable experimental Jev images and box questions
55
+ --no-jev-images Explicitly disable Jev images
56
+ --jev-long-context Enable experimental Jev 262k context
57
+ --no-jev-long-context Explicitly disable Jev long context
53
58
  --account-id ID Cloudflare account (otherwise CLOUDFLARE_ACCOUNT_ID)
54
59
  --reject-if-busy Unavailable for Clef REST; rejected before inference
55
60
  --no-reject-if-busy Explicitly disable Cloudflare capacity rejection
@@ -95,7 +100,7 @@ function argumentsFor(argv) {
95
100
  }
96
101
 
97
102
  function overridesFor(values) {
98
- const overrides = {};
103
+ const overrides = Object.create(null);
99
104
  for (const [flag, field] of Object.entries(FIELD_MAP)) if (values[flag] !== undefined) {
100
105
  const value = values[flag];
101
106
  if (['timeoutMs', 'maxRetries', 'maxTokens'].includes(field)) {
@@ -107,6 +112,12 @@ function overridesFor(values) {
107
112
  check(!(values['reject-if-busy'] && values['no-reject-if-busy']), 'Choose one reject-if-busy flag.', 'USAGE_ERROR');
108
113
  if (values['reject-if-busy'] || values['no-reject-if-busy']) overrides.rejectIfBusy = !!values['reject-if-busy'];
109
114
  if (values['gateway-id'] !== undefined) overrides.gateway = { id: values['gateway-id'] };
115
+ for (const [flag, field] of [['jev-images', 'images'], ['jev-long-context', 'longContext']]) {
116
+ check(!(values[flag] && values['no-' + flag]), 'Choose one Jev feature flag for each setting.', 'USAGE_ERROR');
117
+ if (values[flag] || values['no-' + flag]) overrides.dangerZone = {
118
+ ...(Object.hasOwn(overrides, 'dangerZone') ? overrides.dangerZone : {}), [field]: !!values[flag],
119
+ };
120
+ }
110
121
  return overrides;
111
122
  }
112
123
 
@@ -171,7 +182,7 @@ export async function runCli(argv, { stdin = process.stdin, stdout = process.std
171
182
  '--native requires a decision provider and init/evaluate/validate/batch.', 'USAGE_ERROR');
172
183
  check(!values.native || command === 'init' || values.input || positionals[1],
173
184
  '--native requires an explicit input file or -.', 'USAGE_ERROR');
174
- check(command !== 'models' || !profile,
185
+ check(command !== 'models' || !profile || profile.models,
175
186
  'This decision provider does not implement model listing.', 'UNSUPPORTED_OPERATION');
176
187
  activeEnv = loaded.env;
177
188
  explicitSecret = loaded.config.apiKeyEnv ? activeEnv[loaded.config.apiKeyEnv] : undefined;
@@ -201,7 +212,7 @@ export async function runCli(argv, { stdin = process.stdin, stdout = process.std
201
212
  if (profile && command !== 'schema') return profile.common(raw, profile.options(loaded.config));
202
213
  const request = validateRequest(raw, raw?.model ?? loaded.config.model ?? 'validation-only');
203
214
  if (!isModule(loaded.config)) assertCapabilities(request,
204
- builtInCapabilities(loaded.config.provider, request.model));
215
+ builtInCapabilities(loaded.config.provider, request.model, loaded.config));
205
216
  return request;
206
217
  };
207
218
  if (command === 'init') {
@@ -214,6 +225,7 @@ export async function runCli(argv, { stdin = process.stdin, stdout = process.std
214
225
  ? (await import('../custom/generative/schema.mjs')).buildResponseSchema : undefined;
215
226
  await writeJson(command === 'schema' ? buildResponseSchema(request.questions)
216
227
  : { valid: true, validationScope: isModule(loaded.config) ? 'structure' : 'provider', questionCount: Object.keys(request.questions).length, model: selectedModel,
228
+ ...(profile?.resolveModel ? { resolvedModel: profile.resolveModel(request.model) } : {}),
217
229
  ...(values.native ? { native: true } : {}) });
218
230
  } else {
219
231
  let request;
@@ -39,6 +39,11 @@ export interface OpenAIDecisionResponse {
39
39
  export interface TransportMetadata {
40
40
  readonly attempts: number; readonly durationMs: number; readonly requestId?: string;
41
41
  }
42
+ /** Selected model, resolved outgoing ID, and transport diagnostics for native calls. */
43
+ export interface OpenAIDecisionsTransportMetadata extends TransportMetadata {
44
+ readonly requestedModel: string;
45
+ readonly resolvedModel: string;
46
+ }
42
47
  export type OpenAIDecisionsMetadata = {
43
48
  readonly usage: OpenAIDecisionUsage;
44
49
  readonly answers: Readonly<Record<string, { readonly type: 'predicate' | 'choice' | 'score'; readonly confidence?: number }>>;
package/src/index.d.mts CHANGED
@@ -1,8 +1,11 @@
1
1
  /** JSON values are read-only because the bridge snapshots/freezes its boundaries. */
2
+ import type { JevDangerZone, JevQuestions, JevDecisionRequest, JevDecisionResponse, JevTransportMetadata } from './jev.d.mts';
3
+ export type { JevDangerZone, JevImageAsset, JevQuestions, JevDecisionRequest, JevDecisionResponse, JevTransportMetadata } from './jev.d.mts';
2
4
  import type { NativeDecisionRequest, OpenAIDecisionResponse, OpenAIDecisionUsage,
3
- OpenAIDecisionsMetadata, TransportMetadata } from './decisions.d.mts';
5
+ OpenAIDecisionsMetadata, OpenAIDecisionsTransportMetadata } from './decisions.d.mts';
4
6
  export type { NativeDecisionInputPart, NativeDecisionMessage, NativeDecisionQuestion, NativeDecisionRequest,
5
- NativeDecisionAnswer, OpenAIDecisionResponse, OpenAIDecisionUsage, OpenAIDecisionsMetadata, TransportMetadata } from './decisions.d.mts';
7
+ NativeDecisionAnswer, OpenAIDecisionResponse, OpenAIDecisionUsage, OpenAIDecisionsMetadata,
8
+ OpenAIDecisionsTransportMetadata, TransportMetadata } from './decisions.d.mts';
6
9
  import type { CloudflareDecisionsOptions, CloudflareDecisionRequest, CloudflareDecisionResponse,
7
10
  CloudflareTransportMetadata, CloudflareDecisionsMetadata } from './cloudflare-decisions.d.mts';
8
11
  export type { CloudflareDecisionModel, CloudflareDecisionImage, CloudflareDecisionAudio, CloudflareDecisionVideo,
@@ -24,6 +27,15 @@ export interface NoulQuestion {
24
27
  readonly type: 'noul'; readonly instructions: Instructions;
25
28
  readonly criteria?: { readonly true?: Instructions; readonly false?: Instructions };
26
29
  }
30
+ export interface BoxQuestion {
31
+ readonly type: 'box'; readonly instructions: Instructions; readonly criteria?: never;
32
+ }
33
+ export interface BoxCoordinates {
34
+ readonly top: number; readonly bottom: number; readonly left: number; readonly right: number;
35
+ }
36
+ export interface BoxAnswer {
37
+ readonly type: 'box'; readonly box: BoxCoordinates; readonly confidence?: number;
38
+ }
27
39
  export interface ScoreQuestion<C extends ScoreCriteria = ScoreCriteria> {
28
40
  readonly type: 'score'; readonly instructions: Instructions; readonly criteria: C;
29
41
  readonly levels?: never;
@@ -44,7 +56,7 @@ export type DecisionInputPart = { readonly type: 'text'; readonly text: string }
44
56
  | { readonly type: 'audio'; readonly dataUrl: string }
45
57
  | { readonly type: 'video'; readonly dataUrl: string };
46
58
  export type OrderedInput = readonly DecisionInputPart[];
47
- export type Question = ChoiceQuestion | TypedChoiceQuestion | NoulQuestion | ScoreQuestion | LabeledScoreQuestion;
59
+ export type Question = ChoiceQuestion | TypedChoiceQuestion | NoulQuestion | ScoreQuestion | LabeledScoreQuestion | BoxQuestion;
48
60
  export type Questions = Readonly<Record<string, Question>>;
49
61
  export interface ChoiceAnswer<K extends string = string> {
50
62
  readonly type: 'choice'; readonly choice: K;
@@ -70,7 +82,7 @@ type ChoiceLabel<K> = K extends string ? K : K extends number ? `${K}` : never;
70
82
  export type AnswerFor<Q extends Question> = Q extends TypedChoiceQuestion<infer O> ? TypedChoiceAnswer<O[number]['value']>
71
83
  : Q extends LabeledScoreQuestion<infer L> ? LabeledScoreAnswer<L>
72
84
  : Q extends ChoiceQuestion<infer C>
73
- ? ChoiceAnswer<ChoiceLabel<keyof C>> : Q extends NoulQuestion ? NoulAnswer : ScoreAnswer;
85
+ ? ChoiceAnswer<ChoiceLabel<keyof C>> : Q extends NoulQuestion ? NoulAnswer : Q extends BoxQuestion ? BoxAnswer : ScoreAnswer;
74
86
  export type EvaluationRequest<Q extends Questions = Questions> = {
75
87
  readonly questions: Q; readonly model?: string; readonly safetyIdentifier?: string | null;
76
88
  } & ({ readonly state: State; readonly input?: never } | { readonly input: OrderedInput; readonly state?: never });
@@ -88,6 +100,8 @@ export interface ProviderResult<Q extends Questions = Questions> {
88
100
  }
89
101
  export interface Metadata {
90
102
  readonly provider: string; readonly providerKind: ProviderKind; readonly requestedModel: string;
103
+ /** Upstream model ID sent after provider selection, when supplied by the provider. */
104
+ readonly resolvedModel?: string;
91
105
  readonly probabilitySource: 'native' | 'elicited' | 'unspecified' | (string & {});
92
106
  readonly confidenceSource?: 'native' | 'unavailable' | (string & {});
93
107
  readonly modelSource?: 'reported' | 'requested';
@@ -107,7 +121,7 @@ export interface ModelList {
107
121
  readonly release_date?: string; readonly [key: string]: unknown }[];
108
122
  readonly has_more?: boolean; readonly last_id?: string;
109
123
  }
110
- export interface ResultValidationOptions { readonly native?: boolean; readonly probabilityDecimalPlaces?: number; }
124
+ export interface ResultValidationOptions { readonly native?: boolean; readonly nullableUsage?: boolean; readonly probabilityDecimalPlaces?: number; }
111
125
  export type ProviderKind = 'decision' | 'generative';
112
126
  export interface Provider {
113
127
  readonly kind: ProviderKind;
@@ -122,6 +136,7 @@ export interface Provider {
122
136
  }
123
137
  export interface DecisionProvider extends Provider { readonly kind: 'decision'; }
124
138
  export interface Capabilities {
139
+ readonly boxes?: boolean;
125
140
  readonly audio?: boolean; readonly videos?: boolean;
126
141
  readonly maxAudioClips?: number | null; readonly maxVideos?: number | null;
127
142
  readonly supported: boolean; readonly orderedInput: boolean; readonly images: boolean;
@@ -143,7 +158,7 @@ export interface TransportOptions {
143
158
  timeoutMs?: number; maxRetries?: number; maxRequestBytes?: number; maxResponseBytes?: number;
144
159
  fetchImpl?: (input: string, init?: RequestInit) => Promise<Response>;
145
160
  }
146
- export interface JevOptions extends TransportOptions { apiKey?: string; model?: string; baseURL?: string; }
161
+ export interface JevOptions extends TransportOptions { apiKey?: string; model?: string; baseURL?: string; dangerZone?: JevDangerZone; }
147
162
  export interface OpenAIDecisionsOptions extends TransportOptions { apiKey?: string; model?: string; baseURL?: string; }
148
163
  export interface Bridge {
149
164
  readonly provider: string; readonly providerKind: ProviderKind; readonly defaultModel?: string;
@@ -163,6 +178,10 @@ export class JevProvider implements DecisionProvider {
163
178
  evaluate<const Q extends Questions>(request: EvaluationRequest<Q>, options?: ProviderCallOptions): Promise<ProviderResult<Q>>;
164
179
  listModels(options?: ProviderCallOptions): Promise<ModelList>;
165
180
  getCapabilities(model?: string): Capabilities;
181
+ getResultValidationOptions(model?: string): ResultValidationOptions;
182
+ validateRequest(request: ResolvedRequest): void;
183
+ createDecision<const Q extends JevQuestions>(request: JevDecisionRequest<Q>, options?: ProviderCallOptions):
184
+ Promise<{ readonly decision: JevDecisionResponse<Q>; readonly meta: JevTransportMetadata }>;
166
185
  }
167
186
  export class OpenAIDecisionsProvider implements DecisionProvider {
168
187
  readonly kind: 'decision'; readonly name: 'openai-decisions'; readonly probabilitySource: 'native'; readonly defaultModel: string;
@@ -171,7 +190,7 @@ export class OpenAIDecisionsProvider implements DecisionProvider {
171
190
  validateRequest(request: ResolvedRequest): void;
172
191
  evaluate<const Q extends Questions>(request: EvaluationRequest<Q>, options?: ProviderCallOptions): Promise<ProviderResult<Q>>;
173
192
  createDecision(request: NativeDecisionRequest, options?: ProviderCallOptions):
174
- Promise<{ readonly decision: OpenAIDecisionResponse; readonly meta: TransportMetadata }>;
193
+ Promise<{ readonly decision: OpenAIDecisionResponse; readonly meta: OpenAIDecisionsTransportMetadata }>;
175
194
  }
176
195
  export class CloudflareDecisionsProvider implements DecisionProvider {
177
196
  readonly kind: 'decision'; readonly name: 'cloudflare-decisions'; readonly probabilitySource: 'native'; readonly defaultModel: string;
@@ -184,10 +203,12 @@ export class CloudflareDecisionsProvider implements DecisionProvider {
184
203
  Promise<{ readonly decision: CloudflareDecisionResponse; readonly meta: CloudflareTransportMetadata }>;
185
204
  }
186
205
  export type CloudflareDecisionsConfig = { type: 'cloudflare-decisions' } & CloudflareDecisionsOptions;
187
- export type ProviderConfig = ({ type: 'jev' } & JevOptions)
206
+ export type JevConfig = { type: 'jev' } & JevOptions;
207
+ export type ProviderConfig = JevConfig
188
208
  | ({ type: 'openai-decisions' } & OpenAIDecisionsOptions)
189
209
  | CloudflareDecisionsConfig;
190
210
  export type OpenAIDecisionsConfig = { type: 'openai-decisions' } & OpenAIDecisionsOptions;
211
+ export function createProvider(options: JevConfig): JevProvider;
191
212
  export function createProvider(options: OpenAIDecisionsConfig): OpenAIDecisionsProvider;
192
213
  export function createProvider(options: CloudflareDecisionsConfig): CloudflareDecisionsProvider;
193
214
  export function createProvider(options: ProviderConfig | OpenAIDecisionsConfig): DecisionProvider;
@@ -197,6 +218,7 @@ export function choice<const C extends ChoiceCriteria>(instructions: Instruction
197
218
  export function noul(instructions: Instructions, criteria?: NoulQuestion['criteria']): NoulQuestion;
198
219
  export function score<const C extends ScoreCriteria>(instructions: Instructions, criteria: C): ScoreQuestion<C>;
199
220
  export function scoreLevels<const L extends ScoreLevels>(instructions: Instructions, levels: L): LabeledScoreQuestion<L>;
221
+ export function box(instructions: Instructions): BoxQuestion;
200
222
  export interface ChoicePolicyOptions<K extends string | boolean = string> {
201
223
  minProbability: number; minMargin?: number; abstainOptions?: readonly K[];
202
224
  /** Provider-declared probability precision; omission retains the baseline tolerance. */
package/src/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  export { createBridge } from './bridge.mjs';
2
2
  export { JevProvider, OpenAIDecisionsProvider, CloudflareDecisionsProvider, createProvider } from './providers/index.mjs';
3
- export { choice, noul, score, scoreLevels } from './questions.mjs';
3
+ export { choice, noul, score, scoreLevels, box } from './questions.mjs';
4
4
  export { decideChoice } from './policy.mjs';
5
5
  export { evaluateBatch } from './batch.mjs';
6
6
  export { DecisionError, publicError } from './errors.mjs';
package/src/jev.d.mts ADDED
@@ -0,0 +1,16 @@
1
+ /** Native Jev JSON assets; file reading and image preparation remain caller-owned. */
2
+ import type { State, ChoiceQuestion, NoulQuestion, ScoreQuestion, BoxQuestion, ProviderResult } from './index.d.mts';
3
+
4
+ export interface JevDangerZone { readonly images?: boolean; readonly longContext?: boolean; }
5
+ export interface JevImageAsset {
6
+ readonly type?: 'image'; readonly format: 'png' | 'jpg' | 'jpeg'; readonly data_base64: string;
7
+ }
8
+ export type JevQuestions = Readonly<Record<string, ChoiceQuestion | NoulQuestion | ScoreQuestion | BoxQuestion>>;
9
+ export interface JevDecisionRequest<Q extends JevQuestions = JevQuestions> {
10
+ readonly state: State; readonly questions: Q; readonly model?: string;
11
+ readonly assets?: Readonly<Record<string, JevImageAsset>>;
12
+ }
13
+ export type JevDecisionResponse<Q extends JevQuestions = JevQuestions> = Omit<ProviderResult<Q>, 'meta'>;
14
+ export interface JevTransportMetadata {
15
+ readonly requestedModel: string; readonly durationMs: number; readonly attempts: number; readonly requestId?: string;
16
+ }
@@ -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,65 +1,9 @@
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
9
  check(Array.isArray(images) && images.length <= 4, 'Cloudflare accepts at most four images.');
@@ -88,7 +32,7 @@ export function validateCloudflareImages(images) {
88
32
  const decoded = atob(payload);
89
33
  const bytes = new Uint8Array(decoded.length);
90
34
  for (let i = 0; i < decoded.length; i++) bytes[i] = decoded.charCodeAt(i);
91
- const [width, height] = dimensions(bytes, format);
35
+ const [width, height] = imageDimensions(bytes, format);
92
36
  check(width > 0 && height > 0 && width * height <= 16000000,
93
37
  'Cloudflare images must have positive dimensions within 16 million pixels.');
94
38
  }
@@ -4,17 +4,28 @@ import { assertCapabilities, builtInCapabilities } from '../capabilities.mjs';
4
4
  import { serializeRequestBody } from '../transport.mjs';
5
5
  import { validateDecisionRequest } from './decisions-validation.mjs';
6
6
  import { encodeCommonDecision } from './decisions-codec.mjs';
7
+ import { OPENAI_DEFAULT_MODEL, resolveOpenAIModel } from './openai-profile.mjs';
7
8
 
8
- export function preflightDecision(input, { model = 'gpt-6-luna', maxRequestBytes } = {}) {
9
+ /** Keep the caller selection separate from the resolved, size-checked wire request. */
10
+ export function prepareDecision(input, { model = OPENAI_DEFAULT_MODEL, maxRequestBytes } = {}) {
9
11
  const request = validateDecisionRequest(input, model);
10
- serializeRequestBody(request, maxRequestBytes);
11
- return request;
12
+ const native = freezeJson({ ...request, model: resolveOpenAIModel(request.model) });
13
+ serializeRequestBody(native, maxRequestBytes);
14
+ return { request, native };
15
+ }
16
+
17
+ export function preflightDecision(input, options = {}) {
18
+ return prepareDecision(input, options).request;
12
19
  }
13
20
 
14
21
  /** Check converted JSON text and wire bytes, not just the common request shape. */
15
- export function preflightCommonDecision(input, options = {}) {
16
- const request = validateRequest(input, options.model ?? 'gpt-6-luna');
22
+ export function prepareCommonDecision(input, options = {}) {
23
+ const request = validateRequest(input, options.model ?? OPENAI_DEFAULT_MODEL);
17
24
  assertCapabilities(request, builtInCapabilities('openai-decisions', request.model));
18
- preflightDecision(encodeCommonDecision(request), options);
19
- return freezeJson(request);
25
+ const { native } = prepareDecision(encodeCommonDecision(request), options);
26
+ return { request: freezeJson(request), native };
27
+ }
28
+
29
+ export function preflightCommonDecision(input, options = {}) {
30
+ return prepareCommonDecision(input, options).request;
20
31
  }
@@ -2,6 +2,7 @@ import { DecisionError, requireCondition as check } from '../errors.mjs';
2
2
  import { cloneJson, freezeJson, isRecord } from '../validation.mjs';
3
3
  import { isTextWithinLimit, validateImage } from '../input.mjs';
4
4
  import { builtInCapabilities } from '../capabilities.mjs';
5
+ import { OPENAI_DEFAULT_MODEL } from './openai-profile.mjs';
5
6
 
6
7
  const own = (value, key) => Object.hasOwn(value, key);
7
8
  function fields(value, allowed, required, label, code = 'VALIDATION_ERROR') {
@@ -12,8 +13,8 @@ function text(value, label, max = 1048576) {
12
13
  check(isTextWithinLimit(value, max), `Invalid ${label}.`);
13
14
  }
14
15
 
15
- /** Validate the Decisions wire contract independently of the common API limits. */
16
- export function validateDecisionRequest(input, defaultModel = 'gpt-6-luna') {
16
+ /** Validate native fields while retaining the selected model for preparation and metadata. */
17
+ export function validateDecisionRequest(input, defaultModel = OPENAI_DEFAULT_MODEL) {
17
18
  const request = cloneJson(input, 'native decision request');
18
19
  fields(request, ['input', 'model', 'questions', 'safety_identifier'], ['input', 'questions'], 'decision request');
19
20
  const model = request.model ?? defaultModel;
@@ -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,90 @@
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
+ check(size <= 16 * 1024 * 1024, 'Jev images must be within 16 MiB each.', 'REQUEST_TOO_LARGE');
15
+ const decoded = atob(asset.data_base64);
16
+ // A string iterator makes Uint8Array.from allocate an intermediate element
17
+ // list. Copy directly into one bounded byte buffer for multi-MiB images.
18
+ const bytes = new Uint8Array(decoded.length);
19
+ for (let i = 0; i < decoded.length; i++) bytes[i] = decoded.charCodeAt(i);
20
+ const [width, height] = imageDimensions(bytes, asset.format === 'png' ? 'image/png' : 'image/jpeg');
21
+ check(width >= 256 && width <= 1568 && height >= 256 && height <= 1568 &&
22
+ width * height >= 78400 && width * height <= 2458624,
23
+ 'Jev image dimensions must be 256–1568 pixels with an area of 78400–2458624 pixels.');
24
+ if (asset.format === 'png') {
25
+ const view = new DataView(bytes.buffer);
26
+ for (let offset = 8; offset < bytes.length;) {
27
+ check(offset + 12 <= bytes.length, 'Invalid PNG chunk.');
28
+ const length = view.getUint32(offset);
29
+ check(offset + length + 12 <= bytes.length, 'Invalid PNG chunk length.');
30
+ check(String.fromCharCode(...bytes.subarray(offset + 4, offset + 8)) !== 'acTL',
31
+ 'Jev does not accept animated PNG images.');
32
+ offset += length + 12;
33
+ }
34
+ }
35
+ return size;
36
+ }
37
+
38
+ /** Apply TypeSafe's JSON nesting boundary and collect only replaceable values. */
39
+ function stringValues(value, output, depth = 0) {
40
+ check(depth <= 64, 'Jev state, instructions, and criteria must not exceed 64 nested levels.');
41
+ if (typeof value === 'string') output.push(value);
42
+ else if (value !== null && typeof value === 'object') {
43
+ for (const child of Object.values(value)) stringValues(child, output, depth + 1);
44
+ }
45
+ }
46
+
47
+ /** Whole values win first; other matches are consumed left to right. Alternatives
48
+ * at the same position retain asset-map order, as on the native TypeSafe API.
49
+ */
50
+ function usedReferences(strings, references) {
51
+ const known = new Set(references);
52
+ const used = new Set();
53
+ if (!references.length) return used;
54
+ const pattern = new RegExp(references.map(reference => reference.replace(/[.*+?^$(){}|[\]\\]/g, '\\$&')).join('|'), 'g');
55
+ for (const value of strings) {
56
+ if (known.has(value)) used.add(value);
57
+ else for (const match of value.matchAll(pattern)) used.add(match[0]);
58
+ }
59
+ return used;
60
+ }
61
+
62
+ export function validateJevAssets(request) {
63
+ const strings = [];
64
+ stringValues(request.state, strings);
65
+ for (const question of Object.values(request.questions)) {
66
+ stringValues(question.instructions, strings);
67
+ if (question.criteria !== undefined) stringValues(question.criteria, strings);
68
+ }
69
+ const assets = request.assets === undefined ? {} : request.assets;
70
+ check(isRecord(assets), 'Jev assets must be an object.');
71
+ const references = Object.keys(assets);
72
+ let referenceBytes = 0;
73
+ // Bound the literal pattern before compiling it or decoding any images.
74
+ for (const reference of references) {
75
+ const size = encoder.encode(reference).length;
76
+ check(reference.trim() && size <= 1024, 'Jev asset references must be nonempty and within 1024 UTF-8 bytes.');
77
+ referenceBytes += size;
78
+ check(referenceBytes <= 65536, 'Jev asset references exceed 65536 UTF-8 bytes.', 'REQUEST_TOO_LARGE');
79
+ }
80
+ const used = usedReferences(strings, references);
81
+ let imageBytes = 0;
82
+ for (const reference of references) {
83
+ check(used.has(reference), 'Every Jev asset must be used by reference substitution in a string value.');
84
+ imageBytes += inspectImage(assets[reference]);
85
+ check(imageBytes <= 32 * 1024 * 1024, 'Jev images exceed 32 MiB total.', 'REQUEST_TOO_LARGE');
86
+ }
87
+ // Server re-encoding, expanded references, full image decoding and token limits
88
+ // remain authoritative; original file sizes cannot predict those exactly.
89
+ return references.length;
90
+ }
@@ -0,0 +1,61 @@
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
+ const imageCount = validateJevAssets(native);
45
+ if (imageCount) check(dangerZone.images, 'Enable Jev dangerZone.images to send images.', 'UNSUPPORTED_CAPABILITY');
46
+ if (Object.values(request.questions).some(question => question.type === 'box')) {
47
+ check(imageCount > 0, 'Jev box questions require at least one image asset.');
48
+ }
49
+ serializeRequestBody(native, jevRequestLimit(options.maxRequestBytes));
50
+ return { request: freezeJson(request), native: freezeJson(native) };
51
+ }
52
+
53
+ export function prepareCommonJevDecision(input, options = {}) {
54
+ const request = validateRequest(input, options.model ?? 'jev-latest');
55
+ assertCapabilities(request, builtInCapabilities('jev', request.model, { dangerZone: jevDangerZone(options.dangerZone) }));
56
+ const { native } = prepareJevDecision(encodeJevDecision(request), options);
57
+ return { request: freezeJson(request), native };
58
+ }
59
+
60
+ export const preflightJevDecision = (input, options) => prepareJevDecision(input, options).native;
61
+ export const preflightCommonJevDecision = (input, options) => prepareCommonJevDecision(input, options).request;
@@ -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 <= JEV_REQUEST_LIMIT,
35
+ 'Jev maxRequestBytes must be within 1 to 32 MiB.', 'CONFIGURATION_ERROR');
36
+ return value;
37
+ }