alexa-ai 2.3.0 → 2.5.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,52 @@ 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.5.0] — 2026-09-07
8
+
9
+ ### Added
10
+ - **`proxy` option / `DEEPAI_PROXY`** — route `/api/*` calls through a
11
+ residential or mobile proxy (`http(s)://` / `socks5://`). Honoured by the
12
+ curl transports (`-x`); `transport: 'auto'` drops global fetch from the
13
+ chain when a proxy is set. This is what makes anonymous image generation
14
+ work from a VPS / data-center IP.
15
+ - **`englishOnly` (default on)** — the persona instructs English-only replies;
16
+ any CJK/Kana/Hangul in an answer triggers one translation re-ask and, if
17
+ that fails, script stripping with a plain-English fallback sentence.
18
+ - `DEEPAI_LOGIN_REQUIRED` error for login-gated models ("model only available
19
+ to logged in users") — no key rotation, anonymous retry or transport
20
+ cascade is attempted for them.
21
+
22
+ ### Changed
23
+ - **`detectNsfw()` on free keys** — `nsfw-detector` has no free/anonymous
24
+ tier, so after the API refusal the engine asks the vision model for a
25
+ safety score (`via: 'chat'`) and otherwise returns `DEEPAI_PRO_REQUIRED`
26
+ with an explanatory message.
27
+ - When script stripping empties the reply, a fixed English sentence is sent
28
+ instead of the original non-English text.
29
+
30
+ ## [2.4.0] — 2026-09-07
31
+
32
+ ### Fixed
33
+ - **`detectNsfw()` (and every other `/api/*` helper) now works on free keys.**
34
+ A registered key refused with "Pro members in good standing" gets one
35
+ anonymous browser-dialect retry (`anonymousApiFallback`, on by default) —
36
+ the same mechanism that made `generateImage()` work. Applies to
37
+ `generateImage`, `editImage`, `upscaleImage`, `colorizeImage`,
38
+ `detectNsfw`, `summarizeText` and `deepai.runApi()`.
39
+ - `detectNsfw()` dropped its `opts` argument instead of passing it through.
40
+ - CLI output of the bundled examples is plain ASCII now — emoji and
41
+ typographic dashes rendered as mojibake in Windows consoles with a
42
+ non-UTF-8 codepage.
43
+ - When both the registered and the anonymous attempt fail, the error message
44
+ reports both refusals.
45
+
46
+ ### Changed
47
+ - `generateImage()` route 1 carries the browser fields into its built-in
48
+ anonymous retry, so a Pro refusal recovers directly with `via: 'api'`.
49
+ - Client media helpers accept an `options` argument (third/fourth parameter)
50
+ and default their anonymous dialect per endpoint (`generation_source=img`
51
+ for model pages; none for image-editor/torch-srgan, matching the site).
52
+
7
53
  ## [2.3.0] — 2026-09-07
8
54
 
9
55
  ### Added
package/README.md CHANGED
@@ -894,6 +894,66 @@ github.com/lexiforest/curl-impersonate/releases). Quota and auth errors are
894
894
  never re-driven through other transports. Run `node examples/diagnose.js` to
895
895
  see which transport your network accepts.
