@browserless/ai 13.9.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/LICENSE.md +21 -0
- package/README.md +128 -0
- package/examples/index.js +21 -0
- package/package.json +68 -0
- package/scripts/docker-ci.sh +35 -0
- package/scripts/install-model.js +21 -0
- package/scripts/pack-model.js +172 -0
- package/scripts/probe.js +33 -0
- package/scripts/util.js +274 -0
- package/src/find-dir.js +28 -0
- package/src/index.js +313 -0
- package/src/run-ai.js +151 -0
- package/src/unpack.js +145 -0
package/scripts/util.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { createReadStream, createWriteStream, mkdirSync, statSync } = require('node:fs')
|
|
4
|
+
const { createHmac, createHash } = require('node:crypto')
|
|
5
|
+
const { Readable, Transform } = require('node:stream')
|
|
6
|
+
const { pipeline } = require('node:stream/promises')
|
|
7
|
+
const path = require('node:path')
|
|
8
|
+
|
|
9
|
+
const UNSIGNED = 'UNSIGNED-PAYLOAD'
|
|
10
|
+
const PART_SIZE = 16 * 1024 * 1024
|
|
11
|
+
const DEFAULT_KEY = 'browserless-ai-nano.zip'
|
|
12
|
+
|
|
13
|
+
const encodeRfc3986 = value =>
|
|
14
|
+
encodeURIComponent(value).replace(
|
|
15
|
+
/[!'()*]/g,
|
|
16
|
+
char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
const hmac = (key, data) => createHmac('sha256', key).update(data, 'utf8').digest()
|
|
20
|
+
const sha256Hex = data => createHash('sha256').update(data).digest('hex')
|
|
21
|
+
const amzNow = () => new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
|
|
22
|
+
const signingKey = (secret, date, region) =>
|
|
23
|
+
hmac(hmac(hmac(hmac(`AWS4${secret}`, date), region), 's3'), 'aws4_request')
|
|
24
|
+
|
|
25
|
+
const parseS3Url = value => {
|
|
26
|
+
if (!value) return {}
|
|
27
|
+
try {
|
|
28
|
+
const url = new URL(value)
|
|
29
|
+
const parts = url.pathname.split('/').filter(Boolean)
|
|
30
|
+
return {
|
|
31
|
+
endpoint: url.origin,
|
|
32
|
+
bucket: parts[0],
|
|
33
|
+
key: parts.slice(1).join('/')
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
return {}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const credentials = () => {
|
|
41
|
+
const fromEnv = parseS3Url(process.env.R2_ENDPOINT)
|
|
42
|
+
const accountId = process.env.R2_ACCOUNT_ID || process.env.CLOUDFLARE_ACCOUNT_ID
|
|
43
|
+
return {
|
|
44
|
+
bucket: process.env.R2_BUCKET || fromEnv.bucket,
|
|
45
|
+
key: process.env.R2_KEY || fromEnv.key || DEFAULT_KEY,
|
|
46
|
+
accessKey: process.env.R2_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID,
|
|
47
|
+
secretKey: process.env.R2_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY,
|
|
48
|
+
endpoint:
|
|
49
|
+
fromEnv.endpoint ||
|
|
50
|
+
process.env.R2_ENDPOINT ||
|
|
51
|
+
(accountId ? `https://${accountId}.r2.cloudflarestorage.com` : undefined),
|
|
52
|
+
region: process.env.R2_REGION || 'auto'
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const objectUrl = opts => `${opts.endpoint}/${opts.bucket}/${opts.key}`
|
|
57
|
+
|
|
58
|
+
const objectPath = (bucket, key) =>
|
|
59
|
+
`/${encodeRfc3986(bucket)}/${key.split('/').map(encodeRfc3986).join('/')}`
|
|
60
|
+
|
|
61
|
+
const canonicalQuery = query =>
|
|
62
|
+
Object.keys(query)
|
|
63
|
+
.sort()
|
|
64
|
+
.map(
|
|
65
|
+
name =>
|
|
66
|
+
`${encodeRfc3986(name)}=${query[name] === '' ? '' : encodeRfc3986(String(query[name]))}`
|
|
67
|
+
)
|
|
68
|
+
.join('&')
|
|
69
|
+
|
|
70
|
+
const sign = ({
|
|
71
|
+
method,
|
|
72
|
+
endpoint,
|
|
73
|
+
pathname,
|
|
74
|
+
query = {},
|
|
75
|
+
extraHeaders = {},
|
|
76
|
+
accessKey,
|
|
77
|
+
secretKey,
|
|
78
|
+
region
|
|
79
|
+
}) => {
|
|
80
|
+
const amzDate = amzNow()
|
|
81
|
+
const date = amzDate.slice(0, 8)
|
|
82
|
+
const host = new URL(endpoint).host
|
|
83
|
+
const headers = {
|
|
84
|
+
host,
|
|
85
|
+
'x-amz-content-sha256': UNSIGNED,
|
|
86
|
+
'x-amz-date': amzDate,
|
|
87
|
+
...extraHeaders
|
|
88
|
+
}
|
|
89
|
+
const names = Object.keys(headers)
|
|
90
|
+
.map(name => name.toLowerCase())
|
|
91
|
+
.sort()
|
|
92
|
+
const headerMap = Object.fromEntries(
|
|
93
|
+
Object.entries(headers).map(([name, value]) => [name.toLowerCase(), String(value).trim()])
|
|
94
|
+
)
|
|
95
|
+
const canonicalHeaders = names.map(name => `${name}:${headerMap[name]}\n`).join('')
|
|
96
|
+
const signedHeaders = names.join(';')
|
|
97
|
+
const canonicalRequest = [
|
|
98
|
+
method,
|
|
99
|
+
pathname,
|
|
100
|
+
canonicalQuery(query),
|
|
101
|
+
canonicalHeaders,
|
|
102
|
+
signedHeaders,
|
|
103
|
+
UNSIGNED
|
|
104
|
+
].join('\n')
|
|
105
|
+
const scope = `${date}/${region}/s3/aws4_request`
|
|
106
|
+
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256Hex(canonicalRequest)].join('\n')
|
|
107
|
+
const signature = createHmac('sha256', signingKey(secretKey, date, region))
|
|
108
|
+
.update(stringToSign)
|
|
109
|
+
.digest('hex')
|
|
110
|
+
headers.authorization = `AWS4-HMAC-SHA256 Credential=${accessKey}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
|
|
111
|
+
return headers
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const signedFetch = async (
|
|
115
|
+
opts,
|
|
116
|
+
{ method, query = {}, body, contentLength, contentType, signal }
|
|
117
|
+
) => {
|
|
118
|
+
const pathname = objectPath(opts.bucket, opts.key)
|
|
119
|
+
const extraHeaders = {}
|
|
120
|
+
if (contentLength != null) extraHeaders['content-length'] = String(contentLength)
|
|
121
|
+
if (contentType) extraHeaders['content-type'] = contentType
|
|
122
|
+
const headers = sign({
|
|
123
|
+
method,
|
|
124
|
+
endpoint: opts.endpoint,
|
|
125
|
+
pathname,
|
|
126
|
+
query,
|
|
127
|
+
extraHeaders,
|
|
128
|
+
accessKey: opts.accessKey,
|
|
129
|
+
secretKey: opts.secretKey,
|
|
130
|
+
region: opts.region || 'auto'
|
|
131
|
+
})
|
|
132
|
+
const search = canonicalQuery(query)
|
|
133
|
+
const url = `${opts.endpoint}${pathname}${search ? `?${search}` : ''}`
|
|
134
|
+
return fetch(url, {
|
|
135
|
+
method,
|
|
136
|
+
headers,
|
|
137
|
+
body,
|
|
138
|
+
signal,
|
|
139
|
+
duplex: body && typeof body !== 'string' ? 'half' : undefined
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const request = async (opts, init) => {
|
|
144
|
+
const res = await signedFetch(opts, init)
|
|
145
|
+
const text = await res.text()
|
|
146
|
+
if (!res.ok) throw new Error(`R2 ${init.method} ${res.status}: ${text.slice(0, 500)}`)
|
|
147
|
+
return { headers: res.headers, text }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const xmlText = (xml, tag) => {
|
|
151
|
+
const match = xml.match(new RegExp(`<${tag}>([^<]+)</${tag}>`))
|
|
152
|
+
if (!match) throw new Error(`missing <${tag}> in R2 response`)
|
|
153
|
+
return match[1]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const prettyBytes = n => {
|
|
157
|
+
if (n >= 1e9) return `${(n / 1e9).toFixed(2)} GB`
|
|
158
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(2)} MB`
|
|
159
|
+
return `${n} B`
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const createReporter = label => {
|
|
163
|
+
const interactive = process.stderr.isTTY && !process.env.CI
|
|
164
|
+
let last = 0
|
|
165
|
+
return (loaded, total, done = false) => {
|
|
166
|
+
const now = Date.now()
|
|
167
|
+
if (!done && now - last < (interactive ? 200 : 5000)) return
|
|
168
|
+
last = now
|
|
169
|
+
const pct = total ? Math.min(100, Math.floor((loaded / total) * 100)) : 0
|
|
170
|
+
const line = `${label} ${prettyBytes(loaded)}/${prettyBytes(total)} ${pct}%`
|
|
171
|
+
process.stderr.write(interactive ? `\r${line}` : `${line}\n`)
|
|
172
|
+
if (done && interactive) process.stderr.write('\n')
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const toNodeStream = body => {
|
|
177
|
+
if (!body) throw new Error('empty S3 body')
|
|
178
|
+
if (typeof body.pipe === 'function') return body
|
|
179
|
+
if (typeof body.getReader === 'function') return Readable.fromWeb(body)
|
|
180
|
+
return Readable.from(body)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const DOWNLOAD_TIMEOUT = 15 * 60 * 1000
|
|
184
|
+
|
|
185
|
+
const downloadFile = async (opts, dest) => {
|
|
186
|
+
const res = await signedFetch(opts, {
|
|
187
|
+
method: 'GET',
|
|
188
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT)
|
|
189
|
+
})
|
|
190
|
+
if (!res.ok) {
|
|
191
|
+
const text = await res.text()
|
|
192
|
+
throw new Error(`R2 GET ${res.status}: ${text.slice(0, 500)}`)
|
|
193
|
+
}
|
|
194
|
+
const total = Number(res.headers.get('content-length')) || 0
|
|
195
|
+
const report = createReporter('downloading')
|
|
196
|
+
let loaded = 0
|
|
197
|
+
mkdirSync(path.dirname(dest), { recursive: true })
|
|
198
|
+
await pipeline(
|
|
199
|
+
toNodeStream(res.body),
|
|
200
|
+
new Transform({
|
|
201
|
+
transform (chunk, _enc, cb) {
|
|
202
|
+
loaded += chunk.length
|
|
203
|
+
report(loaded, total)
|
|
204
|
+
cb(null, chunk)
|
|
205
|
+
}
|
|
206
|
+
}),
|
|
207
|
+
createWriteStream(dest)
|
|
208
|
+
)
|
|
209
|
+
report(loaded, total, true)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const uploadFile = async (opts, file) => {
|
|
213
|
+
const { size } = statSync(file)
|
|
214
|
+
const report = createReporter('uploading')
|
|
215
|
+
if (size <= PART_SIZE) {
|
|
216
|
+
report(0, size)
|
|
217
|
+
await request(opts, {
|
|
218
|
+
method: 'PUT',
|
|
219
|
+
body: createReadStream(file),
|
|
220
|
+
contentLength: size,
|
|
221
|
+
contentType: 'application/zip'
|
|
222
|
+
})
|
|
223
|
+
report(size, size, true)
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const created = await request(opts, {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
query: { uploads: '' },
|
|
230
|
+
contentType: 'application/zip'
|
|
231
|
+
})
|
|
232
|
+
const uploadId = xmlText(created.text, 'UploadId')
|
|
233
|
+
const parts = []
|
|
234
|
+
try {
|
|
235
|
+
let partNumber = 1
|
|
236
|
+
for (let start = 0; start < size; start += PART_SIZE, partNumber++) {
|
|
237
|
+
const end = Math.min(start + PART_SIZE, size) - 1
|
|
238
|
+
const length = end - start + 1
|
|
239
|
+
report(start, size)
|
|
240
|
+
const { headers } = await request(opts, {
|
|
241
|
+
method: 'PUT',
|
|
242
|
+
query: { partNumber, uploadId },
|
|
243
|
+
body: createReadStream(file, { start, end }),
|
|
244
|
+
contentLength: length
|
|
245
|
+
})
|
|
246
|
+
const etag = headers.get('etag')
|
|
247
|
+
if (!etag) throw new Error(`part ${partNumber} missing ETag`)
|
|
248
|
+
parts.push({ partNumber, etag })
|
|
249
|
+
report(end + 1, size)
|
|
250
|
+
}
|
|
251
|
+
report(size, size, true)
|
|
252
|
+
const body =
|
|
253
|
+
'<CompleteMultipartUpload>' +
|
|
254
|
+
parts
|
|
255
|
+
.map(
|
|
256
|
+
part =>
|
|
257
|
+
`<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${part.etag}</ETag></Part>`
|
|
258
|
+
)
|
|
259
|
+
.join('') +
|
|
260
|
+
'</CompleteMultipartUpload>'
|
|
261
|
+
await request(opts, {
|
|
262
|
+
method: 'POST',
|
|
263
|
+
query: { uploadId },
|
|
264
|
+
body,
|
|
265
|
+
contentLength: Buffer.byteLength(body),
|
|
266
|
+
contentType: 'application/xml'
|
|
267
|
+
})
|
|
268
|
+
} catch (error) {
|
|
269
|
+
await request(opts, { method: 'DELETE', query: { uploadId } }).catch(() => {})
|
|
270
|
+
throw error
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
module.exports = { credentials, downloadFile, objectUrl, uploadFile }
|
package/src/find-dir.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const path = require('node:path')
|
|
4
|
+
const os = require('node:os')
|
|
5
|
+
const { existsSync, readdirSync, statSync } = require('node:fs')
|
|
6
|
+
|
|
7
|
+
const cacheRoot = () =>
|
|
8
|
+
process.env.XDG_CACHE_HOME
|
|
9
|
+
? path.join(process.env.XDG_CACHE_HOME, 'browserless-ai')
|
|
10
|
+
: path.join(os.homedir(), '.cache', 'browserless-ai')
|
|
11
|
+
|
|
12
|
+
const findDir = (root, predicate) => {
|
|
13
|
+
if (!existsSync(root)) return
|
|
14
|
+
if (predicate(root)) return root
|
|
15
|
+
if (!statSync(root).isDirectory()) return
|
|
16
|
+
for (const name of readdirSync(root)) {
|
|
17
|
+
if (name === '_metadata') continue
|
|
18
|
+
const next = path.join(root, name)
|
|
19
|
+
if (statSync(next).isDirectory()) {
|
|
20
|
+
const found = findDir(next, predicate)
|
|
21
|
+
if (found) return found
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const hasFile = (root, file) => findDir(root, current => existsSync(path.join(current, file)))
|
|
27
|
+
|
|
28
|
+
module.exports = { cacheRoot, findDir, hasFile }
|
package/src/index.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const debug = require('debug-logfmt')('browserless:ai')
|
|
4
|
+
const { crc32 } = require('node:zlib')
|
|
5
|
+
const path = require('node:path')
|
|
6
|
+
const fs = require('node:fs')
|
|
7
|
+
const os = require('node:os')
|
|
8
|
+
|
|
9
|
+
const { cacheRoot, hasFile } = require('./find-dir')
|
|
10
|
+
const runAi = require('./run-ai')
|
|
11
|
+
|
|
12
|
+
const withContext = async (getBrowserless, fn) => {
|
|
13
|
+
let teardown
|
|
14
|
+
const browserless = await getBrowserless(done => (teardown = done))
|
|
15
|
+
try {
|
|
16
|
+
return await fn(browserless)
|
|
17
|
+
} finally {
|
|
18
|
+
if (teardown) await teardown()
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const createMethod =
|
|
23
|
+
(getBrowserless, spec) =>
|
|
24
|
+
(url, { timeout = TIMEOUT, ...opts } = {}) =>
|
|
25
|
+
withContext(getBrowserless, browserless =>
|
|
26
|
+
browserless.evaluate(page => page.evaluate(runAi, { ...opts, ...spec }), { timeout })(url)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const OVERRIDE_SEP = process.platform === 'win32' ? '|' : ':'
|
|
30
|
+
|
|
31
|
+
const FEATURES = 'OnDeviceModelForceCpuBackend,OptimizationHints'
|
|
32
|
+
|
|
33
|
+
const TIMEOUT = 300000
|
|
34
|
+
|
|
35
|
+
const readVarint = (buf, offset) => {
|
|
36
|
+
let value = 0
|
|
37
|
+
let shift = 0
|
|
38
|
+
while (offset < buf.length) {
|
|
39
|
+
const byte = buf[offset++]
|
|
40
|
+
value |= (byte & 0x7f) << shift
|
|
41
|
+
if ((byte & 0x80) === 0) return { value, offset }
|
|
42
|
+
shift += 7
|
|
43
|
+
}
|
|
44
|
+
throw new Error('truncated protobuf varint')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const writeVarint = n => {
|
|
48
|
+
const bytes = []
|
|
49
|
+
while (n > 0x7f) {
|
|
50
|
+
bytes.push((n & 0x7f) | 0x80)
|
|
51
|
+
n >>>= 7
|
|
52
|
+
}
|
|
53
|
+
bytes.push(n)
|
|
54
|
+
return Buffer.from(bytes)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const skipTextSafety = buf => {
|
|
58
|
+
if (!buf.length || buf[0] !== 0x0a) return buf
|
|
59
|
+
const { value: length, offset } = readVarint(buf, 1)
|
|
60
|
+
const inner = buf.subarray(offset, offset + length)
|
|
61
|
+
if (inner.subarray(-2).equals(Buffer.from([0x28, 0x01]))) return buf
|
|
62
|
+
const patched = Buffer.concat([inner, Buffer.from([0x28, 0x01])])
|
|
63
|
+
return Buffer.concat([
|
|
64
|
+
Buffer.from([0x0a]),
|
|
65
|
+
writeVarint(patched.length),
|
|
66
|
+
patched,
|
|
67
|
+
buf.subarray(offset + length)
|
|
68
|
+
])
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const zipStore = entries => {
|
|
72
|
+
const locals = []
|
|
73
|
+
const centrals = []
|
|
74
|
+
let offset = 0
|
|
75
|
+
for (const [name, data] of entries) {
|
|
76
|
+
const nameBuf = Buffer.from(name)
|
|
77
|
+
const crc = crc32(data)
|
|
78
|
+
const local = Buffer.alloc(30)
|
|
79
|
+
local.writeUInt32LE(0x04034b50, 0)
|
|
80
|
+
local.writeUInt16LE(20, 4)
|
|
81
|
+
local.writeUInt32LE(crc, 14)
|
|
82
|
+
local.writeUInt32LE(data.length, 18)
|
|
83
|
+
local.writeUInt32LE(data.length, 22)
|
|
84
|
+
local.writeUInt16LE(nameBuf.length, 26)
|
|
85
|
+
locals.push(local, nameBuf, data)
|
|
86
|
+
const central = Buffer.alloc(46)
|
|
87
|
+
central.writeUInt32LE(0x02014b50, 0)
|
|
88
|
+
central.writeUInt16LE(20, 4)
|
|
89
|
+
central.writeUInt16LE(20, 6)
|
|
90
|
+
central.writeUInt32LE(crc, 16)
|
|
91
|
+
central.writeUInt32LE(data.length, 20)
|
|
92
|
+
central.writeUInt32LE(data.length, 24)
|
|
93
|
+
central.writeUInt16LE(nameBuf.length, 28)
|
|
94
|
+
central.writeUInt32LE(offset, 42)
|
|
95
|
+
centrals.push(central, nameBuf)
|
|
96
|
+
offset += 30 + nameBuf.length + data.length
|
|
97
|
+
}
|
|
98
|
+
const localBuf = Buffer.concat(locals)
|
|
99
|
+
const centralBuf = Buffer.concat(centrals)
|
|
100
|
+
const end = Buffer.alloc(22)
|
|
101
|
+
end.writeUInt32LE(0x06054b50, 0)
|
|
102
|
+
end.writeUInt16LE(entries.length, 8)
|
|
103
|
+
end.writeUInt16LE(entries.length, 10)
|
|
104
|
+
end.writeUInt32LE(centralBuf.length, 12)
|
|
105
|
+
end.writeUInt32LE(localBuf.length, 16)
|
|
106
|
+
return Buffer.concat([localBuf, centralBuf, end])
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const ADAPTATIONS = [
|
|
110
|
+
{
|
|
111
|
+
name: 'prompt',
|
|
112
|
+
target: 49,
|
|
113
|
+
flag: 'OPTIMIZATION_TARGET_MODEL_EXECUTION_FEATURE_PROMPT_API',
|
|
114
|
+
skipSafety: true
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: 'summarize',
|
|
118
|
+
target: 51,
|
|
119
|
+
flag: 'OPTIMIZATION_TARGET_MODEL_EXECUTION_FEATURE_SUMMARIZE',
|
|
120
|
+
skipSafety: true
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'detect',
|
|
124
|
+
target: 2,
|
|
125
|
+
flag: 'OPTIMIZATION_TARGET_LANGUAGE_DETECTION',
|
|
126
|
+
skipSafety: false
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
const packAdaptation = (dir, { name, skipSafety }) => {
|
|
131
|
+
const dest = path.join(
|
|
132
|
+
os.tmpdir(),
|
|
133
|
+
`browserless-ai-${name}-${process.pid}-${process.hrtime.bigint()}.crx3`
|
|
134
|
+
)
|
|
135
|
+
const read = file => {
|
|
136
|
+
const filePath = path.join(dir, file)
|
|
137
|
+
return fs.existsSync(filePath) ? fs.readFileSync(filePath) : Buffer.alloc(0)
|
|
138
|
+
}
|
|
139
|
+
let config = read('on_device_model_execution_config.pb')
|
|
140
|
+
if (config.length && skipSafety) config = skipTextSafety(config)
|
|
141
|
+
const files = [
|
|
142
|
+
['model.tflite', read('model.tflite')],
|
|
143
|
+
['model-info.pb', read('model-info.pb')]
|
|
144
|
+
]
|
|
145
|
+
if (config.length) files.push(['on_device_model_execution_config.pb', config])
|
|
146
|
+
fs.writeFileSync(dest, zipStore(files))
|
|
147
|
+
return dest
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const resolveAdaptations = adaptationPath => {
|
|
151
|
+
const stat = fs.statSync(adaptationPath)
|
|
152
|
+
if (stat.isFile()) {
|
|
153
|
+
return [`OPTIMIZATION_TARGET_LANGUAGE_DETECTION${OVERRIDE_SEP}${path.resolve(adaptationPath)}`]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const pairs = []
|
|
157
|
+
for (const feature of ADAPTATIONS) {
|
|
158
|
+
const dir =
|
|
159
|
+
hasFile(path.join(adaptationPath, feature.name), 'model-info.pb') ||
|
|
160
|
+
hasFile(path.join(adaptationPath, String(feature.target)), 'model-info.pb')
|
|
161
|
+
if (dir) pairs.push(`${feature.flag}${OVERRIDE_SEP}${packAdaptation(dir, feature)}`)
|
|
162
|
+
debug('adaptation', { name: feature.name, dir: dir || false })
|
|
163
|
+
}
|
|
164
|
+
return pairs
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const chromeSupport = (...parts) =>
|
|
168
|
+
process.platform === 'darwin'
|
|
169
|
+
? path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome', ...parts)
|
|
170
|
+
: undefined
|
|
171
|
+
|
|
172
|
+
const resolveModelPath = dir => {
|
|
173
|
+
if (dir) return hasFile(dir, 'weights.bin')
|
|
174
|
+
const chrome = chromeSupport('OptGuideOnDeviceModel')
|
|
175
|
+
return hasFile(cacheRoot(), 'weights.bin') || (chrome && hasFile(chrome, 'weights.bin'))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const resolveAdaptationPath = dir => {
|
|
179
|
+
if (dir) return fs.existsSync(dir) ? dir : undefined
|
|
180
|
+
if (hasFile(cacheRoot(), 'model-info.pb')) return cacheRoot()
|
|
181
|
+
const store = chromeSupport('optimization_guide_model_store')
|
|
182
|
+
return store && fs.existsSync(store) ? store : undefined
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const launch = ({
|
|
186
|
+
dir = process.env.BROWSERLESS_AI_DIR,
|
|
187
|
+
userDataDir = process.env.BROWSERLESS_AI_PROFILE,
|
|
188
|
+
timeout = TIMEOUT,
|
|
189
|
+
protocolTimeout = timeout
|
|
190
|
+
} = {}) => {
|
|
191
|
+
const modelPath = resolveModelPath(dir)
|
|
192
|
+
const adaptationPath = resolveAdaptationPath(dir)
|
|
193
|
+
|
|
194
|
+
const { defaultArgs } = require('browserless').driver
|
|
195
|
+
const args = defaultArgs
|
|
196
|
+
.filter(arg => arg !== '--no-startup-window')
|
|
197
|
+
.map(arg => (arg.startsWith('--enable-features=') ? `${arg},${FEATURES}` : arg))
|
|
198
|
+
if (process.env.BROWSERLESS_AI_DUMPIO) {
|
|
199
|
+
args.push('--enable-logging=stderr', '--vmodule=optimization_guide*=1,on_device_model*=2')
|
|
200
|
+
}
|
|
201
|
+
args.push('--disable-model-download-verification')
|
|
202
|
+
if (modelPath) {
|
|
203
|
+
args.push(`--optimization-guide-ondevice-model-execution-override=${modelPath}`)
|
|
204
|
+
}
|
|
205
|
+
if (adaptationPath) {
|
|
206
|
+
const pairs = resolveAdaptations(adaptationPath)
|
|
207
|
+
if (pairs.length) args.push(`--optimization-guide-model-override=${pairs.join(',')}`)
|
|
208
|
+
}
|
|
209
|
+
const weights = modelPath && path.join(modelPath, 'weights.bin')
|
|
210
|
+
debug('launch', {
|
|
211
|
+
dir: dir || false,
|
|
212
|
+
modelPath: modelPath || false,
|
|
213
|
+
weightsBytes: weights && fs.existsSync(weights) ? fs.statSync(weights).size : 0,
|
|
214
|
+
adaptationPath: adaptationPath || false,
|
|
215
|
+
overrides: args.filter(
|
|
216
|
+
arg =>
|
|
217
|
+
arg.includes('optimization-guide') ||
|
|
218
|
+
arg.includes('PromptAPI') ||
|
|
219
|
+
arg.includes('Summarization')
|
|
220
|
+
)
|
|
221
|
+
})
|
|
222
|
+
return {
|
|
223
|
+
timeout,
|
|
224
|
+
protocolTimeout,
|
|
225
|
+
...(userDataDir && { userDataDir }),
|
|
226
|
+
...(process.env.BROWSERLESS_AI_DUMPIO && { dumpio: true }),
|
|
227
|
+
...(process.env.CI && { headless: false }),
|
|
228
|
+
args
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const createMethods = getBrowserless => {
|
|
233
|
+
const prompt = createMethod(getBrowserless, { api: 'prompt' })
|
|
234
|
+
return {
|
|
235
|
+
prompt,
|
|
236
|
+
extract: (url, opts = {}) => {
|
|
237
|
+
if (!opts.schema && !opts.responseConstraint) {
|
|
238
|
+
return Promise.reject(new Error('extract requires schema'))
|
|
239
|
+
}
|
|
240
|
+
return prompt(url, { temperature: 0, topK: 1, ...opts })
|
|
241
|
+
},
|
|
242
|
+
summarize: createMethod(getBrowserless, { api: 'summarize' }),
|
|
243
|
+
translate: createMethod(getBrowserless, { api: 'translate' }),
|
|
244
|
+
detectLanguage: createMethod(getBrowserless, { api: 'detectLanguage' }),
|
|
245
|
+
capabilities: ({ timeout = TIMEOUT, url = 'https://example.com' } = {}) =>
|
|
246
|
+
withContext(getBrowserless, async browserless => {
|
|
247
|
+
const ctors = await browserless.evaluate(
|
|
248
|
+
page =>
|
|
249
|
+
page.evaluate(() => ({
|
|
250
|
+
languageModel: typeof globalThis.LanguageModel,
|
|
251
|
+
summarizer: typeof globalThis.Summarizer,
|
|
252
|
+
translator: typeof globalThis.Translator,
|
|
253
|
+
languageDetector: typeof globalThis.LanguageDetector
|
|
254
|
+
})),
|
|
255
|
+
{ timeout: Math.min(timeout, 30000) }
|
|
256
|
+
)(url)
|
|
257
|
+
debug('ctors', ctors)
|
|
258
|
+
const started = Date.now()
|
|
259
|
+
let available
|
|
260
|
+
let lastError
|
|
261
|
+
for (;;) {
|
|
262
|
+
const left = timeout - (Date.now() - started)
|
|
263
|
+
if (left <= 0) break
|
|
264
|
+
try {
|
|
265
|
+
available = await browserless.evaluate(
|
|
266
|
+
page => page.evaluate(runAi, { api: 'availability' }),
|
|
267
|
+
{ timeout: Math.min(20000, left) }
|
|
268
|
+
)(url)
|
|
269
|
+
lastError = undefined
|
|
270
|
+
} catch (error) {
|
|
271
|
+
lastError = error
|
|
272
|
+
if (left <= 20000) throw error
|
|
273
|
+
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
const apis = available.apis || available
|
|
277
|
+
const pending = ['languageModel', 'summarizer', 'languageDetector'].some(
|
|
278
|
+
api => apis[api] === 'downloading'
|
|
279
|
+
)
|
|
280
|
+
if (!pending) break
|
|
281
|
+
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
282
|
+
}
|
|
283
|
+
if (!available) throw lastError || new Error('capabilities timed out')
|
|
284
|
+
debug('capabilities', available.apis || available, available.env)
|
|
285
|
+
return available.apis || available
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const createAi = (input = {}) => {
|
|
291
|
+
if (typeof input === 'function') return createMethods(input)
|
|
292
|
+
|
|
293
|
+
const browser = require('browserless')(launch(input))
|
|
294
|
+
const methods = createMethods(async teardown => {
|
|
295
|
+
const browserless = await browser.createContext()
|
|
296
|
+
teardown(() => browserless.destroyContext())
|
|
297
|
+
return browserless
|
|
298
|
+
})
|
|
299
|
+
methods.close = () => browser.close()
|
|
300
|
+
return methods
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
module.exports = createAi
|
|
304
|
+
module.exports.launch = launch
|
|
305
|
+
module.exports.unpack = require('./unpack')
|
|
306
|
+
module.exports.download = dest => {
|
|
307
|
+
const { credentials, downloadFile } = require('../scripts/util')
|
|
308
|
+
const env = credentials()
|
|
309
|
+
if (!env.endpoint || !env.bucket || !env.accessKey || !env.secretKey) {
|
|
310
|
+
throw new Error('set R2_ENDPOINT, R2_ACCESS_KEY_ID, and R2_SECRET_ACCESS_KEY')
|
|
311
|
+
}
|
|
312
|
+
return downloadFile(env, dest)
|
|
313
|
+
}
|