refactor(ai): 模組化重構 - NVIDIA chat 移至 NvidiaProvider

符合 feedback_lewooogo_modular_enforcement.md 規範:
- 移除 openclaw.py 中的 _call_nvidia() (重複邏輯)
- 新增 NvidiaProvider.chat() 方法
- 更新 INvidiaProvider Protocol
- openclaw.py 改用 get_nvidia_provider().chat()
- 測試移至 test_nvidia_chat.py

架構層次:
- Router → Service → Provider (正確)
- 禁止 Service 層重複實作已存在的 Provider 功能

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-29 20:49:23 +08:00
parent 1eb0be8f3f
commit 04bfff9d19
7 changed files with 333 additions and 194 deletions

View File

@@ -92,6 +92,21 @@ class INvidiaProvider(Protocol):
"""關閉資源"""
...
async def chat(
self,
prompt: str,
model: str = ...,
temperature: float = ...,
max_tokens: int = ...,
) -> tuple[str, bool, int, float]:
"""
一般對話 (非 Tool Calling) - 2026-03-29 ogt 新增
Returns:
tuple: (response_text, success, total_tokens, cost_usd)
"""
...
# =============================================================================
# 常量定義
# =============================================================================
@@ -635,6 +650,142 @@ class NvidiaProvider:
if tc.valid and tc.tool_name and self.is_high_risk_tool(tc.tool_name)
]
async def chat(
self,
prompt: str,
model: str | None = None,
temperature: float = 0.1,
max_tokens: int = 2048,
) -> tuple[str, bool, int, float]:
"""
一般對話 (非 Tool Calling) - 用於 RCA 分析
2026-03-29 ogt: 新增,符合模組化規範
從 openclaw.py 遷移,統一由 NvidiaProvider 處理所有 NVIDIA API 呼叫
Args:
prompt: 對話內容
model: 模型名稱 (預設從 ModelRegistry 取得)
temperature: 溫度
max_tokens: 最大輸出 Token
Returns:
tuple: (response_text, success, total_tokens, cost_usd)
"""
start_time = time.perf_counter()
# OTEL Span
with _tracer.start_as_current_span("nvidia_chat") as span:
span.set_attribute("ai.provider", "nvidia")
# Circuit Breaker 檢查
if not self._circuit_breaker.can_execute():
span.set_attribute("ai.error", "circuit_breaker_open")
NVIDIA_REQUESTS_TOTAL.labels(status="circuit_open", tool_name="chat").inc()
logger.warning("nvidia_chat_circuit_breaker_open")
return "Circuit Breaker OPEN - NVIDIA API 暫時不可用", False, 0, 0.0
# 檢查 API Key
if not self._api_key:
span.set_attribute("ai.error", "api_key_not_set")
return "NVIDIA_API_KEY not configured", False, 0, 0.0
# 從 ModelRegistry 取得模型
from src.services.model_registry import get_model_registry
registry = get_model_registry()
model_name = model or registry.get_model("nvidia", "rca")
span.set_attribute("ai.model", model_name)
logger.info(
"nvidia_chat_request_start",
model=model_name,
prompt_length=len(prompt),
)
# Langfuse 追蹤
with LangfuseTraceContext(
name="nvidia_chat",
metadata={"model": model_name, "task": "rca"},
) as langfuse_ctx:
try:
client = await self._get_client()
response = await client.post(
NVIDIA_API_URL,
headers={
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
},
json={
"model": model_name,
"messages": [{"role": "user", "content": prompt}],
"temperature": temperature,
"max_tokens": max_tokens,
"response_format": {"type": "json_object"},
},
)
response.raise_for_status()
data = response.json()
self._circuit_breaker.record_success()
text = data["choices"][0]["message"]["content"]
# Token 用量
usage = data.get("usage", {})
prompt_tokens = usage.get("prompt_tokens", 0)
completion_tokens = usage.get("completion_tokens", 0)
total_tokens = usage.get("total_tokens", prompt_tokens + completion_tokens)
# NVIDIA NIM 免費 tier = $0
cost_usd = 0.0
latency_ms = (time.perf_counter() - start_time) * 1000
span.set_attribute("ai.latency_ms", latency_ms)
span.set_attribute("ai.total_tokens", total_tokens)
# Prometheus
NVIDIA_REQUESTS_TOTAL.labels(status="success", tool_name="chat").inc()
NVIDIA_LATENCY_SECONDS.labels(tool_name="chat").observe(latency_ms / 1000)
# Langfuse
langfuse_ctx.trace.generation(
name="nvidia_chat",
model=model_name,
input=prompt[:500],
output=text[:500],
metadata={
"total_tokens": total_tokens,
"cost_usd": cost_usd,
"latency_ms": round(latency_ms, 2),
},
)
logger.info(
"nvidia_chat_response_received",
model=model_name,
response_length=len(text),
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
latency_ms=round(latency_ms, 2),
)
return text, True, total_tokens, cost_usd
except httpx.TimeoutException as e:
self._circuit_breaker.record_failure()
NVIDIA_REQUESTS_TOTAL.labels(status="timeout", tool_name="chat").inc()
logger.warning("nvidia_chat_timeout", error=str(e))
return f"Timeout: {e}", False, 0, 0.0
except Exception as e:
self._circuit_breaker.record_failure()
NVIDIA_REQUESTS_TOTAL.labels(status="error", tool_name="chat").inc()
logger.warning("nvidia_chat_failed", error=str(e), error_type=type(e).__name__)
return str(e), False, 0, 0.0
# =============================================================================
# 單例與工廠函數

