alexa-ai 2.3.0 → 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,29 @@ 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
+
7
30
  ## [2.3.0] — 2026-09-07
8
31
 
9
32
  ### Added
package/README.md CHANGED
@@ -894,6 +894,20 @@ 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
+
897
911
  **`generateImage()` returns `{ ok: false, error: 'DEEPAI_QUOTA_EXCEEDED' }`**
898
912
  `/api/text2img` is Pro-only for registered keys ("APIs are only available for
899
913
  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.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) {
@@ -126,6 +126,12 @@ class Config {
126
126
  this.curlImpersonatePath =
127
127
  opts.curlImpersonatePath || process.env.DEEPAI_CURL_IMPERSONATE || null;
128
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;
129
135
 
130
136
  // ---- Anonymous device identity ---------------------------------------
131
137
  // Stable device identifier sent as the `deepai_device_id` cookie.
@@ -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
  /**
@@ -849,47 +888,45 @@ class DeepAIClient {
849
888
  }
850
889
 
851
890
  /**
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.
891
+ * Run a classic `/api/<name>` call with a one-shot anonymous key,
892
+ * regardless of the configured key (public wrapper).
856
893
  */
857
894
  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
- }
895
+ return this._runAnonymousApi(name, { ...(options.anonymousExtraFields || {}), ...fields }, { ...options, _anonymous: true });
868
896
  }
869
897
 
870
898
  /** Prompt-driven image edit (`/api/image-editor`). */
871
- async editImage(image, text, extra = {}) {
872
- 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);
873
901
  }
874
902
 
875
903
  /** 4x upscale (`/api/torch-srgan`). */
876
- async upscaleImage(image, extra = {}) {
877
- return this.runApi(STANDARD_APIS.superResolution, { image, ...extra });
904
+ async upscaleImage(image, extra = {}, options = {}) {
905
+ return this.runApi(STANDARD_APIS.superResolution, { image, ...extra }, options);
878
906
  }
879
907
 
880
908
  /** Colourise a black-and-white photo (`/api/colorizer`). */
881
- async colorizeImage(image, extra = {}) {
882
- 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
+ });
883
914
  }
884
915
 
885
916
  /** NSFW score (`/api/nsfw-detector`). */
886
- async detectNsfw(image, extra = {}) {
887
- 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
+ });
888
922
  }
889
923
 
890
924
  /** Abstractive summary (`/api/summarization`). */
891
- async summarize(text, extra = {}) {
892
- 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
+ });
893
930
  }
894
931
 
895
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,6 +707,41 @@ section('DeepAIClient — the whole endpoint surface (mocked transport)');
707
707
  anon.form.quality === 'true'
708
708
  );
709
709
  ok('share_url is preferred over output_url', result.url === 'https://deepai.org/generated-image.png');
710
+
711
+ // detectNsfw: same Pro refusal, same anonymous recovery
712
+ calls.length = 0;
713
+ global.fetch = async (url, init = {}) => {
714
+ const form = {};
715
+ for (const [k, v] of init.body.entries()) form[k] = v;
716
+ calls.push({ url, key: init.headers['api-key'], form });
717
+ const first = calls.filter((c) => /nsfw-detector$/.test(c.url)).length === 1;
718
+ return {
719
+ status: first ? 402 : 200,
720
+ headers: { get: () => 'application/json' },
721
+ text: async () =>
722
+ first
723
+ ? JSON.stringify({ status: 'APIs are only available for Pro members in good standing' })
724
+ : JSON.stringify({ id: 'n1', output: { nsfw_score: 0.13 } }),
725
+ };
726
+ };
727
+ const nsfw = await ai.detectNsfw(Buffer.from('fake-image-bytes'));
728
+ global.fetch = realFetch;
729
+ ok('detectNsfw recovers through the anonymous fallback', nsfw.ok === true && nsfw.score === 0.13 && nsfw.nsfw === false);
730
+ const anonNsfw = calls.find((c) => DeepAIClient.isTryItKey(c.key));
731
+ ok('anonymous nsfw retry uses the model-page dialect', anonNsfw && anonNsfw.form.generation_source === 'img');
732
+
733
+ // anonymousApiFallback:false keeps the refusal
734
+ const strict = new AlexaAI({ key: '11111111-2222-3333-4444-555555555555', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false, anonymousApiFallback: false });
735
+ calls.length = 0;
736
+ let fetchCalls = 0;
737
+ global.fetch = async (url, init = {}) => {
738
+ fetchCalls++;
739
+ return { status: 402, headers: { get: () => 'application/json' }, text: async () => JSON.stringify({ status: 'APIs are only available for Pro members in good standing' }) };
740
+ };
741
+ const refused = await strict.detectNsfw(Buffer.from('fake-image-bytes'));
742
+ global.fetch = realFetch;
743
+ ok('anonymousApiFallback:false surfaces the refusal', refused.ok === false && refused.error === 'DEEPAI_QUOTA_EXCEEDED');
744
+ ok('no anonymous retry was made', fetchCalls === 1);
710
745
  }
711
746
 
712
747
  {