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 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
- * 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';
@@ -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
- 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 */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alexa-ai",
3
- "version": "2.2.1",
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": {
@@ -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
@@ -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 form = new FormData();
619
- for (const [key, value] of Object.entries(fields)) {
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
- 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)}`);
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
- form.append(key, String(value.url));
627
- continue;
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
- form.append(key, JSON.stringify(value));
648
+ entries.push([key, { value: JSON.stringify(value) }]);
634
649
  } else {
635
- form.append(key, String(value));
650
+ entries.push([key, { value: String(value) }]);
636
651
  }
637
652
  }
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));
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
- 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);
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 data;
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
- const cfg = new Config({ key: 'k', postgresUrl: 'postgres://u:p@localhost/db' });
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',