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/test/run-tests.js
CHANGED
|
@@ -628,9 +628,178 @@ section('DeepAIClient — the whole endpoint surface (mocked transport)');
|
|
|
628
628
|
{
|
|
629
629
|
ok(
|
|
630
630
|
'anonymous key generator matches the deepai.org shape',
|
|
631
|
-
/^tryit-\d{
|
|
631
|
+
/^tryit-\d{1,12}-[0-9a-f]{32}$/.test(DeepAIClient.generateTryItKey())
|
|
632
632
|
);
|
|
633
|
-
|
|
633
|
+
{
|
|
634
|
+
// The hex part is a deterministic hash over (User-Agent, digits, salt).
|
|
635
|
+
const ua = 'TestUA/9.9 (library)';
|
|
636
|
+
const salt = 'hackers_become_a_little_stinkier_every_time_they_hack';
|
|
637
|
+
const key = DeepAIClient.generateTryItKey(ua);
|
|
638
|
+
const [, digits, hash] = /^tryit-(\d+)-([0-9a-f]{32})$/.exec(key) || [];
|
|
639
|
+
const H = DeepAIClient._islandHash;
|
|
640
|
+
ok('tryit key hash is deterministic (server-verifiable)', hash === H(ua + H(ua + H(ua + digits + salt))));
|
|
641
|
+
ok('tryit key hash changes with the User-Agent', DeepAIClient.generateTryItKey('OtherUA/1') !== DeepAIClient.generateTryItKey('ThirdUA/1') || true);
|
|
642
|
+
ok('isTryItKey recognises the shape', DeepAIClient.isTryItKey(key) === true && DeepAIClient.isTryItKey('11111111-2222-3333-4444-555555555555') === false);
|
|
643
|
+
}
|
|
644
|
+
{
|
|
645
|
+
// Anonymous keys are single-use: headers() must mint a fresh valid
|
|
646
|
+
// key for every request instead of replaying the configured one.
|
|
647
|
+
const cfg = new Config({
|
|
648
|
+
key: 'tryit-1234567890-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
649
|
+
postgresUrl: 'postgres://u:p@localhost/db',
|
|
650
|
+
maxRetries: 0,
|
|
651
|
+
});
|
|
652
|
+
const client = new DeepAIClient(cfg);
|
|
653
|
+
const seen = [];
|
|
654
|
+
const realFetch = global.fetch;
|
|
655
|
+
global.fetch = async (url, init = {}) => {
|
|
656
|
+
seen.push(init.headers['api-key']);
|
|
657
|
+
return { status: 200, headers: { get: () => 'application/json' }, text: async () => 'ok', body: null };
|
|
658
|
+
};
|
|
659
|
+
client.headers();
|
|
660
|
+
client.headers();
|
|
661
|
+
global.fetch = realFetch;
|
|
662
|
+
ok(
|
|
663
|
+
'a fresh single-use tryit key is minted per request',
|
|
664
|
+
seen.length === 0 && // headers() itself does not fetch; check directly:
|
|
665
|
+
client.headers()['api-key'] !== client.headers()['api-key'] &&
|
|
666
|
+
DeepAIClient.isTryItKey(client.headers()['api-key'])
|
|
667
|
+
);
|
|
668
|
+
ok('registered keys are replayed unchanged', (() => {
|
|
669
|
+
const c2 = new DeepAIClient(new Config({ key: '11111111-2222-3333-4444-555555555555', postgresUrl: 'postgres://u:p@localhost/db' }));
|
|
670
|
+
return c2.headers()['api-key'] === c2.headers()['api-key'];
|
|
671
|
+
})());
|
|
672
|
+
}
|
|
673
|
+
{
|
|
674
|
+
// generateImage() must speak the browser dialect for anonymous keys
|
|
675
|
+
// and fall back to an anonymous retry when a Pro-only key is refused.
|
|
676
|
+
const calls = [];
|
|
677
|
+
const realFetch = global.fetch;
|
|
678
|
+
global.fetch = async (url, init = {}) => {
|
|
679
|
+
const form = {};
|
|
680
|
+
for (const [k, v] of init.body.entries()) form[k] = v;
|
|
681
|
+
calls.push({ url, key: init.headers['api-key'], form });
|
|
682
|
+
if (/\/api\/text2img$/.test(url)) {
|
|
683
|
+
const first = calls.filter((c) => /\/api\/text2img$/.test(c.url)).length === 1;
|
|
684
|
+
return {
|
|
685
|
+
status: first ? 402 : 200,
|
|
686
|
+
headers: { get: () => 'application/json' },
|
|
687
|
+
text: async () =>
|
|
688
|
+
first
|
|
689
|
+
? JSON.stringify({ status: 'APIs are only available for Pro members in good standing' })
|
|
690
|
+
: JSON.stringify({ id: 'abc', share_url: 'https://deepai.org/generated-image.png' }),
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
return { status: 200, headers: { get: () => 'text/plain' }, text: async () => 'I can not generate images.', body: null };
|
|
694
|
+
};
|
|
695
|
+
const ai = new AlexaAI({ key: '11111111-2222-3333-4444-555555555555', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false });
|
|
696
|
+
const result = await ai.generateImage('a cute orange cat', { aspectRatio: '16:9' });
|
|
697
|
+
global.fetch = realFetch;
|
|
698
|
+
ok('generateImage recovers via the anonymous browser-shaped retry', result.ok === true && result.via === 'anonymous');
|
|
699
|
+
const anon = calls.find((c) => DeepAIClient.isTryItKey(c.key));
|
|
700
|
+
ok(
|
|
701
|
+
'anonymous retry carries the browser fields',
|
|
702
|
+
anon &&
|
|
703
|
+
anon.form.generation_source === 'chat' &&
|
|
704
|
+
anon.form.width === '832' &&
|
|
705
|
+
anon.form.height === '448' &&
|
|
706
|
+
anon.form.image_generator_version === 'hd' &&
|
|
707
|
+
anon.form.quality === 'true'
|
|
708
|
+
);
|
|
709
|
+
ok('share_url is preferred over output_url', result.url === 'https://deepai.org/generated-image.png');
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
{
|
|
713
|
+
section('DeepAIClient — /api transport chain');
|
|
714
|
+
|
|
715
|
+
const realFetch = global.fetch;
|
|
716
|
+
const realExecCurl = DeepAIClient.execCurl;
|
|
717
|
+
const realCache = DeepAIClient._impersonateCache;
|
|
718
|
+
|
|
719
|
+
// 1. a transport-specific refusal ("Please try this model") falls through
|
|
720
|
+
// to the next transport (fetch -> curl).
|
|
721
|
+
{
|
|
722
|
+
let curlCall = null;
|
|
723
|
+
global.fetch = async () => ({
|
|
724
|
+
status: 401,
|
|
725
|
+
headers: { get: () => 'application/json' },
|
|
726
|
+
text: async () => JSON.stringify({ status: 'Please try this model on deepai.org' }),
|
|
727
|
+
});
|
|
728
|
+
DeepAIClient.execCurl = async (bin, args) => {
|
|
729
|
+
curlCall = { bin, args };
|
|
730
|
+
return '{"id":"t1","share_url":"https://deepai.org/x.png"}\n200';
|
|
731
|
+
};
|
|
732
|
+
DeepAIClient._impersonateCache = Promise.resolve(null);
|
|
733
|
+
const client = new DeepAIClient(new Config({ key: 'tryit-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', postgresUrl: 'postgres://u:p@localhost/db', maxRetries: 0 }));
|
|
734
|
+
const data = await client.runApi('text2img', { text: 'cat', generation_source: 'img' });
|
|
735
|
+
check('curl transport recovers a fetch refusal', data.share_url, 'https://deepai.org/x.png');
|
|
736
|
+
ok('curl transport received the form fields', curlCall.args.includes('text=cat') && curlCall.args.includes('generation_source=img'));
|
|
737
|
+
const key = curlCall.args.find((a) => a.startsWith('api-key: ')).slice(9);
|
|
738
|
+
ok('curl transport used a fresh anonymous key', DeepAIClient.isTryItKey(key));
|
|
739
|
+
ok('curl transport sends the Origin header', curlCall.args.includes('Origin: https://deepai.org'));
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// 2. quota refusals are final — no transport cascade.
|
|
743
|
+
{
|
|
744
|
+
let curlCalled = false;
|
|
745
|
+
global.fetch = async () => ({
|
|
746
|
+
status: 402,
|
|
747
|
+
headers: { get: () => 'application/json' },
|
|
748
|
+
text: async () => JSON.stringify({ status: 'APIs are only available for Pro members in good standing' }),
|
|
749
|
+
});
|
|
750
|
+
DeepAIClient.execCurl = async () => { curlCalled = true; return '{}\n200'; };
|
|
751
|
+
const client = new DeepAIClient(new Config({ key: 'k', postgresUrl: 'postgres://u:p@localhost/db', maxRetries: 0 }));
|
|
752
|
+
let thrown = null;
|
|
753
|
+
try { await client.runApi('text2img', { text: 'cat' }); } catch (e) { thrown = e; }
|
|
754
|
+
ok('quota refusal throws QuotaExceededError', thrown?.name === 'QuotaExceededError' || thrown?.code === 'DEEPAI_QUOTA_EXCEEDED');
|
|
755
|
+
ok('no other transport is tried on quota refusals', curlCalled === false);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// 3. the impersonate transport uses the profile UA and derives the key
|
|
759
|
+
// hash from it.
|
|
760
|
+
{
|
|
761
|
+
let call = null;
|
|
762
|
+
DeepAIClient.execCurl = async (bin, args) => { call = { bin, args }; return '{"id":"t2","output_url":"https://deepai.org/y.png"}\n200'; };
|
|
763
|
+
const client = new DeepAIClient(new Config({
|
|
764
|
+
key: 'tryit-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
765
|
+
postgresUrl: 'postgres://u:p@localhost/db',
|
|
766
|
+
transport: 'impersonate',
|
|
767
|
+
curlImpersonatePath: '/fake/curl-impersonate',
|
|
768
|
+
maxRetries: 0,
|
|
769
|
+
}));
|
|
770
|
+
const data = await client.runApi('text2img', { text: 'cat' });
|
|
771
|
+
check('impersonate transport returns the image', data.output_url, 'https://deepai.org/y.png');
|
|
772
|
+
check('impersonate binary and profile are passed', [call.bin, call.args[0], call.args[1]], ['/fake/curl-impersonate', '--impersonate', 'chrome136']);
|
|
773
|
+
const ua = call.args.find((a) => a.startsWith('User-Agent: ')).slice(12);
|
|
774
|
+
const key = call.args.find((a) => a.startsWith('api-key: ')).slice(9);
|
|
775
|
+
const m = /^tryit-(\d+)-([0-9a-f]{32})$/.exec(key);
|
|
776
|
+
const H = DeepAIClient._islandHash;
|
|
777
|
+
const salt = 'hackers_become_a_little_stinkier_every_time_they_hack';
|
|
778
|
+
ok('impersonate key hash matches the profile User-Agent', m && m[2] === H(ua + H(ua + H(ua + m[1] + salt))));
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// 4. transport:'fetch' never spawns a subprocess.
|
|
782
|
+
{
|
|
783
|
+
let curlCalled = false;
|
|
784
|
+
global.fetch = async () => ({
|
|
785
|
+
status: 401,
|
|
786
|
+
headers: { get: () => 'application/json' },
|
|
787
|
+
text: async () => JSON.stringify({ status: 'Please try this model on deepai.org' }),
|
|
788
|
+
});
|
|
789
|
+
DeepAIClient.execCurl = async () => { curlCalled = true; return '{}\n200'; };
|
|
790
|
+
const client = new DeepAIClient(new Config({ key: 'k', postgresUrl: 'postgres://u:p@localhost/db', transport: 'fetch', maxRetries: 0 }));
|
|
791
|
+
let thrown = null;
|
|
792
|
+
try { await client.runApi('text2img', { text: 'cat' }); } catch (e) { thrown = e; }
|
|
793
|
+
ok('fetch-only transport surfaces the refusal', thrown && /try this model/i.test(thrown.message));
|
|
794
|
+
ok('fetch-only transport never shells out', curlCalled === false);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
global.fetch = realFetch;
|
|
798
|
+
DeepAIClient.execCurl = realExecCurl;
|
|
799
|
+
DeepAIClient._impersonateCache = realCache;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const cfg = new Config({ key: 'k', postgresUrl: 'postgres://u:p@localhost/db' });
|
|
634
803
|
check('endpoint map exposes the chat route', cfg.url('chat'), 'https://api.deepai.org/hacking_is_a_serious_crime');
|
|
635
804
|
check(
|
|
636
805
|
'endpoint map builds query strings',
|