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 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 — zero-dependency DeepAI text-to-image CLI.
4
+ * text2img-standalone.js - zero-dependency DeepAI text-to-image CLI.
5
5
  *
6
- * Speaks the anonymous browser dialect: a fresh single-use `tryit-…` key
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
- * Requires Node.js 18+ (global fetch). Anonymous generation is refused
18
- * from datacenter/VPN IPs; run from a residential network.
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 — the key
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 — do not simplify it.
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 — undici adds the multipart
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 — needs Pro)' : apiKey + ' (fresh, single-use)'}`);
128
+ console.log(`Key : ${opts.key ? '(registered key - needs Pro)' : apiKey + ' (fresh, single-use)'}`);
120
129
  console.log('POST : ' + API_URL);
121
130
 
122
- const res = await fetch(API_URL, { method: 'POST', headers, body: form });
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→ 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.');
132
- 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.');
133
- 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.');
134
- 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.');
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} — open the URL above in a browser)`);
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} — open the URL above in a browser)`);
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.2.1",
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 extra = this.client.usingTryItKey
646
- ? AlexaAI._browserImageFields(aspectRatio || '1:1', apiFields)
647
- : apiFields;
648
- const data = await this.client.text2img(text, extra, { signal });
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) {
@@ -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
@@ -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
- const form = new FormData();
619
- for (const [key, value] of Object.entries(fields)) {
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
- const mimetype = options.mimetype || DeepAIClient._sniffMime(value) || 'application/octet-stream';
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
- form.append(key, String(value.url));
627
- continue;
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
- form.append(key, JSON.stringify(value));
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, String(value));
768
+ form.append(key, field.value);
636
769
  }
637
770
  }
638
- const url = `${this.config.url('api')}/${String(name).replace(/^\/+/, '')}`;
639
- const data = await this._json(url, { method: 'POST', body: form, signal: options.signal });
640
- // The classic API reports failures as `{ err: "..." }` or `{ status: "..." }` with HTTP 200.
641
- if (data?.err) {
642
- throw DeepAIClient._toError(200, JSON.stringify(data), String(data.err));
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
- if (typeof data?.status === 'string' && !data.share_url && !data.output_url && !data.output && !data.id) {
645
- throw DeepAIClient._toError(200, JSON.stringify(data), data.status);
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 data;
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 tryit key,
657
- * regardless of the configured key. Used as the fallback path when a
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
- const previousKeys = this._keys;
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 — trying the next model`);
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 via the anonymous browser-shaped retry', result.ok === true && result.via === 'anonymous');
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
- const cfg = new Config({ key: 'k', postgresUrl: 'postgres://u:p@localhost/db' });
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',