feat: bill AI decision filtering

This commit is contained in:
TIANHE
2026-09-20 21:24:26 +08:00
parent 12655624e2
commit ecc664b495
8 changed files with 339 additions and 9 deletions
@@ -1796,6 +1796,13 @@ CONFIG_SCHEMA = {
'default': '15',
'description': 'Extra credits charged when a Copilot message includes chart images'
},
{
'key': 'BILLING_COST_AI_DECISION_FILTER',
'label': 'BILLING_COST_AI_DECISION_FILTER',
'type': 'number',
'default': '1',
'description': ''
},
{
'key': 'MARKETPLACE_PLATFORM_FEE_RATE',
'label': 'Marketplace Platform Fee Rate',
@@ -6,7 +6,7 @@ import json
import os
import time
import uuid
from dataclasses import asdict, dataclass, field
from dataclasses import asdict, dataclass, field, replace
from typing import Any
import requests
@@ -136,6 +136,7 @@ class AIDecisionResult:
checks: list[dict[str, Any]] = field(default_factory=list)
latency_ms: int = 0
fallback_reason: str = ""
billing: dict[str, Any] = field(default_factory=dict)
def public_dict(self) -> dict[str, Any]:
return asdict(self)
@@ -162,25 +163,78 @@ class AIDecisionFilter:
failures: list[str] = []
jev_config = self._jev_config()
llm: LLMService | None = None
llm_configured = False
if not jev_config["api_key"]:
try:
llm = LLMService()
llm_configured = llm.is_configured()
except Exception as exc:
failures.append(f"llm:{self._safe_error(exc)}")
logger.warning("LLM configuration check failed open: %s", exc)
if not llm_configured:
reason = "ai_provider_unavailable" if failures else "ai_not_configured"
result = self._result(
True,
"skipped",
"none",
reason,
decision_id,
started,
fallback_reason="; ".join(failures),
)
self._persist(request, result)
return result
billing = self._consume_credits(request.user_id, decision_id)
if not billing.get("accepted"):
status = str(billing.get("message") or "")
reason = "billing_insufficient_credits" if status.startswith("insufficient_credits") else "billing_unavailable"
result = self._result(
True,
"skipped",
"none",
reason,
decision_id,
started,
fallback_reason=status,
billing=billing,
)
self._persist(request, result)
return result
if jev_config["api_key"]:
try:
result = self._evaluate_jev(request, decision_id, started, jev_config)
result = replace(
self._evaluate_jev(request, decision_id, started, jev_config),
billing=billing,
)
self._persist(request, result)
return result
except Exception as exc:
failures.append(f"jev:{self._safe_error(exc)}")
logger.warning("Jev decision failed open: %s", exc)
llm = LLMService()
if llm.is_configured():
if llm is None:
try:
result = self._evaluate_llm(request, decision_id, started, failures)
llm = LLMService()
llm_configured = llm.is_configured()
except Exception as exc:
failures.append(f"llm:{self._safe_error(exc)}")
logger.warning("LLM configuration check failed open: %s", exc)
if llm_configured and llm is not None:
try:
result = replace(
self._evaluate_llm(request, decision_id, started, failures, service=llm),
billing=billing,
)
self._persist(request, result)
return result
except Exception as exc:
failures.append(f"llm:{self._safe_error(exc)}")
logger.warning("LLM decision failed open: %s", exc)
billing = self._refund_credits(request.user_id, billing)
reason = "ai_not_configured" if not failures else "ai_provider_unavailable"
result = self._result(
True,
@@ -190,6 +244,7 @@ class AIDecisionFilter:
decision_id,
started,
fallback_reason="; ".join(failures),
billing=billing,
)
self._persist(request, result)
return result
@@ -292,8 +347,10 @@ class AIDecisionFilter:
decision_id: str,
started: float,
failures: list[str],
*,
service: LLMService | None = None,
) -> AIDecisionResult:
service = LLMService()
service = service or LLMService()
model = service.get_default_model()
content = service.call_llm_api(
[
@@ -467,6 +524,72 @@ class AIDecisionFilter:
def _safe_error(exc: Exception) -> str:
return " ".join(str(exc or exc.__class__.__name__).split())[:300]
@staticmethod
def _consume_credits(user_id: int, decision_id: str) -> dict[str, Any]:
reference_id = f"ai-decision:{decision_id}"
receipt: dict[str, Any] = {
"feature": "ai_decision_filter",
"reference_id": reference_id,
"accepted": False,
"charged": 0,
"refunded": 0,
"status": "unavailable",
"message": "",
}
try:
from app.services.billing_service import get_billing_service
billing = get_billing_service()
cost = max(0, int(billing.get_feature_cost("ai_decision_filter") or 0))
accepted, message = billing.check_and_consume(
int(user_id or 0),
"ai_decision_filter",
reference_id,
)
status = "charged" if message == "consumed" else "free"
return {
**receipt,
"accepted": bool(accepted),
"cost": cost,
"charged": cost if accepted and message == "consumed" else 0,
"status": status if accepted else "rejected",
"message": str(message or ""),
}
except Exception as exc:
logger.warning("AI decision billing failed open: %s", exc)
return {**receipt, "message": AIDecisionFilter._safe_error(exc)}
@staticmethod
def _refund_credits(user_id: int, receipt: dict[str, Any]) -> dict[str, Any]:
charged = max(0, int(receipt.get("charged") or 0))
if charged <= 0:
return receipt
try:
from app.services.billing_service import get_billing_service
refunded, message = get_billing_service().add_credits(
user_id=int(user_id or 0),
amount=charged,
action="refund",
remark="ai_decision_provider_unavailable",
reference_id=str(receipt.get("reference_id") or ""),
)
if refunded:
return {
**receipt,
"refunded": charged,
"status": "refunded",
"refund_message": str(message or ""),
}
return {**receipt, "status": "refund_failed", "refund_message": str(message or "")}
except Exception as exc:
logger.warning("AI decision billing refund failed: %s", exc)
return {
**receipt,
"status": "refund_failed",
"refund_message": AIDecisionFilter._safe_error(exc),
}
@staticmethod
def _result(
allowed: bool,
@@ -481,6 +604,7 @@ class AIDecisionFilter:
probabilities: dict[str, Any] | None = None,
checks: list[dict[str, Any]] | None = None,
fallback_reason: str = "",
billing: dict[str, Any] | None = None,
) -> AIDecisionResult:
return AIDecisionResult(
allowed=allowed,
@@ -494,6 +618,7 @@ class AIDecisionFilter:
checks=checks or [],
latency_ms=max(0, int((time.perf_counter() - started) * 1000)),
fallback_reason=fallback_reason,
billing=billing or {},
)
@staticmethod
@@ -507,10 +632,11 @@ class AIDecisionFilter:
(decision_uid, user_id, source_type, source_id, strategy_run_id,
order_intent_id, symbol, action, market_type, provider, model,
decision, allowed, confidence, reason, fallback_reason,
probabilities_json, checks_json, request_snapshot, latency_ms, created_at)
probabilities_json, checks_json, request_snapshot, billing_json,
latency_ms, created_at)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (decision_uid) DO NOTHING
""",
(
@@ -533,6 +659,7 @@ class AIDecisionFilter:
json.dumps(result.probabilities, ensure_ascii=False, default=str),
json.dumps(result.checks, ensure_ascii=False, default=str),
AIDecisionFilter._state_text(request),
json.dumps(result.billing, ensure_ascii=False, default=str),
int(result.latency_ms),
),
)
@@ -570,7 +697,8 @@ def list_ai_decisions(
f"""
SELECT decision_uid, source_type, source_id, strategy_run_id, symbol, action,
market_type, provider, model, decision, allowed, confidence, reason,
fallback_reason, probabilities_json, checks_json, latency_ms, created_at
fallback_reason, probabilities_json, checks_json, billing_json,
latency_ms, created_at
FROM qd_ai_decisions
WHERE {' AND '.join(clauses)}
ORDER BY id DESC
@@ -12,6 +12,7 @@ DEFAULT_BILLING_CONFIG = {
"cost_ai_code_gen": 30,
"cost_ai_copilot_chat": 5,
"cost_ai_copilot_image": 15,
"cost_ai_decision_filter": 1,
}
FEATURE_NAMES = {
@@ -991,6 +991,7 @@ class BillingService:
'ai_code_gen': config.get('cost_ai_code_gen', 0),
'ai_copilot_chat': config.get('cost_ai_copilot_chat', 0),
'ai_copilot_image': config.get('cost_ai_copilot_image', 0),
'ai_decision_filter': config.get('cost_ai_decision_filter', 0),
}
}
@@ -0,0 +1,2 @@
ALTER TABLE qd_ai_decisions
ADD COLUMN IF NOT EXISTS billing_json JSONB NOT NULL DEFAULT '{}'::jsonb;
+1
View File
@@ -2612,6 +2612,7 @@ CREATE TABLE IF NOT EXISTS qd_ai_decisions (
probabilities_json JSONB NOT NULL DEFAULT '{}'::jsonb,
checks_json JSONB NOT NULL DEFAULT '[]'::jsonb,
request_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
billing_json JSONB NOT NULL DEFAULT '{}'::jsonb,
latency_ms INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
@@ -129,6 +129,7 @@ def test_billing_settings_only_expose_costs_with_real_charge_paths():
"BILLING_COST_AI_CODE_GEN",
"BILLING_COST_AI_COPILOT_CHAT",
"BILLING_COST_AI_COPILOT_IMAGE",
"BILLING_COST_AI_DECISION_FILTER",
}
assert set(DEFAULT_BILLING_CONFIG) == {
"enabled",
@@ -138,6 +139,7 @@ def test_billing_settings_only_expose_costs_with_real_charge_paths():
"cost_ai_code_gen",
"cost_ai_copilot_chat",
"cost_ai_copilot_image",
"cost_ai_decision_filter",
}
assert "ai_indicator_to_strategy" not in FEATURE_NAMES
assert "ai_copilot_radar" not in FEATURE_NAMES
@@ -149,6 +151,7 @@ def test_billing_settings_only_expose_costs_with_real_charge_paths():
"ai_code_gen": "app/routes/strategy.py",
"ai_copilot_chat": "app/routes/ai_chat.py",
"ai_copilot_image": "app/routes/ai_chat.py",
"ai_decision_filter": "app/services/ai_decision_filter.py",
}
backend_root = BACKEND_ROOT / "backend_api_python"
for feature, relative_path in charge_paths.items():
@@ -1,6 +1,20 @@
import pytest
from app.services import ai_decision_filter as module
_REAL_CONSUME_CREDITS = module.AIDecisionFilter._consume_credits
@pytest.fixture(autouse=True)
def _free_ai_decision_billing(monkeypatch):
monkeypatch.setattr(
module.AIDecisionFilter,
"_consume_credits",
staticmethod(lambda *_: _billing_receipt(charged=0, cost=0, status="free", message="billing_disabled")),
)
def _request(**overrides):
values = {
"user_id": 7,
@@ -34,6 +48,21 @@ def _jev_answers(**choices):
}
def _billing_receipt(**overrides):
receipt = {
"feature": "ai_decision_filter",
"reference_id": "ai-decision:test",
"accepted": True,
"cost": 1,
"charged": 1,
"refunded": 0,
"status": "charged",
"message": "consumed",
}
receipt.update(overrides)
return receipt
def test_exit_orders_bypass_ai(monkeypatch):
captured = []
monkeypatch.setattr(module.AIDecisionFilter, "_persist", staticmethod(lambda request, result: captured.append(result)))
@@ -244,3 +273,161 @@ def test_low_confidence_jev_result_falls_back_to_llm(monkeypatch):
assert result.allowed is True
assert result.provider == "llm"
assert "confidence below threshold" in result.fallback_reason
def test_billing_charge_and_refund_use_one_decision_reference(monkeypatch):
from app.services import billing_service
calls = []
class Billing:
def get_feature_cost(self, feature):
assert feature == "ai_decision_filter"
return 1
def check_and_consume(self, user_id, feature, reference_id):
calls.append(("consume", user_id, feature, reference_id))
return True, "consumed"
def add_credits(self, **kwargs):
calls.append(("refund", kwargs))
return True, "100"
monkeypatch.setattr(billing_service, "get_billing_service", lambda: Billing())
receipt = _REAL_CONSUME_CREDITS(7, "decision-1")
refunded = module.AIDecisionFilter._refund_credits(7, receipt)
assert receipt["charged"] == 1
assert receipt["reference_id"] == "ai-decision:decision-1"
assert refunded["status"] == "refunded"
assert refunded["refunded"] == 1
assert calls[0] == ("consume", 7, "ai_decision_filter", "ai-decision:decision-1")
assert calls[1][1]["reference_id"] == "ai-decision:decision-1"
def test_ai_decision_filter_default_cost_is_one(monkeypatch):
from app.services.billing_config import load_billing_config
monkeypatch.delenv("BILLING_COST_AI_DECISION_FILTER", raising=False)
assert load_billing_config()["cost_ai_decision_filter"] == 1
def test_insufficient_credits_skip_provider_without_blocking_order(monkeypatch):
monkeypatch.setattr(module.AIDecisionFilter, "_jev_config", staticmethod(lambda: {
"api_key": "secret",
"base_url": "https://api.typesafe.ai/v1",
"model": "jev-latest",
"timeout_seconds": "8",
"min_confidence": "0.65",
}))
monkeypatch.setattr(module.AIDecisionFilter, "_consume_credits", staticmethod(lambda *_: _billing_receipt(
accepted=False,
charged=0,
status="rejected",
message="insufficient_credits:0:1",
)))
monkeypatch.setattr(module.requests, "post", lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("provider must not run without credits")
))
monkeypatch.setattr(module.AIDecisionFilter, "_persist", staticmethod(lambda request, result: None))
result = module.AIDecisionFilter().evaluate(_request(), enabled=True)
assert result.allowed is True
assert result.decision == "skipped"
assert result.reason == "billing_insufficient_credits"
assert result.billing["charged"] == 0
def test_jev_to_llm_fallback_charges_once(monkeypatch):
calls = []
class LLM:
def is_configured(self):
return True
def get_default_model(self):
return "fallback-model"
def call_llm_api(self, *args, **kwargs):
return '{"decision":"pass","confidence":0.8,"reason":"clear","checks":[]}'
monkeypatch.setattr(module.AIDecisionFilter, "_jev_config", staticmethod(lambda: {
"api_key": "secret",
"base_url": "https://api.typesafe.ai/v1",
"model": "jev-latest",
"timeout_seconds": "8",
"min_confidence": "0.65",
}))
monkeypatch.setattr(module.AIDecisionFilter, "_consume_credits", staticmethod(
lambda *_: (calls.append("consume") or _billing_receipt())
))
monkeypatch.setattr(module.AIDecisionFilter, "_refund_credits", staticmethod(
lambda *_: (_ for _ in ()).throw(AssertionError("successful fallback must not refund"))
))
monkeypatch.setattr(module.requests, "post", lambda *args, **kwargs: (_ for _ in ()).throw(
RuntimeError("jev unavailable")
))
monkeypatch.setattr(module, "LLMService", LLM)
monkeypatch.setattr(module.AIDecisionFilter, "_persist", staticmethod(lambda request, result: None))
result = module.AIDecisionFilter().evaluate(_request(), enabled=True)
assert calls == ["consume"]
assert result.provider == "llm"
assert result.billing["charged"] == 1
def test_all_provider_failures_refund_charge(monkeypatch):
class LLM:
def is_configured(self):
return False
monkeypatch.setattr(module.AIDecisionFilter, "_jev_config", staticmethod(lambda: {
"api_key": "secret",
"base_url": "https://api.typesafe.ai/v1",
"model": "jev-latest",
"timeout_seconds": "8",
"min_confidence": "0.65",
}))
monkeypatch.setattr(module.AIDecisionFilter, "_consume_credits", staticmethod(lambda *_: _billing_receipt()))
monkeypatch.setattr(module.AIDecisionFilter, "_refund_credits", staticmethod(
lambda user_id, receipt: {**receipt, "refunded": 1, "status": "refunded"}
))
monkeypatch.setattr(module.requests, "post", lambda *args, **kwargs: (_ for _ in ()).throw(
RuntimeError("jev unavailable")
))
monkeypatch.setattr(module, "LLMService", LLM)
monkeypatch.setattr(module.AIDecisionFilter, "_persist", staticmethod(lambda request, result: None))
result = module.AIDecisionFilter().evaluate(_request(), enabled=True)
assert result.allowed is True
assert result.reason == "ai_provider_unavailable"
assert result.billing["refunded"] == 1
assert result.billing["status"] == "refunded"
def test_unconfigured_ai_does_not_charge(monkeypatch):
class LLM:
def is_configured(self):
return False
monkeypatch.setattr(module.AIDecisionFilter, "_jev_config", staticmethod(lambda: {
"api_key": "",
"base_url": "https://api.typesafe.ai/v1",
"model": "jev-latest",
"timeout_seconds": "8",
"min_confidence": "0.65",
}))
monkeypatch.setattr(module.AIDecisionFilter, "_consume_credits", staticmethod(
lambda *_: (_ for _ in ()).throw(AssertionError("unconfigured AI must not charge"))
))
monkeypatch.setattr(module, "LLMService", LLM)
monkeypatch.setattr(module.AIDecisionFilter, "_persist", staticmethod(lambda request, result: None))
result = module.AIDecisionFilter().evaluate(_request(), enabled=True)
assert result.allowed is True
assert result.reason == "ai_not_configured"