896
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.
910
+
911
+ **Running on a VPS / server IP.** DeepAI refuses anonymous `/api/*` generation
912
+ from data-center IPs ("Please try this model on deepai.org"), so image
913
+ generation that works at home fails on a VPS. Two options:
914
+
915
+ 1. `proxy` option (2.5.0) — route the `/api/*` calls through a
916
+ residential/mobile proxy; the curl transports honour
917
+ `proxy: 'socks5://host:port'` / `http://host:port` (or `DEEPAI_PROXY`).
918
+ Global fetch cannot use the proxy, so `auto` switches to curl automatically
919
+ when one is configured.
920
+ 2. A DeepAI **Pro key** — works from any IP, no proxy needed.
921
+
922
+ **English-only replies (`englishOnly`, 2.5.0, on by default).** The persona
923
+ now instructs the model to answer in English only; if a reply still contains
924
+ CJK/Kana/Hangul, the engine re-asks once for an English version and strips any
925
+ remaining script as a last resort. Disable with `englishOnly: false`.
926
+
927
+ **`detectNsfw()` on a free key.** The `nsfw-detector` model has **no free or
928
+ anonymous tier** on DeepAI (no try-it on its model page; registered free keys
929
+ get the Pro refusal). 2.5.0 therefore: (a) tries the API, (b) on a plan
930
+ refusal asks the vision model for a score instead (`via: 'chat'` — works when
931
+ the key can see images), (c) otherwise returns `error: 'DEEPAI_PRO_REQUIRED'`
932
+ with a clear message. A Pro key makes the real model work.
933
+
934
+ **Always check `result.ok` before sending media.** On failure every helper
935
+ returns `url: null` — passing that straight to Baileys'
936
+ `prepareWAMessageMedia` crashes the bot with
937
+ `TypeError: Cannot read properties of undefined (reading 'toString')`. Guard
938
+ the bot side:
939
+
940
+ ```js
941
+ const r = await ai.generateImage(prompt);
942
+ if (!r.ok || !r.url) {
943
+ await sock.sendMessage(jid, { text: `Image failed: ${r.message || r.error}` });
944
+ return;
945
+ }
946
+ try {
947
+ await sock.sendMessage(jid, { image: { url: r.url } });
948
+ } catch (e) {
949
+ console.error('send failed', e);
950
+ }
951
+ ```
952
+
953
+ Also register `process.on('uncaughtException', …)` and
954
+ `process.on('unhandledRejection', …)` handlers in the bot entrypoint so one
955
+ bad send can never kill the whole process.
956
+
897
957
  **`generateImage()` returns `{ ok: false, error: 'DEEPAI_QUOTA_EXCEEDED' }`**
