Files
ewoooc/tests/test_anthropic_service.py
OoO 0a75d11a28
All checks were successful
CD Pipeline / deploy (push) Successful in 59s
記錄 Claude 成本節流檢查失敗
2026-05-13 10:03:13 +08:00

332 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
import logging
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_logs_cost_throttle_failure(mock_sdk, monkeypatch, caplog):
"""cost_throttle 檢查故障時仍維持 Claude 可用,但必須留下診斷 log。"""
from services.anthropic_service import AnthropicService
import services.cost_throttle_service as cost_throttle_service
def _raise_cost_throttle_error(provider):
raise RuntimeError("cost throttle unavailable")
monkeypatch.setattr(
cost_throttle_service,
"is_provider_throttled",
_raise_cost_throttle_error,
)
caplog.set_level(logging.WARNING, logger="services.anthropic_service")
svc = AnthropicService(api_key='sk-ant-test')
assert svc.is_available() is True
assert "cost_throttle check failed" in caplog.text
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