Files
awoooi/apps/api/src/services/ai_providers/ollama.py

742 lines
27 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 hashlib
import json
import re
import time
from collections.abc import Awaitable, Callable
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,
cloud_context_block_reason,
is_provider_enabled_by_env,
)
from src.services.ai_providers.tool_schema import openai_tools_for_agent
from src.services.cloud_transport_receipt import verify_cloud_transport_receipt
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 bool(endpoint) and endpoint in {
_normalized_url(getattr(settings, "OLLAMA_URL", "")),
_normalized_url(getattr(settings, "OLLAMA_SECONDARY_URL", "")),
}
CloudTransportReceiptVerifier = Callable[..., Awaitable[str | None]]
def _safe_ollama_error_code(error: BaseException) -> str:
"""Classify provider failures without returning endpoint or body text."""
if isinstance(error, httpx.TimeoutException):
return "provider_timeout"
if isinstance(error, httpx.HTTPStatusError):
status_code = int(error.response.status_code)
if 100 <= status_code <= 599:
return f"http_{status_code}"
return "provider_refusal"
if isinstance(error, httpx.TransportError | ConnectionError):
return "provider_transport_error"
error_type = type(error).__name__
if re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,79}", error_type):
return f"unhandled_{error_type}"
return "provider_error_redacted"
def _gcp_provider_identity(endpoint_url: str) -> str | None:
endpoint = _normalized_url(endpoint_url)
if endpoint == _normalized_url(getattr(settings, "OLLAMA_URL", "")):
return "ollama_gcp_a"
if endpoint == _normalized_url(getattr(settings, "OLLAMA_SECONDARY_URL", "")):
return "ollama_gcp_b"
return None
def _sre_chat_sanitization_receipt_block_reason(
*,
prompt: str,
context: dict,
) -> str | None:
"""Mirror the router's formal real-chat receipt at the provider boundary."""
receipt = context.get("sre_chat_sanitization_receipt")
if not isinstance(receipt, dict):
return "sre_chat_sanitization_receipt_invalid"
prompt_sha256 = hashlib.sha256(prompt.encode("utf-8")).hexdigest()
if receipt.get("prompt_sha256") != prompt_sha256:
return "sre_chat_sanitized_prompt_mismatch"
correlation_fields = ("trace_id", "run_id", "work_item_id")
if (
receipt.get("schema_version") != "sre_chat_sanitization_receipt_v1"
or receipt.get("status") != "verified"
or receipt.get("sanitizer")
!= "src.services.sanitization_service.sanitize"
or receipt.get("source_label") != "awoooi_sre_group_chat"
or receipt.get("data_classification") != "sanitized"
or receipt.get("raw_input_forwarded") is not False
or receipt.get("raw_log_payload_forwarded") is not False
or receipt.get("secret_value_exposed") is not False
or receipt.get("private_network_value_exposed") is not False
or receipt.get("infrastructure_remediation_write_allowed") is not False
or receipt.get("provider_timeout_seconds") != 35
or context.get("data_classification") != "sanitized"
or context.get("infrastructure_remediation_write_allowed") is not False
or context.get("provider_timeout_seconds") != 35
or context.get("executor_invocation_allowed") is not False
or context.get("agent99_dispatch_allowed") is not False
or context.get("cross_domain_execution_allowed") is not False
or any(
not str(context.get(field) or "")
or receipt.get(field) != context.get(field)
for field in correlation_fields
)
):
return "sre_chat_sanitization_receipt_invalid"
return None
async def _gcp_cloud_input_block_reason(
*,
prompt: str,
context: dict | None,
endpoint_url: str,
receipt_verifier: CloudTransportReceiptVerifier,
) -> str | None:
"""Require sanitizer and independent transport receipts on GCP Ollama."""
if not _is_gcp_alert_lane(endpoint_url):
return None
block_reason = cloud_context_block_reason(context)
if block_reason:
return block_reason
cloud_context = context or {}
provider = _gcp_provider_identity(endpoint_url)
if provider is None:
return "gcp_transport_provider_identity_unresolved"
transport_reason = await receipt_verifier(
context=cloud_context,
provider=provider,
endpoint_url=endpoint_url,
)
if transport_reason:
return transport_reason
prompt_sha256 = hashlib.sha256(prompt.encode("utf-8")).hexdigest()
if cloud_context.get("synthetic_validation") is True:
if (
cloud_context.get("synthetic_prompt_sha256") == prompt_sha256
and cloud_context.get("infrastructure_remediation_write_allowed") is False
):
return None
return "gcp_synthetic_prompt_receipt_invalid"
if "sre_chat_sanitization_receipt" in cloud_context:
chat_block = _sre_chat_sanitization_receipt_block_reason(
prompt=prompt,
context=cloud_context,
)
return f"gcp_{chat_block}" if chat_block else None
receipt = cloud_context.get("cloud_sanitization_receipt")
if not isinstance(receipt, dict):
return "gcp_cloud_sanitization_receipt_missing"
if (
receipt.get("schema_version") != "openclaw_paid_cloud_sanitization_v1"
or receipt.get("status") != "verified"
or receipt.get("raw_payload_forwarded") is not False
or receipt.get("raw_log_payload_forwarded") is not False
or receipt.get("secret_value_exposed") is not False
or receipt.get("private_network_value_exposed") is not False
or cloud_context.get("paid_prompt_sha256") != prompt_sha256
):
return "gcp_cloud_sanitization_receipt_invalid"
return None
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:
"""
GCP-A Ollama Provider
privacy_level: cloud不得接收 require_local/未脫敏資料)
capabilities: rca, chat, code_review
"""
def __init__(
self,
*,
transport_receipt_verifier: CloudTransportReceiptVerifier | None = None,
) -> None:
self._http_client: httpx.AsyncClient | None = None
self._transport_receipt_verifier = (
transport_receipt_verifier or verify_cloud_transport_receipt
)
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 "cloud"
async def analyze(
self,
prompt: str,
context: dict | None = None,
) -> AIResult:
start = time.perf_counter()
try:
registry = get_model_registry()
endpoint_url = self._endpoint_url()
cloud_block_reason = await _gcp_cloud_input_block_reason(
prompt=prompt,
context=context,
endpoint_url=endpoint_url,
receipt_verifier=self._transport_receipt_verifier,
)
if cloud_block_reason:
return AIResult(
raw_response="",
success=False,
provider=self.name,
error=cloud_block_reason,
audit_metadata={
"generation_attempted": False,
"transport_boundary": "public_http_degraded",
},
)
client = await self._get_client()
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
error_code = _safe_ollama_error_code(e)
logger.warning(
"ollama_provider_timeout",
error_code=error_code,
error_type=type(e).__name__,
latency_ms=round(latency, 1),
)
return AIResult(
raw_response="",
success=False,
provider=self.name,
latency_ms=latency,
error=error_code,
)
except Exception as e:
latency = (time.perf_counter() - start) * 1000
error_code = _safe_ollama_error_code(e)
logger.warning(
"ollama_provider_failed",
error_code=error_code,
error_type=type(e).__name__,
latency_ms=round(latency, 1),
)
return AIResult(
raw_response="",
success=False,
provider=self.name,
latency_ms=latency,
error=error_code,
)
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."""
cloud_block_reason = await _gcp_cloud_input_block_reason(
prompt=prompt,
context=context,
endpoint_url=self._endpoint_url(),
receipt_verifier=self._transport_receipt_verifier,
)
if cloud_block_reason:
return AIResult(
raw_response="",
success=False,
provider=self.name,
error=cloud_block_reason,
audit_metadata={"generation_attempted": False},
)
if not available_tools:
return await self.analyze(prompt, context=context)
# GCP-A/B currently cross a public clear-text HTTP boundary. Even when
# the initial prompt has a prompt-bound sanitization receipt, an MCP
# result is new data that has not passed that receipt. A second chat
# iteration would therefore export unverified Kubernetes, database,
# metrics, topology, or log content. Keep cloud tool loops fail closed
# until every tool result has its own structured sanitization receipt or
# the providers move behind an authenticated encrypted mesh. Host111 is
# unaffected because its endpoint is not a GCP alert lane.
if _is_gcp_alert_lane(self._endpoint_url()):
return AIResult(
raw_response="",
success=False,
provider=self.name,
error="gcp_tool_loop_blocked_unverified_tool_results",
audit_metadata={
"generation_attempted": False,
"tool_execution_attempted": False,
"transport_boundary": "public_http_degraded",
},
)
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
error_code = _safe_ollama_error_code(e)
logger.warning(
"ollama_agent_loop_failed",
provider=self.name,
agent_role=agent_role,
error_code=error_code,
error_type=type(e).__name__,
latency_ms=round(latency, 1),
)
return AIResult(
raw_response="",
success=False,
provider=f"{self.name}_agent_loop",
tokens=total_tokens,
latency_ms=latency,
error=error_code,
)
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 作為本地最後防線端點。
Host111 is selected directly; host110/188 must never be provider identities.
"""
@property
def name(self) -> str:
return "ollama_local"
@property
def privacy_level(self) -> str:
"""Host111 is the only Ollama hop permitted for local-only data."""
return "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
error_code = _safe_ollama_error_code(e)
logger.warning(
"ollama_local_provider_timeout",
error_code=error_code,
error_type=type(e).__name__,
latency_ms=round(latency, 1),
)
return AIResult(
raw_response="",
success=False,
provider=self.name,
latency_ms=latency,
error=error_code,
)
except Exception as e:
latency = (time.perf_counter() - start) * 1000
error_code = _safe_ollama_error_code(e)
logger.warning(
"ollama_local_provider_failed",
error_code=error_code,
error_type=type(e).__name__,
latency_ms=round(latency, 1),
)
return AIResult(
raw_response="",
success=False,
provider=self.name,
latency_ms=latency,
error=error_code,
)
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