Files
awoooi/apps/api/src/services/ai_providers/ollama.py
Your Name 4111ea4f9f
All checks were successful
Code Review / ai-code-review (push) Successful in 12s
CD Pipeline / tests (push) Successful in 1m13s
CD Pipeline / build-and-deploy (push) Successful in 3m36s
CD Pipeline / post-deploy-checks (push) Successful in 1m20s
fix(ai): remove 188 ollama provider
2026-05-06 14:34:48 +08:00

493 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Ollama Provider - Phase 24 ADR-052
====================================
本地 / 私有 LLM 推理 Provider。
搬移自: openclaw.py _call_ollama (L349-409)
特性: 免費、隱私安全 (local)、可依 ADR-110 指向 GCP-A/GCP-B/111。
2026-04-02 ogt: Phase 24-A 從 openclaw.py 抽出
"""
from __future__ import annotations
import json
import time
import httpx
import structlog
from src.core.config import get_settings
from src.plugins.mcp.interfaces import MCPTool
from src.services.ai_providers.interfaces import (
AIResult,
is_provider_enabled_by_env,
)
from src.services.ai_providers.tool_schema import openai_tools_for_agent
from src.services.model_registry import get_model_registry
logger = structlog.get_logger(__name__)
settings = get_settings()
_GCP_LIGHTWEIGHT_MODELS = {
"gemma3:4b",
}
def _normalized_url(value: str | None) -> str:
return (value or "").rstrip("/")
def _is_gcp_alert_lane(endpoint_url: str) -> bool:
"""Return true for the CPU-only GCP-A/B synchronous alert lane."""
endpoint = _normalized_url(endpoint_url)
return endpoint in {
_normalized_url(getattr(settings, "OLLAMA_URL", "")),
_normalized_url(getattr(settings, "OLLAMA_SECONDARY_URL", "")),
}
def _resolve_model_for_endpoint(
*,
requested_model: str,
endpoint_url: str,
context: dict | None,
) -> str:
"""
Keep non-diagnosis calls from polluting the GCP diagnosis lane.
GCP-A/B are allowed to run the deep incident diagnosis model because the
alert goal is correctness and resolution, not the fastest Telegram card.
Accidental non-diagnosis workloads still fall back to the lightweight health
model so embedding/Hermes/background calls cannot occupy the same lane.
"""
model_name = requested_model.strip()
context = context or {}
allow_gcp_heavy = bool(context.get("allow_gcp_heavy_model"))
task_type = str(context.get("task_type") or context.get("intent_hint") or "").lower()
is_deep_diagnosis = task_type in {"diagnose", "alert_deep", "incident_diagnosis"}
if (
_is_gcp_alert_lane(endpoint_url)
and not allow_gcp_heavy
and not is_deep_diagnosis
and model_name not in _GCP_LIGHTWEIGHT_MODELS
):
fallback_model = str(getattr(settings, "OLLAMA_HEALTH_CHECK_MODEL", "gemma3:4b")).strip() or "gemma3:4b"
logger.warning(
"ollama_gcp_non_diagnosis_model_coerced",
endpoint=endpoint_url,
requested_model=model_name,
safe_model=fallback_model,
task_type=task_type,
)
return fallback_model
return model_name
class OllamaProvider:
"""
Ollama 本地 LLM Provider
privacy_level: local (可處理機密資料)
capabilities: rca, chat, code_review
"""
def __init__(self) -> None:
self._http_client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(
timeout=httpx.Timeout(120.0, connect=10.0),
)
return self._http_client
def _endpoint_url(self) -> str:
return settings.OLLAMA_URL
@property
def name(self) -> str:
return "ollama"
@property
def is_enabled(self) -> bool:
return is_provider_enabled_by_env("ollama")
@property
def capabilities(self) -> set[str]:
return {"rca", "chat", "code_review"}
@property
def privacy_level(self) -> str:
return "local"
async def analyze(
self,
prompt: str,
context: dict | None = None,
) -> AIResult:
start = time.perf_counter()
try:
client = await self._get_client()
registry = get_model_registry()
endpoint_url = self._endpoint_url()
requested_model = str((context or {}).get("ollama_model") or registry.get_model("ollama", "rca")).strip()
model_name = _resolve_model_for_endpoint(
requested_model=requested_model,
endpoint_url=endpoint_url,
context=context,
)
options = registry.get_provider_options("ollama")
# P0 2026-04-04 Claude Code: per-task timeoutOption C 分情境)
# FORCE_LOCAL/diagnose → OLLAMA_DIAGNOSE_TIMEOUT_SECONDS
# 其他 → OPENCLAW_TIMEOUT既有設定
task_type = (context or {}).get("task_type", "")
if task_type in ("diagnose", "force_local"):
read_timeout = float(getattr(settings, "OLLAMA_DIAGNOSE_TIMEOUT_SECONDS", 200))
else:
read_timeout = float(settings.OPENCLAW_TIMEOUT)
response = await client.post(
f"{endpoint_url}/api/generate",
json={
"model": model_name,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {
"num_predict": options.get("num_predict", 1024),
"temperature": options.get("temperature", 0.1),
"top_p": options.get("top_p", 0.9),
},
},
timeout=httpx.Timeout(read_timeout, connect=10.0),
)
response.raise_for_status()
data = response.json()
result = data.get("response", "")
# I3 修復: 追蹤 tokens
tokens = data.get("eval_count", 0) + data.get("prompt_eval_count", 0)
latency = (time.perf_counter() - start) * 1000
logger.info(
"ollama_provider_success",
response_length=len(result),
tokens=tokens,
latency_ms=round(latency, 1),
model=model_name,
)
return AIResult(
raw_response=result,
success=True,
provider=self.name,
tokens=tokens,
latency_ms=latency,
)
except httpx.TimeoutException as e:
latency = (time.perf_counter() - start) * 1000
logger.warning("ollama_provider_timeout", error=str(e), latency_ms=round(latency, 1))
return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error=f"Timeout: {e}")
except Exception as e:
latency = (time.perf_counter() - start) * 1000
logger.warning("ollama_provider_failed", error=str(e), latency_ms=round(latency, 1))
return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error=str(e))
async def analyze_with_tools(
self,
prompt: str,
available_tools: list[MCPTool],
tool_executor,
max_iterations: int = 5,
agent_role: str = "openclaw",
context: dict | None = None,
) -> AIResult:
"""Run Ollama chat tool calling loop when the local model supports tools."""
if not available_tools:
return await self.analyze(prompt, context=context)
tools_schema = openai_tools_for_agent(available_tools, agent_role)
if not tools_schema:
return AIResult(
raw_response="",
success=False,
provider=self.name,
error=f"No MCP tools allowed for agent_role={agent_role}",
)
start = time.perf_counter()
total_tokens = 0
messages: list[dict] = [{"role": "user", "content": prompt}]
registry = get_model_registry()
model_name = str((context or {}).get("ollama_model") or registry.get_model("ollama", "rca")).strip()
options = registry.get_provider_options("ollama")
task_type = (context or {}).get("task_type", "")
if task_type in ("diagnose", "force_local"):
read_timeout = float(getattr(settings, "OLLAMA_DIAGNOSE_TIMEOUT_SECONDS", 200))
else:
read_timeout = float(settings.OPENCLAW_TIMEOUT)
try:
client = await self._get_client()
last_content = ""
for iteration in range(max_iterations):
response = await client.post(
f"{self._endpoint_url()}/api/chat",
json={
"model": model_name,
"messages": messages,
"stream": False,
"tools": tools_schema,
"options": {
"num_predict": options.get("num_predict", 1024),
"temperature": options.get("temperature", 0.1),
"top_p": options.get("top_p", 0.9),
},
},
timeout=httpx.Timeout(read_timeout, connect=10.0),
)
response.raise_for_status()
data = response.json()
total_tokens += int(data.get("eval_count", 0) or 0)
total_tokens += int(data.get("prompt_eval_count", 0) or 0)
message = data.get("message") or {}
last_content = message.get("content") or last_content
tool_calls = message.get("tool_calls") or []
if not tool_calls:
latency = (time.perf_counter() - start) * 1000
return AIResult(
raw_response=last_content or json.dumps(data, ensure_ascii=False),
success=True,
provider=f"{self.name}_agent_loop",
tokens=total_tokens,
latency_ms=latency,
)
messages.append(message)
for call in tool_calls:
function = call.get("function") or {}
tool_name = function.get("name", "")
arguments = function.get("arguments") or {}
result = await tool_executor(tool_name, arguments)
messages.append({
"role": "tool",
"content": json.dumps(
result.to_dict() if hasattr(result, "to_dict") else result,
ensure_ascii=False,
default=str,
),
})
logger.debug(
"ollama_agent_loop_iteration",
provider=self.name,
agent_role=agent_role,
iteration=iteration + 1,
tool_calls=len(tool_calls),
)
latency = (time.perf_counter() - start) * 1000
return AIResult(
raw_response=last_content,
success=False,
provider=f"{self.name}_agent_loop",
tokens=total_tokens,
latency_ms=latency,
error=f"Agent loop exceeded max_iterations={max_iterations}",
)
except Exception as e:
latency = (time.perf_counter() - start) * 1000
logger.warning(
"ollama_agent_loop_failed",
provider=self.name,
agent_role=agent_role,
error=str(e),
latency_ms=round(latency, 1),
)
return AIResult(
raw_response="",
success=False,
provider=f"{self.name}_agent_loop",
tokens=total_tokens,
latency_ms=latency,
error=str(e),
)
async def health_check(self) -> bool:
try:
client = await self._get_client()
resp = await client.get(f"{self._endpoint_url()}/api/tags", timeout=5.0)
return resp.status_code == 200
except Exception:
return False
async def close(self) -> None:
if self._http_client:
await self._http_client.aclose()
self._http_client = None
# 2026-05-06 Codex — 188 不再作為 Ollama Provider本地備援統一命名為 ollama_local。
class OllamaLocalProvider(OllamaProvider):
"""
Ollama Local fallback Provider
使用 OLLAMA_FALLBACK_URL 作為本地最後防線端點。
ADR-110 目前設定為 110 nginx proxy → 111 Ollama188 不得再作為 Ollama provider。
"""
@property
def name(self) -> str:
return "ollama_local"
@property
def is_enabled(self) -> bool:
import os
# 優先查 ENABLE_OLLAMA_LOCAL若未設定預設 true則看 OLLAMA_FALLBACK_URL 是否有值。
env_override = os.getenv("ENABLE_OLLAMA_LOCAL", "true").lower() == "true"
if not env_override:
return False
# OLLAMA_FALLBACK_URL 空字串 → 未設定本地節點 → 停用。
return bool(getattr(settings, "OLLAMA_FALLBACK_URL", ""))
def _endpoint_url(self) -> str:
return getattr(settings, "OLLAMA_FALLBACK_URL", "")
async def analyze(
self,
prompt: str,
context: dict | None = None,
) -> AIResult:
start = time.perf_counter()
fallback_url = getattr(settings, "OLLAMA_FALLBACK_URL", "")
if not fallback_url:
return AIResult(
raw_response="",
success=False,
provider=self.name,
error="OLLAMA_FALLBACK_URL not configured",
)
try:
client = await self._get_client()
registry = get_model_registry()
# 嘗試取本地 fallback 專屬設定fallback 到 ollama 預設。
try:
model_name = str((context or {}).get("ollama_model") or registry.get_model("ollama_local", "rca")).strip()
except Exception:
model_name = str((context or {}).get("ollama_model") or getattr(settings, "OLLAMA_HEALTH_CHECK_MODEL", "qwen2.5:7b-instruct")).strip()
try:
options = registry.get_provider_options("ollama_local")
except Exception:
options = registry.get_provider_options("ollama")
# 本地備援:固定使用較長 timeout避免 111 模型載入時被過早判死。
task_type = (context or {}).get("task_type", "")
if task_type in ("diagnose", "force_local"):
read_timeout = float(getattr(settings, "OLLAMA_DIAGNOSE_TIMEOUT_SECONDS", 200))
else:
read_timeout = float(settings.OPENCLAW_TIMEOUT)
response = await client.post(
f"{fallback_url}/api/generate",
json={
"model": model_name,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {
"num_predict": options.get("num_predict", 1024),
"temperature": options.get("temperature", 0.1),
"top_p": options.get("top_p", 0.9),
},
},
timeout=httpx.Timeout(read_timeout, connect=10.0),
)
response.raise_for_status()
data = response.json()
result = data.get("response", "")
tokens = data.get("eval_count", 0) + data.get("prompt_eval_count", 0)
latency = (time.perf_counter() - start) * 1000
logger.info(
"ollama_local_provider_success",
response_length=len(result),
tokens=tokens,
latency_ms=round(latency, 1),
endpoint=fallback_url,
model=model_name,
)
return AIResult(
raw_response=result,
success=True,
provider=self.name,
tokens=tokens,
latency_ms=latency,
)
except httpx.TimeoutException as e:
latency = (time.perf_counter() - start) * 1000
logger.warning("ollama_local_provider_timeout", error=str(e), latency_ms=round(latency, 1))
return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error=f"Timeout: {e}")
except Exception as e:
latency = (time.perf_counter() - start) * 1000
logger.warning("ollama_local_provider_failed", error=str(e), latency_ms=round(latency, 1))
return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error=str(e))
async def health_check(self) -> bool:
fallback_url = getattr(settings, "OLLAMA_FALLBACK_URL", "")
if not fallback_url:
return False
try:
client = await self._get_client()
resp = await client.get(f"{fallback_url}/api/tags", timeout=5.0)
return resp.status_code == 200
except Exception:
return False
class OllamaGcpBProvider(OllamaProvider):
"""
GCP-B Secondary Ollama Provider
繼承 OllamaProvider使用 OLLAMA_SECONDARY_URL34.21.145.224:11434
ADR-110 三層容災GCP-A → GCP-B → Local(111)。
OllamaFailoverManager 回傳 provider_name="ollama_gcp_b" 時由此 Provider 執行。
2026-05-04 ogt + Claude Sonnet 4.6: ADR-110 GCP-B 容災補全
根因AIProviderRegistry 缺少 "ollama_gcp_b" → not_registered → 跳 Gemini
"""
@property
def name(self) -> str:
return "ollama_gcp_b"
@property
def is_enabled(self) -> bool:
return bool(getattr(settings, "OLLAMA_SECONDARY_URL", ""))
def _endpoint_url(self) -> str:
return getattr(settings, "OLLAMA_SECONDARY_URL", "")
async def health_check(self) -> bool:
url = getattr(settings, "OLLAMA_SECONDARY_URL", "")
if not url:
return False
try:
client = await self._get_client()
resp = await client.get(f"{url}/api/tags", timeout=5.0)
return resp.status_code == 200
except Exception:
return False