alexa-ai 2.1.1 → 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/src/AlexaAI.js CHANGED
@@ -17,6 +17,7 @@ const IdentityResolver = require('./services/IdentityResolver');
17
17
  const TriggerDetector = require('./services/TriggerDetector');
18
18
  const ImageDescriber = require('./services/ImageDescriber');
19
19
  const WebAnswer = require('./services/WebAnswer');
20
+ const WebSearch = require('./services/WebSearch');
20
21
  const StreamParser = require('./core/StreamParser');
21
22
  const JidParser = require('./utils/JidParser');
22
23
  const Media = require('./utils/Media');
@@ -69,6 +70,7 @@ class AlexaAI {
69
70
 
70
71
  this.prompts = new PromptBuilder(this.config);
71
72
  this.vision = new ImageDescriber(this.client, this.config);
73
+ this.webSearch = new WebSearch(this.config);
72
74
 
73
75
  // Persona-aware guards (renaming the assistant renames these too).
74
76
  this.identityGuard = new IdentityGuard({
@@ -594,27 +596,35 @@ class AlexaAI {
594
596
  /**
595
597
  * Text-to-image.
596
598
  *
597
- * Two routes, tried in order:
599
+ * Three routes, tried in order:
598
600
  *
599
- * 1. `POST /api/text2img` — the classic public API. Fast and returns a
600
- * plain `output_url`, but it is a PAID endpoint: anonymous `tryit-…`
601
- * keys get `{"status": "Out of API credits"}` / "try it exceeded".
602
- * 2. The in-chat image tool — the same `generate_image` function call the
603
- * deepai.org web client sends when you press "Create image". This
604
- * works on free chat keys and answers with a `generated_image` packet
605
- * 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.
606
615
  *
607
- * Either way the result is normalised to `{ ok, url, id, error, via }`.
608
- * Every failure is returned, never thrown, so a bot command can simply
609
- * 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`.
610
619
  *
611
620
  * @param {string} prompt
612
621
  * @param {object} [opts]
613
- * @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'
614
623
  * @param {number} [opts.width] / [opts.height] /api/text2img only
615
- * @param {string} [opts.image_generator_version] /api/text2img only
624
+ * @param {string} [opts.image_generator_version] /api/text2img only ('hd', 'standard', 'genius')
616
625
  * @param {boolean} [opts.chatToolOnly] skip /api/text2img
617
626
  * @param {boolean} [opts.apiOnly] skip the in-chat tool
627
+ * @param {boolean} [opts.noAnonymousFallback] skip route 2
618
628
  * @param {AbortSignal} [opts.signal]
619
629
  * @returns {Promise<{ok:boolean, url:string|null, id:string|null, error:string|null, message?:string, via:string|null, raw?:any}>}
620
630
  */
@@ -623,25 +633,52 @@ class AlexaAI {
623
633
  if (!text) {
624
634
  return { ok: false, url: null, id: null, error: 'VALIDATION_ERROR', message: 'generateImage(): prompt is required', via: null };
625
635
  }
626
- const { aspectRatio, chatToolOnly, apiOnly, signal, ...apiFields } = opts || {};
636
+ const { aspectRatio, chatToolOnly, apiOnly, noAnonymousFallback, signal, ...apiFields } = opts || {};
627
637
  const errors = [];
638
+ let quotaRefused = false;
628
639
 
629
640
  // ---- 1. classic /api/text2img -------------------------------------
641
+ // Anonymous keys require the browser dialect (generation_source +
642
+ // size/quality fields).
630
643
  if (!chatToolOnly) {
631
644
  try {
632
- 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 });
633
649
  const url = AlexaAI._outputUrl(data);
634
650
  if (url) return { ok: true, url, id: data.id || null, error: null, via: 'api', raw: data };
635
651
  errors.push('text2img: no output_url in response');
636
652
  } catch (err) {
637
653
  errors.push(`text2img: ${err.message}`);
654
+ if (err instanceof QuotaExceededError) quotaRefused = true;
638
655
  if (err.code === 'ABORTED') {
639
656
  return { ok: false, url: null, id: null, error: 'ABORTED', message: err.message, via: null };
640
657
  }
641
658
  }
642
659
  }
643
660
 
644
- // ---- 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) --------------
645
682
  if (!apiOnly) {
646
683
  try {
647
684
  const answer = await this.client.chatDetailed(
@@ -664,12 +701,41 @@ class AlexaAI {
664
701
  ok: false,
665
702
  url: null,
666
703
  id: null,
667
- 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',
668
707
  message,
669
708
  via: null,
670
709
  };
671
710
  }
672
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
+
673
739
  /**
674
740
  * Prompt-driven image edit (`POST /api/image-editor`).
675
741
  * `image` may be a Buffer, base64, data URI, URL or `{ buffer | url }`.
@@ -785,25 +851,35 @@ class AlexaAI {
785
851
  * One-off, stateless web research request — for "search the web for X"
786
852
  * commands that must not touch anyone's memory.
787
853
  *
788
- * By default the answer is long-form: an intro, three to five `*Heading:*`
789
- * sections with numbered `*Headline*: detail` points (about 300–450
790
- * words), followed by one `*Sources:*` block. Sources come from DeepAI's
791
- * web-result packet when the model sends one, and are otherwise lifted
792
- * out of the "Sources:" list the model writes — either way they are
793
- * returned in `sources` and rendered exactly once in `text`.
854
+ * HOW IT WORKS
855
+ * ------------
856
+ * 1. The engine searches the web itself (`WebSearch`: DuckDuckGo, Bing
857
+ * News, Google News, Wikipedia — no API key needed) and collects real
858
+ * pages with titles, snippets and dates.
859
+ * 2. Those results go to the model as numbered material. The model writes
860
+ * the report from them and cites result numbers; it is told never to
861
+ * write a URL.
862
+ * 3. The `*Sources:*` block is built from the search results only —
863
+ * cited ones first. A URL the model wrote itself is never shown.
794
864
  *
795
- * Small models sometimes ignore the layout and answer in a sentence. When
796
- * a long-form answer comes back under `minWords`, one follow-up turn asks
797
- * the model to rewrite it in full; the longer of the two replies wins.
865
+ * When the search returns nothing (offline host, all providers blocked)
866
+ * the request falls back to DeepAI's server-side web access, which is
867
+ * unreliable on free models: it may skip the search and invent pages.
868
+ * `grounded` in the result tells you which path answered.
798
869
  *
799
- * The persona still applies (WhatsApp formatting, no renaming of the
800
- * assistant), but third-party vendor names in the research itself are
801
- * kept, and the identity/memory guards run without a user context, so
802
- * this is safe to call with no jid at all.
870
+ * Default output is long-form: an intro, three to five `*Heading:*`
871
+ * sections with numbered `*Headline*: detail` points (about 300–450
872
+ * words), then one `*Sources:*` block. A long-form reply under `minWords`
873
+ * is retried once; the longer reply wins.
803
874
  *
804
875
  * @param {string} query
805
876
  * @param {object} [opts]
806
877
  * @param {'long'|'short'} [opts.detail='long'] `short` = 2–4 sentences
878
+ * @param {Array<{title?:string,url:string,description?:string,date?:string}>} [opts.results]
879
+ * results from the host application's own search API (skips the built-in search)
880
+ * @param {boolean} [opts.search=true] `false` = skip the built-in search, use DeepAI's
881
+ * @param {string[]} [opts.providers] subset of WebSearch.PROVIDERS for this call
882
+ * @param {number} [opts.maxResults] results handed to the model (default config.webSearchResults)
807
883
  * @param {number} [opts.minWords=150] long form only: retry once below this (0 disables)
808
884
  * @param {boolean} [opts.includeSources=true] append the *Sources:* block to `text`
809
885
  * @param {number} [opts.maxSources=5] sources listed in `text` (the array is not capped)
@@ -812,12 +888,12 @@ class AlexaAI {
812
888
  * @param {string} [opts.model]
813
889
  * @param {string} [opts.userName]
814
890
  * @param {AbortSignal} [opts.signal]
815
- * @returns {Promise<{ok:boolean, text:string, answer:string, sources:Array<{title:string|null,url:string|null,description:string|null}>, words:number, attempts:number, model?:string|null, error?:string, message?:string}>}
891
+ * @returns {Promise<{ok:boolean, text:string, answer:string, sources:Array<{title:string|null,url:string|null,description:string|null,date?:string|null,provider?:string,cited?:boolean}>, grounded:boolean, providers:string[], words:number, attempts:number, via:'model'|'digest', model?:string|null, error?:string, message?:string}>}
816
892
  */
817
893
  async searchWeb(query, opts = {}) {
818
894
  const question = String(query ?? '').trim();
819
895
  const failure = (error, message, extra = {}) => ({
820
- ok: false, text: '', answer: '', sources: [], words: 0, attempts: 0, error, message, ...extra,
896
+ ok: false, text: '', answer: '', sources: [], grounded: false, providers: [], words: 0, attempts: 0, via: 'model', error, message, ...extra,
821
897
  });
822
898
  if (!question) return failure('VALIDATION_ERROR', 'searchWeb(): query is required');
823
899
 
@@ -826,18 +902,48 @@ class AlexaAI {
826
902
  const maxSources = Number.isFinite(opts.maxSources) ? opts.maxSources : 5;
827
903
  const minWords = detail === 'long' && Number.isFinite(opts.minWords) ? Math.max(0, opts.minWords) : detail === 'long' ? 150 : 0;
828
904
 
829
- const request = { search: true, webAccess: true, model: opts.model, signal: opts.signal };
905
+ // ---- 1. search ------------------------------------------------------
906
+ let results = WebSearch.normalise(opts.results, 'caller');
907
+ let providers = results.length ? ['caller'] : [];
908
+ if (!results.length && opts.search !== false) {
909
+ const found = await this.webSearch.search(question, {
910
+ providers: opts.providers,
911
+ maxResults: opts.maxResults,
912
+ signal: opts.signal,
913
+ });
914
+ results = found.results;
915
+ providers = found.providers;
916
+ if (!results.length && this.config.debug) {
917
+ this.log.debug?.(
918
+ `[AlexaAI] searchWeb: no web results for "${question}" ` +
919
+ (found.errors.length ? `(${found.errors.map((e) => `${e.provider}: ${e.message}`).join('; ')})` : '') +
920
+ ' — falling back to DeepAI web access'
921
+ );
922
+ }
923
+ }
924
+ const grounded = results.length > 0;
925
+
926
+ // ---- 2. ask the model ---------------------------------------------
927
+ const request = { search: !grounded, webAccess: !grounded, model: opts.model, signal: opts.signal };
830
928
  const messages = this.prompts.build({
831
- message: WebAnswer.prompt(question, { detail, language: opts.language, instructions: opts.instructions }),
929
+ message: WebAnswer.prompt(question, {
930
+ detail,
931
+ results: grounded ? results : null,
932
+ language: opts.language,
933
+ instructions: opts.instructions,
934
+ }),
832
935
  memories: {},
833
936
  history: [],
834
937
  userName: opts.userName || null,
835
938
  });
836
939
 
940
+ let reply;
941
+ let result;
942
+ let attempts = 0;
837
943
  try {
838
- let reply = await this.client.chatDetailed(messages, request);
839
- let result = this._webAnswerFrom(reply);
840
- let attempts = 1;
944
+ reply = await this.client.chatDetailed(messages, request);
945
+ result = this._webAnswerFrom(reply, results);
946
+ attempts = 1;
841
947
 
842
948
  if (minWords && result.words < minWords && result.answer) {
843
949
  // Second chance: keep the first reply in the transcript so the
@@ -845,12 +951,12 @@ class AlexaAI {
845
951
  const retryMessages = [
846
952
  ...messages,
847
953
  { role: 'assistant', content: reply.text },
848
- { role: 'user', content: WebAnswer.expandPrompt(question, result.words) },
954
+ { role: 'user', content: WebAnswer.expandPrompt(question, result.words, { grounded }) },
849
955
  ];
850
956
  attempts = 2;
851
957
  try {
852
958
  const second = await this.client.chatDetailed(retryMessages, request);
853
- const candidate = this._webAnswerFrom(second, result.sources);
959
+ const candidate = this._webAnswerFrom(second, results, result.sources);
854
960
  if (candidate.words > result.words) {
855
961
  reply = second;
856
962
  result = candidate;
@@ -859,34 +965,75 @@ class AlexaAI {
859
965
  this.log.debug?.(`[AlexaAI] searchWeb expansion failed, keeping first reply: ${err.message}`);
860
966
  }
861
967
  }
862
-
863
- const { answer, sources, words } = result;
864
- if (!answer && !sources.length) return failure('DEEPAI_EMPTY', 'DeepAI returned no answer', { attempts });
865
-
866
- const text = WebAnswer.render(answer, sources, { includeSources: includeSources || !answer, maxSources });
867
- return { ok: true, text, answer, sources, words, attempts, model: reply.model || null };
868
968
  } catch (err) {
869
969
  this.log.warn?.(`[AlexaAI] searchWeb failed: ${err.message}`);
870
- return failure(err.code || 'SEARCH_FAILED', err.message);
970
+ if (!grounded) return failure(err.code || 'SEARCH_FAILED', err.message, { attempts });
971
+ // The search worked even though the model did not: show the results.
972
+ const sources = AlexaAI._groundedSources(results, []);
973
+ return {
974
+ ok: true,
975
+ text: WebAnswer.render(WebAnswer.digest(results), sources, { includeSources, maxSources }),
976
+ answer: WebAnswer.digest(results),
977
+ sources,
978
+ grounded,
979
+ providers,
980
+ words: 0,
981
+ attempts,
982
+ via: 'digest',
983
+ model: null,
984
+ error: err.code || 'SEARCH_FAILED',
985
+ message: err.message,
986
+ };
871
987
  }
988
+
989
+ // ---- 3. assemble ----------------------------------------------------
990
+ const { answer, sources, words } = result;
991
+ if (!answer && !sources.length) return failure('DEEPAI_EMPTY', 'DeepAI returned no answer', { attempts, grounded, providers });
992
+
993
+ const text = WebAnswer.render(answer, sources, { includeSources: includeSources || !answer, maxSources });
994
+ return { ok: true, text, answer, sources, grounded, providers, words, attempts, via: 'model', model: reply.model || null };
872
995
  }
873
996
 
874
997
  /**
875
998
  * @private Turn one chat reply into `{ answer, sources, words }`.
876
- * Structured packet sources come first (they carry descriptions), then
877
- * whatever the model listed itself; duplicates collapse by URL.
999
+ *
1000
+ * Grounded (we searched): citation markers `[n]` are removed from the
1001
+ * prose and decide the order of the sources; any URL the model wrote is
1002
+ * discarded. Ungrounded: DeepAI's web-results packet comes first (it
1003
+ * carries descriptions), then whatever the model listed; duplicates
1004
+ * collapse by URL.
878
1005
  */
879
- _webAnswerFrom(reply, carried = []) {
1006
+ _webAnswerFrom(reply, results = [], carried = []) {
880
1007
  let formatted = ResponseFormatter.format(MemoryExtractor.strip(reply.text));
881
1008
  // Keep third-party vendor names: this is research output, not the
882
1009
  // assistant introducing herself. WebAnswer removes the sentences in
883
1010
  // which the model talks about *itself*.
884
1011
  formatted = this.identityGuard.sanitise(formatted, false, { vendors: false });
885
1012
  const parsed = WebAnswer.parse(formatted);
1013
+
1014
+ if (results.length) {
1015
+ const { text, cited } = WebAnswer.extractCitations(WebAnswer.stripUrls(parsed.text, results), results.length);
1016
+ return { answer: text, sources: AlexaAI._groundedSources(results, cited), words: WebAnswer.wordCount(text) };
1017
+ }
886
1018
  const sources = WebAnswer.mergeSources(AlexaAI._sources(reply.webResults), parsed.sources, carried);
887
1019
  return { answer: parsed.text, sources, words: WebAnswer.wordCount(parsed.text) };
888
1020
  }
889
1021
 
1022
+ /** @private search results as sources: cited ones first, in citation order. */
1023
+ static _groundedSources(results, cited) {
1024
+ const order = [...cited.map((n) => n - 1), ...results.map((_, i) => i).filter((i) => !cited.includes(i + 1))];
1025
+ return order
1026
+ .filter((i) => results[i])
1027
+ .map((i) => ({
1028
+ title: results[i].title || null,
1029
+ url: results[i].url,
1030
+ description: results[i].description || null,
1031
+ date: results[i].date || null,
1032
+ provider: results[i].provider || null,
1033
+ cited: cited.includes(i + 1),
1034
+ }));
1035
+ }
1036
+
890
1037
  /** Is DeepAI reachable and is the key still good? */
891
1038
  async deepaiHealth() {
892
1039
  const started = Date.now();
@@ -912,7 +1059,8 @@ class AlexaAI {
912
1059
  /** @private the image url carried by an /api/* or tool response. */
913
1060
  static _outputUrl(data) {
914
1061
  if (!data || typeof data !== 'object') return null;
915
- 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);
916
1064
  return typeof url === 'string' && url ? url : null;
917
1065
  }
918
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,22 @@ 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
+
122
+ // ---- Engine web search (searchWeb) ----------------------------------
123
+ // The engine searches first and hands real results to the model, so
124
+ // every URL the bot shows comes from a search, never from the model.
125
+ this.webSearch = opts.webSearch !== false;
126
+ this.webSearchProviders = Config._list(opts.webSearchProviders, ['bing', 'bing-news', 'wikipedia', 'google-news', 'duckduckgo']);
127
+ this.webSearchTimeout = Config._int(opts.webSearchTimeout, 8000, 500, 60000);
128
+ this.webSearchResults = Config._int(opts.webSearchResults, 8, 1, 30);
129
+ this.webSearchProvider = typeof opts.webSearchProvider === 'function' ? opts.webSearchProvider : null;
130
+
110
131
  // ---- Chat request feature flags (mirrors the deepai.org client) -----
111
132
  this.enabledTools = Config._list(opts.enabledTools, ['image_generator', 'image_editor']);
112
133
  this.toolActivitySupport = opts.toolActivitySupport !== false;
@@ -160,7 +181,7 @@ class Config {
160
181
  this.retryDelay = Config._int(opts.retryDelay, 800, 0, 30000);
161
182
  this.userAgent =
162
183
  opts.userAgent ||
163
- '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';
164
185
 
165
186
  // ---- Database --------------------------------------------------------
166
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 });