diff --git a/.env.example b/.env.example index 08dc15e..1bd557b 100644 --- a/.env.example +++ b/.env.example @@ -174,7 +174,10 @@ 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_API_KEY 存在、fallback flag 被誤開,也不會出站產生費用 +GEMINI_API_HARD_DISABLED=true GEMINI_FALLBACK_ENABLED=false +GEMINI_ALLOWED_CONTEXTS= GEMINI_API_KEY= GEMINI_MODEL=gemini-1.5-flash OPENCLAW_MODEL=gemini-2.5-flash-preview-05-20 diff --git a/AGENTS.md b/AGENTS.md index e885f5f..20534ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,7 +129,7 @@ - `gunicorn.conf.py` 必須透過 `docker-compose.yml` bind mount 進 `momo-app`;除救急外,不以 `docker cp` 當常態部署方式。 - CD rebuild 應先完成 image build,再短暫 recreate 三應用容器;禁止把 no-cache build 時間變成長時間 502。 - HTTP health / Blackbox / CD 探測必須打 `/health`,不可打 Dashboard 首頁 `/`,避免監控流量觸發重型查詢造成 worker starvation。 -- 所有 AI Agent / LLM / embedding 呼叫必須 Ollama-first,且只允許 GCP-A `34.143.170.20:11434` → GCP-B `34.21.145.224:11434` → 111 `192.168.0.111:11434` 三主機級聯;Gemini 只能作為備援或 ADR-028 鎖定場景,188 不可作為 Ollama 節點。 +- 所有 AI Agent / LLM / embedding 呼叫必須 Ollama-first,且只允許 GCP-A `34.143.170.20:11434` → GCP-B `34.21.145.224:11434` → 111 `192.168.0.111:11434` 三主機級聯;Gemini 只能作為備援或 ADR-028 鎖定場景,且預設由 `GEMINI_API_HARD_DISABLED=true` 硬封鎖,188 不可作為 Ollama 節點。 ## 8. 常用入口 diff --git a/CONSTITUTION.md b/CONSTITUTION.md index 89551cc..8c0598a 100644 --- a/CONSTITUTION.md +++ b/CONSTITUTION.md @@ -173,7 +173,7 @@ - ✅ **正確**: 所有 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,除非操作員明確開啟緊急備援。 +- ✅ **正確**: `GEMINI_API_HARD_DISABLED` 預設必須為 `true`,`GEMINI_FALLBACK_ENABLED` 預設必須為 `false`;即使 `GEMINI_API_KEY` 存在,也不得出站呼叫 Gemini,除非操作員明確解除 hard switch 並開啟緊急備援。 - ❌ **禁止**: 將 `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。 diff --git a/config.py b/config.py index 6711274..79974c0 100644 --- a/config.py +++ b/config.py @@ -310,9 +310,11 @@ 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 is emergency fallback only. The hard kill switch defaults to true, so +# GEMINI_API_KEY/GEMINI_FALLBACK_ENABLED cannot create paid egress by accident. +GEMINI_API_HARD_DISABLED = os.getenv('GEMINI_API_HARD_DISABLED', 'true') GEMINI_FALLBACK_ENABLED = os.getenv('GEMINI_FALLBACK_ENABLED', 'false') +GEMINI_ALLOWED_CONTEXTS = os.getenv('GEMINI_ALLOWED_CONTEXTS', '') # 預設 AI 提供者: 'ollama' (本地免費) 或 'gemini' (雲端付費) AI_PROVIDER = os.getenv('AI_PROVIDER', 'ollama') @@ -323,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.366" +SYSTEM_VERSION = "V10.367" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 40e5097..a4475e5 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-21:瀏覽器測試守門與 PChome 熱路徑優化 +- **V10.367 Gemini hard egress kill switch**: 新增 `GEMINI_API_HARD_DISABLED=true` 預設硬封鎖,中央 `services.gemini_guard` 會在 hard switch 未解鎖時拒絕 `GEMINI_API_KEY`,即使 `GEMINI_FALLBACK_ENABLED=true` 也不會初始化 SDK 或 REST 出站。Code Review/OpenClaw/MCP/通用 AI fallback 保留 emergency path,但必須同時設 `GEMINI_API_HARD_DISABLED=false` 與 `GEMINI_FALLBACK_ENABLED=true`,必要時再用 `GEMINI_ALLOWED_CONTEXTS` 限定 caller。 - **V10.366 MCP runtime smoke receipt review**: 新增 `mcp_runtime_smoke_receipt` read-only builder、GET/POST endpoint、UI receipt JSON 審核面板與 deployment readiness smoke target,讓操作員貼上 `/api/market_intel/mcp_readiness?execute=true&timeout=3` 的實際收據後,判斷 external/internal MCP runtime 是否可升級為已驗收。 - **V10.366 只讀安全邊界**: 本階段不保存 payload、不打 health、不開 DB、不抓外站、不掛 scheduler;若收據含 DB write/commit/scheduler/writes 旗標或原始 readiness blocked reasons,會直接阻擋。 - **V10.365 專業比價分級連動**: MOMO/PChome matcher 新增 `match_type`、`price_basis`、`alert_tier` 與 evidence flags,將「高信心同款 / 同商品不同包裝 / 同系列不同款 / 可比但需覆核 / 非同款」寫入 diagnostics 與 tags;feeder、競價情報 repository、Hermes payload、NemoTron 派發與 Telegram 告警格式同步讀取同一份分級。NemoTron 也新增硬閘門:非 `exact + total_price + price_alert_exact` 的項目即使模型回傳 price alert,也會改走人工覆核,避免不同包裝或同系列不同款被直接建議降價。 diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index fc27807..24da5e9 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -98,8 +98,7 @@ except ImportError: _OLLAMA_AVAILABLE = False # 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 fallback default is hard-disabled by GEMINI_API_HARD_DISABLED=true. GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models' GEMINI_MODEL = 'gemini-2.0-flash' IMAGE_VISION_OLLAMA_MODEL = os.getenv( diff --git a/services/code_review_pipeline_service.py b/services/code_review_pipeline_service.py index 17d1377..6048b3f 100644 --- a/services/code_review_pipeline_service.py +++ b/services/code_review_pipeline_service.py @@ -33,7 +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 +from services.gemini_guard import gemini_disabled_message, get_gemini_api_key logger = logging.getLogger(__name__) @@ -41,7 +41,6 @@ logger = logging.getLogger(__name__) _current_pipeline: Dict[str, Any] = {} _pipeline_lock = threading.Lock() -GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "") # Gemini 僅作 Code Review Ollama/Claude 主路徑都失敗後的最後雲端備援。 REVIEW_MODEL = os.getenv("OPENCLAW_MODEL", "gemini-2.5-flash") CODE_REVIEW_OLLAMA_MODEL = os.getenv( @@ -440,7 +439,7 @@ class CodeReviewPipeline: 路由優先序: L1 (預設) → Ollama GCP-A → GCP-B → 111 L2 (flag CODE_REVIEW_USE_CLAUDE=true) → Claude Opus 4.7 雲端備援 - L3 (GEMINI_API_KEY 存在) → Gemini 雲端備援 + L3 (Gemini guard 顯式解鎖) → Gemini 雲端備援 L4 (降級) → ElephantAlpha via NIM/OpenRouter """ sev = self.state["severity_summary"] @@ -475,10 +474,9 @@ class CodeReviewPipeline: get_provider_tag, ) - fallback_caller = ( - 'code_review_openclaw' - if CODE_REVIEW_USE_CLAUDE - else 'code_review_openclaw_gemini' + gemini_api_key = get_gemini_api_key("code_review") + fallback_caller = 'code_review_openclaw' if CODE_REVIEW_USE_CLAUDE else ( + 'code_review_openclaw_gemini' if gemini_api_key else 'code_review_elephant' ) ollama_attempts = [ ( @@ -594,11 +592,10 @@ class CodeReviewPipeline: ) else: logger.info( - "[CodeReview] CODE_REVIEW_USE_CLAUDE=true 但 Claude 不可用(缺 API key 或 SDK),改走 Gemini 備援", + "[CodeReview] CODE_REVIEW_USE_CLAUDE=true 但 Claude 不可用(缺 API key 或 SDK),改走下一層備援", ) # ── L3:Gemini — 僅作 Ollama/Claude 都失敗後的備援 ─────────────────── - gemini_api_key = get_gemini_api_key("code_review") if gemini_api_key: with log_ai_call( caller='code_review_openclaw_gemini', @@ -636,6 +633,8 @@ class CodeReviewPipeline: logger.warning("[CodeReview] OpenClaw Gemini 失敗,降級 ElephantAlpha: %s", e) _ctx.set_error(f"{type(e).__name__}: {e}") _ctx.fallback_to_caller('code_review_elephant') + else: + logger.info("[CodeReview] 跳過 Gemini 備援:%s", gemini_disabled_message("code_review")) # 降級:ElephantAlpha via OpenRouter(OPENROUTER_API_KEY 容器內一定有) # Phase 1 v5.0 logger 追蹤 diff --git a/services/gemini_guard.py b/services/gemini_guard.py index dd4736b..9905324 100644 --- a/services/gemini_guard.py +++ b/services/gemini_guard.py @@ -2,9 +2,10 @@ # -*- 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. +Gemini is emergency fallback only in this project. A plain API key must never be +enough to spend money: traffic is blocked by default with a hard kill switch, and +operators must explicitly unlock emergency fallback before any code path may +initialize the SDK or call the REST API. """ from __future__ import annotations @@ -13,11 +14,41 @@ import os _TRUE_VALUES = {"1", "true", "yes", "on"} +_FALSE_VALUES = {"0", "false", "no", "off"} + + +def _env_flag(name: str, default: str) -> bool: + value = os.getenv(name, default).strip().lower() + if value in _TRUE_VALUES: + return True + if value in _FALSE_VALUES: + return False + return default.strip().lower() in _TRUE_VALUES + + +def is_gemini_hard_disabled() -> bool: + """Master kill switch. Default true means zero Gemini API egress.""" + return _env_flag("GEMINI_API_HARD_DISABLED", "true") + + +def _context_allowed(context: str | None) -> bool: + """Optional allowlist for emergency fallback contexts.""" + raw = os.getenv("GEMINI_ALLOWED_CONTEXTS", "").strip() + if not raw: + return True + if not context: + return False + allowed = {item.strip() for item in raw.split(",") if item.strip()} + return context in allowed 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 + if is_gemini_hard_disabled(): + return False + if not _env_flag("GEMINI_FALLBACK_ENABLED", "false"): + return False + return _context_allowed(context) def get_gemini_api_key(context: str | None = None) -> str: @@ -30,4 +61,10 @@ def get_gemini_api_key(context: str | None = None) -> str: 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}" + if is_gemini_hard_disabled(): + return f"Gemini API hard-disabled by GEMINI_API_HARD_DISABLED=true{suffix}" + if not _env_flag("GEMINI_FALLBACK_ENABLED", "false"): + return f"Gemini fallback disabled by GEMINI_FALLBACK_ENABLED=false{suffix}" + if not _context_allowed(context): + return f"Gemini fallback context not allowed by GEMINI_ALLOWED_CONTEXTS{suffix}" + return f"Gemini fallback disabled{suffix}" diff --git a/services/gemini_service.py b/services/gemini_service.py index c5a932a..978d434 100644 --- a/services/gemini_service.py +++ b/services/gemini_service.py @@ -20,8 +20,7 @@ from services.gemini_guard import ( logger = logging.getLogger(__name__) -# Gemini 設定 - 支援環境變數覆蓋 -GEMINI_API_KEY = os.getenv('GEMINI_API_KEY', '') +# Gemini 設定 - 支援環境變數覆蓋;API key 一律透過 gemini_guard 取得 DEFAULT_GEMINI_MODEL = os.getenv('GEMINI_MODEL', 'gemini-1.5-flash') GEMINI_TIMEOUT = int(os.getenv('GEMINI_TIMEOUT', '60')) # 秒 diff --git a/services/mcp_collector_service.py b/services/mcp_collector_service.py index df1ac71..301eed6 100644 --- a/services/mcp_collector_service.py +++ b/services/mcp_collector_service.py @@ -32,7 +32,6 @@ from services.gemini_guard import ( logger = logging.getLogger(__name__) -GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "") MCP_CACHE_TTL_HOURS = int(os.getenv("MCP_CACHE_TTL_HOURS", "24")) # MCP router 是即時情報主路徑;router 不可用時先走 Ollama 三主機級聯做離線洞察, # Gemini Grounding 僅作最後備援,避免再次回到 Gemini-first。 diff --git a/services/openclaw_strategist_service.py b/services/openclaw_strategist_service.py index 83f3878..35a93c1 100644 --- a/services/openclaw_strategist_service.py +++ b/services/openclaw_strategist_service.py @@ -46,7 +46,6 @@ from services.rag_service import rag_service, is_rag_enabled # Phase 11 RAG-fir logger = logging.getLogger(__name__) -GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "") # Gemini 不可作為 OpenClaw 通用主路徑;所有週/月/meta/日報洞察都先走 # OllamaService 的 GCP-A → GCP-B → 111 級聯,Gemini 僅作最後備援。 STRATEGY_MODEL = os.getenv("OPENCLAW_MODEL", "gemini-2.5-flash") @@ -234,9 +233,9 @@ def _legacy_gemini_first_qa( ) user_prompt = f"使用者問題:{q}\n上下文:{json.dumps(context or {}, ensure_ascii=False)}" - # 優先 Gemini;無 key 或失敗時自動備援 NVIDIA NIM + # Gemini 只在中央 guard 顯式解鎖時可用;無 key 或失敗時自動備援 NVIDIA NIM。 text_reply = None - if GEMINI_API_KEY and is_gemini_fallback_enabled("openclaw_qa"): + if get_gemini_api_key("openclaw_strategy"): try: text_reply = _call_gemini( system_prompt, @@ -1161,7 +1160,7 @@ def _call_openclaw_llm_ollama_first( if text_out: return text_out - if GEMINI_API_KEY and is_gemini_fallback_enabled("openclaw_strategy"): + if get_gemini_api_key("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 58ce209..4af3b06 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_API_HARD_DISABLED", "false") monkeypatch.setenv("GEMINI_FALLBACK_ENABLED", "true") service = AIProviderService(default_provider="ollama") diff --git a/tests/test_code_review_claude_routing.py b/tests/test_code_review_claude_routing.py index 88e80da..d9561c6 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_API_HARD_DISABLED', 'false') monkeypatch.setenv('GEMINI_FALLBACK_ENABLED', 'true') monkeypatch.setenv('GEMINI_API_KEY', 'test-key') _stub_logger(monkeypatch) @@ -438,6 +439,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_API_HARD_DISABLED', 'false') monkeypatch.setenv('GEMINI_FALLBACK_ENABLED', 'true') monkeypatch.setenv('GEMINI_API_KEY', 'test-key') captured = _capture_ai_call_states(monkeypatch) @@ -479,6 +481,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_API_HARD_DISABLED', 'false') monkeypatch.setenv('GEMINI_FALLBACK_ENABLED', 'true') monkeypatch.setenv('GEMINI_API_KEY', 'test-key') _stub_logger(monkeypatch) @@ -503,6 +506,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_API_HARD_DISABLED', 'false') 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 index 459ad1f..dfe72cc 100644 --- a/tests/test_gemini_fallback_guard.py +++ b/tests/test_gemini_fallback_guard.py @@ -9,6 +9,7 @@ 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_API_HARD_DISABLED", raising=False) monkeypatch.delenv("GEMINI_FALLBACK_ENABLED", raising=False) monkeypatch.setenv("GEMINI_API_KEY", "test-key") @@ -16,7 +17,42 @@ def test_gemini_guard_defaults_disabled(monkeypatch): assert get_gemini_api_key("test") == "" +def test_gemini_guard_hard_switch_blocks_enabled_fallback(monkeypatch): + from services.gemini_guard import get_gemini_api_key, is_gemini_fallback_enabled + + monkeypatch.delenv("GEMINI_API_HARD_DISABLED", raising=False) + monkeypatch.setenv("GEMINI_FALLBACK_ENABLED", "true") + monkeypatch.setenv("GEMINI_API_KEY", "test-key") + + assert is_gemini_fallback_enabled("test") is False + assert get_gemini_api_key("test") == "" + + +def test_gemini_guard_requires_explicit_emergency_unlock(monkeypatch): + from services.gemini_guard import get_gemini_api_key, is_gemini_fallback_enabled + + monkeypatch.setenv("GEMINI_API_HARD_DISABLED", "false") + monkeypatch.setenv("GEMINI_FALLBACK_ENABLED", "true") + monkeypatch.setenv("GEMINI_API_KEY", "test-key") + + assert is_gemini_fallback_enabled("test") is True + assert get_gemini_api_key("test") == "test-key" + + +def test_gemini_guard_respects_context_allowlist(monkeypatch): + from services.gemini_guard import get_gemini_api_key, is_gemini_fallback_enabled + + monkeypatch.setenv("GEMINI_API_HARD_DISABLED", "false") + monkeypatch.setenv("GEMINI_FALLBACK_ENABLED", "true") + monkeypatch.setenv("GEMINI_ALLOWED_CONTEXTS", "code_review") + monkeypatch.setenv("GEMINI_API_KEY", "test-key") + + assert is_gemini_fallback_enabled("code_review") is True + assert get_gemini_api_key("openclaw_strategy") == "" + + def test_ai_provider_does_not_call_gemini_when_guard_disabled(monkeypatch): + monkeypatch.delenv("GEMINI_API_HARD_DISABLED", raising=False) monkeypatch.delenv("GEMINI_FALLBACK_ENABLED", raising=False) service = AIProviderService(default_provider="ollama") failed_ollama = AIResponse( @@ -34,10 +70,11 @@ def test_ai_provider_does_not_call_gemini_when_guard_disabled(monkeypatch): assert result.success is False assert result.provider == "ollama" - assert "GEMINI_FALLBACK_ENABLED=false" in (result.error or "") + assert "GEMINI_API_HARD_DISABLED=true" in (result.error or "") def test_gemini_service_check_connection_is_blocked_by_default(monkeypatch): + monkeypatch.delenv("GEMINI_API_HARD_DISABLED", raising=False) monkeypatch.delenv("GEMINI_FALLBACK_ENABLED", raising=False) monkeypatch.setenv("GEMINI_API_KEY", "test-key") service = GeminiService() @@ -50,14 +87,15 @@ def test_gemini_service_check_connection_is_blocked_by_default(monkeypatch): result = service.generate("hello") assert result.success is False - assert "GEMINI_FALLBACK_ENABLED=false" in (result.error or "") + assert "GEMINI_API_HARD_DISABLED=true" 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_API_HARD_DISABLED", raising=False) monkeypatch.delenv("GEMINI_FALLBACK_ENABLED", raising=False) - monkeypatch.setattr(mcp_mod, "GEMINI_API_KEY", "test-key") + monkeypatch.setenv("GEMINI_API_KEY", "test-key") service = mcp_mod.MCPCollectorService() assert service._ensure_init() is False @@ -67,7 +105,8 @@ def test_mcp_collector_does_not_initialize_gemini_when_guard_disabled(monkeypatc def test_openclaw_direct_gemini_call_is_blocked_by_default(monkeypatch): import services.openclaw_strategist_service as svc + monkeypatch.delenv("GEMINI_API_HARD_DISABLED", raising=False) monkeypatch.delenv("GEMINI_FALLBACK_ENABLED", raising=False) - monkeypatch.setattr(svc, "GEMINI_API_KEY", "test-key") + monkeypatch.setenv("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 202c1b0..64da913 100644 --- a/tests/test_openclaw_qa_routing.py +++ b/tests/test_openclaw_qa_routing.py @@ -275,7 +275,9 @@ class TestOpenClawReportRouting: assert calls == [("ollama", "openclaw_weekly")] def test_report_llm_gemini_is_suffix_fallback_only(self, monkeypatch): + monkeypatch.setenv("GEMINI_API_HARD_DISABLED", "false") monkeypatch.setenv("GEMINI_FALLBACK_ENABLED", "true") + monkeypatch.setenv("GEMINI_API_KEY", "test-key") calls = [] monkeypatch.setattr(svc, "_call_ollama_strategy", lambda *a, **kw: None) @@ -284,7 +286,6 @@ class TestOpenClawReportRouting: calls.append(("gemini", kwargs["caller"])) return "Gemini fallback content" - monkeypatch.setattr(svc, "GEMINI_API_KEY", "test-key") monkeypatch.setattr(svc, "_call_gemini", fake_gemini) result = svc._call_openclaw_llm_ollama_first( @@ -496,9 +497,9 @@ 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_API_HARD_DISABLED', 'false') 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 備援:請先檢查近七日業績與競品價差。") @@ -520,9 +521,9 @@ 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_API_HARD_DISABLED', 'false') 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) _stub_nim(monkeypatch, text="NIM 備援:請改看 /daily 與 /threats。")