- Add bootstrap.ts as new entry point — resolves Key Vault secrets via DefaultAzureCredential before config/index.ts is evaluated, so all process.env reads pick up KV values (Azure CLI in dev, Managed Identity in prod). Falls back to .env if AZURE_KEYVAULT_URL is not set. - Define INVTTRDG_SECRETS mappings for Cosmos, Azure OpenAI, product-id - Add AZURE_OPENAI_ENDPOINT / KEY / DEPLOYMENT to config - aiClient: prefer AzureOpenAIProvider (AI Foundry) when Azure OpenAI config is present; falls back to direct OpenAI if not configured - Add @azure/identity, @azure/keyvault-secrets, @bytelyst/config deps - Update dev/start scripts to use bootstrap.ts entry point - Document AZURE_KEYVAULT_URL and Azure OpenAI vars in .env.example Key Vault: https://kv-mywisprai.vault.azure.net/ Secrets prefix: invttrdg-* Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
139 lines
5.1 KiB
TypeScript
139 lines
5.1 KiB
TypeScript
import {
|
|
AzureOpenAIProvider,
|
|
createFallbackChain,
|
|
GeminiProvider,
|
|
OpenAIProvider,
|
|
PerplexityProvider,
|
|
type LLMProvider,
|
|
} from '@bytelyst/llm';
|
|
import { config } from '../config/index.js';
|
|
import logger from '../utils/logger.js';
|
|
|
|
export type AIProvider = 'openai' | 'perplexity' | 'gemini';
|
|
|
|
export interface AIProviderHealth {
|
|
provider: AIProvider;
|
|
configured: boolean;
|
|
model: string;
|
|
inFallbackList: boolean;
|
|
fallbackIndex: number | null;
|
|
status: 'configured' | 'missing_key' | 'ok' | 'error';
|
|
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';
|
|
}
|
|
}
|
|
|
|
function makeOpenAIProvider(): LLMProvider {
|
|
// Prefer Azure OpenAI when endpoint + key + deployment are all configured
|
|
if (config.AZURE_OPENAI_ENDPOINT && config.AZURE_OPENAI_KEY && config.AZURE_OPENAI_DEPLOYMENT) {
|
|
return new AzureOpenAIProvider({
|
|
endpoint: config.AZURE_OPENAI_ENDPOINT,
|
|
apiKey: config.AZURE_OPENAI_KEY,
|
|
deployment: config.AZURE_OPENAI_DEPLOYMENT,
|
|
});
|
|
}
|
|
return new OpenAIProvider({ apiKey: config.AI.OPENAI_API_KEY, model: resolveModel('openai') });
|
|
}
|
|
|
|
const PROVIDER_REGISTRY: ProviderEntry[] = [
|
|
{
|
|
name: 'perplexity',
|
|
make: () => new PerplexityProvider({ apiKey: config.AI.PERPLEXITY_API_KEY, model: resolveModel('perplexity') }),
|
|
defaultModel: 'sonar',
|
|
},
|
|
{
|
|
name: 'openai',
|
|
make: makeOpenAIProvider,
|
|
defaultModel: 'gpt-4o',
|
|
},
|
|
{
|
|
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 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 results: AIProviderHealth[] = [];
|
|
|
|
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: entry.name, configured: false, model, inFallbackList, fallbackIndex: inFallbackList ? fallbackIndex : null, status: 'missing_key', message: 'API key missing' });
|
|
continue;
|
|
}
|
|
|
|
if (!probe) {
|
|
results.push({ provider: entry.name, configured: true, model, inFallbackList, fallbackIndex: inFallbackList ? fallbackIndex : null, status: 'configured', message: 'Configured (probe skipped)' });
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
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: entry.name, configured: true, model, inFallbackList, fallbackIndex: inFallbackList ? fallbackIndex : null, status: 'error', message: error?.message || 'Provider probe failed' });
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|