chore: add @doist/todoist-ai

dependency to package.json
اول دفعة من التطوير
This commit is contained in:
Your Name
2026-04-03 00:28:00 +02:00
parent 314f847ece
commit efb6854741
31 changed files with 2693 additions and 91 deletions

View File

@@ -77,6 +77,7 @@ def _serialize_user(row: dict | None) -> dict | None:
"role": _resolve_row_role(row),
"is_allowlisted_admin": is_allowlisted_admin_email(row.get("email")),
"created_at": row.get("created_at"),
"welcome_bonus_available": int(row.get("welcome_bonus_used", 0)) == 0,
}
@@ -252,6 +253,18 @@ def _init_postgres_tables(conn):
"ALTER TABLE usage_events ADD COLUMN cost_points INTEGER NOT NULL DEFAULT 1"
)
# Add quoted_credits column to usage_events if missing
if not _column_exists(conn, "usage_events", "quoted_credits"):
cursor.execute(
"ALTER TABLE usage_events ADD COLUMN quoted_credits INTEGER"
)
# Add welcome_bonus_used flag to users if missing
if not _column_exists(conn, "users", "welcome_bonus_used"):
cursor.execute(
"ALTER TABLE users ADD COLUMN welcome_bonus_used INTEGER NOT NULL DEFAULT 0"
)
def _init_sqlite_tables(conn):
conn.executescript(
@@ -366,6 +379,10 @@ def _init_sqlite_tables(conn):
conn.execute("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'")
if not _column_exists(conn, "usage_events", "cost_points"):
conn.execute("ALTER TABLE usage_events ADD COLUMN cost_points INTEGER NOT NULL DEFAULT 1")
if not _column_exists(conn, "usage_events", "quoted_credits"):
conn.execute("ALTER TABLE usage_events ADD COLUMN quoted_credits INTEGER")
if not _column_exists(conn, "users", "welcome_bonus_used"):
conn.execute("ALTER TABLE users ADD COLUMN welcome_bonus_used INTEGER NOT NULL DEFAULT 0")
def create_user(email: str, password: str) -> dict:
@@ -398,9 +415,9 @@ def create_user(email: str, password: str) -> dict:
user_id = cursor.lastrowid
row_sql = (
"SELECT id, email, plan, role, created_at FROM users WHERE id = %s"
"SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = %s"
if is_postgres()
else "SELECT id, email, plan, role, created_at FROM users WHERE id = ?"
else "SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = ?"
)
cursor2 = execute_query(conn, row_sql, (user_id,))
row = cursor2.fetchone()
@@ -435,9 +452,9 @@ def authenticate_user(email: str, password: str) -> dict | None:
def get_user_by_id(user_id: int) -> dict | None:
with db_connection() as conn:
sql = (
"SELECT id, email, plan, role, created_at FROM users WHERE id = %s"
"SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = %s"
if is_postgres()
else "SELECT id, email, plan, role, created_at FROM users WHERE id = ?"
else "SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = ?"
)
cursor = execute_query(conn, sql, (user_id,))
row = cursor.fetchone()
@@ -485,9 +502,9 @@ def set_user_role(user_id: int, role: str) -> dict | None:
execute_query(conn, sql, (normalized_role, _utc_now(), user_id))
sql2 = (
"SELECT id, email, plan, role, created_at FROM users WHERE id = %s"
"SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = %s"
if is_postgres()
else "SELECT id, email, plan, role, created_at FROM users WHERE id = ?"
else "SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = ?"
)
cursor = execute_query(conn, sql2, (user_id,))
row = cursor.fetchone()
@@ -518,9 +535,9 @@ def update_user_plan(user_id: int, plan: str) -> dict | None:
execute_query(conn, sql, (normalized_plan, _utc_now(), user_id))
sql2 = (
"SELECT id, email, plan, role, created_at FROM users WHERE id = %s"
"SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = %s"
if is_postgres()
else "SELECT id, email, plan, role, created_at FROM users WHERE id = ?"
else "SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = ?"
)
cursor = execute_query(conn, sql2, (user_id,))
row = cursor.fetchone()
@@ -884,6 +901,7 @@ def record_usage_event(
event_type: str,
api_key_id: int | None = None,
cost_points: int = 1,
quoted_credits: int | None = None,
):
if user_id is None:
return
@@ -893,17 +911,17 @@ def record_usage_event(
"""
INSERT INTO usage_events (
user_id, api_key_id, source, tool, task_id,
event_type, created_at, period_month, cost_points
event_type, created_at, period_month, cost_points, quoted_credits
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
"""
if is_postgres()
else """
INSERT INTO usage_events (
user_id, api_key_id, source, tool, task_id,
event_type, created_at, period_month, cost_points
event_type, created_at, period_month, cost_points, quoted_credits
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
)
execute_query(
@@ -919,6 +937,7 @@ def record_usage_event(
_utc_now(),
get_current_period_month(),
cost_points,
quoted_credits,
),
)
@@ -982,9 +1001,9 @@ def get_user_by_email(email: str) -> dict | None:
email = _normalize_email(email)
with db_connection() as conn:
sql = (
"SELECT id, email, plan, role, created_at FROM users WHERE email = %s"
"SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE email = %s"
if is_postgres()
else "SELECT id, email, plan, role, created_at FROM users WHERE email = ?"
else "SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE email = ?"
)
cursor = execute_query(conn, sql, (email,))
row = row_to_dict(cursor.fetchone())
@@ -1087,3 +1106,32 @@ def log_file_event(
else "INSERT INTO file_events (event_type, file_path, detail, created_at) VALUES (?, ?, ?, ?)"
)
execute_query(conn, sql, (event_type, file_path, detail, _utc_now()))
# ── Welcome-bonus helpers ───────────────────────────────────────
def is_welcome_bonus_available(user_id: int) -> bool:
"""Return True if the user has never used their welcome bonus."""
with db_connection() as conn:
sql = (
"SELECT welcome_bonus_used FROM users WHERE id = %s"
if is_postgres()
else "SELECT welcome_bonus_used FROM users WHERE id = ?"
)
cursor = execute_query(conn, sql, (user_id,))
row = row_to_dict(cursor.fetchone())
if row is None:
return False
return int(row.get("welcome_bonus_used", 0)) == 0
def consume_welcome_bonus(user_id: int) -> bool:
"""Mark the welcome bonus as used. Returns True if it was actually consumed."""
with db_connection() as conn:
sql = (
"UPDATE users SET welcome_bonus_used = 1, updated_at = %s WHERE id = %s AND welcome_bonus_used = 0"
if is_postgres()
else "UPDATE users SET welcome_bonus_used = 1, updated_at = ? WHERE id = ? AND welcome_bonus_used = 0"
)
cursor = execute_query(conn, sql, (_utc_now(), user_id))
return cursor.rowcount > 0

View File

@@ -4,12 +4,19 @@ Every tool has a credit cost. Lighter tools cost 1 credit, heavier
server-side conversions cost 2, CPU/ML-intensive tools cost 3,
and AI-powered tools cost 5+.
Heavy and AI tools use dynamic pricing based on file/request size.
Light and medium tools keep a fixed cost per invocation.
This module is the single source of truth for all credit-related
constants consumed by policy_service, credit_service, and the
frontend config endpoint.
constants consumed by policy_service, credit_service, quote_service,
and the frontend config endpoint.
"""
from __future__ import annotations
import math
import os
from dataclasses import dataclass
# ── Credit allocations per rolling 30-day window ────────────────
FREE_CREDITS_PER_WINDOW = int(os.getenv("FREE_CREDITS_PER_WINDOW", "50"))
@@ -29,6 +36,98 @@ TIER_MEDIUM = 2 # Server-side conversion (LibreOffice, Ghostscript, etc.)
TIER_HEAVY = 3 # CPU/ML-intensive (OCR, background removal, compression)
TIER_AI = 5 # AI-powered tools (LLM API calls)
# ── Dynamic pricing model ──────────────────────────────────────
# Tools marked as DYNAMIC_PRICING_TIERS use size-based cost instead of
# fixed tier cost. The formula is:
# cost = base + ceil(file_size_kb / step_kb) * per_step
# capped at max_credits. AI tools also add an estimated-tokens surcharge.
DYNAMIC_PRICING_TIERS: set[str] = {"heavy", "ai"}
@dataclass(frozen=True)
class DynamicPricingRule:
"""Size-based pricing rule for one tool family."""
base: int # minimum credits charged
step_kb: int # every `step_kb` KB adds `per_step` credits
per_step: int # credits added per step
max_credits: int # hard cap on credits per invocation
# AI-specific: extra credits per estimated 1 000 input tokens
token_step: int = 0 # 0 means no token surcharge
per_token_step: int = 0
# Default rules per tier — individual tools can override via
# TOOL_DYNAMIC_OVERRIDES below.
HEAVY_DEFAULT_RULE = DynamicPricingRule(
base=3, step_kb=500, per_step=1, max_credits=10,
)
AI_DEFAULT_RULE = DynamicPricingRule(
base=5, step_kb=200, per_step=1, max_credits=20,
token_step=1000, per_token_step=1,
)
# Per-tool overrides (tool slug → rule).
TOOL_DYNAMIC_OVERRIDES: dict[str, DynamicPricingRule] = {
# Translation is heavier than chat/summarize
"translate-pdf": DynamicPricingRule(
base=6, step_kb=150, per_step=1, max_credits=25,
token_step=1000, per_token_step=1,
),
}
def _tier_label(cost: int) -> str:
"""Return the tier family name for a fixed cost value."""
if cost >= TIER_AI:
return "ai"
if cost >= TIER_HEAVY:
return "heavy"
if cost >= TIER_MEDIUM:
return "medium"
return "light"
def _get_dynamic_rule(tool: str, tier: str) -> DynamicPricingRule | None:
"""Return the dynamic pricing rule for *tool*, or None if fixed."""
if tier not in DYNAMIC_PRICING_TIERS:
return None
if tool in TOOL_DYNAMIC_OVERRIDES:
return TOOL_DYNAMIC_OVERRIDES[tool]
return AI_DEFAULT_RULE if tier == "ai" else HEAVY_DEFAULT_RULE
def is_dynamic_tool(tool: str) -> bool:
"""Return True if *tool* uses size-based dynamic pricing."""
base = TOOL_CREDIT_COSTS.get(tool, DEFAULT_CREDIT_COST)
return _tier_label(base) in DYNAMIC_PRICING_TIERS
def calculate_dynamic_cost(
tool: str,
file_size_kb: float = 0,
estimated_tokens: int = 0,
) -> int:
"""Calculate the credit cost for a dynamic tool given size metrics.
For fixed-price tools this returns the static tier cost unchanged.
"""
base_cost = TOOL_CREDIT_COSTS.get(tool, DEFAULT_CREDIT_COST)
tier = _tier_label(base_cost)
rule = _get_dynamic_rule(tool, tier)
if rule is None:
return base_cost
size_surcharge = math.ceil(max(0, file_size_kb) / rule.step_kb) * rule.per_step
token_surcharge = 0
if rule.token_step and estimated_tokens > 0:
token_surcharge = math.ceil(estimated_tokens / rule.token_step) * rule.per_token_step
total = rule.base + size_surcharge + token_surcharge
return min(total, rule.max_credits)
# ── Per-tool credit costs ───────────────────────────────────────
# Keys match the `tool` parameter passed to record_usage_event / routes.
TOOL_CREDIT_COSTS: dict[str, int] = {
@@ -108,7 +207,11 @@ DEFAULT_CREDIT_COST = TIER_LIGHT
def get_tool_credit_cost(tool: str) -> int:
"""Return the credit cost for a given tool slug."""
"""Return the *fixed* credit cost for a given tool slug.
For dynamic tools this is the base/minimum cost.
Use :func:`calculate_dynamic_cost` when file size is known.
"""
return TOOL_CREDIT_COSTS.get(tool, DEFAULT_CREDIT_COST)
@@ -120,3 +223,29 @@ def get_credits_for_plan(plan: str) -> int:
def get_all_tool_costs() -> dict[str, int]:
"""Return the full cost registry — used by the config API endpoint."""
return dict(TOOL_CREDIT_COSTS)
def get_dynamic_tools_info() -> dict[str, dict]:
"""Return metadata about tools that use dynamic pricing.
Used by the config/credit-info endpoint so the frontend can display
"price varies by file size" for these tools.
"""
result: dict[str, dict] = {}
seen: set[str] = set()
for slug, cost in TOOL_CREDIT_COSTS.items():
tier = _tier_label(cost)
rule = _get_dynamic_rule(slug, tier)
if rule is None:
continue
if slug in seen:
continue
seen.add(slug)
result[slug] = {
"base": rule.base,
"step_kb": rule.step_kb,
"per_step": rule.per_step,
"max_credits": rule.max_credits,
"has_token_surcharge": rule.token_step > 0,
}
return result

View File

@@ -219,19 +219,26 @@ def deduct_credits(user_id: int, plan: str, tool: str) -> int:
Raises ValueError if insufficient credits.
"""
cost = get_tool_credit_cost(tool)
return deduct_credits_quoted(user_id, plan, cost)
def deduct_credits_quoted(user_id: int, plan: str, cost: int) -> int:
"""Deduct an explicit credit amount from the user's window.
Used by the quote engine to deduct a pre-calculated (possibly dynamic)
cost rather than looking up the fixed tier cost.
Raises ValueError if insufficient credits.
"""
with db_connection() as conn:
# Ensure window is current
window = _get_window(conn, user_id)
if window is None or _is_window_expired(window.get("window_end_at", "")):
# get_or_create handles reset
pass
window = get_or_create_credit_window(user_id, plan)
balance = window["credits_allocated"] - window["credits_used"]
if balance < cost:
raise ValueError(
f"Insufficient credits: {balance} remaining, {cost} required for {tool}."
f"Insufficient credits: {balance} remaining, {cost} required."
)
sql = (

View File

@@ -29,6 +29,7 @@ from app.services.guest_budget_service import (
assert_guest_budget_available,
record_guest_usage,
)
from app.services.quote_service import CreditQuote, fulfill_quote
from app.utils.auth import get_current_user_id, logout_user_session
from app.utils.auth import has_session_task_access, remember_task_access
from app.utils.file_validator import validate_file
@@ -223,29 +224,48 @@ def assert_quota_available(actor: ActorContext, tool: str | None = None):
raise PolicyError("Your monthly API quota has been reached.", 429)
def record_accepted_usage(actor: ActorContext, tool: str, celery_task_id: str):
"""Record one accepted usage event and deduct credits after task dispatch."""
def record_accepted_usage(actor: ActorContext, tool: str, celery_task_id: str, quote: CreditQuote | None = None):
"""Record one accepted usage event and deduct credits after task dispatch.
When *quote* is provided the quote engine handles deduction (supports
dynamic pricing and welcome bonus). Without a quote, falls back to the
legacy fixed-cost deduction path.
"""
if actor.source == "web":
remember_task_access(celery_task_id)
cost = get_tool_credit_cost(tool)
charged = 0
# Deduct credits from the rolling window (registered users only)
if actor.user_id is not None and actor.source == "web":
try:
deduct_credits(actor.user_id, actor.plan, tool)
except ValueError:
# Balance check should have caught this in assert_quota_available.
# Log but don't block — the usage event is the source of truth.
import logging
logging.getLogger(__name__).warning(
"Credit deduction failed for user %d tool %s (insufficient balance at record time)",
actor.user_id,
tool,
)
if quote is not None:
try:
charged = fulfill_quote(quote, actor.user_id, actor.plan)
except ValueError:
import logging
logging.getLogger(__name__).warning(
"Quote fulfillment failed for user %d tool %s",
actor.user_id,
tool,
)
charged = quote.charged_credits
else:
cost = get_tool_credit_cost(tool)
try:
deduct_credits(actor.user_id, actor.plan, tool)
charged = cost
except ValueError:
import logging
logging.getLogger(__name__).warning(
"Credit deduction failed for user %d tool %s (insufficient balance at record time)",
actor.user_id,
tool,
)
charged = cost
elif actor.user_id is None and actor.source == "web":
# Record guest demo usage
record_guest_usage()
charged = get_tool_credit_cost(tool)
record_usage_event(
user_id=actor.user_id,
@@ -254,7 +274,8 @@ def record_accepted_usage(actor: ActorContext, tool: str, celery_task_id: str):
task_id=celery_task_id,
event_type="accepted",
api_key_id=actor.api_key_id,
cost_points=cost,
cost_points=charged,
quoted_credits=quote.quoted_credits if quote else None,
)

View File

@@ -0,0 +1,211 @@
"""Central credit-quote engine — calculate and lock a price before dispatch.
The quote engine sits between route handlers and the credit/policy layer.
It produces a ``CreditQuote`` that is:
1. Shown to the user in the 202 response.
2. Used to deduct credits (instead of the old fixed-cost path).
3. Stored in usage_events.quoted_credits for audit.
Light/medium tools still get a fixed quote (== tier cost, no size factor).
Heavy and AI tools get a dynamic quote based on file size and estimated
tokens (for AI tools).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from app.services.account_service import (
consume_welcome_bonus,
is_welcome_bonus_available,
)
from app.services.credit_config import (
calculate_dynamic_cost,
get_tool_credit_cost,
is_dynamic_tool,
)
from app.services.credit_service import (
deduct_credits_quoted,
get_rolling_balance,
)
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class CreditQuote:
"""An immutable, pre-dispatch credit quote."""
tool: str
base_cost: int # fixed tier cost
quoted_credits: int # final cost after dynamic calc
charged_credits: int # actual credits that will be deducted (0 if welcome bonus)
welcome_bonus_applied: bool
is_dynamic: bool
file_size_kb: float
estimated_tokens: int
balance_before: int
balance_after: int
def to_dict(self) -> dict:
"""Serialize for JSON response payloads."""
return {
"tool": self.tool,
"quoted_credits": self.quoted_credits,
"charged_credits": self.charged_credits,
"welcome_bonus_applied": self.welcome_bonus_applied,
"is_dynamic": self.is_dynamic,
"file_size_kb": round(self.file_size_kb, 1),
"balance_before": self.balance_before,
"balance_after": self.balance_after,
}
class QuoteError(Exception):
"""Quote could not be fulfilled (insufficient credits, etc.)."""
def __init__(self, message: str, status_code: int = 402):
self.message = message
self.status_code = status_code
super().__init__(message)
# ── Public API ──────────────────────────────────────────────────
def create_quote(
user_id: int | None,
plan: str,
tool: str,
file_size_kb: float = 0,
estimated_tokens: int = 0,
) -> CreditQuote:
"""Build a quote for one tool invocation.
For anonymous users (user_id is None), returns a zero-cost quote
because guest budget is handled separately by guest_budget_service.
"""
if user_id is None:
base = get_tool_credit_cost(tool)
return CreditQuote(
tool=tool,
base_cost=base,
quoted_credits=base,
charged_credits=0,
welcome_bonus_applied=False,
is_dynamic=False,
file_size_kb=file_size_kb,
estimated_tokens=estimated_tokens,
balance_before=0,
balance_after=0,
)
dynamic = is_dynamic_tool(tool)
base = get_tool_credit_cost(tool)
if dynamic:
quoted = calculate_dynamic_cost(tool, file_size_kb, estimated_tokens)
else:
quoted = base
balance = get_rolling_balance(user_id, plan)
# Welcome bonus: first transaction is free
bonus_available = is_welcome_bonus_available(user_id)
if bonus_available:
charged = 0
else:
charged = quoted
if charged > balance:
raise QuoteError(
f"Insufficient credits: {balance} remaining, {charged} required for {tool}.",
402,
)
balance_after = balance - charged
return CreditQuote(
tool=tool,
base_cost=base,
quoted_credits=quoted,
charged_credits=charged,
welcome_bonus_applied=bonus_available,
is_dynamic=dynamic,
file_size_kb=file_size_kb,
estimated_tokens=estimated_tokens,
balance_before=balance,
balance_after=balance_after,
)
def estimate_quote(
user_id: int | None,
plan: str,
tool: str,
file_size_kb: float = 0,
estimated_tokens: int = 0,
) -> dict:
"""Return a non-binding estimate (for the frontend pre-upload panel).
Does NOT lock or deduct anything — purely informational.
"""
try:
quote = create_quote(user_id, plan, tool, file_size_kb, estimated_tokens)
result = quote.to_dict()
result["affordable"] = True
return result
except QuoteError as exc:
base = get_tool_credit_cost(tool)
dynamic = is_dynamic_tool(tool)
if dynamic:
quoted = calculate_dynamic_cost(tool, file_size_kb, estimated_tokens)
else:
quoted = base
balance = get_rolling_balance(user_id, plan) if user_id else 0
return {
"tool": tool,
"quoted_credits": quoted,
"charged_credits": quoted,
"welcome_bonus_applied": False,
"is_dynamic": dynamic,
"file_size_kb": round(file_size_kb, 1),
"balance_before": balance,
"balance_after": balance - quoted,
"affordable": False,
"reason": exc.message,
}
def fulfill_quote(
quote: CreditQuote,
user_id: int,
plan: str,
) -> int:
"""Deduct credits based on a locked quote. Returns credits charged.
If welcome bonus is applied, the bonus is consumed atomically and
zero credits are deducted from the rolling window.
"""
if quote.welcome_bonus_applied:
consumed = consume_welcome_bonus(user_id)
if not consumed:
# Race condition: bonus was consumed between quote and fulfill.
# Fall back to normal deduction.
logger.warning(
"Welcome bonus race for user %d — falling back to normal deduction",
user_id,
)
return deduct_credits_quoted(user_id, plan, quote.quoted_credits)
logger.info(
"Welcome bonus applied for user %d, tool=%s, cost=%d waived",
user_id,
quote.tool,
quote.quoted_credits,
)
return 0
if quote.charged_credits == 0:
return 0
return deduct_credits_quoted(user_id, plan, quote.charged_credits)