349 lines
12 KiB
Python
349 lines
12 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import uuid
|
||
|
|
from datetime import UTC, datetime, timedelta
|
||
|
|
from decimal import Decimal
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from commercial_runtime_testkit import seed_meter, seed_run, seed_tool_call
|
||
|
|
from sqlalchemy import create_engine, select
|
||
|
|
from sqlalchemy.orm import Session, sessionmaker
|
||
|
|
from sqlalchemy.pool import StaticPool
|
||
|
|
|
||
|
|
import app.models # noqa: F401 - 注册完整 metadata
|
||
|
|
from app.db.base_class import Base
|
||
|
|
from app.models.commercial import UsageMeterEvent
|
||
|
|
from app.models.commercial_runtime import CommercialRuntimeReservation
|
||
|
|
from app.services.agent_runs import AgentRunService
|
||
|
|
from app.services.commercial_entitlements import CommercialEntitlementService
|
||
|
|
from app.services.commercial_runtime_bridge import CommercialRuntimeBridge
|
||
|
|
from app.services.commercial_runtime_reconciler import CommercialRuntimeReconciler
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture()
|
||
|
|
def db() -> Session:
|
||
|
|
engine = create_engine(
|
||
|
|
"sqlite+pysqlite:///:memory:",
|
||
|
|
connect_args={"check_same_thread": False},
|
||
|
|
poolclass=StaticPool,
|
||
|
|
)
|
||
|
|
Base.metadata.create_all(engine)
|
||
|
|
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||
|
|
with factory() as session:
|
||
|
|
yield session
|
||
|
|
Base.metadata.drop_all(engine)
|
||
|
|
engine.dispose()
|
||
|
|
|
||
|
|
|
||
|
|
def test_reserved_success_settles_real_usage_and_replays_idempotently(
|
||
|
|
db: Session,
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
now = datetime.now(UTC)
|
||
|
|
_, entitlement = seed_meter(db, "tenant-a", now, basis="call", hard_limit=Decimal("2"))
|
||
|
|
run = seed_run(db, now, route_json={"tenant_id": "tenant-a"})
|
||
|
|
tool_call_id = str(uuid.uuid4())
|
||
|
|
bridge = CommercialRuntimeBridge(db)
|
||
|
|
|
||
|
|
permit = bridge.reserve_tool(
|
||
|
|
run.run_id,
|
||
|
|
tool_call_id=tool_call_id,
|
||
|
|
tool_type="llm",
|
||
|
|
tool_name="chat.completions",
|
||
|
|
)
|
||
|
|
reservation = db.get(CommercialRuntimeReservation, permit.reservation_id)
|
||
|
|
assert permit.gate.allowed is True
|
||
|
|
assert reservation is not None and reservation.status == "reserved"
|
||
|
|
assert db.query(UsageMeterEvent).count() == 0
|
||
|
|
|
||
|
|
run_service = AgentRunService(db)
|
||
|
|
monkeypatch.setattr(run_service, "_ensure_ready", lambda: None)
|
||
|
|
run_service.record_tool_call(
|
||
|
|
run_id=run.run_id,
|
||
|
|
tool_call_id=tool_call_id,
|
||
|
|
tool_type="llm",
|
||
|
|
tool_name="chat.completions",
|
||
|
|
status="succeeded",
|
||
|
|
)
|
||
|
|
replay = bridge.sync_tool_call(tool_call_id)
|
||
|
|
db.refresh(reservation)
|
||
|
|
quota = CommercialEntitlementService(db).get_account_for_tenant("tenant-a").quotas[0]
|
||
|
|
|
||
|
|
assert reservation.status == "committed"
|
||
|
|
assert Decimal(reservation.actual_quantity or 0) == Decimal("1")
|
||
|
|
assert replay.status == "replayed"
|
||
|
|
assert db.query(UsageMeterEvent).count() == 1
|
||
|
|
assert quota.entitlement.id == entitlement.id
|
||
|
|
assert quota.used_quantity == Decimal("1")
|
||
|
|
assert quota.reserved_quantity == Decimal("0")
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("tool_status", "business_occurred"),
|
||
|
|
[("failed", True), ("blocked", False)],
|
||
|
|
)
|
||
|
|
def test_failed_or_blocked_tool_releases_reservation_without_usage(
|
||
|
|
db: Session,
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
tool_status: str,
|
||
|
|
business_occurred: bool,
|
||
|
|
) -> None:
|
||
|
|
now = datetime.now(UTC)
|
||
|
|
seed_meter(db, "tenant-a", now, basis="call", hard_limit=Decimal("1"))
|
||
|
|
run = seed_run(db, now, route_json={"tenant_id": "tenant-a"})
|
||
|
|
tool_call_id = str(uuid.uuid4())
|
||
|
|
permit = CommercialRuntimeBridge(db).reserve_tool(
|
||
|
|
run.run_id,
|
||
|
|
tool_call_id=tool_call_id,
|
||
|
|
tool_type="llm",
|
||
|
|
tool_name="chat.completions",
|
||
|
|
)
|
||
|
|
run_service = AgentRunService(db)
|
||
|
|
monkeypatch.setattr(run_service, "_ensure_ready", lambda: None)
|
||
|
|
|
||
|
|
run_service.record_tool_call(
|
||
|
|
run_id=run.run_id,
|
||
|
|
tool_call_id=tool_call_id,
|
||
|
|
tool_type="llm",
|
||
|
|
tool_name="chat.completions",
|
||
|
|
status=tool_status,
|
||
|
|
)
|
||
|
|
result = CommercialRuntimeBridge(db).sync_tool_call(tool_call_id)
|
||
|
|
reservation = db.get(CommercialRuntimeReservation, permit.reservation_id)
|
||
|
|
|
||
|
|
assert reservation is not None and reservation.status == "released"
|
||
|
|
assert result.reason_code == "tool_call_not_billable"
|
||
|
|
assert result.business_call_occurred is business_occurred
|
||
|
|
assert db.query(UsageMeterEvent).count() == 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_active_reservation_holds_hard_quota_before_execution(db: Session) -> None:
|
||
|
|
now = datetime.now(UTC)
|
||
|
|
seed_meter(db, "tenant-a", now, basis="call", hard_limit=Decimal("1"))
|
||
|
|
run = seed_run(db, now, route_json={"tenant_id": "tenant-a"})
|
||
|
|
bridge = CommercialRuntimeBridge(db)
|
||
|
|
|
||
|
|
first = bridge.reserve_tool(
|
||
|
|
run.run_id,
|
||
|
|
tool_call_id=str(uuid.uuid4()),
|
||
|
|
tool_type="llm",
|
||
|
|
tool_name="chat.completions",
|
||
|
|
)
|
||
|
|
second = bridge.reserve_tool(
|
||
|
|
run.run_id,
|
||
|
|
tool_call_id=str(uuid.uuid4()),
|
||
|
|
tool_type="llm",
|
||
|
|
tool_name="chat.completions",
|
||
|
|
)
|
||
|
|
quota = CommercialEntitlementService(db).get_account_for_tenant("tenant-a").quotas[0]
|
||
|
|
|
||
|
|
assert first.reservation_id is not None
|
||
|
|
assert second.reservation_id is None
|
||
|
|
assert second.gate.allowed is False
|
||
|
|
assert quota.reserved_quantity == Decimal("1")
|
||
|
|
assert quota.hard_limit_remaining == Decimal("0")
|
||
|
|
|
||
|
|
|
||
|
|
def test_same_reservation_request_replays_without_consuming_its_own_quota(
|
||
|
|
db: Session,
|
||
|
|
) -> None:
|
||
|
|
now = datetime.now(UTC)
|
||
|
|
seed_meter(db, "tenant-a", now, basis="call", hard_limit=Decimal("1"))
|
||
|
|
run = seed_run(db, now, route_json={"tenant_id": "tenant-a"})
|
||
|
|
bridge = CommercialRuntimeBridge(db)
|
||
|
|
tool_call_id = str(uuid.uuid4())
|
||
|
|
|
||
|
|
first = bridge.reserve_tool(
|
||
|
|
run.run_id,
|
||
|
|
tool_call_id=tool_call_id,
|
||
|
|
tool_type="llm",
|
||
|
|
tool_name="chat.completions",
|
||
|
|
)
|
||
|
|
replay = bridge.reserve_tool(
|
||
|
|
run.run_id,
|
||
|
|
tool_call_id=tool_call_id,
|
||
|
|
tool_type="llm",
|
||
|
|
tool_name="chat.completions",
|
||
|
|
)
|
||
|
|
|
||
|
|
assert replay.gate.allowed is True
|
||
|
|
assert replay.gate.reason_code == "reservation_replayed"
|
||
|
|
assert replay.reservation_id == first.reservation_id
|
||
|
|
assert db.query(CommercialRuntimeReservation).count() == 1
|
||
|
|
|
||
|
|
|
||
|
|
def test_variable_quantity_requires_hard_max_and_refuses_actual_over_reservation(
|
||
|
|
db: Session,
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
now = datetime.now(UTC)
|
||
|
|
seed_meter(db, "tenant-a", now, basis="duration_ms", hard_limit=Decimal("1000"))
|
||
|
|
run = seed_run(db, now, route_json={"tenant_id": "tenant-a"})
|
||
|
|
bridge = CommercialRuntimeBridge(db)
|
||
|
|
rejected = bridge.reserve_tool(
|
||
|
|
run.run_id,
|
||
|
|
tool_call_id=str(uuid.uuid4()),
|
||
|
|
tool_type="llm",
|
||
|
|
tool_name="chat.completions",
|
||
|
|
requested_quantity=Decimal("500"),
|
||
|
|
)
|
||
|
|
assert rejected.gate.reason_code == "hard_max_required"
|
||
|
|
|
||
|
|
tool_call_id = str(uuid.uuid4())
|
||
|
|
permit = bridge.reserve_tool(
|
||
|
|
run.run_id,
|
||
|
|
tool_call_id=tool_call_id,
|
||
|
|
tool_type="llm",
|
||
|
|
tool_name="chat.completions",
|
||
|
|
requested_quantity=Decimal("500"),
|
||
|
|
hard_max_confirmed=True,
|
||
|
|
)
|
||
|
|
run_service = AgentRunService(db)
|
||
|
|
monkeypatch.setattr(run_service, "_ensure_ready", lambda: None)
|
||
|
|
run_service.record_tool_call(
|
||
|
|
run_id=run.run_id,
|
||
|
|
tool_call_id=tool_call_id,
|
||
|
|
tool_type="llm",
|
||
|
|
tool_name="chat.completions",
|
||
|
|
status="succeeded",
|
||
|
|
duration_ms=501,
|
||
|
|
)
|
||
|
|
failed = bridge.sync_tool_call(tool_call_id)
|
||
|
|
reservation = db.get(CommercialRuntimeReservation, permit.reservation_id)
|
||
|
|
assert failed.status == "error"
|
||
|
|
assert failed.requires_reconciliation is True
|
||
|
|
assert reservation is not None and reservation.status == "reserved"
|
||
|
|
assert db.query(UsageMeterEvent).count() == 0
|
||
|
|
|
||
|
|
run_service.update_tool_call(tool_call_id, duration_ms=400, status="succeeded")
|
||
|
|
db.refresh(reservation)
|
||
|
|
assert reservation.status == "committed"
|
||
|
|
assert Decimal(db.scalars(select(UsageMeterEvent)).one().quantity) == Decimal("400")
|
||
|
|
|
||
|
|
|
||
|
|
def test_expired_historical_meter_does_not_enable_runtime_enforcement(db: Session) -> None:
|
||
|
|
now = datetime.now(UTC)
|
||
|
|
subscription, _ = seed_meter(db, "tenant-a", now, basis="call")
|
||
|
|
subscription.current_period_start = now - timedelta(days=31)
|
||
|
|
subscription.current_period_end = now - timedelta(days=1)
|
||
|
|
db.commit()
|
||
|
|
run = seed_run(db, now, route_json={"tenant_id": "tenant-a"})
|
||
|
|
|
||
|
|
permit = CommercialRuntimeBridge(db).reserve_tool(
|
||
|
|
run.run_id,
|
||
|
|
tool_call_id=str(uuid.uuid4()),
|
||
|
|
tool_type="llm",
|
||
|
|
tool_name="chat.completions",
|
||
|
|
)
|
||
|
|
|
||
|
|
assert permit.gate.enforced is False
|
||
|
|
assert permit.gate.allowed is True
|
||
|
|
assert permit.gate.reason_code == "runtime_meter_not_configured"
|
||
|
|
assert permit.reservation_id is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_legacy_run_without_tenant_skips_bridge_without_reconciliation(db: Session) -> None:
|
||
|
|
now = datetime.now(UTC)
|
||
|
|
_, tool_call = seed_tool_call(db, now, route_json={})
|
||
|
|
|
||
|
|
result = CommercialRuntimeBridge(db).sync_tool_call(tool_call.id)
|
||
|
|
|
||
|
|
assert result.status == "skipped"
|
||
|
|
assert result.reason_code == "legacy_run_without_tenant"
|
||
|
|
assert result.requires_reconciliation is False
|
||
|
|
assert db.query(CommercialRuntimeReservation).count() == 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_direct_call_on_suspended_contract_is_persisted_for_reconciliation(
|
||
|
|
db: Session,
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
now = datetime.now(UTC)
|
||
|
|
subscription, _ = seed_meter(
|
||
|
|
db,
|
||
|
|
"tenant-a",
|
||
|
|
now,
|
||
|
|
basis="call",
|
||
|
|
)
|
||
|
|
subscription.status = "suspended"
|
||
|
|
db.flush()
|
||
|
|
run = seed_run(db, now, route_json={"tenant_id": "tenant-a"})
|
||
|
|
run_service = AgentRunService(db)
|
||
|
|
monkeypatch.setattr(run_service, "_ensure_ready", lambda: None)
|
||
|
|
|
||
|
|
call = run_service.record_tool_call(
|
||
|
|
run_id=run.run_id,
|
||
|
|
tool_type="llm",
|
||
|
|
tool_name="chat.completions",
|
||
|
|
status="succeeded",
|
||
|
|
)
|
||
|
|
reservation = db.scalars(select(CommercialRuntimeReservation)).one()
|
||
|
|
|
||
|
|
assert reservation.tool_call_id == call.id
|
||
|
|
assert reservation.status == "reconciliation_required"
|
||
|
|
assert db.query(UsageMeterEvent).count() == 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_expired_reconciler_settles_releases_and_preserves_uncertain_holds(
|
||
|
|
db: Session,
|
||
|
|
) -> None:
|
||
|
|
now = datetime.now(UTC)
|
||
|
|
seed_meter(db, "tenant-a", now, basis="call", hard_limit=Decimal("10"))
|
||
|
|
bridge = CommercialRuntimeBridge(db)
|
||
|
|
reservations: dict[str, CommercialRuntimeReservation] = {}
|
||
|
|
for scenario, run_status in (
|
||
|
|
("success", "running"),
|
||
|
|
("failure", "running"),
|
||
|
|
("terminal_without_call", "failed"),
|
||
|
|
("uncertain", "running"),
|
||
|
|
):
|
||
|
|
run = seed_run(
|
||
|
|
db,
|
||
|
|
now,
|
||
|
|
route_json={"tenant_id": "tenant-a"},
|
||
|
|
status=run_status,
|
||
|
|
)
|
||
|
|
tool_call_id = str(uuid.uuid4())
|
||
|
|
permit = bridge.reserve_tool(
|
||
|
|
run.run_id,
|
||
|
|
tool_call_id=tool_call_id,
|
||
|
|
tool_type="llm",
|
||
|
|
tool_name="chat.completions",
|
||
|
|
)
|
||
|
|
reservation = db.get(CommercialRuntimeReservation, permit.reservation_id)
|
||
|
|
assert reservation is not None
|
||
|
|
reservation.created_at = now - timedelta(hours=1)
|
||
|
|
reservation.expires_at = now - timedelta(minutes=30)
|
||
|
|
reservations[scenario] = reservation
|
||
|
|
if scenario == "success":
|
||
|
|
seed_tool_call(
|
||
|
|
db,
|
||
|
|
now - timedelta(minutes=45),
|
||
|
|
route_json={"tenant_id": "tenant-a"},
|
||
|
|
run=run,
|
||
|
|
status="succeeded",
|
||
|
|
)[1].id = tool_call_id
|
||
|
|
elif scenario == "failure":
|
||
|
|
seed_tool_call(
|
||
|
|
db,
|
||
|
|
now - timedelta(minutes=45),
|
||
|
|
route_json={"tenant_id": "tenant-a"},
|
||
|
|
run=run,
|
||
|
|
status="failed",
|
||
|
|
)[1].id = tool_call_id
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
batch = CommercialRuntimeReconciler(db).reconcile_expired(as_of=now, limit=10)
|
||
|
|
for row in reservations.values():
|
||
|
|
db.refresh(row)
|
||
|
|
|
||
|
|
assert batch.settled == 1
|
||
|
|
assert batch.released == 2
|
||
|
|
assert batch.deferred == 1
|
||
|
|
assert batch.errors == 0
|
||
|
|
assert reservations["success"].status == "committed"
|
||
|
|
assert reservations["failure"].status == "released"
|
||
|
|
assert reservations["terminal_without_call"].status == "expired"
|
||
|
|
assert reservations["uncertain"].status == "reserved"
|
||
|
|
assert db.query(UsageMeterEvent).count() == 1
|