alexa-ai 2.2.1 → 2.4.0
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/CHANGELOG.md +39 -0
- package/README.md +35 -1
- package/examples/diagnose.js +134 -0
- package/examples/text2img-standalone.js +59 -18
- package/package.json +1 -1
- package/src/AlexaAI.js +9 -5
- package/src/core/Config.js +21 -0
- package/src/core/DeepAIClient.js +277 -44
- package/src/services/ImageDescriber.js +1 -1
- package/test/run-tests.js +128 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,45 @@ All notable changes to `alexa-ai` are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [2.4.0] — 2026-09-07
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **`detectNsfw()` (and every other `/api/*` helper) now works on free keys.**
|
|
11
|
+
A registered key refused with "Pro members in good standing" gets one
|
|
12
|
+
anonymous browser-dialect retry (`anonymousApiFallback`, on by default) —
|
|
13
|
+
the same mechanism that made `generateImage()` work. Applies to
|
|
14
|
+
`generateImage`, `editImage`, `upscaleImage`, `colorizeImage`,
|
|
15
|
+
`detectNsfw`, `summarizeText` and `deepai.runApi()`.
|
|
16
|
+
- `detectNsfw()` dropped its `opts` argument instead of passing it through.
|
|
17
|
+
- CLI output of the bundled examples is plain ASCII now — emoji and
|
|
18
|
+
typographic dashes rendered as mojibake in Windows consoles with a
|
|
19
|
+
non-UTF-8 codepage.
|
|
20
|
+
- When both the registered and the anonymous attempt fail, the error message
|
|
21
|
+
reports both refusals.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- `generateImage()` route 1 carries the browser fields into its built-in
|
|
25
|
+
anonymous retry, so a Pro refusal recovers directly with `via: 'api'`.
|
|
26
|
+
- Client media helpers accept an `options` argument (third/fourth parameter)
|
|
27
|
+
and default their anonymous dialect per endpoint (`generation_source=img`
|
|
28
|
+
for model pages; none for image-editor/torch-srgan, matching the site).
|
|
29
|
+
|
|
30
|
+
## [2.3.0] — 2026-09-07
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- **Multiple `/api/*` transports** (`transport` option / `DEEPAI_TRANSPORT`):
|
|
34
|
+
`'auto'` (default) tries `fetch`, then system `curl`, then a
|
|
35
|
+
`curl-impersonate` binary (Chrome TLS profile) when one is available;
|
|
36
|
+
`'fetch' | 'curl' | 'impersonate'` pin a single transport. A refusal of the
|
|
37
|
+
form "Please try this model on deepai.org" can be specific to the client
|
|
38
|
+
TLS stack, so `auto` retries that error through the next transport — quota
|
|
39
|
+
and auth errors are never re-driven. `curlImpersonatePath` /
|
|
40
|
+
`DEEPAI_CURL_IMPERSONATE` locate the binary.
|
|
41
|
+
- `examples/diagnose.js` — runs the browser-shaped request through every
|
|
42
|
+
transport with fresh keys and reports which one the network accepts.
|
|
43
|
+
- `examples/text2img-standalone.js` gained `--transport fetch|curl|impersonate`
|
|
44
|
+
and `--imp <path>`.
|
|
45
|
+
|
|
7
46
|
## [2.2.1] — 2026-09-07
|
|
8
47
|
|
|
9
48
|
### Added
|
package/README.md
CHANGED
|
@@ -872,7 +872,41 @@ separate requirements:
|
|
|
872
872
|
cookie value) also helps the per-device quota.
|
|
873
873
|
|
|
874
874
|
`examples/text2img-standalone.js` is a zero-dependency CLI implementing the
|
|
875
|
-
full recipe.
|
|
875
|
+
full recipe, and `examples/diagnose.js` runs the same request through every
|
|
876
|
+
transport on your machine and prints which one DeepAI accepts.
|
|
877
|
+
|
|
878
|
+
**`transport` option (2.3.0).** Some networks serve non-browser TLS stacks a
|
|
879
|
+
refusal (`"Please try this model on deepai.org"`) even with a perfectly valid
|
|
880
|
+
key. `/api/*` calls therefore support multiple transports:
|
|
881
|
+
|
|
882
|
+
```js
|
|
883
|
+
new AlexaAI({ key, postgresUrl, transport: 'auto' }) // default: fetch → curl → curl-impersonate
|
|
884
|
+
new AlexaAI({ key, postgresUrl, transport: 'curl' }) // system curl only
|
|
885
|
+
new AlexaAI({ key, postgresUrl, transport: 'impersonate', curlImpersonatePath: 'C:/tools/curl-impersonate.exe' })
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
With `transport: 'auto'` (the default) the engine starts with `fetch` and, on
|
|
889
|
+
a transport-specific refusal, retries the request through system `curl`
|
|
890
|
+
(shipped with Windows 10+, macOS and Linux) and then through a
|
|
891
|
+
`curl-impersonate` binary if one is on PATH (a Chrome TLS profile; set
|
|
892
|
+
`curlImpersonatePath` or `DEEPAI_CURL_IMPERSONATE` to point at it — builds:
|
|
893
|
+
github.com/lexiforest/curl-impersonate/releases). Quota and auth errors are
|
|
894
|
+
never re-driven through other transports. Run `node examples/diagnose.js` to
|
|
895
|
+
see which transport your network accepts.
|
|
896
|
+
|
|
897
|
+
**`anonymousApiFallback` (2.4.0).** Every `/api/*` helper — `generateImage`,
|
|
898
|
+
`editImage`, `upscaleImage`, `colorizeImage`, `detectNsfw`, `summarizeText`
|
|
899
|
+
and plain `deepai.runApi()` — automatically retries once with a fresh
|
|
900
|
+
anonymous key (browser dialect per endpoint) when the registered key is
|
|
901
|
+
refused with "Pro members in good standing". This is what the website does
|
|
902
|
+
for free visitors, so all of these now work on free keys from a residential
|
|
903
|
+
IP. Disable globally with `anonymousApiFallback: false` in the constructor.
|
|
904
|
+
On failure both refusals are reported (`<registered refusal> | anonymous
|
|
905
|
+
retry: <anonymous refusal>`).
|
|
906
|
+
|
|
907
|
+
**Windows consoles.** CLI output from the bundled examples is plain ASCII on
|
|
908
|
+
purpose — emoji and typographic dashes render as garbage (Chinese-looking
|
|
909
|
+
mojibake) in cmd.exe/PowerShell with a non-UTF-8 codepage.
|
|
876
910
|
|
|
877
911
|
**`generateImage()` returns `{ ok: false, error: 'DEEPAI_QUOTA_EXCEEDED' }`**
|
|
878
912
|
`/api/text2img` is Pro-only for registered keys ("APIs are only available for
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* diagnose.js - DeepAI text2img connectivity diagnostic.
|
|
5
|
+
*
|
|
6
|
+
* Run ON THE MACHINE where the browser playground works:
|
|
7
|
+
*
|
|
8
|
+
* node examples/diagnose.js
|
|
9
|
+
* node examples/diagnose.js --device-id <your deepai_device_id cookie>
|
|
10
|
+
* node examples/diagnose.js --imp /path/to/curl-impersonate
|
|
11
|
+
*
|
|
12
|
+
* Each test sends the browser-shaped request with a FRESH single-use key
|
|
13
|
+
* through a different transport and prints the raw server response, so one
|
|
14
|
+
* run shows exactly which transport DeepAI accepts on your network:
|
|
15
|
+
*
|
|
16
|
+
* 1. Node fetch (the library default)
|
|
17
|
+
* 2. system curl
|
|
18
|
+
* 3. curl-impersonate with a Chrome TLS profile (if the binary is found)
|
|
19
|
+
*
|
|
20
|
+
* Zero dependencies, Node 18+.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const API = 'https://api.deepai.org/api/text2img';
|
|
24
|
+
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
|
|
25
|
+
const PROFILE_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36';
|
|
26
|
+
const SALT = 'hackers_become_a_little_stinkier_every_time_they_hack';
|
|
27
|
+
|
|
28
|
+
// ---- key hash (same algorithm as DeepAIClient._islandHash) ----------------
|
|
29
|
+
function islandHash(input) {
|
|
30
|
+
const a = [];
|
|
31
|
+
for (let b = 0; 64 > b; ) a[b] = 0 | (4294967296 * Math.sin(++b % Math.PI));
|
|
32
|
+
let d, e, f, g = [(d = 1732584193), (e = 4023233417), ~d, ~e], h = [];
|
|
33
|
+
const l = unescape(encodeURI(input)) + '\u0080';
|
|
34
|
+
let k = l.length;
|
|
35
|
+
let c = (--k / 4 + 2) | 15;
|
|
36
|
+
for (h[--c] = 8 * k; ~k; ) h[k >> 2] |= l.charCodeAt(k) << (8 * k--);
|
|
37
|
+
for (let b = 0, m = 0; b < c; b += 16) {
|
|
38
|
+
for (k = g; 64 > m; k = [ (f = k[3]), d + (((f = k[0] + [d & e | ~d & f, f & d | ~f & e, d ^ e ^ f, e ^ (d | ~f)][(k = m >> 4)] + a[m] + ~~h[b | [m, 5 * m + 1, 3 * m + 5, 7 * m][k] & 15]) << (k = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21][4 * k + (m++ % 4)])) | (f >>> -k)), d, e ]) {
|
|
39
|
+
d = k[1] | 0;
|
|
40
|
+
e = k[2];
|
|
41
|
+
}
|
|
42
|
+
for (m = 4; m; ) g[--m] += k[m];
|
|
43
|
+
}
|
|
44
|
+
let result = '';
|
|
45
|
+
for (let i = 0; 32 > i; ) result += ((g[i >> 3] >> 4 * (1 ^ i++)) & 15).toString(16);
|
|
46
|
+
return result.split('').reverse().join('');
|
|
47
|
+
}
|
|
48
|
+
const freshKey = (ua) => {
|
|
49
|
+
const digits = String(Math.round(Math.random() * 100000000000));
|
|
50
|
+
return `tryit-${digits}-${islandHash(ua + islandHash(ua + islandHash(ua + digits + SALT)))}`;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ---- args -------------------------------------------------------------------
|
|
54
|
+
const args = process.argv.slice(2);
|
|
55
|
+
const opt = {};
|
|
56
|
+
for (let i = 0; i < args.length; i++) {
|
|
57
|
+
if (args[i] === '--device-id') opt.deviceId = args[++i];
|
|
58
|
+
else if (args[i] === '--imp') opt.imp = args[++i];
|
|
59
|
+
}
|
|
60
|
+
const deviceId = opt.deviceId || Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
|
|
61
|
+
const cookie = `deepai_device_id=${deviceId}`;
|
|
62
|
+
|
|
63
|
+
// ---- transports ---------------------------------------------------------------
|
|
64
|
+
async function viaFetch() {
|
|
65
|
+
const form = new FormData();
|
|
66
|
+
form.append('text', 'a small red boat on a calm lake');
|
|
67
|
+
form.append('generation_source', 'img');
|
|
68
|
+
const res = await fetch(API, {
|
|
69
|
+
method: 'POST', body: form,
|
|
70
|
+
headers: { 'api-key': freshKey(UA), 'User-Agent': UA, Origin: 'https://deepai.org', Referer: 'https://deepai.org/machine-learning-model/text2img', Accept: '*/*', Cookie: cookie },
|
|
71
|
+
});
|
|
72
|
+
return { status: res.status, body: await res.text() };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function runCurl(binary, impersonate) {
|
|
76
|
+
const { execFileSync } = require('child_process');
|
|
77
|
+
const ua = impersonate ? PROFILE_UA : UA;
|
|
78
|
+
const a = impersonate ? ['--impersonate', 'chrome136'] : [];
|
|
79
|
+
a.push(API, '-sS', '--compressed', '--max-time', '60', '-X', 'POST',
|
|
80
|
+
'-H', `api-key: ${freshKey(ua)}`);
|
|
81
|
+
if (!impersonate) a.push('-H', `User-Agent: ${ua}`);
|
|
82
|
+
a.push('-H', 'Origin: https://deepai.org',
|
|
83
|
+
'-H', 'Referer: https://deepai.org/machine-learning-model/text2img',
|
|
84
|
+
'-H', 'Accept: */*',
|
|
85
|
+
'-H', `Cookie: ${cookie}`,
|
|
86
|
+
'-F', 'text=a small red boat on a calm lake',
|
|
87
|
+
'-F', 'generation_source=img',
|
|
88
|
+
'-w', '\n%{http_code}');
|
|
89
|
+
const out = execFileSync(binary, a, { encoding: 'utf8', timeout: 60000, stdio: ['ignore', 'pipe', 'pipe'] }).replace(/\r/g, '');
|
|
90
|
+
const cut = out.lastIndexOf('\n');
|
|
91
|
+
return { status: Number(out.slice(cut + 1).trim()), body: out.slice(0, cut) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function label(t) {
|
|
95
|
+
const b = String(t.body).slice(0, 140).replace(/\s+/g, ' ');
|
|
96
|
+
if (t.status === 200 && /share_url|output_url/.test(t.body)) return `[OK] SUCCESS - image generated (HTTP 200)`;
|
|
97
|
+
if (/valid Api-Key/i.test(b)) return `[FAIL] key rejected (unexpected - fresh key was used)`;
|
|
98
|
+
if (/try this model/i.test(b)) return `[FAIL] transport refused ("Please try this model on deepai.org")`;
|
|
99
|
+
if (/Pro members/i.test(b)) return `[FAIL] account-level refusal (needs a Pro key)`;
|
|
100
|
+
if (/try it exceeded/i.test(b)) return `[WARN] free quota exhausted for this device/IP - retry later or change --device-id`;
|
|
101
|
+
return `[FAIL] HTTP ${t.status}: ${b}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
(async () => {
|
|
105
|
+
console.log(`DeepAI text2img diagnostic - device ${deviceId.slice(0, 8)}...\n`);
|
|
106
|
+
const rows = [];
|
|
107
|
+
try { rows.push(['1. Node fetch (library default)', label(await viaFetch())]); } catch (e) { rows.push(['1. Node fetch', `[WARN] ${e.message}`]); }
|
|
108
|
+
try { rows.push(['2. system curl', label(runCurl('curl', false))]); } catch (e) { rows.push(['2. system curl', `[WARN] ${e.message.split('\n')[0]}`]); }
|
|
109
|
+
|
|
110
|
+
let impBin = opt.imp;
|
|
111
|
+
if (!impBin) {
|
|
112
|
+
const { execFileSync } = require('child_process');
|
|
113
|
+
for (const name of ['curl-impersonate', 'curl-impersonate.exe']) {
|
|
114
|
+
try { execFileSync(name, ['--version'], { timeout: 5000, stdio: 'ignore' }); impBin = name; break; } catch { /* keep looking */ }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (impBin) {
|
|
118
|
+
try { rows.push([`3. curl-impersonate (${impBin})`, label(runCurl(impBin, true))]); } catch (e) { rows.push(['3. curl-impersonate', `[WARN] ${e.message.split('\n')[0]}`]); }
|
|
119
|
+
} else {
|
|
120
|
+
rows.push(['3. curl-impersonate', '[SKIP] skipped - binary not found (see README for install)']);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const [name, v] of rows) console.log(`${name.padEnd(34)} ${v}`);
|
|
124
|
+
console.log(`
|
|
125
|
+
Reading the results:
|
|
126
|
+
- A [OK] on ANY line -> that transport works; use it (library: transport option,
|
|
127
|
+
standalone: --transport).
|
|
128
|
+
- [FAIL] on line 1 only -> non-browser TLS stack refused; use 'curl' or
|
|
129
|
+
'impersonate'.
|
|
130
|
+
- [FAIL] on lines 1+2, [OK] on 3 -> strict browser-TLS matching; use 'impersonate'.
|
|
131
|
+
- [FAIL] everywhere -> the IP is refused for anonymous generation, or the free
|
|
132
|
+
quota is exhausted. Compare with the browser: DevTools
|
|
133
|
+
-> Network -> generate -> text2img request -> Response.`);
|
|
134
|
+
})();
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
/**
|
|
4
|
-
* text2img-standalone.js
|
|
4
|
+
* text2img-standalone.js - zero-dependency DeepAI text-to-image CLI.
|
|
5
5
|
*
|
|
6
|
-
* Speaks the anonymous browser dialect: a fresh single-use `tryit
|
|
6
|
+
* Speaks the anonymous browser dialect: a fresh single-use `tryit-...` key
|
|
7
7
|
* (hashed over the User-Agent) per run, browser-identical headers and a
|
|
8
8
|
* multipart/form-data body, plus a stable `deepai_device_id` cookie.
|
|
9
9
|
*
|
|
@@ -13,27 +13,34 @@
|
|
|
13
13
|
* node examples/text2img-standalone.js "a cat" --aspect 16:9
|
|
14
14
|
* node examples/text2img-standalone.js "a cat" --device-id <cookieValue>
|
|
15
15
|
* node examples/text2img-standalone.js "a cat" --key <proKey>
|
|
16
|
+
* node examples/text2img-standalone.js "a cat" --transport curl
|
|
17
|
+
* node examples/text2img-standalone.js "a cat" --transport impersonate --imp /path/to/curl-impersonate
|
|
16
18
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
+
* Transports: 'fetch' (default, Node fetch) | 'curl' (system curl) |
|
|
20
|
+
* 'impersonate' (curl-impersonate binary, Chrome TLS profile). Some
|
|
21
|
+
* networks serve non-browser TLS stacks a refusal - if 'fetch' fails with
|
|
22
|
+
* "Please try this model on deepai.org", try 'curl', then 'impersonate'.
|
|
23
|
+
*
|
|
24
|
+
* Requires Node.js 18+. Anonymous generation is refused from
|
|
25
|
+
* datacenter/VPN IPs; run from a residential network.
|
|
19
26
|
*/
|
|
20
27
|
|
|
21
28
|
const API_URL = 'https://api.deepai.org/api/text2img';
|
|
22
29
|
const SALT = 'hackers_become_a_little_stinkier_every_time_they_hack';
|
|
23
|
-
// Keep this EXACT string in sync with the User-Agent header below
|
|
30
|
+
// Keep this EXACT string in sync with the User-Agent header below - the key
|
|
24
31
|
// hash is computed over it and the server recomputes it from the request.
|
|
25
32
|
const USER_AGENT =
|
|
26
33
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
|
|
27
34
|
|
|
28
35
|
// ---------------------------------------------------------------------------
|
|
29
36
|
// Deterministic key hash (see DeepAIClient._islandHash in the engine).
|
|
30
|
-
// The integer/bit-level behaviour is intentional
|
|
37
|
+
// The integer/bit-level behaviour is intentional - do not simplify it.
|
|
31
38
|
// ---------------------------------------------------------------------------
|
|
32
39
|
function islandHash(input) {
|
|
33
40
|
const a = [];
|
|
34
41
|
for (let b = 0; 64 > b; ) a[b] = 0 | (4294967296 * Math.sin(++b % Math.PI));
|
|
35
42
|
let d, e, f, g = [(d = 1732584193), (e = 4023233417), ~d, ~e], h = [];
|
|
36
|
-
const l = unescape(encodeURI(input)) + '
|
|
43
|
+
const l = unescape(encodeURI(input)) + '\u0080';
|
|
37
44
|
let k = l.length;
|
|
38
45
|
let c = (--k / 4 + 2) | 15;
|
|
39
46
|
for (h[--c] = 8 * k; ~k; ) h[k >> 2] |= l.charCodeAt(k) << (8 * k--);
|
|
@@ -65,7 +72,7 @@ function randomDeviceId() {
|
|
|
65
72
|
// CLI
|
|
66
73
|
// ---------------------------------------------------------------------------
|
|
67
74
|
function parseArgs(argv) {
|
|
68
|
-
const opts = { prompt: '', out: null, key: null, deviceId: randomDeviceId(), aspect: null, genSource: 'img' };
|
|
75
|
+
const opts = { prompt: '', out: null, key: null, deviceId: randomDeviceId(), aspect: null, genSource: 'img', transport: 'fetch', imp: null };
|
|
69
76
|
for (let i = 0; i < argv.length; i++) {
|
|
70
77
|
const a = argv[i];
|
|
71
78
|
if (a === '--out') opts.out = argv[++i];
|
|
@@ -73,6 +80,8 @@ function parseArgs(argv) {
|
|
|
73
80
|
else if (a === '--device-id') opts.deviceId = argv[++i];
|
|
74
81
|
else if (a === '--aspect') opts.aspect = argv[++i];
|
|
75
82
|
else if (a === '--chat-source') opts.genSource = 'chat';
|
|
83
|
+
else if (a === '--transport') opts.transport = argv[++i];
|
|
84
|
+
else if (a === '--imp') opts.imp = argv[++i];
|
|
76
85
|
else if (a === '--help' || a === '-h') opts.help = true;
|
|
77
86
|
else if (!opts.prompt) opts.prompt = a;
|
|
78
87
|
}
|
|
@@ -84,7 +93,7 @@ const ASPECTS = { '16:9': [832, 448], '4:3': [768, 576], '1:1': [640, 640], '3:4
|
|
|
84
93
|
async function main() {
|
|
85
94
|
const opts = parseArgs(process.argv.slice(2));
|
|
86
95
|
if (opts.help || !opts.prompt) {
|
|
87
|
-
console.log('Usage: node examples/text2img-standalone.js "your prompt" [--out file.jpg] [--key PRO_KEY] [--device-id VALUE] [--aspect 16:9|1:1|9:16|4:3|3:4] [--chat-source]');
|
|
96
|
+
console.log('Usage: node examples/text2img-standalone.js "your prompt" [--out file.jpg] [--key PRO_KEY] [--device-id VALUE] [--aspect 16:9|1:1|9:16|4:3|3:4] [--chat-source] [--transport fetch|curl|impersonate] [--imp PATH]');
|
|
88
97
|
process.exit(opts.help ? 0 : 1);
|
|
89
98
|
}
|
|
90
99
|
|
|
@@ -111,15 +120,47 @@ async function main() {
|
|
|
111
120
|
Accept: '*/*',
|
|
112
121
|
'Accept-Language': 'en-US,en;q=0.9',
|
|
113
122
|
Cookie: `deepai_device_id=${opts.deviceId}`,
|
|
114
|
-
// NOTE: do NOT set Content-Type yourself
|
|
123
|
+
// NOTE: do NOT set Content-Type yourself - undici adds the multipart
|
|
115
124
|
// boundary. A manual Content-Type without the boundary is rejected.
|
|
116
125
|
};
|
|
117
126
|
|
|
118
127
|
console.log(`Prompt : ${opts.prompt}`);
|
|
119
|
-
console.log(`Key : ${opts.key ? '(registered key
|
|
128
|
+
console.log(`Key : ${opts.key ? '(registered key - needs Pro)' : apiKey + ' (fresh, single-use)'}`);
|
|
120
129
|
console.log('POST : ' + API_URL);
|
|
121
130
|
|
|
122
|
-
|
|
131
|
+
let res;
|
|
132
|
+
if (opts.transport === 'curl' || opts.transport === 'impersonate') {
|
|
133
|
+
const { execFile } = require('child_process');
|
|
134
|
+
const binary = opts.transport === 'impersonate' ? (opts.imp || 'curl-impersonate') : 'curl';
|
|
135
|
+
// the chrome136 profile sends its own Mac Chrome UA - the anonymous
|
|
136
|
+
// key hash must be derived from exactly that UA
|
|
137
|
+
const profileUa = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36';
|
|
138
|
+
const apiKey = opts.key || freshTryItKey(opts.transport === 'impersonate' ? profileUa : USER_AGENT);
|
|
139
|
+
const curlArgs = [];
|
|
140
|
+
if (opts.transport === 'impersonate') curlArgs.push('--impersonate', 'chrome136');
|
|
141
|
+
curlArgs.push(API_URL, '-sS', '--compressed', '--max-time', '120', '-X', 'POST',
|
|
142
|
+
'-H', `api-key: ${apiKey}`);
|
|
143
|
+
if (opts.transport !== 'impersonate') curlArgs.push('-H', `User-Agent: ${USER_AGENT}`);
|
|
144
|
+
curlArgs.push(
|
|
145
|
+
'-H', `Origin: ${headers.Origin}`,
|
|
146
|
+
'-H', `Referer: ${headers.Referer}`,
|
|
147
|
+
'-H', 'Accept: */*',
|
|
148
|
+
'-H', `Cookie: deepai_device_id=${opts.deviceId}`,
|
|
149
|
+
'-w', '\n%{http_code}'
|
|
150
|
+
);
|
|
151
|
+
for (const [k, v] of form.entries()) curlArgs.push('-F', `${k}=${v}`);
|
|
152
|
+
res = await new Promise((resolve, reject) => {
|
|
153
|
+
execFile(binary, curlArgs, { timeout: 120000, maxBuffer: 32 * 1024 * 1024 }, (err, stdout) => {
|
|
154
|
+
if (err && stdout == null) return reject(err);
|
|
155
|
+
const body = String(stdout).replace(/\r/g, '');
|
|
156
|
+
const cut = body.lastIndexOf('\n');
|
|
157
|
+
const status = Number(body.slice(cut + 1).trim());
|
|
158
|
+
resolve({ status, ok: status < 300, text: async () => body.slice(0, cut) });
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
} else {
|
|
162
|
+
res = await fetch(API_URL, { method: 'POST', headers, body: form });
|
|
163
|
+
}
|
|
123
164
|
const raw = await res.text();
|
|
124
165
|
let data = null;
|
|
125
166
|
try { data = JSON.parse(raw); } catch { /* non-JSON */ }
|
|
@@ -128,10 +169,10 @@ async function main() {
|
|
|
128
169
|
console.error(`\nFAILED HTTP ${res.status}`);
|
|
129
170
|
console.error(raw.slice(0, 500));
|
|
130
171
|
const s = String(data?.status || data?.err || '');
|
|
131
|
-
if (/valid Api-Key/i.test(s)) console.error('\n
|
|
132
|
-
else if (/try this model on deepai\.org/i.test(s)) console.error('\n
|
|
133
|
-
else if (/Pro members/i.test(s)) console.error('\n
|
|
134
|
-
else if (/try it exceeded/i.test(s)) console.error('\n
|
|
172
|
+
if (/valid Api-Key/i.test(s)) console.error('\n-> The key is invalid or already used. Keys are single-use and must be hashed for the exact User-Agent sent; this script mints a fresh one each run.');
|
|
173
|
+
else if (/try this model on deepai\.org/i.test(s)) console.error('\n-> DeepAI is refusing anonymous generation from your IP (datacenter/VPN) or the Origin header is missing. Run from a residential IP and keep the Origin/Referer headers.');
|
|
174
|
+
else if (/Pro members/i.test(s)) console.error('\n-> Your registered key is on the free plan; /api/* needs Pro. Use the anonymous mode (omit --key) or upgrade.');
|
|
175
|
+
else if (/try it exceeded/i.test(s)) console.error('\n-> Free quota for this device/IP is exhausted. Try a different --device-id or wait for the reset.');
|
|
135
176
|
process.exit(1);
|
|
136
177
|
}
|
|
137
178
|
|
|
@@ -149,10 +190,10 @@ async function main() {
|
|
|
149
190
|
require('fs').writeFileSync(out, buf);
|
|
150
191
|
console.log(`Saved : ${out} (${(buf.length / 1024).toFixed(1)} KB)`);
|
|
151
192
|
} else {
|
|
152
|
-
console.log(`(download skipped: HTTP ${img.status}
|
|
193
|
+
console.log(`(download skipped: HTTP ${img.status} - open the URL above in a browser)`);
|
|
153
194
|
}
|
|
154
195
|
} catch (e) {
|
|
155
|
-
console.log(`(download failed: ${e.message}
|
|
196
|
+
console.log(`(download failed: ${e.message} - open the URL above in a browser)`);
|
|
156
197
|
}
|
|
157
198
|
}
|
|
158
199
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "alexa-ai",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "AI engine for the Alexa WhatsApp bot: DeepAI-powered chat with PostgreSQL-backed long-term memory, cross-chat identity (@lid <-> phone), vision/OCR, image generation and web search.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
package/src/AlexaAI.js
CHANGED
|
@@ -642,10 +642,14 @@ class AlexaAI {
|
|
|
642
642
|
// size/quality fields).
|
|
643
643
|
if (!chatToolOnly) {
|
|
644
644
|
try {
|
|
645
|
-
const
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
645
|
+
const browserFields = AlexaAI._browserImageFields(aspectRatio || '1:1', apiFields);
|
|
646
|
+
const extra = this.client.usingTryItKey ? browserFields : apiFields;
|
|
647
|
+
const data = await this.client.text2img(text, extra, {
|
|
648
|
+
signal,
|
|
649
|
+
// used when a registered key is refused and runApi()
|
|
650
|
+
// retries anonymously
|
|
651
|
+
anonymousExtraFields: browserFields,
|
|
652
|
+
});
|
|
649
653
|
const url = AlexaAI._outputUrl(data);
|
|
650
654
|
if (url) return { ok: true, url, id: data.id || null, error: null, via: 'api', raw: data };
|
|
651
655
|
errors.push('text2img: no output_url in response');
|
|
@@ -784,7 +788,7 @@ class AlexaAI {
|
|
|
784
788
|
if (!field) return { ...AlexaAI._mediaError('detectNsfw', 'NSFW_FAILED'), score: null, nsfw: null };
|
|
785
789
|
const threshold = typeof opts.threshold === 'number' ? opts.threshold : 0.7;
|
|
786
790
|
try {
|
|
787
|
-
const data = await this.client.detectNsfw(field);
|
|
791
|
+
const data = await this.client.detectNsfw(field, {}, opts);
|
|
788
792
|
const score = typeof data?.output?.nsfw_score === 'number' ? data.output.nsfw_score : null;
|
|
789
793
|
return { ok: true, score, nsfw: score == null ? null : score >= threshold, error: null, raw: data };
|
|
790
794
|
} catch (err) {
|
package/src/core/Config.js
CHANGED
|
@@ -23,6 +23,8 @@ class Config {
|
|
|
23
23
|
* @param {string} [options.visionModel] Model used when images are attached
|
|
24
24
|
* @param {string[]} [options.visionModels] Vision fallback chain
|
|
25
25
|
* @param {string} [options.imageModel] Model used by generateImage()
|
|
26
|
+
* @param {string} [options.transport] /api/* transport: 'auto' (default) | 'fetch' | 'curl' | 'impersonate'
|
|
27
|
+
* @param {string} [options.curlImpersonatePath] Path to a curl-impersonate binary for the 'impersonate' transport
|
|
26
28
|
* @param {string} [options.assistantName] Persona name (default 'Alexa')
|
|
27
29
|
* @param {string} [options.creator] Persona creator (default 'Hansaka')
|
|
28
30
|
* @param {string} [options.systemPrompt] Override the whole persona text
|
|
@@ -112,6 +114,25 @@ class Config {
|
|
|
112
114
|
]);
|
|
113
115
|
this.imageModel = opts.imageModel || 'text2img';
|
|
114
116
|
|
|
117
|
+
// ---- /api/* transport -------------------------------------------------
|
|
118
|
+
// 'auto' fetch first, then system curl, then curl-impersonate
|
|
119
|
+
// when the binary is available (some networks serve
|
|
120
|
+
// non-browser TLS stacks a refusal page).
|
|
121
|
+
// 'fetch' Node global fetch only (previous behaviour)
|
|
122
|
+
// 'curl' system curl subprocess only
|
|
123
|
+
// 'impersonate' curl-impersonate subprocess only (Chrome TLS profile)
|
|
124
|
+
this.transport = opts.transport || process.env.DEEPAI_TRANSPORT || 'auto';
|
|
125
|
+
this.curlPath = opts.curlPath || process.env.DEEPAI_CURL || 'curl';
|
|
126
|
+
this.curlImpersonatePath =
|
|
127
|
+
opts.curlImpersonatePath || process.env.DEEPAI_CURL_IMPERSONATE || null;
|
|
128
|
+
this.curlImpersonateTarget = opts.curlImpersonateTarget || 'chrome136';
|
|
129
|
+
// ---- /api/* anonymous fallback ----------------------------------------
|
|
130
|
+
// When a registered key is refused ("Pro members in good standing"),
|
|
131
|
+
// retry once with a fresh anonymous key in the browser dialect
|
|
132
|
+
// (options.anonymousExtraFields). Mirrors how the website keeps
|
|
133
|
+
// working for free visitors. Disable with anonymousApiFallback:false.
|
|
134
|
+
this.anonymousApiFallback = opts.anonymousApiFallback !== false;
|
|
135
|
+
|
|
115
136
|
// ---- Anonymous device identity ---------------------------------------
|
|
116
137
|
// Stable device identifier sent as the `deepai_device_id` cookie.
|
|
117
138
|
// Anonymous /api/* generation is rate-limited per device, so the id
|
package/src/core/DeepAIClient.js
CHANGED
|
@@ -615,36 +615,271 @@ class DeepAIClient {
|
|
|
615
615
|
* @returns {Promise<object>} e.g. `{ id, output_url }`
|
|
616
616
|
*/
|
|
617
617
|
async runApi(name, fields = {}, options = {}) {
|
|
618
|
-
|
|
619
|
-
|
|
618
|
+
try {
|
|
619
|
+
const url = `${this.config.url('api')}/${String(name).replace(/^\/+/, '')}`;
|
|
620
|
+
const entries = this._buildApiFields(fields, options);
|
|
621
|
+
return await this._apiFormRequest(url, entries, options);
|
|
622
|
+
} catch (err) {
|
|
623
|
+
// A registered key refused for plan reasons ("Pro members in
|
|
624
|
+
// good standing") gets one anonymous browser-shaped retry, the
|
|
625
|
+
// same way the website keeps serving free visitors.
|
|
626
|
+
if (
|
|
627
|
+
err instanceof QuotaExceededError &&
|
|
628
|
+
this.config.anonymousApiFallback !== false &&
|
|
629
|
+
!options._anonymous &&
|
|
630
|
+
!this.usingTryItKey
|
|
631
|
+
) {
|
|
632
|
+
const anonFields = { ...(options.anonymousExtraFields || {}), ...fields };
|
|
633
|
+
if (this.config.debug) this.log.warn?.(`[AlexaAI] ${name} refused for the registered key; retrying anonymously`);
|
|
634
|
+
return this._runAnonymousApi(name, anonFields, options).catch((anonErr) => {
|
|
635
|
+
anonErr.message = `${err.message} | anonymous retry: ${anonErr.message}`;
|
|
636
|
+
throw anonErr;
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
throw err;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Run one `/api/<name>` call with a one-shot anonymous key regardless of
|
|
645
|
+
* the configured key. The active keys are swapped out for the duration
|
|
646
|
+
* of the call and restored afterwards.
|
|
647
|
+
* @private
|
|
648
|
+
*/
|
|
649
|
+
async _runAnonymousApi(name, fields, options = {}) {
|
|
650
|
+
const previousKeys = this._keys;
|
|
651
|
+
const previousIndex = this._keyIndex;
|
|
652
|
+
this._keys = [DeepAIClient.generateTryItKey(this.config.userAgent)];
|
|
653
|
+
this._keyIndex = 0;
|
|
654
|
+
try {
|
|
655
|
+
return await this.runApi(name, fields, { ...options, _anonymous: true });
|
|
656
|
+
} finally {
|
|
657
|
+
this._keys = previousKeys;
|
|
658
|
+
this._keyIndex = previousIndex;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Normalise API form fields into a transport-neutral entry list:
|
|
664
|
+
* `[[key, { value | buffer, mimetype, filename }], …]`.
|
|
665
|
+
* @private
|
|
666
|
+
*/
|
|
667
|
+
_buildApiFields(fields, options = {}) {
|
|
668
|
+
const entries = [];
|
|
669
|
+
for (const [key, value] of Object.entries(fields || {})) {
|
|
620
670
|
if (value == null) continue;
|
|
621
|
-
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
|
|
622
|
-
|
|
623
|
-
form.append(key, new Blob([value], { type: mimetype }), options.filename || `${key}.${DeepAIClient._ext(mimetype)}`);
|
|
671
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array || value instanceof Blob) {
|
|
672
|
+
entries.push([key, { buffer: value, mimetype: options.mimetype, filename: options.filename }]);
|
|
624
673
|
} else if (typeof value === 'object' && (value.buffer || value.url)) {
|
|
625
674
|
if (value.url && !value.buffer) {
|
|
626
|
-
|
|
627
|
-
|
|
675
|
+
entries.push([key, { value: String(value.url) }]);
|
|
676
|
+
} else {
|
|
677
|
+
entries.push([
|
|
678
|
+
key,
|
|
679
|
+
{
|
|
680
|
+
buffer: Buffer.isBuffer(value.buffer) ? value.buffer : Buffer.from(value.buffer),
|
|
681
|
+
mimetype: value.mimetype,
|
|
682
|
+
filename: value.filename,
|
|
683
|
+
},
|
|
684
|
+
]);
|
|
628
685
|
}
|
|
629
|
-
const bytes = Buffer.isBuffer(value.buffer) ? value.buffer : Buffer.from(value.buffer);
|
|
630
|
-
const mimetype = value.mimetype || DeepAIClient._sniffMime(bytes) || 'application/octet-stream';
|
|
631
|
-
form.append(key, new Blob([bytes], { type: mimetype }), value.filename || `${key}.${DeepAIClient._ext(mimetype)}`);
|
|
632
686
|
} else if (typeof value === 'object') {
|
|
633
|
-
|
|
687
|
+
entries.push([key, { value: JSON.stringify(value) }]);
|
|
688
|
+
} else {
|
|
689
|
+
entries.push([key, { value: String(value) }]);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return entries;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* POST a multipart form to a `/api/*` URL across the configured transport
|
|
697
|
+
* chain. A refusal of the form "Please try this model on deepai.org" can
|
|
698
|
+
* be transport-specific (non-browser TLS stacks receive it even with a
|
|
699
|
+
* perfectly valid key), so on that error the next transport is tried;
|
|
700
|
+
* every other error (quota, auth, network) is final for this request.
|
|
701
|
+
* @private
|
|
702
|
+
*/
|
|
703
|
+
async _apiFormRequest(url, entries, options = {}) {
|
|
704
|
+
const chain = await this._transportChain();
|
|
705
|
+
let lastError;
|
|
706
|
+
for (const transport of chain) {
|
|
707
|
+
try {
|
|
708
|
+
const raw = await this._runApiTransport(transport, url, entries, options);
|
|
709
|
+
const data = DeepAIClient._safeJson(raw.body);
|
|
710
|
+
if (raw.status > 299 || data === null) {
|
|
711
|
+
throw DeepAIClient._toError(raw.status, raw.body, data?.status || data?.error);
|
|
712
|
+
}
|
|
713
|
+
if (data?.err) {
|
|
714
|
+
throw DeepAIClient._toError(200, JSON.stringify(data), String(data.err));
|
|
715
|
+
}
|
|
716
|
+
if (typeof data?.status === 'string' && !data.share_url && !data.output_url && !data.output && !data.id) {
|
|
717
|
+
throw DeepAIClient._toError(200, JSON.stringify(data), data.status);
|
|
718
|
+
}
|
|
719
|
+
return data;
|
|
720
|
+
} catch (err) {
|
|
721
|
+
if (DeepAIClient._isTransportRejected(err) && chain.length > 1) {
|
|
722
|
+
lastError = err;
|
|
723
|
+
if (this.config.debug) this.log.warn?.(`[AlexaAI] ${transport} transport refused for ${url}; trying the next`);
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
if (err instanceof QuotaExceededError || err.retryable === false || err.code === 'ABORTED') throw err;
|
|
727
|
+
throw err;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
throw lastError || new DeepAIError('DeepAI request failed', { code: 'DEEPAI_ERROR' });
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** @private */
|
|
734
|
+
static _isTransportRejected(err) {
|
|
735
|
+
return /try this model on deepai\.org/i.test(String(err?.message || ''));
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Ordered transport list for /api/* calls. @private */
|
|
739
|
+
async _transportChain() {
|
|
740
|
+
const t = this.config.transport;
|
|
741
|
+
if (t === 'fetch') return ['fetch'];
|
|
742
|
+
if (t === 'curl') return ['curl'];
|
|
743
|
+
if (t === 'impersonate') return ['impersonate'];
|
|
744
|
+
const chain = ['fetch', 'curl'];
|
|
745
|
+
if (await DeepAIClient.resolveImpersonateBinary(this.config)) chain.push('impersonate');
|
|
746
|
+
return chain;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** @private */
|
|
750
|
+
async _runApiTransport(transport, url, entries, options) {
|
|
751
|
+
if (transport === 'fetch') return this._runApiFetch(url, entries, options);
|
|
752
|
+
const impersonate = transport === 'impersonate';
|
|
753
|
+
const binary = impersonate
|
|
754
|
+
? await DeepAIClient.resolveImpersonateBinary(this.config)
|
|
755
|
+
: this.config.curlPath;
|
|
756
|
+
return this._runApiCurl(binary, url, entries, options, { impersonate });
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** @private global-fetch transport (previous behaviour). */
|
|
760
|
+
async _runApiFetch(url, entries, options = {}) {
|
|
761
|
+
const form = new FormData();
|
|
762
|
+
for (const [key, field] of entries) {
|
|
763
|
+
if (field.buffer != null) {
|
|
764
|
+
const bytes = field.buffer instanceof Blob ? Buffer.from(await field.buffer.arrayBuffer()) : field.buffer;
|
|
765
|
+
const mimetype = field.mimetype || DeepAIClient._sniffMime(bytes) || 'application/octet-stream';
|
|
766
|
+
form.append(key, new Blob([bytes], { type: mimetype }), field.filename || `${key}.${DeepAIClient._ext(mimetype)}`);
|
|
634
767
|
} else {
|
|
635
|
-
form.append(key,
|
|
768
|
+
form.append(key, field.value);
|
|
636
769
|
}
|
|
637
770
|
}
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
771
|
+
const data = await this._json(url, { method: 'POST', body: form, signal: options.signal, errorCode: 'BAD_RESPONSE' });
|
|
772
|
+
return { status: 200, body: JSON.stringify(data) };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* The User-Agent a curl-impersonate binary sends for the configured
|
|
777
|
+
* target profile. The anonymous key hash must be derived from the exact
|
|
778
|
+
* UA the request carries, so impersonated requests use the profile's own
|
|
779
|
+
* UA instead of `config.userAgent`.
|
|
780
|
+
* @private
|
|
781
|
+
*/
|
|
782
|
+
static _impersonateUserAgent(target) {
|
|
783
|
+
const m = /chrome(\d+)/i.exec(String(target || ''));
|
|
784
|
+
const v = m ? m[1] : '136';
|
|
785
|
+
return `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${v}.0.0.0 Safari/537.36`;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** @private curl / curl-impersonate subprocess transport. */
|
|
789
|
+
async _runApiCurl(binary, url, entries, options = {}, { impersonate = false } = {}) {
|
|
790
|
+
const ua = impersonate ? DeepAIClient._impersonateUserAgent(this.config.curlImpersonateTarget) : this.config.userAgent;
|
|
791
|
+
const apiKey = DeepAIClient.isTryItKey(this.apiKey)
|
|
792
|
+
? DeepAIClient.generateTryItKey(ua)
|
|
793
|
+
: this.apiKey;
|
|
794
|
+
|
|
795
|
+
const args = impersonate ? ['--impersonate', this.config.curlImpersonateTarget] : [];
|
|
796
|
+
args.push(
|
|
797
|
+
url,
|
|
798
|
+
'-sS', '--compressed',
|
|
799
|
+
'--max-time', String(Math.max(1, Math.round(this.config.timeout / 1000))),
|
|
800
|
+
'-X', 'POST',
|
|
801
|
+
'-H', `api-key: ${apiKey}`,
|
|
802
|
+
'-H', `User-Agent: ${ua}`,
|
|
803
|
+
'-H', `Origin: ${this.config.origin}`,
|
|
804
|
+
'-H', `Referer: ${this.config.origin}/machine-learning-model/${this.config.imageModel}`,
|
|
805
|
+
'-H', 'Accept: */*',
|
|
806
|
+
'-H', 'Accept-Language: en-US,en;q=0.9',
|
|
807
|
+
'-w', '\n%{http_code}'
|
|
808
|
+
);
|
|
809
|
+
if (this.deviceId) args.push('-H', `Cookie: deepai_device_id=${this.deviceId}`);
|
|
810
|
+
|
|
811
|
+
const tmpFiles = [];
|
|
812
|
+
try {
|
|
813
|
+
for (const [key, field] of entries) {
|
|
814
|
+
if (field.buffer != null) {
|
|
815
|
+
const bytes = field.buffer instanceof Blob ? Buffer.from(await field.buffer.arrayBuffer()) : field.buffer;
|
|
816
|
+
const mimetype = field.mimetype || DeepAIClient._sniffMime(bytes) || 'application/octet-stream';
|
|
817
|
+
const ext = DeepAIClient._ext(mimetype);
|
|
818
|
+
const tmp = require('fs').mkdtempSync(require('path').join(require('os').tmpdir(), 'alexa-')) + `/${key}.${ext}`;
|
|
819
|
+
require('fs').writeFileSync(tmp, bytes);
|
|
820
|
+
tmpFiles.push(tmp);
|
|
821
|
+
args.push('-F', `${key}=@${tmp};type=${mimetype}`);
|
|
822
|
+
} else {
|
|
823
|
+
args.push('-F', `${key}=${field.value}`);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
const out = await DeepAIClient.execCurl(binary, args, this.config.timeout, options.signal);
|
|
827
|
+
const body = String(out).replace(/\r/g, '');
|
|
828
|
+
const idx = body.lastIndexOf('\n');
|
|
829
|
+
const status = Number(body.slice(idx + 1).trim());
|
|
830
|
+
if (!Number.isFinite(status)) {
|
|
831
|
+
throw new DeepAIError(`curl transport failed for ${url}: ${body.slice(0, 200)}`, {
|
|
832
|
+
code: 'DEEPAI_NETWORK', retryable: true,
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
return { status, body: body.slice(0, idx) };
|
|
836
|
+
} finally {
|
|
837
|
+
for (const f of tmpFiles) { try { require('fs').unlinkSync(f); } catch { /* best effort */ } try { require('fs').rmSync(require('path').dirname(f), { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
643
838
|
}
|
|
644
|
-
|
|
645
|
-
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Locate a curl-impersonate binary: explicit path, then the usual
|
|
843
|
+
* executable names on PATH. Cached per path. Tests may override.
|
|
844
|
+
* @private
|
|
845
|
+
*/
|
|
846
|
+
static async resolveImpersonateBinary(config) {
|
|
847
|
+
if (config.curlImpersonatePath) return config.curlImpersonatePath;
|
|
848
|
+
if (config._noImpersonateBinary) return null;
|
|
849
|
+
if (!DeepAIClient._impersonateCache) {
|
|
850
|
+
const { execFile } = require('child_process');
|
|
851
|
+
const names = process.platform === 'win32' ? ['curl-impersonate.exe', 'curl-impersonate'] : ['curl-impersonate'];
|
|
852
|
+
DeepAIClient._impersonateCache = new Promise((resolve) => {
|
|
853
|
+
let i = 0;
|
|
854
|
+
const tryNext = () => {
|
|
855
|
+
if (i >= names.length) return resolve(null);
|
|
856
|
+
const name = names[i++];
|
|
857
|
+
execFile(name, ['--version'], { timeout: 5000 }, (err) => resolve(err ? tryNext() : name));
|
|
858
|
+
};
|
|
859
|
+
tryNext();
|
|
860
|
+
});
|
|
646
861
|
}
|
|
647
|
-
return
|
|
862
|
+
return DeepAIClient._impersonateCache;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Run a curl-compatible binary and capture stdout. Separated so tests
|
|
867
|
+
* can stub the subprocess layer.
|
|
868
|
+
* @private
|
|
869
|
+
*/
|
|
870
|
+
static async execCurl(binary, args, timeoutMs, signal) {
|
|
871
|
+
const { execFile } = require('child_process');
|
|
872
|
+
return new Promise((resolve, reject) => {
|
|
873
|
+
const child = execFile(binary, args, { timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024, windowsHide: true }, (err, stdout, stderr) => {
|
|
874
|
+
if (err && stdout == null) return reject(new DeepAIError(`curl transport error: ${err.message}`, { code: 'DEEPAI_NETWORK', retryable: true }));
|
|
875
|
+
resolve(stdout != null ? stdout : '');
|
|
876
|
+
void stderr;
|
|
877
|
+
});
|
|
878
|
+
if (signal) {
|
|
879
|
+
if (signal.aborted) child.kill();
|
|
880
|
+
else signal.addEventListener('abort', () => child.kill(), { once: true });
|
|
881
|
+
}
|
|
882
|
+
});
|
|
648
883
|
}
|
|
649
884
|
|
|
650
885
|
/** Text-to-image (`/api/text2img`). Returns `{ id, output_url }`. */
|
|
@@ -653,47 +888,45 @@ class DeepAIClient {
|
|
|
653
888
|
}
|
|
654
889
|
|
|
655
890
|
/**
|
|
656
|
-
* Run a classic `/api/<name>` call with a one-shot anonymous
|
|
657
|
-
* regardless of the configured key
|
|
658
|
-
* registered key is refused ("Pro members only"); a fresh key is minted
|
|
659
|
-
* for this single request.
|
|
891
|
+
* Run a classic `/api/<name>` call with a one-shot anonymous key,
|
|
892
|
+
* regardless of the configured key (public wrapper).
|
|
660
893
|
*/
|
|
661
894
|
async runApiWithTryItKey(name, fields = {}, options = {}) {
|
|
662
|
-
|
|
663
|
-
const previousIndex = this._keyIndex;
|
|
664
|
-
this._keys = [DeepAIClient.generateTryItKey(this.config.userAgent)];
|
|
665
|
-
this._keyIndex = 0;
|
|
666
|
-
try {
|
|
667
|
-
return await this.runApi(name, fields, options);
|
|
668
|
-
} finally {
|
|
669
|
-
this._keys = previousKeys;
|
|
670
|
-
this._keyIndex = previousIndex;
|
|
671
|
-
}
|
|
895
|
+
return this._runAnonymousApi(name, { ...(options.anonymousExtraFields || {}), ...fields }, { ...options, _anonymous: true });
|
|
672
896
|
}
|
|
673
897
|
|
|
674
898
|
/** Prompt-driven image edit (`/api/image-editor`). */
|
|
675
|
-
async editImage(image, text, extra = {}) {
|
|
676
|
-
return this.runApi(STANDARD_APIS.imageEditor, { image, text, ...extra });
|
|
899
|
+
async editImage(image, text, extra = {}, options = {}) {
|
|
900
|
+
return this.runApi(STANDARD_APIS.imageEditor, { image, text, ...extra }, options);
|
|
677
901
|
}
|
|
678
902
|
|
|
679
903
|
/** 4x upscale (`/api/torch-srgan`). */
|
|
680
|
-
async upscaleImage(image, extra = {}) {
|
|
681
|
-
return this.runApi(STANDARD_APIS.superResolution, { image, ...extra });
|
|
904
|
+
async upscaleImage(image, extra = {}, options = {}) {
|
|
905
|
+
return this.runApi(STANDARD_APIS.superResolution, { image, ...extra }, options);
|
|
682
906
|
}
|
|
683
907
|
|
|
684
908
|
/** Colourise a black-and-white photo (`/api/colorizer`). */
|
|
685
|
-
async colorizeImage(image, extra = {}) {
|
|
686
|
-
return this.runApi(STANDARD_APIS.colorizer, { image, ...extra }
|
|
909
|
+
async colorizeImage(image, extra = {}, options = {}) {
|
|
910
|
+
return this.runApi(STANDARD_APIS.colorizer, { image, ...extra }, {
|
|
911
|
+
anonymousExtraFields: { generation_source: 'img' },
|
|
912
|
+
...options,
|
|
913
|
+
});
|
|
687
914
|
}
|
|
688
915
|
|
|
689
916
|
/** NSFW score (`/api/nsfw-detector`). */
|
|
690
|
-
async detectNsfw(image, extra = {}) {
|
|
691
|
-
return this.runApi(STANDARD_APIS.nsfwDetector, { image, ...extra }
|
|
917
|
+
async detectNsfw(image, extra = {}, options = {}) {
|
|
918
|
+
return this.runApi(STANDARD_APIS.nsfwDetector, { image, ...extra }, {
|
|
919
|
+
anonymousExtraFields: { generation_source: 'img' },
|
|
920
|
+
...options,
|
|
921
|
+
});
|
|
692
922
|
}
|
|
693
923
|
|
|
694
924
|
/** Abstractive summary (`/api/summarization`). */
|
|
695
|
-
async summarize(text, extra = {}) {
|
|
696
|
-
return this.runApi(STANDARD_APIS.summarization, { text, ...extra }
|
|
925
|
+
async summarize(text, extra = {}, options = {}) {
|
|
926
|
+
return this.runApi(STANDARD_APIS.summarization, { text, ...extra }, {
|
|
927
|
+
anonymousExtraFields: { generation_source: 'img' },
|
|
928
|
+
...options,
|
|
929
|
+
});
|
|
697
930
|
}
|
|
698
931
|
|
|
699
932
|
/** Sentiment labels (`/api/sentiment-analysis`). */
|
|
@@ -154,7 +154,7 @@ class ImageDescriber {
|
|
|
154
154
|
sawRefusal = true;
|
|
155
155
|
this._modelsRefused.add(model);
|
|
156
156
|
if (this.config.debug) {
|
|
157
|
-
this.log.warn?.(`[AlexaAI] ${model} cannot see attachments
|
|
157
|
+
this.log.warn?.(`[AlexaAI] ${model} cannot see attachments - trying the next model`);
|
|
158
158
|
}
|
|
159
159
|
continue;
|
|
160
160
|
}
|
package/test/run-tests.js
CHANGED
|
@@ -695,7 +695,7 @@ section('DeepAIClient — the whole endpoint surface (mocked transport)');
|
|
|
695
695
|
const ai = new AlexaAI({ key: '11111111-2222-3333-4444-555555555555', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false });
|
|
696
696
|
const result = await ai.generateImage('a cute orange cat', { aspectRatio: '16:9' });
|
|
697
697
|
global.fetch = realFetch;
|
|
698
|
-
ok('generateImage recovers
|
|
698
|
+
ok('generateImage recovers through the built-in anonymous fallback', result.ok === true && result.via === 'api');
|
|
699
699
|
const anon = calls.find((c) => DeepAIClient.isTryItKey(c.key));
|
|
700
700
|
ok(
|
|
701
701
|
'anonymous retry carries the browser fields',
|
|
@@ -707,8 +707,134 @@ section('DeepAIClient — the whole endpoint surface (mocked transport)');
|
|
|
707
707
|
anon.form.quality === 'true'
|
|
708
708
|
);
|
|
709
709
|
ok('share_url is preferred over output_url', result.url === 'https://deepai.org/generated-image.png');
|
|
710
|
+
|
|
711
|
+
// detectNsfw: same Pro refusal, same anonymous recovery
|
|
712
|
+
calls.length = 0;
|
|
713
|
+
global.fetch = async (url, init = {}) => {
|
|
714
|
+
const form = {};
|
|
715
|
+
for (const [k, v] of init.body.entries()) form[k] = v;
|
|
716
|
+
calls.push({ url, key: init.headers['api-key'], form });
|
|
717
|
+
const first = calls.filter((c) => /nsfw-detector$/.test(c.url)).length === 1;
|
|
718
|
+
return {
|
|
719
|
+
status: first ? 402 : 200,
|
|
720
|
+
headers: { get: () => 'application/json' },
|
|
721
|
+
text: async () =>
|
|
722
|
+
first
|
|
723
|
+
? JSON.stringify({ status: 'APIs are only available for Pro members in good standing' })
|
|
724
|
+
: JSON.stringify({ id: 'n1', output: { nsfw_score: 0.13 } }),
|
|
725
|
+
};
|
|
726
|
+
};
|
|
727
|
+
const nsfw = await ai.detectNsfw(Buffer.from('fake-image-bytes'));
|
|
728
|
+
global.fetch = realFetch;
|
|
729
|
+
ok('detectNsfw recovers through the anonymous fallback', nsfw.ok === true && nsfw.score === 0.13 && nsfw.nsfw === false);
|
|
730
|
+
const anonNsfw = calls.find((c) => DeepAIClient.isTryItKey(c.key));
|
|
731
|
+
ok('anonymous nsfw retry uses the model-page dialect', anonNsfw && anonNsfw.form.generation_source === 'img');
|
|
732
|
+
|
|
733
|
+
// anonymousApiFallback:false keeps the refusal
|
|
734
|
+
const strict = new AlexaAI({ key: '11111111-2222-3333-4444-555555555555', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false, anonymousApiFallback: false });
|
|
735
|
+
calls.length = 0;
|
|
736
|
+
let fetchCalls = 0;
|
|
737
|
+
global.fetch = async (url, init = {}) => {
|
|
738
|
+
fetchCalls++;
|
|
739
|
+
return { status: 402, headers: { get: () => 'application/json' }, text: async () => JSON.stringify({ status: 'APIs are only available for Pro members in good standing' }) };
|
|
740
|
+
};
|
|
741
|
+
const refused = await strict.detectNsfw(Buffer.from('fake-image-bytes'));
|
|
742
|
+
global.fetch = realFetch;
|
|
743
|
+
ok('anonymousApiFallback:false surfaces the refusal', refused.ok === false && refused.error === 'DEEPAI_QUOTA_EXCEEDED');
|
|
744
|
+
ok('no anonymous retry was made', fetchCalls === 1);
|
|
710
745
|
}
|
|
711
|
-
|
|
746
|
+
|
|
747
|
+
{
|
|
748
|
+
section('DeepAIClient — /api transport chain');
|
|
749
|
+
|
|
750
|
+
const realFetch = global.fetch;
|
|
751
|
+
const realExecCurl = DeepAIClient.execCurl;
|
|
752
|
+
const realCache = DeepAIClient._impersonateCache;
|
|
753
|
+
|
|
754
|
+
// 1. a transport-specific refusal ("Please try this model") falls through
|
|
755
|
+
// to the next transport (fetch -> curl).
|
|
756
|
+
{
|
|
757
|
+
let curlCall = null;
|
|
758
|
+
global.fetch = async () => ({
|
|
759
|
+
status: 401,
|
|
760
|
+
headers: { get: () => 'application/json' },
|
|
761
|
+
text: async () => JSON.stringify({ status: 'Please try this model on deepai.org' }),
|
|
762
|
+
});
|
|
763
|
+
DeepAIClient.execCurl = async (bin, args) => {
|
|
764
|
+
curlCall = { bin, args };
|
|
765
|
+
return '{"id":"t1","share_url":"https://deepai.org/x.png"}\n200';
|
|
766
|
+
};
|
|
767
|
+
DeepAIClient._impersonateCache = Promise.resolve(null);
|
|
768
|
+
const client = new DeepAIClient(new Config({ key: 'tryit-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', postgresUrl: 'postgres://u:p@localhost/db', maxRetries: 0 }));
|
|
769
|
+
const data = await client.runApi('text2img', { text: 'cat', generation_source: 'img' });
|
|
770
|
+
check('curl transport recovers a fetch refusal', data.share_url, 'https://deepai.org/x.png');
|
|
771
|
+
ok('curl transport received the form fields', curlCall.args.includes('text=cat') && curlCall.args.includes('generation_source=img'));
|
|
772
|
+
const key = curlCall.args.find((a) => a.startsWith('api-key: ')).slice(9);
|
|
773
|
+
ok('curl transport used a fresh anonymous key', DeepAIClient.isTryItKey(key));
|
|
774
|
+
ok('curl transport sends the Origin header', curlCall.args.includes('Origin: https://deepai.org'));
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// 2. quota refusals are final — no transport cascade.
|
|
778
|
+
{
|
|
779
|
+
let curlCalled = false;
|
|
780
|
+
global.fetch = async () => ({
|
|
781
|
+
status: 402,
|
|
782
|
+
headers: { get: () => 'application/json' },
|
|
783
|
+
text: async () => JSON.stringify({ status: 'APIs are only available for Pro members in good standing' }),
|
|
784
|
+
});
|
|
785
|
+
DeepAIClient.execCurl = async () => { curlCalled = true; return '{}\n200'; };
|
|
786
|
+
const client = new DeepAIClient(new Config({ key: 'k', postgresUrl: 'postgres://u:p@localhost/db', maxRetries: 0 }));
|
|
787
|
+
let thrown = null;
|
|
788
|
+
try { await client.runApi('text2img', { text: 'cat' }); } catch (e) { thrown = e; }
|
|
789
|
+
ok('quota refusal throws QuotaExceededError', thrown?.name === 'QuotaExceededError' || thrown?.code === 'DEEPAI_QUOTA_EXCEEDED');
|
|
790
|
+
ok('no other transport is tried on quota refusals', curlCalled === false);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// 3. the impersonate transport uses the profile UA and derives the key
|
|
794
|
+
// hash from it.
|
|
795
|
+
{
|
|
796
|
+
let call = null;
|
|
797
|
+
DeepAIClient.execCurl = async (bin, args) => { call = { bin, args }; return '{"id":"t2","output_url":"https://deepai.org/y.png"}\n200'; };
|
|
798
|
+
const client = new DeepAIClient(new Config({
|
|
799
|
+
key: 'tryit-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
800
|
+
postgresUrl: 'postgres://u:p@localhost/db',
|
|
801
|
+
transport: 'impersonate',
|
|
802
|
+
curlImpersonatePath: '/fake/curl-impersonate',
|
|
803
|
+
maxRetries: 0,
|
|
804
|
+
}));
|
|
805
|
+
const data = await client.runApi('text2img', { text: 'cat' });
|
|
806
|
+
check('impersonate transport returns the image', data.output_url, 'https://deepai.org/y.png');
|
|
807
|
+
check('impersonate binary and profile are passed', [call.bin, call.args[0], call.args[1]], ['/fake/curl-impersonate', '--impersonate', 'chrome136']);
|
|
808
|
+
const ua = call.args.find((a) => a.startsWith('User-Agent: ')).slice(12);
|
|
809
|
+
const key = call.args.find((a) => a.startsWith('api-key: ')).slice(9);
|
|
810
|
+
const m = /^tryit-(\d+)-([0-9a-f]{32})$/.exec(key);
|
|
811
|
+
const H = DeepAIClient._islandHash;
|
|
812
|
+
const salt = 'hackers_become_a_little_stinkier_every_time_they_hack';
|
|
813
|
+
ok('impersonate key hash matches the profile User-Agent', m && m[2] === H(ua + H(ua + H(ua + m[1] + salt))));
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// 4. transport:'fetch' never spawns a subprocess.
|
|
817
|
+
{
|
|
818
|
+
let curlCalled = false;
|
|
819
|
+
global.fetch = async () => ({
|
|
820
|
+
status: 401,
|
|
821
|
+
headers: { get: () => 'application/json' },
|
|
822
|
+
text: async () => JSON.stringify({ status: 'Please try this model on deepai.org' }),
|
|
823
|
+
});
|
|
824
|
+
DeepAIClient.execCurl = async () => { curlCalled = true; return '{}\n200'; };
|
|
825
|
+
const client = new DeepAIClient(new Config({ key: 'k', postgresUrl: 'postgres://u:p@localhost/db', transport: 'fetch', maxRetries: 0 }));
|
|
826
|
+
let thrown = null;
|
|
827
|
+
try { await client.runApi('text2img', { text: 'cat' }); } catch (e) { thrown = e; }
|
|
828
|
+
ok('fetch-only transport surfaces the refusal', thrown && /try this model/i.test(thrown.message));
|
|
829
|
+
ok('fetch-only transport never shells out', curlCalled === false);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
global.fetch = realFetch;
|
|
833
|
+
DeepAIClient.execCurl = realExecCurl;
|
|
834
|
+
DeepAIClient._impersonateCache = realCache;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const cfg = new Config({ key: 'k', postgresUrl: 'postgres://u:p@localhost/db' });
|
|
712
838
|
check('endpoint map exposes the chat route', cfg.url('chat'), 'https://api.deepai.org/hacking_is_a_serious_crime');
|
|
713
839
|
check(
|
|
714
840
|
'endpoint map builds query strings',
|