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/CHANGELOG.md +74 -0
- package/README.md +124 -31
- package/examples/text2img-standalone.js +159 -0
- package/index.js +2 -0
- package/package.json +2 -1
- package/src/AlexaAI.js +198 -50
- package/src/core/Config.js +23 -2
- package/src/core/DeepAIClient.js +119 -11
- package/src/services/WebAnswer.js +153 -22
- package/src/services/WebSearch.js +428 -0
- package/test/fakes.js +171 -0
- package/test/run-tests.js +1113 -0
- package/test/wrapper-methods.js +986 -0
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
|
-
*
|
|
599
|
+
* Three routes, tried in order:
|
|
598
600
|
*
|
|
599
|
-
* 1. `POST /api/text2img` — the classic public API
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
*
|
|
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
|
-
*
|
|
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']
|
|
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
|
|
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.
|
|
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)
|
|
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
|
-
*
|
|
789
|
-
*
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
*
|
|
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
|
-
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
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
|
-
*
|
|
800
|
-
*
|
|
801
|
-
*
|
|
802
|
-
*
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
*
|
|
877
|
-
*
|
|
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
|
-
|
|
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
|
|
package/src/core/Config.js
CHANGED
|
@@ -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 =
|
|
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/
|
|
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
|
package/src/core/DeepAIClient.js
CHANGED
|
@@ -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
|
|
85
|
-
*
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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':
|
|
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(
|
|
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 });
|