Files
awoooi/apps/api/tests/test_chat_manager_ollama_routing.py

509 lines
18 KiB
Python
Raw 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.
from __future__ import annotations
import hashlib
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock
import pytest
from src.services import ai_control as ai_control_module
from src.services import ai_rate_limiter as limiter_module
from src.services import chat_manager as chat_module
from src.services import ollama_failover_manager as failover_module
from src.services.ai_provider_policy import PRODUCTION_PROVIDER_ORDER
from src.services.ai_providers.interfaces import AIResult
from src.services.chat_manager import ChatManager
def _settings() -> SimpleNamespace:
return SimpleNamespace(
OPENCLAW_DEFAULT_MODEL="qwen3:14b",
OLLAMA_HEALTH_CHECK_MODEL="gemma3:4b",
CLAUDE_API_KEY="configured-test-value",
CLAUDE_EXECUTION_MODE="canary",
CLAUDE_CANARY_PERCENT=100,
CLAUDE_MODEL="claude-sonnet-5",
GEMINI_API_KEY="configured-test-value",
GEMINI_EXECUTION_MODE="canary",
GEMINI_CANARY_PERCENT=100,
GEMINI_MODEL="gemini-2.5-flash-lite",
)
class _Executor:
def __init__(self, result: AIResult) -> None:
self.result = result
self.calls: list[dict[str, Any]] = []
async def execute(
self,
prompt: str,
*,
provider_order: list[str],
context: dict[str, Any],
cache_ttl: int,
require_local: bool,
) -> AIResult:
self.calls.append(
{
"prompt": prompt,
"provider_order": list(provider_order),
"context": dict(context),
"cache_ttl": cache_ttl,
"require_local": require_local,
}
)
return self.result
class _Limiter:
async def get_usage_stats(self, provider: str) -> dict[str, Any]:
return {
"provider": provider,
"cost_exceeded": False,
"pricing_policy": {"supported": True},
}
async def get_provider_authentication_status(
self,
provider: str,
) -> dict[str, Any]:
authenticated = provider == "claude"
return {
"provider": provider,
"configured": True,
"authenticated": authenticated,
"authentication_verified": authenticated,
"verification_status": (
"authenticated_verified_by_generation"
if authenticated
else "authentication_rejected"
),
"verification_source": "durable_generation_receipt",
}
class _FailoverManager:
async def select_provider(
self,
*,
task_type: str,
emit_side_effects: bool,
) -> SimpleNamespace:
assert task_type == "chat_status"
assert emit_side_effects is False
return SimpleNamespace(
primary=SimpleNamespace(provider_name="ollama_gcp_a"),
observed_fallback=SimpleNamespace(provider_name="ollama_gcp_b"),
health_gcp_a=SimpleNamespace(status=SimpleNamespace(value="offline")),
health_gcp_b=SimpleNamespace(status=SimpleNamespace(value="healthy")),
health_local=SimpleNamespace(status=SimpleNamespace(value="offline")),
)
def _bounded_canary_context() -> dict[str, Any]:
return {
"trace_id": "trace-AIA-SRE-013-chat",
"run_id": "AIA-SRE-013-chat-canary-1",
"work_item_id": "AIA-SRE-013",
"synthetic_validation": True,
"data_classification": "sanitized",
"infrastructure_remediation_write_allowed": False,
"paid_canary_max_cost_usd": "0.25",
"paid_canary_max_output_tokens": 512,
}
@pytest.fixture(autouse=True)
def _configure(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(chat_module, "get_settings", _settings)
monkeypatch.setenv("ENABLE_CLAUDE", "true")
monkeypatch.setenv("ENABLE_GEMINI", "true")
@pytest.mark.asyncio
async def test_sanitized_group_chat_uses_three_free_hops_and_never_executes_repair(
monkeypatch: pytest.MonkeyPatch,
) -> None:
executor = _Executor(
AIResult(
raw_response='{"message":"老闆,這是可驗證的本地判讀。"}',
success=True,
provider="ollama",
tokens=24,
)
)
monkeypatch.setattr(chat_module, "get_ai_executor", lambda: executor)
result = await ChatManager().route_chat(
role="openclaw",
system_prompt=("系統摘要 host=192.168.0.111 peer=10.8.0.2 node=172.16.1.9"),
user_message="請分析目前服務狀況",
)
assert result.success is True
assert result.provider == "ollama_gcp_a"
assert result.model == "gemma3:4b"
assert result.policy_order == PRODUCTION_PROVIDER_ORDER
assert result.admitted_provider_order == (
"ollama_gcp_a",
"ollama_gcp_b",
"ollama_local",
)
assert result.cost_receipt_id is None
assert result.fallback_reason == "canonical_primary_succeeded"
assert result.infrastructure_remediation_write_allowed is False
assert result.cross_domain_execution_allowed is False
call = executor.calls[0]
assert call["provider_order"] == [
"ollama_gcp_a",
"ollama_gcp_b",
"ollama_local",
]
assert call["require_local"] is False
assert call["context"]["executor_invocation_allowed"] is False
assert call["context"]["agent99_dispatch_allowed"] is False
assert call["context"]["cross_domain_execution_allowed"] is False
assert call["context"]["provider_timeout_seconds"] == 35
assert "synthetic_validation" not in call["context"]
assert "synthetic_prompt_sha256" not in call["context"]
assert call["context"]["cloud_transport_auto_preflight"] is True
receipt = call["context"]["sre_chat_sanitization_receipt"]
assert receipt["schema_version"] == "sre_chat_sanitization_receipt_v1"
assert receipt["status"] == "verified"
assert receipt["source_label"] == "awoooi_sre_group_chat"
assert (
receipt["prompt_sha256"]
== hashlib.sha256(call["prompt"].encode("utf-8")).hexdigest()
)
assert receipt["trace_id"] == call["context"]["trace_id"]
assert receipt["run_id"] == call["context"]["run_id"]
assert receipt["work_item_id"] == call["context"]["work_item_id"]
assert receipt["raw_input_forwarded"] is False
assert receipt["raw_log_payload_forwarded"] is False
assert receipt["secret_value_exposed"] is False
assert receipt["private_network_value_exposed"] is False
assert receipt["provider_timeout_seconds"] == 35
assert "192.168.0.111" not in call["prompt"]
assert "10.8.0.2" not in call["prompt"]
assert "172.16.1.9" not in call["prompt"]
assert call["prompt"].count("[PRIVATE_IP_REDACTED]") == 3
@pytest.mark.asyncio
async def test_bounded_run_owned_canary_uses_exact_five_hop_order_and_receipt(
monkeypatch: pytest.MonkeyPatch,
) -> None:
executor = _Executor(
AIResult(
raw_response='{"reasoning":"Claude 已完成受控 critic 回應。"}',
success=True,
provider="claude",
tokens=45,
cost_usd=0.004,
audit_metadata={"generation_receipt_id": "gen-claude-chat-1"},
)
)
monkeypatch.setattr(chat_module, "get_ai_executor", lambda: executor)
manager = ChatManager()
monkeypatch.setattr(
manager,
"_paid_provider_candidates",
AsyncMock(
return_value=(
["claude", "gemini"],
{
"status": "admitted",
"run_owned_lease_verified": True,
"cost_policy_preflight_verified": True,
},
)
),
)
result = await manager.route_chat(
role="openclaw",
system_prompt="不含敏感資料的固定 canary",
user_message="評估固定的合成 SRE 狀態",
route_context=_bounded_canary_context(),
)
assert result.success is True
assert result.provider == "claude"
assert result.model == "claude-sonnet-5"
assert result.policy_order == PRODUCTION_PROVIDER_ORDER
assert result.admitted_provider_order == PRODUCTION_PROVIDER_ORDER
assert result.fallback_reason == "canonical_position_4_after_prior_hops_no_success"
assert result.cost_receipt_id == "gen-claude-chat-1"
assert result.cost_usd == 0.004
call = executor.calls[0]
assert call["provider_order"] == list(PRODUCTION_PROVIDER_ORDER)
assert call["require_local"] is False
assert call["context"]["synthetic_validation"] is True
assert (
call["context"]["synthetic_prompt_sha256"]
== hashlib.sha256(call["prompt"].encode("utf-8")).hexdigest()
)
assert call["context"]["paid_canary_max_cost_usd"] == "0.25"
assert "provider_timeout_seconds" not in call["context"]
assert call["context"]["infrastructure_remediation_write_allowed"] is False
@pytest.mark.asyncio
async def test_paid_candidate_requires_canary_lease_bucket_and_cost_policy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
ai_control_module,
"get_paid_provider_canary_control_readback",
AsyncMock(return_value={"leased_enabled": True}),
)
monkeypatch.setattr(
ai_control_module,
"is_provider_disabled",
AsyncMock(return_value=False),
)
monkeypatch.setattr(limiter_module, "get_ai_rate_limiter", lambda: _Limiter())
candidates, readback = await ChatManager()._paid_provider_candidates(
_bounded_canary_context()
)
assert candidates == ["claude", "gemini"]
assert readback["status"] == "admitted"
assert readback["run_owned_lease_verified"] is True
assert readback["cost_policy_preflight_verified"] is True
assert readback["provider_states"] == {
"claude": "admitted_atomic_reservation_pending",
"gemini": "admitted_atomic_reservation_pending",
}
@pytest.mark.asyncio
async def test_paid_candidate_fails_closed_without_bounded_run_context() -> None:
candidates, readback = await ChatManager()._paid_provider_candidates(None)
assert candidates == []
assert readback["status"] == "bounded_canary_context_missing"
assert readback["run_owned_lease_verified"] is False
@pytest.mark.asyncio
async def test_paid_success_without_valid_cost_receipt_is_not_rendered(
monkeypatch: pytest.MonkeyPatch,
) -> None:
executor = _Executor(
AIResult(
raw_response="must not be rendered",
success=True,
provider="gemini",
tokens=10,
cost_usd=0.003,
audit_metadata={},
)
)
monkeypatch.setattr(chat_module, "get_ai_executor", lambda: executor)
manager = ChatManager()
monkeypatch.setattr(
manager,
"_paid_provider_candidates",
AsyncMock(return_value=(["claude", "gemini"], {"status": "admitted"})),
)
result = await manager.route_chat(
role="openclaw",
system_prompt="固定 canary",
user_message="合成狀態",
route_context=_bounded_canary_context(),
)
assert result.success is False
assert result.provider == "gemini"
assert result.fallback_reason == "paid_generation_cost_receipt_invalid"
assert result.cost_receipt_id is None
assert "must not be rendered" not in result.text
@pytest.mark.asyncio
async def test_provider_status_question_returns_structured_runtime_readback(
monkeypatch: pytest.MonkeyPatch,
) -> None:
readback = {
"schema_version": "sre_chat_provider_runtime_readback_v1",
"policy_order": list(PRODUCTION_PROVIDER_ORDER),
"ollama_health": {
"ollama_gcp_a": "offline",
"ollama_gcp_b": "healthy",
"ollama_local": "not_checked",
},
"paid": {
"claude": {
"configured": True,
"authenticated": True,
"authentication_verified": True,
"verification_status": "authenticated_verified_by_generation",
"execution_mode": "canary",
"canary_percent": 5,
"route_lease_state": "inactive_disabled_after_canary",
"runtime_state": (
"configured_authenticated_canary_waiting_for_run_owned_lease"
),
},
"gemini": {
"configured": True,
"authenticated": False,
"authentication_verified": False,
"verification_status": "authentication_rejected",
"execution_mode": "canary",
"canary_percent": 5,
"route_lease_state": "inactive_disabled_after_canary",
"runtime_state": "configured_canary_authentication_rejected",
},
},
"paid_control": {
"readback_status": "verified",
"route_lease_state": "inactive_disabled_after_canary",
},
"provider_route_switch_performed": False,
"infrastructure_remediation_write_allowed": False,
"cross_domain_execution_allowed": False,
}
manager = ChatManager()
monkeypatch.setattr(
manager,
"get_provider_runtime_readback",
AsyncMock(return_value=readback),
)
result = await manager.route_chat(
role="nemoclaw",
system_prompt="system",
user_message="Gemini API 現在是硬鎖定狀態嗎?",
)
assert result.provider == "runtime_readback"
assert result.model == "deterministic_policy"
assert result.fallback_reason == "provider_status_request_no_generation"
assert result.runtime_readback == readback
assert "Claudeconfigured=trueauthenticated=true" in result.text
assert "verification=authenticated_verified_by_generation" in result.text
assert "Geminiconfigured=trueauthenticated=false" in result.text
assert "verification=authentication_rejected" in result.text
assert "canary=canary 5%" in result.text
assert "route_lease=inactive_disabled_after_canary" in result.text
assert "硬鎖" not in result.text
assert "locked" not in result.text.casefold()
assert "無法正常回應" not in result.text
assert result.to_dict()["cross_domain_execution_allowed"] is False
@pytest.mark.asyncio
async def test_provider_runtime_readback_separates_auth_canary_and_route_lease(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
failover_module,
"get_ollama_failover_manager",
lambda: _FailoverManager(),
)
monkeypatch.setattr(limiter_module, "get_ai_rate_limiter", lambda: _Limiter())
monkeypatch.setattr(
ai_control_module,
"get_paid_provider_canary_control_readback",
AsyncMock(
return_value={
"claude_disabled": True,
"gemini_disabled": True,
"lock_present": False,
"lock_ttl_seconds": -2,
"disabled_verified": True,
"persistent_enabled": False,
"legacy_persistent_state_detected": False,
}
),
)
manager = ChatManager()
monkeypatch.setattr(
manager,
"_paid_provider_candidates",
AsyncMock(
return_value=(
[],
{
"status": "bounded_canary_context_missing",
"run_owned_lease_verified": False,
"provider_states": {},
},
)
),
)
readback = await manager.get_provider_runtime_readback()
assert readback["paid_control"] == {
"readback_status": "verified",
"route_lease_state": "inactive_disabled_after_canary",
"lock_present": False,
"lock_ttl_seconds": -2,
"claude_disabled": True,
"gemini_disabled": True,
"disabled_verified": True,
"persistent_enabled": False,
"legacy_persistent_state_detected": False,
}
claude = readback["paid"]["claude"]
assert claude["configured"] is True
assert claude["authenticated"] is True
assert claude["authentication_verified"] is True
assert claude["verification_status"] == ("authenticated_verified_by_generation")
assert claude["execution_mode"] == "canary"
assert claude["canary_percent"] == 100
assert claude["route_lease_state"] == "inactive_disabled_after_canary"
assert claude["runtime_state"] == (
"configured_authenticated_canary_waiting_for_run_owned_lease"
)
gemini = readback["paid"]["gemini"]
assert gemini["configured"] is True
assert gemini["authenticated"] is False
assert gemini["authentication_verified"] is False
assert gemini["verification_status"] == "authentication_rejected"
assert gemini["runtime_state"] == "configured_canary_authentication_rejected"
assert "configured-test-value" not in repr(readback)
@pytest.mark.asyncio
async def test_openclaw_compatibility_render_includes_route_evidence(
monkeypatch: pytest.MonkeyPatch,
) -> None:
executor = _Executor(
AIResult(
raw_response="老闆,服務狀態已完成判讀。",
success=True,
provider="ollama_local",
tokens=9,
)
)
monkeypatch.setattr(chat_module, "get_ai_executor", lambda: executor)
rendered = await ChatManager()._call_openclaw("system", "請看服務")
assert rendered is not None
assert "provider=ollama_local" in rendered
assert "model=qwen3:14b" in rendered
assert "cost_receipt=none" in rendered
def test_chat_manager_reuses_router_and_has_no_direct_provider_http_path() -> None:
source_path = Path(chat_module.__file__).resolve()
source = source_path.read_text(encoding="utf-8")
assert "get_ai_executor" in source
assert "httpx.AsyncClient" not in source
assert "generativelanguage.googleapis.com" not in source
assert "api.anthropic.com" not in source
assert "/api/chat" not in source