mirror of
https://github.com/OpenByteInc/QuantDinger.git
synced 2026-09-29 17:07:04 +08:00
feat: bill AI decision filtering
This commit is contained in:
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user