alexa-ai 2.2.1 → 2.3.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 +16 -0
- package/README.md +21 -1
- package/examples/diagnose.js +134 -0
- package/examples/text2img-standalone.js +46 -5
- package/package.json +1 -1
- package/src/core/Config.js +15 -0
- package/src/core/DeepAIClient.js +216 -20
- package/test/run-tests.js +92 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@ 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.3.0] — 2026-09-07
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Multiple `/api/*` transports** (`transport` option / `DEEPAI_TRANSPORT`):
|
|
11
|
+
`'auto'` (default) tries `fetch`, then system `curl`, then a
|
|
12
|
+
`curl-impersonate` binary (Chrome TLS profile) when one is available;
|
|
13
|
+
`'fetch' | 'curl' | 'impersonate'` pin a single transport. A refusal of the
|
|
14
|
+
form "Please try this model on deepai.org" can be specific to the client
|
|
15
|
+
TLS stack, so `auto` retries that error through the next transport — quota
|
|
16
|
+
and auth errors are never re-driven. `curlImpersonatePath` /
|
|
17
|
+
`DEEPAI_CURL_IMPERSONATE` locate the binary.
|
|
18
|
+
- `examples/diagnose.js` — runs the browser-shaped request through every
|
|
19
|
+
transport with fresh keys and reports which one the network accepts.
|
|
20
|
+
- `examples/text2img-standalone.js` gained `--transport fetch|curl|impersonate`
|
|
21
|
+
and `--imp <path>`.
|
|
22
|
+
|
|
7
23
|
## [2.2.1] — 2026-09-07
|
|
8
24
|
|
|
9
25
|
### Added
|
package/README.md
CHANGED
|
@@ -872,7 +872,27 @@ 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.
|
|
876
896
|
|
|
877
897
|
**`generateImage()` returns `{ ok: false, error: 'DEEPAI_QUOTA_EXCEEDED' }`**
|
|
878
898
|
`/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 `✅ SUCCESS — image generated (HTTP 200)`;
|
|
97
|
+
if (/valid Api-Key/i.test(b)) return `❌ key rejected (unexpected — fresh key was used)`;
|
|
98
|
+
if (/try this model/i.test(b)) return `❌ transport refused ("Please try this model on deepai.org")`;
|
|
99
|
+
if (/Pro members/i.test(b)) return `❌ account-level refusal (needs a Pro key)`;
|
|
100
|
+
if (/try it exceeded/i.test(b)) return `⚠️ free quota exhausted for this device/IP — retry later or change --device-id`;
|
|
101
|
+
return `❌ 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', `⚠️ ${e.message}`]); }
|
|
108
|
+
try { rows.push(['2. system curl', label(runCurl('curl', false))]); } catch (e) { rows.push(['2. system curl', `⚠️ ${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', `⚠️ ${e.message.split('\n')[0]}`]); }
|
|
119
|
+
} else {
|
|
120
|
+
rows.push(['3. curl-impersonate', '⏭ 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 ✅ on ANY line → that transport works; use it (library: transport option,
|
|
127
|
+
standalone: --transport).
|
|
128
|
+
- ❌ on line 1 only → non-browser TLS stack refused; use 'curl' or
|
|
129
|
+
'impersonate'.
|
|
130
|
+
- ❌ on lines 1+2, ✅ on 3 → strict browser-TLS matching; use 'impersonate'.
|
|
131
|
+
- ❌ 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
|
+
})();
|
|
@@ -13,9 +13,16 @@
|
|
|
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';
|
|
@@ -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
|
|
|
@@ -119,7 +128,39 @@ async function main() {
|
|
|
119
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 */ }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "alexa-ai",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.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/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,19 @@ 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
|
+
|
|
115
130
|
// ---- Anonymous device identity ---------------------------------------
|
|
116
131
|
// Stable device identifier sent as the `deepai_device_id` cookie.
|
|
117
132
|
// Anonymous /api/* generation is rate-limited per device, so the id
|
package/src/core/DeepAIClient.js
CHANGED
|
@@ -615,36 +615,232 @@ class DeepAIClient {
|
|
|
615
615
|
* @returns {Promise<object>} e.g. `{ id, output_url }`
|
|
616
616
|
*/
|
|
617
617
|
async runApi(name, fields = {}, options = {}) {
|
|
618
|
-
const
|
|
619
|
-
|
|
618
|
+
const url = `${this.config.url('api')}/${String(name).replace(/^\/+/, '')}`;
|
|
619
|
+
const entries = this._buildApiFields(fields, options);
|
|
620
|
+
return this._apiFormRequest(url, entries, options);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Normalise API form fields into a transport-neutral entry list:
|
|
625
|
+
* `[[key, { value | buffer, mimetype, filename }], …]`.
|
|
626
|
+
* @private
|
|
627
|
+
*/
|
|
628
|
+
_buildApiFields(fields, options = {}) {
|
|
629
|
+
const entries = [];
|
|
630
|
+
for (const [key, value] of Object.entries(fields || {})) {
|
|
620
631
|
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)}`);
|
|
632
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array || value instanceof Blob) {
|
|
633
|
+
entries.push([key, { buffer: value, mimetype: options.mimetype, filename: options.filename }]);
|
|
624
634
|
} else if (typeof value === 'object' && (value.buffer || value.url)) {
|
|
625
635
|
if (value.url && !value.buffer) {
|
|
626
|
-
|
|
627
|
-
|
|
636
|
+
entries.push([key, { value: String(value.url) }]);
|
|
637
|
+
} else {
|
|
638
|
+
entries.push([
|
|
639
|
+
key,
|
|
640
|
+
{
|
|
641
|
+
buffer: Buffer.isBuffer(value.buffer) ? value.buffer : Buffer.from(value.buffer),
|
|
642
|
+
mimetype: value.mimetype,
|
|
643
|
+
filename: value.filename,
|
|
644
|
+
},
|
|
645
|
+
]);
|
|
628
646
|
}
|
|
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
647
|
} else if (typeof value === 'object') {
|
|
633
|
-
|
|
648
|
+
entries.push([key, { value: JSON.stringify(value) }]);
|
|
634
649
|
} else {
|
|
635
|
-
|
|
650
|
+
entries.push([key, { value: String(value) }]);
|
|
636
651
|
}
|
|
637
652
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
653
|
+
return entries;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* POST a multipart form to a `/api/*` URL across the configured transport
|
|
658
|
+
* chain. A refusal of the form "Please try this model on deepai.org" can
|
|
659
|
+
* be transport-specific (non-browser TLS stacks receive it even with a
|
|
660
|
+
* perfectly valid key), so on that error the next transport is tried;
|
|
661
|
+
* every other error (quota, auth, network) is final for this request.
|
|
662
|
+
* @private
|
|
663
|
+
*/
|
|
664
|
+
async _apiFormRequest(url, entries, options = {}) {
|
|
665
|
+
const chain = await this._transportChain();
|
|
666
|
+
let lastError;
|
|
667
|
+
for (const transport of chain) {
|
|
668
|
+
try {
|
|
669
|
+
const raw = await this._runApiTransport(transport, url, entries, options);
|
|
670
|
+
const data = DeepAIClient._safeJson(raw.body);
|
|
671
|
+
if (raw.status > 299 || data === null) {
|
|
672
|
+
throw DeepAIClient._toError(raw.status, raw.body, data?.status || data?.error);
|
|
673
|
+
}
|
|
674
|
+
if (data?.err) {
|
|
675
|
+
throw DeepAIClient._toError(200, JSON.stringify(data), String(data.err));
|
|
676
|
+
}
|
|
677
|
+
if (typeof data?.status === 'string' && !data.share_url && !data.output_url && !data.output && !data.id) {
|
|
678
|
+
throw DeepAIClient._toError(200, JSON.stringify(data), data.status);
|
|
679
|
+
}
|
|
680
|
+
return data;
|
|
681
|
+
} catch (err) {
|
|
682
|
+
if (DeepAIClient._isTransportRejected(err) && chain.length > 1) {
|
|
683
|
+
lastError = err;
|
|
684
|
+
if (this.config.debug) this.log.warn?.(`[AlexaAI] ${transport} transport refused for ${url}; trying the next`);
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
if (err instanceof QuotaExceededError || err.retryable === false || err.code === 'ABORTED') throw err;
|
|
688
|
+
throw err;
|
|
689
|
+
}
|
|
643
690
|
}
|
|
644
|
-
|
|
645
|
-
|
|
691
|
+
throw lastError || new DeepAIError('DeepAI request failed', { code: 'DEEPAI_ERROR' });
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** @private */
|
|
695
|
+
static _isTransportRejected(err) {
|
|
696
|
+
return /try this model on deepai\.org/i.test(String(err?.message || ''));
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Ordered transport list for /api/* calls. @private */
|
|
700
|
+
async _transportChain() {
|
|
701
|
+
const t = this.config.transport;
|
|
702
|
+
if (t === 'fetch') return ['fetch'];
|
|
703
|
+
if (t === 'curl') return ['curl'];
|
|
704
|
+
if (t === 'impersonate') return ['impersonate'];
|
|
705
|
+
const chain = ['fetch', 'curl'];
|
|
706
|
+
if (await DeepAIClient.resolveImpersonateBinary(this.config)) chain.push('impersonate');
|
|
707
|
+
return chain;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** @private */
|
|
711
|
+
async _runApiTransport(transport, url, entries, options) {
|
|
712
|
+
if (transport === 'fetch') return this._runApiFetch(url, entries, options);
|
|
713
|
+
const impersonate = transport === 'impersonate';
|
|
714
|
+
const binary = impersonate
|
|
715
|
+
? await DeepAIClient.resolveImpersonateBinary(this.config)
|
|
716
|
+
: this.config.curlPath;
|
|
717
|
+
return this._runApiCurl(binary, url, entries, options, { impersonate });
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** @private global-fetch transport (previous behaviour). */
|
|
721
|
+
async _runApiFetch(url, entries, options = {}) {
|
|
722
|
+
const form = new FormData();
|
|
723
|
+
for (const [key, field] of entries) {
|
|
724
|
+
if (field.buffer != null) {
|
|
725
|
+
const bytes = field.buffer instanceof Blob ? Buffer.from(await field.buffer.arrayBuffer()) : field.buffer;
|
|
726
|
+
const mimetype = field.mimetype || DeepAIClient._sniffMime(bytes) || 'application/octet-stream';
|
|
727
|
+
form.append(key, new Blob([bytes], { type: mimetype }), field.filename || `${key}.${DeepAIClient._ext(mimetype)}`);
|
|
728
|
+
} else {
|
|
729
|
+
form.append(key, field.value);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
const data = await this._json(url, { method: 'POST', body: form, signal: options.signal, errorCode: 'BAD_RESPONSE' });
|
|
733
|
+
return { status: 200, body: JSON.stringify(data) };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* The User-Agent a curl-impersonate binary sends for the configured
|
|
738
|
+
* target profile. The anonymous key hash must be derived from the exact
|
|
739
|
+
* UA the request carries, so impersonated requests use the profile's own
|
|
740
|
+
* UA instead of `config.userAgent`.
|
|
741
|
+
* @private
|
|
742
|
+
*/
|
|
743
|
+
static _impersonateUserAgent(target) {
|
|
744
|
+
const m = /chrome(\d+)/i.exec(String(target || ''));
|
|
745
|
+
const v = m ? m[1] : '136';
|
|
746
|
+
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`;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** @private curl / curl-impersonate subprocess transport. */
|
|
750
|
+
async _runApiCurl(binary, url, entries, options = {}, { impersonate = false } = {}) {
|
|
751
|
+
const ua = impersonate ? DeepAIClient._impersonateUserAgent(this.config.curlImpersonateTarget) : this.config.userAgent;
|
|
752
|
+
const apiKey = DeepAIClient.isTryItKey(this.apiKey)
|
|
753
|
+
? DeepAIClient.generateTryItKey(ua)
|
|
754
|
+
: this.apiKey;
|
|
755
|
+
|
|
756
|
+
const args = impersonate ? ['--impersonate', this.config.curlImpersonateTarget] : [];
|
|
757
|
+
args.push(
|
|
758
|
+
url,
|
|
759
|
+
'-sS', '--compressed',
|
|
760
|
+
'--max-time', String(Math.max(1, Math.round(this.config.timeout / 1000))),
|
|
761
|
+
'-X', 'POST',
|
|
762
|
+
'-H', `api-key: ${apiKey}`,
|
|
763
|
+
'-H', `User-Agent: ${ua}`,
|
|
764
|
+
'-H', `Origin: ${this.config.origin}`,
|
|
765
|
+
'-H', `Referer: ${this.config.origin}/machine-learning-model/${this.config.imageModel}`,
|
|
766
|
+
'-H', 'Accept: */*',
|
|
767
|
+
'-H', 'Accept-Language: en-US,en;q=0.9',
|
|
768
|
+
'-w', '\n%{http_code}'
|
|
769
|
+
);
|
|
770
|
+
if (this.deviceId) args.push('-H', `Cookie: deepai_device_id=${this.deviceId}`);
|
|
771
|
+
|
|
772
|
+
const tmpFiles = [];
|
|
773
|
+
try {
|
|
774
|
+
for (const [key, field] of entries) {
|
|
775
|
+
if (field.buffer != null) {
|
|
776
|
+
const bytes = field.buffer instanceof Blob ? Buffer.from(await field.buffer.arrayBuffer()) : field.buffer;
|
|
777
|
+
const mimetype = field.mimetype || DeepAIClient._sniffMime(bytes) || 'application/octet-stream';
|
|
778
|
+
const ext = DeepAIClient._ext(mimetype);
|
|
779
|
+
const tmp = require('fs').mkdtempSync(require('path').join(require('os').tmpdir(), 'alexa-')) + `/${key}.${ext}`;
|
|
780
|
+
require('fs').writeFileSync(tmp, bytes);
|
|
781
|
+
tmpFiles.push(tmp);
|
|
782
|
+
args.push('-F', `${key}=@${tmp};type=${mimetype}`);
|
|
783
|
+
} else {
|
|
784
|
+
args.push('-F', `${key}=${field.value}`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
const out = await DeepAIClient.execCurl(binary, args, this.config.timeout, options.signal);
|
|
788
|
+
const body = String(out).replace(/\r/g, '');
|
|
789
|
+
const idx = body.lastIndexOf('\n');
|
|
790
|
+
const status = Number(body.slice(idx + 1).trim());
|
|
791
|
+
if (!Number.isFinite(status)) {
|
|
792
|
+
throw new DeepAIError(`curl transport failed for ${url}: ${body.slice(0, 200)}`, {
|
|
793
|
+
code: 'DEEPAI_NETWORK', retryable: true,
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
return { status, body: body.slice(0, idx) };
|
|
797
|
+
} finally {
|
|
798
|
+
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 */ } }
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Locate a curl-impersonate binary: explicit path, then the usual
|
|
804
|
+
* executable names on PATH. Cached per path. Tests may override.
|
|
805
|
+
* @private
|
|
806
|
+
*/
|
|
807
|
+
static async resolveImpersonateBinary(config) {
|
|
808
|
+
if (config.curlImpersonatePath) return config.curlImpersonatePath;
|
|
809
|
+
if (config._noImpersonateBinary) return null;
|
|
810
|
+
if (!DeepAIClient._impersonateCache) {
|
|
811
|
+
const { execFile } = require('child_process');
|
|
812
|
+
const names = process.platform === 'win32' ? ['curl-impersonate.exe', 'curl-impersonate'] : ['curl-impersonate'];
|
|
813
|
+
DeepAIClient._impersonateCache = new Promise((resolve) => {
|
|
814
|
+
let i = 0;
|
|
815
|
+
const tryNext = () => {
|
|
816
|
+
if (i >= names.length) return resolve(null);
|
|
817
|
+
const name = names[i++];
|
|
818
|
+
execFile(name, ['--version'], { timeout: 5000 }, (err) => resolve(err ? tryNext() : name));
|
|
819
|
+
};
|
|
820
|
+
tryNext();
|
|
821
|
+
});
|
|
646
822
|
}
|
|
647
|
-
return
|
|
823
|
+
return DeepAIClient._impersonateCache;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Run a curl-compatible binary and capture stdout. Separated so tests
|
|
828
|
+
* can stub the subprocess layer.
|
|
829
|
+
* @private
|
|
830
|
+
*/
|
|
831
|
+
static async execCurl(binary, args, timeoutMs, signal) {
|
|
832
|
+
const { execFile } = require('child_process');
|
|
833
|
+
return new Promise((resolve, reject) => {
|
|
834
|
+
const child = execFile(binary, args, { timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024, windowsHide: true }, (err, stdout, stderr) => {
|
|
835
|
+
if (err && stdout == null) return reject(new DeepAIError(`curl transport error: ${err.message}`, { code: 'DEEPAI_NETWORK', retryable: true }));
|
|
836
|
+
resolve(stdout != null ? stdout : '');
|
|
837
|
+
void stderr;
|
|
838
|
+
});
|
|
839
|
+
if (signal) {
|
|
840
|
+
if (signal.aborted) child.kill();
|
|
841
|
+
else signal.addEventListener('abort', () => child.kill(), { once: true });
|
|
842
|
+
}
|
|
843
|
+
});
|
|
648
844
|
}
|
|
649
845
|
|
|
650
846
|
/** Text-to-image (`/api/text2img`). Returns `{ id, output_url }`. */
|
package/test/run-tests.js
CHANGED
|
@@ -708,7 +708,98 @@ section('DeepAIClient — the whole endpoint surface (mocked transport)');
|
|
|
708
708
|
);
|
|
709
709
|
ok('share_url is preferred over output_url', result.url === 'https://deepai.org/generated-image.png');
|
|
710
710
|
}
|
|
711
|
-
|
|
711
|
+
|
|
712
|
+
{
|
|
713
|
+
section('DeepAIClient — /api transport chain');
|
|
714
|
+
|
|
715
|
+
const realFetch = global.fetch;
|
|
716
|
+
const realExecCurl = DeepAIClient.execCurl;
|
|
717
|
+
const realCache = DeepAIClient._impersonateCache;
|
|
718
|
+
|
|
719
|
+
// 1. a transport-specific refusal ("Please try this model") falls through
|
|
720
|
+
// to the next transport (fetch -> curl).
|
|
721
|
+
{
|
|
722
|
+
let curlCall = null;
|
|
723
|
+
global.fetch = async () => ({
|
|
724
|
+
status: 401,
|
|
725
|
+
headers: { get: () => 'application/json' },
|
|
726
|
+
text: async () => JSON.stringify({ status: 'Please try this model on deepai.org' }),
|
|
727
|
+
});
|
|
728
|
+
DeepAIClient.execCurl = async (bin, args) => {
|
|
729
|
+
curlCall = { bin, args };
|
|
730
|
+
return '{"id":"t1","share_url":"https://deepai.org/x.png"}\n200';
|
|
731
|
+
};
|
|
732
|
+
DeepAIClient._impersonateCache = Promise.resolve(null);
|
|
733
|
+
const client = new DeepAIClient(new Config({ key: 'tryit-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', postgresUrl: 'postgres://u:p@localhost/db', maxRetries: 0 }));
|
|
734
|
+
const data = await client.runApi('text2img', { text: 'cat', generation_source: 'img' });
|
|
735
|
+
check('curl transport recovers a fetch refusal', data.share_url, 'https://deepai.org/x.png');
|
|
736
|
+
ok('curl transport received the form fields', curlCall.args.includes('text=cat') && curlCall.args.includes('generation_source=img'));
|
|
737
|
+
const key = curlCall.args.find((a) => a.startsWith('api-key: ')).slice(9);
|
|
738
|
+
ok('curl transport used a fresh anonymous key', DeepAIClient.isTryItKey(key));
|
|
739
|
+
ok('curl transport sends the Origin header', curlCall.args.includes('Origin: https://deepai.org'));
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// 2. quota refusals are final — no transport cascade.
|
|
743
|
+
{
|
|
744
|
+
let curlCalled = false;
|
|
745
|
+
global.fetch = async () => ({
|
|
746
|
+
status: 402,
|
|
747
|
+
headers: { get: () => 'application/json' },
|
|
748
|
+
text: async () => JSON.stringify({ status: 'APIs are only available for Pro members in good standing' }),
|
|
749
|
+
});
|
|
750
|
+
DeepAIClient.execCurl = async () => { curlCalled = true; return '{}\n200'; };
|
|
751
|
+
const client = new DeepAIClient(new Config({ key: 'k', postgresUrl: 'postgres://u:p@localhost/db', maxRetries: 0 }));
|
|
752
|
+
let thrown = null;
|
|
753
|
+
try { await client.runApi('text2img', { text: 'cat' }); } catch (e) { thrown = e; }
|
|
754
|
+
ok('quota refusal throws QuotaExceededError', thrown?.name === 'QuotaExceededError' || thrown?.code === 'DEEPAI_QUOTA_EXCEEDED');
|
|
755
|
+
ok('no other transport is tried on quota refusals', curlCalled === false);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// 3. the impersonate transport uses the profile UA and derives the key
|
|
759
|
+
// hash from it.
|
|
760
|
+
{
|
|
761
|
+
let call = null;
|
|
762
|
+
DeepAIClient.execCurl = async (bin, args) => { call = { bin, args }; return '{"id":"t2","output_url":"https://deepai.org/y.png"}\n200'; };
|
|
763
|
+
const client = new DeepAIClient(new Config({
|
|
764
|
+
key: 'tryit-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
765
|
+
postgresUrl: 'postgres://u:p@localhost/db',
|
|
766
|
+
transport: 'impersonate',
|
|
767
|
+
curlImpersonatePath: '/fake/curl-impersonate',
|
|
768
|
+
maxRetries: 0,
|
|
769
|
+
}));
|
|
770
|
+
const data = await client.runApi('text2img', { text: 'cat' });
|
|
771
|
+
check('impersonate transport returns the image', data.output_url, 'https://deepai.org/y.png');
|
|
772
|
+
check('impersonate binary and profile are passed', [call.bin, call.args[0], call.args[1]], ['/fake/curl-impersonate', '--impersonate', 'chrome136']);
|
|
773
|
+
const ua = call.args.find((a) => a.startsWith('User-Agent: ')).slice(12);
|
|
774
|
+
const key = call.args.find((a) => a.startsWith('api-key: ')).slice(9);
|
|
775
|
+
const m = /^tryit-(\d+)-([0-9a-f]{32})$/.exec(key);
|
|
776
|
+
const H = DeepAIClient._islandHash;
|
|
777
|
+
const salt = 'hackers_become_a_little_stinkier_every_time_they_hack';
|
|
778
|
+
ok('impersonate key hash matches the profile User-Agent', m && m[2] === H(ua + H(ua + H(ua + m[1] + salt))));
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// 4. transport:'fetch' never spawns a subprocess.
|
|
782
|
+
{
|
|
783
|
+
let curlCalled = false;
|
|
784
|
+
global.fetch = async () => ({
|
|
785
|
+
status: 401,
|
|
786
|
+
headers: { get: () => 'application/json' },
|
|
787
|
+
text: async () => JSON.stringify({ status: 'Please try this model on deepai.org' }),
|
|
788
|
+
});
|
|
789
|
+
DeepAIClient.execCurl = async () => { curlCalled = true; return '{}\n200'; };
|
|
790
|
+
const client = new DeepAIClient(new Config({ key: 'k', postgresUrl: 'postgres://u:p@localhost/db', transport: 'fetch', maxRetries: 0 }));
|
|
791
|
+
let thrown = null;
|
|
792
|
+
try { await client.runApi('text2img', { text: 'cat' }); } catch (e) { thrown = e; }
|
|
793
|
+
ok('fetch-only transport surfaces the refusal', thrown && /try this model/i.test(thrown.message));
|
|
794
|
+
ok('fetch-only transport never shells out', curlCalled === false);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
global.fetch = realFetch;
|
|
798
|
+
DeepAIClient.execCurl = realExecCurl;
|
|
799
|
+
DeepAIClient._impersonateCache = realCache;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const cfg = new Config({ key: 'k', postgresUrl: 'postgres://u:p@localhost/db' });
|
|
712
803
|
check('endpoint map exposes the chat route', cfg.url('chat'), 'https://api.deepai.org/hacking_is_a_serious_crime');
|
|
713
804
|
check(
|
|
714
805
|
'endpoint map builds query strings',
|