@fayegram-ai/decision-bridge 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/CONTRIBUTING.md +5 -3
  3. package/README.md +24 -8
  4. package/SECURITY.md +18 -11
  5. package/docs/README.md +17 -4
  6. package/docs/development/ARCHITECTURE.md +32 -5
  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 +5 -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 +13 -20
  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 +48 -10
  25. package/docs/reference/CONFIGURATION.md +31 -8
  26. package/docs/reference/ERRORS.md +41 -12
  27. package/docs/reference/PROVIDERS.md +19 -6
  28. package/docs/reference/SDK.md +62 -24
  29. package/docs/reference/SOURCES.md +7 -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 +88 -11
  34. package/src/bridge.mjs +9 -6
  35. package/src/capabilities.mjs +28 -4
  36. package/src/cli/config-validation.mjs +4 -4
  37. package/src/cli/config.mjs +4 -2
  38. package/src/cli/initialize.mjs +8 -4
  39. package/src/cli/live.mjs +63 -0
  40. package/src/cli/main.mjs +35 -12
  41. package/src/cli/provider-loader.mjs +3 -2
  42. package/src/cloudflare-decisions.d.mts +12 -5
  43. package/src/decisions.d.mts +28 -4
  44. package/src/errors.mjs +7 -1
  45. package/src/extraction/index.d.mts +66 -0
  46. package/src/extraction/index.mjs +78 -0
  47. package/src/extraction/validation.mjs +105 -0
  48. package/src/index.d.mts +18 -4
  49. package/src/input.mjs +21 -3
  50. package/src/live/index.d.mts +73 -0
  51. package/src/live/index.mjs +170 -0
  52. package/src/live/state.mjs +125 -0
  53. package/src/live/validation.mjs +122 -0
  54. package/src/providers/cloudflare-images.mjs +3 -9
  55. package/src/providers/cloudflare-media.mjs +5 -10
  56. package/src/providers/cloudflare-preflight.mjs +4 -3
  57. package/src/providers/cloudflare-profile.mjs +10 -14
  58. package/src/providers/cloudflare-transport.mjs +12 -5
  59. package/src/providers/cloudflare-validation.mjs +3 -1
  60. package/src/providers/decisions-codec.mjs +10 -5
  61. package/src/providers/decisions-input.mjs +120 -0
  62. package/src/providers/decisions-validation.mjs +37 -69
  63. package/src/providers/jev-images.mjs +2 -8
  64. package/src/providers/jev-preflight.mjs +1 -0
  65. package/src/providers/jev-profile.mjs +2 -2
  66. package/src/providers/media-binary.mjs +0 -7
  67. package/src/providers/openai-decisions.mjs +2 -1
  68. package/src/providers/openai-errors.mjs +23 -0
  69. package/src/providers/openai-profile.mjs +2 -1
  70. package/src/response-transport.mjs +84 -17
  71. package/src/validation.mjs +26 -14
  72. package/src/version.mjs +1 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "title": "Decision Bridge configuration",
4
- "description": "Explicit schemaVersion 1 configuration. Runtime preflight also enforces URL, credential and provider limits.",
4
+ "description": "Explicit schemaVersion 1 configuration. Runtime validation checks adapter structure and client resource budgets; service limits remain authoritative upstream.",
5
5
  "oneOf": [
6
6
  {
7
7
  "type": "object",
@@ -31,7 +31,7 @@
31
31
  "maxRequestBytes": {
32
32
  "type": "integer",
33
33
  "minimum": 1,
34
- "maximum": 33554432
34
+ "maximum": 67108864
35
35
  },
36
36
  "maxResponseBytes": {
37
37
  "$ref": "#/$defs/bytes"
@@ -407,6 +407,89 @@
407
407
  "provider",
408
408
  "module"
409
409
  ]
410
+ },
411
+ {
412
+ "type": "object",
413
+ "additionalProperties": false,
414
+ "properties": {
415
+ "$schema": {
416
+ "$ref": "#/$defs/text"
417
+ },
418
+ "schemaVersion": {
419
+ "const": 1
420
+ },
421
+ "model": {
422
+ "enum": [
423
+ "clairic",
424
+ "@cf/cloudflare/clairic"
425
+ ]
426
+ },
427
+ "apiKeyEnv": {
428
+ "$ref": "#/$defs/keyEnv"
429
+ },
430
+ "timeoutMs": {
431
+ "$ref": "#/$defs/timeout"
432
+ },
433
+ "baseURL": {
434
+ "$ref": "#/$defs/text"
435
+ },
436
+ "maxRetries": {
437
+ "$ref": "#/$defs/retries"
438
+ },
439
+ "maxRequestBytes": {
440
+ "$ref": "#/$defs/bytes"
441
+ },
442
+ "maxResponseBytes": {
443
+ "$ref": "#/$defs/bytes"
444
+ },
445
+ "provider": {
446
+ "const": "cloudflare-extraction"
447
+ },
448
+ "accountId": {
449
+ "$ref": "#/$defs/text"
450
+ },
451
+ "rejectIfBusy": {
452
+ "$ref": "#/$defs/boolean"
453
+ },
454
+ "gateway": {
455
+ "$ref": "#/$defs/gateway"
456
+ }
457
+ },
458
+ "required": [
459
+ "provider"
460
+ ],
461
+ "allOf": [
462
+ {
463
+ "if": {
464
+ "properties": {
465
+ "rejectIfBusy": {
466
+ "const": true
467
+ }
468
+ },
469
+ "required": [
470
+ "rejectIfBusy"
471
+ ]
472
+ },
473
+ "then": {
474
+ "properties": {
475
+ "maxRetries": {
476
+ "const": 0
477
+ },
478
+ "gateway": {
479
+ "properties": {
480
+ "retries": {
481
+ "properties": {
482
+ "maxAttempts": {
483
+ "const": 1
484
+ }
485
+ }
486
+ }
487
+ }
488
+ }
489
+ }
490
+ }
491
+ }
492
+ ]
410
493
  }
411
494
  ],
