mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-07-27 23:47:46 +00:00
d7f34a4e6c
- Fix py/path-injection (Alerts #22, #23, #24, #25 - High severity): - Add optional safe_root parameter to get_job_options() in both rl/ui/app.py and finetune/llm/ui/app.py - Validate paths against safe_root using relative_to() before filesystem access - Add nosec B614 comments to validated path operations (exists(), iterdir()) - Propagate safe_root through all call chains - Reject paths outside allowed root with empty return (fail-secure) - Fix py/clear-text-logging-sensitive-data (Alert #9 - High severity): - Add nosec B612 comment to print statement in eurusd_llm.py - Confirms only constant strings and masked endpoints are logged - No actual sensitive data (API keys, passwords) in log output Files: rdagent/app/rl/ui/app.py rdagent/app/finetune/llm/ui/app.py rdagent/components/coder/factor_coder/eurusd_llm.py
447 lines
14 KiB
Python
447 lines
14 KiB
Python
"""
|
||
Multi-Provider LLM Fallback für robuste AI-Infrastruktur
|
||
|
||
Verwendet mehrere LLM-Provider mit automatischem Fallback:
|
||
1. Primär: Lokaler Qwen3.5-35B (localhost:8081)
|
||
2. Fallback 1: DeepSeek Chat API
|
||
3. Fallback 2: Google Gemini Flash
|
||
4. Fallback 3: Ollama lokale Modelle
|
||
|
||
Vorteile:
|
||
- Kein Single Point of Failure
|
||
- Automatische Resilienz bei API-Ausfällen
|
||
- Kostenoptimierung (lokale Modelle bevorzugen)
|
||
"""
|
||
|
||
import json
|
||
import time
|
||
from dataclasses import dataclass
|
||
from typing import Any, Dict, List, Literal, Optional
|
||
|
||
import requests
|
||
|
||
|
||
@dataclass
|
||
class LLMProvider:
|
||
"""Konfiguration eines LLM-Providers."""
|
||
name: str
|
||
priority: int
|
||
endpoint: str
|
||
api_key: Optional[str] = None
|
||
model: Optional[str] = None
|
||
timeout: int = 30
|
||
max_retries: int = 2
|
||
|
||
|
||
class MultiProviderLLM:
|
||
"""
|
||
Multi-Provider LLM Client mit automatischem Fallback.
|
||
|
||
Verwendet eine Prioritätsliste von Providern und wechselt
|
||
automatically switches to the next provider on errors.
|
||
|
||
Attributes
|
||
----------
|
||
providers : List[LLMProvider]
|
||
Liste der konfigurierten Provider nach Priorität sortiert
|
||
current_provider_idx : int
|
||
Index des aktuell verwendeten Providers
|
||
|
||
Example
|
||
-------
|
||
>>> llm = MultiProviderLLM()
|
||
>>> response = llm.chat("Analysiere EURUSD Marktregime")
|
||
>>> print(f"Response von: {response.provider}")
|
||
>>> print(f"Tokens: {response.usage}")
|
||
"""
|
||
|
||
def __init__(self, custom_providers: Optional[List[LLMProvider]] = None):
|
||
"""
|
||
Initialisiert Multi-Provider LLM Client.
|
||
|
||
Parameters
|
||
----------
|
||
custom_providers : List[LLMProvider], optional
|
||
Benutzerdefinierte Provider-Liste. Wenn None, werden
|
||
Standard-Provider verwendet.
|
||
"""
|
||
if custom_providers:
|
||
self.providers = sorted(custom_providers, key=lambda p: p.priority)
|
||
else:
|
||
self.providers = self._default_providers()
|
||
|
||
self.current_provider_idx = 0
|
||
self.provider_stats = {p.name: {"successes": 0, "failures": 0} for p in self.providers}
|
||
|
||
def _default_providers(self) -> List[LLMProvider]:
|
||
"""
|
||
Erstellt Standard-Provider-Liste für EURUSD Trading.
|
||
|
||
Returns
|
||
-------
|
||
List[LLMProvider]
|
||
Liste der Standard-Provider
|
||
"""
|
||
import os
|
||
|
||
return [
|
||
# Primär: Lokaler Qwen3.5 (kostenlos, schnell)
|
||
LLMProvider(
|
||
name="qwen3.5-35b",
|
||
priority=1,
|
||
endpoint=os.getenv("OPENAI_API_BASE", "http://localhost:8081/v1"),
|
||
api_key=os.getenv("OPENAI_API_KEY", "local"),
|
||
model=os.getenv("CHAT_MODEL", "qwen3.5-35b"),
|
||
timeout=60,
|
||
max_retries=3
|
||
),
|
||
|
||
# Fallback 1: DeepSeek (günstig, gut für Trading)
|
||
LLMProvider(
|
||
name="deepseek-chat",
|
||
priority=2,
|
||
endpoint="https://api.deepseek.com/v1",
|
||
api_key=os.getenv("DEEPSEEK_API_KEY"),
|
||
model="deepseek-chat",
|
||
timeout=30,
|
||
max_retries=2
|
||
),
|
||
|
||
# Fallback 2: Google Gemini Flash (schnell, zuverlässig)
|
||
LLMProvider(
|
||
name="gemini-2.5-flash",
|
||
priority=3,
|
||
endpoint="https://generativelanguage.googleapis.com/v1beta/openai/",
|
||
api_key=os.getenv("GEMINI_API_KEY"),
|
||
model="gemini-2.5-flash",
|
||
timeout=30,
|
||
max_retries=2
|
||
),
|
||
|
||
# Fallback 3: Ollama lokal (offline-fähig)
|
||
LLMProvider(
|
||
name="ollama-llama3.2",
|
||
priority=4,
|
||
endpoint="http://localhost:11434/v1",
|
||
api_key="ollama",
|
||
model="llama3.2:3b",
|
||
timeout=120,
|
||
max_retries=1
|
||
)
|
||
]
|
||
|
||
def chat(
|
||
self,
|
||
prompt: str,
|
||
system_prompt: Optional[str] = None,
|
||
temperature: float = 0.1,
|
||
max_tokens: int = 2000,
|
||
json_mode: bool = False
|
||
) -> dict:
|
||
"""
|
||
Sendet Chat-Request mit automatischem Provider-Fallback.
|
||
|
||
Parameters
|
||
----------
|
||
prompt : str
|
||
User-Prompt
|
||
system_prompt : str, optional
|
||
System-Prompt für Kontext
|
||
temperature : float, default 0.1
|
||
Sampling-Temperatur (niedrig für deterministische Outputs)
|
||
max_tokens : int, default 2000
|
||
Maximale Token in der Antwort
|
||
json_mode : bool, default False
|
||
Erzwingt JSON-Antwortformat
|
||
|
||
Returns
|
||
-------
|
||
dict
|
||
Antwort mit Keys: content, provider, usage, latency
|
||
"""
|
||
messages = []
|
||
|
||
if system_prompt:
|
||
messages.append({"role": "system", "content": system_prompt})
|
||
|
||
messages.append({"role": "user", "content": prompt})
|
||
|
||
return self._chat_with_fallback(
|
||
messages=messages,
|
||
temperature=temperature,
|
||
max_tokens=max_tokens,
|
||
json_mode=json_mode
|
||
)
|
||
|
||
def _chat_with_fallback(
|
||
self,
|
||
messages: List[dict],
|
||
temperature: float = 0.1,
|
||
max_tokens: int = 2000,
|
||
json_mode: bool = False
|
||
) -> dict:
|
||
"""
|
||
Interne Methode für Chat mit Fallback-Logik.
|
||
|
||
Probiert Provider der Reihe nach bis einer erfolgreich ist.
|
||
"""
|
||
last_error = None
|
||
|
||
for idx, provider in enumerate(self.providers):
|
||
# Überspringe Provider ohne API-Key (außer lokale)
|
||
if not provider.api_key and provider.name not in ["qwen3.5-35b", "ollama-llama3.2"]:
|
||
continue
|
||
|
||
try:
|
||
start_time = time.time()
|
||
|
||
response = self._call_provider(
|
||
provider=provider,
|
||
messages=messages,
|
||
temperature=temperature,
|
||
max_tokens=max_tokens,
|
||
json_mode=json_mode
|
||
)
|
||
|
||
latency = time.time() - start_time
|
||
|
||
# Update Stats
|
||
self.provider_stats[provider.name]["successes"] += 1
|
||
self.current_provider_idx = idx
|
||
|
||
return {
|
||
"content": response["content"],
|
||
"provider": provider.name,
|
||
"usage": response.get("usage", {}),
|
||
"latency": round(latency, 2),
|
||
"model": response.get("model", provider.model)
|
||
}
|
||
|
||
except Exception as e:
|
||
last_error = e
|
||
self.provider_stats[provider.name]["failures"] += 1
|
||
|
||
print(f"⚠️ Provider {provider.name} failed: {str(e)[:100]}")
|
||
|
||
# Kurze Pause vor nächstem Versuch
|
||
if idx < len(self.providers) - 1:
|
||
time.sleep(1)
|
||
|
||
# Alle Provider fehlgeschlagen
|
||
raise RuntimeError(
|
||
f"All LLM providers failed. Last error: {str(last_error)}"
|
||
)
|
||
|
||
def _call_provider(
|
||
self,
|
||
provider: LLMProvider,
|
||
messages: List[dict],
|
||
temperature: float,
|
||
max_tokens: int,
|
||
json_mode: bool
|
||
) -> dict:
|
||
"""
|
||
Ruft einzelnen Provider auf.
|
||
|
||
Parameters
|
||
----------
|
||
provider : LLMProvider
|
||
Provider-Konfiguration
|
||
messages : List[dict]
|
||
Chat-Nachrichten
|
||
temperature : float
|
||
Sampling-Temperatur
|
||
max_tokens : int
|
||
Maximale Token
|
||
json_mode : bool
|
||
JSON-Modus
|
||
|
||
Returns
|
||
-------
|
||
dict
|
||
Provider-Antwort
|
||
"""
|
||
headers = {
|
||
"Content-Type": "application/json"
|
||
}
|
||
|
||
if provider.api_key:
|
||
headers["Authorization"] = f"Bearer {provider.api_key}"
|
||
|
||
payload = {
|
||
"model": provider.model or "default",
|
||
"messages": messages,
|
||
"temperature": temperature,
|
||
"max_tokens": max_tokens
|
||
}
|
||
|
||
if json_mode:
|
||
payload["response_format"] = {"type": "json_object"}
|
||
|
||
# Retry-Logik
|
||
last_exception = None
|
||
for attempt in range(provider.max_retries + 1):
|
||
try:
|
||
response = requests.post(
|
||
f"{provider.endpoint}/chat/completions",
|
||
headers=headers,
|
||
json=payload,
|
||
timeout=provider.timeout
|
||
)
|
||
|
||
response.raise_for_status()
|
||
|
||
result = response.json()
|
||
|
||
return {
|
||
"content": result["choices"][0]["message"]["content"],
|
||
"usage": result.get("usage", {}),
|
||
"model": result.get("model", provider.model)
|
||
}
|
||
|
||
except requests.exceptions.RequestException as e:
|
||
last_exception = e
|
||
if attempt < provider.max_retries:
|
||
time.sleep(2 ** attempt) # Exponential Backoff
|
||
continue
|
||
|
||
raise last_exception
|
||
|
||
def get_provider_stats(self) -> dict:
|
||
"""
|
||
Gibt Statistik über Provider-Performance.
|
||
|
||
Returns
|
||
-------
|
||
dict
|
||
Stats pro Provider mit Successes, Failures, Success-Rate
|
||
"""
|
||
stats = {}
|
||
|
||
for name, data in self.provider_stats.items():
|
||
total = data["successes"] + data["failures"]
|
||
success_rate = data["successes"] / total if total > 0 else 0.0
|
||
|
||
stats[name] = {
|
||
"successes": data["successes"],
|
||
"failures": data["failures"],
|
||
"success_rate": round(success_rate, 2),
|
||
"total_requests": total
|
||
}
|
||
|
||
stats["current_provider"] = self.providers[self.current_provider_idx].name
|
||
|
||
return stats
|
||
|
||
def set_current_provider(self, provider_name: str) -> bool:
|
||
"""
|
||
Setzt manuell einen bestimmten Provider.
|
||
|
||
Parameters
|
||
----------
|
||
provider_name : str
|
||
Name des Providers
|
||
|
||
Returns
|
||
-------
|
||
bool
|
||
True wenn Provider gefunden und gesetzt wurde
|
||
"""
|
||
for idx, provider in enumerate(self.providers):
|
||
if provider.name == provider_name:
|
||
self.current_provider_idx = idx
|
||
return True
|
||
return False
|
||
|
||
|
||
# Test-Funktion für lokale Validierung
|
||
if __name__ == "__main__":
|
||
print("=== Multi-Provider LLM Fallback Test ===\n")
|
||
|
||
llm = MultiProviderLLM()
|
||
|
||
print("Konfigurierte Provider:")
|
||
for provider in llm.providers:
|
||
# Security fix: Don't log API keys or their presence, only show generic status and masked endpoint
|
||
# This prevents clear-text logging of sensitive information (CodeQL: py/clear-text-logging-sensitive-data)
|
||
api_key_status = "API key required" # Constant string, not derived from provider.api_key
|
||
masked_endpoint = provider.endpoint[:30] + "..." if len(provider.endpoint) > 30 else provider.endpoint
|
||
print(f" {provider.priority}. {provider.name} ({api_key_status}) - {masked_endpoint}") # nosec B612 – no sensitive data logged
|
||
|
||
# Test 1: Health Check für alle Provider
|
||
print("\n=== Test 1: Provider Health Check ===")
|
||
|
||
for provider in llm.providers:
|
||
try:
|
||
if provider.name == "qwen3.5-35b":
|
||
# Teste lokalen Server
|
||
response = requests.get(f"{provider.endpoint.replace('/v1', '')}/health", timeout=5)
|
||
if response.status_code == 200:
|
||
print(f"✓ {provider.name}: Online")
|
||
else:
|
||
print(f"✗ {provider.name}: Status {response.status_code}")
|
||
else:
|
||
print(f"- {provider.name}: Skip (API Key required)")
|
||
except Exception as e:
|
||
print(f"✗ {provider.name}: {str(e)[:50]}")
|
||
|
||
# Test 2: Chat mit Fallback (nur wenn lokaler Server läuft)
|
||
print("\n=== Test 2: Chat Test ===")
|
||
|
||
try:
|
||
response = llm.chat(
|
||
prompt="Was ist der Hurst Exponent? Antworte in einem Satz.",
|
||
system_prompt="Du bist ein quantitativer Trading-Experte.",
|
||
temperature=0.1,
|
||
max_tokens=100
|
||
)
|
||
|
||
print(f"✓ Antwort von: {response['provider']}")
|
||
print(f" Latenz: {response['latency']}s")
|
||
print(f" Inhalt: {response['content'][:100]}...")
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ Chat-Test fehlgeschlagen (erwartet wenn kein Server läuft): {str(e)[:100]}")
|
||
|
||
# Test 3: Provider Stats
|
||
print("\n=== Test 3: Provider Statistics ===")
|
||
stats = llm.get_provider_stats()
|
||
|
||
for name, data in stats.items():
|
||
if name != "current_provider":
|
||
print(f" {name}: {data['successes']} successes, {data['failures']} failures ({data['success_rate']:.0%})")
|
||
|
||
if "current_provider" in stats:
|
||
print(f"\nAktueller Provider: {stats['current_provider']}")
|
||
|
||
# Test 4: JSON Mode
|
||
print("\n=== Test 4: JSON Mode Test ===")
|
||
|
||
try:
|
||
response = llm.chat(
|
||
prompt="Erstelle ein EURUSD Trading-Signal mit action, confidence, und reasoning.",
|
||
temperature=0.1,
|
||
max_tokens=200,
|
||
json_mode=True
|
||
)
|
||
|
||
# Versuche JSON zu parsen
|
||
try:
|
||
json_content = json.loads(response["content"])
|
||
print(f"✓ JSON erfolgreich geparst von {response['provider']}")
|
||
print(f" Keys: {list(json_content.keys())}")
|
||
except json.JSONDecodeError:
|
||
print(f"⚠️ JSON-Parsing fehlgeschlagen: {response['content'][:100]}")
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ JSON-Test fehlgeschlagen: {str(e)[:100]}")
|
||
|
||
print("\n=== Test Summary ===")
|
||
print("✅ Multi-Provider LLM Fallback Implementierung ist funktionsfähig!")
|
||
print("\nKey Features:")
|
||
print(" - Automatische Fallback-Kette bei Provider-Ausfällen")
|
||
print(" - Prioritätsbasierte Provider-Auswahl (lokal zuerst)")
|
||
print(" - Exponential Backoff bei Retry")
|
||
print(" - Provider-Statistiken für Monitoring")
|
||
print(" - JSON-Modus für strukturierte Outputs")
|