269 lines
9.1 KiB
Python
269 lines
9.1 KiB
Python
"""
|
||
AI Provider 版本探測 — 為每個 Provider 提供 get_version()
|
||
|
||
每個 probe 函數獨立運作,失敗只影響該 provider,不 crash 整批。
|
||
|
||
Provider:
|
||
- ollama : 34.143.170.20 GCP-A Ollama (primary) — 2026-05-03 ogt: ADR-110 GCP-A Primary
|
||
- ollama_local : 192.168.0.111 / 110 proxy Ollama (local fallback)
|
||
- gemini : Google Gemini API (版本 = model name)
|
||
- claude : Anthropic Claude (版本 = model name)
|
||
- openclaw_nemo : OpenClaw NemoTron (版本 = OPENCLAW_DEFAULT_MODEL)
|
||
|
||
# 2026-04-27 P3.2.1 by Claude
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
import structlog
|
||
|
||
from src.core.config import settings
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||
|
||
|
||
@dataclass
|
||
class ProviderVersionInfo:
|
||
"""AI Provider 版本快照"""
|
||
|
||
provider: str # "ollama" / "ollama_local" / "gemini" / "claude" / "openclaw_nemo"
|
||
model: str
|
||
version: str # version string 或 tag(Ollama 用 modified_at,其他用 model name)
|
||
digest: str | None = None # SHA256 digest(僅 Ollama 有)
|
||
captured_at: datetime = field(default_factory=lambda: datetime.now(TAIPEI_TZ))
|
||
|
||
|
||
# =============================================================================
|
||
# Ollama Probe
|
||
# =============================================================================
|
||
|
||
async def probe_ollama_version(url: str, model: str) -> ProviderVersionInfo:
|
||
"""探測 Ollama(GCP-A/GCP-B 或本地 111):GET /api/tags 取 model digest + modified_at
|
||
|
||
Args:
|
||
url: Ollama base URL,例如 "http://34.143.170.20:11434"(GCP-A Primary)
|
||
model: model name,例如 "qwen2.5:7b-instruct"
|
||
|
||
Returns:
|
||
ProviderVersionInfo — provider 依 URL 自動判斷(GCP-A=ollama, 本地111=ollama_local, 否則=ollama_remote)
|
||
|
||
Raises:
|
||
ValueError: model 不在清單
|
||
httpx.HTTPError: 連線失敗
|
||
"""
|
||
import httpx
|
||
|
||
# 2026-05-06 Codex: 188 不再作為 Ollama provider;local fallback 一律標示 ollama_local。
|
||
_GCP_OLLAMA_IPS = {"34.143.170.20", "34.21.145.224"}
|
||
if any(ip in url for ip in _GCP_OLLAMA_IPS):
|
||
provider_name = "ollama"
|
||
elif "192.168.0.111" in url or "192.168.0.110:11437" in url:
|
||
provider_name = "ollama_local"
|
||
else:
|
||
provider_name = "ollama_remote"
|
||
|
||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||
resp = await client.get(f"{url}/api/tags")
|
||
resp.raise_for_status()
|
||
models = resp.json().get("models", [])
|
||
|
||
for m in models:
|
||
if m.get("name") == model:
|
||
return ProviderVersionInfo(
|
||
provider=provider_name,
|
||
model=model,
|
||
version=m.get("modified_at", ""),
|
||
digest=m.get("digest"),
|
||
)
|
||
|
||
raise ValueError(f"Model {model!r} not found at {url}; available: {[m.get('name') for m in models]}")
|
||
|
||
|
||
# =============================================================================
|
||
# Gemini Probe
|
||
# =============================================================================
|
||
|
||
async def probe_gemini_version() -> ProviderVersionInfo:
|
||
"""探測 Gemini:以設定的 model name 作為版本字串
|
||
|
||
Gemini model name 本身即版本識別碼(e.g. "gemini-1.5-flash"),
|
||
不需要額外 API 呼叫。若 GEMINI_API_KEY 存在則視為可用。
|
||
|
||
Returns:
|
||
ProviderVersionInfo — version = model name (e.g. "gemini-1.5-flash")
|
||
|
||
Raises:
|
||
RuntimeError: GEMINI_API_KEY 未設定
|
||
"""
|
||
api_key = settings.GEMINI_API_KEY
|
||
if not api_key:
|
||
raise RuntimeError("GEMINI_API_KEY not configured")
|
||
|
||
# Gemini 以 AI_FALLBACK_ORDER 中 "gemini" 的設定決定 model
|
||
# 實際 model name 在 ai_router 層,此處以已知預設值作為版本
|
||
# 透過 list models API 取得最新版本資訊
|
||
import httpx
|
||
|
||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||
resp = await client.get(
|
||
"https://generativelanguage.googleapis.com/v1beta/models",
|
||
params={"pageSize": 50},
|
||
headers={"x-goog-api-key": api_key},
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
|
||
# 找第一個 GENERATE_CONTENT 功能的 gemini 模型版本
|
||
models = data.get("models", [])
|
||
gemini_model = None
|
||
for m in models:
|
||
name = m.get("name", "")
|
||
if "gemini" in name and "generateContent" in m.get("supportedGenerationMethods", []):
|
||
gemini_model = name.replace("models/", "")
|
||
break
|
||
|
||
if not gemini_model:
|
||
gemini_model = "gemini-unknown"
|
||
|
||
return ProviderVersionInfo(
|
||
provider="gemini",
|
||
model=gemini_model,
|
||
version=gemini_model,
|
||
digest=None,
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# Claude Probe
|
||
# =============================================================================
|
||
|
||
async def probe_claude_version() -> ProviderVersionInfo:
|
||
"""Claude:model name 即版本識別(例如 "claude-haiku-4-5-20251001")
|
||
|
||
Anthropic 沒有 list models endpoint(截至 2026-04),
|
||
以設定中的 claude model name 作為版本字串。
|
||
若 CLAUDE_API_KEY 存在則視為可用。
|
||
|
||
Returns:
|
||
ProviderVersionInfo — version = model name(來自設定或預設)
|
||
|
||
Raises:
|
||
RuntimeError: CLAUDE_API_KEY 未設定
|
||
"""
|
||
api_key = settings.CLAUDE_API_KEY
|
||
if not api_key:
|
||
raise RuntimeError("CLAUDE_API_KEY not configured")
|
||
|
||
model_name = "claude-haiku-4-5-20251001"
|
||
|
||
return ProviderVersionInfo(
|
||
provider="claude",
|
||
model=model_name,
|
||
version=model_name,
|
||
digest=None,
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# OpenClaw NemoTron Probe
|
||
# =============================================================================
|
||
|
||
async def probe_openclaw_nemo_version() -> ProviderVersionInfo:
|
||
"""OpenClaw NemoTron:版本字串從 settings.OPENCLAW_DEFAULT_MODEL 讀取
|
||
|
||
NemoTron 運行在 OpenClaw 節點,
|
||
透過 OPENCLAW_URL /api/tags 探測,模型名稱即版本識別。
|
||
|
||
Returns:
|
||
ProviderVersionInfo — version = model tag (e.g. "deepseek-r1:14b")
|
||
|
||
Raises:
|
||
RuntimeError: OPENCLAW_DEFAULT_MODEL 未設定
|
||
httpx.HTTPError: 連線失敗
|
||
"""
|
||
model = settings.OPENCLAW_DEFAULT_MODEL
|
||
if not model:
|
||
raise RuntimeError("OPENCLAW_DEFAULT_MODEL not configured")
|
||
|
||
# OpenClaw 底層是 Ollama,使用 OPENCLAW_URL 的 host:port 加上 Ollama port
|
||
# OPENCLAW_URL 是 8088(OpenClaw API),Ollama 通常在 11434
|
||
# OpenClaw 底層 tags 來源優先使用本地 fallback Ollama URL。
|
||
ollama_local_url = settings.OLLAMA_FALLBACK_URL
|
||
if not ollama_local_url:
|
||
# fallback:從 OPENCLAW_URL host 構建 Ollama URL
|
||
from urllib.parse import urlparse
|
||
parsed = urlparse(settings.OPENCLAW_URL)
|
||
ollama_local_url = f"{parsed.scheme}://{parsed.hostname}:11434"
|
||
|
||
import httpx
|
||
|
||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||
resp = await client.get(f"{ollama_local_url}/api/tags")
|
||
resp.raise_for_status()
|
||
models = resp.json().get("models", [])
|
||
|
||
for m in models:
|
||
if m.get("name") == model:
|
||
return ProviderVersionInfo(
|
||
provider="openclaw_nemo",
|
||
model=model,
|
||
version=m.get("modified_at", model),
|
||
digest=m.get("digest"),
|
||
)
|
||
|
||
# model 不在清單時:version 用 model name,digest=None
|
||
logger.warning("openclaw_nemo_model_not_in_tags", model=model, url=ollama_local_url)
|
||
return ProviderVersionInfo(
|
||
provider="openclaw_nemo",
|
||
model=model,
|
||
version=model,
|
||
digest=None,
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# Probe All
|
||
# =============================================================================
|
||
|
||
async def probe_all_providers() -> list[ProviderVersionInfo]:
|
||
"""並行探測所有 5 個 AI Provider,失敗的 provider 以 exception 跳過
|
||
|
||
Returns:
|
||
成功探測的 ProviderVersionInfo 列表(長度 0~5)
|
||
|
||
Notes:
|
||
- 使用 return_exceptions=True 確保任一 provider 失敗不影響其他
|
||
- 每個 exception 都有對應的 log warning
|
||
"""
|
||
tasks = [
|
||
probe_ollama_version(settings.OLLAMA_URL, settings.OLLAMA_HEALTH_CHECK_MODEL),
|
||
probe_ollama_version(
|
||
settings.OLLAMA_FALLBACK_URL or settings.OLLAMA_URL,
|
||
settings.OLLAMA_HEALTH_CHECK_MODEL,
|
||
),
|
||
probe_gemini_version(),
|
||
probe_claude_version(),
|
||
probe_openclaw_nemo_version(),
|
||
]
|
||
|
||
raw = await asyncio.gather(*tasks, return_exceptions=True)
|
||
|
||
results: list[ProviderVersionInfo] = []
|
||
provider_labels = ["ollama", "ollama_local", "gemini", "claude", "openclaw_nemo"]
|
||
for label, outcome in zip(provider_labels, raw, strict=True):
|
||
if isinstance(outcome, ProviderVersionInfo):
|
||
results.append(outcome)
|
||
else:
|
||
logger.warning(
|
||
"provider_probe_failed",
|
||
provider=label,
|
||
error=str(outcome),
|
||
)
|
||
|
||
return results
|