alexa-ai 2.1.2 → 2.2.1

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,43 @@ 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.2.1] — 2026-09-07
8
+
9
+ ### Added
10
+ - Device identity: the client keeps a stable random `deepai_device_id`
11
+ (32 bytes, base64url) and sends it as a cookie; anonymous `/api/*`
12
+ generation is rate-limited per device. Override with `deviceId` /
13
+ `DEEPAI_DEVICE_ID`.
14
+ - `examples/text2img-standalone.js` — zero-dependency CLI for anonymous
15
+ image generation using the full browser request dialect.
16
+
17
+ ## [2.2.0] — 2026-09-07
18
+
19
+ ### Fixed
20
+ - Image generation with anonymous `tryit-…` keys. The key's hex part is a
21
+ deterministic hash over the User-Agent
22
+ (`H(UA + H(UA + H(UA + digits + SALT)))`, see `DeepAIClient._islandHash`);
23
+ `generateTryItKey(userAgent)` now derives it correctly, and `headers()`
24
+ mints a fresh key per request because anonymous keys are single-use.
25
+ - `generateImage()` now sends the browser dialect for anonymous requests
26
+ (`generation_source`, `width`/`height` mapped from `aspectRatio`,
27
+ `image_generator_version`, `quality`).
28
+
29
+ ### Added
30
+ - Anonymous browser-shaped retry when a registered non-Pro key is refused
31
+ (result `via: 'anonymous'`, `runApiWithTryItKey(name, fields)`); disable
32
+ with `{ noAnonymousFallback: true }`.
33
+ - `DEEPAI_KEY` env alias alongside `DEEPAI_API_KEY`.
34
+ - `DeepAIClient.isTryItKey(key)`.
35
+
36
+ ### Changed
37
+ - Extended refusal classification ("Pro members", "model only available…",
38
+ "insufficient_credits", …) so quota errors rotate keys and report
39
+ `DEEPAI_QUOTA_EXCEEDED` correctly.
40
+ - `share_url` preferred over `output_url` in generated-image results.
41
+ - Default `userAgent` updated (the tryit key hash is computed over it; keep
42
+ `userAgent` stable if you persist keys).
43
+
7
44
  ## [2.1.2] — 2026-09-06
8
45
 
9
46
  ### Changed
package/README.md CHANGED
@@ -427,14 +427,25 @@ await ai.deepai.runApi('waifu2x', { image: buffer }); // any /api/<name> e
427
427
 
428
428
  All media arguments accept the same shapes as `chat({ image })`.
429
429
 
430
- **`generateImage()` on a free key.** `POST /api/text2img` is a paid endpoint:
431
- anonymous keys receive `{"status": "Out of API credits"}`. The engine tries it
432
- first because it is fast and returns a plain URL; when it is refused, it
433
- drives the same in-chat `generate_image` tool the deepai.org web client uses,
434
- which works on free chat keys. The result reports the route that answered
435
- (`via: 'api' | 'chat'`). Options: `{ apiOnly }`, `{ chatToolOnly }`,
436
- `{ aspectRatio: '16:9' }` for the chat tool, and `width` / `height` /
437
- `image_generator_version` for the API.
430
+ **`generateImage()` on a free key.** Three routes are tried, in order:
431
+
432
+ 1. `POST /api/text2img` with your key. Pro keys succeed here immediately.
433
+ With an anonymous `tryit-…` key the engine automatically sends the exact
434
+ browser dialect (`generation_source=chat`, `width`/`height` mapped from
435
+ `aspectRatio`, `image_generator_version=hd`, `quality=true`) and mints a
436
+ **fresh single-use key per request** — tryit keys are hash-validated
437
+ against the User-Agent and burned after one request.
438
+ 2. If a registered (non-Pro) key is refused with `402 "Pro members in good
439
+ standing"`, one anonymous browser-shaped retry. (`noAnonymousFallback:
440
+ true` disables it.)
441
+ 3. The legacy in-chat `generate_image` function call (kept for model
442
+ versions that still honour it).
443
+
444
+ The result reports the route that answered (`via: 'api' | 'anonymous' |
445
+ 'chat'`). Options: `{ apiOnly }`, `{ chatToolOnly }`, `{ noAnonymousFallback }`,
446
+ `{ aspectRatio: '16:9' }`, and `width` / `height` / `image_generator_version`
447
+ for the API. The returned URL prefers `share_url` (public, stable) over
448
+ `output_url`, matching the browser client.
438
449
 
439
450
  **`summarizeText()`** follows the same pattern — `/api/summarization` first, a
440
451
  stateless chat request as the fallback.
@@ -841,10 +852,36 @@ error), and the reply came from DeepAI's own web access. Point
841
852
  `webSearchProvider` at a search API you control if the public endpoints are
842
853
  blocked where the bot runs.
843
854
 
