feat(backend): replace custom aiClient with @bytelyst/llm platform package

- Import PerplexityProvider, OpenAIProvider, GeminiProvider from @bytelyst/llm
- Use createFallbackChain() instead of manual axios fallback loop
- Remove axios and @types/axios — no longer needed
- Preserve AIClient class interface (generateAnalysis, getProviderHealth) —
  no changes required in apiServer.ts or AIAnalysisRule.ts
- Fallback order still driven by config.AI.FALLBACK_LIST

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Saravana Achu Mac 2026-04-05 12:50:34 -07:00
parent bcb6bf4d71
commit c3651f5696
3 changed files with 88 additions and 262 deletions

View File

@ -53,11 +53,11 @@
"@azure/cosmos": "^4.3.0",
"@bytelyst/auth": "link:../../../learning_ai/learning_ai_common_plat/packages/auth",
"@bytelyst/cosmos": "link:../../../learning_ai/learning_ai_common_plat/packages/cosmos",
"@bytelyst/llm": "link:../../../learning_ai/learning_ai_common_plat/packages/llm",
"@alpacahq/alpaca-trade-api": "^3.1.3",
"@supabase/supabase-js": "^2.90.1",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"axios": "^1.13.2",
"ccxt": "^4.5.31",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
@ -68,7 +68,6 @@
"winston": "^3.19.0"
},
"devDependencies": {
"@types/axios": "^0.14.4",
"@types/node": "^25.0.3",
"c8": "^10.1.3",
"ts-node": "^10.9.2",

View File

@ -1,4 +1,10 @@
import axios from 'axios';
import {
createFallbackChain,
GeminiProvider,
OpenAIProvider,
PerplexityProvider,
type LLMProvider,
} from '@bytelyst/llm';
import { config } from '../config/index.js';
import logger from '../utils/logger.js';
@ -14,224 +20,106 @@ export interface AIProviderHealth {
message: string;
}
interface ProviderEntry {
name: AIProvider;
make: () => LLMProvider;
defaultModel: string;
}
function resolveModel(provider: AIProvider): string {
switch (provider) {
case 'perplexity':
return config.AI.MODEL.includes('sonar') ? config.AI.MODEL : 'sonar';
case 'openai':
return config.AI.MODEL.includes('gpt') ? config.AI.MODEL : 'gpt-4o-mini';
case 'gemini':
return config.AI.MODEL.includes('gemini') ? config.AI.MODEL : 'gemini-1.5-flash';
}
}
const PROVIDER_REGISTRY: ProviderEntry[] = [
{
name: 'perplexity',
make: () => new PerplexityProvider({ apiKey: config.AI.PERPLEXITY_API_KEY, model: resolveModel('perplexity') }),
defaultModel: 'sonar',
},
{
name: 'openai',
make: () => new OpenAIProvider({ apiKey: config.AI.OPENAI_API_KEY, model: resolveModel('openai') }),
defaultModel: 'gpt-4o-mini',
},
{
name: 'gemini',
make: () => new GeminiProvider({ apiKey: config.AI.GEMINI_API_KEY, model: resolveModel('gemini') }),
defaultModel: 'gemini-1.5-flash',
},
];
export class AIClient {
public async generateAnalysis(prompt: string): Promise<string | null> {
const fallbackList = config.AI.FALLBACK_LIST;
for (const provider of fallbackList) {
try {
logger.info(`[AI] Attempting analysis with provider: ${provider}...`);
let result: string | null = null;
switch (provider) {
case 'openai':
result = await this.callOpenAI(prompt);
break;
case 'perplexity':
result = await this.callPerplexity(prompt);
break;
case 'gemini':
result = await this.callGemini(prompt);
break;
default:
logger.warn(`[AI] Unsupported provider in fallback list: ${provider}`);
continue;
}
if (result) {
logger.info(`[AI] Successfully generated analysis using ${provider}.`);
return result;
}
} catch (error: any) {
logger.error(`[AI] Provider ${provider} failed: ${error.message}`);
// Continue to next provider in fallback list
}
}
logger.error('[AI] All providers in fallback list failed or were not configured.');
return null;
public async generateAnalysis(prompt: string): Promise<string | null> {
const ordered = this.buildOrderedProviders();
const chain = createFallbackChain(ordered.map(e => e.make()));
if (!chain.isConfigured()) {
logger.warn('[AI] No providers configured — skipping analysis');
return null;
}
try {
const result = await chain.chatCompletion({
messages: [
{ role: 'system', content: 'You are an expert crypto trading assistant. Strictly output JSON.' },
{ role: 'user', content: prompt },
],
temperature: 0.2,
});
logger.info('[AI] Analysis generated successfully');
return result.content;
} catch (error: any) {
logger.error(`[AI] All providers failed: ${error.message}`);
return null;
}
}
public async getProviderHealth(probe: boolean = false): Promise<AIProviderHealth[]> {
const fallbackList = config.AI.FALLBACK_LIST;
const providers: AIProvider[] = ['openai', 'perplexity', 'gemini'];
const results: AIProviderHealth[] = [];
for (const provider of providers) {
const configured = this.isProviderConfigured(provider);
const model = this.resolveModel(provider);
const fallbackIndex = fallbackList.indexOf(provider);
for (const entry of PROVIDER_REGISTRY) {
const provider = entry.make();
const configured = provider.isConfigured();
const fallbackIndex = fallbackList.indexOf(entry.name);
const inFallbackList = fallbackIndex >= 0;
const model = resolveModel(entry.name);
if (!configured) {
results.push({
provider,
configured: false,
model,
inFallbackList,
fallbackIndex: inFallbackList ? fallbackIndex : null,
status: 'missing_key',
message: 'API key missing'
});
results.push({ provider: entry.name, configured: false, model, inFallbackList, fallbackIndex: inFallbackList ? fallbackIndex : null, status: 'missing_key', message: 'API key missing' });
continue;
}
if (!probe) {
results.push({
provider,
configured: true,
model,
inFallbackList,
fallbackIndex: inFallbackList ? fallbackIndex : null,
status: 'configured',
message: 'Configured (probe skipped)'
});
results.push({ provider: entry.name, configured: true, model, inFallbackList, fallbackIndex: inFallbackList ? fallbackIndex : null, status: 'configured', message: 'Configured (probe skipped)' });
continue;
}
try {
await this.probeProvider(provider);
results.push({
provider,
configured: true,
model,
inFallbackList,
fallbackIndex: inFallbackList ? fallbackIndex : null,
status: 'ok',
message: 'Provider probe succeeded'
await provider.chatCompletion({
messages: [{ role: 'user', content: 'Return JSON: {"action":"HOLD","confidence":0,"reasoning":"probe"}' }],
temperature: 0,
});
results.push({ provider: entry.name, configured: true, model, inFallbackList, fallbackIndex: inFallbackList ? fallbackIndex : null, status: 'ok', message: 'Provider probe succeeded' });
} catch (error: any) {
results.push({
provider,
configured: true,
model,
inFallbackList,
fallbackIndex: inFallbackList ? fallbackIndex : null,
status: 'error',
message: error?.message || 'Provider probe failed'
});
results.push({ provider: entry.name, configured: true, model, inFallbackList, fallbackIndex: inFallbackList ? fallbackIndex : null, status: 'error', message: error?.message || 'Provider probe failed' });
}
}
return results;
}
private isProviderConfigured(provider: AIProvider): boolean {
switch (provider) {
case 'openai':
return !!config.AI.OPENAI_API_KEY;
case 'perplexity':
return !!config.AI.PERPLEXITY_API_KEY;
case 'gemini':
return !!config.AI.GEMINI_API_KEY;
default:
return false;
}
private buildOrderedProviders(): ProviderEntry[] {
const fallbackList = config.AI.FALLBACK_LIST;
return fallbackList
.map(name => PROVIDER_REGISTRY.find(e => e.name === name))
.filter((e): e is ProviderEntry => e !== undefined);
}
private resolveModel(provider: AIProvider): string {
switch (provider) {
case 'openai':
return config.AI.MODEL.includes('gpt') ? config.AI.MODEL : 'gpt-4o-mini';
case 'perplexity':
return config.AI.MODEL.includes('sonar') ? config.AI.MODEL : 'sonar';
case 'gemini':
return config.AI.MODEL.includes('gemini') ? config.AI.MODEL : 'gemini-1.5-flash';
default:
return config.AI.MODEL;
}
}
private async probeProvider(provider: AIProvider): Promise<void> {
const probePrompt = 'Return JSON: {"action":"HOLD","confidence":0,"reasoning":"probe"}';
let response: string | null = null;
switch (provider) {
case 'openai':
response = await this.callOpenAI(probePrompt);
break;
case 'perplexity':
response = await this.callPerplexity(probePrompt);
break;
case 'gemini':
response = await this.callGemini(probePrompt);
break;
default:
throw new Error(`Unsupported provider: ${provider}`);
}
if (!response || !String(response).trim()) {
throw new Error('Empty response from provider');
}
}
private async callOpenAI(prompt: string): Promise<string | null> {
const apiKey = config.AI.OPENAI_API_KEY;
if (!apiKey) return null;
const model = config.AI.MODEL.includes('gpt') ? config.AI.MODEL : 'gpt-4o-mini';
const response = await axios.post(
'https://api.openai.com/v1/chat/completions',
{
model: model,
messages: [
{ role: 'system', content: 'You are an expert crypto trading assistant. strictly output JSON.' },
{ role: 'user', content: prompt }
],
temperature: 0.2
},
{
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
timeout: 10000
}
);
return response.data.choices[0].message.content;
}
private async callPerplexity(prompt: string): Promise<string | null> {
const apiKey = config.AI.PERPLEXITY_API_KEY;
if (!apiKey) return null;
const model = config.AI.MODEL.includes('sonar') ? config.AI.MODEL : 'sonar';
const response = await axios.post(
'https://api.perplexity.ai/chat/completions',
{
model: model,
messages: [
{ role: 'system', content: 'You are an expert crypto trading assistant. Strictly output JSON.' },
{ role: 'user', content: prompt }
],
temperature: 0.2
},
{
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
timeout: 10000
}
);
return response.data.choices[0].message.content;
}
private async callGemini(prompt: string): Promise<string | null> {
const apiKey = config.AI.GEMINI_API_KEY;
if (!apiKey) return null;
const model = config.AI.MODEL.includes('gemini') ? config.AI.MODEL : 'gemini-1.5-flash';
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
const response = await axios.post(url, {
contents: [{
parts: [{ text: `You are an expert crypto trading assistant. Strictly output JSON.\n\n${prompt}` }]
}]
}, { timeout: 10000 });
return response.data.candidates[0].content.parts[0].text;
}
}
}

67
pnpm-lock.yaml generated
View File

@ -39,6 +39,9 @@ importers:
'@bytelyst/cosmos':
specifier: link:../../../learning_ai/learning_ai_common_plat/packages/cosmos
version: link:../../../learning_ai/learning_ai_common_plat/packages/cosmos
'@bytelyst/llm':
specifier: link:../../../learning_ai/learning_ai_common_plat/packages/llm
version: link:../../../learning_ai/learning_ai_common_plat/packages/llm
'@supabase/supabase-js':
specifier: ^2.90.1
version: 2.101.1
@ -48,9 +51,6 @@ importers:
'@types/express':
specifier: ^5.0.6
version: 5.0.6
axios:
specifier: ^1.13.2
version: 1.14.0
ccxt:
specifier: ^4.5.31
version: 4.5.46
@ -76,9 +76,6 @@ importers:
specifier: ^3.19.0
version: 3.19.0
devDependencies:
'@types/axios':
specifier: ^0.14.4
version: 0.14.4
'@types/node':
specifier: ^25.0.3
version: 25.5.2
@ -2168,10 +2165,6 @@ packages:
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
'@types/axios@0.14.4':
resolution: {integrity: sha512-9JgOaunvQdsQ/qW2OPmE5+hCeUB52lQSolecrFrthct55QekhmXEwT203s20RL+UHtCQc15y3VXpby9E7Kkh/g==}
deprecated: This is a stub types definition. axios provides its own type definitions, so you do not need this installed.
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@ -2684,9 +2677,6 @@ packages:
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
autoprefixer@10.4.27:
resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==}
engines: {node: ^10 || ^12 || >=14}
@ -2701,9 +2691,6 @@ packages:
axios@0.21.4:
resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==}
axios@1.14.0:
resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==}
babel-jest@29.7.0:
resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -2988,10 +2975,6 @@ packages:
resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==}
engines: {node: '>=18'}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commander@12.1.0:
resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
engines: {node: '>=18'}
@ -3207,10 +3190,6 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
@ -3850,10 +3829,6 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
@ -5105,10 +5080,6 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@ -8596,12 +8567,6 @@ snapshots:
'@types/aria-query@5.0.4': {}
'@types/axios@0.14.4':
dependencies:
axios: 1.14.0
transitivePeerDependencies:
- debug
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.29.2
@ -9171,8 +9136,6 @@ snapshots:
async@3.2.6: {}
asynckit@0.4.0: {}
autoprefixer@10.4.27(postcss@8.5.8):
dependencies:
browserslist: 4.28.2
@ -9192,14 +9155,6 @@ snapshots:
transitivePeerDependencies:
- debug
axios@1.14.0:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.5
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
babel-jest@29.7.0(@babel/core@7.29.0):
dependencies:
'@babel/core': 7.29.0
@ -9565,10 +9520,6 @@ snapshots:
color-convert: 3.1.3
color-string: 2.1.4
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commander@12.1.0: {}
commander@2.20.3: {}
@ -9777,8 +9728,6 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
delayed-stream@1.0.0: {}
depd@2.0.0: {}
dequal@2.0.3: {}
@ -10667,14 +10616,6 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
forwarded@0.2.0: {}
fraction.js@5.3.4: {}
@ -12135,8 +12076,6 @@ snapshots:
forwarded: 0.2.0
ipaddr.js: 1.9.1
proxy-from-env@2.1.0: {}
punycode@2.3.1: {}
qrcode-terminal@0.11.0: {}