""" 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