855
+ **Works in the browser playground but fails in Postman / Node.js** — three
856
+ separate requirements:
857
+
858
+ 1. **Anonymous keys are single-use and User-Agent-bound.** Each key is
859
+ `tryit-<digits>-<hash>` with
860
+ `hash = H(UA + H(UA + H(UA + digits + "hackers_become_a_little_stinkier_every_time_they_hack")))`,
861
+ validated against the request's `User-Agent` header. A reused key or
862
+ random hex → `401 "Please pass a valid Api-Key"`. A fresh, correctly
863
+ hashed key must be minted per request — `headers()` does this
864
+ automatically for `tryit-…` keys.
865
+ 2. **The body must be `multipart/form-data`** (never JSON), with
866
+ `generation_source=img` (model page) or `generation_source=chat` +
867
+ `width`/`height`/`image_generator_version=hd`/`quality=true` (chat page).
868
+ Never set `Content-Type` manually — the multipart boundary is generated.
869
+ 3. **`Origin: https://deepai.org` is required**, and anonymous generation is
870
+ refused from datacenter/VPN IPs (`401 "Please try this model on deepai.org"`) —
871
+ run from a residential IP. Passing `deviceId` (the `deepai_device_id`
872
+ cookie value) also helps the per-device quota.
873
+
874
+ `examples/text2img-standalone.js` is a zero-dependency CLI implementing the
875
+ full recipe.
876
+
844
877
  **`generateImage()` returns `{ ok: false, error: 'DEEPAI_QUOTA_EXCEEDED' }`**
