safety layer: hard daily budget, user quotas, spend report, forgetme + privacy

This commit is contained in:
Oleksandr Kozachuk
2026-07-13 13:21:45 +02:00
parent f6c3e7d8e5
commit 02c989946b
11 changed files with 465 additions and 6 deletions
+20
View File
@@ -10,6 +10,7 @@ import openai
from .ai_responder import AIResponder, exponential_backoff, pp, sanitize_external_text
from .igdblib import IGDBQuery
from .leonardo_draw import LeonardoAIDrawMixIn
from .quota import QuotaLedger
# The response envelope, enforced server-side via structured outputs
# (ENV-19). All fields required, closed object, nullable where the
@@ -48,6 +49,8 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
self.client = openai.AsyncOpenAI(api_key=self.config.get("openai-token", self.config.get("openai-key", "")))
# After a rate limit the next attempt runs on retry-model (ENV-15 / D2)
self._use_retry_model = False
# Daily usage metering + hard budget, fail-closed (SAF-04/05)
self.ledger = QuotaLedger(self.store, lambda: self.config)
# Initialize IGDB if enabled
self.igdb = None
@@ -69,21 +72,36 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
logging.warning("❌ IGDB integration DISABLED - missing configuration or disabled in config")
async def draw_openai(self, description: str) -> BytesIO:
if not self.ledger.budget_ok():
raise RuntimeError("daily budget exhausted - refusing image call")
for _ in range(3):
try:
response = await openai_image(self.client, prompt=description, n=1, size="1024x1024", model="dall-e-3")
self.ledger.add_images(1)
logging.info(f"Drawed a picture with DALL-E on this description: {repr(description)}")
return response
except Exception as err:
logging.warning(f"Failed to generate image {repr(description)}: {repr(err)}")
raise RuntimeError(f"Failed to generate image {repr(description)} after multiple retries")
def _record_usage(self, result: Any) -> None:
usage = getattr(result, "usage", None)
prompt_tokens = getattr(usage, "prompt_tokens", None)
completion_tokens = getattr(usage, "completion_tokens", None)
if isinstance(prompt_tokens, int) and isinstance(completion_tokens, int):
self.ledger.add_tokens(prompt_tokens, completion_tokens)
async def chat(self, messages: List[Dict[str, Any]], limit: int) -> Tuple[Optional[Dict[str, Any]], int]:
# Safety check for mock objects in tests
if not isinstance(messages, list) or len(messages) == 0:
logging.warning("Invalid messages format in chat method")
return None, limit
# Hard daily budget, fail-closed (SAF-04)
if not self.ledger.budget_ok():
logging.error("daily budget exhausted - refusing model call")
return None, limit
try:
# Clean up any orphaned tool messages from previous conversations
clean_messages = []
@@ -134,6 +152,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
)
result = await openai_chat(self.client, **chat_kwargs)
self._record_usage(result)
# Handle function calls if present
message = result.choices[0].message
@@ -204,6 +223,7 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
logging.debug(f"🔧 Last few messages: {messages[-3:] if len(messages) > 3 else messages}")
final_result = await openai_chat(self.client, **final_chat_kwargs)
self._record_usage(final_result)
answer_obj = final_result.choices[0].message
logging.debug(