@fayegram-ai/decision-bridge 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/CONTRIBUTING.md +5 -3
- package/README.md +26 -10
- package/SECURITY.md +18 -11
- package/docs/README.md +19 -5
- package/docs/development/ARCHITECTURE.md +36 -7
- package/docs/development/DESIGN.md +21 -3
- package/docs/development/RELEASING.md +7 -3
- package/docs/development/REPOSITORY_LAYOUT.md +2 -0
- package/docs/development/TESTING.md +31 -3
- package/docs/guides/BROWSER.md +8 -0
- package/docs/guides/CLOUDFLARE_DECISIONS.md +24 -46
- package/docs/guides/CUSTOMIZATION.md +8 -2
- package/docs/guides/CUSTOM_GENERATIVE.md +5 -3
- package/docs/guides/CUSTOM_PROVIDERS.md +5 -3
- package/docs/guides/EXTRACTION.md +125 -0
- package/docs/guides/GETTING_STARTED.md +8 -4
- package/docs/guides/JEV.md +167 -0
- package/docs/guides/LIVE_DECISIONS.md +163 -0
- package/docs/guides/OPENAI_DECISIONS.md +52 -24
- package/docs/guides/PLATFORMS.md +3 -3
- package/docs/guides/SWITCHING_MODELS.md +6 -2
- package/docs/guides/TROUBLESHOOTING.md +25 -0
- package/docs/reference/CLI.md +60 -11
- package/docs/reference/CONFIGURATION.md +34 -6
- package/docs/reference/ERRORS.md +41 -12
- package/docs/reference/PROVIDERS.md +31 -8
- package/docs/reference/SDK.md +72 -26
- package/docs/reference/SOURCES.md +11 -0
- package/examples/extraction.mjs +16 -0
- package/examples/live-decisions.mjs +24 -0
- package/package.json +11 -1
- package/schemas/decision.config.schema.json +102 -11
- package/src/bridge.mjs +9 -6
- package/src/capabilities.mjs +34 -4
- package/src/cli/config-validation.mjs +13 -5
- package/src/cli/config.mjs +19 -4
- package/src/cli/decisions.mjs +8 -0
- package/src/cli/initialize.mjs +8 -4
- package/src/cli/live.mjs +63 -0
- package/src/cli/main.mjs +50 -16
- package/src/cli/provider-loader.mjs +3 -2
- package/src/cloudflare-decisions.d.mts +12 -5
- package/src/decisions.d.mts +28 -4
- package/src/errors.mjs +7 -1
- package/src/extraction/index.d.mts +66 -0
- package/src/extraction/index.mjs +78 -0
- package/src/extraction/validation.mjs +105 -0
- package/src/index.d.mts +42 -9
- package/src/index.mjs +1 -1
- package/src/input.mjs +21 -3
- package/src/jev.d.mts +16 -0
- package/src/live/index.d.mts +73 -0
- package/src/live/index.mjs +170 -0
- package/src/live/state.mjs +125 -0
- package/src/live/validation.mjs +122 -0
- package/src/probabilities.mjs +6 -3
- package/src/providers/cloudflare-images.mjs +5 -67
- package/src/providers/cloudflare-media.mjs +5 -10
- package/src/providers/cloudflare-preflight.mjs +4 -3
- package/src/providers/cloudflare-profile.mjs +10 -14
- package/src/providers/cloudflare-transport.mjs +12 -5
- package/src/providers/cloudflare-validation.mjs +3 -1
- package/src/providers/decisions-codec.mjs +10 -5
- package/src/providers/decisions-input.mjs +120 -0
- package/src/providers/decisions-validation.mjs +37 -69
- package/src/providers/image-header.mjs +59 -0
- package/src/providers/jev-images.mjs +84 -0
- package/src/providers/jev-preflight.mjs +62 -0
- package/src/providers/jev-profile.mjs +37 -0
- package/src/providers/jev.mjs +32 -13
- package/src/providers/media-binary.mjs +0 -7
- package/src/providers/openai-decisions.mjs +2 -1
- package/src/providers/openai-errors.mjs +23 -0
- package/src/providers/openai-profile.mjs +2 -1
- package/src/questions.mjs +2 -0
- package/src/response-transport.mjs +84 -17
- package/src/validation.mjs +39 -20
- package/src/version.mjs +1 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { optionsObject } from '../options.mjs';
|
|
2
|
+
import { requireCondition as check } from '../errors.mjs';
|
|
3
|
+
|
|
4
|
+
export const JEV_REQUEST_LIMIT = 32 * 1024 * 1024;
|
|
5
|
+
export const JEV_RESULT_OPTIONS = Object.freeze({ native: true, nullableUsage: true });
|
|
6
|
+
|
|
7
|
+
/** Experimental features are explicit provider policy, never inferred from input. */
|
|
8
|
+
export function jevDangerZone(value = {}) {
|
|
9
|
+
const options = optionsObject(value, ['images', 'longContext'], 'Jev dangerZone');
|
|
10
|
+
const images = Object.hasOwn(options, 'images') ? options.images : false;
|
|
11
|
+
const longContext = Object.hasOwn(options, 'longContext') ? options.longContext : false;
|
|
12
|
+
check([images, longContext].every(value => value === undefined || typeof value === 'boolean'),
|
|
13
|
+
'Jev dangerZone flags must be boolean.', 'CONFIGURATION_ERROR');
|
|
14
|
+
return Object.freeze({ images: images ?? false, longContext: longContext ?? false });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Moving stable aliases stay conservative; pin a supported version for boxes. */
|
|
18
|
+
export function jevFeatures(model, dangerZone = {}) {
|
|
19
|
+
const version = /^jev-(\d+)\.(\d+)\.(\d+)$/.exec(model ?? '');
|
|
20
|
+
const boxes = model === 'jev-preview' || (version !== null &&
|
|
21
|
+
(Number(version[1]) > 1 || (Number(version[1]) === 1 && Number(version[2]) >= 14)));
|
|
22
|
+
const images = Object.hasOwn(dangerZone, 'images') && dangerZone.images === true;
|
|
23
|
+
return { orderedInput: true, images, boxes: images && boxes, maxImages: null };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function jevHeaders(dangerZone) {
|
|
27
|
+
return {
|
|
28
|
+
...(dangerZone.images ? { 'TypeSafe-Danger-Zone-Images': 'true' } : {}),
|
|
29
|
+
...(dangerZone.longContext ? { 'TypeSafe-Danger-Zone-Long-Context': 'true' } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function jevRequestLimit(value = JEV_REQUEST_LIMIT) {
|
|
34
|
+
check(Number.isSafeInteger(value) && value > 0 && value <= 64 * 1024 * 1024,
|
|
35
|
+
'Jev maxRequestBytes must be within the client budget of 1 to 64 MiB.', 'CONFIGURATION_ERROR');
|
|
36
|
+
return value;
|
|
37
|
+
}
|
package/src/providers/jev.mjs
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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.#
|
|
28
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
43
|
+
getCapabilities(model = this.defaultModel) { return builtInCapabilities('jev', model, this.#options); }
|
|
44
|
+
|
|
45
|
+
getResultValidationOptions() { return JEV_RESULT_OPTIONS; }
|
|
46
|
+
|
|
47
|
+
validateRequest(request) { prepareCommonJevDecision(request, this.#options); }
|
|
48
|
+
|
|
49
|
+
/** Native JSON assets allow images anywhere in state, instructions or criteria. */
|
|
50
|
+
async createDecision(input, options = {}) {
|
|
51
|
+
return this.#perform(prepareJevDecision(input, this.#options), options);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async #perform({ request, native }, options) {
|
|
55
|
+
const { data, meta: transport } = await this.#transport.request('POST', '/v1/systemone', native, options);
|
|
56
|
+
const meta = { ...transport, requestedModel: request.model };
|
|
57
|
+
const decision = withResponseMeta(meta, () => validateResult(data, request.questions, JEV_RESULT_OPTIONS));
|
|
58
|
+
return freezeJson({ decision, meta });
|
|
59
|
+
}
|
|
41
60
|
|
|
42
61
|
async listModels(options = {}) {
|
|
43
62
|
const { data, meta } = await this.#transport.request('GET', '/v1/models', undefined, options);
|
|
@@ -42,10 +42,3 @@ export function mediaBytes(payload, size) {
|
|
|
42
42
|
},
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
-
|
|
46
|
-
/** Container metadata can reject an overlong clip; full decoding remains upstream. */
|
|
47
|
-
export function checkMediaDuration(duration, limit) {
|
|
48
|
-
if (duration === undefined) return;
|
|
49
|
-
mediaCheck(Number.isFinite(duration) && duration >= 0);
|
|
50
|
-
check(duration <= limit + 1e-9, 'Embedded media exceeds the duration limit.');
|
|
51
|
-
}
|
|
@@ -8,6 +8,7 @@ import { validateDecisionResponse } from './decisions-validation.mjs';
|
|
|
8
8
|
import { decodeCommonDecision } from './decisions-codec.mjs';
|
|
9
9
|
import { preflightCommonDecision, prepareCommonDecision, prepareDecision } from './decisions-preflight.mjs';
|
|
10
10
|
import { OPENAI_DEFAULT_MODEL } from './openai-profile.mjs';
|
|
11
|
+
import { openaiError } from './openai-errors.mjs';
|
|
11
12
|
|
|
12
13
|
/** Native Decisions transport. Common and dedicated access share a single codec. */
|
|
13
14
|
export class OpenAIDecisionsProvider {
|
|
@@ -26,7 +27,7 @@ export class OpenAIDecisionsProvider {
|
|
|
26
27
|
const key = validateApiKey(supplied, 'OPENAI_API_KEY');
|
|
27
28
|
check(typeof model === 'string' && model.trim(), 'model must be nonempty.', 'CONFIGURATION_ERROR');
|
|
28
29
|
this.defaultModel = model.trim();
|
|
29
|
-
this.#transport = new JsonTransport({ ...transportOptions, maxRetries, baseURL: endpoint.baseURL,
|
|
30
|
+
this.#transport = new JsonTransport({ ...transportOptions, maxRetries, errorDecoder: openaiError, baseURL: endpoint.baseURL,
|
|
30
31
|
headers: { authorization: `Bearer ${key}` }, secrets: [key] });
|
|
31
32
|
this.#maxRequestBytes = transportOptions.maxRequestBytes;
|
|
32
33
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { DecisionError } from '../errors.mjs';
|
|
2
|
+
import { isRecord } from '../validation.mjs';
|
|
3
|
+
|
|
4
|
+
const BILLING_CODES = new Set(['insufficient_quota', 'credit_balance_exhausted',
|
|
5
|
+
'organization_spend_limit_exceeded', 'project_spend_limit_exceeded', 'organization_usage_limit_exceeded']);
|
|
6
|
+
const machineCode = value => typeof value === 'string' && /^[a-zA-Z0-9_.-]{1,128}$/.test(value) ? value : undefined;
|
|
7
|
+
|
|
8
|
+
/** Only machine codes leave the decoder; upstream messages can contain input. */
|
|
9
|
+
export function openaiError(data, status, meta = {}) {
|
|
10
|
+
const details = isRecord(data) && isRecord(data.error) ? data.error : {};
|
|
11
|
+
const upstreamErrorCode = machineCode(details.code);
|
|
12
|
+
const upstreamErrorType = machineCode(details.type);
|
|
13
|
+
const billing = BILLING_CODES.has(upstreamErrorCode) || upstreamErrorType === 'insufficient_quota';
|
|
14
|
+
const code = billing ? 'ALLOCATION_EXCEEDED' : status === 401 ? 'AUTHENTICATION_ERROR'
|
|
15
|
+
: status === 403 ? 'PERMISSION_DENIED' : status === 429 ? 'RATE_LIMITED'
|
|
16
|
+
: status === 413 ? 'REQUEST_TOO_LARGE' : status === 400 || status === 422 ? 'INVALID_REQUEST' : 'HTTP_ERROR';
|
|
17
|
+
return new DecisionError(`OpenAI rejected the request (${code}).`, {
|
|
18
|
+
...meta, code, status, source: 'provider',
|
|
19
|
+
retryable: !billing && [429, 500, 502, 503, 504, 529].includes(status),
|
|
20
|
+
...(upstreamErrorCode ? { upstreamErrorCode } : {}),
|
|
21
|
+
...(upstreamErrorType ? { upstreamErrorType } : {}),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -8,7 +8,8 @@ const PROFILES = Object.freeze({
|
|
|
8
8
|
[LUNA_MODEL]: Object.freeze({
|
|
9
9
|
supported: true, orderedInput: true, images: true, typedChoices: true,
|
|
10
10
|
labeledScores: true, safetyIdentifier: true,
|
|
11
|
-
maxImages:
|
|
11
|
+
maxImages: null, maxChoiceOptions: null, maxScoreLevels: null,
|
|
12
|
+
conversation: true, toolHistory: true, remoteImages: true, promptCaching: true,
|
|
12
13
|
}),
|
|
13
14
|
});
|
|
14
15
|
|
package/src/questions.mjs
CHANGED
|
@@ -13,3 +13,5 @@ export const noul = (instructions, criteria) => build({
|
|
|
13
13
|
});
|
|
14
14
|
export const score = (instructions, criteria) => build({ type: 'score', instructions, criteria });
|
|
15
15
|
export const scoreLevels = (instructions, levels) => build({ type: 'score', instructions, levels });
|
|
16
|
+
/** One bounding box; use a separate noul to check whether the target is present. */
|
|
17
|
+
export const box = instructions => build({ type: 'box', instructions });
|
|
@@ -13,8 +13,9 @@ const BYTE_VIEW_KIND = Object.getOwnPropertyDescriptor(BYTE_VIEW_PROTOTYPE, Symb
|
|
|
13
13
|
/** Shared wire-size check for offline preflight and actual HTTP dispatch. */
|
|
14
14
|
export function serializeRequestBody(body, maxRequestBytes = 8 * 1024 * 1024) {
|
|
15
15
|
const serialized = body === undefined ? undefined : JSON.stringify(body);
|
|
16
|
-
|
|
17
|
-
'
|
|
16
|
+
if (serialized !== undefined && UTF8_ENCODER.encode(serialized).byteLength > maxRequestBytes) {
|
|
17
|
+
throw new DecisionError('Request exceeds the configured client byte budget.', { code: 'REQUEST_TOO_LARGE', source: 'client' });
|
|
18
|
+
}
|
|
18
19
|
return serialized;
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -35,15 +36,52 @@ export function sanitizeId(value, secrets = []) {
|
|
|
35
36
|
return safe.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 200) || undefined;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
/** RFC 9110 HTTP dates use UTC, including the timezone-free asctime form. */
|
|
40
|
+
function httpDate(value, now) {
|
|
41
|
+
let parts = /^([A-Z][a-z]{2}), (\d{2}) ([A-Z][a-z]{2}) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/.exec(value);
|
|
42
|
+
let legacy = false;
|
|
43
|
+
if (!parts) {
|
|
44
|
+
parts = /^([A-Z][a-z]+), (\d{2})-([A-Z][a-z]{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/.exec(value);
|
|
45
|
+
legacy = !!parts;
|
|
46
|
+
}
|
|
47
|
+
if (!parts) {
|
|
48
|
+
const match = /^([A-Z][a-z]{2}) ([A-Z][a-z]{2}) (\d{2}| \d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/.exec(value);
|
|
49
|
+
if (!match) return undefined;
|
|
50
|
+
const [, weekday, month, day, hour, minute, second, year] = match;
|
|
51
|
+
parts = [value, weekday, day, month, year, hour, minute, second];
|
|
52
|
+
}
|
|
53
|
+
const [, weekday, dayText, monthText, yearText, hourText, minuteText, secondText] = parts;
|
|
54
|
+
const days = legacy ? ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
|
55
|
+
: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
56
|
+
const month = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].indexOf(monthText);
|
|
57
|
+
const [day, hour, minute, second] = [dayText, hourText, minuteText, secondText].map(Number);
|
|
58
|
+
if (!days.includes(weekday) || month < 0 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 60) return undefined;
|
|
59
|
+
const future = new Date(now);
|
|
60
|
+
future.setUTCFullYear(future.getUTCFullYear() + 50);
|
|
61
|
+
let year = Number(yearText) + (legacy ? Math.floor(future.getUTCFullYear() / 100) * 100 : 0);
|
|
62
|
+
const dateFor = selectedYear => {
|
|
63
|
+
const date = new Date(0);
|
|
64
|
+
date.setUTCFullYear(selectedYear, month, day);
|
|
65
|
+
date.setUTCHours(hour, minute, Math.min(second, 59), 0);
|
|
66
|
+
return date;
|
|
67
|
+
};
|
|
68
|
+
let date = dateFor(year);
|
|
69
|
+
// Two-digit years denote the most recent matching year no more than 50 years ahead.
|
|
70
|
+
if (legacy && date > future) { year -= 100; date = dateFor(year); }
|
|
71
|
+
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month || date.getUTCDate() !== day) return undefined;
|
|
72
|
+
return date.getTime() + (second === 60 ? 1000 : 0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function retryAfter(headers) {
|
|
76
|
+
const raw = headers.get('retry-after');
|
|
77
|
+
if (raw === null) return undefined;
|
|
78
|
+
if (/^\d+$/.test(raw)) {
|
|
79
|
+
const milliseconds = Number(raw) * 1000;
|
|
80
|
+
return Number.isFinite(milliseconds) ? milliseconds : undefined;
|
|
81
|
+
}
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const date = httpDate(raw, now);
|
|
84
|
+
return date === undefined ? undefined : Math.max(0, date - now);
|
|
47
85
|
}
|
|
48
86
|
|
|
49
87
|
async function readJson(response, maxBytes, budget) {
|
|
@@ -123,7 +161,18 @@ export class JsonResponseTransport {
|
|
|
123
161
|
for (let retry = 0; ; retry++) {
|
|
124
162
|
budget.throwIfAborted();
|
|
125
163
|
attempts++;
|
|
126
|
-
|
|
164
|
+
let response;
|
|
165
|
+
try { response = await withSignal(dispatch(serialized, budget.signal), budget.signal); }
|
|
166
|
+
catch (error) {
|
|
167
|
+
// Binding exceptions may provide structured own data, never inspect messages/getters.
|
|
168
|
+
const descriptors = error && typeof error === 'object' ? Object.getOwnPropertyDescriptors(error) : {};
|
|
169
|
+
const status = descriptors.status?.value;
|
|
170
|
+
const internalCode = descriptors.internalCode?.value;
|
|
171
|
+
if (this.#errorDecoder && Number.isInteger(status) && status >= 400 && status <= 599 &&
|
|
172
|
+
Number.isSafeInteger(internalCode) && internalCode >= 0) {
|
|
173
|
+
response = Response.json({ internalCode }, { status });
|
|
174
|
+
} else throw error;
|
|
175
|
+
}
|
|
127
176
|
check(response instanceof Response, 'Provider must return a Response.', 'INVALID_RESPONSE');
|
|
128
177
|
const extra = this.#responseMetadata?.(response) ?? {};
|
|
129
178
|
lastRequestId = sanitizeId(extra.requestId ?? response.headers.get('x-request-id') ??
|
|
@@ -135,7 +184,14 @@ export class JsonResponseTransport {
|
|
|
135
184
|
...(lastGatewayLogId ? { gatewayLogId: lastGatewayLogId } : {}),
|
|
136
185
|
};
|
|
137
186
|
if (response.ok) {
|
|
138
|
-
|
|
187
|
+
let data;
|
|
188
|
+
try { data = await readJson(response, this.#options.maxResponseBytes, budget); }
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (error instanceof DecisionError) throw new DecisionError(error.message,
|
|
191
|
+
{ ...error.toJSON(), status: response.status, ...meta,
|
|
192
|
+
source: error.code === 'RESPONSE_TOO_LARGE' ? 'client' : 'provider' });
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
139
195
|
budget.throwIfAborted();
|
|
140
196
|
meta.durationMs = Math.round((performance.now() - started) * 1000) / 1000;
|
|
141
197
|
return { data, meta };
|
|
@@ -143,15 +199,25 @@ export class JsonResponseTransport {
|
|
|
143
199
|
let data;
|
|
144
200
|
if (this.#errorDecoder) {
|
|
145
201
|
try { data = await readJson(response, this.#options.maxResponseBytes, budget); }
|
|
146
|
-
catch
|
|
202
|
+
catch {
|
|
203
|
+
// A rejection remains authoritative when its body is malformed,
|
|
204
|
+
// oversized or interrupted. Cancellation/deadlines still win.
|
|
205
|
+
budget.throwIfAborted();
|
|
206
|
+
}
|
|
147
207
|
} else response.body?.cancel().catch(() => {});
|
|
148
208
|
const error = this.#errorDecoder?.(data, response.status, meta) ??
|
|
149
209
|
new DecisionError(`Provider returned HTTP ${response.status}.`, {
|
|
150
|
-
code: 'HTTP_ERROR', status: response.status, ...meta,
|
|
210
|
+
code: 'HTTP_ERROR', status: response.status, source: 'provider', ...meta,
|
|
151
211
|
});
|
|
152
212
|
budget.throwIfAborted();
|
|
153
|
-
|
|
154
|
-
const
|
|
213
|
+
error.retryable ??= RETRY_STATUSES.has(response.status);
|
|
214
|
+
const hint = retryAfter(response.headers);
|
|
215
|
+
if (hint !== undefined) error.retryAfterMs = hint;
|
|
216
|
+
for (const key of ['upstreamErrorCode', 'upstreamErrorType']) {
|
|
217
|
+
if (error[key] !== undefined) error[key] = sanitizeId(error[key], this.#secrets);
|
|
218
|
+
}
|
|
219
|
+
if (!error.retryable || retry >= this.#options.maxRetries) throw error;
|
|
220
|
+
const delay = hint === undefined ? Math.min(8000, 250 * 2 ** retry) * (0.5 + Math.random()) : hint;
|
|
155
221
|
if (delay >= budget.remainingMs) throw new DecisionError('Retry delay exceeds the remaining request deadline.', {
|
|
156
222
|
...error.toJSON(), code: 'RETRY_BUDGET_EXCEEDED',
|
|
157
223
|
});
|
|
@@ -164,6 +230,7 @@ export class JsonResponseTransport {
|
|
|
164
230
|
if (budget.timedOut) throw new DecisionError('Provider request deadline exceeded.', { code: 'TIMEOUT', ...diagnostics });
|
|
165
231
|
if (error instanceof DecisionError) {
|
|
166
232
|
error.attempts ??= attempts;
|
|
233
|
+
error.source ??= 'client';
|
|
167
234
|
if (lastRequestId) error.requestId ??= lastRequestId;
|
|
168
235
|
if (lastGatewayLogId) error.gatewayLogId ??= lastGatewayLogId;
|
|
169
236
|
throw error;
|
package/src/validation.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { requireCondition as check } from './errors.mjs';
|
|
2
|
-
import {
|
|
2
|
+
import { validateOrderedInput } from './input.mjs';
|
|
3
|
+
import { validateCacheOptions, validateCacheBreakpoint, validateDecisionInput } from './providers/decisions-input.mjs';
|
|
3
4
|
import { validateProbabilitySum, validateResultOptions } from './probabilities.mjs';
|
|
4
5
|
|
|
5
6
|
const own = (value, key) => Object.hasOwn(value, key);
|
|
@@ -81,14 +82,25 @@ function allowedKeys(value, expected, label, code = 'VALIDATION_ERROR') {
|
|
|
81
82
|
export function validateRequest(input, defaultModel) {
|
|
82
83
|
const request = cloneJson(input, 'request');
|
|
83
84
|
check(isRecord(request), 'request must be an object.');
|
|
84
|
-
allowedKeys(request, ['state', 'input', 'questions', 'model', 'safetyIdentifier'
|
|
85
|
-
|
|
85
|
+
allowedKeys(request, ['state', 'input', 'conversation', 'questions', 'model', 'safetyIdentifier',
|
|
86
|
+
'promptCacheKey', 'promptCacheOptions'], 'request');
|
|
87
|
+
check(['state', 'input', 'conversation'].filter(key => own(request, key)).length === 1,
|
|
88
|
+
'Provide exactly one of state, input or conversation.');
|
|
86
89
|
if (own(request, 'state')) check(request.state !== null && isEntry(request.state),
|
|
87
90
|
'state must be text, a JSON object, or an array.');
|
|
88
|
-
else
|
|
91
|
+
else if (own(request, 'conversation')) {
|
|
92
|
+
check(Array.isArray(request.conversation), 'conversation must be an ordered item array.');
|
|
93
|
+
validateDecisionInput(request.conversation);
|
|
94
|
+
} else {
|
|
95
|
+
validateOrderedInput(request.input);
|
|
96
|
+
for (const part of request.input) validateCacheBreakpoint(part.promptCacheBreakpoint);
|
|
97
|
+
}
|
|
98
|
+
if (own(request, 'promptCacheKey')) check(request.promptCacheKey === null || typeof request.promptCacheKey === 'string',
|
|
99
|
+
'promptCacheKey must be text or null.');
|
|
100
|
+
validateCacheOptions(request.promptCacheOptions);
|
|
89
101
|
if (own(request, 'safetyIdentifier')) check(request.safetyIdentifier === null ||
|
|
90
|
-
|
|
91
|
-
'safetyIdentifier must be a string
|
|
102
|
+
typeof request.safetyIdentifier === 'string',
|
|
103
|
+
'safetyIdentifier must be a string or null.');
|
|
92
104
|
if (own(request, 'model')) check(nonempty(request.model), 'model must be a nonempty string when provided.');
|
|
93
105
|
const model = own(request, 'model') ? request.model : defaultModel;
|
|
94
106
|
check(nonempty(model), 'model must be a nonempty string.');
|
|
@@ -97,9 +109,9 @@ export function validateRequest(input, defaultModel) {
|
|
|
97
109
|
for (const [id, q] of Object.entries(request.questions)) {
|
|
98
110
|
check(nonempty(id), 'Question IDs must be nonempty.');
|
|
99
111
|
check(isRecord(q), 'Every question must be an object.');
|
|
100
|
-
allowedKeys(q, q.type === 'choice' ? ['type', 'instructions', 'criteria', 'options'] :
|
|
112
|
+
allowedKeys(q, q.type === 'box' ? ['type', 'instructions'] : q.type === 'choice' ? ['type', 'instructions', 'criteria', 'options'] :
|
|
101
113
|
q.type === 'score' ? ['type', 'instructions', 'criteria', 'levels'] : ['type', 'instructions', 'criteria'], 'question');
|
|
102
|
-
check(['choice', 'noul', 'score'].includes(q.type), 'Question type must be choice, noul, or
|
|
114
|
+
check(['choice', 'noul', 'score', 'box'].includes(q.type), 'Question type must be choice, noul, score, or box.');
|
|
103
115
|
check(own(q, 'instructions') && q.instructions !== null && isEntry(q.instructions),
|
|
104
116
|
'instructions are required and must be text, a JSON object, or an array.');
|
|
105
117
|
if (typeof q.instructions === 'string') check(nonempty(q.instructions), 'instructions must not be empty.');
|
|
@@ -125,14 +137,14 @@ export function validateRequest(input, defaultModel) {
|
|
|
125
137
|
if (own(level, 'description')) check(level.description !== null && isEntry(level.description), 'Invalid level description.');
|
|
126
138
|
}
|
|
127
139
|
} else if (q.type === 'choice') {
|
|
128
|
-
check(isRecord(q.criteria) && Object.keys(q.criteria).length > 0
|
|
129
|
-
'choice requires a criteria map
|
|
140
|
+
check(isRecord(q.criteria) && Object.keys(q.criteria).length > 0,
|
|
141
|
+
'choice requires a nonempty criteria map.');
|
|
130
142
|
for (const [label, description] of Object.entries(q.criteria)) {
|
|
131
143
|
check(nonempty(label) && isEntry(description), 'choice criteria require labels and text, structured JSON, or null descriptions.');
|
|
132
144
|
}
|
|
133
145
|
} else if (q.type === 'score') {
|
|
134
|
-
check(Array.isArray(q.criteria) && q.criteria.length >= 2
|
|
135
|
-
'score requires
|
|
146
|
+
check(Array.isArray(q.criteria) && q.criteria.length >= 2,
|
|
147
|
+
'score requires at least two ordered criteria.');
|
|
136
148
|
check(q.criteria.every(value => value !== null && isEntry(value)),
|
|
137
149
|
'score descriptions must be text or structured JSON, not null.');
|
|
138
150
|
} else if (own(q, 'criteria')) {
|
|
@@ -158,7 +170,7 @@ function distribution(value, labels, decimalPlaces) {
|
|
|
158
170
|
|
|
159
171
|
/** Validate, but do not invent, round, or normalize model results. */
|
|
160
172
|
export function validateResult(input, questions, options = {}) {
|
|
161
|
-
const { native, probabilityDecimalPlaces } = validateResultOptions(options);
|
|
173
|
+
const { native, nullableUsage, probabilityDecimalPlaces } = validateResultOptions(options);
|
|
162
174
|
const result = cloneJson(input, 'response', 'INVALID_RESPONSE');
|
|
163
175
|
// Snapshot both sides so rubric comparison uses JSON values without executing accessors.
|
|
164
176
|
const questionSnapshot = cloneJson(questions, 'questions');
|
|
@@ -168,23 +180,30 @@ export function validateResult(input, questions, options = {}) {
|
|
|
168
180
|
check(nonempty(result.model), 'Response must include model.', 'INVALID_RESPONSE');
|
|
169
181
|
exactKeys(result.answers, Object.keys(questionSnapshot), 'answers', 'INVALID_RESPONSE');
|
|
170
182
|
for (const [id, q] of Object.entries(questionSnapshot)) {
|
|
171
|
-
check(isRecord(q) && ['noul', 'choice', 'score'].includes(q.type), 'A valid question type is required.');
|
|
183
|
+
check(isRecord(q) && ['noul', 'choice', 'score', 'box'].includes(q.type), 'A valid question type is required.');
|
|
172
184
|
if (q.type === 'choice' && own(q, 'options')) check(Array.isArray(q.options) && q.options.length > 0 &&
|
|
173
185
|
q.options.every(option => isRecord(option)), 'Typed choice requires nonempty option objects.');
|
|
174
186
|
if (q.type === 'score' && own(q, 'levels')) check(Array.isArray(q.levels) && q.levels.length >= 2 &&
|
|
175
187
|
q.levels.every(level => isRecord(level) && typeof level.label === 'string'), 'Labelled score requires valid levels.');
|
|
176
|
-
if (q.type === 'choice' && !own(q, 'options')) check(isRecord(q.criteria) && Object.keys(q.criteria).length > 0
|
|
177
|
-
|
|
178
|
-
if (q.type === 'score' && !own(q, 'levels')) check(Array.isArray(q.criteria) && q.criteria.length >= 2
|
|
188
|
+
if (q.type === 'choice' && !own(q, 'options')) check(isRecord(q.criteria) && Object.keys(q.criteria).length > 0,
|
|
189
|
+
'choice requires nonempty criteria.');
|
|
190
|
+
if (q.type === 'score' && !own(q, 'levels')) check(Array.isArray(q.criteria) && q.criteria.length >= 2, 'score requires at least two levels.');
|
|
179
191
|
const answer = result.answers[id];
|
|
180
192
|
check(isRecord(answer) && answer.type === q.type, 'Answer type must match its question.', 'INVALID_RESPONSE');
|
|
181
|
-
allowedKeys(answer, q.type === 'noul' ? ['type', 'noul'] : q.type === 'choice'
|
|
193
|
+
allowedKeys(answer, q.type === 'box' ? ['type', 'box', 'confidence'] : q.type === 'noul' ? ['type', 'noul'] : q.type === 'choice'
|
|
182
194
|
? ['type', 'choice', 'probabilities', 'confidence'] : ['type', 'score', 'legend', 'probabilities', 'confidence'], 'answer', 'INVALID_RESPONSE');
|
|
183
195
|
if (q.type === 'noul') {
|
|
184
196
|
probability(answer.noul, 'noul');
|
|
185
197
|
continue;
|
|
186
198
|
}
|
|
187
199
|
if (native || own(answer, 'confidence')) probability(answer.confidence, 'confidence');
|
|
200
|
+
if (q.type === 'box') {
|
|
201
|
+
exactKeys(answer.box, ['top', 'bottom', 'left', 'right'], 'box', 'INVALID_RESPONSE');
|
|
202
|
+
Object.values(answer.box).forEach(value => probability(value, 'box edge'));
|
|
203
|
+
check(answer.box.left < answer.box.right && answer.box.top < answer.box.bottom,
|
|
204
|
+
'Box edges must enclose a positive area.', 'INVALID_RESPONSE');
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
188
207
|
if (q.type === 'choice' && q.options) {
|
|
189
208
|
validateTypedChoice(answer, q.options, probabilityDecimalPlaces);
|
|
190
209
|
continue;
|
|
@@ -195,7 +214,7 @@ export function validateResult(input, questions, options = {}) {
|
|
|
195
214
|
if (q.type === 'choice') {
|
|
196
215
|
check(typeof answer.choice === 'string' && labels.includes(answer.choice), 'choice must be a supplied option.', 'INVALID_RESPONSE');
|
|
197
216
|
const selected = answer.probabilities[answer.choice];
|
|
198
|
-
check(selected + 1e-6 >=
|
|
217
|
+
check(selected + 1e-6 >= Object.values(answer.probabilities).reduce((max, value) => Math.max(max, value), 0),
|
|
199
218
|
'choice must select a highest-probability option.', 'INVALID_RESPONSE');
|
|
200
219
|
} else {
|
|
201
220
|
exactKeys(answer.legend, labels, 'legend', 'INVALID_RESPONSE');
|
|
@@ -215,7 +234,7 @@ export function validateResult(input, questions, options = {}) {
|
|
|
215
234
|
if (own(result.usage, field)) check(Number.isSafeInteger(result.usage[field]) && result.usage[field] >= 0, 'Invalid cache token count.', 'INVALID_RESPONSE');
|
|
216
235
|
}
|
|
217
236
|
for (const field of ['input_tokens', 'output_tokens']) {
|
|
218
|
-
check((!native && result.usage[field] === null) ||
|
|
237
|
+
check(((!native || nullableUsage) && result.usage[field] === null) ||
|
|
219
238
|
(Number.isSafeInteger(result.usage[field]) && result.usage[field] >= 0),
|
|
220
239
|
'Token counts must be nonnegative safe integers.', 'INVALID_RESPONSE');
|
|
221
240
|
}
|
package/src/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Keep in sync with package.json; scripts/check.mjs verifies the release version. */
|
|
2
|
-
export const VERSION = '0.1.
|
|
2
|
+
export const VERSION = '0.1.3';
|