zorya-classifier 0.2.1 → 0.2.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/README.md +31 -17
- package/dist/app/classifier-config.d.ts +30 -0
- package/dist/app/classifier-config.js +46 -0
- package/dist/app/classifier-contracts.d.ts +32 -0
- package/dist/app/classifier-contracts.js +1 -0
- package/dist/app/classifier-runtime.d.ts +7 -0
- package/dist/app/classifier-runtime.js +71 -0
- package/dist/app/classifier-spatial.d.ts +31 -0
- package/dist/app/classifier-spatial.js +78 -0
- package/dist/app/cli-arguments.d.ts +2 -2
- package/dist/app/composed-classifier.d.ts +17 -0
- package/dist/app/composed-classifier.js +1 -0
- package/dist/app/create-classifier.d.ts +9 -33
- package/dist/app/create-classifier.js +53 -245
- package/dist/cli.js +1 -1
- package/dist/entities/classification.d.ts +83 -0
- package/dist/entities/classification.js +28 -0
- package/dist/entities/contracts.d.ts +6 -475
- package/dist/entities/contracts.js +6 -97
- package/dist/entities/decision.d.ts +211 -0
- package/dist/entities/decision.js +33 -0
- package/dist/entities/geometry.d.ts +37 -0
- package/dist/entities/geometry.js +26 -0
- package/dist/entities/image.d.ts +30 -0
- package/dist/entities/image.js +12 -0
- package/dist/entities/localization.d.ts +110 -0
- package/dist/entities/localization.js +13 -0
- package/dist/entities/object-proposals.d.ts +1 -1
- package/dist/entities/representation.d.ts +0 -183
- package/dist/entities/representation.js +1 -33
- package/dist/entities/segmentation-metadata.d.ts +70 -0
- package/dist/entities/segmentation-metadata.js +21 -1
- package/dist/entities/segmentation-verification.d.ts +1 -1
- package/dist/entities/segmentation.d.ts +5 -20
- package/dist/entities/validation.d.ts +2 -0
- package/dist/entities/validation.js +9 -0
- package/dist/entities/whole-object.d.ts +6 -21
- package/dist/features/choice-beam-layout.js +2 -7
- package/dist/features/proposals/presentation.d.ts +21 -0
- package/dist/features/proposals/presentation.js +122 -0
- package/dist/features/proposals/validation.d.ts +138 -0
- package/dist/features/proposals/validation.js +125 -0
- package/dist/features/segmentation/box-check.js +10 -23
- package/dist/features/segmentation/mask.js +2 -7
- package/dist/features/segmentation/region-result.d.ts +18 -0
- package/dist/features/segmentation/region-result.js +171 -0
- package/dist/features/segmentation/source-mask.d.ts +6 -0
- package/dist/features/segmentation/source-mask.js +32 -0
- package/dist/features/segmentation/validation.d.ts +9 -0
- package/dist/features/segmentation/validation.js +94 -0
- package/dist/infrastructure/decision-transport/errors.d.ts +8 -0
- package/dist/infrastructure/decision-transport/errors.js +34 -0
- package/dist/infrastructure/decision-transport/images.d.ts +1 -0
- package/dist/infrastructure/decision-transport/images.js +52 -0
- package/dist/infrastructure/{openrouter-common.d.ts → decision-transport/request.d.ts} +6 -15
- package/dist/infrastructure/decision-transport/request.js +162 -0
- package/dist/infrastructure/decision-transport/response.d.ts +1 -0
- package/dist/infrastructure/decision-transport/response.js +72 -0
- package/dist/infrastructure/decision-transport/route-identity.d.ts +11 -0
- package/dist/infrastructure/decision-transport/route-identity.js +23 -0
- package/dist/infrastructure/openrouter-vision.js +1 -1
- package/dist/infrastructure/openrouter.js +7 -19
- package/dist/infrastructure/raster.js +3 -8
- package/dist/infrastructure/segmentation/grabcut/index.d.ts +2 -2
- package/dist/infrastructure/segmentation/grabcut/index.js +3 -5
- package/dist/infrastructure/segmentation/grabcut/masks.d.ts +49 -0
- package/dist/infrastructure/segmentation/grabcut/masks.js +89 -0
- package/dist/infrastructure/segmentation/grabcut/model-asset.d.ts +4 -0
- package/dist/infrastructure/segmentation/grabcut/model-asset.js +4 -0
- package/dist/infrastructure/segmentation/grabcut/runtime.d.ts +70 -0
- package/dist/infrastructure/segmentation/grabcut/runtime.js +96 -0
- package/dist/infrastructure/segmentation/grabcut/worker-inference.d.ts +4 -43
- package/dist/infrastructure/segmentation/grabcut/worker-inference.js +4 -187
- package/dist/infrastructure/segmentation/mobile-sam/index.d.ts +3 -66
- package/dist/infrastructure/segmentation/mobile-sam/index.js +2 -478
- package/dist/infrastructure/segmentation/mobile-sam/input.d.ts +46 -0
- package/dist/infrastructure/segmentation/mobile-sam/input.js +83 -0
- package/dist/infrastructure/segmentation/mobile-sam/model-assets.d.ts +8 -0
- package/dist/infrastructure/segmentation/mobile-sam/model-assets.js +8 -0
- package/dist/infrastructure/segmentation/mobile-sam/object-proposals.d.ts +7 -0
- package/dist/infrastructure/segmentation/mobile-sam/object-proposals.js +124 -0
- package/dist/infrastructure/segmentation/mobile-sam/options.d.ts +43 -0
- package/dist/infrastructure/segmentation/mobile-sam/options.js +19 -0
- package/dist/infrastructure/segmentation/mobile-sam/proposal-masks.d.ts +26 -0
- package/dist/infrastructure/segmentation/mobile-sam/proposal-masks.js +71 -0
- package/dist/infrastructure/segmentation/mobile-sam/segmentation-masks.d.ts +112 -0
- package/dist/infrastructure/segmentation/mobile-sam/segmentation-masks.js +230 -0
- package/dist/infrastructure/segmentation/mobile-sam/segmenter.d.ts +22 -0
- package/dist/infrastructure/segmentation/mobile-sam/segmenter.js +268 -0
- package/dist/infrastructure/segmentation/mobile-sam/worker-expand.d.ts +7 -0
- package/dist/infrastructure/segmentation/mobile-sam/worker-expand.js +188 -0
- package/dist/infrastructure/segmentation/mobile-sam/worker-inference.d.ts +6 -149
- package/dist/infrastructure/segmentation/mobile-sam/worker-inference.js +6 -845
- package/dist/infrastructure/segmentation/mobile-sam/worker-models.d.ts +14 -0
- package/dist/infrastructure/segmentation/mobile-sam/worker-models.js +72 -0
- package/dist/infrastructure/segmentation/mobile-sam/worker-proposals.d.ts +7 -0
- package/dist/infrastructure/segmentation/mobile-sam/worker-proposals.js +159 -0
- package/dist/infrastructure/segmentation/mobile-sam/worker-segment.d.ts +4 -0
- package/dist/infrastructure/segmentation/mobile-sam/worker-segment.js +126 -0
- package/dist/infrastructure/segmentation/native-protocol.d.ts +59 -34
- package/dist/infrastructure/segmentation/native-protocol.js +26 -16
- package/dist/infrastructure/segmentation/proposal-presentation-input.d.ts +20 -0
- package/dist/infrastructure/segmentation/proposal-presentation-input.js +107 -0
- package/dist/infrastructure/segmentation/proposal-presentation-pixels.d.ts +8 -0
- package/dist/infrastructure/segmentation/proposal-presentation-pixels.js +93 -0
- package/dist/infrastructure/segmentation/proposal-presentation.js +10 -201
- package/dist/infrastructure/vision-chat/options.d.ts +62 -0
- package/dist/infrastructure/vision-chat/options.js +46 -0
- package/dist/infrastructure/vision-chat/request.d.ts +23 -0
- package/dist/infrastructure/vision-chat/request.js +109 -0
- package/dist/infrastructure/vision-chat/response.d.ts +3 -0
- package/dist/infrastructure/vision-chat/response.js +105 -0
- package/dist/infrastructure/vision-chat.d.ts +4 -61
- package/dist/infrastructure/vision-chat.js +8 -271
- package/dist/openai-compatible.js +1 -1
- package/dist/processes/extract-from-region.js +11 -216
- package/dist/processes/extract.d.ts +2 -8
- package/dist/processes/extract.js +12 -128
- package/dist/processes/proposal-location.js +5 -251
- package/docs/README.ru.md +50 -33
- package/docs/acceptance.md +6 -4
- package/docs/agents.ru.md +97 -0
- package/docs/api.md +4 -4
- package/docs/architecture.md +33 -7
- package/docs/architecture.ru.md +146 -0
- package/docs/benchmarks.md +3 -1
- package/docs/development.ru.md +141 -0
- package/docs/migrations/experimental-text-image-removal.ru.md +9 -0
- package/docs/provenance.json +288 -1
- package/docs/publishing.md +5 -3
- package/docs/refactoring-plan.ru.md +183 -0
- package/docs/v02-usage.md +8 -8
- package/docs/vision-results.md +1 -1
- package/package.json +5 -3
- package/dist/infrastructure/boundary-view.d.ts +0 -2
- package/dist/infrastructure/boundary-view.js +0 -263
- package/dist/infrastructure/openrouter-common.js +0 -317
- package/dist/infrastructure/text-representation.d.ts +0 -23
- package/dist/infrastructure/text-representation.js +0 -226
package/docs/v02-usage.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Region-seeded masks and extraction (0.2)
|
|
2
2
|
|
|
3
|
-
Everything in this guide—default region `locate`, `locate({ mode: 'whole-object' })`, `extract`, optional `RefinementPort`, `SegmentationPort`, box-check composition, `objectProposals`, and exported native factories—is
|
|
3
|
+
Everything in this guide—default region `locate`, `locate({ mode: 'whole-object' })`, `extract`, optional `RefinementPort`, `SegmentationPort`, box-check composition, `objectProposals`, and exported native factories—is implemented and caller-configured. Passing structural or lifecycle checks does not establish semantic object accuracy or mask quality. The [H8a vs Jev DEV12 comparison](research/v03-jev-native-box-results.ru.md) documents one research box recipe; it is not default locate/extract. Historical metrics in `docs/evidence/**` are unchanged.
|
|
4
4
|
|
|
5
5
|
The [accepted plan](0.2.0-plan.md) and [acceptance status](acceptance.md) distinguish implementation, offline checks, live quality and platform qualification. The frozen caller recipe passed admission with only 8/60 complete positive object boxes; 19/30 mask references had no prediction. Configure and test the model and segmenter for your own data. No text-image encoder qualified.
|
|
6
6
|
|
|
@@ -8,12 +8,12 @@ The library does not select or connect a model. Supply a `DecisionPort` to `crea
|
|
|
8
8
|
|
|
9
9
|
## Choose the operation
|
|
10
10
|
|
|
11
|
-
| Operation | Status
|
|
12
|
-
| ------------------------------------------------------- |
|
|
13
|
-
| `classify` | Supported
|
|
14
|
-
| `locate` (default / `mode: 'region'`) |
|
|
15
|
-
| `locate` with `mode: 'whole-object'` and `segmentation` |
|
|
16
|
-
| `extract` with `segmentation` |
|
|
11
|
+
| Operation | Status | Result | Model requests |
|
|
12
|
+
| ------------------------------------------------------- | --------- | ---------------------------------------------------------------------------- | ---------------------------------------------- |
|
|
13
|
+
| `classify` | Supported | One supplied class | One |
|
|
14
|
+
| `locate` (default / `mode: 'region'`) | Available | A grid cell that may contain an object fragment | Up to three |
|
|
15
|
+
| `locate` with `mode: 'whole-object'` and `segmentation` | Available | Visible bounds and center derived from a mask | Up to five, plus local segmentation/correction |
|
|
16
|
+
| `extract` with `segmentation` | Available | Mask-derived bounds, center, transparent PNG, cropped mask and source offset | Up to five, plus local segmentation/correction |
|
|
17
17
|
|
|
18
18
|
The primary object workflow is the unchanged region locator followed by an explicitly configured segmenter. Segmentation receives the locator's seed prompt on the full EXIF-oriented source image; it does not receive a seed crop or treat the seed as an object box. Bounds and center come from actual foreground mask pixels. After the initial mask, a closed decision checks the original image with the actual mask box and original seed anchor: `complete`, `needs-expansion`, or `unknown`. It reuses the caller-supplied `DecisionPort`. An incomplete box permits one actual enclosing-mask correction and one final check; there is no separate extent search or generated model geometry. Default `locate` behavior remains compatible.
|
|
19
19
|
|
|
@@ -157,7 +157,7 @@ Successful extraction writes `<prefix>.png`, `<prefix>.mask.u8` and `<prefix>.js
|
|
|
157
157
|
|
|
158
158
|
## Experimental text-only images
|
|
159
159
|
|
|
160
|
-
The
|
|
160
|
+
The former experimental selector `representation: { type: 'experimental-text', profile: 'text-image-v1' }` was removed from the public runtime. Requests that include it fail strict validation with `INVALID_INPUT` before any `DecisionPort` call. Text-image encoders remain under `benchmarks/experiments/` only and are not pending public qualification. Ordinary string/`{ text }` classification and vision inputs (`{ type: 'vision' }` or omitted) remain supported. See [migration note (RU)](migrations/experimental-text-image-removal.ru.md).
|
|
161
161
|
|
|
162
162
|
Research candidates compare normalized luma ASCII, color palette grids, SVG color runs, strict pixel RLE, binary/edge ASCII, and layered ASCII + SVG. No OCR, semantic helper or hidden vision call supplies object facts. Text adapters reject image parts rather than silently dropping them. Complete encoded views and metadata have a 64 KiB UTF-8 budget, allocated before encoding; output is never truncated to fit. Indistinguishable geometry returns a named unknown.
|
|
163
163
|
|
package/docs/vision-results.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Current 0.2 region and extraction evidence
|
|
4
4
|
|
|
5
|
-
Region `locate`, whole-object location, `extract`, proposal/refinement strategies
|
|
5
|
+
Region `locate`, whole-object location, `extract`, proposal/refinement strategies and native segmentation adapters are available public APIs. Results below describe specific model recipes, not general accuracy. In one exposed DEV12 pilot, the historical H8a output passed the strict same-instance box criterion on 7/9 positive tasks; a single native Jev 1.14 run passed on 3/9. The criterion requires IoU ≥0.5 and at least 90% reference-box coverage. See the [full v03 comparison (RU)](research/v03-jev-native-box-results.ru.md); H8a was not rerun for this comparison. The same H8a run produced false outputs on 2/3 tasks where the target was absent, so it failed its full gate; the single Jev run produced one such false output on 1/3 tasks.
|
|
6
6
|
|
|
7
7
|
The final [frozen v3 heldout report](evidence/v02/region-seeded-heldout-v3/README.md) evaluates all 80 cases per profile. Classification is 65/80; positive seed hits 26/60; complete object boxes 8/60; false seeds and extractions 0/20 negatives. Of 30 exact-canvas mask references, eight pass IoU 0.8, three fail and 19 have no prediction. Both positive-photo paired seed intervals are [0.500, 0.833]. These are separate metrics; a successful seed or containment decision does not guarantee a tight mask. Candidate accounting includes 266 physical calls, two HTTP 502 failures and two unknown charges. The control made 80 image-free calls. This one controlled corpus admission and the resource/native checks qualify the documented recipe only; model choice stays with the caller. Development and failed historical evidence below is preserved.
|
|
8
8
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zorya-classifier",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Provider-independent classification
|
|
3
|
+
"version": "0.2.3",
|
|
4
|
+
"description": "Provider-independent Node.js classification, spatial localization, and caller-owned mask-based object extraction",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"engines": {
|
|
@@ -42,7 +42,9 @@
|
|
|
42
42
|
"lint": "eslint . --max-warnings 0",
|
|
43
43
|
"format": "prettier --write .",
|
|
44
44
|
"format:check": "prettier --check .",
|
|
45
|
-
"test": "node --import tsx
|
|
45
|
+
"test": "node --import tsx scripts/run-classified-tests.mjs portable",
|
|
46
|
+
"test:local-evidence": "node --import tsx scripts/run-classified-tests.mjs local-evidence",
|
|
47
|
+
"test:python-native": "node --import tsx scripts/run-classified-tests.mjs python-native",
|
|
46
48
|
"check": "npm run secrets:check && npm run typecheck && npm run lint && npm run boundaries && npm run dead-code:ci && npm test && npm run build",
|
|
47
49
|
"boundaries": "node scripts/boundaries.mjs",
|
|
48
50
|
"dead-code:ci": "knip --no-progress",
|
|
@@ -1,263 +0,0 @@
|
|
|
1
|
-
import sharp from 'sharp';
|
|
2
|
-
import { z } from 'zod';
|
|
3
|
-
import { limits, pixelRectSchema } from '../entities/contracts.js';
|
|
4
|
-
import { ClassifierError, assertActive } from '../entities/errors.js';
|
|
5
|
-
const sourceSchema = z
|
|
6
|
-
.object({
|
|
7
|
-
bytes: z
|
|
8
|
-
.custom((v) => v instanceof Uint8Array)
|
|
9
|
-
.refine((v) => v.byteLength > 0 && v.byteLength <= limits.imageBytes),
|
|
10
|
-
width: z.number().int().positive().max(16384),
|
|
11
|
-
height: z.number().int().positive().max(16384),
|
|
12
|
-
})
|
|
13
|
-
.strict();
|
|
14
|
-
const candidateSchema = z
|
|
15
|
-
.object({
|
|
16
|
-
id: z
|
|
17
|
-
.string()
|
|
18
|
-
.min(1)
|
|
19
|
-
.max(128)
|
|
20
|
-
.regex(/^[A-Za-z][A-Za-z0-9_-]*$/),
|
|
21
|
-
display: z
|
|
22
|
-
.string()
|
|
23
|
-
.min(1)
|
|
24
|
-
.max(8)
|
|
25
|
-
.regex(/^[A-Za-z0-9_-]+$/),
|
|
26
|
-
interval: z
|
|
27
|
-
.object({
|
|
28
|
-
start: z.number().int().nonnegative().safe(),
|
|
29
|
-
endExclusive: z.number().int().positive().safe(),
|
|
30
|
-
})
|
|
31
|
-
.strict()
|
|
32
|
-
.refine((v) => v.start < v.endExclusive),
|
|
33
|
-
})
|
|
34
|
-
.strict();
|
|
35
|
-
const inputSchema = z
|
|
36
|
-
.object({
|
|
37
|
-
source: sourceSchema,
|
|
38
|
-
sourceRegion: pixelRectSchema,
|
|
39
|
-
axis: z.enum(['left', 'right', 'top', 'bottom']),
|
|
40
|
-
candidates: z.array(candidateSchema).min(1).max(16),
|
|
41
|
-
signal: z.custom((v) => v instanceof AbortSignal),
|
|
42
|
-
})
|
|
43
|
-
.strict();
|
|
44
|
-
const maxContentSide = 1280;
|
|
45
|
-
const maxOutputSide = 1567;
|
|
46
|
-
const axisGutter = 210;
|
|
47
|
-
const outerGutter = 10;
|
|
48
|
-
const maxRenderedBytes = limits.imageBytes;
|
|
49
|
-
function contained(outer, inner) {
|
|
50
|
-
return (inner.left >= outer.left &&
|
|
51
|
-
inner.top >= outer.top &&
|
|
52
|
-
inner.left + inner.width <= outer.left + outer.width &&
|
|
53
|
-
inner.top + inner.height <= outer.top + outer.height);
|
|
54
|
-
}
|
|
55
|
-
function escapeXml(value) {
|
|
56
|
-
return value.replace(/[&<>"']/gu, (char) => {
|
|
57
|
-
switch (char) {
|
|
58
|
-
case '&':
|
|
59
|
-
return '&';
|
|
60
|
-
case '<':
|
|
61
|
-
return '<';
|
|
62
|
-
case '>':
|
|
63
|
-
return '>';
|
|
64
|
-
case '"':
|
|
65
|
-
return '"';
|
|
66
|
-
default:
|
|
67
|
-
return ''';
|
|
68
|
-
}
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
function failInput(message) {
|
|
72
|
-
throw new ClassifierError('INVALID_INPUT', message, { component: 'boundary-view' });
|
|
73
|
-
}
|
|
74
|
-
function validate(input) {
|
|
75
|
-
const parsed = inputSchema.safeParse(input);
|
|
76
|
-
if (!parsed.success)
|
|
77
|
-
failInput('Invalid boundary-view request');
|
|
78
|
-
const value = parsed.data;
|
|
79
|
-
if (value.source.width * value.source.height > limits.imagePixels)
|
|
80
|
-
throw new ClassifierError('RESOURCE_LIMIT', 'Source raster exceeds pixel limits', {
|
|
81
|
-
component: 'boundary-view',
|
|
82
|
-
});
|
|
83
|
-
const full = { left: 0, top: 0, width: value.source.width, height: value.source.height };
|
|
84
|
-
if (!contained(full, value.sourceRegion))
|
|
85
|
-
failInput('Source region lies outside the oriented image');
|
|
86
|
-
const xAxis = value.axis === 'left' || value.axis === 'right';
|
|
87
|
-
const axisSize = xAxis ? value.source.width : value.source.height;
|
|
88
|
-
const axisStart = xAxis ? value.sourceRegion.left : value.sourceRegion.top;
|
|
89
|
-
const axisEnd = axisStart + (xAxis ? value.sourceRegion.width : value.sourceRegion.height);
|
|
90
|
-
const ids = new Set();
|
|
91
|
-
const displays = new Set();
|
|
92
|
-
let priorEnd = -1;
|
|
93
|
-
for (const candidate of value.candidates) {
|
|
94
|
-
if (ids.has(candidate.id) || displays.has(candidate.display))
|
|
95
|
-
failInput('Boundary candidate IDs and display labels must be unique');
|
|
96
|
-
ids.add(candidate.id);
|
|
97
|
-
displays.add(candidate.display);
|
|
98
|
-
if (candidate.interval.endExclusive > axisSize)
|
|
99
|
-
failInput('Boundary interval lies outside the oriented source');
|
|
100
|
-
if (candidate.interval.start < axisStart || candidate.interval.endExclusive > axisEnd)
|
|
101
|
-
failInput('Boundary interval lies outside the source region');
|
|
102
|
-
if (candidate.interval.start < priorEnd)
|
|
103
|
-
failInput('Boundary intervals must be ordered and non-overlapping');
|
|
104
|
-
priorEnd = candidate.interval.endExclusive;
|
|
105
|
-
}
|
|
106
|
-
return value;
|
|
107
|
-
}
|
|
108
|
-
function svgOverlay(axis, candidates, region, contentWidth, contentHeight, contentLeft, contentTop, outputWidth, outputHeight) {
|
|
109
|
-
const vertical = axis === 'left' || axis === 'right';
|
|
110
|
-
const axisLength = vertical ? region.width : region.height;
|
|
111
|
-
const outputLength = vertical ? contentWidth : contentHeight;
|
|
112
|
-
const sourceOffset = vertical ? region.left : region.top;
|
|
113
|
-
const unit = outputLength / axisLength;
|
|
114
|
-
const strokes = [];
|
|
115
|
-
// Presentation is tied to the coordinate dimension, never to the semantic side being located.
|
|
116
|
-
const color = vertical ? '#ff6b5f' : '#49d6a1';
|
|
117
|
-
const title = vertical ? 'X coordinates' : 'Y coordinates';
|
|
118
|
-
strokes.push(vertical
|
|
119
|
-
? `<text x="${outputWidth - outerGutter - 2}" y="${outerGutter + 10}" text-anchor="end" font-family="DejaVu Sans,sans-serif" font-size="9" fill="#f3f4f6">${title}</text>`
|
|
120
|
-
: `<text x="${outerGutter}" y="${outerGutter + 10}" font-family="DejaVu Sans,sans-serif" font-size="9" fill="#f3f4f6">${title}</text>`);
|
|
121
|
-
const positions = candidates.map((candidate) => {
|
|
122
|
-
const begin = (candidate.interval.start - sourceOffset) * unit;
|
|
123
|
-
const end = (candidate.interval.endExclusive - sourceOffset) * unit;
|
|
124
|
-
return (begin + end) / 2;
|
|
125
|
-
});
|
|
126
|
-
const labelBaselines = vertical
|
|
127
|
-
? (() => {
|
|
128
|
-
const pitch = 12;
|
|
129
|
-
const lanes = [];
|
|
130
|
-
const rightEdges = [];
|
|
131
|
-
return candidates.map((candidate, index) => {
|
|
132
|
-
const center = contentLeft + (positions[index] ?? 0);
|
|
133
|
-
const width = candidate.display.length * 7 + 6;
|
|
134
|
-
let lane = rightEdges.findIndex((right) => right + 3 <= center - width / 2);
|
|
135
|
-
if (lane < 0) {
|
|
136
|
-
lane = rightEdges.length;
|
|
137
|
-
rightEdges.push(Number.NEGATIVE_INFINITY);
|
|
138
|
-
}
|
|
139
|
-
rightEdges[lane] = center + width / 2;
|
|
140
|
-
lanes[index] = lane;
|
|
141
|
-
return contentTop - 5 - (lane + 1) * pitch;
|
|
142
|
-
});
|
|
143
|
-
})()
|
|
144
|
-
: (() => {
|
|
145
|
-
const min = outerGutter + 10;
|
|
146
|
-
const max = outputHeight - outerGutter - 3;
|
|
147
|
-
const pitch = 12;
|
|
148
|
-
const desired = positions.map((position) => contentTop + position + 4);
|
|
149
|
-
const placed = desired.map((position) => Math.max(min, Math.min(max, position)));
|
|
150
|
-
for (let index = 1; index < placed.length; index++)
|
|
151
|
-
placed[index] = Math.max(placed[index] ?? min, (placed[index - 1] ?? min) + pitch);
|
|
152
|
-
if ((placed.at(-1) ?? max) > max) {
|
|
153
|
-
placed[placed.length - 1] = max;
|
|
154
|
-
for (let index = placed.length - 2; index >= 0; index--)
|
|
155
|
-
placed[index] = Math.min(placed[index] ?? max, (placed[index + 1] ?? max) - pitch);
|
|
156
|
-
}
|
|
157
|
-
return placed;
|
|
158
|
-
})();
|
|
159
|
-
for (const [index, candidate] of candidates.entries()) {
|
|
160
|
-
const start = (candidate.interval.start - sourceOffset) * unit;
|
|
161
|
-
const end = (candidate.interval.endExclusive - sourceOffset) * unit;
|
|
162
|
-
const lo = Math.min(start, end);
|
|
163
|
-
const hi = Math.max(start, end);
|
|
164
|
-
const midpoint = (lo + hi) / 2;
|
|
165
|
-
const label = escapeXml(candidate.display);
|
|
166
|
-
if (vertical) {
|
|
167
|
-
const center = contentLeft + midpoint;
|
|
168
|
-
const halfWidth = candidate.display.length * 3.5 + 3;
|
|
169
|
-
const labelCenter = Math.max(contentLeft + halfWidth, Math.min(contentLeft + contentWidth - halfWidth, center));
|
|
170
|
-
const baseline = labelBaselines[index] ?? contentTop - 17;
|
|
171
|
-
strokes.push(`<rect x="${contentLeft + lo}" y="${contentTop}" width="${Math.max(1, hi - lo)}" height="${contentHeight}" fill="${color}" fill-opacity="0.045"/>`, `<line x1="${contentLeft + start}" y1="${contentTop}" x2="${contentLeft + start}" y2="${contentTop + contentHeight}" stroke="${color}" stroke-opacity="0.9" stroke-width="2"/>`, `<line x1="${contentLeft + end}" y1="${contentTop}" x2="${contentLeft + end}" y2="${contentTop + contentHeight}" stroke="${color}" stroke-opacity="0.9" stroke-width="2"/>`, `<path d="M ${center} ${contentTop} L ${center} ${baseline + 4} L ${labelCenter} ${baseline + 4}" fill="none" stroke="${color}" stroke-width="1"/>`, `<text x="${labelCenter}" y="${baseline}" text-anchor="middle" font-family="DejaVu Sans,sans-serif" font-size="10" font-weight="bold" fill="${color}">${label}</text>`);
|
|
172
|
-
}
|
|
173
|
-
else {
|
|
174
|
-
const midpointY = contentTop + midpoint;
|
|
175
|
-
const baseline = labelBaselines[index] ?? midpointY + 4;
|
|
176
|
-
const elbowX = contentLeft - 12 - index * 3;
|
|
177
|
-
const labelX = contentLeft - 82;
|
|
178
|
-
strokes.push(`<rect x="${contentLeft}" y="${contentTop + lo}" width="${contentWidth}" height="${Math.max(1, hi - lo)}" fill="${color}" fill-opacity="0.045"/>`, `<line x1="${contentLeft}" y1="${contentTop + start}" x2="${contentLeft + contentWidth}" y2="${contentTop + start}" stroke="${color}" stroke-opacity="0.9" stroke-width="2"/>`, `<line x1="${contentLeft}" y1="${contentTop + end}" x2="${contentLeft + contentWidth}" y2="${contentTop + end}" stroke="${color}" stroke-opacity="0.9" stroke-width="2"/>`, `<path d="M ${contentLeft} ${midpointY} L ${elbowX} ${midpointY} L ${elbowX} ${baseline} L ${labelX + 4} ${baseline}" fill="none" stroke="${color}" stroke-width="1"/>`, `<text x="${labelX}" y="${baseline}" text-anchor="end" font-family="DejaVu Sans,sans-serif" font-size="10" font-weight="bold" fill="${color}">${label}</text>`);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
const sourceBounds = `<rect x="${contentLeft}" y="${contentTop}" width="${contentWidth}" height="${contentHeight}" fill="none" stroke="#f3f4f6" stroke-opacity="0.65" stroke-width="1"/>`;
|
|
182
|
-
return Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${outputWidth}" height="${outputHeight}">${sourceBounds}${strokes.join('')}</svg>`);
|
|
183
|
-
}
|
|
184
|
-
async function render(input) {
|
|
185
|
-
assertActive(input.signal);
|
|
186
|
-
const value = validate(input);
|
|
187
|
-
assertActive(value.signal);
|
|
188
|
-
// Sharp retains its input through an asynchronous pipeline; take ownership before awaiting.
|
|
189
|
-
const ownedBytes = Buffer.from(value.source.bytes);
|
|
190
|
-
const candidates = value.candidates.map((candidate) => ({
|
|
191
|
-
id: candidate.id,
|
|
192
|
-
display: candidate.display,
|
|
193
|
-
interval: { ...candidate.interval },
|
|
194
|
-
}));
|
|
195
|
-
const { sourceRegion: region } = value;
|
|
196
|
-
const scale = Math.min(maxContentSide / Math.max(region.width, region.height), Math.max(1, 768 / Math.min(region.width, region.height)));
|
|
197
|
-
const contentWidth = Math.max(1, Math.round(region.width * scale));
|
|
198
|
-
const contentHeight = Math.max(1, Math.round(region.height * scale));
|
|
199
|
-
const vertical = value.axis === 'left' || value.axis === 'right';
|
|
200
|
-
const contentLeft = vertical ? outerGutter : axisGutter;
|
|
201
|
-
const contentTop = vertical ? axisGutter : outerGutter;
|
|
202
|
-
const outputWidth = Math.max(280, contentLeft + contentWidth + outerGutter);
|
|
203
|
-
const outputHeight = Math.max(280, contentTop + contentHeight + outerGutter);
|
|
204
|
-
if (outputWidth > maxOutputSide || outputHeight > maxOutputSide)
|
|
205
|
-
throw new ClassifierError('RESOURCE_LIMIT', 'Boundary view exceeds output side limit', {
|
|
206
|
-
component: 'boundary-view',
|
|
207
|
-
});
|
|
208
|
-
const pixels = outputWidth * outputHeight;
|
|
209
|
-
if (pixels > limits.imagePixels)
|
|
210
|
-
throw new ClassifierError('RESOURCE_LIMIT', 'Boundary view exceeds output pixel limit', {
|
|
211
|
-
component: 'boundary-view',
|
|
212
|
-
});
|
|
213
|
-
const overlay = svgOverlay(value.axis, candidates, region, contentWidth, contentHeight, contentLeft, contentTop, outputWidth, outputHeight);
|
|
214
|
-
if (overlay.byteLength > 128 * 1024)
|
|
215
|
-
throw new ClassifierError('RESOURCE_LIMIT', 'Boundary overlay exceeds byte budget', {
|
|
216
|
-
component: 'boundary-view',
|
|
217
|
-
});
|
|
218
|
-
let pipeline;
|
|
219
|
-
try {
|
|
220
|
-
assertActive(value.signal);
|
|
221
|
-
pipeline = sharp(ownedBytes, { limitInputPixels: limits.imagePixels, failOn: 'error' })
|
|
222
|
-
.extract(region)
|
|
223
|
-
.resize(contentWidth, contentHeight, { kernel: 'nearest' })
|
|
224
|
-
.extend({
|
|
225
|
-
left: contentLeft,
|
|
226
|
-
top: contentTop,
|
|
227
|
-
right: outputWidth - contentLeft - contentWidth,
|
|
228
|
-
bottom: outputHeight - contentTop - contentHeight,
|
|
229
|
-
background: { r: 17, g: 24, b: 39, alpha: 1 },
|
|
230
|
-
})
|
|
231
|
-
.composite([{ input: overlay }]);
|
|
232
|
-
const result = await pipeline.png().toBuffer();
|
|
233
|
-
assertActive(value.signal);
|
|
234
|
-
if (result.byteLength > maxRenderedBytes)
|
|
235
|
-
throw new ClassifierError('RESOURCE_LIMIT', 'Rendered boundary view exceeds byte limit', {
|
|
236
|
-
component: 'boundary-view',
|
|
237
|
-
});
|
|
238
|
-
return {
|
|
239
|
-
image: { bytes: result, width: outputWidth, height: outputHeight },
|
|
240
|
-
sourceRegion: { ...region },
|
|
241
|
-
content: { left: contentLeft, top: contentTop, width: contentWidth, height: contentHeight },
|
|
242
|
-
scaleX: contentWidth / region.width,
|
|
243
|
-
scaleY: contentHeight / region.height,
|
|
244
|
-
candidates,
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
catch (error) {
|
|
248
|
-
assertActive(value.signal);
|
|
249
|
-
if (error instanceof ClassifierError)
|
|
250
|
-
throw error;
|
|
251
|
-
throw new ClassifierError('INVALID_INPUT', 'Boundary view could not be rendered', {
|
|
252
|
-
component: 'boundary-view',
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
finally {
|
|
256
|
-
pipeline?.destroy();
|
|
257
|
-
ownedBytes.fill(0);
|
|
258
|
-
overlay.fill(0);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
export function createSharpBoundaryView() {
|
|
262
|
-
return { render };
|
|
263
|
-
}
|
|
@@ -1,317 +0,0 @@
|
|
|
1
|
-
import { isProxy } from 'node:util/types';
|
|
2
|
-
import { z } from 'zod';
|
|
3
|
-
import { ClassifierError } from '../entities/errors.js';
|
|
4
|
-
export const requestLimit = 32 * 1024 * 1024;
|
|
5
|
-
export const responseLimit = 1024 * 1024;
|
|
6
|
-
export const probability = z.number().finite().min(0).max(1);
|
|
7
|
-
export const key = z
|
|
8
|
-
.string()
|
|
9
|
-
.min(1)
|
|
10
|
-
.max(128)
|
|
11
|
-
.refine((value) => value !== '__proto__');
|
|
12
|
-
const text = z.string().min(1).max(64_000);
|
|
13
|
-
const imageSchema = z
|
|
14
|
-
.object({
|
|
15
|
-
type: z.literal('image_url'),
|
|
16
|
-
image_url: z.object({ url: z.string().max(22_369_656), detail: z.literal('high') }).strict(),
|
|
17
|
-
})
|
|
18
|
-
.strict();
|
|
19
|
-
const questionSchema = z.discriminatedUnion('type', [
|
|
20
|
-
z
|
|
21
|
-
.object({
|
|
22
|
-
type: z.literal('choice'),
|
|
23
|
-
rankingSize: z.union([z.literal(1), z.literal(4)]).default(4),
|
|
24
|
-
instructions: text,
|
|
25
|
-
criteria: z
|
|
26
|
-
.record(key, text)
|
|
27
|
-
.refine((value) => Object.keys(value).length >= 2 && Object.keys(value).length <= 255),
|
|
28
|
-
})
|
|
29
|
-
.strict(),
|
|
30
|
-
z.object({ type: z.literal('noul'), instructions: text }).strict(),
|
|
31
|
-
]);
|
|
32
|
-
export const requestSchema = z
|
|
33
|
-
.object({
|
|
34
|
-
state: z
|
|
35
|
-
.array(z.union([text, imageSchema]))
|
|
36
|
-
.min(1)
|
|
37
|
-
.max(64)
|
|
38
|
-
.refine((value) => value.filter((part) => typeof part !== 'string').length <= 9),
|
|
39
|
-
questions: z
|
|
40
|
-
.record(key, questionSchema)
|
|
41
|
-
.refine((value) => Object.keys(value).length >= 1 && Object.keys(value).length <= 16),
|
|
42
|
-
})
|
|
43
|
-
.strict();
|
|
44
|
-
const localErrors = new WeakSet();
|
|
45
|
-
export function ownedError(code, message, details = {}) {
|
|
46
|
-
const error = new ClassifierError(code, message, details);
|
|
47
|
-
localErrors.add(error);
|
|
48
|
-
return error;
|
|
49
|
-
}
|
|
50
|
-
export function invalid(code, message) {
|
|
51
|
-
return ownedError(code, message);
|
|
52
|
-
}
|
|
53
|
-
export function active(signal) {
|
|
54
|
-
if (!signal.aborted)
|
|
55
|
-
return;
|
|
56
|
-
const timeout = signal.reason instanceof ClassifierError && signal.reason.code === 'TIMEOUT';
|
|
57
|
-
throw ownedError(timeout ? 'TIMEOUT' : 'ABORTED', timeout ? 'Operation timed out' : 'Operation cancelled');
|
|
58
|
-
}
|
|
59
|
-
export function validated(schema, input, code) {
|
|
60
|
-
const parsed = schema.safeParse(input);
|
|
61
|
-
if (!parsed.success)
|
|
62
|
-
throw invalid(code, 'Decision schema validation failed');
|
|
63
|
-
return parsed.data;
|
|
64
|
-
}
|
|
65
|
-
function imageSize(bytes, format) {
|
|
66
|
-
if (format === 'png') {
|
|
67
|
-
if (bytes.length < 33 ||
|
|
68
|
-
!bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||
|
|
69
|
-
bytes.readUInt32BE(8) !== 13 ||
|
|
70
|
-
bytes.toString('ascii', 12, 16) !== 'IHDR')
|
|
71
|
-
throw invalid('INVALID_INPUT', 'Invalid PNG image header');
|
|
72
|
-
return [bytes.readUInt32BE(16), bytes.readUInt32BE(20)];
|
|
73
|
-
}
|
|
74
|
-
if (bytes[0] !== 255 || bytes[1] !== 216)
|
|
75
|
-
throw invalid('INVALID_INPUT', 'Invalid JPEG image header');
|
|
76
|
-
let offset = 2;
|
|
77
|
-
while (offset + 3 < bytes.length) {
|
|
78
|
-
if (bytes[offset++] !== 255)
|
|
79
|
-
break;
|
|
80
|
-
while (bytes[offset] === 255)
|
|
81
|
-
offset++;
|
|
82
|
-
const marker = bytes[offset++];
|
|
83
|
-
if (marker === undefined || marker === 218 || marker === 217)
|
|
84
|
-
break;
|
|
85
|
-
if (offset + 2 > bytes.length)
|
|
86
|
-
break;
|
|
87
|
-
const length = bytes.readUInt16BE(offset);
|
|
88
|
-
if (length < 2 || offset + length > bytes.length)
|
|
89
|
-
break;
|
|
90
|
-
if ([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207].includes(marker)) {
|
|
91
|
-
if (length < 8)
|
|
92
|
-
break;
|
|
93
|
-
return [bytes.readUInt16BE(offset + 5), bytes.readUInt16BE(offset + 3)];
|
|
94
|
-
}
|
|
95
|
-
offset += length;
|
|
96
|
-
}
|
|
97
|
-
throw invalid('INVALID_INPUT', 'Invalid JPEG image dimensions');
|
|
98
|
-
}
|
|
99
|
-
export function validateImage(url) {
|
|
100
|
-
const prefix = /^data:image\/(png|jpeg);base64,/u.exec(url);
|
|
101
|
-
if (!prefix)
|
|
102
|
-
throw invalid('INVALID_INPUT', 'Only inline PNG or JPEG images are supported');
|
|
103
|
-
const encoded = url.slice(prefix[0].length);
|
|
104
|
-
const padding = encoded.endsWith('==') ? 2 : Number(encoded.endsWith('='));
|
|
105
|
-
if (!encoded.length ||
|
|
106
|
-
encoded.length % 4 !== 0 ||
|
|
107
|
-
!/^[A-Za-z0-9+/]+$/u.test(encoded.slice(0, encoded.length - padding)))
|
|
108
|
-
throw invalid('INVALID_INPUT', 'Invalid image base64');
|
|
109
|
-
const bytes = Buffer.from(encoded, 'base64');
|
|
110
|
-
if (bytes.length > 16 * 1024 * 1024 || bytes.toString('base64') !== encoded)
|
|
111
|
-
throw invalid('INVALID_INPUT', 'Invalid image byte representation');
|
|
112
|
-
const [width, height] = imageSize(bytes, prefix[1] ?? '');
|
|
113
|
-
if (width < 280 || height < 280 || width > 1568 || height > 1568)
|
|
114
|
-
throw invalid('INVALID_INPUT', 'Prepared image sides must be 280..1568 pixels');
|
|
115
|
-
}
|
|
116
|
-
/** Bounded owned snapshot; schema parsing never receives caller iterators or accessors. */
|
|
117
|
-
export function preflightJson(input, limit) {
|
|
118
|
-
let remaining = limit;
|
|
119
|
-
let nodes = 0;
|
|
120
|
-
const ancestors = new Set();
|
|
121
|
-
function charge(bytes) {
|
|
122
|
-
remaining -= bytes;
|
|
123
|
-
if (remaining < 0)
|
|
124
|
-
throw ownedError('RESOURCE_LIMIT', 'Decision request exceeds byte limit');
|
|
125
|
-
}
|
|
126
|
-
function string(value) {
|
|
127
|
-
charge(2);
|
|
128
|
-
for (let index = 0; index < value.length; index++) {
|
|
129
|
-
const code = value.charCodeAt(index);
|
|
130
|
-
if (code < 32)
|
|
131
|
-
charge([8, 9, 10, 12, 13].includes(code) ? 2 : 6);
|
|
132
|
-
else if (code === 34 || code === 92)
|
|
133
|
-
charge(2);
|
|
134
|
-
else if (code < 128)
|
|
135
|
-
charge(1);
|
|
136
|
-
else if (code < 2048)
|
|
137
|
-
charge(2);
|
|
138
|
-
else if (code >= 0xd800 && code <= 0xdbff) {
|
|
139
|
-
const next = value.charCodeAt(index + 1);
|
|
140
|
-
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
141
|
-
charge(4);
|
|
142
|
-
index++;
|
|
143
|
-
}
|
|
144
|
-
else
|
|
145
|
-
charge(6);
|
|
146
|
-
}
|
|
147
|
-
else
|
|
148
|
-
charge(code >= 0xdc00 && code <= 0xdfff ? 6 : 3);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
function visit(value, depth) {
|
|
152
|
-
if (++nodes > 10_000 || depth > 6)
|
|
153
|
-
throw invalid('INVALID_INPUT', 'Decision structure exceeds supported limits');
|
|
154
|
-
if (typeof value === 'string') {
|
|
155
|
-
string(value);
|
|
156
|
-
return value;
|
|
157
|
-
}
|
|
158
|
-
if (value === null || typeof value === 'boolean') {
|
|
159
|
-
charge(value === false ? 5 : 4);
|
|
160
|
-
return value;
|
|
161
|
-
}
|
|
162
|
-
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
163
|
-
charge(String(value).length);
|
|
164
|
-
return value;
|
|
165
|
-
}
|
|
166
|
-
if (typeof value !== 'object' || value === null)
|
|
167
|
-
throw invalid('INVALID_INPUT', 'Unsupported decision value');
|
|
168
|
-
if (isProxy(value))
|
|
169
|
-
throw invalid('INVALID_INPUT', 'Decision input must contain plain values');
|
|
170
|
-
// Zod reads declared object fields even when they are not enumerable; JSON does not.
|
|
171
|
-
for (const name of [
|
|
172
|
-
'state',
|
|
173
|
-
'questions',
|
|
174
|
-
'type',
|
|
175
|
-
'image_url',
|
|
176
|
-
'url',
|
|
177
|
-
'detail',
|
|
178
|
-
'instructions',
|
|
179
|
-
'criteria',
|
|
180
|
-
]) {
|
|
181
|
-
const descriptor = Object.getOwnPropertyDescriptor(value, name);
|
|
182
|
-
if (descriptor && !descriptor.enumerable)
|
|
183
|
-
throw invalid('INVALID_INPUT', 'Decision fields must be enumerable');
|
|
184
|
-
}
|
|
185
|
-
if (ancestors.has(value))
|
|
186
|
-
throw invalid('INVALID_INPUT', 'Circular decision structure');
|
|
187
|
-
ancestors.add(value);
|
|
188
|
-
charge(2);
|
|
189
|
-
if (Array.isArray(value)) {
|
|
190
|
-
const copy = [];
|
|
191
|
-
if (value.length > 64)
|
|
192
|
-
throw invalid('INVALID_INPUT', 'Decision array exceeds supported limits');
|
|
193
|
-
for (let index = 0; index < value.length; index++) {
|
|
194
|
-
if (index)
|
|
195
|
-
charge(1);
|
|
196
|
-
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
197
|
-
if (!descriptor || !Object.hasOwn(descriptor, 'value'))
|
|
198
|
-
throw invalid('INVALID_INPUT', 'Decision input must contain plain values');
|
|
199
|
-
copy.push(visit(descriptor.value, depth + 1));
|
|
200
|
-
}
|
|
201
|
-
ancestors.delete(value);
|
|
202
|
-
return copy;
|
|
203
|
-
}
|
|
204
|
-
else {
|
|
205
|
-
const copy = {};
|
|
206
|
-
const prototype = Object.getPrototypeOf(value);
|
|
207
|
-
if (prototype !== Object.prototype && prototype !== null)
|
|
208
|
-
throw invalid('INVALID_INPUT', 'Decision input must contain plain objects');
|
|
209
|
-
let count = 0;
|
|
210
|
-
for (const name in value) {
|
|
211
|
-
if (!Object.hasOwn(value, name))
|
|
212
|
-
continue;
|
|
213
|
-
if (++count > 255)
|
|
214
|
-
throw invalid('INVALID_INPUT', 'Decision object exceeds supported limits');
|
|
215
|
-
if (count > 1)
|
|
216
|
-
charge(1);
|
|
217
|
-
string(name);
|
|
218
|
-
charge(1);
|
|
219
|
-
const descriptor = Object.getOwnPropertyDescriptor(value, name);
|
|
220
|
-
if (!descriptor || !Object.hasOwn(descriptor, 'value'))
|
|
221
|
-
throw invalid('INVALID_INPUT', 'Decision input must contain plain values');
|
|
222
|
-
Object.defineProperty(copy, name, {
|
|
223
|
-
value: visit(descriptor.value, depth + 1),
|
|
224
|
-
enumerable: true,
|
|
225
|
-
configurable: true,
|
|
226
|
-
writable: true,
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
ancestors.delete(value);
|
|
230
|
-
return copy;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
return visit(input, 0);
|
|
234
|
-
}
|
|
235
|
-
function statusError(status) {
|
|
236
|
-
const code = status === 401 || status === 403
|
|
237
|
-
? 'AUTHENTICATION'
|
|
238
|
-
: status === 413
|
|
239
|
-
? 'RESOURCE_LIMIT'
|
|
240
|
-
: status === 415
|
|
241
|
-
? 'UNSUPPORTED'
|
|
242
|
-
: 'PROVIDER';
|
|
243
|
-
return ownedError(code, 'Model provider rejected the request', { status });
|
|
244
|
-
}
|
|
245
|
-
export function failure(error, signal, cleanupFailed = false) {
|
|
246
|
-
const known = error instanceof ClassifierError && localErrors.has(error);
|
|
247
|
-
const incompleteCleanup = cleanupFailed || (known && error.details.cleanupFailed === true);
|
|
248
|
-
try {
|
|
249
|
-
active(signal);
|
|
250
|
-
}
|
|
251
|
-
catch (aborted) {
|
|
252
|
-
if (aborted instanceof ClassifierError)
|
|
253
|
-
return ownedError(aborted.code, aborted.message, incompleteCleanup ? { cleanupFailed: true } : {});
|
|
254
|
-
}
|
|
255
|
-
return ownedError(known ? error.code : 'PROVIDER', known ? error.message : 'Model provider transport failed', { ...(known ? error.details : {}), ...(incompleteCleanup ? { cleanupFailed: true } : {}) });
|
|
256
|
-
}
|
|
257
|
-
export async function readResponse(response, signal, limit) {
|
|
258
|
-
const reader = response.body?.getReader();
|
|
259
|
-
if (!reader) {
|
|
260
|
-
active(signal);
|
|
261
|
-
if (!response.ok)
|
|
262
|
-
throw statusError(response.status);
|
|
263
|
-
throw invalid('INVALID_RESPONSE', 'Provider response has no body');
|
|
264
|
-
}
|
|
265
|
-
let cancelTask;
|
|
266
|
-
function cancel() {
|
|
267
|
-
cancelTask ??= reader.cancel().then(() => true, () => false);
|
|
268
|
-
}
|
|
269
|
-
signal.addEventListener('abort', cancel, { once: true });
|
|
270
|
-
let caught;
|
|
271
|
-
let output = '';
|
|
272
|
-
let complete = false;
|
|
273
|
-
try {
|
|
274
|
-
active(signal);
|
|
275
|
-
if (!response.ok)
|
|
276
|
-
throw statusError(response.status);
|
|
277
|
-
const length = response.headers.get('content-length');
|
|
278
|
-
if (length !== null && (!/^\d+$/u.test(length) || Number(length) > limit))
|
|
279
|
-
throw ownedError('RESOURCE_LIMIT', 'Provider response exceeds byte limit');
|
|
280
|
-
const storage = Buffer.allocUnsafe(limit);
|
|
281
|
-
let bytes = 0;
|
|
282
|
-
while (true) {
|
|
283
|
-
active(signal);
|
|
284
|
-
const next = await reader.read();
|
|
285
|
-
active(signal);
|
|
286
|
-
if (next.done)
|
|
287
|
-
break;
|
|
288
|
-
if (next.value.byteLength > limit - bytes)
|
|
289
|
-
throw ownedError('RESOURCE_LIMIT', 'Provider response exceeds byte limit');
|
|
290
|
-
storage.set(next.value, bytes);
|
|
291
|
-
bytes += next.value.byteLength;
|
|
292
|
-
}
|
|
293
|
-
complete = true;
|
|
294
|
-
try {
|
|
295
|
-
output = new TextDecoder('utf-8', { fatal: true }).decode(storage.subarray(0, bytes));
|
|
296
|
-
}
|
|
297
|
-
catch {
|
|
298
|
-
throw invalid('INVALID_RESPONSE', 'Provider returned invalid UTF-8');
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
catch (error) {
|
|
302
|
-
caught = error;
|
|
303
|
-
}
|
|
304
|
-
finally {
|
|
305
|
-
signal.removeEventListener('abort', cancel);
|
|
306
|
-
if (!complete)
|
|
307
|
-
cancel();
|
|
308
|
-
const released = cancelTask ? await cancelTask : true;
|
|
309
|
-
reader.releaseLock();
|
|
310
|
-
if (!released)
|
|
311
|
-
caught = failure(caught, signal, true);
|
|
312
|
-
}
|
|
313
|
-
if (caught !== undefined)
|
|
314
|
-
throw failure(caught, signal);
|
|
315
|
-
active(signal);
|
|
316
|
-
return output;
|
|
317
|
-
}
|