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/src/AlexaAI.js CHANGED
@@ -596,27 +596,35 @@ class AlexaAI {
596
596
  /**
597
597
  * Text-to-image.
598
598
  *
599
- * Two routes, tried in order:
599
+ * Three routes, tried in order:
600
600
  *
601
- * 1. `POST /api/text2img` — the classic public API. Fast and returns a
602
- * plain `output_url`, but it is a PAID endpoint: anonymous `tryit-…`
603
- * keys get `{"status": "Out of API credits"}` / "try it exceeded".
604
- * 2. The in-chat image tool — the same `generate_image` function call the
605
- * deepai.org web client sends when you press "Create image". This
606
- * works on free chat keys and answers with a `generated_image` packet
607
- * carrying a `share_url`.
601
+ * 1. `POST /api/text2img` — the classic public API, returns
602
+ * `share_url` / `output_url`. With a Pro key this always works.
603
+ * With an anonymous `tryit-…` key the request is automatically sent
604
+ * in the exact browser shape (`generation_source=chat` + size +
605
+ * `quality=true`), because a bare `{ text }` form is refused with
606
+ * "Please try this model on deepai.org". Anonymous keys are also
607
+ * single-use, so a fresh one is minted per request.
608
+ * 2. Anonymous browser-style retry — when a registered (non-Pro) key
609
+ * is refused with 402 "Pro members in good standing", the engine
610
+ * retries once with a fresh anonymous key. Disable with
611
+ * `{ noAnonymousFallback: true }`.
612
+ * 3. The legacy in-chat image tool — a `generate_image` function-call
613
+ * message sent to the chat endpoint; kept for model versions that
614
+ * still honor it.
608
615
  *
609
- * Either way the result is normalised to `{ ok, url, id, error, via }`.
610
- * Every failure is returned, never thrown, so a bot command can simply
611
- * check `result.ok`.
616
+ * Either way the result is normalised to `{ ok, url, id, error, via }`
617
+ * (`via` is 'api' | 'anonymous' | 'chat'). Every failure is returned,
618
+ * never thrown, so a bot command can simply check `result.ok`.
612
619
  *
613
620
  * @param {string} prompt
614
621
  * @param {object} [opts]
615
- * @param {string} [opts.aspectRatio='1:1'] in-chat tool only ('1:1', '16:9', '9:16'…)
622
+ * @param {string} [opts.aspectRatio='1:1'] '1:1', '16:9', '9:16', '4:3', '3:4'
616
623
  * @param {number} [opts.width] / [opts.height] /api/text2img only
617
- * @param {string} [opts.image_generator_version] /api/text2img only
624
+ * @param {string} [opts.image_generator_version] /api/text2img only ('hd', 'standard', 'genius')
618
625
  * @param {boolean} [opts.chatToolOnly] skip /api/text2img
619
626
  * @param {boolean} [opts.apiOnly] skip the in-chat tool
627
+ * @param {boolean} [opts.noAnonymousFallback] skip route 2
620
628
  * @param {AbortSignal} [opts.signal]
621
629
  * @returns {Promise<{ok:boolean, url:string|null, id:string|null, error:string|null, message?:string, via:string|null, raw?:any}>}
622
630
  */
@@ -625,25 +633,52 @@ class AlexaAI {
625
633
  if (!text) {
626
634
  return { ok: false, url: null, id: null, error: 'VALIDATION_ERROR', message: 'generateImage(): prompt is required', via: null };
627
635
  }
628
- const { aspectRatio, chatToolOnly, apiOnly, signal, ...apiFields } = opts || {};
636
+ const { aspectRatio, chatToolOnly, apiOnly, noAnonymousFallback, signal, ...apiFields } = opts || {};
629
637
  const errors = [];
638
+ let quotaRefused = false;
630
639
 
631
640
  // ---- 1. classic /api/text2img -------------------------------------
641
+ // Anonymous keys require the browser dialect (generation_source +
642
+ // size/quality fields).
632
643
  if (!chatToolOnly) {
633
644
  try {
634
- const data = await this.client.text2img(text, apiFields, { signal });
645
+ const extra = this.client.usingTryItKey
646
+ ? AlexaAI._browserImageFields(aspectRatio || '1:1', apiFields)
647
+ : apiFields;
648
+ const data = await this.client.text2img(text, extra, { signal });
635
649
  const url = AlexaAI._outputUrl(data);
636
650
  if (url) return { ok: true, url, id: data.id || null, error: null, via: 'api', raw: data };
637
651
  errors.push('text2img: no output_url in response');
638
652
  } catch (err) {
639
653
  errors.push(`text2img: ${err.message}`);
654
+ if (err instanceof QuotaExceededError) quotaRefused = true;
640
655
  if (err.code === 'ABORTED') {
641
656
  return { ok: false, url: null, id: null, error: 'ABORTED', message: err.message, via: null };
642
657
  }
643
658
  }
644
659
  }
645
660
 
646
- // ---- 2. the chat image tool (works on free chat keys) ---------------
661
+ // ---- 2. anonymous browser-style retry ------------------------------
662
+ // A registered key without Pro gets 402 "APIs are only available for
663
+ // Pro members in good standing"; retry once with a fresh anonymous
664
+ // key in the full browser shape.
665
+ if (quotaRefused && !noAnonymousFallback && !this.client.usingTryItKey) {
666
+ try {
667
+ const extra = AlexaAI._browserImageFields(aspectRatio || '1:1', apiFields);
668
+ const data = await this.client.runApiWithTryItKey(
669
+ this.client.config.imageModel || 'text2img',
670
+ { text, ...extra },
671
+ { signal }
672
+ );
673
+ const url = AlexaAI._outputUrl(data);
674
+ if (url) return { ok: true, url, id: data.id || null, error: null, via: 'anonymous', raw: data };
675
+ errors.push('anonymous text2img: no output_url in response');
676
+ } catch (err) {
677
+ errors.push(`anonymous text2img: ${err.message}`);
678
+ }
679
+ }
680
+
681
+ // ---- 3. the chat image tool (legacy; model-side tool) --------------
647
682
  if (!apiOnly) {
648
683
  try {
649
684
  const answer = await this.client.chatDetailed(
@@ -666,12 +701,41 @@ class AlexaAI {
666
701
  ok: false,
667
702
  url: null,
668
703
  id: null,
669
- error: /credits|exceeded|paid|api-key|api key/i.test(message) ? 'DEEPAI_QUOTA_EXCEEDED' : 'IMAGE_FAILED',
704
+ error: quotaRefused || /credits|exceeded|paid|pro members|api-key|api key/i.test(message)
705
+ ? 'DEEPAI_QUOTA_EXCEEDED'
706
+ : 'IMAGE_FAILED',
670
707
  message,
671
708
  via: null,
672
709
  };
673
710
  }
674
711
 
712
+ /**
713
+ * Extra form fields required for anonymous image generation: the aspect
714
+ * ratio is translated to pixel sizes, generation runs in "hd" quality,
715
+ * and the request is tagged generation_source=chat.
716
+ *
717
+ * @param {string} aspectRatio '1:1' | '16:9' | '9:16' | '4:3' | '3:4'
718
+ * @param {object} [overrides] explicit width/height/image_generator_version win
719
+ * @private
720
+ */
721
+ static _browserImageFields(aspectRatio, overrides = {}) {
722
+ const map = {
723
+ '16:9': [832, 448],
724
+ '4:3': [768, 576],
725
+ '1:1': [640, 640],
726
+ '3:4': [576, 768],
727
+ '9:16': [448, 832],
728
+ };
729
+ const [width, height] = map[String(aspectRatio || '1:1')] || map['1:1'];
730
+ return {
731
+ generation_source: 'chat',
732
+ width: overrides.width ?? width,
733
+ height: overrides.height ?? height,
734
+ image_generator_version: overrides.image_generator_version ?? 'hd',
735
+ quality: 'true',
736
+ };
737
+ }
738
+
675
739
  /**
676
740
  * Prompt-driven image edit (`POST /api/image-editor`).
677
741
  * `image` may be a Buffer, base64, data URI, URL or `{ buffer | url }`.
@@ -995,7 +1059,8 @@ class AlexaAI {
995
1059
  /** @private the image url carried by an /api/* or tool response. */
996
1060
  static _outputUrl(data) {
997
1061
  if (!data || typeof data !== 'object') return null;
998
- const url = data.output_url || data.share_url || data.url || (Array.isArray(data.output) ? data.output[0] : null);
1062
+ // Prefer share_url (stable, public) over output_url.
1063
+ const url = data.share_url || data.output_url || data.url || (Array.isArray(data.output) ? data.output[0] : null);
999
1064
  return typeof url === 'string' && url ? url : null;
1000
1065
  }
1001
1066
 
@@ -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 = opts.key || opts.apiKey || opts.deepaiKey || process.env.DEEPAI_API_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/126.0 Safari/537.36';
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
@@ -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}
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 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}`;
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
  }
@@ -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 form = new FormData();
539
- for (const [key, value] of Object.entries(fields)) {
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
- const mimetype = options.mimetype || DeepAIClient._sniffMime(value) || 'application/octet-stream';
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
- form.append(key, String(value.url));
547
- continue;
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
- form.append(key, JSON.stringify(value));
648
+ entries.push([key, { value: JSON.stringify(value) }]);
554
649
  } else {
555
- form.append(key, String(value));
650
+ entries.push([key, { value: String(value) }]);
556
651
  }
557
652
  }
558
- const url = `${this.config.url('api')}/${String(name).replace(/^\/+/, '')}`;
559
- const data = await this._json(url, { method: 'POST', body: form, signal: options.signal });
560
- // The classic API reports failures as `{ err: "..." }` or `{ status: "..." }` with HTTP 200.
561
- if (data?.err) {
562
- throw DeepAIClient._toError(200, JSON.stringify(data), String(data.err));
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
- if (typeof data?.status === 'string' && !data.output_url && !data.output && !data.id) {
565
- throw DeepAIClient._toError(200, JSON.stringify(data), data.status);
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
- return data;
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(status);
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 });