From a46396ca7f63bd248e4ab65a24533dbfef790898 Mon Sep 17 00:00:00 2001 From: OoO Date: Wed, 20 May 2026 20:10:21 +0800 Subject: [PATCH] =?UTF-8?q?[V10.350]=20=E9=97=9C=E9=96=89=20Gemini=20?= =?UTF-8?q?=E9=A0=90=E8=A8=AD=E5=82=99=E6=8F=B4=E5=87=BA=E7=AB=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + CONSTITUTION.md | 2 + config.py | 5 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 5 +- routes/openclaw_bot_routes.py | 38 +++++++----- services/ai_provider.py | 9 +++ services/code_review_pipeline_service.py | 6 +- services/gemini_guard.py | 33 +++++++++++ services/gemini_service.py | 22 ++++++- services/mcp_collector_service.py | 22 +++++-- services/openclaw_strategist_service.py | 17 ++++-- tests/test_ai_provider_ollama_first.py | 1 + tests/test_code_review_claude_routing.py | 4 ++ tests/test_gemini_fallback_guard.py | 73 ++++++++++++++++++++++++ tests/test_openclaw_qa_routing.py | 5 ++ 15 files changed, 216 insertions(+), 27 deletions(-) create mode 100644 services/gemini_guard.py create mode 100644 tests/test_gemini_fallback_guard.py diff --git a/.env.example b/.env.example index e6531c5..2f325fb 100644 --- a/.env.example +++ b/.env.example @@ -174,6 +174,7 @@ ELEPHANT_ALPHA_OPENCLAW_GEMINI_ENDPOINT=https://generativelanguage.googleapis.co # Gemini 只能作為 Ollama 失敗備援或 ADR-028 鎖定場景,不可設為通用預設 provider # 取得方式:https://aistudio.google.com/app/apikey # 注意:Gemini 2.0 Flash 將於 2026-06-01 關閉,後續需遷移至 2.5 Flash +GEMINI_FALLBACK_ENABLED=false GEMINI_API_KEY= GEMINI_MODEL=gemini-1.5-flash OPENCLAW_MODEL=gemini-2.5-flash-preview-05-20 diff --git a/CONSTITUTION.md b/CONSTITUTION.md index 0dda0f7..89551cc 100644 --- a/CONSTITUTION.md +++ b/CONSTITUTION.md @@ -173,8 +173,10 @@ - ✅ **正確**: 所有 AI Agent、LLM 推理與 embedding 預設必須走 Ollama 三主機級聯:GCP-A `34.143.170.20:11434` → GCP-B `34.21.145.224:11434` → 111 `192.168.0.111:11434`。 - ✅ **正確**: 所有通用文字生成、Q&A 第一響應、Hermes、NemoTron qwen3 路徑、AiderHeal 與 embedding 必須透過 `services/ollama_service.resolve_ollama_host()` 或同等核准 wrapper 取得主機。 - ✅ **正確**: Gemini 只能作為 Ollama 主路徑失敗後的備援,或 ADR-028 明確鎖定的低頻特殊場景。 +- ✅ **正確**: `GEMINI_FALLBACK_ENABLED` 預設必須為 `false`;即使 `GEMINI_API_KEY` 存在,也不得出站呼叫 Gemini,除非操作員明確開啟緊急備援。 - ❌ **禁止**: 將 `AI_PROVIDER`、`OLLAMA_HOST`、`HERMES_URL`、`EMBEDDING_HOST`、`OLLAMA_API_BASE` 指向非 GCP-A / GCP-B / 111 的 Ollama 端點。 - ❌ **禁止**: 新增 Gemini-first 的 AI Agent、LLM caller 或把 Gemini 設為通用預設 provider;新增 Gemini caller 必須走 ADR review。 +- ❌ **禁止**: 繞過 `services.gemini_guard` 直接初始化 Gemini SDK 或直接用 `GEMINI_API_KEY` 打 Google Gemini REST API。 - ❌ **禁止**: 使用 188 主機作為 Ollama 節點;188 只作為 App/DB/容器宿主與 AutoHeal target。 --- diff --git a/config.py b/config.py index cb5269f..e58ae9b 100644 --- a/config.py +++ b/config.py @@ -310,6 +310,9 @@ OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gemma3:4b') # Google Gemini AI 雲端服務 GEMINI_API_KEY = os.getenv('GEMINI_API_KEY', '') GEMINI_MODEL = os.getenv('GEMINI_MODEL', 'gemini-1.5-flash') +# Gemini is fallback-only. Default false means no Gemini API egress unless an +# operator explicitly enables emergency fallback. +GEMINI_FALLBACK_ENABLED = os.getenv('GEMINI_FALLBACK_ENABLED', 'false') # 預設 AI 提供者: 'ollama' (本地免費) 或 'gemini' (雲端付費) AI_PROVIDER = os.getenv('AI_PROVIDER', 'ollama') @@ -320,7 +323,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.348" +SYSTEM_VERSION = "V10.350" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index e1d1b71..a534ab2 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -1,8 +1,8 @@ # MOMO PRO — AI 競價情報模組 Single Source of Truth > **最後更新**: 2026-05-20 (台北時間) -> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 僅備援 -> **適用版本**: V10.327 +> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 備援預設關閉 +> **適用版本**: V10.350 --- @@ -24,6 +24,7 @@ - OpenClaw Telegram 圖片商品辨識也必須 Ollama-first:`_identify_product_name_with_ollama_vision()` 透過 `OllamaService` 嘗試 GCP-A → GCP-B → 111;Gemini 只允許以 `openclaw_bot_image_gemini` caller 作為失敗後備援。 - OpenClaw 週報、月報、Meta analysis、日報洞察、Telegram PPT 分析與 MCP fallback 也必須 Ollama-first;Gemini caller 只能帶 `_gemini_fallback` 或明確 fallback caller 語意,且不得先於 Ollama/NIM 被呼叫。 - OpenClaw 週報、月報、Meta analysis、日報洞察與每日報告的 Gemini/NIM 備援 caller 必須登錄在 caller registry、AI 觀測台 agent group 與 Telegram 狀態統計,避免 fallback 用量被歸類為未知或漏算。 +- Gemini API 出站有第二道 kill switch:`GEMINI_FALLBACK_ENABLED` 預設為 `false`。即使 `GEMINI_API_KEY` 存在,通用 AI fallback、OpenClaw 報告/QA/PPT/圖片、MCP Grounding 與 Code Review L3 都不得呼叫 Gemini;只有操作員明確設為 `true` 時,Gemini 才能作緊急備援。 ## 一、四 AI Agent 路由架構 diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index ebbf676..fc27807 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -52,6 +52,7 @@ from services.openclaw_bot.telegram_api import ( send_typing, ) from services.ai_call_logger import log_ai_call # Operation Ollama-First v5.0 P1 +from services.gemini_guard import get_gemini_api_key, is_gemini_fallback_enabled from services.openclaw_bot.menu_keyboards import ( _BACK, _SUBMENUS, @@ -96,10 +97,8 @@ try: except ImportError: _OLLAMA_AVAILABLE = False -# AI 引擎:Gemini Flash(主,2~5秒)→ NIM(備援,45~90秒) -# LOCKED-GEMINI: PPT 簡報文案需長 context (5K+ rows + 多輪歷史) + 繁中商業敘事 -# Ollama qwen2.5-coder:7b 為 PPT 失敗時 L3 fallback(已在 _call_ollama 路徑) -# ADR-028 鎖定場景 #7 +# AI 引擎:Ollama 三主機級聯 → NIM → Gemini emergency fallback。 +# Gemini fallback default is disabled by GEMINI_FALLBACK_ENABLED=false. GEMINI_API_KEY = os.getenv('GEMINI_API_KEY', '') GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models' GEMINI_MODEL = 'gemini-2.0-flash' @@ -115,6 +114,14 @@ sys_log = SystemLogger("OpenClawBot").get_logger() openclaw_bot_bp = Blueprint('openclaw_bot', __name__) + +def _gemini_fallback_api_key(context: str) -> str: + return get_gemini_api_key(context) + + +def _gemini_fallback_allowed(context: str) -> bool: + return is_gemini_fallback_enabled(context) and bool(_gemini_fallback_api_key(context)) + # ── Telegram retry 去重 (update_id 快取,最多保留 500 筆) ───── BOT_TOKEN = os.getenv('OPENCLAW_BOT_TOKEN') or os.getenv('TELEGRAM_BOT_TOKEN', '') BOT_API_URL = f"https://api.telegram.org/bot{BOT_TOKEN}" @@ -2470,8 +2477,11 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str: max_tokens = 900 def _call_gemini(prompt: str, tokens: int) -> str: + gemini_api_key = _gemini_fallback_api_key('openclaw_ppt_analysis') + if not gemini_api_key: + return '' r = requests.post( - f"{GEMINI_BASE_URL}/{GEMINI_MODEL}:generateContent?key={GEMINI_API_KEY}", + f"{GEMINI_BASE_URL}/{GEMINI_MODEL}:generateContent?key={gemini_api_key}", headers={'Content-Type': 'application/json'}, json={ 'contents': [{'parts': [{'text': prompt}]}], @@ -2526,7 +2536,7 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str: except Exception as e0: sys_log.warning(f"[PPT] Ollama first path error: {e0}") - if not NVIDIA_API_KEY and not GEMINI_API_KEY: + if not NVIDIA_API_KEY and not _gemini_fallback_allowed('openclaw_ppt_analysis'): return '(AI 分析暫不可用,請確認 Ollama / API Key 設定)' # ── NIM fallback(Ollama 失敗後) ──────────── @@ -2566,7 +2576,7 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str: sys_log.warning(f"[PPT] NIM unavailable after Ollama ({type(e).__name__}), fallback Gemini") # ── Gemini final fallback ───────────────────────────────── - if GEMINI_API_KEY: + if _gemini_fallback_allowed('openclaw_ppt_analysis'): try: raw = _call_gemini(f"{sys_instruction}\n\n--- 資料 ---\n{prompt_data}", max_tokens) result_text = _clean_ai_text(raw) @@ -6927,11 +6937,12 @@ def openclaw_answer(question: str, chat_id: int = None): except Exception as e: sys_log.warning(f"[Ollama] 例外發生: {e},fallback 到 Gemini") - if not GEMINI_API_KEY and not NVIDIA_API_KEY: + if not _gemini_fallback_allowed('openclaw_bot_fc') and not NVIDIA_API_KEY: return "(AI 引擎未設定,請確認 API Key 或啟動 Ollama 服務)", None # ── Gemini Function Calling (備援 1) ───────────────────────── - if GEMINI_API_KEY: + gemini_fc_api_key = _gemini_fallback_api_key('openclaw_bot_fc') + if gemini_fc_api_key: try: sys_msg = ( f"你是 OpenClaw(小O),服務「小龍蝦」電商業務團隊的 AI 助理。\n" @@ -6971,7 +6982,7 @@ def openclaw_answer(question: str, chat_id: int = None): meta={'chat_id_hash': hashlib.sha1(str(chat_id or 0).encode()).hexdigest()[:8], 'turn': 1}, ) as _ctx_g1: r1 = requests.post( - f"{GEMINI_BASE_URL}/{GEMINI_MODEL}:generateContent?key={GEMINI_API_KEY}", + f"{GEMINI_BASE_URL}/{GEMINI_MODEL}:generateContent?key={gemini_fc_api_key}", headers={"Content-Type": "application/json"}, json=payload, timeout=30, ) @@ -7038,7 +7049,7 @@ def openclaw_answer(question: str, chat_id: int = None): meta={'chat_id_hash': hashlib.sha1(str(chat_id or 0).encode()).hexdigest()[:8], 'turn': 2, 'tools_used': used_sources}, ) as _ctx_g2: r2 = requests.post( - f"{GEMINI_BASE_URL}/{GEMINI_MODEL}:generateContent?key={GEMINI_API_KEY}", + f"{GEMINI_BASE_URL}/{GEMINI_MODEL}:generateContent?key={gemini_fc_api_key}", headers={"Content-Type": "application/json"}, json=payload2, timeout=35, ) @@ -8820,7 +8831,8 @@ def _identify_product_name_with_ollama_vision(img_b64: str, request_id: str) -> def _identify_product_name_with_gemini_vision(img_b64: str, request_id: str) -> str: """圖片比價的雲端備援:只有 Ollama vision 失敗後才呼叫。""" - if not GEMINI_API_KEY: + gemini_api_key = _gemini_fallback_api_key('openclaw_image_vision') + if not gemini_api_key: return '' vision_payload = { 'contents': [{ @@ -8843,7 +8855,7 @@ def _identify_product_name_with_gemini_vision(img_b64: str, request_id: str) -> ) as ctx: try: vis_r = requests.post( - f"{GEMINI_BASE_URL}/{IMAGE_VISION_GEMINI_MODEL}:generateContent?key={GEMINI_API_KEY}", + f"{GEMINI_BASE_URL}/{IMAGE_VISION_GEMINI_MODEL}:generateContent?key={gemini_api_key}", json=vision_payload, timeout=20, ) vis_r.raise_for_status() diff --git a/services/ai_provider.py b/services/ai_provider.py index 2a767f2..0dc2142 100644 --- a/services/ai_provider.py +++ b/services/ai_provider.py @@ -20,6 +20,7 @@ AI_PROVIDER = os.getenv('AI_PROVIDER', 'ollama') from .ollama_service import OllamaService, OllamaResponse from .gemini_service import GeminiService, GeminiResponse, AVAILABLE_GEMINI_MODELS from .elephant_service import ElephantService, ElephantResponse +from .gemini_guard import gemini_disabled_message, is_gemini_fallback_enabled @dataclass @@ -229,6 +230,14 @@ class AIProviderService: """Ollama 失敗時才呼叫 Gemini,讓通用 AI 入口符合 Ollama-first。""" if ollama_result.success: return ollama_result + if not is_gemini_fallback_enabled("ai_provider"): + disabled = gemini_disabled_message("ai_provider") + logger.warning("Ollama 主路徑失敗,但 %s", disabled) + ollama_result.error = ( + f"{ollama_result.error}; {disabled}" + if ollama_result.error else disabled + ) + return ollama_result logger.warning("Ollama 主路徑失敗,啟用 Gemini 備援:%s", ollama_result.error) try: gemini_response = fallback_call() diff --git a/services/code_review_pipeline_service.py b/services/code_review_pipeline_service.py index 08f23e9..5555144 100644 --- a/services/code_review_pipeline_service.py +++ b/services/code_review_pipeline_service.py @@ -33,6 +33,7 @@ from sqlalchemy import text from services.hermes_analyst_service import HERMES_MODEL as _HERMES_MODEL from services.ai_call_logger import log_ai_call # Operation Ollama-First v5.0 P1 from services.action_plan_dedupe import active_code_review_action_exists +from services.gemini_guard import get_gemini_api_key logger = logging.getLogger(__name__) @@ -592,7 +593,8 @@ class CodeReviewPipeline: ) # ── L3:Gemini — 僅作 Ollama/Claude 都失敗後的備援 ─────────────────── - if GEMINI_API_KEY: + gemini_api_key = get_gemini_api_key("code_review") + if gemini_api_key: with log_ai_call( caller='code_review_openclaw_gemini', provider='gemini', @@ -606,7 +608,7 @@ class CodeReviewPipeline: ) as _ctx: try: import google.generativeai as genai - genai.configure(api_key=GEMINI_API_KEY) + genai.configure(api_key=gemini_api_key) model = genai.GenerativeModel( model_name=REVIEW_MODEL, generation_config=genai.types.GenerationConfig( diff --git a/services/gemini_guard.py b/services/gemini_guard.py new file mode 100644 index 0000000..dd4736b --- /dev/null +++ b/services/gemini_guard.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Central guard for Gemini API egress. + +Gemini is fallback-only in this project. The fallback is disabled by default and +must be explicitly enabled with GEMINI_FALLBACK_ENABLED=true before any code path +may initialize the SDK or call the REST API. +""" + +from __future__ import annotations + +import os + + +_TRUE_VALUES = {"1", "true", "yes", "on"} + + +def is_gemini_fallback_enabled(context: str | None = None) -> bool: + """Return whether Gemini fallback traffic is allowed for this process.""" + return os.getenv("GEMINI_FALLBACK_ENABLED", "false").strip().lower() in _TRUE_VALUES + + +def get_gemini_api_key(context: str | None = None) -> str: + """Return the Gemini API key only when fallback traffic is enabled.""" + if not is_gemini_fallback_enabled(context): + return "" + return os.getenv("GEMINI_API_KEY", "") + + +def gemini_disabled_message(context: str | None = None) -> str: + """Human-readable reason for telemetry and error paths.""" + suffix = f" ({context})" if context else "" + return f"Gemini fallback disabled by GEMINI_FALLBACK_ENABLED=false{suffix}" diff --git a/services/gemini_service.py b/services/gemini_service.py index b8d8f15..c5a932a 100644 --- a/services/gemini_service.py +++ b/services/gemini_service.py @@ -12,6 +12,11 @@ import logging from typing import Optional, Dict, Any, List from dataclasses import dataclass, field from datetime import date +from services.gemini_guard import ( + gemini_disabled_message, + get_gemini_api_key, + is_gemini_fallback_enabled, +) logger = logging.getLogger(__name__) @@ -71,7 +76,7 @@ class GeminiService: api_key: Google API Key(預設從環境變數取得) model: 預設模型(預設 gemini-1.5-flash) """ - self.api_key = api_key or GEMINI_API_KEY + self.api_key = api_key or get_gemini_api_key("gemini_service") self.model = model or DEFAULT_GEMINI_MODEL self._client = None self._initialized = False @@ -81,6 +86,13 @@ class GeminiService: if self._initialized: return True + if not is_gemini_fallback_enabled("gemini_service"): + logger.info(gemini_disabled_message("gemini_service")) + return False + + if not self.api_key: + self.api_key = get_gemini_api_key("gemini_service") + if not self.api_key: logger.warning("Gemini API Key 未設定") return False @@ -189,6 +201,14 @@ class GeminiService: model_name = model or self.model request_timeout = timeout or GEMINI_TIMEOUT + if not is_gemini_fallback_enabled("gemini_service.generate"): + return GeminiResponse( + success=False, + content='', + model=model_name, + error=gemini_disabled_message("gemini_service.generate") + ) + if not self._ensure_initialized(): return GeminiResponse( success=False, diff --git a/services/mcp_collector_service.py b/services/mcp_collector_service.py index e510620..df1ac71 100644 --- a/services/mcp_collector_service.py +++ b/services/mcp_collector_service.py @@ -24,6 +24,11 @@ from typing import Any, Dict, List, Optional from database.manager import get_session from sqlalchemy import text +from services.gemini_guard import ( + gemini_disabled_message, + get_gemini_api_key, + is_gemini_fallback_enabled, +) logger = logging.getLogger(__name__) @@ -66,7 +71,7 @@ _SEARCH_TOPICS = { class MCPCollectorService: """ 外部情報收集服務(MCP 節點) - 使用 Gemini Search Grounding 抓取即時市場資訊 + 先用 MCP / Ollama;Gemini Search Grounding 僅作顯式開啟後的緊急備援。 """ def __init__(self): @@ -76,12 +81,16 @@ class MCPCollectorService: def _ensure_init(self) -> bool: if self._initialized: return True - if not GEMINI_API_KEY: + if not is_gemini_fallback_enabled("mcp_collector"): + logger.info("[MCP] %s", gemini_disabled_message("mcp_collector")) + return False + gemini_api_key = get_gemini_api_key("mcp_collector") + if not gemini_api_key: logger.warning("[MCP] GEMINI_API_KEY 未設定,跳過外部情報收集") return False try: import google.generativeai as genai - genai.configure(api_key=GEMINI_API_KEY) + genai.configure(api_key=gemini_api_key) self._genai = genai self._initialized = True return True @@ -211,7 +220,12 @@ class MCPCollectorService: return ollama_content if not self._ensure_init(): - return self._fallback_topic_content(topic, "GEMINI_API_KEY 未設定,使用本地行銷情報。") + reason = ( + gemini_disabled_message("mcp_collector") + if not is_gemini_fallback_enabled("mcp_collector") + else "GEMINI_API_KEY 未設定,使用本地行銷情報。" + ) + return self._fallback_topic_content(topic, reason) try: prompt = f"請用繁體中文整理以下主題的最新資訊,提供具體數據與洞察,500字以內:\n{query}" diff --git a/services/openclaw_strategist_service.py b/services/openclaw_strategist_service.py index 84fa2f9..8f2551b 100644 --- a/services/openclaw_strategist_service.py +++ b/services/openclaw_strategist_service.py @@ -33,6 +33,11 @@ from database.manager import get_session from sqlalchemy import bindparam, inspect, text from services.ai_call_logger import log_ai_call # Operation Ollama-First v5.0 P1 +from services.gemini_guard import ( + gemini_disabled_message, + get_gemini_api_key, + is_gemini_fallback_enabled, +) from services.action_plan_dedupe import ( active_openclaw_recommendation_exists, openclaw_action_metadata, @@ -231,7 +236,7 @@ def _legacy_gemini_first_qa( # 優先 Gemini;無 key 或失敗時自動備援 NVIDIA NIM text_reply = None - if GEMINI_API_KEY: + if GEMINI_API_KEY and is_gemini_fallback_enabled("openclaw_qa"): try: text_reply = _call_gemini( system_prompt, @@ -1016,7 +1021,11 @@ def _call_gemini( (openclaw_daily / openclaw_weekly / openclaw_monthly / openclaw_meta / openclaw_qa_gemini_fallback) """ - if not GEMINI_API_KEY: + if not is_gemini_fallback_enabled("openclaw_strategy"): + logger.info("[OpenClaw] %s", gemini_disabled_message("openclaw_strategy")) + return None + gemini_api_key = get_gemini_api_key("openclaw_strategy") + if not gemini_api_key: logger.warning("[OpenClaw] GEMINI_API_KEY 未設定") return None with log_ai_call( @@ -1027,7 +1036,7 @@ def _call_gemini( ) as _ctx: try: import google.generativeai as genai - genai.configure(api_key=GEMINI_API_KEY) + genai.configure(api_key=gemini_api_key) model = genai.GenerativeModel( model_name=STRATEGY_MODEL, generation_config=genai.types.GenerationConfig( @@ -1146,7 +1155,7 @@ def _call_openclaw_llm_ollama_first( if text_out: return text_out - if GEMINI_API_KEY: + if GEMINI_API_KEY and is_gemini_fallback_enabled("openclaw_strategy"): text_out = _call_gemini( system_prompt, user_prompt, diff --git a/tests/test_ai_provider_ollama_first.py b/tests/test_ai_provider_ollama_first.py index 5777ca4..58ce209 100644 --- a/tests/test_ai_provider_ollama_first.py +++ b/tests/test_ai_provider_ollama_first.py @@ -35,6 +35,7 @@ def test_requested_gemini_still_uses_ollama_first(monkeypatch): def test_gemini_is_called_only_after_ollama_failure(monkeypatch): + monkeypatch.setenv("GEMINI_FALLBACK_ENABLED", "true") service = AIProviderService(default_provider="ollama") monkeypatch.setattr( diff --git a/tests/test_code_review_claude_routing.py b/tests/test_code_review_claude_routing.py index a5a2eaf..88e80da 100644 --- a/tests/test_code_review_claude_routing.py +++ b/tests/test_code_review_claude_routing.py @@ -414,6 +414,7 @@ def test_ollama_failure_flag_true_uses_claude_backup(monkeypatch): def test_ollama_failure_flag_false_uses_gemini_backup(monkeypatch): """Ollama 失敗且 Claude flag=false → Gemini 才作備援""" monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'false') + monkeypatch.setenv('GEMINI_FALLBACK_ENABLED', 'true') monkeypatch.setenv('GEMINI_API_KEY', 'test-key') _stub_logger(monkeypatch) @@ -437,6 +438,7 @@ def test_ollama_failure_flag_false_uses_gemini_backup(monkeypatch): def test_gemini_backup_uses_dedicated_caller_in_telemetry(monkeypatch): """Ollama 失敗後的 Gemini 必須記為 code_review_openclaw_gemini。""" monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'false') + monkeypatch.setenv('GEMINI_FALLBACK_ENABLED', 'true') monkeypatch.setenv('GEMINI_API_KEY', 'test-key') captured = _capture_ai_call_states(monkeypatch) @@ -477,6 +479,7 @@ def test_gemini_backup_uses_dedicated_caller_in_telemetry(monkeypatch): def test_ollama_failure_claude_unavailable_uses_gemini_backup(monkeypatch): """Ollama 失敗 + flag=true 但 Claude unavailable → Gemini 才作備援""" monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'true') + monkeypatch.setenv('GEMINI_FALLBACK_ENABLED', 'true') monkeypatch.setenv('GEMINI_API_KEY', 'test-key') _stub_logger(monkeypatch) @@ -500,6 +503,7 @@ def test_ollama_failure_claude_unavailable_uses_gemini_backup(monkeypatch): def test_full_fallback_chain_after_ollama_failure(monkeypatch): """Ollama 失敗 + Claude 失敗 + Gemini 失敗 → 最終 Elephant 接手""" monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'true') + monkeypatch.setenv('GEMINI_FALLBACK_ENABLED', 'true') monkeypatch.setenv('GEMINI_API_KEY', 'test-key') _stub_logger(monkeypatch) diff --git a/tests/test_gemini_fallback_guard.py b/tests/test_gemini_fallback_guard.py new file mode 100644 index 0000000..459ad1f --- /dev/null +++ b/tests/test_gemini_fallback_guard.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Gemini fallback kill-switch contract.""" + +from services.ai_provider import AIProviderService, AIResponse +from services.gemini_service import GeminiService + + +def test_gemini_guard_defaults_disabled(monkeypatch): + from services.gemini_guard import get_gemini_api_key, is_gemini_fallback_enabled + + monkeypatch.delenv("GEMINI_FALLBACK_ENABLED", raising=False) + monkeypatch.setenv("GEMINI_API_KEY", "test-key") + + assert is_gemini_fallback_enabled("test") is False + assert get_gemini_api_key("test") == "" + + +def test_ai_provider_does_not_call_gemini_when_guard_disabled(monkeypatch): + monkeypatch.delenv("GEMINI_FALLBACK_ENABLED", raising=False) + service = AIProviderService(default_provider="ollama") + failed_ollama = AIResponse( + success=False, + content="", + model="qwen2.5-coder:7b", + provider="ollama", + error="ollama down", + ) + + def forbidden_fallback(): + raise AssertionError("Gemini fallback must be blocked by default") + + result = service._gemini_fallback(failed_ollama, forbidden_fallback) + + assert result.success is False + assert result.provider == "ollama" + assert "GEMINI_FALLBACK_ENABLED=false" in (result.error or "") + + +def test_gemini_service_check_connection_is_blocked_by_default(monkeypatch): + monkeypatch.delenv("GEMINI_FALLBACK_ENABLED", raising=False) + monkeypatch.setenv("GEMINI_API_KEY", "test-key") + service = GeminiService() + + def forbidden_init(): + raise AssertionError("SDK initialization must not run when fallback is disabled") + + monkeypatch.setattr(service, "_ensure_initialized", forbidden_init) + + result = service.generate("hello") + + assert result.success is False + assert "GEMINI_FALLBACK_ENABLED=false" in (result.error or "") + + +def test_mcp_collector_does_not_initialize_gemini_when_guard_disabled(monkeypatch): + import services.mcp_collector_service as mcp_mod + + monkeypatch.delenv("GEMINI_FALLBACK_ENABLED", raising=False) + monkeypatch.setattr(mcp_mod, "GEMINI_API_KEY", "test-key") + service = mcp_mod.MCPCollectorService() + + assert service._ensure_init() is False + assert service._genai is None + + +def test_openclaw_direct_gemini_call_is_blocked_by_default(monkeypatch): + import services.openclaw_strategist_service as svc + + monkeypatch.delenv("GEMINI_FALLBACK_ENABLED", raising=False) + monkeypatch.setattr(svc, "GEMINI_API_KEY", "test-key") + + assert svc._call_gemini("system", "user", caller="openclaw_qa_gemini_fallback") is None diff --git a/tests/test_openclaw_qa_routing.py b/tests/test_openclaw_qa_routing.py index 3fe87a5..202c1b0 100644 --- a/tests/test_openclaw_qa_routing.py +++ b/tests/test_openclaw_qa_routing.py @@ -275,6 +275,7 @@ class TestOpenClawReportRouting: assert calls == [("ollama", "openclaw_weekly")] def test_report_llm_gemini_is_suffix_fallback_only(self, monkeypatch): + monkeypatch.setenv("GEMINI_FALLBACK_ENABLED", "true") calls = [] monkeypatch.setattr(svc, "_call_ollama_strategy", lambda *a, **kw: None) @@ -495,6 +496,8 @@ class TestLegacyFallbackTelemetry: def test_gemini_backup_uses_dedicated_caller(self, monkeypatch, reset_state): """Ollama 後的 Gemini 備援應記 openclaw_qa_gemini_fallback,不污染 openclaw_qa。""" captured = reset_state + monkeypatch.setenv('GEMINI_FALLBACK_ENABLED', 'true') + monkeypatch.setenv('GEMINI_API_KEY', 'test-gemini-key') monkeypatch.setattr(svc, 'GEMINI_API_KEY', 'test-gemini-key') monkeypatch.setattr(svc, 'NVIDIA_API_KEY', '') _stub_gemini(monkeypatch, text="Gemini 備援:請先檢查近七日業績與競品價差。") @@ -517,6 +520,8 @@ class TestLegacyFallbackTelemetry: def test_gemini_backup_failure_falls_to_standard_nim_caller(self, monkeypatch, reset_state): """Gemini 備援失敗後,NIM 應記 openclaw_qa_nim,而非 fallback_fallback_nim。""" captured = reset_state + monkeypatch.setenv('GEMINI_FALLBACK_ENABLED', 'true') + monkeypatch.setenv('GEMINI_API_KEY', 'test-gemini-key') monkeypatch.setattr(svc, 'GEMINI_API_KEY', 'test-gemini-key') monkeypatch.setattr(svc, 'NVIDIA_API_KEY', 'test-nim-key') _stub_gemini(monkeypatch, raise_error=True)