742 lines
27 KiB
Python
742 lines
27 KiB
Python
"""
|
||
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 timeout(Option 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_URL(34.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
|