alexa-ai 2.1.2 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/README.md +68 -11
- package/examples/diagnose.js +134 -0
- package/examples/text2img-standalone.js +200 -0
- package/package.json +1 -1
- package/src/AlexaAI.js +83 -18
- package/src/core/Config.js +29 -2
- package/src/core/DeepAIClient.js +334 -30
- package/test/run-tests.js +171 -2
package/src/AlexaAI.js
CHANGED
|
@@ -596,27 +596,35 @@ class AlexaAI {
|
|
|
596
596
|
/**
|
|
597
597
|
* Text-to-image.
|
|
598
598
|
*
|
|
599
|
-
*
|
|
599
|
+
* Three routes, tried in order:
|
|
600
600
|
*
|
|
601
|
-
* 1. `POST /api/text2img` — the classic public API
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
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
|
-
*
|
|
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']
|
|
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
|
|
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.
|
|
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)
|
|
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
|
-
|
|
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
|
|
package/src/core/Config.js
CHANGED
|
@@ -23,6 +23,8 @@ class Config {
|
|
|
23
23
|
* @param {string} [options.visionModel] Model used when images are attached
|
|
24
24
|
* @param {string[]} [options.visionModels] Vision fallback chain
|
|
25
25
|
* @param {string} [options.imageModel] Model used by generateImage()
|
|
26
|
+
* @param {string} [options.transport] /api/* transport: 'auto' (default) | 'fetch' | 'curl' | 'impersonate'
|
|
27
|
+
* @param {string} [options.curlImpersonatePath] Path to a curl-impersonate binary for the 'impersonate' transport
|
|
26
28
|
* @param {string} [options.assistantName] Persona name (default 'Alexa')
|
|
27
29
|
* @param {string} [options.creator] Persona creator (default 'Hansaka')
|
|
28
30
|
* @param {string} [options.systemPrompt] Override the whole persona text
|
|
@@ -42,7 +44,12 @@ class Config {
|
|
|
42
44
|
const opts = options || {};
|
|
43
45
|
|
|
44
46
|
// ---- Accept several aliases so the host bot can stay terse ----------
|
|
45
|
-
const key =
|
|
47
|
+
const key =
|
|
48
|
+
opts.key ||
|
|
49
|
+
opts.apiKey ||
|
|
50
|
+
opts.deepaiKey ||
|
|
51
|
+
process.env.DEEPAI_KEY ||
|
|
52
|
+
process.env.DEEPAI_API_KEY;
|
|
46
53
|
const postgresUrl =
|
|
47
54
|
opts.postgresUrl ||
|
|
48
55
|
opts.postgresURL ||
|
|
@@ -107,6 +114,26 @@ class Config {
|
|
|
107
114
|
]);
|
|
108
115
|
this.imageModel = opts.imageModel || 'text2img';
|
|
109
116
|
|
|
117
|
+
// ---- /api/* transport -------------------------------------------------
|
|
118
|
+
// 'auto' fetch first, then system curl, then curl-impersonate
|
|
119
|
+
// when the binary is available (some networks serve
|
|
120
|
+
// non-browser TLS stacks a refusal page).
|
|
121
|
+
// 'fetch' Node global fetch only (previous behaviour)
|
|
122
|
+
// 'curl' system curl subprocess only
|
|
123
|
+
// 'impersonate' curl-impersonate subprocess only (Chrome TLS profile)
|
|
124
|
+
this.transport = opts.transport || process.env.DEEPAI_TRANSPORT || 'auto';
|
|
125
|
+
this.curlPath = opts.curlPath || process.env.DEEPAI_CURL || 'curl';
|
|
126
|
+
this.curlImpersonatePath =
|
|
127
|
+
opts.curlImpersonatePath || process.env.DEEPAI_CURL_IMPERSONATE || null;
|
|
128
|
+
this.curlImpersonateTarget = opts.curlImpersonateTarget || 'chrome136';
|
|
129
|
+
|
|
130
|
+
// ---- Anonymous device identity ---------------------------------------
|
|
131
|
+
// Stable device identifier sent as the `deepai_device_id` cookie.
|
|
132
|
+
// Anonymous /api/* generation is rate-limited per device, so the id
|
|
133
|
+
// is kept stable per instance; pass your own to share an existing
|
|
134
|
+
// device quota.
|
|
135
|
+
this.deviceId = opts.deviceId || process.env.DEEPAI_DEVICE_ID || null;
|
|
136
|
+
|
|
110
137
|
// ---- Engine web search (searchWeb) ----------------------------------
|
|
111
138
|
// The engine searches first and hands real results to the model, so
|
|
112
139
|
// every URL the bot shows comes from a search, never from the model.
|
|
@@ -169,7 +196,7 @@ class Config {
|
|
|
169
196
|
this.retryDelay = Config._int(opts.retryDelay, 800, 0, 30000);
|
|
170
197
|
this.userAgent =
|
|
171
198
|
opts.userAgent ||
|
|
172
|
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
199
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
|
|
173
200
|
|
|
174
201
|
// ---- Database --------------------------------------------------------
|
|
175
202
|
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}
|
|
100
|
+
*/
|
|
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
|
|
86
134
|
*/
|
|
87
|
-
static
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
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
|
}
|
|
@@ -535,36 +615,232 @@ class DeepAIClient {
|
|
|
535
615
|
* @returns {Promise<object>} e.g. `{ id, output_url }`
|
|
536
616
|
*/
|
|
537
617
|
async runApi(name, fields = {}, options = {}) {
|
|
538
|
-
const
|
|
539
|
-
|
|
618
|
+
const url = `${this.config.url('api')}/${String(name).replace(/^\/+/, '')}`;
|
|
619
|
+
const entries = this._buildApiFields(fields, options);
|
|
620
|
+
return this._apiFormRequest(url, entries, options);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Normalise API form fields into a transport-neutral entry list:
|
|
625
|
+
* `[[key, { value | buffer, mimetype, filename }], …]`.
|
|
626
|
+
* @private
|
|
627
|
+
*/
|
|
628
|
+
_buildApiFields(fields, options = {}) {
|
|
629
|
+
const entries = [];
|
|
630
|
+
for (const [key, value] of Object.entries(fields || {})) {
|
|
540
631
|
if (value == null) continue;
|
|
541
|
-
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
|
|
542
|
-
|
|
543
|
-
form.append(key, new Blob([value], { type: mimetype }), options.filename || `${key}.${DeepAIClient._ext(mimetype)}`);
|
|
632
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array || value instanceof Blob) {
|
|
633
|
+
entries.push([key, { buffer: value, mimetype: options.mimetype, filename: options.filename }]);
|
|
544
634
|
} else if (typeof value === 'object' && (value.buffer || value.url)) {
|
|
545
635
|
if (value.url && !value.buffer) {
|
|
546
|
-
|
|
547
|
-
|
|
636
|
+
entries.push([key, { value: String(value.url) }]);
|
|
637
|
+
} else {
|
|
638
|
+
entries.push([
|
|
639
|
+
key,
|
|
640
|
+
{
|
|
641
|
+
buffer: Buffer.isBuffer(value.buffer) ? value.buffer : Buffer.from(value.buffer),
|
|
642
|
+
mimetype: value.mimetype,
|
|
643
|
+
filename: value.filename,
|
|
644
|
+
},
|
|
645
|
+
]);
|
|
548
646
|
}
|
|
549
|
-
const bytes = Buffer.isBuffer(value.buffer) ? value.buffer : Buffer.from(value.buffer);
|
|
550
|
-
const mimetype = value.mimetype || DeepAIClient._sniffMime(bytes) || 'application/octet-stream';
|
|
551
|
-
form.append(key, new Blob([bytes], { type: mimetype }), value.filename || `${key}.${DeepAIClient._ext(mimetype)}`);
|
|
552
647
|
} else if (typeof value === 'object') {
|
|
553
|
-
|
|
648
|
+
entries.push([key, { value: JSON.stringify(value) }]);
|
|
554
649
|
} else {
|
|
555
|
-
|
|
650
|
+
entries.push([key, { value: String(value) }]);
|
|
556
651
|
}
|
|
557
652
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
653
|
+
return entries;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* POST a multipart form to a `/api/*` URL across the configured transport
|
|
658
|
+
* chain. A refusal of the form "Please try this model on deepai.org" can
|
|
659
|
+
* be transport-specific (non-browser TLS stacks receive it even with a
|
|
660
|
+
* perfectly valid key), so on that error the next transport is tried;
|
|
661
|
+
* every other error (quota, auth, network) is final for this request.
|
|
662
|
+
* @private
|
|
663
|
+
*/
|
|
664
|
+
async _apiFormRequest(url, entries, options = {}) {
|
|
665
|
+
const chain = await this._transportChain();
|
|
666
|
+
let lastError;
|
|
667
|
+
for (const transport of chain) {
|
|
668
|
+
try {
|
|
669
|
+
const raw = await this._runApiTransport(transport, url, entries, options);
|
|
670
|
+
const data = DeepAIClient._safeJson(raw.body);
|
|
671
|
+
if (raw.status > 299 || data === null) {
|
|
672
|
+
throw DeepAIClient._toError(raw.status, raw.body, data?.status || data?.error);
|
|
673
|
+
}
|
|
674
|
+
if (data?.err) {
|
|
675
|
+
throw DeepAIClient._toError(200, JSON.stringify(data), String(data.err));
|
|
676
|
+
}
|
|
677
|
+
if (typeof data?.status === 'string' && !data.share_url && !data.output_url && !data.output && !data.id) {
|
|
678
|
+
throw DeepAIClient._toError(200, JSON.stringify(data), data.status);
|
|
679
|
+
}
|
|
680
|
+
return data;
|
|
681
|
+
} catch (err) {
|
|
682
|
+
if (DeepAIClient._isTransportRejected(err) && chain.length > 1) {
|
|
683
|
+
lastError = err;
|
|
684
|
+
if (this.config.debug) this.log.warn?.(`[AlexaAI] ${transport} transport refused for ${url}; trying the next`);
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
if (err instanceof QuotaExceededError || err.retryable === false || err.code === 'ABORTED') throw err;
|
|
688
|
+
throw err;
|
|
689
|
+
}
|
|
563
690
|
}
|
|
564
|
-
|
|
565
|
-
|
|
691
|
+
throw lastError || new DeepAIError('DeepAI request failed', { code: 'DEEPAI_ERROR' });
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** @private */
|
|
695
|
+
static _isTransportRejected(err) {
|
|
696
|
+
return /try this model on deepai\.org/i.test(String(err?.message || ''));
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Ordered transport list for /api/* calls. @private */
|
|
700
|
+
async _transportChain() {
|
|
701
|
+
const t = this.config.transport;
|
|
702
|
+
if (t === 'fetch') return ['fetch'];
|
|
703
|
+
if (t === 'curl') return ['curl'];
|
|
704
|
+
if (t === 'impersonate') return ['impersonate'];
|
|
705
|
+
const chain = ['fetch', 'curl'];
|
|
706
|
+
if (await DeepAIClient.resolveImpersonateBinary(this.config)) chain.push('impersonate');
|
|
707
|
+
return chain;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** @private */
|
|
711
|
+
async _runApiTransport(transport, url, entries, options) {
|
|
712
|
+
if (transport === 'fetch') return this._runApiFetch(url, entries, options);
|
|
713
|
+
const impersonate = transport === 'impersonate';
|
|
714
|
+
const binary = impersonate
|
|
715
|
+
? await DeepAIClient.resolveImpersonateBinary(this.config)
|
|
716
|
+
: this.config.curlPath;
|
|
717
|
+
return this._runApiCurl(binary, url, entries, options, { impersonate });
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** @private global-fetch transport (previous behaviour). */
|
|
721
|
+
async _runApiFetch(url, entries, options = {}) {
|
|
722
|
+
const form = new FormData();
|
|
723
|
+
for (const [key, field] of entries) {
|
|
724
|
+
if (field.buffer != null) {
|
|
725
|
+
const bytes = field.buffer instanceof Blob ? Buffer.from(await field.buffer.arrayBuffer()) : field.buffer;
|
|
726
|
+
const mimetype = field.mimetype || DeepAIClient._sniffMime(bytes) || 'application/octet-stream';
|
|
727
|
+
form.append(key, new Blob([bytes], { type: mimetype }), field.filename || `${key}.${DeepAIClient._ext(mimetype)}`);
|
|
728
|
+
} else {
|
|
729
|
+
form.append(key, field.value);
|
|
730
|
+
}
|
|
566
731
|
}
|
|
567
|
-
|
|
732
|
+
const data = await this._json(url, { method: 'POST', body: form, signal: options.signal, errorCode: 'BAD_RESPONSE' });
|
|
733
|
+
return { status: 200, body: JSON.stringify(data) };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* The User-Agent a curl-impersonate binary sends for the configured
|
|
738
|
+
* target profile. The anonymous key hash must be derived from the exact
|
|
739
|
+
* UA the request carries, so impersonated requests use the profile's own
|
|
740
|
+
* UA instead of `config.userAgent`.
|
|
741
|
+
* @private
|
|
742
|
+
*/
|
|
743
|
+
static _impersonateUserAgent(target) {
|
|
744
|
+
const m = /chrome(\d+)/i.exec(String(target || ''));
|
|
745
|
+
const v = m ? m[1] : '136';
|
|
746
|
+
return `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${v}.0.0.0 Safari/537.36`;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** @private curl / curl-impersonate subprocess transport. */
|
|
750
|
+
async _runApiCurl(binary, url, entries, options = {}, { impersonate = false } = {}) {
|
|
751
|
+
const ua = impersonate ? DeepAIClient._impersonateUserAgent(this.config.curlImpersonateTarget) : this.config.userAgent;
|
|
752
|
+
const apiKey = DeepAIClient.isTryItKey(this.apiKey)
|
|
753
|
+
? DeepAIClient.generateTryItKey(ua)
|
|
754
|
+
: this.apiKey;
|
|
755
|
+
|
|
756
|
+
const args = impersonate ? ['--impersonate', this.config.curlImpersonateTarget] : [];
|
|
757
|
+
args.push(
|
|
758
|
+
url,
|
|
759
|
+
'-sS', '--compressed',
|
|
760
|
+
'--max-time', String(Math.max(1, Math.round(this.config.timeout / 1000))),
|
|
761
|
+
'-X', 'POST',
|
|
762
|
+
'-H', `api-key: ${apiKey}`,
|
|
763
|
+
'-H', `User-Agent: ${ua}`,
|
|
764
|
+
'-H', `Origin: ${this.config.origin}`,
|
|
765
|
+
'-H', `Referer: ${this.config.origin}/machine-learning-model/${this.config.imageModel}`,
|
|
766
|
+
'-H', 'Accept: */*',
|
|
767
|
+
'-H', 'Accept-Language: en-US,en;q=0.9',
|
|
768
|
+
'-w', '\n%{http_code}'
|
|
769
|
+
);
|
|
770
|
+
if (this.deviceId) args.push('-H', `Cookie: deepai_device_id=${this.deviceId}`);
|
|
771
|
+
|
|
772
|
+
const tmpFiles = [];
|
|
773
|
+
try {
|
|
774
|
+
for (const [key, field] of entries) {
|
|
775
|
+
if (field.buffer != null) {
|
|
776
|
+
const bytes = field.buffer instanceof Blob ? Buffer.from(await field.buffer.arrayBuffer()) : field.buffer;
|
|
777
|
+
const mimetype = field.mimetype || DeepAIClient._sniffMime(bytes) || 'application/octet-stream';
|
|
778
|
+
const ext = DeepAIClient._ext(mimetype);
|
|
779
|
+
const tmp = require('fs').mkdtempSync(require('path').join(require('os').tmpdir(), 'alexa-')) + `/${key}.${ext}`;
|
|
780
|
+
require('fs').writeFileSync(tmp, bytes);
|
|
781
|
+
tmpFiles.push(tmp);
|
|
782
|
+
args.push('-F', `${key}=@${tmp};type=${mimetype}`);
|
|
783
|
+
} else {
|
|
784
|
+
args.push('-F', `${key}=${field.value}`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
const out = await DeepAIClient.execCurl(binary, args, this.config.timeout, options.signal);
|
|
788
|
+
const body = String(out).replace(/\r/g, '');
|
|
789
|
+
const idx = body.lastIndexOf('\n');
|
|
790
|
+
const status = Number(body.slice(idx + 1).trim());
|
|
791
|
+
if (!Number.isFinite(status)) {
|
|
792
|
+
throw new DeepAIError(`curl transport failed for ${url}: ${body.slice(0, 200)}`, {
|
|
793
|
+
code: 'DEEPAI_NETWORK', retryable: true,
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
return { status, body: body.slice(0, idx) };
|
|
797
|
+
} finally {
|
|
798
|
+
for (const f of tmpFiles) { try { require('fs').unlinkSync(f); } catch { /* best effort */ } try { require('fs').rmSync(require('path').dirname(f), { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Locate a curl-impersonate binary: explicit path, then the usual
|
|
804
|
+
* executable names on PATH. Cached per path. Tests may override.
|
|
805
|
+
* @private
|
|
806
|
+
*/
|
|
807
|
+
static async resolveImpersonateBinary(config) {
|
|
808
|
+
if (config.curlImpersonatePath) return config.curlImpersonatePath;
|
|
809
|
+
if (config._noImpersonateBinary) return null;
|
|
810
|
+
if (!DeepAIClient._impersonateCache) {
|
|
811
|
+
const { execFile } = require('child_process');
|
|
812
|
+
const names = process.platform === 'win32' ? ['curl-impersonate.exe', 'curl-impersonate'] : ['curl-impersonate'];
|
|
813
|
+
DeepAIClient._impersonateCache = new Promise((resolve) => {
|
|
814
|
+
let i = 0;
|
|
815
|
+
const tryNext = () => {
|
|
816
|
+
if (i >= names.length) return resolve(null);
|
|
817
|
+
const name = names[i++];
|
|
818
|
+
execFile(name, ['--version'], { timeout: 5000 }, (err) => resolve(err ? tryNext() : name));
|
|
819
|
+
};
|
|
820
|
+
tryNext();
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
return DeepAIClient._impersonateCache;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Run a curl-compatible binary and capture stdout. Separated so tests
|
|
828
|
+
* can stub the subprocess layer.
|
|
829
|
+
* @private
|
|
830
|
+
*/
|
|
831
|
+
static async execCurl(binary, args, timeoutMs, signal) {
|
|
832
|
+
const { execFile } = require('child_process');
|
|
833
|
+
return new Promise((resolve, reject) => {
|
|
834
|
+
const child = execFile(binary, args, { timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024, windowsHide: true }, (err, stdout, stderr) => {
|
|
835
|
+
if (err && stdout == null) return reject(new DeepAIError(`curl transport error: ${err.message}`, { code: 'DEEPAI_NETWORK', retryable: true }));
|
|
836
|
+
resolve(stdout != null ? stdout : '');
|
|
837
|
+
void stderr;
|
|
838
|
+
});
|
|
839
|
+
if (signal) {
|
|
840
|
+
if (signal.aborted) child.kill();
|
|
841
|
+
else signal.addEventListener('abort', () => child.kill(), { once: true });
|
|
842
|
+
}
|
|
843
|
+
});
|
|
568
844
|
}
|
|
569
845
|
|
|
570
846
|
/** Text-to-image (`/api/text2img`). Returns `{ id, output_url }`. */
|
|
@@ -572,6 +848,25 @@ class DeepAIClient {
|
|
|
572
848
|
return this.runApi(this.config.imageModel || STANDARD_APIS.text2img, { text, ...extra }, options);
|
|
573
849
|
}
|
|
574
850
|
|
|
851
|
+
/**
|
|
852
|
+
* Run a classic `/api/<name>` call with a one-shot anonymous tryit key,
|
|
853
|
+
* regardless of the configured key. Used as the fallback path when a
|
|
854
|
+
* registered key is refused ("Pro members only"); a fresh key is minted
|
|
855
|
+
* for this single request.
|
|
856
|
+
*/
|
|
857
|
+
async runApiWithTryItKey(name, fields = {}, options = {}) {
|
|
858
|
+
const previousKeys = this._keys;
|
|
859
|
+
const previousIndex = this._keyIndex;
|
|
860
|
+
this._keys = [DeepAIClient.generateTryItKey(this.config.userAgent)];
|
|
861
|
+
this._keyIndex = 0;
|
|
862
|
+
try {
|
|
863
|
+
return await this.runApi(name, fields, options);
|
|
864
|
+
} finally {
|
|
865
|
+
this._keys = previousKeys;
|
|
866
|
+
this._keyIndex = previousIndex;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
575
870
|
/** Prompt-driven image edit (`/api/image-editor`). */
|
|
576
871
|
async editImage(image, text, extra = {}) {
|
|
577
872
|
return this.runApi(STANDARD_APIS.imageEditor, { image, text, ...extra });
|
|
@@ -711,7 +1006,9 @@ class DeepAIClient {
|
|
|
711
1006
|
}
|
|
712
1007
|
|
|
713
1008
|
static _isRefusal(status) {
|
|
714
|
-
return /exceeded|paid|credits|api-key|api key|login|not allowed|forbidden|unauthori[sz]ed/i.test(
|
|
1009
|
+
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(
|
|
1010
|
+
status
|
|
1011
|
+
);
|
|
715
1012
|
}
|
|
716
1013
|
|
|
717
1014
|
/** @private magic-number sniff so uploads carry a real content type. */
|
|
@@ -749,6 +1046,13 @@ class DeepAIClient {
|
|
|
749
1046
|
'api key',
|
|
750
1047
|
'api-key',
|
|
751
1048
|
'please login',
|
|
1049
|
+
// refusal statuses returned by the API:
|
|
1050
|
+
'pro members', // "APIs are only available for Pro members in good standing…"
|
|
1051
|
+
'good standing',
|
|
1052
|
+
'model only available', // "model only available to (logged in|paid) users"
|
|
1053
|
+
'signed in try-it quota exceeded',
|
|
1054
|
+
'insufficient_credits',
|
|
1055
|
+
'pro user out of credits',
|
|
752
1056
|
];
|
|
753
1057
|
if (quotaHints.some((h) => lowered.includes(h))) {
|
|
754
1058
|
return new QuotaExceededError(`DeepAI refused the request: ${msg}`, { status, body });
|