@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
@@ -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);
@@ -5,8 +5,9 @@ import { builtInCapabilities } from '../capabilities.mjs';
5
5
  import { JsonTransport, parseBaseURL, validateApiKey } from '../transport.mjs';
6
6
  import { environmentValue } from '../runtime-env.mjs';
7
7
  import { validateDecisionResponse } from './decisions-validation.mjs';
8
- import { encodeCommonDecision, decodeCommonDecision } from './decisions-codec.mjs';
9
- import { preflightCommonDecision, preflightDecision } from './decisions-preflight.mjs';
8
+ import { decodeCommonDecision } from './decisions-codec.mjs';
9
+ import { preflightCommonDecision, prepareCommonDecision, prepareDecision } from './decisions-preflight.mjs';
10
+ import { OPENAI_DEFAULT_MODEL } from './openai-profile.mjs';
10
11
 
11
12
  /** Native Decisions transport. Common and dedicated access share a single codec. */
12
13
  export class OpenAIDecisionsProvider {
@@ -17,7 +18,7 @@ export class OpenAIDecisionsProvider {
17
18
  #maxRequestBytes;
18
19
 
19
20
  constructor(options = {}) {
20
- const { apiKey, model = 'gpt-6-luna', baseURL = 'https://api.openai.com', maxRetries = 0, ...transportOptions } =
21
+ const { apiKey, model = OPENAI_DEFAULT_MODEL, baseURL = 'https://api.openai.com', maxRetries = 0, ...transportOptions } =
21
22
  optionsObject(options, ['apiKey', 'model', 'baseURL', ...TRANSPORT_FIELDS], 'OpenAI Decisions options');
22
23
  const endpoint = parseBaseURL(baseURL, { originOnly: true });
23
24
  const supplied = apiKey === undefined && endpoint.origin === 'https://api.openai.com'
@@ -36,16 +37,22 @@ export class OpenAIDecisionsProvider {
36
37
  preflightCommonDecision(request, { model: this.defaultModel, maxRequestBytes: this.#maxRequestBytes });
37
38
  }
38
39
 
39
- async createDecision(input, options = {}) {
40
- const request = preflightDecision(input, { model: this.defaultModel, maxRequestBytes: this.#maxRequestBytes });
41
- const { data, meta } = await this.#transport.request('POST', '/v1/decisions', request, options);
42
- const decision = withResponseMeta(meta, () => validateDecisionResponse(data, request));
40
+ /** Dispatch only prepared wire input; keep selection and reported identity independent. */
41
+ async #perform({ request, native }, options) {
42
+ const { data, meta: transport } = await this.#transport.request('POST', '/v1/decisions', native, options);
43
+ const meta = { ...transport, requestedModel: request.model, resolvedModel: native.model };
44
+ const decision = withResponseMeta(meta, () => validateDecisionResponse(data, native));
43
45
  return freezeJson({ decision, meta });
44
46
  }
45
47
 
48
+ async createDecision(input, options = {}) {
49
+ const prepared = prepareDecision(input, { model: this.defaultModel, maxRequestBytes: this.#maxRequestBytes });
50
+ return this.#perform(prepared, options);
51
+ }
52
+
46
53
  async evaluate(input, options = {}) {
47
- const request = preflightCommonDecision(input, { model: this.defaultModel, maxRequestBytes: this.#maxRequestBytes });
48
- const { decision, meta } = await this.createDecision(encodeCommonDecision(request), options);
49
- return freezeJson(withResponseMeta(meta, () => decodeCommonDecision(decision, request, meta)));
54
+ const prepared = prepareCommonDecision(input, { model: this.defaultModel, maxRequestBytes: this.#maxRequestBytes });
55
+ const { decision, meta } = await this.#perform(prepared, options);
56
+ return freezeJson(withResponseMeta(meta, () => decodeCommonDecision(decision, prepared.request, meta)));
50
57
  }
51
58
  }
@@ -0,0 +1,24 @@
1
+ import { requireCondition as check } from '../errors.mjs';
2
+
3
+ /** Release-owned selectors resolve locally; upstream responses never change this mapping. */
4
+ export const OPENAI_DEFAULT_MODEL = 'default';
5
+ const LUNA_MODEL = 'gpt-6-luna';
6
+ const SELECTORS = Object.freeze({ default: LUNA_MODEL, luna: LUNA_MODEL, [LUNA_MODEL]: LUNA_MODEL });
7
+ const PROFILES = Object.freeze({
8
+ [LUNA_MODEL]: Object.freeze({
9
+ supported: true, orderedInput: true, images: true, typedChoices: true,
10
+ labeledScores: true, safetyIdentifier: true,
11
+ maxImages: 128, maxChoiceOptions: 255, maxScoreLevels: null,
12
+ }),
13
+ });
14
+
15
+ /** Lookup does not throw for unsupported selections, matching capability-query semantics. */
16
+ export function openaiModelProfile(selector) {
17
+ return typeof selector === 'string' && Object.hasOwn(SELECTORS, selector)
18
+ ? PROFILES[SELECTORS[selector]] : undefined;
19
+ }
20
+
21
+ export function resolveOpenAIModel(selector) {
22
+ check(openaiModelProfile(selector), 'Selected provider/model does not support this model.', 'UNSUPPORTED_CAPABILITY');
23
+ return SELECTORS[selector];
24
+ }
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 });
@@ -97,9 +97,9 @@ export function validateRequest(input, defaultModel) {
97
97
  for (const [id, q] of Object.entries(request.questions)) {
98
98
  check(nonempty(id), 'Question IDs must be nonempty.');
99
99
  check(isRecord(q), 'Every question must be an object.');
100
- allowedKeys(q, q.type === 'choice' ? ['type', 'instructions', 'criteria', 'options'] :
100
+ allowedKeys(q, q.type === 'box' ? ['type', 'instructions'] : q.type === 'choice' ? ['type', 'instructions', 'criteria', 'options'] :
101
101
  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.');
102
+ check(['choice', 'noul', 'score', 'box'].includes(q.type), 'Question type must be choice, noul, score, or box.');
103
103
  check(own(q, 'instructions') && q.instructions !== null && isEntry(q.instructions),
104
104
  'instructions are required and must be text, a JSON object, or an array.');
105
105
  if (typeof q.instructions === 'string') check(nonempty(q.instructions), 'instructions must not be empty.');
@@ -158,7 +158,7 @@ function distribution(value, labels, decimalPlaces) {
158
158
 
159
159
  /** Validate, but do not invent, round, or normalize model results. */
160
160
  export function validateResult(input, questions, options = {}) {
161
- const { native, probabilityDecimalPlaces } = validateResultOptions(options);
161
+ const { native, nullableUsage, probabilityDecimalPlaces } = validateResultOptions(options);
162
162
  const result = cloneJson(input, 'response', 'INVALID_RESPONSE');
163
163
  // Snapshot both sides so rubric comparison uses JSON values without executing accessors.
164
164
  const questionSnapshot = cloneJson(questions, 'questions');
@@ -168,7 +168,7 @@ export function validateResult(input, questions, options = {}) {
168
168
  check(nonempty(result.model), 'Response must include model.', 'INVALID_RESPONSE');
169
169
  exactKeys(result.answers, Object.keys(questionSnapshot), 'answers', 'INVALID_RESPONSE');
170
170
  for (const [id, q] of Object.entries(questionSnapshot)) {
171
- check(isRecord(q) && ['noul', 'choice', 'score'].includes(q.type), 'A valid question type is required.');
171
+ check(isRecord(q) && ['noul', 'choice', 'score', 'box'].includes(q.type), 'A valid question type is required.');
172
172
  if (q.type === 'choice' && own(q, 'options')) check(Array.isArray(q.options) && q.options.length > 0 &&
173
173
  q.options.every(option => isRecord(option)), 'Typed choice requires nonempty option objects.');
174
174
  if (q.type === 'score' && own(q, 'levels')) check(Array.isArray(q.levels) && q.levels.length >= 2 &&
@@ -178,13 +178,20 @@ export function validateResult(input, questions, options = {}) {
178
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.');
179
179
  const answer = result.answers[id];
180
180
  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'
181
+ allowedKeys(answer, q.type === 'box' ? ['type', 'box', 'confidence'] : q.type === 'noul' ? ['type', 'noul'] : q.type === 'choice'
182
182
  ? ['type', 'choice', 'probabilities', 'confidence'] : ['type', 'score', 'legend', 'probabilities', 'confidence'], 'answer', 'INVALID_RESPONSE');
183
183
  if (q.type === 'noul') {
184
184
  probability(answer.noul, 'noul');
185
185
  continue;
186
186
  }
187
187
  if (native || own(answer, 'confidence')) probability(answer.confidence, 'confidence');
188
+ if (q.type === 'box') {
189
+ exactKeys(answer.box, ['top', 'bottom', 'left', 'right'], 'box', 'INVALID_RESPONSE');
190
+ Object.values(answer.box).forEach(value => probability(value, 'box edge'));
191
+ check(answer.box.left < answer.box.right && answer.box.top < answer.box.bottom,
192
+ 'Box edges must enclose a positive area.', 'INVALID_RESPONSE');
193
+ continue;
194
+ }
188
195
  if (q.type === 'choice' && q.options) {
189
196
  validateTypedChoice(answer, q.options, probabilityDecimalPlaces);
190
197
  continue;
@@ -215,7 +222,7 @@ export function validateResult(input, questions, options = {}) {
215
222
  if (own(result.usage, field)) check(Number.isSafeInteger(result.usage[field]) && result.usage[field] >= 0, 'Invalid cache token count.', 'INVALID_RESPONSE');
216
223
  }
217
224
  for (const field of ['input_tokens', 'output_tokens']) {
218
- check((!native && result.usage[field] === null) ||
225
+ check(((!native || nullableUsage) && result.usage[field] === null) ||
219
226
  (Number.isSafeInteger(result.usage[field]) && result.usage[field] >= 0),
220
227
  'Token counts must be nonnegative safe integers.', 'INVALID_RESPONSE');
221
228
  }
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.0';
2
+ export const VERSION = '0.1.2';