@arcforgelabs/flow 2026.10.49 → 2026.10.51

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 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, and the hash is checked on
37
- read. Download links are signed.
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, images or scans, and keeps a patient
59
- reference only where an invoice must print one.
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/VISION.md CHANGED
@@ -62,8 +62,9 @@ Two parts follow the board:
62
62
  changes until a person accepts. The model steps are optional and cost money
63
63
  per page, so the manager sees the month's cost.
64
64
  - **A practice portal** (#268). Practice staff sign in to follow their own
65
- jobs, on desktop first, with a simpler view on phones. The server limits
66
- each person to their practice.
65
+ jobs, send requests, scans and case notes, chat with the lab and manage
66
+ their own team. It works on a phone first and on a desktop. The server
67
+ limits each person to their practice.
67
68
 
68
69
  Job management is a bundled module, built the same way as the integrations,
69
70
  and a lab can remove it. Where the case system cannot take a change through
@@ -110,13 +111,20 @@ record in Flow with who, when and what they saw.
110
111
  What a customer was sent is a commercial commitment. A change makes a new
111
112
  version; the sent one stays as it was, attributed to whoever sent it.
112
113
 
113
- ### Why does Flow keep as little patient data as it can?
114
+ ### Why does Flow treat all its data as health data?
114
115
 
115
- Dental cases carry health information, which Australian privacy law treats as
116
- sensitive. Flow keeps the case number and the practice. It keeps a patient
117
- reference only when a practice needs one printed on its invoice, and it keeps
118
- no clinical notes or images. A form photo the intake reader reads is not kept
119
- after its job is updated.
116
+ Dental work carries health information, which Australian privacy law treats as
117
+ sensitive. A lab or a practice decides what it puts into Flow: case notes,
118
+ scans, photos and patient names can all arrive. So Flow treats every record
119
+ and every file as sensitive health data:
120
+
121
+ - The server limits each person to what their role and practice allow.
122
+ - Files are encrypted at rest. A download uses a short-lived signed link.
123
+ - Flow records each read and change of practice data in an audit log.
124
+ - Each installation sets how long files and notes are kept. Flow deletes them
125
+ after that time.
126
+ - Each installation picks its hosting region. Flow documents what a host needs
127
+ for health data.
120
128
 
121
129
  ### Why does Flow keep its records in PostgreSQL only?
122
130
 
@@ -137,7 +145,8 @@ its own data, configuration and secrets.
137
145
  - Replacing the case system, for now. The case system owns the case record.
138
146
  Flow's job board shows and updates the work, and does not do clinical
139
147
  case management or production scheduling.
140
- - Patient records, clinical notes, scans and images.
148
+ - Clinical decisions. Flow keeps the notes and files that practices send; it
149
+ does not diagnose or plan treatment.
141
150
  - Acting as the general ledger. Accounting software owns the ledger and the
142
151
  bank feed.
143
152
  - Browser automation of another product. Integrations use published APIs.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.49",
3
+ "version": "2026.10.51",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.49",
9
+ "version": "2026.10.51",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.49",
3
+ "version": "2026.10.51",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,109 @@
1
+ import express from 'express';
2
+ import {
3
+ MAX_PHOTO_BYTES, captureView, decideCapture, 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. Logs from this router are
11
+ // a capture id and an error code, never the photo or the fields.
12
+
13
+ const ERROR_STATUS = {
14
+ not_found: 404,
15
+ conflict: 409,
16
+ organisation_required: 409,
17
+ unsupported_media: 415,
18
+ photo_too_large: 413,
19
+ link_invalid: 403,
20
+ link_expired: 403,
21
+ storage_unavailable: 503,
22
+ };
23
+
24
+ function respond(res, error) {
25
+ return res.status(ERROR_STATUS[error.code] ?? 400).json({ ok: false, code: error.code, error: error.message });
26
+ }
27
+
28
+ export function createCapturesRouter({ db, storage, personAuth, log = console, publicOrigin = null, reader = null }) {
29
+ const router = express.Router();
30
+ const signedIn = [personAuth.requireSession];
31
+
32
+ const handle = (fn) => async (req, res) => {
33
+ try {
34
+ res.set('Cache-Control', 'no-store');
35
+ await fn(req, res);
36
+ } catch (error) {
37
+ if (!(error instanceof FlowError)) {
38
+ log.error?.('[captures] - error');
39
+ return res.status(500).json({ ok: false, code: 'internal', error: 'Something went wrong.' });
40
+ }
41
+ if (error.code === 'hash_mismatch' || error.code === 'storage_unavailable') {
42
+ log.error?.(`[captures] ${error.captureId ?? '-'} ${error.code}`);
43
+ }
44
+ if (error.code === 'hash_mismatch') {
45
+ return res.status(404).json({ ok: false, code: 'not_found', error: 'photo not found' });
46
+ }
47
+ return respond(res, error);
48
+ }
49
+ };
50
+
51
+ const origin = (req) => {
52
+ const configured = String(publicOrigin ?? '').trim().replace(/\/$/, '');
53
+ if (configured) return configured;
54
+ return `${req.protocol}://${req.get('host')}`;
55
+ };
56
+ const organisationOf = (req) => resolveOrganisation(db, req.body?.organisationId ?? req.query.organisationId);
57
+
58
+ router.get('/file', handle(async (req, res) => {
59
+ const photo = await readCapturePhoto(db, storage, req.query.token);
60
+ res.set('Cache-Control', 'private, no-store');
61
+ res.set('X-Content-Type-Options', 'nosniff');
62
+ res.set('Content-Type', photo.contentType);
63
+ res.end(photo.body);
64
+ }));
65
+
66
+ router.post('/', ...signedIn, express.raw({ type: () => true, limit: MAX_PHOTO_BYTES }), handle(async (req, res) => {
67
+ const organisationId = await organisationOf(req);
68
+ const result = await uploadCapture(db, storage, organisationId, {
69
+ bytes: typeof req.body === 'string' || Array.isArray(req.body) || !Buffer.isBuffer(req.body) ? Buffer.alloc(0) : req.body,
70
+ contentType: req.get('content-type'),
71
+ idempotencyKey: req.get('idempotency-key'),
72
+ source: req.get('x-flow-capture-source') ?? req.query.source,
73
+ personId: req.person.id,
74
+ baseUrl: origin(req),
75
+ reader,
76
+ });
77
+ res.status(result.created ? 201 : 200).json({ ok: true, capture: captureView(result.capture, result.link) });
78
+ }));
79
+
80
+ router.use(express.json({ limit: '32kb' }));
81
+
82
+ router.get('/:id', ...signedIn, handle(async (req, res) => {
83
+ const result = await readCapture(db, storage, await organisationOf(req), req.params.id, origin(req));
84
+ res.json({ ok: true, capture: captureView(result.capture, result.link) });
85
+ }));
86
+
87
+ router.post('/:id/proposal', ...signedIn, handle(async (req, res) => {
88
+ const result = await typeProposal(db, storage, await organisationOf(req), req.params.id, req.body, { baseUrl: origin(req) });
89
+ res.json({ ok: true, capture: captureView(result.capture, result.link) });
90
+ }));
91
+
92
+ router.post('/:id/decision', ...signedIn, handle(async (req, res) => {
93
+ const result = await decideCapture(db, storage, await organisationOf(req), req.params.id, req.body?.decision, {
94
+ personId: req.person.id,
95
+ baseUrl: origin(req),
96
+ });
97
+ res.json({ ok: true, capture: captureView(result.capture, result.link) });
98
+ }));
99
+
100
+ router.use((error, _req, res, next) => {
101
+ if (error?.type === 'entity.too.large' || error?.status === 413) {
102
+ return respond(res, new FlowError('photo_too_large', 'photo is too large'));
103
+ }
104
+ if (error instanceof FlowError) return respond(res, error);
105
+ return next(error);
106
+ });
107
+
108
+ return router;
109
+ }
@@ -43,7 +43,7 @@ import {
43
43
  //
44
44
  // GET /context the organisations, and whether this person may issue
45
45
  // GET /invoices?organisationId=&status=&q=&limit= newest first, with counts by tab;
46
- // status is a tab: pending, ready, draft, approved, sent or void (TABS)
46
+ // status is a tab: pending, ready, draft, approved, sent, non_chargeable or void (TABS)
47
47
  // POST /invoices/mark-ready { invoiceIds }: a person checked these drafts; one with a problem stays
48
48
  // POST /invoices/mark-not-ready { invoiceIds }: back to To check
49
49
  // POST /invoices/send-ready owner or admin: issue every ready draft and queue it for the
@@ -118,7 +118,7 @@ const DATE = /^\d{4}-\d{2}-\d{2}$/;
118
118
  // issued invoice, by its stage in the accounting system (its invoiceStages;
119
119
  // Draft while it is on its way); with no accounting system, an issued invoice
120
120
  // is Approved. Void: void in Flow or in the accounting system.
121
- const TABS = ['pending', 'ready', 'draft', 'approved', 'sent', 'void'];
121
+ const TABS = ['pending', 'ready', 'draft', 'approved', 'sent', 'non_chargeable', 'void'];
122
122
  // Earlier names a saved link may still use.
123
123
  const OLD_TABS = { problem: 'pending', to_check: 'ready', issued: 'approved' };
124
124
  const VIEWS = ['in_work', 'ready', 'pending', 'drafted', 'invoiced', 'paid', 'held', 'review_required'];
@@ -332,6 +332,7 @@ async function invoiceTabs(db, organisationId, system) {
332
332
  for (const { id } of issued) tabOf.set(id, issuedTab(id, { views, stages, system }));
333
333
  for (const { id } of voided) tabOf.set(id, 'void');
334
334
  for (const id of views.problems.keys()) tabOf.set(id, 'pending');
335
+ for (const id of views.nonChargeable.keys()) tabOf.set(id, 'non_chargeable');
335
336
  const ids = Object.fromEntries(TABS.map((t) => [t, []]));
336
337
  for (const [id, tab] of tabOf) ids[tab].push(id);
337
338
  return { tabOf, ids, problems: views.problems, sending: views.sending };
@@ -348,6 +349,7 @@ function issuedTab(id, { views, stages, system }) {
348
349
  async function tabOfInvoice(db, organisationId, invoice, view, accounting) {
349
350
  if (invoice.status === 'void') return 'void';
350
351
  if (view === 'problem') return 'pending';
352
+ if (view === 'non_chargeable') return 'non_chargeable';
351
353
  if (invoice.status === 'draft') return 'ready';
352
354
  if (!accounting) return 'approved';
353
355
  if (view === 'sending') return 'draft';
@@ -626,7 +628,9 @@ export function createInvoicingRouter({
626
628
  // A draft whose job changed after it was made waits for a person to fix
627
629
  // the job at its source or adopt the change (ledger.js), as Move to
628
630
  // Ready and Send do.
629
- const { problems } = await invoiceView(db, organisationId, await getInvoice(db, organisationId, invoiceId));
631
+ const { problems, nonChargeable } = await invoiceView(db, organisationId, await getInvoice(db, organisationId, invoiceId));
632
+ // A non-chargeable draft is never billed (invoice-rules.js).
633
+ if (nonChargeable?.length) throw new FlowError('not_ready', `${nonChargeable[0].message} Nothing was approved.`);
630
634
  // A lab's pending rule, or a skip rule the job matches (invoice-rules.js),
631
635
  // is a gate too: fix its cause, void the draft, or turn the rule off.
632
636
  const held = problems.find((p) => p.code === 'job_review' || p.code === 'rule');
package/server/app.js CHANGED
@@ -1,5 +1,6 @@
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';
@@ -59,6 +60,13 @@ export function createApp({
59
60
  buttonCooldowns = true,
60
61
  log = console,
61
62
  tracing = null,
63
+ // Object storage for capture photos (server/core/storage.js). Null leaves
64
+ // the capture routes off.
65
+ storage = null,
66
+ publicOrigin = null,
67
+ // The vision reader (server/core/reader.js). Null is off: a capture is a
68
+ // problem a person can type, and no model is called.
69
+ reader = null,
62
70
  } = {}) {
63
71
  const app = express();
64
72
  app.disable('x-powered-by');
@@ -147,6 +155,11 @@ export function createApp({
147
155
  // items, Seazona products).
148
156
  const priceSystems = () => integrations?.with('listPriceItems') ?? [];
149
157
  app.use('/api/invoicing', createInvoicingRouter({ db: database.db, personAuth: auth.personAuth, log, liveCheckFor, accounting, work, priceSystems, buttonCooldowns }));
158
+ if (storage) {
159
+ app.use('/api/captures', createCapturesRouter({
160
+ db: database.db, storage, personAuth: auth.personAuth, log, publicOrigin, reader,
161
+ }));
162
+ }
150
163
  // Custom fields for jobs (server/core/custom-fields.js): kept in Flow
151
164
  // only; the work systems' statuses show beside them, read only.
152
165
  app.use('/api/custom-fields', createCustomFieldsRouter({ db: database.db, personAuth: auth.personAuth, work, log }));
@@ -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,144 @@
1
+ import { FlowError } from './errors.js';
2
+
3
+ // The AI port. One call reads one image and returns text. The vision reader
4
+ // (server/core/reader.js) is the caller. A client implements readImage; this
5
+ // file does not name a vendor.
6
+ //
7
+ // The model id and the region come from the environment (resolveReader).
8
+ // A model id that is not an Australia inference profile (au.) is refused
9
+ // before any call. An error that leaves this port has a fixed message, so a
10
+ // log never receives the photo or the model's answer.
11
+
12
+ export const READER_REGIONS = Object.freeze(['ap-southeast-2', 'ap-southeast-4']);
13
+
14
+ // USD micros per million tokens for the Australia profile: Anthropic's list
15
+ // price plus 10% (October 2026). Haiku 5.5 uses the long rate when the
16
+ // prompt is over 100,000 tokens. An operator sets FLOW_READER_*_MICROS_PER_MILLION
17
+ // when the model id is not listed here, or when the price changes.
18
+ const AU_MICROS = Object.freeze({
19
+ 'au.anthropic.claude-haiku-5-5': Object.freeze({
20
+ input: 110_000, output: 550_000, longInput: 550_000, longOutput: 2_750_000, longFrom: 100_000,
21
+ }),
22
+ 'au.anthropic.claude-sonnet-5-5': Object.freeze({ input: 2_200_000, output: 11_000_000 }),
23
+ });
24
+
25
+ const PROFILE = /^au\.[a-z0-9][a-z0-9._:-]{0,127}$/;
26
+
27
+ export function auProfile(id) {
28
+ return typeof id === 'string' && PROFILE.test(id) && !id.includes('..');
29
+ }
30
+
31
+ // Integer micros for one call. Null when this model has no price: the tokens
32
+ // are still recorded, and the cost is not a guess.
33
+ export function callCostMicros({ inputTokens, outputTokens, price }) {
34
+ if (!price || !Number.isInteger(inputTokens) || !Number.isInteger(outputTokens)) return null;
35
+ if (inputTokens < 0 || outputTokens < 0) return null;
36
+ const long = Number.isInteger(price.longFrom) && inputTokens > price.longFrom;
37
+ const input = long ? price.longInput : price.input;
38
+ const output = long ? price.longOutput : price.output;
39
+ if (!Number.isInteger(input) || !Number.isInteger(output)) return null;
40
+ return Math.round((inputTokens * input + outputTokens * output) / 1_000_000);
41
+ }
42
+
43
+ const MESSAGES = Object.freeze({
44
+ reader_unavailable: 'the reader did not answer',
45
+ reader_rejected: 'could not read the form',
46
+ reader_unreadable: 'could not read the form',
47
+ });
48
+
49
+ function clean(error) {
50
+ const code = MESSAGES[error?.code] ? error.code : 'reader_unavailable';
51
+ const next = new FlowError(code, MESSAGES[code]);
52
+ next.retry = error?.retry !== false && code !== 'reader_rejected';
53
+ return next;
54
+ }
55
+
56
+ export function createAiPort(client) {
57
+ if (typeof client?.readImage !== 'function') throw new TypeError('an AI port needs readImage');
58
+ return {
59
+ async readImage(request) {
60
+ if (!auProfile(request?.model) || !Buffer.isBuffer(request?.bytes) || request.bytes.length === 0) {
61
+ const error = new FlowError('reader_rejected', MESSAGES.reader_rejected);
62
+ error.retry = false;
63
+ throw error;
64
+ }
65
+ try {
66
+ const result = await client.readImage(request);
67
+ const inputTokens = Number(result?.inputTokens);
68
+ const outputTokens = Number(result?.outputTokens);
69
+ return {
70
+ model: request.model,
71
+ text: typeof result?.text === 'string' ? result.text : '',
72
+ inputTokens: Number.isInteger(inputTokens) && inputTokens >= 0 ? inputTokens : 0,
73
+ outputTokens: Number.isInteger(outputTokens) && outputTokens >= 0 ? outputTokens : 0,
74
+ };
75
+ } catch (error) {
76
+ throw clean(error);
77
+ }
78
+ },
79
+ };
80
+ }
81
+
82
+ function readMicros(raw, name) {
83
+ if (raw == null || String(raw).trim() === '') return null;
84
+ const value = Number(String(raw).trim());
85
+ if (!Number.isInteger(value) || value < 0) throw new Error(`${name} must be a whole number of micros per million tokens`);
86
+ return value;
87
+ }
88
+
89
+ function priceFrom(env, model, prefix) {
90
+ const input = readMicros(env[`${prefix}_INPUT_MICROS_PER_MILLION`], `${prefix}_INPUT_MICROS_PER_MILLION`);
91
+ const output = readMicros(env[`${prefix}_OUTPUT_MICROS_PER_MILLION`], `${prefix}_OUTPUT_MICROS_PER_MILLION`);
92
+ if (input == null && output == null) return AU_MICROS[model] ?? null;
93
+ if (input == null || output == null) {
94
+ throw new Error(`${prefix}_INPUT_MICROS_PER_MILLION and ${prefix}_OUTPUT_MICROS_PER_MILLION are set together`);
95
+ }
96
+ return { input, output };
97
+ }
98
+
99
+ function sureLineOf(env) {
100
+ const raw = String(env.FLOW_READER_SURE_LINE ?? '').trim();
101
+ if (!raw) return 0.8;
102
+ const value = Number(raw);
103
+ if (!Number.isFinite(value) || value < 0 || value > 1) throw new Error('FLOW_READER_SURE_LINE must be a number from 0 to 1');
104
+ return value;
105
+ }
106
+
107
+ // Answers the reader settings. `on` is false when the module is off or the
108
+ // keys are missing: a capture is then a problem a person can type. A region
109
+ // outside Sydney and Melbourne, or a model id that is not an au. profile,
110
+ // stops boot rather than sending a form there.
111
+ export function resolveReader({ env = process.env } = {}) {
112
+ const flag = String(env.FLOW_READER ?? '').trim().toLowerCase();
113
+ if (flag && flag !== 'on' && flag !== 'off') throw new Error('FLOW_READER must be "on", "off" or unset');
114
+ if (flag !== 'on') return { on: false, reason: flag === 'off' ? 'off' : 'unset' };
115
+
116
+ const region = String(env.FLOW_READER_REGION ?? '').trim();
117
+ const model = String(env.FLOW_READER_MODEL ?? '').trim();
118
+ const large = String(env.FLOW_READER_MODEL_LARGE ?? '').trim();
119
+ if (!READER_REGIONS.includes(region)) {
120
+ throw new Error('FLOW_READER=on needs FLOW_READER_REGION set to ap-southeast-2 (Sydney) or ap-southeast-4 (Melbourne)');
121
+ }
122
+ if (!model || !large) throw new Error('FLOW_READER=on needs FLOW_READER_MODEL and FLOW_READER_MODEL_LARGE');
123
+ if (!auProfile(model) || !auProfile(large)) {
124
+ throw new Error('FLOW_READER_MODEL and FLOW_READER_MODEL_LARGE must be au. inference profiles');
125
+ }
126
+ const accessKeyId = String(env.FLOW_READER_ACCESS_KEY_ID ?? '').trim();
127
+ const secretAccessKey = String(env.FLOW_READER_SECRET_ACCESS_KEY ?? '').trim();
128
+ if (!accessKeyId || !secretAccessKey) return { on: false, reason: 'no_credentials' };
129
+
130
+ return {
131
+ on: true,
132
+ reason: null,
133
+ region,
134
+ model,
135
+ large,
136
+ accessKeyId,
137
+ secretAccessKey,
138
+ sureLine: sureLineOf(env),
139
+ prices: {
140
+ small: priceFrom(env, model, 'FLOW_READER'),
141
+ large: priceFrom(env, large, 'FLOW_READER_LARGE'),
142
+ },
143
+ };
144
+ }