@arcforgelabs/flow 2026.10.50 → 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 +9 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/captures.js +109 -0
- package/server/app.js +13 -0
- package/server/core/ai-bedrock.js +124 -0
- package/server/core/ai.js +144 -0
- package/server/core/captures.js +397 -0
- package/server/core/reader.js +333 -0
- package/server/core/storage.js +491 -0
- package/server/db/migrations/0023_captures.sql +46 -0
- package/server/db/migrations/meta/0023_snapshot.json +4693 -0
- package/server/db/migrations/meta/_journal.json +8 -1
- package/server/db/schema.js +47 -0
- package/server/index.js +47 -1
- package/server/ui/app.js +54 -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.51",
|
|
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.51",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
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
|
+
}
|