898
958
  `/api/text2img` is Pro-only for registered keys ("APIs are only available for
899
959
  Pro members in good standing"), the anonymous browser-shaped retry was
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
  /**
4
- * diagnose.js — DeepAI text2img connectivity diagnostic.
4
+ * diagnose.js - DeepAI text2img connectivity diagnostic.
5
5
  *
6
6
  * Run ON THE MACHINE where the browser playground works:
7
7
  *
@@ -93,19 +93,19 @@ function runCurl(binary, impersonate) {
93
93
 
94
94
  function label(t) {
95
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}`;
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
102
  }
103
103
 
104
104
  (async () => {
105
- console.log(`DeepAI text2img diagnostic — device ${deviceId.slice(0, 8)}…\n`);
105
+ console.log(`DeepAI text2img diagnostic - device ${deviceId.slice(0, 8)}...\n`);
106
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]}`]); }
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
109
 
110
110
  let impBin = opt.imp;
111
111
  if (!impBin) {
@@ -115,20 +115,20 @@ function label(t) {
115
115
  }
116
116
  }
117
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]}`]); }
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
119
  } else {
120
- rows.push(['3. curl-impersonate', '⏭ skipped — binary not found (see README for install)']);
120
+ rows.push(['3. curl-impersonate', '[SKIP] skipped - binary not found (see README for install)']);
121
121
  }
122
122
 
123
123
  for (const [name, v] of rows) console.log(`${name.padEnd(34)} ${v}`);
124
124
  console.log(`
125
125
  Reading the results:
126
- - A ✅ on ANY line → that transport works; use it (library: transport option,
126
+ - A [OK] on ANY line -> that transport works; use it (library: transport option,
127
127
  standalone: --transport).
128
- - ❌ on line 1 only → non-browser TLS stack refused; use 'curl' or
128
+ - [FAIL] on line 1 only -> non-browser TLS stack refused; use 'curl' or
129
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
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
132
  quota is exhausted. Compare with the browser: DevTools
133
- → Network → generate → text2img request → Response.`);
133
+ -> Network -> generate -> text2img request -> Response.`);
134
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
  *
@@ -18,7 +18,7 @@
18
18
  *
19
19
  * Transports: 'fetch' (default, Node fetch) | 'curl' (system curl) |
20
20
  * 'impersonate' (curl-impersonate binary, Chrome TLS profile). Some
21
- * networks serve non-browser TLS stacks a refusal — if 'fetch' fails with
21
+ * networks serve non-browser TLS stacks a refusal - if 'fetch' fails with
22
22
  * "Please try this model on deepai.org", try 'curl', then 'impersonate'.
23
23
  *
24
24
  * Requires Node.js 18+. Anonymous generation is refused from
@@ -27,20 +27,20 @@
27
27
 
28
28
  const API_URL = 'https://api.deepai.org/api/text2img';
29
29
  const SALT = 'hackers_become_a_little_stinkier_every_time_they_hack';
30
- // 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
31
31
  // hash is computed over it and the server recomputes it from the request.
32
32
  const USER_AGENT =
33
33
  'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
34
34
 
35
35
  // ---------------------------------------------------------------------------
36
36
  // Deterministic key hash (see DeepAIClient._islandHash in the engine).
37
- // The integer/bit-level behaviour is intentional — do not simplify it.
37
+ // The integer/bit-level behaviour is intentional - do not simplify it.
38
38
  // ---------------------------------------------------------------------------
39
39
  function islandHash(input) {
40
40
  const a = [];
41
41
  for (let b = 0; 64 > b; ) a[b] = 0 | (4294967296 * Math.sin(++b % Math.PI));
42
42
  let d, e, f, g = [(d = 1732584193), (e = 4023233417), ~d, ~e], h = [];
43
- const l = unescape(encodeURI(input)) + '€';
43
+ const l = unescape(encodeURI(input)) + '\u0080';
44
44
  let k = l.length;
45
45
  let c = (--k / 4 + 2) | 15;
46
46
  for (h[--c] = 8 * k; ~k; ) h[k >> 2] |= l.charCodeAt(k) << (8 * k--);
@@ -120,19 +120,19 @@ async function main() {
120
120
  Accept: '*/*',
121
121
  'Accept-Language': 'en-US,en;q=0.9',
122
122
  Cookie: `deepai_device_id=${opts.deviceId}`,
123
- // NOTE: do NOT set Content-Type yourself — undici adds the multipart
123
+ // NOTE: do NOT set Content-Type yourself - undici adds the multipart
124
124
  // boundary. A manual Content-Type without the boundary is rejected.
125
125
  };
126
126
 
127
127
  console.log(`Prompt : ${opts.prompt}`);
128
- 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)'}`);
129
129
  console.log('POST : ' + API_URL);
130
130
 
131
131
  let res;
132
132
  if (opts.transport === 'curl' || opts.transport === 'impersonate') {
133
133
  const { execFile } = require('child_process');
134
134
  const binary = opts.transport === 'impersonate' ? (opts.imp || 'curl-impersonate') : 'curl';
135
- // the chrome136 profile sends its own Mac Chrome UA — the anonymous
135
+ // the chrome136 profile sends its own Mac Chrome UA - the anonymous
136
136
  // key hash must be derived from exactly that UA
137
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
138
  const apiKey = opts.key || freshTryItKey(opts.transport === 'impersonate' ? profileUa : USER_AGENT);
@@ -169,10 +169,10 @@ async function main() {
169
169
  console.error(`\nFAILED HTTP ${res.status}`);
170
170
  console.error(raw.slice(0, 500));
171
171
  const s = String(data?.status || data?.err || '');
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.');
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.');
176
176
  process.exit(1);
177
177
  }
178
178
 
@@ -190,10 +190,10 @@ async function main() {
190
190
  require('fs').writeFileSync(out, buf);
191
191
  console.log(`Saved : ${out} (${(buf.length / 1024).toFixed(1)} KB)`);
192
192
  } else {
193
- 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)`);
194
194
  }
195
195
  } catch (e) {
196
- 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)`);
197
197
  }
198
198
  }
