feat(p7): Anthropic SDK + Claude Opus 4.7 接 Code Review (feature flag OFF)
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

Operation Ollama-First v5.0 / Phase 7 Frontier 升級

services/anthropic_service.py (新檔, 226 行)
- AnthropicService 包裝 + ClaudeResponse dataclass
- Ephemeral prompt cache 5 分鐘 TTL(重複 system_prompt 省 90% 成本)
- usage 解析 input/output/cache_creation/cache_read 四欄位
- ANTHROPIC_API_KEY 未設或 SDK 缺失時 is_available()=False 靜默退化

code_review_pipeline_service.py — _openclaw_assess 加 L1 Claude 分支
- CODE_REVIEW_USE_CLAUDE flag (預設 OFF,等 ANTHROPIC_API_KEY 設定後翻 ON)
- 路由:Claude Opus 4.7 (Arena code Elo 1548) → Gemini → ElephantAlpha 三層
- request_id 串鏈不變

ai_call_logger.py COST_TABLE 補 3 個 Claude 模型:
- claude-opus-4-7:    $15/$75 per M tokens (程式碼 #1)
- claude-sonnet-4-6:  $3/$15  per M tokens (agentic 平衡)
- claude-haiku-4-5:   $0.8/$4 per M tokens (輕量快速)

requirements.txt: 加 anthropic>=0.40.0
.env.example: 加 ANTHROPIC_API_KEY / CODE_REVIEW_USE_CLAUDE / CLAUDE_MODEL

52 unit tests 全綠(22 logger + 18 anthropic + 5 routing + 7 security)

啟用步驟(待統帥手動):
  1. .env 加 ANTHROPIC_API_KEY=sk-ant-...
  2. CODE_REVIEW_USE_CLAUDE=true + restart momo-app
  3. 觀察 ai_calls.cache_read_tokens > 0 確認 cache 生效

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
OoO
2026-05-03 23:31:30 +08:00
parent bc4332d53f
commit 943de8466c
7 changed files with 855 additions and 8 deletions

View File

@@ -0,0 +1,309 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
tests/test_anthropic_service.py
services/anthropic_service.py 單元測試 — Operation Ollama-First v5.0 Phase 7
測試紀律Phase 7 spec
- generate 正常路徑cache_creation + cache_read 解析正確
- generate prompt cachecache_system=True 時 system 加 cache_control
- ANTHROPIC_API_KEY 未設定時 is_available() == False
- SDK ImportError 時不爆log.erroris_available()=False
- SDK 例外時 generate 回 success=False 不 raise
- cache_hit property 邏輯cache_read_tokens > 0 → True
- check_connection 正常與失敗
"""
from __future__ import annotations
import os
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# ─────────────────────────────────────────────────────────────────────────────
# Helpers仿造 anthropic SDK Response 結構
# ─────────────────────────────────────────────────────────────────────────────
def _fake_anthropic_response(
text: str = "ok",
input_tokens: int = 100,
output_tokens: int = 50,
cache_creation_input_tokens: int = 0,
cache_read_input_tokens: int = 0,
model: str = "claude-opus-4-7",
):
"""模擬 anthropic.types.Message"""
block = SimpleNamespace(text=text, type="text")
usage = SimpleNamespace(
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_creation_input_tokens=cache_creation_input_tokens,
cache_read_input_tokens=cache_read_input_tokens,
)
return SimpleNamespace(content=[block], usage=usage, model=model)
@pytest.fixture
def mock_sdk(monkeypatch):
"""模擬 anthropic SDK注入 fake module 到 sys.modules
讓 AnthropicService._init_client 走 import anthropic 成功路徑。"""
fake_anthropic = MagicMock()
fake_client = MagicMock()
fake_anthropic.Anthropic.return_value = fake_client
monkeypatch.setitem(sys.modules, 'anthropic', fake_anthropic)
return fake_anthropic, fake_client
# ─────────────────────────────────────────────────────────────────────────────
# is_available() 測試
# ─────────────────────────────────────────────────────────────────────────────
def test_is_available_false_when_no_api_key(monkeypatch):
"""ANTHROPIC_API_KEY 未設 → is_available() False"""
monkeypatch.delenv('ANTHROPIC_API_KEY', raising=False)
# reload 確保模組層 ANTHROPIC_API_KEY 重新讀取
import importlib
import services.anthropic_service as svc_mod
importlib.reload(svc_mod)
svc = svc_mod.AnthropicService(api_key='')
assert svc.is_available() is False
def test_is_available_true_when_sdk_ready(mock_sdk):
"""API key + SDK 都有 → is_available() True"""
from services.anthropic_service import AnthropicService
svc = AnthropicService(api_key='sk-ant-test')
assert svc.is_available() is True
def test_is_available_false_on_import_error(monkeypatch):
"""SDK 未安裝ImportError→ is_available() False不 raise"""
# 移除 anthropic 模組讓 import 失敗
monkeypatch.setitem(sys.modules, 'anthropic', None)
from services.anthropic_service import AnthropicService
svc = AnthropicService(api_key='sk-ant-test')
assert svc.is_available() is False
def test_is_available_false_on_init_exception(monkeypatch):
"""SDK 初始化拋例外 → is_available() False不 raise"""
fake_anthropic = MagicMock()
fake_anthropic.Anthropic.side_effect = RuntimeError("auth failed")
monkeypatch.setitem(sys.modules, 'anthropic', fake_anthropic)
from services.anthropic_service import AnthropicService
svc = AnthropicService(api_key='sk-ant-test')
assert svc.is_available() is False
# ─────────────────────────────────────────────────────────────────────────────
# generate() 正常路徑
# ─────────────────────────────────────────────────────────────────────────────
def test_generate_success_basic(mock_sdk):
"""generate 正常路徑tokens 正確解析"""
fake_anthropic, fake_client = mock_sdk
fake_client.messages.create.return_value = _fake_anthropic_response(
text="hello world",
input_tokens=120,
output_tokens=40,
)
from services.anthropic_service import AnthropicService
svc = AnthropicService(api_key='sk-ant-test')
resp = svc.generate(prompt="say hi", max_tokens=100)
assert resp.success is True
assert resp.content == "hello world"
assert resp.input_tokens == 120
assert resp.output_tokens == 40
assert resp.cache_creation_tokens == 0
assert resp.cache_read_tokens == 0
assert resp.cache_hit is False
assert resp.duration_ms >= 0
assert resp.error is None
def test_generate_with_cache_creation_and_read(mock_sdk):
"""generate 解析 cache_creation_input_tokens / cache_read_input_tokens"""
fake_anthropic, fake_client = mock_sdk
fake_client.messages.create.return_value = _fake_anthropic_response(
cache_creation_input_tokens=500,
cache_read_input_tokens=2000,
)
from services.anthropic_service import AnthropicService
svc = AnthropicService(api_key='sk-ant-test')
resp = svc.generate(prompt="reuse", system_prompt="stable system")
assert resp.success is True
assert resp.cache_creation_tokens == 500
assert resp.cache_read_tokens == 2000
assert resp.cache_hit is True
def test_generate_with_cache_system_adds_cache_control(mock_sdk):
"""cache_system=True 時 system_prompt 帶 ephemeral cache_control"""
fake_anthropic, fake_client = mock_sdk
fake_client.messages.create.return_value = _fake_anthropic_response()
from services.anthropic_service import AnthropicService
svc = AnthropicService(api_key='sk-ant-test')
svc.generate(
prompt="user",
system_prompt="my-system",
cache_system=True,
)
_, kwargs = fake_client.messages.create.call_args
assert isinstance(kwargs['system'], list)
assert kwargs['system'][0]['type'] == 'text'
assert kwargs['system'][0]['text'] == 'my-system'
assert kwargs['system'][0]['cache_control'] == {"type": "ephemeral"}
def test_generate_without_cache_system_uses_string(mock_sdk):
"""cache_system=False 時 system 為純字串"""
fake_anthropic, fake_client = mock_sdk
fake_client.messages.create.return_value = _fake_anthropic_response()
from services.anthropic_service import AnthropicService
svc = AnthropicService(api_key='sk-ant-test')
svc.generate(prompt="user", system_prompt="my-system", cache_system=False)
_, kwargs = fake_client.messages.create.call_args
assert kwargs['system'] == 'my-system'
def test_generate_without_system_prompt(mock_sdk):
"""無 system_prompt 時 kwargs 不含 system"""
fake_anthropic, fake_client = mock_sdk
fake_client.messages.create.return_value = _fake_anthropic_response()
from services.anthropic_service import AnthropicService
svc = AnthropicService(api_key='sk-ant-test')
svc.generate(prompt="user")
_, kwargs = fake_client.messages.create.call_args
assert 'system' not in kwargs
def test_generate_passes_temperature_and_max_tokens(mock_sdk):
"""temperature / max_tokens / model 正確傳給 SDK"""
fake_anthropic, fake_client = mock_sdk
fake_client.messages.create.return_value = _fake_anthropic_response()
from services.anthropic_service import AnthropicService
svc = AnthropicService(api_key='sk-ant-test')
svc.generate(
prompt="x", model="claude-sonnet-4-6",
max_tokens=2048, temperature=0.5,
)
_, kwargs = fake_client.messages.create.call_args
assert kwargs['model'] == 'claude-sonnet-4-6'
assert kwargs['max_tokens'] == 2048
assert kwargs['temperature'] == 0.5
assert kwargs['messages'] == [{"role": "user", "content": "x"}]
# ─────────────────────────────────────────────────────────────────────────────
# generate() 失敗路徑
# ─────────────────────────────────────────────────────────────────────────────
def test_generate_returns_failure_when_no_client(monkeypatch):
"""無 API key → generate 回 success=False 不 raise"""
monkeypatch.delenv('ANTHROPIC_API_KEY', raising=False)
from services.anthropic_service import AnthropicService
svc = AnthropicService(api_key='')
resp = svc.generate(prompt="x")
assert resp.success is False
assert resp.content == ""
assert "not initialized" in (resp.error or "")
def test_generate_handles_sdk_exception(mock_sdk):
"""SDK 拋例外 → generate 回 success=Falseerror 含 type+msg"""
fake_anthropic, fake_client = mock_sdk
fake_client.messages.create.side_effect = RuntimeError("rate limit")
from services.anthropic_service import AnthropicService
svc = AnthropicService(api_key='sk-ant-test')
resp = svc.generate(prompt="x")
assert resp.success is False
assert "RuntimeError" in (resp.error or "")
assert "rate limit" in (resp.error or "")
assert resp.duration_ms >= 0
# ─────────────────────────────────────────────────────────────────────────────
# ClaudeResponse cache_hit property
# ─────────────────────────────────────────────────────────────────────────────
def test_cache_hit_property():
from services.anthropic_service import ClaudeResponse
assert ClaudeResponse(success=True, content="x", model="m",
cache_read_tokens=0).cache_hit is False
assert ClaudeResponse(success=True, content="x", model="m",
cache_read_tokens=1).cache_hit is True
assert ClaudeResponse(success=True, content="x", model="m",
cache_read_tokens=10000).cache_hit is True
def test_total_tokens_property():
from services.anthropic_service import ClaudeResponse
r = ClaudeResponse(success=True, content="x", model="m",
input_tokens=100, output_tokens=50)
assert r.total_tokens == 150
# ─────────────────────────────────────────────────────────────────────────────
# check_connection
# ─────────────────────────────────────────────────────────────────────────────
def test_check_connection_success(mock_sdk):
fake_anthropic, fake_client = mock_sdk
fake_client.messages.create.return_value = _fake_anthropic_response(text="pong")
from services.anthropic_service import AnthropicService
svc = AnthropicService(api_key='sk-ant-test')
assert svc.check_connection() is True
def test_check_connection_fail_no_client(monkeypatch):
monkeypatch.delenv('ANTHROPIC_API_KEY', raising=False)
from services.anthropic_service import AnthropicService
svc = AnthropicService(api_key='')
assert svc.check_connection() is False
def test_check_connection_fail_on_sdk_error(mock_sdk):
fake_anthropic, fake_client = mock_sdk
fake_client.messages.create.side_effect = RuntimeError("boom")
from services.anthropic_service import AnthropicService
svc = AnthropicService(api_key='sk-ant-test')
assert svc.check_connection() is False
# ─────────────────────────────────────────────────────────────────────────────
# COST_TABLE 整合(確認 ai_call_logger 認得 claude 模型)
# ─────────────────────────────────────────────────────────────────────────────
def test_cost_table_has_claude_models():
from services.ai_call_logger import COST_TABLE, _calc_cost
assert 'claude-opus-4-7' in COST_TABLE
assert 'claude-sonnet-4-6' in COST_TABLE
assert 'claude-haiku-4-5' in COST_TABLE
# opus 1M in/1M out 應為 15 + 75 = 90 USD
assert abs(_calc_cost('claude-opus-4-7', 1_000_000, 1_000_000) - 90.0) < 1e-6
# haiku 1M in/1M out 應為 0.8 + 4.0 = 4.8 USD
assert abs(_calc_cost('claude-haiku-4-5', 1_000_000, 1_000_000) - 4.8) < 1e-6

View File

@@ -0,0 +1,244 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
tests/test_code_review_claude_routing.py
Phase 7 Frontier 升級CodeReviewPipeline._openclaw_assess Claude 路由測試
驗收項目:
- flag=false → Claude 路徑零觸發regression 防護)
- flag=true + Claude 可用 → 走 Claude回傳 Claude content
- flag=true + Claude 失敗 → fallback 進入 Gemini/Elephant 路徑
- flag=true + Claude 不可用is_available=False→ 跳過 Claude
"""
from __future__ import annotations
import importlib
import os
import sys
import types
from unittest.mock import MagicMock
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# ─────────────────────────────────────────────────────────────────────────────
# 共用工具
# ─────────────────────────────────────────────────────────────────────────────
def _reload_pipeline():
"""重新載入 pipeline 模組(讓 module-level CODE_REVIEW_USE_CLAUDE flag 即時生效)"""
import services.code_review_pipeline_service as svc_mod
importlib.reload(svc_mod)
return svc_mod
def _make_pipeline(svc_mod, commit="abc12345"):
return svc_mod.CodeReviewPipeline(
commit_sha=commit,
changed_files=["services/foo.py"],
branch="main",
deploy_type="sync",
)
def _stub_anthropic(monkeypatch, svc_mod, *, available: bool, success: bool = True,
content: str = "CLAUDE-RESULT", error: str = None):
"""注入假的 anthropic_service 全域單例到 services.anthropic_service。
目標:避免 monkeypatch sys.modules 失敗pipeline 內是 from services.anthropic_service import anthropic_service
"""
fake_resp = MagicMock()
fake_resp.success = success
fake_resp.content = content if success else ""
fake_resp.input_tokens = 200
fake_resp.output_tokens = 100
fake_resp.cache_creation_tokens = 50
fake_resp.cache_read_tokens = 150
fake_resp.cache_hit = True
fake_resp.error = error
fake_svc = MagicMock()
fake_svc.is_available.return_value = available
fake_svc.generate.return_value = fake_resp
# 動態造一個假 module 並寫入 sys.modules覆蓋既有 import 結果)
fake_module = types.ModuleType('services.anthropic_service')
fake_module.anthropic_service = fake_svc
monkeypatch.setitem(sys.modules, 'services.anthropic_service', fake_module)
return fake_svc
def _stub_logger(monkeypatch):
"""避免 log_ai_call 真寫 DB"""
import services.ai_call_logger as logger_mod
monkeypatch.setattr(logger_mod, '_write_to_db', lambda state: None)
monkeypatch.setenv('AI_CALL_LOGGING_ENABLED', 'false')
def _stub_gemini_and_elephant(monkeypatch, *,
gemini_text: str = "GEMINI-RESULT",
elephant_text: str = "ELEPHANT-RESULT",
gemini_works: bool = True):
"""攔截 _openclaw_assess 內的 import google.generativeai / elephant_service。
pipeline 內是 lazy import所以注入到 sys.modules 即可生效。
"""
# 1) Fake google.generativeai
fake_genai = types.ModuleType('google.generativeai')
fake_types = types.SimpleNamespace(GenerationConfig=lambda **kw: MagicMock())
fake_genai.types = fake_types
fake_genai.configure = lambda **kw: None
if gemini_works:
fake_resp = MagicMock()
fake_resp.text = gemini_text
fake_resp.usage_metadata = MagicMock(prompt_token_count=10, candidates_token_count=5)
fake_model = MagicMock()
fake_model.generate_content.return_value = fake_resp
fake_genai.GenerativeModel = MagicMock(return_value=fake_model)
else:
fake_genai.GenerativeModel = MagicMock(side_effect=RuntimeError("gemini broken"))
# google.generativeai 是子模組;注入它和父模組
fake_google = types.ModuleType('google')
fake_google.generativeai = fake_genai
monkeypatch.setitem(sys.modules, 'google', fake_google)
monkeypatch.setitem(sys.modules, 'google.generativeai', fake_genai)
# 2) Fake elephant_service
fake_eresp = MagicMock(success=True, content=elephant_text,
input_tokens=20, output_tokens=10, error=None)
fake_elephant = MagicMock()
fake_elephant.generate.return_value = fake_eresp
fake_eservice_mod = types.ModuleType('services.elephant_service')
fake_eservice_mod.elephant_service = fake_elephant
monkeypatch.setitem(sys.modules, 'services.elephant_service', fake_eservice_mod)
return fake_genai, fake_elephant
# ─────────────────────────────────────────────────────────────────────────────
# Tests
# ─────────────────────────────────────────────────────────────────────────────
def test_flag_false_uses_gemini(monkeypatch):
"""CODE_REVIEW_USE_CLAUDE=false預設→ Claude 路徑完全不觸發"""
monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'false')
monkeypatch.setenv('GEMINI_API_KEY', 'test-key')
_stub_logger(monkeypatch)
svc_mod = _reload_pipeline()
fake_claude = _stub_anthropic(monkeypatch, svc_mod, available=True)
_stub_gemini_and_elephant(monkeypatch)
pipeline = _make_pipeline(svc_mod)
result = pipeline._openclaw_assess(
files={"services/foo.py": "def x(): pass"},
findings=[],
)
assert result == "GEMINI-RESULT"
fake_claude.generate.assert_not_called()
def test_flag_true_uses_claude(monkeypatch):
"""flag=true + Claude 可用 → 走 ClaudeGemini 不被呼叫"""
monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'true')
monkeypatch.setenv('GEMINI_API_KEY', 'test-key')
_stub_logger(monkeypatch)
svc_mod = _reload_pipeline()
fake_claude = _stub_anthropic(monkeypatch, svc_mod, available=True,
success=True, content="CLAUDE-RESULT")
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=[{"severity": "HIGH", "file": "services/foo.py",
"description": "x", "type": "bug"}],
)
assert result == "CLAUDE-RESULT"
fake_claude.generate.assert_called_once()
# Gemini 不應被觸發Claude 成功時短路)
fake_genai.GenerativeModel.assert_not_called()
fake_elephant.generate.assert_not_called()
# 確認 Claude 呼叫參數正確
call_kwargs = fake_claude.generate.call_args.kwargs
assert call_kwargs['cache_system'] is True # 啟用 ephemeral cache
assert call_kwargs['temperature'] == 0.2 # code review 精確設定
assert call_kwargs['model'] == 'claude-opus-4-7' # 預設 Opus 4.7
assert call_kwargs['system_prompt'] # system prompt 有傳
assert 'commit ' + 'abc12345'[:8] in call_kwargs['prompt'].lower() or \
'abc12345' in call_kwargs['prompt']
def test_flag_true_claude_fails_falls_back_to_gemini(monkeypatch):
"""Claude 回 success=False → fallback Gemini 接手"""
monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'true')
monkeypatch.setenv('GEMINI_API_KEY', 'test-key')
_stub_logger(monkeypatch)
svc_mod = _reload_pipeline()
fake_claude = _stub_anthropic(monkeypatch, svc_mod, available=True,
success=False, error="RateLimitError: too many")
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 == "GEMINI-RESULT"
fake_claude.generate.assert_called_once()
fake_genai.GenerativeModel.assert_called_once()
def test_flag_true_claude_unavailable_uses_gemini(monkeypatch):
"""flag=true 但 is_available()=False → 跳 L1 直走 L2"""
monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'true')
monkeypatch.setenv('GEMINI_API_KEY', 'test-key')
_stub_logger(monkeypatch)
svc_mod = _reload_pipeline()
fake_claude = _stub_anthropic(monkeypatch, svc_mod, available=False)
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 == "GEMINI-RESULT"
fake_claude.generate.assert_not_called()
fake_genai.GenerativeModel.assert_called_once()
def test_flag_true_full_fallback_chain(monkeypatch):
"""Claude 失敗 + Gemini 也失敗 → 最終 Elephant 接手"""
monkeypatch.setenv('CODE_REVIEW_USE_CLAUDE', 'true')
monkeypatch.setenv('GEMINI_API_KEY', 'test-key')
_stub_logger(monkeypatch)
svc_mod = _reload_pipeline()
fake_claude = _stub_anthropic(monkeypatch, svc_mod, available=True,
success=False, error="claude down")
fake_genai, fake_elephant = _stub_gemini_and_elephant(
monkeypatch, gemini_works=False,
)
pipeline = _make_pipeline(svc_mod)
result = pipeline._openclaw_assess(
files={"services/foo.py": "def x(): pass"},
findings=[],
)
assert result == "ELEPHANT-RESULT"
fake_claude.generate.assert_called_once()
fake_elephant.generate.assert_called_once()