Files
2026-07-13 13:12:33 +08:00

202 lines
6.4 KiB
Python

"""Integration tests for turn runtime cost source persistence."""
from __future__ import annotations
from collections.abc import AsyncIterator
from types import SimpleNamespace
import pytest
from opensquilla.engine.runtime import TurnRunner
from opensquilla.provider import DoneEvent as ProviderDone
from opensquilla.provider import Message, ModelInfo
from opensquilla.provider import TextDeltaEvent as ProviderText
from opensquilla.session.manager import SessionManager
from opensquilla.session.storage import SessionStorage
from opensquilla.tools.types import CallerKind, ToolContext
class _CostProvider:
provider_name = "test"
def __init__(self, done_events: list[ProviderDone]) -> None:
self._done_events = list(done_events)
self._model = done_events[0].model if done_events else "claude-opus-4-7"
@property
def model(self) -> str:
return self._model
def chat(self, messages: list[Message], tools=None, config=None) -> AsyncIterator:
return self._stream()
async def _stream(self) -> AsyncIterator:
done = self._done_events.pop(0)
self._model = done.model
yield ProviderText(text="ok")
yield done
async def list_models(self) -> list[ModelInfo]:
return []
class _ReasoningOnlyCostProvider(_CostProvider):
async def _stream(self) -> AsyncIterator:
done = self._done_events.pop(0)
self._model = done.model
yield done
class _SelectorClone:
def __init__(self, provider: _CostProvider) -> None:
self.provider = provider
self.current_config = SimpleNamespace(model=provider.model)
def override_model(self, model: str) -> None:
self.current_config = SimpleNamespace(model=model)
self.provider._model = model
def resolve(self) -> _CostProvider:
return self.provider
class _ProviderSelector:
def __init__(self, provider: _CostProvider) -> None:
self.provider = provider
def clone(self) -> _SelectorClone:
return _SelectorClone(self.provider)
@pytest.mark.asyncio
async def test_runtime_session_id_log_lookup_uses_private_storage_fallback() -> None:
class _Storage:
async def get_session(self, session_key: str) -> SimpleNamespace:
assert session_key == "agent:main:private-storage"
return SimpleNamespace(session_id="session-123")
manager = SimpleNamespace(_storage=_Storage())
runner = TurnRunner(provider_selector=None, session_manager=manager)
assert (
await runner._resolve_session_id_for_log("agent:main:private-storage")
== "session-123"
)
@pytest.mark.asyncio
async def test_runtime_persists_billed_and_estimated_cost_source_rollup() -> None:
storage = SessionStorage(":memory:")
await storage.connect()
manager = SessionManager(storage)
session_key = "agent:main:cost-source"
await manager.create(session_key)
provider = _CostProvider(
[
ProviderDone(
input_tokens=100,
output_tokens=10,
billed_cost=0.004,
model="claude-opus-4-7",
),
ProviderDone(
input_tokens=1000,
output_tokens=0,
billed_cost=0.0,
model="claude-opus-4-7",
),
]
)
runner = TurnRunner(
provider_selector=_ProviderSelector(provider),
session_manager=manager,
)
tool_context = ToolContext(is_owner=True, caller_kind=CallerKind.CLI)
try:
async for _ in runner.run(
"first",
session_key,
tool_context=tool_context,
history_has_persisted_user=False,
no_memory_capture=True,
):
pass
first = await manager.get_session(session_key)
assert first is not None
assert first.total_cost_usd == pytest.approx(0.004)
assert first.billed_cost_usd == pytest.approx(0.004)
assert first.estimated_cost_component_usd == 0.0
assert first.cost_source == "provider_billed"
async for _ in runner.run(
"second",
session_key,
tool_context=tool_context,
history_has_persisted_user=False,
no_memory_capture=True,
):
pass
second = await manager.get_session(session_key)
assert second is not None
assert second.total_cost_usd == pytest.approx(0.019)
assert second.billed_cost_usd == pytest.approx(0.004)
assert second.estimated_cost_component_usd == pytest.approx(0.015)
assert second.cost_source == "mixed"
assert second.estimated_cost_usd == pytest.approx(0.019)
assert second.missing_cost_entries == 0
finally:
await storage.close()
@pytest.mark.asyncio
async def test_runtime_persists_usage_before_yielding_terminal_error() -> None:
storage = SessionStorage(":memory:")
await storage.connect()
manager = SessionManager(storage)
session_key = "agent:main:error-usage"
await manager.create(session_key)
provider = _ReasoningOnlyCostProvider(
[
ProviderDone(
input_tokens=40,
output_tokens=2,
reasoning_tokens=2,
reasoning_content="internal reasoning",
billed_cost=0.004,
model="claude-opus-4-7",
)
]
)
runner = TurnRunner(
provider_selector=_ProviderSelector(provider),
session_manager=manager,
)
tool_context = ToolContext(is_owner=True, caller_kind=CallerKind.CLI)
try:
events = []
async for event in runner.run(
"fail after usage",
session_key,
tool_context=tool_context,
history_has_persisted_user=False,
no_memory_capture=True,
):
events.append(event)
if getattr(event, "kind", "") == "error":
break
session = await manager.get_session(session_key)
assert session is not None
assert any(event.kind == "done" for event in events)
assert events[-1].kind == "error"
assert session.input_tokens == 40
assert session.output_tokens == 2
assert session.total_tokens == 42
assert session.total_cost_usd == pytest.approx(0.004)
assert session.billed_cost_usd == pytest.approx(0.004)
assert session.cost_source == "provider_billed"
finally:
await storage.close()