412
495
  "$defs": {
@@ -496,19 +579,16 @@
496
579
  "id": {
497
580
  "type": "string",
498
581
  "minLength": 1,
499
- "maxLength": 64,
500
582
  "pattern": "^[\\x20-\\x7e]*\\S[\\x20-\\x7e]*$"
501
583
  },
502
584
  "cacheKey": {
503
585
  "type": "string",
504
586
  "minLength": 1,
505
- "maxLength": 200,
506
587
  "pattern": "^[\\x20-\\x7e]*\\S[\\x20-\\x7e]*$"
507
588
  },
508
589
  "cacheTtl": {
509
590
  "type": "integer",
510
- "minimum": 60,
511
- "maximum": 2592000
591
+ "minimum": 0
512
592
  },
513
593
  "skipCache": {
514
594
  "type": "boolean"
@@ -523,7 +603,6 @@
523
603
  },
524
604
  "metadata": {
525
605
  "type": "object",
526
- "maxProperties": 5,
527
606
  "propertyNames": {
528
607
  "not": {
529
608
  "pattern": "^cf\\."
@@ -544,13 +623,11 @@
544
623
  "properties": {
545
624
  "maxAttempts": {
546
625
  "type": "integer",
547
- "minimum": 1,
548
- "maximum": 5
626
+ "minimum": 1
549
627
  },
550
628
  "retryDelayMs": {
551
629
  "type": "integer",
552
- "minimum": 0,
553
- "maximum": 60000
630
+ "minimum": 0
554
631
  },
555
632
  "backoff": {
556
633
  "enum": [
package/src/bridge.mjs CHANGED
@@ -9,6 +9,9 @@ import { assertCapabilities, commonSchemaVersion, providerCapabilities } from '.
9
9
  import { validateResultOptions } from './probabilities.mjs';
10
10
 
11
11
  const elapsed = start => Math.round((performance.now() - start) * 1000) / 1000;
12
+ const inputField = request => Object.hasOwn(request, 'state') ? 'state'
13
+ : Object.hasOwn(request, 'conversation') ? 'conversation' : 'input';
14
+ const inputMethod = request => ({ state: 'prepareState', input: 'prepareInput', conversation: 'prepareConversation' })[inputField(request)];
12
15
 
13
16
  /** Stable interface. Prepare context, evaluate, validate, then observe immutable results. */
14
17
  export function createBridge(options) {
@@ -26,11 +29,11 @@ export function createBridge(options) {
26
29
  'Bridge timeouts must be positive 32-bit integers.', 'CONFIGURATION_ERROR');
27
30
  check(Array.isArray(extensions), 'extensions must be an array.', 'CONFIGURATION_ERROR');
28
31
  const registered = Array.from(extensions, extension => {
29
- optionsObject(extension, ['name', 'prepareState', 'prepareInput', 'onResult'], 'Extension');
32
+ optionsObject(extension, ['name', 'prepareState', 'prepareInput', 'prepareConversation', 'onResult'], 'Extension');
30
33
  check(isRecord(extension) && typeof extension.name === 'string' && extension.name.trim() &&
31
34
  extension.name.length <= 100 && !/[\x00-\x1f\x7f]/.test(extension.name),
32
35
  'Each extension requires a printable name up to 100 characters.', 'CONFIGURATION_ERROR');
33
- for (const method of ['prepareState', 'prepareInput', 'onResult']) check(extension[method] === undefined || typeof extension[method] === 'function',
36
+ for (const method of ['prepareState', 'prepareInput', 'prepareConversation', 'onResult']) check(extension[method] === undefined || typeof extension[method] === 'function',
34
37
  'Extension handlers must be functions.', 'CONFIGURATION_ERROR');
35
38
  return { ...extension };
36
39
  });
@@ -39,9 +42,9 @@ export function createBridge(options) {
39
42
  function validate(input) {
40
43
  const request = validateRequest(input, backend.defaultModel ?? '');
41
44
  assertCapabilities(request, providerCapabilities(backend, request.model));
42
- const method = Object.hasOwn(request, 'state') ? 'prepareState' : 'prepareInput';
45
+ const method = inputMethod(request);
43
46
  for (const extension of registered) {
44
- check(!(extension.prepareState || extension.prepareInput) || typeof extension[method] === 'function',
47
+ check(!(extension.prepareState || extension.prepareInput || extension.prepareConversation) || typeof extension[method] === 'function',
45
48
  `Preparation extension ${extension.name} cannot handle this input form.`, 'UNSUPPORTED_CAPABILITY');
46
49
  }
47
50
  freezeJson(request);
@@ -80,8 +83,8 @@ export function createBridge(options) {
80
83
  questionIds: Object.freeze(Object.keys(request.questions)), signal: budget.signal });
81
84
  for (const extension of registered) {
82
85
  budget.throwIfAborted();
83
- const field = Object.hasOwn(request, 'state') ? 'state' : 'input';
84
- const method = field === 'state' ? 'prepareState' : 'prepareInput';
86
+ const field = inputField(request);
87
+ const method = inputMethod(request);
85
88
  if (!extension[method]) continue;
86
89
  let prepared;
87
90
  try {
@@ -11,22 +11,24 @@ export const LEGACY_CAPABILITIES = freezeJson({
11
11
  maxImages: 0, maxChoiceOptions: 255, maxScoreLevels: 10,
12
12
  audio: false, videos: false, maxAudioClips: 0, maxVideos: 0,
13
13
  boxes: false,
14
+ conversation: false, toolHistory: false, remoteImages: false, promptCaching: false,
14
15
  });
15
16
 
16
17
  const CLOUDFLARE_CAPABILITIES = freezeJson({
17
18
  supported: true, orderedInput: true, images: true, typedChoices: true,
18
- labeledScores: true, safetyIdentifier: false, maxImages: 4, maxChoiceOptions: 255, maxScoreLevels: 10,
19
+ labeledScores: true, safetyIdentifier: false, maxImages: null, maxChoiceOptions: null, maxScoreLevels: null,
19
20
  });
20
21
 
21
22
  export function builtInCapabilities(type, model, options = {}) {
22
23
  if (type === 'jev') return freezeJson({ ...LEGACY_CAPABILITIES,
23
- ...jevFeatures(model, Object.hasOwn(options, 'dangerZone') ? options.dangerZone : undefined) });
24
+ ...jevFeatures(model, Object.hasOwn(options, 'dangerZone') ? options.dangerZone : undefined),
25
+ maxChoiceOptions: null, maxScoreLevels: null });
24
26
  if (type === 'cloudflare-decisions') {
25
27
  const key = typeof model === 'string' ? model.trim().replace(/^@cf\/cloudflare\//, '') : '';
26
28
  const profile = Object.hasOwn(CLOUDFLARE_MODELS, key) ? CLOUDFLARE_MODELS[key] : undefined;
27
29
  return freezeJson({ ...LEGACY_CAPABILITIES, ...CLOUDFLARE_CAPABILITIES, supported: !!profile,
28
30
  audio: profile?.omni ?? false, videos: profile?.omni ?? false,
29
- maxAudioClips: profile?.omni ? 4 : 0, maxVideos: profile?.omni ? 2 : 0 });
31
+ maxAudioClips: profile?.omni ? null : 0, maxVideos: profile?.omni ? null : 0 });
30
32
  }
31
33
  if (type !== 'openai-decisions') return LEGACY_CAPABILITIES;
32
34
  const profile = openaiModelProfile(model);
@@ -49,7 +51,8 @@ export function providerCapabilities(provider, model) {
49
51
  check(isRecord(profile) && Object.keys(profile).every(key => Object.hasOwn(LEGACY_CAPABILITIES, key)),
50
52
  'Invalid capability profile.', 'CONFIGURATION_ERROR');
51
53
  const result = { ...LEGACY_CAPABILITIES, ...profile };
52
- for (const key of ['supported', 'orderedInput', 'images', 'typedChoices', 'labeledScores', 'safetyIdentifier', 'audio', 'videos', 'boxes']) {
54
+ for (const key of ['supported', 'orderedInput', 'images', 'typedChoices', 'labeledScores', 'safetyIdentifier', 'audio', 'videos', 'boxes',
55
+ 'conversation', 'toolHistory', 'remoteImages', 'promptCaching']) {
53
56
  check(typeof result[key] === 'boolean', 'Capability flags must be boolean.', 'CONFIGURATION_ERROR');
54
57
  }
55
58
  for (const key of ['maxImages', 'maxChoiceOptions', 'maxScoreLevels', 'maxAudioClips', 'maxVideos']) {
@@ -64,9 +67,27 @@ export function assertCapabilities(request, capabilities) {
64
67
  const requireFeature = (condition, feature) => check(condition,
65
68
  `Selected provider/model does not support ${feature}.`, 'UNSUPPORTED_CAPABILITY');
66
69
  requireFeature(capabilities.supported, 'this model');
70
+ if (Object.hasOwn(request, 'conversation')) {
71
+ requireFeature(capabilities.conversation, 'conversation input');
72
+ if (request.conversation.some(item => !Object.hasOwn(item, 'role'))) requireFeature(capabilities.toolHistory, 'tool history');
73
+ const parts = request.conversation.flatMap(item => Array.isArray(item.content) ? item.content
74
+ : Array.isArray(item.output) ? item.output : []);
75
+ const images = parts.filter(part => part.type === 'input_image');
76
+ if (images.length) requireFeature(capabilities.images, 'image input');
77
+ requireFeature(capabilities.maxImages === null || images.length <= capabilities.maxImages, 'this image count');
78
+ if (parts.some(part => part.type === 'input_image' && /^https?:/i.test(part.image_url))) {
79
+ requireFeature(capabilities.remoteImages, 'remote images');
80
+ }
81
+ if (parts.some(part => Object.hasOwn(part, 'prompt_cache_breakpoint'))) requireFeature(capabilities.promptCaching, 'prompt caching');
82
+ }
83
+ if (Object.hasOwn(request, 'promptCacheKey') || Object.hasOwn(request, 'promptCacheOptions') ||
84
+ request.input?.some(part => Object.hasOwn(part, 'promptCacheBreakpoint'))) requireFeature(capabilities.promptCaching, 'prompt caching');
67
85
  if (Object.hasOwn(request, 'input')) {
68
86
  requireFeature(capabilities.orderedInput, 'ordered input');
69
87
  const images = request.input.filter(part => part.type === 'image').length;
88
+ if (request.input.some(part => part.type === 'image' && Object.hasOwn(part, 'url'))) {
89
+ requireFeature(capabilities.remoteImages, 'remote images');
90
+ }
70
91
  if (images) requireFeature(capabilities.images, 'image input');
71
92
  requireFeature(capabilities.maxImages === null || images <= capabilities.maxImages, 'this image count');
72
93
  for (const [kind, flag, limit] of [['audio', 'audio', 'maxAudioClips'], ['video', 'videos', 'maxVideos']]) {
@@ -91,6 +112,9 @@ export function assertCapabilities(request, capabilities) {
91
112
  }
92
113
 
93
114
  export function commonSchemaVersion(request) {
115
+ if (Object.hasOwn(request, 'conversation') || Object.hasOwn(request, 'promptCacheKey') ||
116
+ Object.hasOwn(request, 'promptCacheOptions') || request.input?.some(part =>
117
+ Object.hasOwn(part, 'url') || Object.hasOwn(part, 'promptCacheBreakpoint'))) return 5;
94
118
  if (Object.values(request.questions).some(q => q.type === 'box')) return 4;
95
119
  if (request.input?.some(part => ['audio', 'video'].includes(part.type))) return 3;
96
120
  return Object.hasOwn(request, 'input') || Object.hasOwn(request, 'safetyIdentifier') ||
@@ -4,7 +4,7 @@ import { cloudflareGateway, validateCloudflareLimits } from '../providers/cloudf
4
4
  import { generationOptions } from '../custom/generative/shared.mjs';
5
5
  import { jevDangerZone, jevRequestLimit } from '../providers/jev-profile.mjs';
6
6
 
7
- export const PROVIDERS = ['jev', 'openai-decisions', 'cloudflare-decisions', 'custom-decision', 'custom-generative'];
7
+ export const PROVIDERS = ['jev', 'openai-decisions', 'cloudflare-decisions', 'cloudflare-extraction', 'custom-decision', 'custom-generative'];
8
8
  export const PRESETS = ['openai-compatible', 'openrouter', 'anthropic'];
9
9
  export const TRANSPORT = ['baseURL', 'maxRetries', 'maxRequestBytes', 'maxResponseBytes'];
10
10
  export const GENERATION = ['maxTokens', 'responseFormat', 'tokenParameter', 'parameters', 'noAuth'];
@@ -13,7 +13,7 @@ export const PROVIDER_FIELDS = [...TRANSPORT, ...GENERATION, 'apiKeyEnv', 'model
13
13
  const FIELDS = new Set(['$schema', 'schemaVersion', 'provider', 'timeoutMs', ...PROVIDER_FIELDS]);
14
14
  export const NUMERIC = new Set(['timeoutMs', 'maxRetries', 'maxRequestBytes', 'maxResponseBytes', 'maxTokens']);
15
15
  export const KEY_ENVS = { jev: 'TYPESAFE_API_KEY', 'openai-decisions': 'OPENAI_API_KEY',
16
- 'cloudflare-decisions': 'CLOUDFLARE_API_TOKEN', 'openai-compatible': 'OPENAI_API_KEY',
16
+ 'cloudflare-decisions': 'CLOUDFLARE_API_TOKEN', 'cloudflare-extraction': 'CLOUDFLARE_API_TOKEN', 'openai-compatible': 'OPENAI_API_KEY',
17
17
  openrouter: 'OPENROUTER_API_KEY', anthropic: 'ANTHROPIC_API_KEY' };
18
18
  export const isModule = config => ['custom-decision', 'custom-generative'].includes(config.provider) && config.module !== undefined;
19
19
 
@@ -65,8 +65,8 @@ export function validateConfig(input, { complete = false } = {}) {
65
65
  else {
66
66
  jevRequestLimit(config.maxRequestBytes);
67
67
  }
68
- if (config.provider !== 'cloudflare-decisions') check(['accountId', 'rejectIfBusy', 'gateway'].every(key => config[key] === undefined),
69
- 'Cloudflare settings require cloudflare-decisions.', 'CONFIGURATION_ERROR');
68
+ if (!['cloudflare-decisions', 'cloudflare-extraction'].includes(config.provider)) check(['accountId', 'rejectIfBusy', 'gateway'].every(key => config[key] === undefined),
69
+ 'Cloudflare settings require a Cloudflare provider.', 'CONFIGURATION_ERROR');
70
70
  else {
71
71
  config.gateway = cloudflareGateway(config.gateway);
72
72
  if (config.gateway === undefined) delete config.gateway;
@@ -30,7 +30,8 @@ async function boundedFile(path, maxBytes, label) {
30
30
  }
31
31
 
32
32
  /** Explicit file only. Flags > DECISION_* environment > file > defaults. */
33
- export async function loadConfig({ configPath, envFile, overrides = {}, cwd = process.cwd(), env = process.env, allowIncomplete = false } = {}) {
33
+ export async function loadConfig({ configPath, envFile, overrides = {}, cwd = process.cwd(), env = process.env, allowIncomplete = false,
34
+ defaultProvider = 'jev' } = {}) {
34
35
  const effectiveEnv = { ...env };
35
36
  if (envFile) {
36
37
  const text = await boundedFile(resolve(cwd, envFile), 65536, 'environment file');
@@ -88,11 +89,12 @@ export async function loadConfig({ configPath, envFile, overrides = {}, cwd = pr
88
89
  ...(layer.gateway === undefined ? {} : { gateway: { ...next.gateway, ...layer.gateway } }) };
89
90
  }
90
91
  const config = validateConfig(mergeProviderLayer(mergeProviderLayer(
91
- { schemaVersion: 1, provider: 'jev', ...fileConfig }, environment), overrides, cwd), { complete: !allowIncomplete });
92
+ { schemaVersion: 1, provider: defaultProvider, ...fileConfig }, environment), overrides, cwd), { complete: !allowIncomplete });
92
93
  if (config.provider === 'jev' && config.model === undefined && effectiveEnv.JEV_MODEL?.trim()) {
93
94
  config.model = effectiveEnv.JEV_MODEL.trim();
94
95
  }
95
96
  if (config.provider === 'openai-decisions' && config.model === undefined) config.model = OPENAI_DEFAULT_MODEL;
96
97
  if (config.provider === 'cloudflare-decisions' && config.model === undefined) config.model = 'clef';
98
+ if (config.provider === 'cloudflare-extraction' && config.model === undefined) config.model = 'clairic';
97
99
  return { config, env: effectiveEnv, moduleBase };
98
100
  }
@@ -5,6 +5,7 @@ import { validateRequest } from '../validation.mjs';
5
5
  import { decisionProfile } from './decisions.mjs';
6
6
  import { validateConfig, KEY_ENVS } from './config-validation.mjs';
7
7
  import { OPENAI_DEFAULT_MODEL } from '../providers/openai-profile.mjs';
8
+ import { validateExtractionRequest } from '../extraction/validation.mjs';
8
9
 
9
10
  /** Templates are honest edit points for an unknown protocol, never fake inference. */
10
11
  function providerTemplate(kind) {
@@ -42,15 +43,18 @@ export async function initialize(directory, config, native = false, moduleBase =
42
43
  ...(config.provider === 'openai-decisions' && config.model === undefined ? { model: OPENAI_DEFAULT_MODEL } : {}),
43
44
  ...(custom && config.model === undefined ? { model: 'YOUR_MODEL' } : {}),
44
45
  }, { complete: true });
45
- const request = { state: "ModuleNotFoundError: No module named 'pytest'", questions: {
46
+ const request = config.provider === 'cloudflare-extraction' ? {
47
+ state: 'Invoice total: 1080 USD. Due date: 2026-11-01.',
48
+ fields: { total: { type: 'number', unit: 'USD' }, due_date: { type: 'date' } },
49
+ } : { state: "ModuleNotFoundError: No module named 'pytest'", questions: {
46
50
  category: { type: 'choice', instructions: 'Classify the failure using only the supplied evidence.',
47
51
  criteria: { dependency: 'A required package or module is unavailable.',
48
52
  network: 'A network operation failed.', assertion: 'A test assertion failed.', unknown: 'Insufficient evidence.' } },
49
53
  missing_dependency: { type: 'noul', instructions: 'Does this indicate a missing Python package?' },
50
54
  } };
51
55
  const profile = decisionProfile(config.provider);
52
- const sample = native ? profile.native(profile.encode(validateRequest(request, template.model)),
53
- profile.options(template)) : request;
56
+ const sample = config.provider === 'cloudflare-extraction' ? validateExtractionRequest(request, { model: template.model })
57
+ : native ? profile.native(profile.encode(validateRequest(request, template.model)), profile.options(template)) : request;
54
58
  // OpenAI request files inherit selection from configuration, including after later edits.
55
59
  const generated = { ...sample };
56
60
  if (config.provider === 'openai-decisions') delete generated.model;
@@ -60,7 +64,7 @@ export async function initialize(directory, config, native = false, moduleBase =
60
64
  'decision.config.schema.json': schema,
61
65
  'request.json': JSON.stringify(generated, null, 2) + '\n',
62
66
  '.env.example': `${template.apiKeyEnv}=replace_with_your_api_key\n` +
63
- (config.provider === 'cloudflare-decisions' ? 'CLOUDFLARE_ACCOUNT_ID=replace_with_your_account_id\n' : ''),
67
+ (['cloudflare-decisions', 'cloudflare-extraction'].includes(config.provider) ? 'CLOUDFLARE_ACCOUNT_ID=replace_with_your_account_id\n' : ''),
64
68
  '.gitignore': '.env\n.env.*\n!.env.example\nnode_modules/\n*.tmp\n',
65
69
  ...(generateModule ? { 'provider.mjs': providerTemplate(config.provider === 'custom-decision' ? 'decision' : 'generative') } : {}),
66
70
  };
@@ -0,0 +1,63 @@
1
+ import { DecisionError, requireCondition as check, publicError } from '../errors.mjs';
2
+ import { validateLiveCommand, validateLiveConfiguration, validateLiveEvent } from '../live/validation.mjs';
3
+ import { createLiveState } from '../live/state.mjs';
4
+ import { assertDistinctFiles, createWriter, readJson, readJsonLines } from './io.mjs';
5
+
6
+ /** Offline protocol tools deliberately do not load provider configuration or keys. */
7
+ export async function runLiveCli(positionals, values, context) {
8
+ const [command, positional] = positionals;
9
+ check(['validate', 'replay'].includes(command) && positionals.length <= 2, 'Use live validate or live replay.', 'USAGE_ERROR');
10
+ check(Object.keys(values).every(key => ['input', 'output', 'force', 'pretty', 'compact', 'kind'].includes(key)),
11
+ 'Unsupported option for offline Live tools.', 'USAGE_ERROR');
12
+ check(!(values.input && positional), 'Supply one Live input file.', 'USAGE_ERROR');
13
+ check(!(values.pretty && values.compact), 'Choose pretty or compact output.', 'USAGE_ERROR');
14
+ check(!values.force || (values.output && values.output !== '-'), '--force requires file output.', 'USAGE_ERROR');
15
+ const path = values.input ?? positional;
16
+ check(path, 'Live tools require an explicit input file or -.', 'USAGE_ERROR');
17
+ if (values.output && values.output !== '-' && path !== '-') await assertDistinctFiles(path, values.output, context.cwd);
18
+ const validators = { config: validateLiveConfiguration, command: validateLiveCommand, event: validateLiveEvent };
19
+ check(command !== 'validate' || Object.hasOwn(validators, values.kind ?? ''), 'Live validation requires --kind config, command or event.', 'USAGE_ERROR');
20
+ check(command !== 'replay' || values.kind === undefined, '--kind applies only to validation.', 'USAGE_ERROR');
21
+ const writer = await createWriter({ ...context, output: values.output, force: values.force });
22
+ try {
23
+ if (command === 'validate') {
24
+ const input = await readJson(path, context);
25
+ try { validators[values.kind](input); }
26
+ catch (error) {
27
+ // An offline event file is caller input, even though the SDK's
28
+ // event validator correctly describes malformed server responses.
29
+ if (values.kind === 'event' && error instanceof DecisionError && error.code === 'INVALID_RESPONSE') {
30
+ throw new DecisionError(error.message, { ...error.toJSON(), code: 'VALIDATION_ERROR', source: 'client' });
31
+ }
32
+ throw error;
33
+ }
34
+ await writer.write(JSON.stringify({ valid: true, operation: 'live', kind: values.kind,
35
+ validationScope: 'structure' }, null, values.compact ? 0 : 2) + '\n');
36
+ await writer.finish();
37
+ return 0;
38
+ }
39
+ const state = createLiveState();
40
+ let failed = false;
41
+ for await (const row of readJsonLines(path, context)) {
42
+ let record;
43
+ try {
44
+ if (row.error) { failed = true; record = { line: row.line, ok: false, error: row.error }; }
45
+ else {
46
+ const input = row.input;
47
+ check(input && typeof input === 'object' && Object.keys(input).length === 2 &&
48
+ Object.hasOwn(input, 'event') && ['client', 'server'].includes(input.direction), 'Replay requires direction and event.');
49
+ let event;
50
+ if (input.direction === 'client') {
51
+ const command = validateLiveCommand(input.event);
52
+ state.observeClient(command);
53
+ event = { type: 'command', eventType: command.type, ...(command.event_id ? { eventId: command.event_id } : {}) };
54
+ } else event = state.accept(validateLiveEvent(input.event)) ?? { type: 'ignored', eventType: input.event.type };
55
+ record = { line: row.line, ok: true, event };
56
+ }
57
+ } catch (error) { failed = true; record = { line: row.line, ok: false, error: publicError(error) }; }
58
+ await writer.write(JSON.stringify(record) + '\n');
59
+ }
60
+ await writer.finish();
61
+ return failed ? 1 : 0;
62
+ } catch (error) { await writer.abort(); throw error; }
63
+ }
package/src/cli/main.mjs CHANGED
@@ -13,12 +13,14 @@ import { isModule } from './config-validation.mjs';
13
13
  import { loadConfig, configuredProvider } from './config.mjs';
14
14
  import { assertDistinctFiles, createWriter, readJson, readJsonLines } from './io.mjs';
15
15
  import { mapOrdered } from './ordered.mjs';
16
+ import { validateExtractionRequest } from '../extraction/index.mjs';
17
+ import { runLiveCli } from './live.mjs';
16
18
 
17
19
  const SAMPLE_PATH = fileURLToPath(new URL('../../examples/request.json', import.meta.url));
18
20
  const BOOLS = ['help', 'version', 'pretty', 'compact', 'no-auth', 'force', 'native', 'reject-if-busy', 'no-reject-if-busy',
19
21
  'jev-images', 'no-jev-images', 'jev-long-context', 'no-jev-long-context'];
20
22
  const STRINGS = ['config', 'env-file', 'provider', 'preset', 'model', 'base-url', 'api-key-env', 'timeout-ms',
21
- 'retries', 'max-tokens', 'response-format', 'token-parameter', 'input', 'output', 'concurrency', 'provider-module', 'account-id', 'gateway-id'];
23
+ 'retries', 'max-tokens', 'response-format', 'token-parameter', 'input', 'output', 'concurrency', 'provider-module', 'account-id', 'gateway-id', 'kind'];
22
24
  const FIELD_MAP = { preset: 'preset', provider: 'provider', model: 'model', 'base-url': 'baseURL', 'api-key-env': 'apiKeyEnv',
23
25
  'timeout-ms': 'timeoutMs', retries: 'maxRetries', 'max-tokens': 'maxTokens',
24
26
  'response-format': 'responseFormat', 'token-parameter': 'tokenParameter', 'provider-module': 'module', 'account-id': 'accountId' };
@@ -32,6 +34,10 @@ Usage:
32
34
  decision-bridge schema [request.json | -] [options] (offline generative schema)
33
35
  decision-bridge models [options]
34
36
  decision-bridge batch requests.jsonl [--concurrency 4] [options]
37
+ decision-bridge extraction run|validate|batch request-file [options]
38
+ decision-bridge extraction init [directory] [options]
39
+ decision-bridge live validate FILE --kind config|command|event (offline)
40
+ decision-bridge live replay FILE.jsonl (offline)
35
41
 
36
42
  Command aliases: decision, faye-decision
37
43
 
@@ -49,7 +55,7 @@ Options:
49
55
  --api-key-env NAME Read credential from this environment variable
50
56
  --no-auth Local OpenAI-compatible endpoint only
51
57
  --timeout-ms N End-to-end evaluation/provider deadline
52
- --retries N HTTP retries, 0–10; Decisions default 0, others 2
58
+ --retries N HTTP retries, 0–10; OpenAI/Cloudflare default 0, Jev 2
53
59
  --native Selected decision provider native format (init/evaluate/validate/batch)
54
60
  --jev-images Enable experimental Jev images and box questions
55
61
  --no-jev-images Explicitly disable Jev images
@@ -70,7 +76,8 @@ Options:
70
76
  --pretty / --compact JSON layout; default pretty (batch is always JSONL)
71
77
  --help, -h / --version, -v
72
78
 
73
- Without an input file, evaluate/validate/schema use the bundled sample.
79
+ Without an input file, decision evaluate/validate/schema use the bundled sample.
80
+ Extraction and Live operations require an explicit input file or stdin.
74
81
  No credentials, file reads, or network calls are needed for help/version.
75
82
  All API calls are explicit; evaluate/models/batch may access paid services.
76
83
  Exit codes: 0 success; 1 provider/partial-batch failure; 2 input/config/IO;
@@ -123,7 +130,7 @@ function overridesFor(values) {
123
130
 
124
131
  const INPUT_CODES = new Set(['USAGE_ERROR', 'CONFIGURATION_ERROR', 'VALIDATION_ERROR', 'UNSUPPORTED_CAPABILITY', 'IO_ERROR', 'INPUT_TOO_LARGE', 'REQUEST_TOO_LARGE']);
125
132
  export function exitCode(error) {
126
- return error?.code === 'ABORTED' ? 130 : INPUT_CODES.has(error?.code) ? 2 : 1;
133
+ return error?.code === 'ABORTED' ? 130 : error?.source === 'provider' ? 1 : INPUT_CODES.has(error?.code) ? 2 : 1;
127
134
  }
128
135
 
129
136
  function scrub(value, env, explicitSecret) {
@@ -152,14 +159,21 @@ export async function runCli(argv, { stdin = process.stdin, stdout = process.std
152
159
  if (signal?.aborted) cancelled();
153
160
  signal?.addEventListener('abort', cancelled, { once: true });
154
161
  try {
155
- const { values, positionals } = argumentsFor(argv);
162
+ const { values, positionals: originalPositionals } = argumentsFor(argv);
163
+ const namespace = ['extraction', 'live'].includes(originalPositionals[0]) ? originalPositionals[0] : undefined;
164
+ const positionals = namespace ? originalPositionals.slice(1) : originalPositionals;
156
165
  if (values.help || (positionals.length === 0 && !values.version)) { stdout.write(HELP); return 0; }
157
166
  if (values.version) { stdout.write(VERSION + '\n'); return 0; }
158
- const aliases = { eval: 'evaluate', ask: 'evaluate' };
167
+ if (namespace === 'live') return await runLiveCli(positionals, values,
168
+ { stdin, stdout, cwd, signal: internal.signal });
169
+ const extracting = namespace === 'extraction';
170
+ const aliases = { eval: 'evaluate', ask: 'evaluate', ...(extracting ? { run: 'evaluate' } : {}) };
159
171
  const command = aliases[positionals[0]] ?? positionals[0];
160
172
  if (command === 'help') { stdout.write(HELP); return 0; }
161
173
  if (internal.signal.aborted) throw abortError();
162
174
  check(['evaluate', 'validate', 'schema', 'models', 'init', 'batch'].includes(command), 'Unknown command. Run --help.', 'USAGE_ERROR');
175
+ check(!extracting || ['evaluate', 'validate', 'init', 'batch'].includes(command), 'Unsupported extraction command.', 'USAGE_ERROR');
176
+ check(values.kind === undefined, '--kind applies only to live validation.', 'USAGE_ERROR');
163
177
  check(positionals.length <= (command === 'models' ? 1 : 2), 'Too many positional arguments.', 'USAGE_ERROR');
164
178
  check(!(values.input && positionals[1]), 'Supply either --input or a positional file, not both.', 'USAGE_ERROR');
165
179
  check(!(values.pretty && values.compact), 'Choose --pretty or --compact, not both.', 'USAGE_ERROR');
@@ -176,8 +190,13 @@ export async function runCli(argv, { stdin = process.stdin, stdout = process.std
176
190
  'concurrency must be from 1 to 64.', 'USAGE_ERROR');
177
191
  }
178
192
  const loaded = await loadConfig({ configPath: values.config, envFile: values['env-file'],
179
- overrides: overridesFor(values), cwd, env, allowIncomplete: command === 'init' });
180
- const profile = decisionProfile(loaded.config.provider);
193
+ overrides: overridesFor(values), cwd, env, allowIncomplete: command === 'init',
194
+ defaultProvider: extracting ? 'cloudflare-extraction' : 'jev' });
195
+ check(extracting === (loaded.config.provider === 'cloudflare-extraction'), 'Selected provider does not match the command operation.', 'CONFIGURATION_ERROR');
196
+ const profile = extracting ? { common: validateExtractionRequest, inputLimit: 64 * 1024 * 1024,
197
+ options: config => Object.fromEntries(['model', 'maxRequestBytes', 'maxRetries', 'rejectIfBusy', 'gateway']
198
+ .filter(key => config[key] !== undefined).map(key => [key, config[key]])) } : decisionProfile(loaded.config.provider);
199
+ check(!extracting || !values.native, 'Extraction already uses its native field schema.', 'USAGE_ERROR');
181
200
  check(!values.native || (profile && ['init', 'evaluate', 'validate', 'batch'].includes(command)),
182
201
  '--native requires a decision provider and init/evaluate/validate/batch.', 'USAGE_ERROR');
183
202
  check(!values.native || command === 'init' || values.input || positionals[1],
@@ -187,12 +206,14 @@ export async function runCli(argv, { stdin = process.stdin, stdout = process.std
187
206
  activeEnv = loaded.env;
188
207
  explicitSecret = loaded.config.apiKeyEnv ? activeEnv[loaded.config.apiKeyEnv] : undefined;
189
208
  const inputPath = values.input ?? positionals[1] ?? SAMPLE_PATH;
209
+ check(!extracting || command === 'init' || values.input || positionals[1], 'Extraction requires an explicit input file or -.', 'USAGE_ERROR');
190
210
  if (values.output && values.output !== '-' && inputPath !== '-') {
191
211
  await assertDistinctFiles(inputPath, values.output, cwd);
192
212
  }
193
213
  writer = await createWriter({ output: values.output, force: values.force, stdout, cwd, signal: internal.signal });
194
214
  const writeJson = value => writer.write(JSON.stringify(value, null, values.compact ? 0 : 2) + '\n');
195
- const context = { stdin, cwd, signal: internal.signal, maxInputBytes: profile?.inputLimit };
215
+ const context = { stdin, cwd, signal: internal.signal,
216
+ maxInputBytes: Math.max(profile?.inputLimit ?? 8 * 1024 * 1024, loaded.config.maxRequestBytes ?? 0) };
196
217
  const withModel = input => {
197
218
  if (!input || typeof input !== 'object' || Array.isArray(input)) return input;
198
219
  // Apply the configured fallback independently of a custom factory's default.
@@ -200,7 +221,7 @@ export async function runCli(argv, { stdin = process.stdin, stdout = process.std
200
221
  const model = values.model ?? (isModule(loaded.config) && !Object.hasOwn(input, 'model')
201
222
  ? loaded.config.model : undefined);
202
223
  const request = model === undefined ? input : { ...input, model };
203
- if (values.native && (values['reject-if-busy'] || values['no-reject-if-busy'])) {
224
+ if ((values.native || extracting) && (values['reject-if-busy'] || values['no-reject-if-busy'])) {
204
225
  check(request.options === undefined || (request.options !== null && typeof request.options === 'object' &&
205
226
  !Array.isArray(request.options)), 'Native execution options must be an object.');
206
227
  return { ...request, options: { ...request.options, rejectIfBusy: !!values['reject-if-busy'] } };
@@ -224,7 +245,8 @@ export async function runCli(argv, { stdin = process.stdin, stdout = process.std
224
245
  const buildResponseSchema = command === 'schema'
225
246
  ? (await import('../custom/generative/schema.mjs')).buildResponseSchema : undefined;
226
247
  await writeJson(command === 'schema' ? buildResponseSchema(request.questions)
227
- : { valid: true, validationScope: isModule(loaded.config) ? 'structure' : 'provider', questionCount: Object.keys(request.questions).length, model: selectedModel,
248
+ : { valid: true, operation: extracting ? 'extraction' : 'decision', validationScope: isModule(loaded.config) ? 'structure' : 'provider',
249
+ ...(extracting ? { fieldCount: Object.keys(request.fields).length } : { questionCount: Object.keys(request.questions).length }), model: selectedModel,
228
250
  ...(profile?.resolveModel ? { resolvedModel: profile.resolveModel(request.model) } : {}),
229
251
  ...(values.native ? { native: true } : {}) });
230
252
  } else {
@@ -237,8 +259,9 @@ export async function runCli(argv, { stdin = process.stdin, stdout = process.std
237
259
  validateInput(request);
238
260
  }
239
261
  const provider = await configuredProvider(loaded, { signal: internal.signal });
240
- const bridge = createBridge({ provider, timeoutMs: loaded.config.timeoutMs ?? 60000 });
262
+ const bridge = extracting ? undefined : createBridge({ provider, timeoutMs: loaded.config.timeoutMs ?? 60000 });
241
263
  async function evaluateInput(input) {
264
+ if (extracting) return provider.extract(input, { signal: internal.signal });
242
265
  if (!values.native) return bridge.evaluate(input, { signal: internal.signal });
243
266
  const budget = deadline(loaded.config.timeoutMs ?? 60000, internal.signal);
244
267
  try {
@@ -4,6 +4,7 @@ import { DecisionError, requireCondition as check } from '../errors.mjs';
4
4
  import { deadline, withSignal, abortError } from '../async.mjs';
5
5
  import { providerContract } from '../provider-contract.mjs';
6
6
  import { createProvider } from '../providers/index.mjs';
7
+ import { CloudflareExtractionProvider } from '../extraction/index.mjs';
7
8
  import { validateConfig, isModule, KEY_ENVS } from './config-validation.mjs';
8
9
 
9
10
  /** Executable modules are loaded only by explicit inference/model-list commands. */
@@ -52,7 +53,7 @@ export async function configuredProvider(loaded, { signal } = {}) {
52
53
  const { createGenerativeProvider } = await import('../custom/generative/index.mjs');
53
54
  return createGenerativeProvider({ type: config.preset, ...options });
54
55
  }
55
- if (type === 'cloudflare-decisions') options.accountId = config.accountId ??
56
+ if (['cloudflare-decisions', 'cloudflare-extraction'].includes(type)) options.accountId = config.accountId ??
56
57
  (config.baseURL === undefined ? loaded.env.CLOUDFLARE_ACCOUNT_ID : undefined) ?? '';
57
- return createProvider({ type, ...options });
58
+ return type === 'cloudflare-extraction' ? new CloudflareExtractionProvider(options) : createProvider({ type, ...options });
58
59
  }
@@ -41,25 +41,32 @@ export interface CloudflareDecisionResponse {
41
41
  readonly usage: CloudflareDecisionUsage;
42
42
  }
43
43
  export interface CloudflareGatewayOptions {
44
- /** Nonblank printable ASCII Gateway name, at most 64 characters. */
44
+ /** Nonblank printable ASCII Gateway name. */
45
45
  readonly id: string;
46
46
  readonly cacheKey?: string;
47
- /** Integer seconds from 60 through 2,592,000 (30 days). */
47
+ /** Nonnegative integer seconds; the service validates its cache policy. */
48
48
  readonly cacheTtl?: number;
49
49
  readonly skipCache?: boolean;
50
50
  readonly collectLog?: boolean;
51
- /** At most five JSON primitive entries; caller-supplied cf.* keys are reserved. */
51
+ /** JSON primitive entries; caller-supplied cf.* keys are reserved. */
52
52
  readonly metadata?: Readonly<Record<string, string | number | boolean | null>>;
53
53
  readonly requestTimeoutMs?: number;
54
- readonly retries?: { readonly maxAttempts?: 1 | 2 | 3 | 4 | 5; readonly retryDelayMs?: number;
54
+ readonly retries?: { readonly maxAttempts?: number; readonly retryDelayMs?: number;
55
55
  readonly backoff?: 'constant' | 'linear' | 'exponential' };
56
56
  }
57
57
  export interface CloudflareAIBinding {
58
58
  run(model: '@cf/cloudflare/clef' | '@cf/cloudflare/clef-flash' | '@cf/cloudflare/clef-omni',
59
59
  input: Omit<CloudflareDecisionRequest, 'model' | 'options'> & { readonly model: 'clef' | 'clef-flash' | 'clef-omni' },
60
60
  options?: { readonly signal?: AbortSignal; readonly returnRawResponse?: boolean;
61
- readonly rejectIfBusy?: boolean; readonly gateway?: CloudflareGatewayOptions }): Promise<unknown>;
61
+ readonly rejectIfBusy?: boolean; readonly gateway?: CloudflareBindingGatewayOptions }): Promise<unknown>;
62
62
  }
63
+
64
+ /** Known Workers binding ABI. Provider configuration remains forwardable to the service. */
65
+ export type CloudflareBindingGatewayOptions = Omit<CloudflareGatewayOptions, 'retries'> & {
66
+ readonly retries?: Omit<NonNullable<CloudflareGatewayOptions['retries']>, 'maxAttempts'> & {
67
+ readonly maxAttempts?: 1 | 2 | 3 | 4 | 5;
68
+ };
69
+ };
63
70
  type CommonOptions = {
64
71
  model?: CloudflareDecisionModel;
65
72
  /** Defaults to false. Enabling it requires binding transport and no retries. */