diff --git a/config.py b/config.py index 99557ff..2ec03dd 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.629" +SYSTEM_VERSION = "V10.630" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/services/code_review_pipeline_service.py b/services/code_review_pipeline_service.py index 27cd726..8508cef 100644 --- a/services/code_review_pipeline_service.py +++ b/services/code_review_pipeline_service.py @@ -142,6 +142,38 @@ def _code_review_ollama_host_reachable(host: str) -> bool: return False +def _code_review_ollama_model_available(host: str, model: str) -> bool: + """Skip a host/model pair when Ollama reports the requested model is absent.""" + if not CODE_REVIEW_OLLAMA_HOST_PREFLIGHT_ENABLED or not model: + return True + try: + resp = requests.get( + f"{str(host or '').rstrip('/')}/api/tags", + timeout=CODE_REVIEW_OLLAMA_HOST_PREFLIGHT_TIMEOUT, + ) + if resp.status_code != 200: + return True + names = { + str(item.get("name") or "") + for item in (resp.json().get("models") or []) + if isinstance(item, dict) + } + if model in names: + return True + if ":" not in model and f"{model}:latest" in names: + return True + logger.warning( + "[CodeReview] Ollama model preflight failed host=%s model=%s available=%s", + host, + model, + sorted(name for name in names if name)[:12], + ) + return False + except Exception as exc: + logger.warning("[CodeReview] Ollama model preflight fail-open host=%s model=%s error=%s", host, model, exc) + return True + + # ═══════════════════════════════════════════════════════════════════════════════ # Pipeline Class # ═══════════════════════════════════════════════════════════════════════════════ @@ -374,6 +406,21 @@ class CodeReviewPipeline: 'max_chars': CODE_REVIEW_HERMES_MAX_CHARS, 'timeout_s': timeout_s}, ) as _ctx: + _ctx.add_meta('host', host) + _ctx.add_meta('host_label', get_host_label(host)) + if not _code_review_ollama_host_reachable(host): + last_error = ( + "ollama host preflight failed " + f"host={host} timeout={CODE_REVIEW_OLLAMA_HOST_PREFLIGHT_TIMEOUT}s" + ) + _ctx.add_meta('preflight', 'api_version') + _ctx.set_error(last_error) + continue + if not _code_review_ollama_model_available(host, model_name): + last_error = f"ollama model preflight failed host={host} model={model_name}" + _ctx.add_meta('preflight', 'api_tags') + _ctx.set_error(last_error) + continue ollama = OllamaService(host=host, model=model_name) resp = ollama.generate( prompt=prompt, @@ -584,6 +631,13 @@ class CodeReviewPipeline: if attempt_index == len(ollama_attempts): _ctx.fallback_to_caller(fallback_caller) continue + if not _code_review_ollama_model_available(host, model_name): + last_ollama_error = f"ollama model preflight failed host={host} model={model_name}" + _ctx.add_meta('preflight', 'api_tags') + _ctx.set_error(last_ollama_error) + if attempt_index == len(ollama_attempts): + _ctx.fallback_to_caller(fallback_caller) + continue ollama = OllamaService(host=host, model=model_name) resp = ollama.generate( prompt=user_prompt, diff --git a/tests/test_code_review_claude_routing.py b/tests/test_code_review_claude_routing.py index b0ab303..3e7bb65 100644 --- a/tests/test_code_review_claude_routing.py +++ b/tests/test_code_review_claude_routing.py @@ -373,6 +373,72 @@ def test_openclaw_preflight_skips_dead_primary_before_generate(monkeypatch): fake_elephant.generate.assert_not_called() +def test_openclaw_preflight_skips_primary_when_model_missing(monkeypatch): + """GCP-A 可達但缺 primary model 時,直接進 GCP-B 的可用模型。""" + monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'false') + monkeypatch.setenv('CODE_REVIEW_OLLAMA_HOST_PREFLIGHT_ENABLED', 'true') + monkeypatch.setenv('GEMINI_API_KEY', 'test-key') + _stub_logger(monkeypatch) + + svc_mod = _reload_pipeline() + import services.ollama_service as ollama_mod + + monkeypatch.setattr(svc_mod, "_code_review_ollama_host_reachable", lambda host: True) + monkeypatch.setattr( + svc_mod, + "_code_review_ollama_model_available", + lambda host, model: not ( + host == ollama_mod.OLLAMA_HOST_PRIMARY + and model == "qwen2.5-coder:7b" + ), + ) + calls = [] + + class FakeResp: + success = True + content = "SECONDARY-MODEL-OK" + error = None + input_tokens = 20 + output_tokens = 8 + + def __init__(self, *, host, model): + self.host = host + self.model = model + + class FakeOllama: + def __init__(self, host=None, model=None): + self.host = host + self.model = model + + def generate(self, **kwargs): + calls.append({ + "host": self.host, + "model": kwargs["model"], + "timeout": kwargs["timeout"], + }) + return FakeResp(host=self.host, model=kwargs["model"]) + + monkeypatch.setattr(ollama_mod, "OllamaService", FakeOllama) + fake_claude = _stub_anthropic(monkeypatch, svc_mod, available=True) + fake_genai, fake_elephant = _stub_gemini_and_elephant(monkeypatch) + + pipeline = _make_pipeline(svc_mod) + result = pipeline._openclaw_assess( + files={"services/foo.py": "def x(): pass"}, + findings=[], + ) + + assert result == "SECONDARY-MODEL-OK" + assert calls == [{ + "host": ollama_mod.OLLAMA_HOST_SECONDARY, + "model": "gemma3:4b", + "timeout": 25, + }] + fake_claude.generate.assert_not_called() + fake_genai.GenerativeModel.assert_not_called() + fake_elephant.generate.assert_not_called() + + def test_openclaw_skips_111_and_cloud_by_default_when_gcp_pair_fails(monkeypatch): """GCP-A/B 都失敗時,預設不把 Code Review 重分析丟給 111 或雲端。""" monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'false')