diff --git a/backend/package.json b/backend/package.json index ff366c6..a33c3b8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/services/aiClient.ts b/backend/src/services/aiClient.ts index c1b7d43..89bba10 100644 --- a/backend/src/services/aiClient.ts +++ b/backend/src/services/aiClient.ts @@ -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 { - 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 { + 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 { 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 { - 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 { - 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 { - 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 { - 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; - } -} +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09e2722..53c94b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}