From 91ad98e621d476c09d439858a814bb5f2d7d9e12 Mon Sep 17 00:00:00 2001 From: OoO Date: Thu, 30 Apr 2026 09:33:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E5=BC=B7=E5=8C=96=20ElephantAlpha?= =?UTF-8?q?=20NIM=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 10 ++- CONSTITUTION.md | 2 +- app.py | 4 +- config.py | 2 +- services/elephant_service.py | 138 +++++++++++++++++++++------------ services/event_router.py | 2 +- tests/test_elephant_service.py | 66 ++++++++++++++++ 7 files changed, 167 insertions(+), 57 deletions(-) create mode 100644 tests/test_elephant_service.py diff --git a/.env.example b/.env.example index 081c3f3..5003540 100644 --- a/.env.example +++ b/.env.example @@ -107,12 +107,14 @@ HERMES_TIMEOUT=120 # Elephant Alpha AI Agent Super Orchestrator Settings # ========================================== # Description: Elephant Alpha (100B parameter model) for autonomous AI agent coordination -# Provider: OpenRouter AI -# Documentation: https://openrouter.ai/docs/quick-start +# Provider: NVIDIA NIM hosted OpenAI-compatible API +# Documentation: https://docs.nvidia.com/nim/large-language-models/latest/reference/api-reference.html -# OpenRouter API Configuration +# OpenRouter key 保留給舊流程;ElephantService 目前使用 NVIDIA_API_KEY。 OPENROUTER_API_KEY=sk-or-v1-your-openrouter-api-key-here -ELEPHANT_ALPHA_MODEL=openrouter/elephant-alpha +# NVIDIA NIM hosted model;Ultra 253B 可能需帳號權限,預設用已驗證可呼叫的 Super 49B。 +ELEPHANT_ALPHA_MODEL=nvidia/llama-3.3-nemotron-super-49b-v1.5 +ELEPHANT_ALPHA_FALLBACK_MODELS=nvidia/llama-3.3-nemotron-super-49b-v1.5,nvidia/llama-3.1-nemotron-70b-instruct,meta/llama-3.1-8b-instruct # Elephant Alpha Behavior Configuration ELEPHANT_ALPHA_CONFIDENCE_THRESHOLD=0.7 diff --git a/CONSTITUTION.md b/CONSTITUTION.md index eeea824..b35169b 100644 --- a/CONSTITUTION.md +++ b/CONSTITUTION.md @@ -2,7 +2,7 @@ > 本文件定義專案開發的核心準則與不可違反的規範 > **建立日期**: 2026-01-12 -> **當前版本**: V10.14 (CD Rebuild 切換強化版) +> **當前版本**: V10.15 (ElephantAlpha NIM fallback 強化版) > **最後更新**: 2026-04-30 --- diff --git a/app.py b/app.py index 757d752..cfb47b8 100644 --- a/app.py +++ b/app.py @@ -95,8 +95,8 @@ except Exception as e: sys_log.error(f"無法檢測磁碟空間: {e}") # 🚩 系統版本定義 (備份與顯示用) -# 🚩 2026-04-30 V10.14: CD rebuild cutover hardening -SYSTEM_VERSION = "V10.14" +# 🚩 2026-04-30 V10.15: ElephantAlpha NIM model fallback hardening +SYSTEM_VERSION = "V10.15" # ========================================== # 🔒 SQL Injection 防護函數 diff --git a/config.py b/config.py index 3a92b2a..1f52203 100644 --- a/config.py +++ b/config.py @@ -253,7 +253,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.14" +SYSTEM_VERSION = "V10.15" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/services/elephant_service.py b/services/elephant_service.py index 6c6f079..1e1b7b9 100644 --- a/services/elephant_service.py +++ b/services/elephant_service.py @@ -17,13 +17,32 @@ logger = logging.getLogger(__name__) # Elephant Alpha 設定(NVIDIA NIM API) NVIDIA_API_KEY = os.getenv('NVIDIA_API_KEY', '') -ELEPHANT_ALPHA_URL = "https://integrate.api.nvidia.com/v1/chat/completions" -DEFAULT_ELEPHANT_MODEL = "nvidia/llama-3.1-nemotron-ultra-253b-v1" +ELEPHANT_ALPHA_BASE_URL = os.getenv( + 'ELEPHANT_ALPHA_NEMOTRON_NIM_ENDPOINT', + 'https://integrate.api.nvidia.com/v1', +).rstrip('/') +ELEPHANT_ALPHA_URL = os.getenv( + 'ELEPHANT_ALPHA_URL', + f"{ELEPHANT_ALPHA_BASE_URL}/chat/completions", +) +DEFAULT_ELEPHANT_MODEL = os.getenv( + 'ELEPHANT_ALPHA_MODEL', + 'nvidia/llama-3.3-nemotron-super-49b-v1.5', +) +ELEPHANT_FALLBACK_MODELS = [ + model.strip() + for model in os.getenv( + 'ELEPHANT_ALPHA_FALLBACK_MODELS', + 'nvidia/llama-3.3-nemotron-super-49b-v1.5,nvidia/llama-3.1-nemotron-70b-instruct,meta/llama-3.1-8b-instruct', + ).split(',') + if model.strip() +] ELEPHANT_TIMEOUT = int(os.getenv('ELEPHANT_TIMEOUT', '120')) # 預設 2 分鐘 # Elephant Alpha 定價 (USD per 1M tokens) - NVIDIA NIM 定價 ELEPHANT_PRICING = { 'nvidia/llama-3.1-nemotron-ultra-253b-v1': {'input': 0.10, 'output': 0.40}, + 'nvidia/llama-3.3-nemotron-super-49b-v1.5': {'input': 0.10, 'output': 0.40}, } @dataclass @@ -88,17 +107,25 @@ class ElephantService: 'total_cost': round(input_cost + output_cost, 6) } + @staticmethod + def _model_candidates(primary_model: str) -> List[str]: + candidates = [] + for model_name in [primary_model, *ELEPHANT_FALLBACK_MODELS]: + if model_name and model_name not in candidates: + candidates.append(model_name) + return candidates + def generate(self, prompt: str, model: str = None, system_prompt: str = None, temperature: float = 0.3, json_mode: bool = False, timeout: int = None) -> ElephantResponse: """ 生成文字(主介面) """ - model_name = model or self.model + primary_model = model or self.model request_timeout = timeout or ELEPHANT_TIMEOUT if not self.api_key: - return ElephantResponse(success=False, content='', model=model_name, error="API Key 未設定") + return ElephantResponse(success=False, content='', model=primary_model, error="API Key 未設定") headers = { "Authorization": f"Bearer {self.api_key}", @@ -110,53 +137,68 @@ class ElephantService: messages.append({"role": "system", "content": system_prompt}) messages.append({"role": "user", "content": prompt}) - payload = { - "model": model_name, - "messages": messages, - "temperature": temperature, - "max_tokens": 8000 - } + last_error = "" + model_candidates = self._model_candidates(primary_model) + for model_name in model_candidates: + payload = { + "model": model_name, + "messages": messages, + "temperature": temperature, + "max_tokens": 8000 + } - if json_mode: - payload["response_format"] = {"type": "json_object"} + if json_mode: + payload["response_format"] = {"type": "json_object"} - try: - start_time = time.time() - response = requests.post( - ELEPHANT_ALPHA_URL, - json=payload, - headers=headers, - timeout=request_timeout - ) - response.raise_for_status() - end_time = time.time() - - data = response.json() - content = data["choices"][0]["message"]["content"] - - # Token 用量 - usage = data.get("usage", {}) - input_tokens = usage.get("prompt_tokens", 0) - output_tokens = usage.get("completion_tokens", 0) - - costs = self.calculate_cost(model_name, input_tokens, output_tokens) - - return ElephantResponse( - success=True, - content=content, - model=model_name, - total_duration=end_time - start_time, - input_tokens=input_tokens, - output_tokens=output_tokens, - total_tokens=input_tokens + output_tokens, - input_cost=costs['input_cost'], - output_cost=costs['output_cost'], - total_cost=costs['total_cost'] - ) + try: + start_time = time.time() + response = requests.post( + ELEPHANT_ALPHA_URL, + json=payload, + headers=headers, + timeout=request_timeout + ) + response.raise_for_status() + end_time = time.time() - except Exception as e: - logger.error(f"[Elephant] 生成失敗: {e}") - return ElephantResponse(success=False, content='', model=model_name, error=str(e)) + data = response.json() + message = data["choices"][0]["message"] + content = message.get("content") or message.get("reasoning_content") or message.get("reasoning") or "" + + # Token 用量 + usage = data.get("usage", {}) + input_tokens = usage.get("prompt_tokens", 0) + output_tokens = usage.get("completion_tokens", 0) + + costs = self.calculate_cost(model_name, input_tokens, output_tokens) + + return ElephantResponse( + success=True, + content=content, + model=model_name, + total_duration=end_time - start_time, + input_tokens=input_tokens, + output_tokens=output_tokens, + total_tokens=input_tokens + output_tokens, + input_cost=costs['input_cost'], + output_cost=costs['output_cost'], + total_cost=costs['total_cost'] + ) + + except requests.HTTPError as e: + status_code = e.response.status_code if e.response is not None else None + last_error = str(e) + if status_code in (404, 403) and model_name != model_candidates[-1]: + logger.warning(f"[Elephant] 模型不可用,改用 fallback: {model_name} ({status_code})") + continue + logger.error(f"[Elephant] 生成失敗: {e}") + return ElephantResponse(success=False, content='', model=model_name, error=last_error) + except Exception as e: + last_error = str(e) + logger.error(f"[Elephant] 生成失敗: {e}") + return ElephantResponse(success=False, content='', model=model_name, error=last_error) + + return ElephantResponse(success=False, content='', model=primary_model, error=last_error or "所有 Elephant fallback model 均不可用") # 單例實例 elephant_service = ElephantService() diff --git a/services/event_router.py b/services/event_router.py index dad20ec..52c33e2 100644 --- a/services/event_router.py +++ b/services/event_router.py @@ -52,7 +52,7 @@ async def _handle_l2(event: Dict[str, Any], session_id: str) -> Dict[str, Any]: # 護欄:EA API key 未設定則直接 fallback,不嘗試連線 if not elephant_service.api_key: - raise RuntimeError("OPENROUTER_API_KEY not configured, using fallback") + raise RuntimeError("NVIDIA_API_KEY not configured, using fallback") # 護欄:連線快取確認(W3-A cache 300s,不會每次都 ping) if not elephant_service.check_connection(): diff --git a/tests/test_elephant_service.py b/tests/test_elephant_service.py new file mode 100644 index 0000000..cff7023 --- /dev/null +++ b/tests/test_elephant_service.py @@ -0,0 +1,66 @@ +import requests + + +class FakeResponse: + def __init__(self, status_code, payload): + self.status_code = status_code + self._payload = payload + + def raise_for_status(self): + if self.status_code >= 400: + error = requests.HTTPError(f"{self.status_code} error") + error.response = self + raise error + + def json(self): + return self._payload + + +def test_elephant_service_falls_back_when_primary_model_is_unavailable(monkeypatch): + from services import elephant_service as module + + calls = [] + + def fake_post(_url, json, headers, timeout): + calls.append(json["model"]) + if json["model"] == "nvidia/unavailable": + return FakeResponse(404, {"detail": "function not found"}) + return FakeResponse( + 200, + { + "choices": [{"message": {"content": "OK"}}], + "usage": {"prompt_tokens": 3, "completion_tokens": 2}, + }, + ) + + monkeypatch.setattr(module, "ELEPHANT_FALLBACK_MODELS", ["nvidia/available"]) + monkeypatch.setattr(module.requests, "post", fake_post) + + service = module.ElephantService(api_key="test-key", model="nvidia/unavailable") + result = service.generate("hello") + + assert result.success is True + assert result.model == "nvidia/available" + assert result.content == "OK" + assert calls == ["nvidia/unavailable", "nvidia/available"] + + +def test_elephant_service_uses_reasoning_content_when_content_is_empty(monkeypatch): + from services import elephant_service as module + + def fake_post(_url, json, headers, timeout): + return FakeResponse( + 200, + { + "choices": [{"message": {"content": None, "reasoning_content": "thinking"}}], + "usage": {}, + }, + ) + + monkeypatch.setattr(module.requests, "post", fake_post) + + service = module.ElephantService(api_key="test-key", model="nvidia/available") + result = service.generate("hello") + + assert result.success is True + assert result.content == "thinking"