@arcforgelabs/flow 2026.10.50 → 2026.10.52
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/SECURITY.md +9 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/captures.js +131 -0
- package/server/api/invoicing.js +1 -1
- package/server/app.js +15 -0
- package/server/auth/people.js +3 -3
- package/server/core/ai-bedrock.js +124 -0
- package/server/core/ai.js +145 -0
- package/server/core/capture-match.js +158 -0
- package/server/core/captures.js +501 -0
- package/server/core/invoice-rules.js +16 -5
- package/server/core/reader.js +324 -0
- package/server/core/sending.js +7 -3
- package/server/core/storage.js +491 -0
- package/server/db/migrations/0023_captures.sql +46 -0
- package/server/db/migrations/0024_rule_icon.sql +5 -0
- package/server/db/migrations/meta/0023_snapshot.json +4693 -0
- package/server/db/migrations/meta/0024_snapshot.json +4703 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +50 -0
- package/server/index.js +47 -1
- package/server/integrations/seazona/history/backfill.js +39 -9
- package/server/integrations/seazona/index.js +1 -12
- package/server/integrations/seazona/stage-moves.js +3 -13
- package/server/integrations/sentry/index.js +6 -6
- package/server/integrations/setup.js +1 -0
- package/server/integrations/xero/drafts.js +27 -13
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +101 -8
- package/server/ui/capture-fields.js +17 -0
- package/server/ui/captures.js +283 -0
- package/server/ui/columns.js +68 -3
- package/server/ui/flow.css +92 -17
- package/server/ui/invoice-rules.js +10 -2
- package/server/ui/invoicing-settings.js +2 -8
- package/server/ui/invoicing.js +12 -8
- package/server/ui/job-view.js +6 -3
- package/server/ui/jobs.js +40 -117
- package/server/ui/kit.js +86 -1
- package/server/ui/list-views.js +47 -3
- package/server/ui/mobile.js +45 -5
- package/server/ui/nav-pins.js +4 -2
- package/server/ui/price-list.js +138 -69
- package/server/ui/routes.js +4 -1
- package/server/ui/saved-views.js +2 -0
- package/server/ui/view-bar.js +61 -2
package/SECURITY.md
CHANGED
|
@@ -33,8 +33,9 @@ paste a credential, in full or in part.
|
|
|
33
33
|
organisation, encrypted, and are write-only in Settings.
|
|
34
34
|
- **Records and files.** Every record is in the installation's PostgreSQL.
|
|
35
35
|
Files reach the core only through the object storage port; PostgreSQL
|
|
36
|
-
holds each file's key, owner, size and hash
|
|
37
|
-
|
|
36
|
+
holds each file's key, owner, size and hash. When Flow serves a file it
|
|
37
|
+
checks the hash first. Download links are signed. An S3 link is a presigned
|
|
38
|
+
URL the store itself honours.
|
|
38
39
|
- **Issued documents are records.** An invoice a customer has been sent is
|
|
39
40
|
never changed in place; a change produces a new version.
|
|
40
41
|
|
|
@@ -55,8 +56,12 @@ paste a credential, in full or in part.
|
|
|
55
56
|
minute, `/api` 120, and the Clerk proxy 300. A client over the limit gets
|
|
56
57
|
`429` with `Retry-After`.
|
|
57
58
|
- Session cookies are `Secure`, `HttpOnly` and `SameSite`.
|
|
58
|
-
- Flow stores no clinical notes
|
|
59
|
-
|
|
59
|
+
- Flow stores no clinical notes or scans, and keeps a patient reference only
|
|
60
|
+
where an invoice must print one. A form photo is kept only until someone
|
|
61
|
+
decides the capture, or for 7 days when nobody does, and then the bytes are
|
|
62
|
+
deleted. The reader sends that photo only to Amazon Bedrock in Sydney or
|
|
63
|
+
Melbourne, using an `au.` inference profile. Logs and error reports get the
|
|
64
|
+
capture id and an error code, never the photo or what was read or typed.
|
|
60
65
|
- Object keys and file names carry a case number at most, never a patient
|
|
61
66
|
name or other patient data.
|
|
62
67
|
- CI runs gitleaks on every push and pull request
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.52",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.10.
|
|
9
|
+
"version": "2026.10.52",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import {
|
|
3
|
+
MAX_PHOTO_BYTES, captureView, decideCapture, listCaptures, matchCapture, readCapture, readCapturePhoto, typeProposal, uploadCapture,
|
|
4
|
+
} from '../core/captures.js';
|
|
5
|
+
import { FlowError } from '../core/errors.js';
|
|
6
|
+
import { resolveOrganisation } from './invoicing.js';
|
|
7
|
+
|
|
8
|
+
// Photo capture (server/core/captures.js). A signed-in person uploads one
|
|
9
|
+
// photo. The reader, when it is on, reads it after the response. When it is
|
|
10
|
+
// off, the capture is a problem a person can type. A read capture is matched
|
|
11
|
+
// to a job on the Jobs board the first time it is opened, and again when a
|
|
12
|
+
// person types the fields. Logs from this router are
|
|
13
|
+
// a capture id and an error code, never the photo or the fields.
|
|
14
|
+
|
|
15
|
+
const ERROR_STATUS = {
|
|
16
|
+
not_found: 404,
|
|
17
|
+
conflict: 409,
|
|
18
|
+
organisation_required: 409,
|
|
19
|
+
unsupported_media: 415,
|
|
20
|
+
photo_too_large: 413,
|
|
21
|
+
link_invalid: 403,
|
|
22
|
+
link_expired: 403,
|
|
23
|
+
storage_unavailable: 503,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function respond(res, error) {
|
|
27
|
+
return res.status(ERROR_STATUS[error.code] ?? 400).json({ ok: false, code: error.code, error: error.message });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// `jobsFor(organisationId)` answers the Jobs board's jobs for the matcher.
|
|
31
|
+
export function createCapturesRouter({
|
|
32
|
+
db, storage, personAuth, log = console, publicOrigin = null, reader = null, jobsFor = async () => [],
|
|
33
|
+
}) {
|
|
34
|
+
const router = express.Router();
|
|
35
|
+
const signedIn = [personAuth.requireSession];
|
|
36
|
+
|
|
37
|
+
const handle = (fn) => async (req, res) => {
|
|
38
|
+
try {
|
|
39
|
+
res.set('Cache-Control', 'no-store');
|
|
40
|
+
await fn(req, res);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (!(error instanceof FlowError)) {
|
|
43
|
+
log.error?.('[captures] - error');
|
|
44
|
+
return res.status(500).json({ ok: false, code: 'internal', error: 'Something went wrong.' });
|
|
45
|
+
}
|
|
46
|
+
if (error.code === 'hash_mismatch' || error.code === 'storage_unavailable') {
|
|
47
|
+
log.error?.(`[captures] ${error.captureId ?? '-'} ${error.code}`);
|
|
48
|
+
}
|
|
49
|
+
if (error.code === 'hash_mismatch') {
|
|
50
|
+
return res.status(404).json({ ok: false, code: 'not_found', error: 'photo not found' });
|
|
51
|
+
}
|
|
52
|
+
return respond(res, error);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const origin = (req) => {
|
|
57
|
+
const configured = String(publicOrigin ?? '').trim().replace(/\/$/, '');
|
|
58
|
+
if (configured) return configured;
|
|
59
|
+
return `${req.protocol}://${req.get('host')}`;
|
|
60
|
+
};
|
|
61
|
+
const organisationOf = (req) => resolveOrganisation(db, req.body?.organisationId ?? req.query.organisationId);
|
|
62
|
+
|
|
63
|
+
router.get('/file', handle(async (req, res) => {
|
|
64
|
+
const photo = await readCapturePhoto(db, storage, req.query.token);
|
|
65
|
+
res.set('Cache-Control', 'private, no-store');
|
|
66
|
+
res.set('X-Content-Type-Options', 'nosniff');
|
|
67
|
+
res.set('Content-Type', photo.contentType);
|
|
68
|
+
res.end(photo.body);
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
router.post('/', ...signedIn, express.raw({ type: () => true, limit: MAX_PHOTO_BYTES }), handle(async (req, res) => {
|
|
72
|
+
const organisationId = await organisationOf(req);
|
|
73
|
+
const result = await uploadCapture(db, storage, organisationId, {
|
|
74
|
+
bytes: typeof req.body === 'string' || Array.isArray(req.body) || !Buffer.isBuffer(req.body) ? Buffer.alloc(0) : req.body,
|
|
75
|
+
contentType: req.get('content-type'),
|
|
76
|
+
idempotencyKey: req.get('idempotency-key'),
|
|
77
|
+
source: req.get('x-flow-capture-source') ?? req.query.source,
|
|
78
|
+
personId: req.person.id,
|
|
79
|
+
baseUrl: origin(req),
|
|
80
|
+
reader,
|
|
81
|
+
});
|
|
82
|
+
res.status(result.created ? 201 : 200).json({ ok: true, capture: captureView(result.capture, result.link) });
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
router.use(express.json({ limit: '32kb' }));
|
|
86
|
+
|
|
87
|
+
router.get('/', ...signedIn, handle(async (req, res) => {
|
|
88
|
+
const rows = await listCaptures(db, await organisationOf(req));
|
|
89
|
+
res.json({ ok: true, captures: rows.map((row) => captureView(row)) });
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
router.get('/:id', ...signedIn, handle(async (req, res) => {
|
|
93
|
+
const organisationId = await organisationOf(req);
|
|
94
|
+
let result = await readCapture(db, storage, organisationId, req.params.id, origin(req));
|
|
95
|
+
if (result.capture.state === 'ready' && result.capture.read && !result.capture.match) {
|
|
96
|
+
result = await matchCapture(db, storage, organisationId, req.params.id, { jobs: await jobsFor(organisationId), baseUrl: origin(req) });
|
|
97
|
+
}
|
|
98
|
+
res.json({ ok: true, capture: captureView(result.capture, result.link) });
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
router.post('/:id/match', ...signedIn, handle(async (req, res) => {
|
|
102
|
+
const organisationId = await organisationOf(req);
|
|
103
|
+
const result = await matchCapture(db, storage, organisationId, req.params.id, {
|
|
104
|
+
jobs: await jobsFor(organisationId), fields: req.body?.fields ?? null, baseUrl: origin(req),
|
|
105
|
+
});
|
|
106
|
+
res.json({ ok: true, capture: captureView(result.capture, result.link) });
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
router.post('/:id/proposal', ...signedIn, handle(async (req, res) => {
|
|
110
|
+
const result = await typeProposal(db, storage, await organisationOf(req), req.params.id, req.body, { baseUrl: origin(req) });
|
|
111
|
+
res.json({ ok: true, capture: captureView(result.capture, result.link) });
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
router.post('/:id/decision', ...signedIn, handle(async (req, res) => {
|
|
115
|
+
const result = await decideCapture(db, storage, await organisationOf(req), req.params.id, req.body?.decision, {
|
|
116
|
+
personId: req.person.id,
|
|
117
|
+
baseUrl: origin(req),
|
|
118
|
+
});
|
|
119
|
+
res.json({ ok: true, capture: captureView(result.capture, result.link) });
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
router.use((error, _req, res, next) => {
|
|
123
|
+
if (error?.type === 'entity.too.large' || error?.status === 413) {
|
|
124
|
+
return respond(res, new FlowError('photo_too_large', 'photo is too large'));
|
|
125
|
+
}
|
|
126
|
+
if (error instanceof FlowError) return respond(res, error);
|
|
127
|
+
return next(error);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return router;
|
|
131
|
+
}
|
package/server/api/invoicing.js
CHANGED
|
@@ -74,7 +74,7 @@ import {
|
|
|
74
74
|
// PUT /settings owner or admin: change some of them
|
|
75
75
|
// GET /rules the lab's invoice rules, in order (server/core/invoice-rules.js)
|
|
76
76
|
// GET /rule-fields what a rule can name, and the menus of values
|
|
77
|
-
// POST /rules owner or admin: a new rule { name, enabled, conditions, action }
|
|
77
|
+
// POST /rules owner or admin: a new rule { name, icon, enabled, conditions, action }
|
|
78
78
|
// PUT /rules/:id owner or admin: change some of a rule
|
|
79
79
|
// DELETE /rules/:id owner or admin
|
|
80
80
|
// GET /line-mappings the lab's line mappings and the mapping steps (server/core/line-mapping.js)
|
package/server/app.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import { createAlertsRouter } from './api/alerts.js';
|
|
3
|
+
import { createCapturesRouter } from './api/captures.js';
|
|
3
4
|
import { createCustomFieldsRouter } from './api/custom-fields.js';
|
|
4
5
|
import { createWorkMovesRouter } from './api/work-moves.js';
|
|
5
6
|
import { createConnectorApi } from './api/connector.js';
|
|
6
7
|
import { createInvoicingRouter } from './api/invoicing.js';
|
|
7
8
|
import { createSignInRouter } from './auth/routes.js';
|
|
8
9
|
import { integrationAlertsSource, invoiceProblemsSource, priceDifferencesSource } from './core/alerts.js';
|
|
10
|
+
import { boardJobs } from './core/job-board.js';
|
|
9
11
|
import { reconcileHealth } from './core/reconcile.js';
|
|
10
12
|
import { appliedMigrationCounts } from './db/doctor.js';
|
|
11
13
|
import { deployedBuildStatus } from './deployed-build.js';
|
|
@@ -59,6 +61,13 @@ export function createApp({
|
|
|
59
61
|
buttonCooldowns = true,
|
|
60
62
|
log = console,
|
|
61
63
|
tracing = null,
|
|
64
|
+
// Object storage for capture photos (server/core/storage.js). Null leaves
|
|
65
|
+
// the capture routes off.
|
|
66
|
+
storage = null,
|
|
67
|
+
publicOrigin = null,
|
|
68
|
+
// The vision reader (server/core/reader.js). Null is off: a capture is a
|
|
69
|
+
// problem a person can type, and no model is called.
|
|
70
|
+
reader = null,
|
|
62
71
|
} = {}) {
|
|
63
72
|
const app = express();
|
|
64
73
|
app.disable('x-powered-by');
|
|
@@ -147,6 +156,12 @@ export function createApp({
|
|
|
147
156
|
// items, Seazona products).
|
|
148
157
|
const priceSystems = () => integrations?.with('listPriceItems') ?? [];
|
|
149
158
|
app.use('/api/invoicing', createInvoicingRouter({ db: database.db, personAuth: auth.personAuth, log, liveCheckFor, accounting, work, priceSystems, buttonCooldowns }));
|
|
159
|
+
if (storage) {
|
|
160
|
+
app.use('/api/captures', createCapturesRouter({
|
|
161
|
+
db: database.db, storage, personAuth: auth.personAuth, log, publicOrigin, reader,
|
|
162
|
+
jobsFor: async (organisationId) => (await boardJobs(database.db, organisationId, { work: work(), log })).jobs,
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
150
165
|
// Custom fields for jobs (server/core/custom-fields.js): kept in Flow
|
|
151
166
|
// only; the work systems' statuses show beside them, read only.
|
|
152
167
|
app.use('/api/custom-fields', createCustomFieldsRouter({ db: database.db, personAuth: auth.personAuth, work, log }));
|
package/server/auth/people.js
CHANGED
|
@@ -2,8 +2,8 @@ import { and, asc, eq, isNull, ne, sql } from 'drizzle-orm';
|
|
|
2
2
|
import { people, PERSON_ROLES } from '../db/schema.js';
|
|
3
3
|
import { FlowError } from '../core/errors.js';
|
|
4
4
|
import { isThemeFamily, normalizeAccent } from '../theme/themes.js';
|
|
5
|
-
import { invoiceColumnsView, normaliseJobsView, normaliseListView,
|
|
6
|
-
import { normaliseSavedViews, SAVED_VIEW_PAGES } from '../ui/list-views.js';
|
|
5
|
+
import { invoiceColumnsView, normaliseJobsView, normaliseListView, RECONCILE_COLUMNS } from '../ui/job-view.js';
|
|
6
|
+
import { normalisePriceView, normaliseSavedViews, SAVED_VIEW_PAGES } from '../ui/list-views.js';
|
|
7
7
|
import { normaliseNavPins } from '../ui/nav-pins.js';
|
|
8
8
|
import { isPlainEmail } from './settings.js';
|
|
9
9
|
|
|
@@ -61,7 +61,7 @@ function viewCheck(page) {
|
|
|
61
61
|
case 'jobs': return normaliseJobsView;
|
|
62
62
|
case 'invoices': return invoiceColumnsView;
|
|
63
63
|
case 'reconcile': return (view) => normaliseListView(view, RECONCILE_COLUMNS);
|
|
64
|
-
case 'price-list': return
|
|
64
|
+
case 'price-list': return normalisePriceView;
|
|
65
65
|
case 'nav': return normaliseNavPins;
|
|
66
66
|
default: return null;
|
|
67
67
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { auProfile, READER_REGIONS } from './ai.js';
|
|
3
|
+
import { FlowError } from './errors.js';
|
|
4
|
+
import { authorizationHeader } from './storage.js';
|
|
5
|
+
|
|
6
|
+
// Amazon Bedrock, Converse API, Australia only. The endpoint region is
|
|
7
|
+
// Sydney or Melbourne and the model id is an au. inference profile, so a
|
|
8
|
+
// call is not sent on a global or US profile. Credentials come from the
|
|
9
|
+
// caller (the host secret store). Nothing here is logged: a failure keeps
|
|
10
|
+
// the response body out of the error.
|
|
11
|
+
|
|
12
|
+
const IMAGE_FORMAT = Object.freeze({
|
|
13
|
+
'image/jpeg': 'jpeg',
|
|
14
|
+
'image/png': 'png',
|
|
15
|
+
'image/webp': 'webp',
|
|
16
|
+
'image/gif': 'gif',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function sha256Hex(body) {
|
|
20
|
+
return createHash('sha256').update(body).digest('hex');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function amzDate(date) {
|
|
24
|
+
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fail(code, { retry }) {
|
|
28
|
+
const error = new FlowError(code, code === 'reader_unavailable' ? 'the reader did not answer' : 'could not read the form');
|
|
29
|
+
error.retry = retry;
|
|
30
|
+
return error;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function drop(res) {
|
|
34
|
+
await res.arrayBuffer?.().catch(() => {});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The text blocks only. A thinking block is not the form, and it is not returned.
|
|
38
|
+
function textOf(parsed) {
|
|
39
|
+
const blocks = parsed?.output?.message?.content;
|
|
40
|
+
if (!Array.isArray(blocks)) return '';
|
|
41
|
+
return blocks.filter((block) => typeof block?.text === 'string').map((block) => block.text).join('');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createBedrockClient({
|
|
45
|
+
region, accessKeyId, secretAccessKey, fetchImpl = globalThis.fetch, now = () => new Date(), timeoutMs = 120_000,
|
|
46
|
+
}) {
|
|
47
|
+
if (!READER_REGIONS.includes(region)) throw new FlowError('invalid', 'reader region must be Sydney or Melbourne');
|
|
48
|
+
if (!accessKeyId || !secretAccessKey) throw new FlowError('invalid', 'reader credentials are required');
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
async readImage({ model, mediaType, bytes, instruction, schema, maxTokens = 4096 }) {
|
|
52
|
+
if (!auProfile(model) || !IMAGE_FORMAT[mediaType] || !Buffer.isBuffer(bytes)) throw fail('reader_rejected', { retry: false });
|
|
53
|
+
const payload = JSON.stringify({
|
|
54
|
+
system: [{ text: 'You read one handwritten lab form. You never guess a value.' }],
|
|
55
|
+
messages: [{
|
|
56
|
+
role: 'user',
|
|
57
|
+
content: [
|
|
58
|
+
{ image: { format: IMAGE_FORMAT[mediaType], source: { bytes: bytes.toString('base64') } } },
|
|
59
|
+
{ text: String(instruction ?? '') },
|
|
60
|
+
],
|
|
61
|
+
}],
|
|
62
|
+
inferenceConfig: { maxTokens },
|
|
63
|
+
// Effort low keeps the read short. Thinking stays on its default:
|
|
64
|
+
// Haiku 5.5 and Sonnet 5.5 do not share one way to turn it off.
|
|
65
|
+
additionalModelRequestFields: { output_config: { effort: 'low' } },
|
|
66
|
+
outputConfig: {
|
|
67
|
+
textFormat: {
|
|
68
|
+
type: 'json_schema',
|
|
69
|
+
structure: {
|
|
70
|
+
jsonSchema: {
|
|
71
|
+
name: 'lab_form',
|
|
72
|
+
description: 'Fields read from one lab form',
|
|
73
|
+
schema: JSON.stringify(schema),
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
const at = now();
|
|
80
|
+
const host = `bedrock-runtime.${region}.amazonaws.com`;
|
|
81
|
+
const path = `/model/${encodeURIComponent(model)}/converse`;
|
|
82
|
+
const payloadHash = sha256Hex(payload);
|
|
83
|
+
const headers = {
|
|
84
|
+
host,
|
|
85
|
+
'content-type': 'application/json',
|
|
86
|
+
'x-amz-content-sha256': payloadHash,
|
|
87
|
+
'x-amz-date': amzDate(at),
|
|
88
|
+
};
|
|
89
|
+
const { header } = authorizationHeader({
|
|
90
|
+
method: 'POST', path, headers, payloadHash, accessKeyId, secretAccessKey, region, service: 'bedrock', now: at,
|
|
91
|
+
});
|
|
92
|
+
const sent = { ...headers, authorization: header };
|
|
93
|
+
delete sent.host;
|
|
94
|
+
let res;
|
|
95
|
+
try {
|
|
96
|
+
res = await fetchImpl(`https://${host}${path}`, {
|
|
97
|
+
method: 'POST', headers: sent, body: payload, redirect: 'error', signal: AbortSignal.timeout(timeoutMs),
|
|
98
|
+
});
|
|
99
|
+
} catch {
|
|
100
|
+
throw fail('reader_unavailable', { retry: true });
|
|
101
|
+
}
|
|
102
|
+
if (!res?.ok) {
|
|
103
|
+
if (res) await drop(res);
|
|
104
|
+
const status = res?.status ?? 0;
|
|
105
|
+
throw fail(status === 429 || status >= 500 ? 'reader_unavailable' : 'reader_rejected', {
|
|
106
|
+
retry: status === 429 || status >= 500,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
let parsed;
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(Buffer.from(await res.arrayBuffer()).toString('utf8'));
|
|
112
|
+
} catch {
|
|
113
|
+
throw fail('reader_unreadable', { retry: true });
|
|
114
|
+
}
|
|
115
|
+
const text = textOf(parsed);
|
|
116
|
+
if (!text || parsed?.stopReason === 'max_tokens') throw fail('reader_unreadable', { retry: true });
|
|
117
|
+
return {
|
|
118
|
+
text,
|
|
119
|
+
inputTokens: parsed?.usage?.inputTokens ?? 0,
|
|
120
|
+
outputTokens: parsed?.usage?.outputTokens ?? 0,
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { FlowError } from './errors.js';
|
|
2
|
+
import { SURE_LINE } from '../ui/capture-fields.js';
|
|
3
|
+
|
|
4
|
+
// The AI port. One call reads one image and returns text. The vision reader
|
|
5
|
+
// (server/core/reader.js) is the caller. A client implements readImage; this
|
|
6
|
+
// file does not name a vendor.
|
|
7
|
+
//
|
|
8
|
+
// The model id and the region come from the environment (resolveReader).
|
|
9
|
+
// A model id that is not an Australia inference profile (au.) is refused
|
|
10
|
+
// before any call. An error that leaves this port has a fixed message, so a
|
|
11
|
+
// log never receives the photo or the model's answer.
|
|
12
|
+
|
|
13
|
+
export const READER_REGIONS = Object.freeze(['ap-southeast-2', 'ap-southeast-4']);
|
|
14
|
+
|
|
15
|
+
// USD micros per million tokens for the Australia profile: Anthropic's list
|
|
16
|
+
// price plus 10% (October 2026). Haiku 5.5 uses the long rate when the
|
|
17
|
+
// prompt is over 100,000 tokens. An operator sets FLOW_READER_*_MICROS_PER_MILLION
|
|
18
|
+
// when the model id is not listed here, or when the price changes.
|
|
19
|
+
const AU_MICROS = Object.freeze({
|
|
20
|
+
'au.anthropic.claude-haiku-5-5': Object.freeze({
|
|
21
|
+
input: 110_000, output: 550_000, longInput: 550_000, longOutput: 2_750_000, longFrom: 100_000,
|
|
22
|
+
}),
|
|
23
|
+
'au.anthropic.claude-sonnet-5-5': Object.freeze({ input: 2_200_000, output: 11_000_000 }),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const PROFILE = /^au\.[a-z0-9][a-z0-9._:-]{0,127}$/;
|
|
27
|
+
|
|
28
|
+
export function auProfile(id) {
|
|
29
|
+
return typeof id === 'string' && PROFILE.test(id) && !id.includes('..');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Integer micros for one call. Null when this model has no price: the tokens
|
|
33
|
+
// are still recorded, and the cost is not a guess.
|
|
34
|
+
export function callCostMicros({ inputTokens, outputTokens, price }) {
|
|
35
|
+
if (!price || !Number.isInteger(inputTokens) || !Number.isInteger(outputTokens)) return null;
|
|
36
|
+
if (inputTokens < 0 || outputTokens < 0) return null;
|
|
37
|
+
const long = Number.isInteger(price.longFrom) && inputTokens > price.longFrom;
|
|
38
|
+
const input = long ? price.longInput : price.input;
|
|
39
|
+
const output = long ? price.longOutput : price.output;
|
|
40
|
+
if (!Number.isInteger(input) || !Number.isInteger(output)) return null;
|
|
41
|
+
return Math.round((inputTokens * input + outputTokens * output) / 1_000_000);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const MESSAGES = Object.freeze({
|
|
45
|
+
reader_unavailable: 'the reader did not answer',
|
|
46
|
+
reader_rejected: 'could not read the form',
|
|
47
|
+
reader_unreadable: 'could not read the form',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function clean(error) {
|
|
51
|
+
const code = MESSAGES[error?.code] ? error.code : 'reader_unavailable';
|
|
52
|
+
const next = new FlowError(code, MESSAGES[code]);
|
|
53
|
+
next.retry = error?.retry !== false && code !== 'reader_rejected';
|
|
54
|
+
return next;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createAiPort(client) {
|
|
58
|
+
if (typeof client?.readImage !== 'function') throw new TypeError('an AI port needs readImage');
|
|
59
|
+
return {
|
|
60
|
+
async readImage(request) {
|
|
61
|
+
if (!auProfile(request?.model) || !Buffer.isBuffer(request?.bytes) || request.bytes.length === 0) {
|
|
62
|
+
const error = new FlowError('reader_rejected', MESSAGES.reader_rejected);
|
|
63
|
+
error.retry = false;
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const result = await client.readImage(request);
|
|
68
|
+
const inputTokens = Number(result?.inputTokens);
|
|
69
|
+
const outputTokens = Number(result?.outputTokens);
|
|
70
|
+
return {
|
|
71
|
+
model: request.model,
|
|
72
|
+
text: typeof result?.text === 'string' ? result.text : '',
|
|
73
|
+
inputTokens: Number.isInteger(inputTokens) && inputTokens >= 0 ? inputTokens : 0,
|
|
74
|
+
outputTokens: Number.isInteger(outputTokens) && outputTokens >= 0 ? outputTokens : 0,
|
|
75
|
+
};
|
|
76
|
+
} catch (error) {
|
|
77
|
+
throw clean(error);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readMicros(raw, name) {
|
|
84
|
+
if (raw == null || String(raw).trim() === '') return null;
|
|
85
|
+
const value = Number(String(raw).trim());
|
|
86
|
+
if (!Number.isInteger(value) || value < 0) throw new Error(`${name} must be a whole number of micros per million tokens`);
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function priceFrom(env, model, prefix) {
|
|
91
|
+
const input = readMicros(env[`${prefix}_INPUT_MICROS_PER_MILLION`], `${prefix}_INPUT_MICROS_PER_MILLION`);
|
|
92
|
+
const output = readMicros(env[`${prefix}_OUTPUT_MICROS_PER_MILLION`], `${prefix}_OUTPUT_MICROS_PER_MILLION`);
|
|
93
|
+
if (input == null && output == null) return AU_MICROS[model] ?? null;
|
|
94
|
+
if (input == null || output == null) {
|
|
95
|
+
throw new Error(`${prefix}_INPUT_MICROS_PER_MILLION and ${prefix}_OUTPUT_MICROS_PER_MILLION are set together`);
|
|
96
|
+
}
|
|
97
|
+
return { input, output };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function sureLineOf(env) {
|
|
101
|
+
const raw = String(env.FLOW_READER_SURE_LINE ?? '').trim();
|
|
102
|
+
if (!raw) return SURE_LINE;
|
|
103
|
+
const value = Number(raw);
|
|
104
|
+
if (!Number.isFinite(value) || value < 0 || value > 1) throw new Error('FLOW_READER_SURE_LINE must be a number from 0 to 1');
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Answers the reader settings. `on` is false when the module is off or the
|
|
109
|
+
// keys are missing: a capture is then a problem a person can type. A region
|
|
110
|
+
// outside Sydney and Melbourne, or a model id that is not an au. profile,
|
|
111
|
+
// stops boot rather than sending a form there.
|
|
112
|
+
export function resolveReader({ env = process.env } = {}) {
|
|
113
|
+
const flag = String(env.FLOW_READER ?? '').trim().toLowerCase();
|
|
114
|
+
if (flag && flag !== 'on' && flag !== 'off') throw new Error('FLOW_READER must be "on", "off" or unset');
|
|
115
|
+
if (flag !== 'on') return { on: false, reason: flag === 'off' ? 'off' : 'unset' };
|
|
116
|
+
|
|
117
|
+
const region = String(env.FLOW_READER_REGION ?? '').trim();
|
|
118
|
+
const model = String(env.FLOW_READER_MODEL ?? '').trim();
|
|
119
|
+
const large = String(env.FLOW_READER_MODEL_LARGE ?? '').trim();
|
|
120
|
+
if (!READER_REGIONS.includes(region)) {
|
|
121
|
+
throw new Error('FLOW_READER=on needs FLOW_READER_REGION set to ap-southeast-2 (Sydney) or ap-southeast-4 (Melbourne)');
|
|
122
|
+
}
|
|
123
|
+
if (!model || !large) throw new Error('FLOW_READER=on needs FLOW_READER_MODEL and FLOW_READER_MODEL_LARGE');
|
|
124
|
+
if (!auProfile(model) || !auProfile(large)) {
|
|
125
|
+
throw new Error('FLOW_READER_MODEL and FLOW_READER_MODEL_LARGE must be au. inference profiles');
|
|
126
|
+
}
|
|
127
|
+
const accessKeyId = String(env.FLOW_READER_ACCESS_KEY_ID ?? '').trim();
|
|
128
|
+
const secretAccessKey = String(env.FLOW_READER_SECRET_ACCESS_KEY ?? '').trim();
|
|
129
|
+
if (!accessKeyId || !secretAccessKey) return { on: false, reason: 'no_credentials' };
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
on: true,
|
|
133
|
+
reason: null,
|
|
134
|
+
region,
|
|
135
|
+
model,
|
|
136
|
+
large,
|
|
137
|
+
accessKeyId,
|
|
138
|
+
secretAccessKey,
|
|
139
|
+
sureLine: sureLineOf(env),
|
|
140
|
+
prices: {
|
|
141
|
+
small: priceFrom(env, model, 'FLOW_READER'),
|
|
142
|
+
large: priceFrom(env, large, 'FLOW_READER_LARGE'),
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|