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 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, verify_jwt_or_anon
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, allow_anon: bool = False):
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
- auth = verify_jwt_or_anon if allow_anon else verify_jwt
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
- # The rewrite is the one thing an install can do before signing in.
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
- def create_anon_token() -> tuple:
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 (jwt.InvalidTokenError, KeyError):
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 v5.0.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,7 +162,7 @@ def health_check():
162
  return {
163
  "status": "running",
164
  "service": "Context-Aware Prompt Engine",
165
- "version": "5.0.0",
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, Request
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, create_anon_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, verify_jwt_or_anon, is_anonymous
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, in_memory_prompt_logs
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(status_code=429, content=_limit_refusal(tier, used, limit, degraded))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
 
276
  logger.info(f"\n🎯 /enhance — user={user_id[:8]}... mode={request.mode} tier={tier} ({used}/{limit})")
277
- if not anon: # an anonymous draft is not written anywhere, logs included
278
- logger.info(f" Prompt: \"{request.prompt[:80]}...\"")
279
 
280
- ctx = _build_enhance_context(request, user_id, _fetch_saved_prompt, use_memory=not anon)
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 = _limit_refusal(tier, used, limit, degraded)
 
 
 
 
 
 
 
 
 
395
  yield f"data: {json.dumps(payload)}\n\n"
396
- yield f"data: {json.dumps({'done': True, 'failed': True, **payload})}\n\n"
397
  return StreamingResponse(refuse(), media_type="text/event-stream")
398
 
399
- ctx = _build_enhance_context(request, user_id, _fetch_saved_prompt, use_memory=not anon)
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
- counted = False
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 (log_id or counted) else 0), "limit": limit, "tier": tier},
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(verify_jwt_or_anon)):
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
- use_memory: bool = True) -> dict:
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 [])] if use_memory else []
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
- ] if use_memory else []
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
- ) if use_memory else []
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) if use_memory else ""
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,