View File

@@ -461,76 +461,8 @@ class OpenClawService:
logger.warning("claude_call_failed", error=str(e))
return str(e), False
async def _call_nvidia(self, prompt: str) -> tuple[str, bool, int, float]:
"""
呼叫 NVIDIA Nemotron (OpenAI 相容格式)
2026-03-29 ogt: 新增 Nemotron 一般告警支援 (非 Tool Calling)
2026-03-29 ogt: P1 修復 - 從 ModelRegistry 取得模型名稱
Returns:
tuple: (response_text, success, total_tokens, cost_usd)
"""
if not settings.NVIDIA_API_KEY:
return "NVIDIA_API_KEY not configured", False, 0, 0.0
try:
client = await self._get_client()
# 從 ModelRegistry 取得模型 (P1-1 修復)
registry = get_model_registry()
model_name = registry.get_model("nvidia", "rca")
options = registry.get_provider_options("nvidia")
logger.info(
"nvidia_request_start",
model=model_name,
prompt_length=len(prompt),
)
response = await client.post(
"https://integrate.api.nvidia.com/v1/chat/completions",
headers={
"Authorization": f"Bearer {settings.NVIDIA_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": model_name,
"messages": [{"role": "user", "content": prompt}],
"temperature": options.get("temperature", 0.1),
"max_tokens": options.get("max_tokens", 2048),
"response_format": {"type": "json_object"}, # 強制 JSON
},
timeout=60.0,
)
response.raise_for_status()
data = response.json()
text = data["choices"][0]["message"]["content"]
# Token 用量
usage = data.get("usage", {})
prompt_tokens = usage.get("prompt_tokens", 0)
completion_tokens = usage.get("completion_tokens", 0)
total_tokens = usage.get("total_tokens", prompt_tokens + completion_tokens)
# NVIDIA NIM 免費 tier = $0
cost_usd = 0.0
logger.info(
"nvidia_response_received",
model=model_name,
response_length=len(text),
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
cost_usd=f"${cost_usd:.6f}",
)
return text, True, total_tokens, cost_usd
except Exception as e:
logger.warning("nvidia_call_failed", error=str(e), error_type=type(e).__name__)
return str(e), False, 0, 0.0
# 2026-03-29 ogt: _call_nvidia 已移至 nvidia_provider.py
# 符合模組化規範 - 所有 NVIDIA API 呼叫統一由 NvidiaProvider 處理
# =========================================================================
# Mock LLM - Intelligent Fallback with SignOz Data
@@ -948,8 +880,10 @@ class OpenClawService:
elif provider == "gemini":
response, success, total_tokens, cost_usd = await self._call_gemini(prompt)
elif provider == "nvidia":
# 2026-03-29 ogt: Nemotron 一般告警支援
response, success, total_tokens, cost_usd = await self._call_nvidia(prompt)
# 2026-03-29 ogt: 使用 NvidiaProvider.chat() (模組化規範)
from src.services.nvidia_provider import get_nvidia_provider
nvidia_provider = get_nvidia_provider()
response, success, total_tokens, cost_usd = await nvidia_provider.chat(prompt)
elif provider == "claude":
response, success = await self._call_claude(prompt)
else: