Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
bdca29f
1
Parent(s): 159ebd9
Sync backend to GitHub d13146e: Roll main back to 4.9.0 (revert the 5.0.0 merge)
Browse files- backend/core/config.py +0 -11
- backend/core/ratelimit.py +4 -7
- backend/core/security.py +4 -49
- backend/main.py +2 -2
- backend/routers/auth.py +2 -35
- backend/routers/prompts.py +44 -116
- backend/services/prompt_builder.py +6 -12
backend/core/config.py
CHANGED
|
@@ -172,18 +172,7 @@ class Settings:
|
|
| 172 |
SHARED_KEY_DAILY_LIMIT = int(os.getenv("SHARED_KEY_DAILY_LIMIT", "15"))
|
| 173 |
BYOK_DAILY_LIMIT = int(os.getenv("BYOK_DAILY_LIMIT", "1000"))
|
| 174 |
|
| 175 |
-
# Anonymous tier: an install that has not signed in. Quick rewrites only,
|
| 176 |
-
# no memory, nothing stored. Two caps: one per install, and one across all
|
| 177 |
-
# anonymous installs together, because a token is free to mint and the
|
| 178 |
-
# global cap is what actually bounds spend on the server key.
|
| 179 |
-
ANON_DAILY_LIMIT = int(os.getenv("ANON_DAILY_LIMIT", "10"))
|
| 180 |
-
ANON_GLOBAL_DAILY_LIMIT = int(os.getenv("ANON_GLOBAL_DAILY_LIMIT", "2000"))
|
| 181 |
-
ANON_TOKEN_DAYS = int(os.getenv("ANON_TOKEN_DAYS", "90"))
|
| 182 |
-
# New anonymous tokens per client address per hour.
|
| 183 |
-
ANON_MINT_PER_HOUR = int(os.getenv("ANON_MINT_PER_HOUR", "10"))
|
| 184 |
-
|
| 185 |
TIER_LIMITS = {
|
| 186 |
-
"anon": ANON_DAILY_LIMIT,
|
| 187 |
"free": int(os.getenv("FREE_TIER_LIMIT", str(SHARED_KEY_DAILY_LIMIT))),
|
| 188 |
"byok": int(os.getenv("BYOK_DAILY_LIMIT", "1000")),
|
| 189 |
"pro": int(os.getenv("PRO_TIER_LIMIT", "200")),
|
|
|
|
| 172 |
SHARED_KEY_DAILY_LIMIT = int(os.getenv("SHARED_KEY_DAILY_LIMIT", "15"))
|
| 173 |
BYOK_DAILY_LIMIT = int(os.getenv("BYOK_DAILY_LIMIT", "1000"))
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
TIER_LIMITS = {
|
|
|
|
| 176 |
"free": int(os.getenv("FREE_TIER_LIMIT", str(SHARED_KEY_DAILY_LIMIT))),
|
| 177 |
"byok": int(os.getenv("BYOK_DAILY_LIMIT", "1000")),
|
| 178 |
"pro": int(os.getenv("PRO_TIER_LIMIT", "200")),
|
backend/core/ratelimit.py
CHANGED
|
@@ -25,7 +25,7 @@ from collections import defaultdict, deque
|
|
| 25 |
from fastapi import Depends, HTTPException
|
| 26 |
|
| 27 |
from .config import settings
|
| 28 |
-
from .security import verify_jwt
|
| 29 |
|
| 30 |
_lock = threading.Lock()
|
| 31 |
_hits: dict = defaultdict(deque) # bucket key -> deque[timestamp]
|
|
@@ -62,16 +62,14 @@ def _check(key: str, limit: int, window: int) -> tuple:
|
|
| 62 |
return True, 0
|
| 63 |
|
| 64 |
|
| 65 |
-
def limit(rule: str, scope: str
|
| 66 |
"""
|
| 67 |
Build a dependency enforcing `rule` for the authenticated user.
|
| 68 |
|
| 69 |
Depends on verify_jwt, so an unauthenticated caller is still rejected with
|
| 70 |
the usual 401 before any counting happens.
|
| 71 |
"""
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
def dependency(user_id: str = Depends(auth)) -> str:
|
| 75 |
limit_n, window = parse_rule(rule)
|
| 76 |
allowed, retry_after = _check(f"{scope}:{user_id}", limit_n, window)
|
| 77 |
if not allowed:
|
|
@@ -84,6 +82,5 @@ def limit(rule: str, scope: str, allow_anon: bool = False):
|
|
| 84 |
return dependency
|
| 85 |
|
| 86 |
|
| 87 |
-
|
| 88 |
-
enhance_limit = limit(settings.RATE_LIMIT_ENHANCE, "enhance", allow_anon=True)
|
| 89 |
voice_limit = limit(settings.RATE_LIMIT_VOICE, "voice")
|
|
|
|
| 25 |
from fastapi import Depends, HTTPException
|
| 26 |
|
| 27 |
from .config import settings
|
| 28 |
+
from .security import verify_jwt
|
| 29 |
|
| 30 |
_lock = threading.Lock()
|
| 31 |
_hits: dict = defaultdict(deque) # bucket key -> deque[timestamp]
|
|
|
|
| 62 |
return True, 0
|
| 63 |
|
| 64 |
|
| 65 |
+
def limit(rule: str, scope: str):
|
| 66 |
"""
|
| 67 |
Build a dependency enforcing `rule` for the authenticated user.
|
| 68 |
|
| 69 |
Depends on verify_jwt, so an unauthenticated caller is still rejected with
|
| 70 |
the usual 401 before any counting happens.
|
| 71 |
"""
|
| 72 |
+
def dependency(user_id: str = Depends(verify_jwt)) -> str:
|
|
|
|
|
|
|
| 73 |
limit_n, window = parse_rule(rule)
|
| 74 |
allowed, retry_after = _check(f"{scope}:{user_id}", limit_n, window)
|
| 75 |
if not allowed:
|
|
|
|
| 82 |
return dependency
|
| 83 |
|
| 84 |
|
| 85 |
+
enhance_limit = limit(settings.RATE_LIMIT_ENHANCE, "enhance")
|
|
|
|
| 86 |
voice_limit = limit(settings.RATE_LIMIT_VOICE, "voice")
|
backend/core/security.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
| 1 |
|
| 2 |
-
import uuid
|
| 3 |
from datetime import datetime, timedelta
|
| 4 |
import jwt
|
| 5 |
from fastapi import HTTPException, Depends
|
|
@@ -8,16 +7,6 @@ from .config import settings
|
|
| 8 |
|
| 9 |
security_scheme = HTTPBearer()
|
| 10 |
|
| 11 |
-
# Anonymous installs get a signed token whose subject carries this prefix.
|
| 12 |
-
# Google accounts are uuid4 strings, so the two can never collide, and the
|
| 13 |
-
# prefix lets every route tell them apart without a database read.
|
| 14 |
-
ANON_PREFIX = "anon_"
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
def is_anonymous(user_id: str) -> bool:
|
| 18 |
-
return str(user_id or "").startswith(ANON_PREFIX)
|
| 19 |
-
|
| 20 |
-
|
| 21 |
def create_jwt_token(user_id: str, email: str) -> str:
|
| 22 |
"""Creates a signed JWT with 7-day expiration."""
|
| 23 |
expiration = datetime.utcnow() + timedelta(days=7)
|
|
@@ -28,47 +17,13 @@ def create_jwt_token(user_id: str, email: str) -> str:
|
|
| 28 |
}
|
| 29 |
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.ALGORITHM)
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
"""
|
| 34 |
-
A token for an install that has not signed in. Returns (token, user_id).
|
| 35 |
-
|
| 36 |
-
It names no person and nothing is stored against it: it only lets the
|
| 37 |
-
server count one install's free rewrites. No email, so /auth/refresh
|
| 38 |
-
refuses it; when it lapses the extension asks for a new one.
|
| 39 |
-
"""
|
| 40 |
-
user_id = f"{ANON_PREFIX}{uuid.uuid4().hex}"
|
| 41 |
-
payload = {
|
| 42 |
-
"sub": user_id,
|
| 43 |
-
"anon": True,
|
| 44 |
-
"exp": datetime.utcnow() + timedelta(days=settings.ANON_TOKEN_DAYS),
|
| 45 |
-
}
|
| 46 |
-
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.ALGORITHM), user_id
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
def _decode_subject(credentials: HTTPAuthorizationCredentials) -> str:
|
| 50 |
token = credentials.credentials
|
| 51 |
try:
|
| 52 |
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.ALGORITHM])
|
| 53 |
-
return payload["sub"]
|
| 54 |
except jwt.ExpiredSignatureError:
|
| 55 |
raise HTTPException(status_code=401, detail="Token expired")
|
| 56 |
-
except
|
| 57 |
raise HTTPException(status_code=401, detail="Invalid token")
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def verify_jwt(credentials: HTTPAuthorizationCredentials = Depends(security_scheme)):
|
| 61 |
-
"""
|
| 62 |
-
A signed-in user's id. Anonymous tokens are refused here, so every route
|
| 63 |
-
that keeps data (library, history, feedback, voice) needs an account
|
| 64 |
-
without each one having to remember to check.
|
| 65 |
-
"""
|
| 66 |
-
user_id = _decode_subject(credentials)
|
| 67 |
-
if is_anonymous(user_id):
|
| 68 |
-
raise HTTPException(status_code=403, detail="sign_in_required")
|
| 69 |
-
return user_id
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
def verify_jwt_or_anon(credentials: HTTPAuthorizationCredentials = Depends(security_scheme)):
|
| 73 |
-
"""A signed-in or anonymous id. Only for the free rewrite and its usage count."""
|
| 74 |
-
return _decode_subject(credentials)
|
|
|
|
| 1 |
|
|
|
|
| 2 |
from datetime import datetime, timedelta
|
| 3 |
import jwt
|
| 4 |
from fastapi import HTTPException, Depends
|
|
|
|
| 7 |
|
| 8 |
security_scheme = HTTPBearer()
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
def create_jwt_token(user_id: str, email: str) -> str:
|
| 11 |
"""Creates a signed JWT with 7-day expiration."""
|
| 12 |
expiration = datetime.utcnow() + timedelta(days=7)
|
|
|
|
| 17 |
}
|
| 18 |
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.ALGORITHM)
|
| 19 |
|
| 20 |
+
def verify_jwt(credentials: HTTPAuthorizationCredentials = Depends(security_scheme)):
|
| 21 |
+
"""Dependency to verify JWT header."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
token = credentials.credentials
|
| 23 |
try:
|
| 24 |
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.ALGORITHM])
|
| 25 |
+
return payload["sub"] # Returns user_id
|
| 26 |
except jwt.ExpiredSignatureError:
|
| 27 |
raise HTTPException(status_code=401, detail="Token expired")
|
| 28 |
+
except jwt.InvalidTokenError:
|
| 29 |
raise HTTPException(status_code=401, detail="Invalid token")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/main.py
CHANGED
|
@@ -136,7 +136,7 @@ def startup_db_client():
|
|
| 136 |
env_label = "🔧 DEVELOPMENT" if not settings.is_production else "🚀 PRODUCTION"
|
| 137 |
cors_label = "* (all origins)" if not settings.is_production else ", ".join(settings.cors_origins) or "(none configured!)"
|
| 138 |
logger.info(f"\n{'='*60}")
|
| 139 |
-
logger.info(f"🚀 Prompt Memory
|
| 140 |
logger.info(f" Environment: {env_label}")
|
| 141 |
logger.info(f" CORS Origins: {cors_label}")
|
| 142 |
logger.info(f" http://localhost:8000")
|
|
@@ -162,7 +162,7 @@ def health_check():
|
|
| 162 |
return {
|
| 163 |
"status": "running",
|
| 164 |
"service": "Context-Aware Prompt Engine",
|
| 165 |
-
"version": "
|
| 166 |
"environment": "production" if settings.is_production else "development",
|
| 167 |
"cors_origins": len(settings.cors_origins),
|
| 168 |
}
|
|
|
|
| 136 |
env_label = "🔧 DEVELOPMENT" if not settings.is_production else "🚀 PRODUCTION"
|
| 137 |
cors_label = "* (all origins)" if not settings.is_production else ", ".join(settings.cors_origins) or "(none configured!)"
|
| 138 |
logger.info(f"\n{'='*60}")
|
| 139 |
+
logger.info(f"🚀 Prompt Memory v4.0 — Server Ready!")
|
| 140 |
logger.info(f" Environment: {env_label}")
|
| 141 |
logger.info(f" CORS Origins: {cors_label}")
|
| 142 |
logger.info(f" http://localhost:8000")
|
|
|
|
| 162 |
return {
|
| 163 |
"status": "running",
|
| 164 |
"service": "Context-Aware Prompt Engine",
|
| 165 |
+
"version": "4.1",
|
| 166 |
"environment": "production" if settings.is_production else "development",
|
| 167 |
"cors_origins": len(settings.cors_origins),
|
| 168 |
}
|
backend/routers/auth.py
CHANGED
|
@@ -4,13 +4,12 @@ import uuid
|
|
| 4 |
import secrets
|
| 5 |
import httpx
|
| 6 |
import jwt
|
| 7 |
-
from fastapi import APIRouter, HTTPException, Depends
|
| 8 |
from fastapi.responses import HTMLResponse
|
| 9 |
from ..models.schemas import RefreshRequest
|
| 10 |
from ..core.config import settings
|
| 11 |
from ..core.database import MongoDB, in_memory_users
|
| 12 |
-
from ..core.security import create_jwt_token
|
| 13 |
-
from ..core import ratelimit
|
| 14 |
|
| 15 |
router = APIRouter()
|
| 16 |
_oauth_state_store = {} # state -> expiry. CSRF protection for OAuth.
|
|
@@ -183,38 +182,6 @@ def google_poll(state: str):
|
|
| 183 |
return {"status": "ready", **entry[1]}
|
| 184 |
|
| 185 |
|
| 186 |
-
# --- ANONYMOUS INSTALLS ---
|
| 187 |
-
|
| 188 |
-
def _client_address(request: Request) -> str:
|
| 189 |
-
"""The caller's address. The Space sits behind a proxy, so the first
|
| 190 |
-
X-Forwarded-For hop is the real client; the socket peer is the proxy."""
|
| 191 |
-
forwarded = request.headers.get("x-forwarded-for", "")
|
| 192 |
-
if forwarded:
|
| 193 |
-
return forwarded.split(",")[0].strip()
|
| 194 |
-
return request.client.host if request.client else "unknown"
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
@router.post("/auth/anonymous")
|
| 198 |
-
def anonymous_token(request: Request):
|
| 199 |
-
"""
|
| 200 |
-
A token for an install that has not signed in, so a new user's first
|
| 201 |
-
rewrite needs no account and no API key. Nothing is stored. Minting is
|
| 202 |
-
capped per address; the real bound on spend is the global anonymous cap
|
| 203 |
-
in /enhance.
|
| 204 |
-
"""
|
| 205 |
-
allowed, retry_after = ratelimit._check(
|
| 206 |
-
f"anon_mint:{_client_address(request)}", settings.ANON_MINT_PER_HOUR, 3600
|
| 207 |
-
)
|
| 208 |
-
if not allowed:
|
| 209 |
-
raise HTTPException(
|
| 210 |
-
status_code=429,
|
| 211 |
-
detail="Too many new sessions from this network. Try again later.",
|
| 212 |
-
headers={"Retry-After": str(retry_after)},
|
| 213 |
-
)
|
| 214 |
-
token, user_id = create_anon_token()
|
| 215 |
-
return {"token": token, "anon": True, "daily_limit": settings.ANON_DAILY_LIMIT}
|
| 216 |
-
|
| 217 |
-
|
| 218 |
# --- TOKEN REFRESH ---
|
| 219 |
|
| 220 |
@router.post("/auth/refresh")
|
|
|
|
| 4 |
import secrets
|
| 5 |
import httpx
|
| 6 |
import jwt
|
| 7 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 8 |
from fastapi.responses import HTMLResponse
|
| 9 |
from ..models.schemas import RefreshRequest
|
| 10 |
from ..core.config import settings
|
| 11 |
from ..core.database import MongoDB, in_memory_users
|
| 12 |
+
from ..core.security import create_jwt_token
|
|
|
|
| 13 |
|
| 14 |
router = APIRouter()
|
| 15 |
_oauth_state_store = {} # state -> expiry. CSRF protection for OAuth.
|
|
|
|
| 182 |
return {"status": "ready", **entry[1]}
|
| 183 |
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
# --- TOKEN REFRESH ---
|
| 186 |
|
| 187 |
@router.post("/auth/refresh")
|
backend/routers/prompts.py
CHANGED
|
@@ -10,10 +10,10 @@ from fastapi import APIRouter, Depends, UploadFile, File, Form, Request, Backgro
|
|
| 10 |
from fastapi.responses import StreamingResponse, JSONResponse
|
| 11 |
from ..models.schemas import TrackRequest, EnhanceRequest, FeedbackRequest
|
| 12 |
from ..core.config import settings
|
| 13 |
-
from ..core.security import verify_jwt
|
| 14 |
from ..core.ratelimit import enhance_limit, voice_limit
|
| 15 |
from ..core import usage
|
| 16 |
-
from ..core.database import MongoDB, in_memory_users, in_memory_saved_prompts
|
| 17 |
from ..services.analytics_service import AnalyticsService
|
| 18 |
from ..services.memory_service import MemoryService
|
| 19 |
from ..services.llm_service import get_groq_client, mark_groq_rate_limited
|
|
@@ -64,80 +64,9 @@ def _transcription_parts(result) -> tuple[str, str]:
|
|
| 64 |
return str(text or "").strip(), code or "unknown"
|
| 65 |
|
| 66 |
|
| 67 |
-
def _build_enhance_context(request: EnhanceRequest, user_id: str, fetch_saved_prompt_fn=None
|
| 68 |
-
use_memory: bool = True) -> dict:
|
| 69 |
"""The shared builder, reading attached saved prompts from this router's store."""
|
| 70 |
-
return _build_context(request, user_id, fetch_saved_prompt_fn or _fetch_saved_prompt
|
| 71 |
-
use_memory=use_memory)
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
# ══════════════════════════════════════════════════════════════
|
| 75 |
-
# ANONYMOUS TIER — an install that has not signed in
|
| 76 |
-
# ══════════════════════════════════════════════════════════════
|
| 77 |
-
# Quick rewrites of the draft alone: no chat context, no saved prompts, no
|
| 78 |
-
# memory, no BYOK. Nothing is logged, so the count lives in core.usage's
|
| 79 |
-
# per-process tally (it resets on restart, which errs toward the user).
|
| 80 |
-
|
| 81 |
-
ANON_TOTAL_KEY = "__anon_total__"
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
def _anon_request(request: EnhanceRequest) -> EnhanceRequest:
|
| 85 |
-
"""The parts of a request the anonymous tier is allowed to use."""
|
| 86 |
-
return request.model_copy(update={
|
| 87 |
-
"mode": "quick",
|
| 88 |
-
"conversation_context": None,
|
| 89 |
-
"selected_prompt_ids": None,
|
| 90 |
-
"excluded_prompt_ids": None,
|
| 91 |
-
"tracking_enabled": False,
|
| 92 |
-
"byok_provider": None,
|
| 93 |
-
"byok_key": None,
|
| 94 |
-
"byok_model": None,
|
| 95 |
-
})
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
def _anon_capacity_left() -> bool:
|
| 99 |
-
return usage.get(ANON_TOTAL_KEY) < settings.ANON_GLOBAL_DAILY_LIMIT
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def _record_anon_rewrite(user_id: str):
|
| 103 |
-
usage.record(user_id)
|
| 104 |
-
usage.record(ANON_TOTAL_KEY)
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
def _limit_refusal(tier: str, used: int, limit: int, degraded: bool) -> dict:
|
| 108 |
-
"""The body of a 429 for a user who cannot rewrite right now."""
|
| 109 |
-
if tier == "anon":
|
| 110 |
-
busy = not _anon_capacity_left()
|
| 111 |
-
return {
|
| 112 |
-
"error": "free_capacity_reached" if busy else "daily_limit_reached",
|
| 113 |
-
"detail": (
|
| 114 |
-
"Free rewrites without an account are busy right now. "
|
| 115 |
-
"Sign in with Google (it's free) to keep going."
|
| 116 |
-
if busy else
|
| 117 |
-
f"You've used today's {limit} free rewrites. Sign in with Google "
|
| 118 |
-
"(it's free) for more each day, plus the Deep and Creative styles "
|
| 119 |
-
"and a library for your saved prompts."
|
| 120 |
-
),
|
| 121 |
-
"used": used, "limit": limit, "tier": tier, "degraded": False,
|
| 122 |
-
"sign_in_available": True, "byok_available": False,
|
| 123 |
-
}
|
| 124 |
-
degraded_note = (
|
| 125 |
-
" The prompt store is temporarily unreachable, so free usage is capped"
|
| 126 |
-
" lower than usual until it recovers."
|
| 127 |
-
if degraded else ""
|
| 128 |
-
)
|
| 129 |
-
return {
|
| 130 |
-
"error": "daily_limit_reached",
|
| 131 |
-
"detail": (
|
| 132 |
-
f"You've used all {limit} free enhancements for today. "
|
| 133 |
-
"Add your own free API key in the extension settings for "
|
| 134 |
-
"1,000 per day." + degraded_note
|
| 135 |
-
if tier != "byok" else
|
| 136 |
-
f"Daily limit of {limit} reached."
|
| 137 |
-
),
|
| 138 |
-
"used": used, "limit": limit, "tier": tier, "degraded": degraded,
|
| 139 |
-
"byok_available": tier != "byok",
|
| 140 |
-
}
|
| 141 |
|
| 142 |
|
| 143 |
# ══════════════════════════════════════════════════════════════
|
|
@@ -161,8 +90,6 @@ def get_user_tier(user_id: str) -> str:
|
|
| 161 |
|
| 162 |
def effective_tier(user_id: str, request) -> str:
|
| 163 |
"""A user supplying BYOK is not rationed against shared allowance."""
|
| 164 |
-
if is_anonymous(user_id):
|
| 165 |
-
return "anon"
|
| 166 |
if getattr(request, "byok_key", None):
|
| 167 |
return "byok"
|
| 168 |
return get_user_tier(user_id)
|
|
@@ -201,9 +128,6 @@ def count_today(user_id: str) -> tuple:
|
|
| 201 |
def check_daily_limit(user_id: str, tier: str) -> tuple:
|
| 202 |
"""Returns (allowed: bool, count: int, limit: int, degraded: bool)."""
|
| 203 |
limit = settings.TIER_LIMITS.get(tier, settings.SHARED_KEY_DAILY_LIMIT)
|
| 204 |
-
if tier == "anon":
|
| 205 |
-
used = usage.get(user_id)
|
| 206 |
-
return (used < limit and _anon_capacity_left(), used, limit, False)
|
| 207 |
count, degraded = count_today(user_id)
|
| 208 |
|
| 209 |
if degraded and tier != "byok":
|
|
@@ -266,18 +190,36 @@ def enhance_prompt(
|
|
| 266 |
):
|
| 267 |
"""The core prompt engineering endpoint — intent-aware, mode-aware."""
|
| 268 |
tier = effective_tier(user_id, request)
|
| 269 |
-
anon = tier == "anon"
|
| 270 |
-
if anon:
|
| 271 |
-
request = _anon_request(request)
|
| 272 |
allowed, used, limit, degraded = check_daily_limit(user_id, tier)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
if not allowed:
|
| 274 |
-
return JSONResponse(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
|
| 276 |
logger.info(f"\n🎯 /enhance — user={user_id[:8]}... mode={request.mode} tier={tier} ({used}/{limit})")
|
| 277 |
-
|
| 278 |
-
logger.info(f" Prompt: \"{request.prompt[:80]}...\"")
|
| 279 |
|
| 280 |
-
ctx = _build_enhance_context(request, user_id, _fetch_saved_prompt
|
| 281 |
|
| 282 |
try:
|
| 283 |
result = providers.chat(
|
|
@@ -305,18 +247,6 @@ def enhance_prompt(
|
|
| 305 |
|
| 306 |
enhanced_prompt = result["content"]
|
| 307 |
process_time = round(time.time() - ctx["start_time"], 2)
|
| 308 |
-
|
| 309 |
-
if anon:
|
| 310 |
-
_record_anon_rewrite(user_id)
|
| 311 |
-
return {
|
| 312 |
-
"original": request.prompt, "enhanced": enhanced_prompt, "log_id": None,
|
| 313 |
-
"latency": process_time, "mode": ctx["mode"], "model": result["model"],
|
| 314 |
-
"provider": result["provider"], "byok": False,
|
| 315 |
-
"truncated": result.get("truncated", False),
|
| 316 |
-
"usage_today": {"used": used + 1, "limit": limit, "tier": tier},
|
| 317 |
-
"context_used": _context_used(ctx, request),
|
| 318 |
-
"context_details": _context_details(ctx),
|
| 319 |
-
}
|
| 320 |
|
| 321 |
# Log the enhancement; DO NOT memorize here. Memorization happens on /enhance/accept.
|
| 322 |
# Always logged, whatever tracking_enabled says: this log is the user's
|
|
@@ -384,19 +314,25 @@ def enhance_prompt(
|
|
| 384 |
def enhance_prompt_stream(request: EnhanceRequest, user_id: str = Depends(enhance_limit)):
|
| 385 |
"""Streaming enhancement — returns tokens as Server-Sent Events."""
|
| 386 |
tier = effective_tier(user_id, request)
|
| 387 |
-
anon = tier == "anon"
|
| 388 |
-
if anon:
|
| 389 |
-
request = _anon_request(request)
|
| 390 |
allowed, used, limit, degraded = check_daily_limit(user_id, tier)
|
| 391 |
|
| 392 |
if not allowed:
|
| 393 |
def refuse():
|
| 394 |
-
payload =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
yield f"data: {json.dumps(payload)}\n\n"
|
| 396 |
-
yield f"data: {json.dumps({'done': True, 'failed': True
|
| 397 |
return StreamingResponse(refuse(), media_type="text/event-stream")
|
| 398 |
|
| 399 |
-
ctx = _build_enhance_context(request, user_id, _fetch_saved_prompt
|
| 400 |
|
| 401 |
def generate():
|
| 402 |
enhanced_parts = []
|
|
@@ -434,12 +370,7 @@ def enhance_prompt_stream(request: EnhanceRequest, user_id: str = Depends(enhanc
|
|
| 434 |
process_time = round(time.time() - ctx["start_time"], 2)
|
| 435 |
|
| 436 |
log_id = None
|
| 437 |
-
|
| 438 |
-
if enhanced_prompt.strip() and anon:
|
| 439 |
-
# Counted, never logged: see the anonymous tier above.
|
| 440 |
-
_record_anon_rewrite(user_id)
|
| 441 |
-
counted = True
|
| 442 |
-
elif enhanced_prompt.strip():
|
| 443 |
max_similarity = ctx["similar_saved"][0]["score"] if ctx["similar_saved"] else 0.0
|
| 444 |
# Always logged; see enhance_prompt for why tracking does not gate this.
|
| 445 |
log_id = MemoryService.log_prompt(
|
|
@@ -479,7 +410,7 @@ def enhance_prompt_stream(request: EnhanceRequest, user_id: str = Depends(enhanc
|
|
| 479 |
"model": meta.get("model"),
|
| 480 |
"provider": meta.get("provider"),
|
| 481 |
"byok": meta.get("byok", False),
|
| 482 |
-
"usage_today": {"used": used + (1 if
|
| 483 |
"context_used": _context_used(ctx, request),
|
| 484 |
"context_details": _context_details(ctx),
|
| 485 |
"truncated": meta.get("truncated", False),
|
|
@@ -537,11 +468,8 @@ def enhance_history(user_id: str = Depends(verify_jwt)):
|
|
| 537 |
|
| 538 |
|
| 539 |
@router.get("/enhance/usage")
|
| 540 |
-
def enhance_usage(byok: bool = False, user_id: str = Depends(
|
| 541 |
"""Returns today's enhancement count for the user."""
|
| 542 |
-
if is_anonymous(user_id):
|
| 543 |
-
return {"count": usage.get(user_id), "limit": settings.ANON_DAILY_LIMIT,
|
| 544 |
-
"tier": "anon", "degraded": False}
|
| 545 |
count, degraded = count_today(user_id)
|
| 546 |
tier = "byok" if byok else get_user_tier(user_id)
|
| 547 |
limit = settings.TIER_LIMITS.get(tier, settings.SHARED_KEY_DAILY_LIMIT)
|
|
|
|
| 10 |
from fastapi.responses import StreamingResponse, JSONResponse
|
| 11 |
from ..models.schemas import TrackRequest, EnhanceRequest, FeedbackRequest
|
| 12 |
from ..core.config import settings
|
| 13 |
+
from ..core.security import verify_jwt
|
| 14 |
from ..core.ratelimit import enhance_limit, voice_limit
|
| 15 |
from ..core import usage
|
| 16 |
+
from ..core.database import MongoDB, in_memory_users, in_memory_saved_prompts
|
| 17 |
from ..services.analytics_service import AnalyticsService
|
| 18 |
from ..services.memory_service import MemoryService
|
| 19 |
from ..services.llm_service import get_groq_client, mark_groq_rate_limited
|
|
|
|
| 64 |
return str(text or "").strip(), code or "unknown"
|
| 65 |
|
| 66 |
|
| 67 |
+
def _build_enhance_context(request: EnhanceRequest, user_id: str, fetch_saved_prompt_fn=None) -> dict:
|
|
|
|
| 68 |
"""The shared builder, reading attached saved prompts from this router's store."""
|
| 69 |
+
return _build_context(request, user_id, fetch_saved_prompt_fn or _fetch_saved_prompt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
|
| 72 |
# ══════════════════════════════════════════════════════════════
|
|
|
|
| 90 |
|
| 91 |
def effective_tier(user_id: str, request) -> str:
|
| 92 |
"""A user supplying BYOK is not rationed against shared allowance."""
|
|
|
|
|
|
|
| 93 |
if getattr(request, "byok_key", None):
|
| 94 |
return "byok"
|
| 95 |
return get_user_tier(user_id)
|
|
|
|
| 128 |
def check_daily_limit(user_id: str, tier: str) -> tuple:
|
| 129 |
"""Returns (allowed: bool, count: int, limit: int, degraded: bool)."""
|
| 130 |
limit = settings.TIER_LIMITS.get(tier, settings.SHARED_KEY_DAILY_LIMIT)
|
|
|
|
|
|
|
|
|
|
| 131 |
count, degraded = count_today(user_id)
|
| 132 |
|
| 133 |
if degraded and tier != "byok":
|
|
|
|
| 190 |
):
|
| 191 |
"""The core prompt engineering endpoint — intent-aware, mode-aware."""
|
| 192 |
tier = effective_tier(user_id, request)
|
|
|
|
|
|
|
|
|
|
| 193 |
allowed, used, limit, degraded = check_daily_limit(user_id, tier)
|
| 194 |
+
degraded_note = (
|
| 195 |
+
" The prompt store is temporarily unreachable, so free usage is capped"
|
| 196 |
+
" lower than usual until it recovers."
|
| 197 |
+
if degraded else ""
|
| 198 |
+
)
|
| 199 |
if not allowed:
|
| 200 |
+
return JSONResponse(
|
| 201 |
+
status_code=429,
|
| 202 |
+
content={
|
| 203 |
+
"error": "daily_limit_reached",
|
| 204 |
+
"detail": (
|
| 205 |
+
f"You've used all {limit} free enhancements for today. "
|
| 206 |
+
"Add your own free API key in the extension settings for "
|
| 207 |
+
"1,000 per day." + degraded_note
|
| 208 |
+
if tier != "byok" else
|
| 209 |
+
f"Daily limit of {limit} reached."
|
| 210 |
+
),
|
| 211 |
+
"used": used,
|
| 212 |
+
"limit": limit,
|
| 213 |
+
"tier": tier,
|
| 214 |
+
"degraded": degraded,
|
| 215 |
+
"byok_available": tier != "byok",
|
| 216 |
+
},
|
| 217 |
+
)
|
| 218 |
|
| 219 |
logger.info(f"\n🎯 /enhance — user={user_id[:8]}... mode={request.mode} tier={tier} ({used}/{limit})")
|
| 220 |
+
logger.info(f" Prompt: \"{request.prompt[:80]}...\"")
|
|
|
|
| 221 |
|
| 222 |
+
ctx = _build_enhance_context(request, user_id, _fetch_saved_prompt)
|
| 223 |
|
| 224 |
try:
|
| 225 |
result = providers.chat(
|
|
|
|
| 247 |
|
| 248 |
enhanced_prompt = result["content"]
|
| 249 |
process_time = round(time.time() - ctx["start_time"], 2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
# Log the enhancement; DO NOT memorize here. Memorization happens on /enhance/accept.
|
| 252 |
# Always logged, whatever tracking_enabled says: this log is the user's
|
|
|
|
| 314 |
def enhance_prompt_stream(request: EnhanceRequest, user_id: str = Depends(enhance_limit)):
|
| 315 |
"""Streaming enhancement — returns tokens as Server-Sent Events."""
|
| 316 |
tier = effective_tier(user_id, request)
|
|
|
|
|
|
|
|
|
|
| 317 |
allowed, used, limit, degraded = check_daily_limit(user_id, tier)
|
| 318 |
|
| 319 |
if not allowed:
|
| 320 |
def refuse():
|
| 321 |
+
payload = {
|
| 322 |
+
"error": "daily_limit_reached",
|
| 323 |
+
"detail": (
|
| 324 |
+
f"You've used all {limit} free enhancements for today. "
|
| 325 |
+
"Add your own free API key in the extension settings for 1,000 per day."
|
| 326 |
+
if tier != "byok" else f"Daily limit of {limit} reached."
|
| 327 |
+
),
|
| 328 |
+
"used": used, "limit": limit, "degraded": degraded,
|
| 329 |
+
"byok_available": tier != "byok",
|
| 330 |
+
}
|
| 331 |
yield f"data: {json.dumps(payload)}\n\n"
|
| 332 |
+
yield f"data: {json.dumps({'done': True, 'failed': True})}\n\n"
|
| 333 |
return StreamingResponse(refuse(), media_type="text/event-stream")
|
| 334 |
|
| 335 |
+
ctx = _build_enhance_context(request, user_id, _fetch_saved_prompt)
|
| 336 |
|
| 337 |
def generate():
|
| 338 |
enhanced_parts = []
|
|
|
|
| 370 |
process_time = round(time.time() - ctx["start_time"], 2)
|
| 371 |
|
| 372 |
log_id = None
|
| 373 |
+
if enhanced_prompt.strip():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
max_similarity = ctx["similar_saved"][0]["score"] if ctx["similar_saved"] else 0.0
|
| 375 |
# Always logged; see enhance_prompt for why tracking does not gate this.
|
| 376 |
log_id = MemoryService.log_prompt(
|
|
|
|
| 410 |
"model": meta.get("model"),
|
| 411 |
"provider": meta.get("provider"),
|
| 412 |
"byok": meta.get("byok", False),
|
| 413 |
+
"usage_today": {"used": used + (1 if log_id else 0), "limit": limit, "tier": tier},
|
| 414 |
"context_used": _context_used(ctx, request),
|
| 415 |
"context_details": _context_details(ctx),
|
| 416 |
"truncated": meta.get("truncated", False),
|
|
|
|
| 468 |
|
| 469 |
|
| 470 |
@router.get("/enhance/usage")
|
| 471 |
+
def enhance_usage(byok: bool = False, user_id: str = Depends(verify_jwt)):
|
| 472 |
"""Returns today's enhancement count for the user."""
|
|
|
|
|
|
|
|
|
|
| 473 |
count, degraded = count_today(user_id)
|
| 474 |
tier = "byok" if byok else get_user_tier(user_id)
|
| 475 |
limit = settings.TIER_LIMITS.get(tier, settings.SHARED_KEY_DAILY_LIMIT)
|
backend/services/prompt_builder.py
CHANGED
|
@@ -285,14 +285,8 @@ def _already_in_prompt(item: dict, prompt: str) -> bool:
|
|
| 285 |
return float(item.get("score") or 0) >= _NEAR_DUPLICATE_SCORE
|
| 286 |
|
| 287 |
|
| 288 |
-
def _build_enhance_context(request: EnhanceRequest, user_id: str, fetch_saved_prompt_fn=None
|
| 289 |
-
|
| 290 |
-
"""
|
| 291 |
-
Shared context builder for every enhance route (text, stream, voice).
|
| 292 |
-
|
| 293 |
-
use_memory=False is the anonymous tier: no saved prompts, no past
|
| 294 |
-
patterns, no feedback summary, so nothing about any user is read.
|
| 295 |
-
"""
|
| 296 |
start_time = time.time()
|
| 297 |
mode = (request.mode or "deep").lower()
|
| 298 |
if mode not in MODE_INSTRUCTIONS:
|
|
@@ -303,7 +297,7 @@ def _build_enhance_context(request: EnhanceRequest, user_id: str, fetch_saved_pr
|
|
| 303 |
|
| 304 |
selected_context_parts = []
|
| 305 |
selected_prompts = []
|
| 306 |
-
selected_ids = [str(pid) for pid in (request.selected_prompt_ids or [])]
|
| 307 |
for pid in selected_ids:
|
| 308 |
doc = fetch_saved_prompt_fn(pid, user_id) if fetch_saved_prompt_fn else None
|
| 309 |
if doc:
|
|
@@ -322,7 +316,7 @@ def _build_enhance_context(request: EnhanceRequest, user_id: str, fetch_saved_pr
|
|
| 322 |
exclude_ids=selected_ids + excluded_ids,
|
| 323 |
)
|
| 324 |
if not _already_in_prompt(item, request.prompt)
|
| 325 |
-
]
|
| 326 |
similarity_context_parts = []
|
| 327 |
for item in similar_saved:
|
| 328 |
label = item.get("title") or "Saved Prompt"
|
|
@@ -335,14 +329,14 @@ def _build_enhance_context(request: EnhanceRequest, user_id: str, fetch_saved_pr
|
|
| 335 |
user_id=user_id,
|
| 336 |
query_text=request.prompt,
|
| 337 |
limit=3,
|
| 338 |
-
)
|
| 339 |
for pm in passive_matches:
|
| 340 |
if pm["original"] != pm["refined"]:
|
| 341 |
passive_context_parts.append(
|
| 342 |
f'[Past pattern] User asked: "{pm["original"]}" → Was refined to: "{pm["refined"]}"'
|
| 343 |
)
|
| 344 |
|
| 345 |
-
feedback_summary = MemoryService.get_user_feedback_summary(user_id)
|
| 346 |
|
| 347 |
system_parts = [
|
| 348 |
SYSTEM_PROMPT_BASE,
|
|
|
|
| 285 |
return float(item.get("score") or 0) >= _NEAR_DUPLICATE_SCORE
|
| 286 |
|
| 287 |
|
| 288 |
+
def _build_enhance_context(request: EnhanceRequest, user_id: str, fetch_saved_prompt_fn=None) -> dict:
|
| 289 |
+
"""Shared context builder for every enhance route (text, stream, voice)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
start_time = time.time()
|
| 291 |
mode = (request.mode or "deep").lower()
|
| 292 |
if mode not in MODE_INSTRUCTIONS:
|
|
|
|
| 297 |
|
| 298 |
selected_context_parts = []
|
| 299 |
selected_prompts = []
|
| 300 |
+
selected_ids = [str(pid) for pid in (request.selected_prompt_ids or [])]
|
| 301 |
for pid in selected_ids:
|
| 302 |
doc = fetch_saved_prompt_fn(pid, user_id) if fetch_saved_prompt_fn else None
|
| 303 |
if doc:
|
|
|
|
| 316 |
exclude_ids=selected_ids + excluded_ids,
|
| 317 |
)
|
| 318 |
if not _already_in_prompt(item, request.prompt)
|
| 319 |
+
]
|
| 320 |
similarity_context_parts = []
|
| 321 |
for item in similar_saved:
|
| 322 |
label = item.get("title") or "Saved Prompt"
|
|
|
|
| 329 |
user_id=user_id,
|
| 330 |
query_text=request.prompt,
|
| 331 |
limit=3,
|
| 332 |
+
)
|
| 333 |
for pm in passive_matches:
|
| 334 |
if pm["original"] != pm["refined"]:
|
| 335 |
passive_context_parts.append(
|
| 336 |
f'[Past pattern] User asked: "{pm["original"]}" → Was refined to: "{pm["refined"]}"'
|
| 337 |
)
|
| 338 |
|
| 339 |
+
feedback_summary = MemoryService.get_user_feedback_summary(user_id)
|
| 340 |
|
| 341 |
system_parts = [
|
| 342 |
SYSTEM_PROMPT_BASE,
|