199
199
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alexa-ai",
3
- "version": "2.3.0",
3
+ "version": "2.5.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
@@ -418,6 +418,26 @@ class AlexaAI {
418
418
  // Guarantee no @MEMORY remnant ever reaches WhatsApp.
419
419
  if (/@\s*MEMORY/i.test(finalText)) finalText = MemoryExtractor.strip(finalText);
420
420
 
421
+ // English-only replies: if the model answered in another script,
422
+ // re-ask once for an English version and strip any remainder.
423
+ if (this.config.englishOnly && ResponseFormatter.hasNonEnglish(finalText)) {
424
+ let repaired = null;
425
+ try {
426
+ repaired = await this.client.chat([
427
+ { role: 'user', content: `Rewrite the following in plain English only, keeping the same meaning and formatting. Output only the English text and nothing else:\n\n${finalText}` },
428
+ ]);
429
+ } catch {
430
+ repaired = null;
431
+ }
432
+ if (repaired && repaired.trim() && !ResponseFormatter.hasNonEnglish(repaired)) {
433
+ finalText = repaired.trim();
434
+ } else {
435
+ finalText =
436
+ ResponseFormatter.stripNonEnglishScripts(finalText) ||
437
+ 'Sorry, I could not phrase that in English. Please ask me again.';
438
+ }
439
+ }
440
+
421
441
  if (!finalText.trim()) {
422
442
  finalText = 'Sorry, I did not quite catch that. Could you say it again?';
423
443
  }
@@ -642,10 +662,14 @@ class AlexaAI {
642
662
  // size/quality fields).
643
663
  if (!chatToolOnly) {
644
664
  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 });
665
+ const browserFields = AlexaAI._browserImageFields(aspectRatio || '1:1', apiFields);
666
+ const extra = this.client.usingTryItKey ? browserFields : apiFields;
667
+ const data = await this.client.text2img(text, extra, {
668
+ signal,
669
+ // used when a registered key is refused and runApi()
670
+ // retries anonymously
671
+ anonymousExtraFields: browserFields,
672
+ });
649
673
  const url = AlexaAI._outputUrl(data);
650
674
  if (url) return { ok: true, url, id: data.id || null, error: null, via: 'api', raw: data };
651
675
  errors.push('text2img: no output_url in response');
@@ -784,10 +808,36 @@ class AlexaAI {
784
808
  if (!field) return { ...AlexaAI._mediaError('detectNsfw', 'NSFW_FAILED'), score: null, nsfw: null };
785
809
  const threshold = typeof opts.threshold === 'number' ? opts.threshold : 0.7;
786
810
  try {
787
- const data = await this.client.detectNsfw(field);
811
+ const data = await this.client.detectNsfw(field, {}, opts);
788
812
  const score = typeof data?.output?.nsfw_score === 'number' ? data.output.nsfw_score : null;
789
- return { ok: true, score, nsfw: score == null ? null : score >= threshold, error: null, raw: data };
813
+ return { ok: true, score, nsfw: score == null ? null : score >= threshold, error: null, via: 'api', raw: data };
790
814
  } catch (err) {
815
+ // The dedicated model is Pro/login-only. As a best effort, ask
816
+ // the vision model for a safety score instead — works whenever
817
+ // the key can see images.
818
+ if (err.code === 'DEEPAI_LOGIN_REQUIRED' || err.code === 'DEEPAI_QUOTA_EXCEEDED') {
819
+ try {
820
+ const media = Media.normalize(image);
821
+ if (media) {
822
+ const judged = await this.vision.describe(media,
823
+ 'Rate how sexually explicit this image is. Reply with ONLY a single decimal number between 0 (completely safe) and 1 (explicit), with no other text.');
824
+ const m = /(?:0\.\d+|1(?:\.0+)?|0|1)/.exec(String(judged.description || judged.text || ''));
825
+ if (judged.ok && m) {
826
+ const score = Number(m[0]);
827
+ return { ok: true, score, nsfw: score >= threshold, error: null, via: 'chat' };
828
+ }
829
+ }
830
+ } catch {
831
+ /* fall through to the honest refusal */
832
+ }
833
+ return {
834
+ ok: false,
835
+ score: null,
836
+ nsfw: null,
837
+ error: 'DEEPAI_PRO_REQUIRED',
838
+ message: 'nsfw-detector is only available to DeepAI Pro keys (it has no free/anonymous tier), and the vision fallback could not run on this key.',
839
+ };
840
+ }
791
841
  return { ok: false, score: null, nsfw: null, error: err.code || 'NSFW_FAILED', message: err.message };
792
842
  }
793
843
  }
@@ -126,6 +126,18 @@ class Config {
126
126
  this.curlImpersonatePath =
127
127
  opts.curlImpersonatePath || process.env.DEEPAI_CURL_IMPERSONATE || null;
128
128
  this.curlImpersonateTarget = opts.curlImpersonateTarget || 'chrome136';
129
+ // Optional proxy for /api/* calls (http/https/socks5 URL). Applied to
130
+ // the curl transports via -x; global fetch cannot honour it without
131
+ // the undici package, so 'auto' skips fetch entirely when this is set.
132
+ // Useful when the bot runs on a server IP that DeepAI refuses for
133
+ // anonymous generation.
134
+ this.proxy = opts.proxy || process.env.DEEPAI_PROXY || null;
135
+ // ---- /api/* anonymous fallback ----------------------------------------
136
+ // When a registered key is refused ("Pro members in good standing"),
137
+ // retry once with a fresh anonymous key in the browser dialect
138
+ // (options.anonymousExtraFields). Mirrors how the website keeps
139
+ // working for free visitors. Disable with anonymousApiFallback:false.
140
+ this.anonymousApiFallback = opts.anonymousApiFallback !== false;
129
141
 
130
142
  // ---- Anonymous device identity ---------------------------------------
131
143
  // Stable device identifier sent as the `deepai_device_id` cookie.
@@ -207,6 +219,10 @@ class Config {
207
219
  opts.pool || {}
208
220
  );
209
221
 
222
+ // Replies must be plain English: any CJK/Kana/Hangul in a model
223
+ // answer triggers one translation re-ask, then script stripping.
224
+ this.englishOnly = opts.englishOnly !== false;
225
+
210
226
  this.debug = Boolean(opts.debug);
211
227
  this.logger = opts.logger || console;
212
228
 
@@ -615,9 +615,48 @@ class DeepAIClient {
615
615
  * @returns {Promise<object>} e.g. `{ id, output_url }`
616
616
  */
617
617
  async runApi(name, fields = {}, options = {}) {
618
- const url = `${this.config.url('api')}/${String(name).replace(/^\/+/, '')}`;
619
- const entries = this._buildApiFields(fields, options);
620
- return this._apiFormRequest(url, entries, options);
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
+ }
621
660
  }
622
661
 
623
662
  /**
@@ -702,7 +741,8 @@ class DeepAIClient {
702
741
  if (t === 'fetch') return ['fetch'];
703
742
  if (t === 'curl') return ['curl'];
704
743
  if (t === 'impersonate') return ['impersonate'];
705
- const chain = ['fetch', 'curl'];
744
+ // A proxy can only be honoured by the curl transports.
745
+ const chain = this.config.proxy ? ['curl'] : ['fetch', 'curl'];
706
746
  if (await DeepAIClient.resolveImpersonateBinary(this.config)) chain.push('impersonate');
707
747
  return chain;
708
748
  }
@@ -754,6 +794,7 @@ class DeepAIClient {
754
794
  : this.apiKey;
755
795
 
756
796
  const args = impersonate ? ['--impersonate', this.config.curlImpersonateTarget] : [];
797
+ if (this.config.proxy) args.push('-x', this.config.proxy);
757
798
  args.push(
758
799
  url,
759
800
  '-sS', '--compressed',
@@ -849,47 +890,45 @@ class DeepAIClient {
849
890
  }
850
891
 
851
892
  /**
852
- * Run a classic `/api/<name>` call with a one-shot anonymous tryit key,
853
- * regardless of the configured key. Used as the fallback path when a
854
- * registered key is refused ("Pro members only"); a fresh key is minted
855
- * for this single request.
893
+ * Run a classic `/api/<name>` call with a one-shot anonymous key,
894
+ * regardless of the configured key (public wrapper).
856
895
  */
857
896
  async runApiWithTryItKey(name, fields = {}, options = {}) {
858
- const previousKeys = this._keys;
859
- const previousIndex = this._keyIndex;
860
- this._keys = [DeepAIClient.generateTryItKey(this.config.userAgent)];
861
- this._keyIndex = 0;
862
- try {
863
- return await this.runApi(name, fields, options);
864
- } finally {
865
- this._keys = previousKeys;
866
- this._keyIndex = previousIndex;
867
- }
897
+ return this._runAnonymousApi(name, { ...(options.anonymousExtraFields || {}), ...fields }, { ...options, _anonymous: true });
868
898
  }
869
899
 
870
900
  /** Prompt-driven image edit (`/api/image-editor`). */
871
- async editImage(image, text, extra = {}) {
872
- return this.runApi(STANDARD_APIS.imageEditor, { image, text, ...extra });
901
+ async editImage(image, text, extra = {}, options = {}) {
902
+ return this.runApi(STANDARD_APIS.imageEditor, { image, text, ...extra }, options);
873
903
  }
874
904
 
875
905
  /** 4x upscale (`/api/torch-srgan`). */
876
- async upscaleImage(image, extra = {}) {
877
- return this.runApi(STANDARD_APIS.superResolution, { image, ...extra });
906
+ async upscaleImage(image, extra = {}, options = {}) {
907
+ return this.runApi(STANDARD_APIS.superResolution, { image, ...extra }, options);
878
908
  }
879
909
 
880
910
  /** Colourise a black-and-white photo (`/api/colorizer`). */
881
- async colorizeImage(image, extra = {}) {
882
- return this.runApi(STANDARD_APIS.colorizer, { image, ...extra });
911
+ async colorizeImage(image, extra = {}, options = {}) {
912
+ return this.runApi(STANDARD_APIS.colorizer, { image, ...extra }, {
913
+ anonymousExtraFields: { generation_source: 'img' },
914
+ ...options,
915
+ });
883
916
  }
884
917
 
885
918
  /** NSFW score (`/api/nsfw-detector`). */
886
- async detectNsfw(image, extra = {}) {
887
- return this.runApi(STANDARD_APIS.nsfwDetector, { image, ...extra });
919
+ async detectNsfw(image, extra = {}, options = {}) {
920
+ return this.runApi(STANDARD_APIS.nsfwDetector, { image, ...extra }, {
921
+ anonymousExtraFields: { generation_source: 'img' },
922
+ ...options,
923
+ });
888
924
  }
889
925
 
890
926
  /** Abstractive summary (`/api/summarization`). */
891
- async summarize(text, extra = {}) {
892
- return this.runApi(STANDARD_APIS.summarization, { text, ...extra });
927
+ async summarize(text, extra = {}, options = {}) {
928
+ return this.runApi(STANDARD_APIS.summarization, { text, ...extra }, {
929
+ anonymousExtraFields: { generation_source: 'img' },
930
+ ...options,
931
+ });
893
932
  }
894
933
 
895
934
  /** Sentiment labels (`/api/sentiment-analysis`). */
@@ -1035,6 +1074,17 @@ class DeepAIClient {
1035
1074
  const msg = statusMessage || DeepAIClient._detectJsonStatus(body) || `HTTP ${status}`;
1036
1075
  const lowered = String(msg).toLowerCase();
1037
1076
 
1077
+ // Login-gated models cannot be recovered by key rotation, anonymous
1078
+ // retries or another transport — report them as their own error.
1079
+ if (lowered.includes('model only available to logged in users')) {
1080
+ return new DeepAIError(`DeepAI refused the request: ${msg}`, {
1081
+ code: 'DEEPAI_LOGIN_REQUIRED',
1082
+ status,
1083
+ body,
1084
+ retryable: false,
1085
+ });
1086
+ }
1087
+
1038
1088
  const quotaHints = [
1039
1089
  'quota exceeded',
1040
1090
  'try it exceeded',
@@ -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
  }
@@ -128,6 +128,7 @@ class PromptBuilder {
128
128
  `You are ${assistantName}, a warm, friendly female WhatsApp assistant created by ${creator}.`,
129
129
  `Your name is exactly "${assistantName}" — never a variant such as "${assistantName} Mini" or "${assistantName} AI".`,
130
130
  'Never mention DeepAI, ChatGPT, OpenAI, GPT, Llama, Gemini or any model/company name, and never call yourself a language model.',
131
+ 'You always reply in plain English only. Never answer in Chinese, Japanese, Korean or any other non-Latin script.',
131
132
  'Use WhatsApp formatting only: *bold*, _italic_, ~strike~, `code`. Never use ** or markdown headers.',
132
133
  'You have a permanent memory database: facts you are given about a person are things you genuinely remember, in private chats and in every group. Never claim you cannot remember.',
133
134
  'Append new personal facts at the very end as @MEMORY: {"key": "value"} and never mention that tag.',
@@ -19,6 +19,23 @@
19
19
  * Fenced code blocks are protected and restored verbatim.
20
20
  */
21
21
  class ResponseFormatter {
22
+ // CJK ideographs, Kana, Hangul, fullwidth forms — the scripts that must
23
+ // never appear in an English-only reply.
24
+ static NON_ENGLISH_RE = /[\u2E80-\u2EFF\u3000-\u303F\u3040-\u30FF\u3130-\u318F\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF00-\uFFEF]/;
25
+
26
+ /** True when the text contains CJK/Kana/Hangul/fullwidth characters. */
27
+ static hasNonEnglish(text) {
28
+ return ResponseFormatter.NON_ENGLISH_RE.test(String(text || ''));
29
+ }
30
+
31
+ /** Remove CJK/Kana/Hangul/fullwidth runs (last-resort cleanup). */
32
+ static stripNonEnglishScripts(text) {
33
+ return String(text || '')
34
+ .replace(/([\u2E80-\u2EFF\u3000-\u303F\u3040-\u30FF\u3130-\u318F\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF00-\uFFEF])+/g, ' ')
35
+ .replace(/ {2,}/g, ' ')
36
+ .trim();
37
+ }
38
+
22
39
  /**
23
40
  * @param {string} reply
24
41
  * @returns {string}
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,6 +707,67 @@ 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 (no /api retry; the
734
+ // vision fallback may still run but makes no /api/nsfw-detector call)
735
+ const strict = new AlexaAI({ key: '11111111-2222-3333-4444-555555555555', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false, anonymousApiFallback: false });
736
+ let apiCalls = 0;
737
+ global.fetch = async (url, init = {}) => {
738
+ if (/nsfw-detector$/.test(String(url))) apiCalls++;
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_PRO_REQUIRED');
744
+ ok('no anonymous retry was made', apiCalls === 1);
745
+
746
+ // login-gated models map to DEEPAI_LOGIN_REQUIRED and never rotate keys
747
+ {
748
+ const cfg2 = new Config({ key: 'tryit-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', keys: ['tryit-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'tryit-2-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'], postgresUrl: 'postgres://u:p@localhost/db', maxRetries: 0 });
749
+ const c2 = new DeepAIClient(cfg2);
750
+ const seen2 = [];
751
+ global.fetch = async (url, init = {}) => {
752
+ seen2.push(init.headers['api-key']);
753
+ return { status: 401, headers: { get: () => 'application/json' }, text: async () => JSON.stringify({ status: 'model only available to logged in users' }) };
754
+ };
755
+ let loginErr = null;
756
+ try { await c2.runApi('nsfw-detector', { image: 'https://x/y.png' }); } catch (e) { loginErr = e; }
757
+ global.fetch = realFetch;
758
+ ok('login-gated model raises DEEPAI_LOGIN_REQUIRED', loginErr?.code === 'DEEPAI_LOGIN_REQUIRED');
759
+ ok('no key rotation on login-gated models', seen2.length === 1);
760
+ }
761
+
762
+ // the vision fallback answers when the model refuses for plan reasons
763
+ {
764
+ const ai2 = new AlexaAI({ key: '11111111-2222-3333-4444-555555555555', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false });
765
+ ai2.vision = { describe: async () => ({ ok: true, description: '0.85' }) };
766
+ global.fetch = async () => ({ status: 402, headers: { get: () => 'application/json' }, text: async () => JSON.stringify({ status: 'APIs are only available for Pro members in good standing' }) });
767
+ const judged = await ai2.detectNsfw(Buffer.from('fake'), { threshold: 0.7 });
768
+ global.fetch = realFetch;
769
+ ok('detectNsfw falls back to the vision model', judged.ok === true && judged.via === 'chat' && judged.score === 0.85 && judged.nsfw === true);
770
+ }
710
771
  }
711
772
 
712
773
  {
@@ -794,6 +855,22 @@ section('DeepAIClient — the whole endpoint surface (mocked transport)');
794
855
  ok('fetch-only transport never shells out', curlCalled === false);
795
856
  }
796
857
 
858
+ // 5. a proxy pins the chain to the curl transports and passes -x
859
+ {
860
+ let call = null;
861
+ DeepAIClient.execCurl = async (bin, args) => { call = { bin, args }; return '{"id":"p1","share_url":"https://deepai.org/p.png"}\n200'; };
862
+ const client = new DeepAIClient(new Config({
863
+ key: 'k', postgresUrl: 'postgres://u:p@localhost/db',
864
+ proxy: 'socks5://127.0.0.1:9050', maxRetries: 0,
865
+ }));
866
+ const chain = await client._transportChain();
867
+ ok('proxy removes fetch from the transport chain', !chain.includes('fetch') && chain.includes('curl'));
868
+ const data = await client.runApi('text2img', { text: 'cat' });
869
+ ok('proxied request succeeds through curl', data.share_url === 'https://deepai.org/p.png');
870
+ const xi = call.args.indexOf('-x');
871
+ ok('curl receives the proxy flag', xi !== -1 && call.args[xi + 1] === 'socks5://127.0.0.1:9050');
872
+ }
873
+
797
874
  global.fetch = realFetch;
798
875
  DeepAIClient.execCurl = realExecCurl;
799
876
  DeepAIClient._impersonateCache = realCache;
@@ -890,6 +967,19 @@ async function endToEndTests() {
890
967
  deepai.push('Sure! DeepAI can help you with that.');
891
968
  const leak = await ai.chat({ message: 'can you help me?', userId: '78151912841263@lid' });
892
969
  ok('vendor name never ships', !/deepai/i.test(leak.text));
970
+
971
+ // 7. A non-English reply is re-asked in English.
972
+ deepai.push('你好!很高兴认识你,Nimal。');
973
+ deepai.push('Hello! Very nice to meet you, Nimal.');
974
+ const zh = await ai.chat({ message: 'hi again', userId: '78151912841263@lid' });
975
+ check('Chinese reply is re-asked in English', zh.text, 'Hello! Very nice to meet you, Nimal.');
976
+
977
+ // 8. When the re-ask also fails, the CJK is stripped (or replaced).
978
+ deepai.push('这是一段完全中文的回答。');
979
+ deepai.push('还是中文。');
980
+ const zh2 = await ai.chat({ message: 'hello', userId: '78151912841263@lid' });
981
+ ok('unrecoverable reply carries no CJK', !/[\u4E00-\u9FFF]/.test(zh2.text));
982
+ ok('unrecoverable reply is non-empty', zh2.text.trim().length > 0);
893
983
  } finally {
894
984
  deepai.restore();
895
985
  }