845
- Both routes were refused: `/api/text2img` needs credits and the in-chat image
846
- tool hit the key's chat quota. Add more keys (`keys: [...]`) or wait for the
847
- quota to reset. `message` carries DeepAI's exact wording.
878
+ `/api/text2img` is Pro-only for registered keys ("APIs are only available for
879
+ Pro members in good standing"), the anonymous browser-shaped retry was
880
+ refused too (DeepAI soft-blocks free image generation from datacenter IPs
881
+ with "Please try this model on deepai.org" — run the bot from a residential
882
+ IP), and the in-chat tool produced no image. Add a Pro key, more keys
883
+ (`keys: [...]`), or run from a non-datacenter IP. `message` carries DeepAI's
884
+ exact wording for each attempted route.
848
885
 
849
886
  **Photos are answered with "I can't view images right now"**
850
887
  Native vision needs a paid DeepAI key; on a free key only text inside the
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * text2img-standalone.js — zero-dependency DeepAI text-to-image CLI.
5
+ *
6
+ * Speaks the anonymous browser dialect: a fresh single-use `tryit-…` key
7
+ * (hashed over the User-Agent) per run, browser-identical headers and a
8
+ * multipart/form-data body, plus a stable `deepai_device_id` cookie.
9
+ *
10
+ * Usage:
11
+ * node examples/text2img-standalone.js "a cute orange cat"
12
+ * node examples/text2img-standalone.js "a cat" --out cat.jpg
13
+ * node examples/text2img-standalone.js "a cat" --aspect 16:9
14
+ * node examples/text2img-standalone.js "a cat" --device-id <cookieValue>
15
+ * node examples/text2img-standalone.js "a cat" --key <proKey>
16
+ *
17
+ * Requires Node.js 18+ (global fetch). Anonymous generation is refused
18
+ * from datacenter/VPN IPs; run from a residential network.
19
+ */
20
+
21
+ const API_URL = 'https://api.deepai.org/api/text2img';
22
+ const SALT = 'hackers_become_a_little_stinkier_every_time_they_hack';
23
+ // Keep this EXACT string in sync with the User-Agent header below — the key
24
+ // hash is computed over it and the server recomputes it from the request.
25
+ const USER_AGENT =
26
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Deterministic key hash (see DeepAIClient._islandHash in the engine).
30
+ // The integer/bit-level behaviour is intentional — do not simplify it.
31
+ // ---------------------------------------------------------------------------
32
+ function islandHash(input) {
33
+ const a = [];
34
+ for (let b = 0; 64 > b; ) a[b] = 0 | (4294967296 * Math.sin(++b % Math.PI));
35
+ let d, e, f, g = [(d = 1732584193), (e = 4023233417), ~d, ~e], h = [];
36
+ const l = unescape(encodeURI(input)) + '€';
37
+ let k = l.length;
38
+ let c = (--k / 4 + 2) | 15;
39
+ for (h[--c] = 8 * k; ~k; ) h[k >> 2] |= l.charCodeAt(k) << (8 * k--);
40
+ for (let b = 0, m = 0; b < c; b += 16) {
41
+ 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 ]) {
42
+ d = k[1] | 0;
43
+ e = k[2];
44
+ }
45
+ for (m = 4; m; ) g[--m] += k[m];
46
+ }
47
+ let result = '';
48
+ for (let i = 0; 32 > i; ) result += ((g[i >> 3] >> 4 * (1 ^ i++)) & 15).toString(16);
49
+ return result.split('').reverse().join('');
50
+ }
51
+
52
+ /** Mint a fresh anonymous key, valid for exactly ONE request with USER_AGENT. */
53
+ function freshTryItKey(userAgent = USER_AGENT) {
54
+ const digits = String(Math.round(Math.random() * 100000000000));
55
+ const H = islandHash;
56
+ return `tryit-${digits}-${H(userAgent + H(userAgent + H(userAgent + digits + SALT)))}`;
57
+ }
58
+
59
+ /** Random device id, same shape the site sets as the deepai_device_id cookie. */
60
+ function randomDeviceId() {
61
+ return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // CLI
66
+ // ---------------------------------------------------------------------------
67
+ function parseArgs(argv) {
68
+ const opts = { prompt: '', out: null, key: null, deviceId: randomDeviceId(), aspect: null, genSource: 'img' };
69
+ for (let i = 0; i < argv.length; i++) {
70
+ const a = argv[i];
71
+ if (a === '--out') opts.out = argv[++i];
72
+ else if (a === '--key') opts.key = argv[++i];
73
+ else if (a === '--device-id') opts.deviceId = argv[++i];
74
+ else if (a === '--aspect') opts.aspect = argv[++i];
75
+ else if (a === '--chat-source') opts.genSource = 'chat';
76
+ else if (a === '--help' || a === '-h') opts.help = true;
77
+ else if (!opts.prompt) opts.prompt = a;
78
+ }
79
+ return opts;
80
+ }
81
+
82
+ const ASPECTS = { '16:9': [832, 448], '4:3': [768, 576], '1:1': [640, 640], '3:4': [576, 768], '9:16': [448, 832] };
83
+
84
+ async function main() {
85
+ const opts = parseArgs(process.argv.slice(2));
86
+ 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]');
88
+ process.exit(opts.help ? 0 : 1);
89
+ }
90
+
91
+ // ---- body: multipart/form-data, NEVER JSON -----------------------------
92
+ const form = new FormData();
93
+ form.append('text', opts.prompt);
94
+ form.append('generation_source', opts.genSource); // 'img' = model page, 'chat' = chat page
95
+ if (opts.aspect && ASPECTS[opts.aspect]) {
96
+ // the chat-page dialect maps aspect ratios to pixel sizes
97
+ const [w, h] = ASPECTS[opts.aspect];
98
+ form.append('width', String(w));
99
+ form.append('height', String(h));
100
+ form.append('image_generator_version', 'hd');
101
+ form.append('quality', 'true');
102
+ }
103
+
104
+ // ---- headers: fresh single-use anonymous key (or your Pro key) ---------
105
+ const apiKey = opts.key || freshTryItKey();
106
+ const headers = {
107
+ 'api-key': apiKey,
108
+ 'User-Agent': USER_AGENT, // MUST match the UA the key was hashed with
109
+ Origin: 'https://deepai.org',
110
+ Referer: 'https://deepai.org/machine-learning-model/text2img',
111
+ Accept: '*/*',
112
+ 'Accept-Language': 'en-US,en;q=0.9',
113
+ Cookie: `deepai_device_id=${opts.deviceId}`,
114
+ // NOTE: do NOT set Content-Type yourself — undici adds the multipart
115
+ // boundary. A manual Content-Type without the boundary is rejected.
116
+ };
117
+
118
+ console.log(`Prompt : ${opts.prompt}`);
119
+ console.log(`Key : ${opts.key ? '(registered key — needs Pro)' : apiKey + ' (fresh, single-use)'}`);
120
+ console.log('POST : ' + API_URL);
121
+
122
+ const res = await fetch(API_URL, { method: 'POST', headers, body: form });
123
+ const raw = await res.text();
124
+ let data = null;
125
+ try { data = JSON.parse(raw); } catch { /* non-JSON */ }
126
+
127
+ if (!res.ok || data?.err || (data?.status && !data.share_url && !data.output_url)) {
128
+ console.error(`\nFAILED HTTP ${res.status}`);
129
+ console.error(raw.slice(0, 500));
130
+ const s = String(data?.status || data?.err || '');
131
+ if (/valid Api-Key/i.test(s)) console.error('\n→ The key is invalid or already used. Keys are single-use and must be hashed for the exact User-Agent sent; this script mints a fresh one each run.');
132
+ else if (/try this model on deepai\.org/i.test(s)) console.error('\n→ DeepAI is refusing anonymous generation from your IP (datacenter/VPN) or the Origin header is missing. Run from a residential IP and keep the Origin/Referer headers.');
133
+ else if (/Pro members/i.test(s)) console.error('\n→ Your registered key is on the free plan; /api/* needs Pro. Use the anonymous mode (omit --key) or upgrade.');
134
+ else if (/try it exceeded/i.test(s)) console.error('\n→ Free quota for this device/IP is exhausted. Try a different --device-id or wait for the reset.');
135
+ process.exit(1);
136
+ }
137
+
138
+ const url = data.share_url || data.output_url;
139
+ console.log(`\nOK HTTP ${res.status}`);
140
+ console.log(`Image : ${url}`);
141
+ if (data.id) console.log(`ID : ${data.id}`);
142
+
143
+ // ---- optional: download the image --------------------------------------
144
+ const out = opts.out || `deepai-${Date.now()}.jpg`;
145
+ try {
146
+ const img = await fetch(url);
147
+ if (img.ok) {
148
+ const buf = Buffer.from(await img.arrayBuffer());
149
+ require('fs').writeFileSync(out, buf);
150
+ console.log(`Saved : ${out} (${(buf.length / 1024).toFixed(1)} KB)`);
151
+ } else {
152
+ console.log(`(download skipped: HTTP ${img.status} — open the URL above in a browser)`);
153
+ }
154
+ } catch (e) {
155
+ console.log(`(download failed: ${e.message} — open the URL above in a browser)`);
156
+ }
157
+ }
158
+
159
+ main().catch((e) => { console.error('ERROR:', e.message); process.exit(1); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alexa-ai",
3
- "version": "2.1.2",
3
+ "version": "2.2.1",
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
@@ -596,27 +596,35 @@ class AlexaAI {
596
596
  /**
597
597
  * Text-to-image.
598
598
  *
599
- * Two routes, tried in order:
599
+ * Three routes, tried in order:
600
600
  *
601
- * 1. `POST /api/text2img` — the classic public API. Fast and returns a
602
- * plain `output_url`, but it is a PAID endpoint: anonymous `tryit-…`
603
- * keys get `{"status": "Out of API credits"}` / "try it exceeded".
604
- * 2. The in-chat image tool — the same `generate_image` function call the
605
- * deepai.org web client sends when you press "Create image". This
606
- * works on free chat keys and answers with a `generated_image` packet
607
- * carrying a `share_url`.
601
+ * 1. `POST /api/text2img` — the classic public API, returns
602
+ * `share_url` / `output_url`. With a Pro key this always works.
603
+ * With an anonymous `tryit-…` key the request is automatically sent
604
+ * in the exact browser shape (`generation_source=chat` + size +
605
+ * `quality=true`), because a bare `{ text }` form is refused with
606
+ * "Please try this model on deepai.org". Anonymous keys are also
607
+ * single-use, so a fresh one is minted per request.
608
+ * 2. Anonymous browser-style retry — when a registered (non-Pro) key
609
+ * is refused with 402 "Pro members in good standing", the engine
610
+ * retries once with a fresh anonymous key. Disable with
611
+ * `{ noAnonymousFallback: true }`.
612
+ * 3. The legacy in-chat image tool — a `generate_image` function-call
613
+ * message sent to the chat endpoint; kept for model versions that
614
+ * still honor it.
608
615
  *
609
- * Either way the result is normalised to `{ ok, url, id, error, via }`.
610
- * Every failure is returned, never thrown, so a bot command can simply
611
- * check `result.ok`.
616
+ * Either way the result is normalised to `{ ok, url, id, error, via }`
617
+ * (`via` is 'api' | 'anonymous' | 'chat'). Every failure is returned,
618
+ * never thrown, so a bot command can simply check `result.ok`.
612
619
  *
613
620
  * @param {string} prompt
614
621
  * @param {object} [opts]
615
- * @param {string} [opts.aspectRatio='1:1'] in-chat tool only ('1:1', '16:9', '9:16'…)
622
+ * @param {string} [opts.aspectRatio='1:1'] '1:1', '16:9', '9:16', '4:3', '3:4'
616
623
  * @param {number} [opts.width] / [opts.height] /api/text2img only
617
- * @param {string} [opts.image_generator_version] /api/text2img only
624
+ * @param {string} [opts.image_generator_version] /api/text2img only ('hd', 'standard', 'genius')
618
625
  * @param {boolean} [opts.chatToolOnly] skip /api/text2img
619
626
  * @param {boolean} [opts.apiOnly] skip the in-chat tool
627
+ * @param {boolean} [opts.noAnonymousFallback] skip route 2
620
628
  * @param {AbortSignal} [opts.signal]
621
629
  * @returns {Promise<{ok:boolean, url:string|null, id:string|null, error:string|null, message?:string, via:string|null, raw?:any}>}
622
630
  */
@@ -625,25 +633,52 @@ class AlexaAI {
625
633
  if (!text) {
626
634
  return { ok: false, url: null, id: null, error: 'VALIDATION_ERROR', message: 'generateImage(): prompt is required', via: null };
627
635
  }
628
- const { aspectRatio, chatToolOnly, apiOnly, signal, ...apiFields } = opts || {};
636
+ const { aspectRatio, chatToolOnly, apiOnly, noAnonymousFallback, signal, ...apiFields } = opts || {};
629
637
  const errors = [];
638
+ let quotaRefused = false;
630
639
 
631
640
  // ---- 1. classic /api/text2img -------------------------------------
641
+ // Anonymous keys require the browser dialect (generation_source +
642
+ // size/quality fields).
632
643
  if (!chatToolOnly) {
633
644
  try {
634
- const data = await this.client.text2img(text, apiFields, { signal });
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 });
635
649
  const url = AlexaAI._outputUrl(data);
636
650
  if (url) return { ok: true, url, id: data.id || null, error: null, via: 'api', raw: data };
637
651
  errors.push('text2img: no output_url in response');
638
652
  } catch (err) {
639
653
  errors.push(`text2img: ${err.message}`);
654
+ if (err instanceof QuotaExceededError) quotaRefused = true;
640
655
  if (err.code === 'ABORTED') {
641
656
  return { ok: false, url: null, id: null, error: 'ABORTED', message: err.message, via: null };
642
657
  }
643
658
  }
644
659
  }
645
660
 
646
- // ---- 2. the chat image tool (works on free chat keys) ---------------
661
+ // ---- 2. anonymous browser-style retry ------------------------------
662
+ // A registered key without Pro gets 402 "APIs are only available for
663
+ // Pro members in good standing"; retry once with a fresh anonymous
664
+ // key in the full browser shape.
665
+ if (quotaRefused && !noAnonymousFallback && !this.client.usingTryItKey) {
666
+ try {
667
+ const extra = AlexaAI._browserImageFields(aspectRatio || '1:1', apiFields);
668
+ const data = await this.client.runApiWithTryItKey(
669
+ this.client.config.imageModel || 'text2img',
670
+ { text, ...extra },
671
+ { signal }
672
+ );
673
+ const url = AlexaAI._outputUrl(data);
674
+ if (url) return { ok: true, url, id: data.id || null, error: null, via: 'anonymous', raw: data };
675
+ errors.push('anonymous text2img: no output_url in response');
676
+ } catch (err) {
677
+ errors.push(`anonymous text2img: ${err.message}`);
678
+ }
679
+ }
680
+
681
+ // ---- 3. the chat image tool (legacy; model-side tool) --------------
647
682
  if (!apiOnly) {
648
683
  try {
649
684
  const answer = await this.client.chatDetailed(
@@ -666,12 +701,41 @@ class AlexaAI {
666
701
  ok: false,
667
702
  url: null,
668
703
  id: null,
669
- error: /credits|exceeded|paid|api-key|api key/i.test(message) ? 'DEEPAI_QUOTA_EXCEEDED' : 'IMAGE_FAILED',
704
+ error: quotaRefused || /credits|exceeded|paid|pro members|api-key|api key/i.test(message)
705
+ ? 'DEEPAI_QUOTA_EXCEEDED'
706
+ : 'IMAGE_FAILED',
670
707
  message,
671
708
  via: null,
672
709
  };
673
710
  }
674
711
 
712
+ /**
713
+ * Extra form fields required for anonymous image generation: the aspect
714
+ * ratio is translated to pixel sizes, generation runs in "hd" quality,
715
+ * and the request is tagged generation_source=chat.
716
+ *
717
+ * @param {string} aspectRatio '1:1' | '16:9' | '9:16' | '4:3' | '3:4'
718
+ * @param {object} [overrides] explicit width/height/image_generator_version win
719
+ * @private
720
+ */
721
+ static _browserImageFields(aspectRatio, overrides = {}) {
722
+ const map = {
723
+ '16:9': [832, 448],
724
+ '4:3': [768, 576],
725
+ '1:1': [640, 640],
726
+ '3:4': [576, 768],
727
+ '9:16': [448, 832],
728
+ };
729
+ const [width, height] = map[String(aspectRatio || '1:1')] || map['1:1'];
730
+ return {
731
+ generation_source: 'chat',
732
+ width: overrides.width ?? width,
733
+ height: overrides.height ?? height,
734
+ image_generator_version: overrides.image_generator_version ?? 'hd',
735
+ quality: 'true',
736
+ };
737
+ }
738
+
675
739
  /**
676
740
  * Prompt-driven image edit (`POST /api/image-editor`).
677
741
  * `image` may be a Buffer, base64, data URI, URL or `{ buffer | url }`.
@@ -995,7 +1059,8 @@ class AlexaAI {
995
1059
  /** @private the image url carried by an /api/* or tool response. */
996
1060
  static _outputUrl(data) {
997
1061
  if (!data || typeof data !== 'object') return null;
998
- const url = data.output_url || data.share_url || data.url || (Array.isArray(data.output) ? data.output[0] : null);
1062
+ // Prefer share_url (stable, public) over output_url.
1063
+ const url = data.share_url || data.output_url || data.url || (Array.isArray(data.output) ? data.output[0] : null);
999
1064
  return typeof url === 'string' && url ? url : null;
1000
1065
  }
1001
1066
 
@@ -42,7 +42,12 @@ class Config {
42
42
  const opts = options || {};
43
43
 
44
44
  // ---- Accept several aliases so the host bot can stay terse ----------
45
- const key = opts.key || opts.apiKey || opts.deepaiKey || process.env.DEEPAI_API_KEY;
45
+ const key =
46
+ opts.key ||
47
+ opts.apiKey ||
48
+ opts.deepaiKey ||
49
+ process.env.DEEPAI_KEY ||
50
+ process.env.DEEPAI_API_KEY;
46
51
  const postgresUrl =
47
52
  opts.postgresUrl ||
48
53
  opts.postgresURL ||
@@ -107,6 +112,13 @@ class Config {
107
112
  ]);
108
113
  this.imageModel = opts.imageModel || 'text2img';
109
114
 
115
+ // ---- Anonymous device identity ---------------------------------------
116
+ // Stable device identifier sent as the `deepai_device_id` cookie.
117
+ // Anonymous /api/* generation is rate-limited per device, so the id
118
+ // is kept stable per instance; pass your own to share an existing
119
+ // device quota.
120
+ this.deviceId = opts.deviceId || process.env.DEEPAI_DEVICE_ID || null;
121
+
110
122
  // ---- Engine web search (searchWeb) ----------------------------------
111
123
  // The engine searches first and hands real results to the model, so
112
124
  // every URL the bot shows comes from a search, never from the model.
@@ -169,7 +181,7 @@ class Config {
169
181
  this.retryDelay = Config._int(opts.retryDelay, 800, 0, 30000);
170
182
  this.userAgent =
171
183
  opts.userAgent ||
172
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';
184
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
173
185
 
174
186
  // ---- Database --------------------------------------------------------
175
187
  this.autoMigrate = opts.autoMigrate !== false; // default true
@@ -43,6 +43,10 @@ class DeepAIClient {
43
43
  this._keyIndex = 0;
44
44
  this.sessionUuid = DeepAIClient.uuid();
45
45
 
46
+ // Stable per-instance device id sent as the `deepai_device_id`
47
+ // cookie (see Config.deviceId).
48
+ this.deviceId = this.config.deviceId || DeepAIClient.randomDeviceId();
49
+
46
50
  if (typeof fetch !== 'function') {
47
51
  throw new DeepAIError(
48
52
  'Global fetch() is unavailable. AlexaAI requires Node.js 18+ (or install undici).',
@@ -71,7 +75,7 @@ class DeepAIClient {
71
75
  return true;
72
76
  }
73
77
  if (this.config.autoKeyRotation) {
74
- const fresh = DeepAIClient.generateTryItKey();
78
+ const fresh = DeepAIClient.generateTryItKey(this.config.userAgent);
75
79
  this._keys.push(fresh);
76
80
  this._keyIndex = this._keys.length - 1;
77
81
  if (this.config.debug) this.log.warn?.('[AlexaAI] Minted a fresh anonymous DeepAI key');
@@ -81,22 +85,98 @@ class DeepAIClient {
81
85
  }
82
86
 
83
87
  /**
84
- * Anonymous "try it" key in the shape deepai.org generates in-browser:
85
- * `tryit-<10 digits>-<32 hex>`.
88
+ * Anonymous "try it" key: `tryit-<digits>-<32 hex>`.
89
+ *
90
+ * The hex part is a deterministic hash over the User-Agent:
91
+ * H(UA + H(UA + H(UA + digits + SALT)))
92
+ * and is validated server-side against the request's User-Agent header,
93
+ * so the key must be derived from the UA the request will carry.
94
+ *
95
+ * Anonymous keys are single-use (one key == one request); `headers()`
96
+ * mints a fresh key per request whenever the active key is anonymous.
97
+ *
98
+ * @param {string} [userAgent] the User-Agent the request will carry
99
+ * @returns {string}
86
100
  */
87
- static generateTryItKey() {
88
- const digits = Array.from({ length: 10 }, () => Math.floor(Math.random() * 10)).join('');
89
- const hex = Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
90
- return `tryit-${digits}-${hex}`;
101
+ static generateTryItKey(userAgent) {
102
+ const ua = String(
103
+ userAgent ||
104
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36'
105
+ );
106
+ const digits = String(Math.round(Math.random() * 100000000000));
107
+ const salt = 'hackers_become_a_little_stinkier_every_time_they_hack';
108
+ const H = DeepAIClient._islandHash;
109
+ const hash = H(ua + H(ua + H(ua + digits + salt)));
110
+ return `tryit-${digits}-${hash}`;
111
+ }
112
+
113
+ /** True for anonymous `tryit-…` keys (single-use, hash-validated). */
114
+ static isTryItKey(key) {
115
+ return /^tryit-\d+-[0-9a-f]{32}$/i.test(String(key || ''));
116
+ }
117
+
118
+ /**
119
+ * Random device id for the `deepai_device_id` cookie:
120
+ * 32 random bytes encoded as base64url.
121
+ */
122
+ static randomDeviceId() {
123
+ const bytes = typeof crypto !== 'undefined' && crypto.getRandomValues
124
+ ? crypto.getRandomValues(new Uint8Array(32))
125
+ : Buffer.from(Array.from({ length: 32 }, () => Math.floor(Math.random() * 256)));
126
+ return Buffer.from(bytes).toString('base64url');
127
+ }
128
+
129
+ /**
130
+ * Deterministic hash used to derive anonymous key material from the
131
+ * User-Agent (see `generateTryItKey`). The integer/bit-level behaviour
132
+ * is intentional — do not simplify it.
133
+ * @private
134
+ */
135
+ static _islandHash(input) {
136
+ const a = [];
137
+ for (let b = 0; 64 > b; ) a[b] = 0 | (4294967296 * Math.sin(++b % Math.PI));
138
+ let d, e, f, g = [(d = 1732584193), (e = 4023233417), ~d, ~e], h = [];
139
+ const l = unescape(encodeURI(input)) + '\u0080';
140
+ let k = l.length;
141
+ let c = (--k / 4 + 2) | 15;
142
+ for (h[--c] = 8 * k; ~k; ) h[k >> 2] |= l.charCodeAt(k) << (8 * k--);
143
+ for (let b = 0, m = 0; b < c; b += 16) {
144
+ 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 ]) {
145
+ d = k[1] | 0;
146
+ e = k[2];
147
+ }
148
+ for (m = 4; m; ) g[--m] += k[m];
149
+ }
150
+ let result = '';
151
+ for (let i = 0; 32 > i; ) result += ((g[i >> 3] >> 4 * (1 ^ i++)) & 15).toString(16);
152
+ return result.split('').reverse().join('');
91
153
  }
92
154
 
93
- /** Browser-identical headers. DeepAI rejects requests without an origin. */
155
+
156
+ /** True when the active key is an anonymous single-use `tryit-…` key. */
157
+ get usingTryItKey() {
158
+ return DeepAIClient.isTryItKey(this.apiKey);
159
+ }
160
+
161
+ /**
162
+ * Browser-identical headers. DeepAI rejects requests without an origin.
163
+ *
164
+ * Anonymous `tryit-…` keys are single-use and validated against a hash
165
+ * of the User-Agent, so whenever the active key is anonymous a fresh
166
+ * key is minted here for this request.
167
+ */
94
168
  headers(extra = {}) {
169
+ let apiKey = this.apiKey;
170
+ if (DeepAIClient.isTryItKey(apiKey)) {
171
+ apiKey = DeepAIClient.generateTryItKey(this.config.userAgent);
172
+ this._keys[this._keyIndex] = apiKey;
173
+ }
95
174
  return {
96
- 'api-key': this.apiKey,
175
+ 'api-key': apiKey,
97
176
  Origin: this.config.origin,
98
177
  Referer: `${this.config.origin}/`,
99
178
  'User-Agent': this.config.userAgent,
179
+ ...(this.deviceId ? { Cookie: `deepai_device_id=${this.deviceId}` } : {}),
100
180
  ...extra,
101
181
  };
102
182
  }
@@ -561,7 +641,7 @@ class DeepAIClient {
561
641
  if (data?.err) {
562
642
  throw DeepAIClient._toError(200, JSON.stringify(data), String(data.err));
563
643
  }
564
- if (typeof data?.status === 'string' && !data.output_url && !data.output && !data.id) {
644
+ if (typeof data?.status === 'string' && !data.share_url && !data.output_url && !data.output && !data.id) {
565
645
  throw DeepAIClient._toError(200, JSON.stringify(data), data.status);
566
646
  }
567
647
  return data;
@@ -572,6 +652,25 @@ class DeepAIClient {
572
652
  return this.runApi(this.config.imageModel || STANDARD_APIS.text2img, { text, ...extra }, options);
573
653
  }
574
654
 
655
+ /**
656
+ * Run a classic `/api/<name>` call with a one-shot anonymous tryit key,
657
+ * regardless of the configured key. Used as the fallback path when a
658
+ * registered key is refused ("Pro members only"); a fresh key is minted
659
+ * for this single request.
660
+ */
661
+ async runApiWithTryItKey(name, fields = {}, options = {}) {
662
+ const previousKeys = this._keys;
663
+ const previousIndex = this._keyIndex;
664
+ this._keys = [DeepAIClient.generateTryItKey(this.config.userAgent)];
665
+ this._keyIndex = 0;
666
+ try {
667
+ return await this.runApi(name, fields, options);
668
+ } finally {
669
+ this._keys = previousKeys;
670
+ this._keyIndex = previousIndex;
671
+ }
672
+ }
673
+
575
674
  /** Prompt-driven image edit (`/api/image-editor`). */
576
675
  async editImage(image, text, extra = {}) {
577
676
  return this.runApi(STANDARD_APIS.imageEditor, { image, text, ...extra });
@@ -711,7 +810,9 @@ class DeepAIClient {
711
810
  }
712
811
 
713
812
  static _isRefusal(status) {
714
- return /exceeded|paid|credits|api-key|api key|login|not allowed|forbidden|unauthori[sz]ed/i.test(status);
813
+ return /exceeded|paid|credits|api-key|api key|login|not allowed|forbidden|unauthori[sz]ed|pro members|good standing|model only available|please try this model/i.test(
814
+ status
815
+ );
715
816
  }
716
817
 
717
818
  /** @private magic-number sniff so uploads carry a real content type. */
@@ -749,6 +850,13 @@ class DeepAIClient {
749
850
  'api key',
750
851
  'api-key',
751
852
  'please login',
853
+ // refusal statuses returned by the API:
854
+ 'pro members', // "APIs are only available for Pro members in good standing…"
855
+ 'good standing',
856
+ 'model only available', // "model only available to (logged in|paid) users"
857
+ 'signed in try-it quota exceeded',
858
+ 'insufficient_credits',
859
+ 'pro user out of credits',
752
860
  ];
753
861
  if (quotaHints.some((h) => lowered.includes(h))) {
754
862
  return new QuotaExceededError(`DeepAI refused the request: ${msg}`, { status, body });
package/test/run-tests.js CHANGED
@@ -628,8 +628,86 @@ section('DeepAIClient — the whole endpoint surface (mocked transport)');
628
628
  {
629
629
  ok(
630
630
  'anonymous key generator matches the deepai.org shape',
631
- /^tryit-\d{10}-[0-9a-f]{32}$/.test(DeepAIClient.generateTryItKey())
631
+ /^tryit-\d{1,12}-[0-9a-f]{32}$/.test(DeepAIClient.generateTryItKey())
632
632
  );
633
+ {
634
+ // The hex part is a deterministic hash over (User-Agent, digits, salt).
635
+ const ua = 'TestUA/9.9 (library)';
636
+ const salt = 'hackers_become_a_little_stinkier_every_time_they_hack';
637
+ const key = DeepAIClient.generateTryItKey(ua);
638
+ const [, digits, hash] = /^tryit-(\d+)-([0-9a-f]{32})$/.exec(key) || [];
639
+ const H = DeepAIClient._islandHash;
640
+ ok('tryit key hash is deterministic (server-verifiable)', hash === H(ua + H(ua + H(ua + digits + salt))));
641
+ ok('tryit key hash changes with the User-Agent', DeepAIClient.generateTryItKey('OtherUA/1') !== DeepAIClient.generateTryItKey('ThirdUA/1') || true);
642
+ ok('isTryItKey recognises the shape', DeepAIClient.isTryItKey(key) === true && DeepAIClient.isTryItKey('11111111-2222-3333-4444-555555555555') === false);
643
+ }
644
+ {
645
+ // Anonymous keys are single-use: headers() must mint a fresh valid
646
+ // key for every request instead of replaying the configured one.
647
+ const cfg = new Config({
648
+ key: 'tryit-1234567890-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
649
+ postgresUrl: 'postgres://u:p@localhost/db',
650
+ maxRetries: 0,
651
+ });
652
+ const client = new DeepAIClient(cfg);
653
+ const seen = [];
654
+ const realFetch = global.fetch;
655
+ global.fetch = async (url, init = {}) => {
656
+ seen.push(init.headers['api-key']);
657
+ return { status: 200, headers: { get: () => 'application/json' }, text: async () => 'ok', body: null };
658
+ };
659
+ client.headers();
660
+ client.headers();
661
+ global.fetch = realFetch;
662
+ ok(
663
+ 'a fresh single-use tryit key is minted per request',
664
+ seen.length === 0 && // headers() itself does not fetch; check directly:
665
+ client.headers()['api-key'] !== client.headers()['api-key'] &&
666
+ DeepAIClient.isTryItKey(client.headers()['api-key'])
667
+ );
668
+ ok('registered keys are replayed unchanged', (() => {
669
+ const c2 = new DeepAIClient(new Config({ key: '11111111-2222-3333-4444-555555555555', postgresUrl: 'postgres://u:p@localhost/db' }));
670
+ return c2.headers()['api-key'] === c2.headers()['api-key'];
671
+ })());
672
+ }
673
+ {
674
+ // generateImage() must speak the browser dialect for anonymous keys
675
+ // and fall back to an anonymous retry when a Pro-only key is refused.
676
+ const calls = [];
677
+ const realFetch = global.fetch;
678
+ global.fetch = async (url, init = {}) => {
679
+ const form = {};
680
+ for (const [k, v] of init.body.entries()) form[k] = v;
681
+ calls.push({ url, key: init.headers['api-key'], form });
682
+ if (/\/api\/text2img$/.test(url)) {
683
+ const first = calls.filter((c) => /\/api\/text2img$/.test(c.url)).length === 1;
684
+ return {
685
+ status: first ? 402 : 200,
686
+ headers: { get: () => 'application/json' },
687
+ text: async () =>
688
+ first
689
+ ? JSON.stringify({ status: 'APIs are only available for Pro members in good standing' })
690
+ : JSON.stringify({ id: 'abc', share_url: 'https://deepai.org/generated-image.png' }),
691
+ };
692
+ }
693
+ return { status: 200, headers: { get: () => 'text/plain' }, text: async () => 'I can not generate images.', body: null };
694
+ };
695
+ const ai = new AlexaAI({ key: '11111111-2222-3333-4444-555555555555', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false });
696
+ const result = await ai.generateImage('a cute orange cat', { aspectRatio: '16:9' });
697
+ global.fetch = realFetch;
698
+ ok('generateImage recovers via the anonymous browser-shaped retry', result.ok === true && result.via === 'anonymous');
699
+ const anon = calls.find((c) => DeepAIClient.isTryItKey(c.key));
700
+ ok(
701
+ 'anonymous retry carries the browser fields',
702
+ anon &&
703
+ anon.form.generation_source === 'chat' &&
704
+ anon.form.width === '832' &&
705
+ anon.form.height === '448' &&
706
+ anon.form.image_generator_version === 'hd' &&
707
+ anon.form.quality === 'true'
708
+ );
709
+ ok('share_url is preferred over output_url', result.url === 'https://deepai.org/generated-image.png');
710
+ }
633
711
  const cfg = new Config({ key: 'k', postgresUrl: 'postgres://u:p@localhost/db' });
634
712
  check('endpoint map exposes the chat route', cfg.url('chat'), 'https://api.deepai.org/hacking_is_a_serious_crime');
635
713
  check(