-
Notifications
You must be signed in to change notification settings - Fork 2k
Add ChatGPT/Codex subscription tier (loopback proxy) #7401
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -215,6 +215,64 @@ def clear_byok_active(uid: str): | |||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def get_chatgpt_state(uid: str) -> dict: | ||||||||||||||||||||||||||||||||||
| user_ref = db.collection('users').document(uid) | ||||||||||||||||||||||||||||||||||
| data = user_ref.get().to_dict() or {} | ||||||||||||||||||||||||||||||||||
| return data.get('chatgpt', {}) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def is_chatgpt_active(uid: str) -> bool: | ||||||||||||||||||||||||||||||||||
| """True if user enrolled ChatGPT/Codex tier (LLM-only; separate from four-key BYOK).""" | ||||||||||||||||||||||||||||||||||
| state = get_chatgpt_state(uid) | ||||||||||||||||||||||||||||||||||
| if not state.get('active'): | ||||||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||||||
| last_seen = state.get('last_seen_at') | ||||||||||||||||||||||||||||||||||
| if not last_seen: | ||||||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||||||
| if isinstance(last_seen, datetime): | ||||||||||||||||||||||||||||||||||
| age = (datetime.now(timezone.utc) - last_seen).total_seconds() | ||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||||||
| return age <= BYOK_HEARTBEAT_TTL_SECONDS | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def set_chatgpt_active(uid: str, fingerprint: str): | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+232
to
+239
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||||||||||||||||||||||||||||||||||
| user_ref = db.collection('users').document(uid) | ||||||||||||||||||||||||||||||||||
| user_ref.set( | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| 'chatgpt': { | ||||||||||||||||||||||||||||||||||
| 'active': True, | ||||||||||||||||||||||||||||||||||
| 'fingerprint': fingerprint, | ||||||||||||||||||||||||||||||||||
| 'last_seen_at': datetime.now(timezone.utc), | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||
| merge=True, | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def touch_chatgpt_heartbeat(uid: str): | ||||||||||||||||||||||||||||||||||
| """Refresh ChatGPT tier heartbeat (called when a valid fingerprint is on the request).""" | ||||||||||||||||||||||||||||||||||
| user_ref = db.collection('users').document(uid) | ||||||||||||||||||||||||||||||||||
| user_ref.set( | ||||||||||||||||||||||||||||||||||
| {'chatgpt': {'last_seen_at': datetime.now(timezone.utc)}}, | ||||||||||||||||||||||||||||||||||
| merge=True, | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def clear_chatgpt_active(uid: str): | ||||||||||||||||||||||||||||||||||
| user_ref = db.collection('users').document(uid) | ||||||||||||||||||||||||||||||||||
| user_ref.set( | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| 'chatgpt': { | ||||||||||||||||||||||||||||||||||
| 'active': False, | ||||||||||||||||||||||||||||||||||
| 'fingerprint': '', | ||||||||||||||||||||||||||||||||||
| 'last_seen_at': datetime.now(timezone.utc), | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||
| merge=True, | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def set_user_deletion_feedback(uid: str, reason: Optional[str], reason_details: Optional[str] = None): | ||||||||||||||||||||||||||||||||||
| # Stored in a top-level collection so it survives the user record being deleted. | ||||||||||||||||||||||||||||||||||
| db.collection('account_deletions').document(uid).set( | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -98,6 +98,7 @@ | |
| from utils.webhooks import webhook_first_time_setup | ||
| from database.action_items import get_action_items as get_standalone_action_items | ||
| from utils.byok import has_byok_keys, invalidate_byok_state_cache | ||
| from utils.chatgpt import chatgpt_request_grants_bypass | ||
| import logging | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
@@ -813,6 +814,47 @@ def deactivate_byok_endpoint(uid: str = Depends(auth.get_current_user_uid_no_byo | |
| return {"active": False} | ||
|
|
||
|
|
||
| class ChatGPTActivateRequest(BaseModel): | ||
| fingerprint: str | ||
|
|
||
|
|
||
| @router.post('/v1/users/me/chatgpt-active', tags=['v1']) | ||
| def activate_chatgpt_endpoint( | ||
| data: ChatGPTActivateRequest, uid: str = Depends(auth.get_current_user_uid_no_byok_validation) | ||
| ): | ||
| """Enroll ChatGPT / Codex subscription tier (LLM workloads only; no provider keys stored).""" | ||
| if not _SHA256_HEX_RE.match(data.fingerprint): | ||
| raise HTTPException( | ||
| status_code=400, | ||
| detail='Invalid fingerprint: expected lowercase hex SHA-256 (64 chars)', | ||
| ) | ||
| users_db.set_chatgpt_active(uid, data.fingerprint) | ||
|
Comment on lines
+827
to
+831
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This endpoint grants ChatGPT enrollment after only a format check on Useful? React with 👍 / 👎. |
||
| clear_trial_paywall_cache(uid) | ||
| return {"active": True} | ||
|
Comment on lines
+821
to
+833
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The activation endpoint accepts any 64-char hex string as a valid enrollment fingerprint. The fingerprint is stored in Firestore, but |
||
|
|
||
|
|
||
| @router.delete('/v1/users/me/chatgpt-active', tags=['v1']) | ||
| def deactivate_chatgpt_endpoint(uid: str = Depends(auth.get_current_user_uid_no_byok_validation)): | ||
| """Drop ChatGPT / Codex tier enrollment.""" | ||
| users_db.clear_chatgpt_active(uid) | ||
| clear_trial_paywall_cache(uid) | ||
| return {"active": False} | ||
|
|
||
|
|
||
| def _chatgpt_unlimited_subscription() -> Subscription: | ||
| return Subscription( | ||
| plan=PlanType.unlimited, | ||
| status=SubscriptionStatus.active, | ||
| features=["chatgpt"], | ||
| limits=PlanLimits( | ||
| transcription_seconds=None, | ||
| words_transcribed=None, | ||
| insights_gained=None, | ||
| memories_created=None, | ||
| ), | ||
| ) | ||
|
|
||
|
|
||
| def _byok_unlimited_subscription() -> Subscription: | ||
| """BYOK free plan: unlimited limits, marked with the `byok` feature flag.""" | ||
| return Subscription( | ||
|
|
@@ -844,6 +886,22 @@ def get_user_subscription_endpoint( | |
| # these users aren't surprised by a disabled phone-call feature. | ||
| unlimited_phone_quota = PhoneCallQuota(has_access=True, is_paid=True) | ||
|
|
||
| if chatgpt_request_grants_bypass(uid): | ||
| return UserSubscriptionResponse( | ||
| subscription=_chatgpt_unlimited_subscription(), | ||
| transcription_seconds_used=0, | ||
| transcription_seconds_limit=0, | ||
| words_transcribed_used=0, | ||
| words_transcribed_limit=0, | ||
| insights_gained_used=0, | ||
| insights_gained_limit=0, | ||
| memories_created_used=0, | ||
| memories_created_limit=0, | ||
| available_plans=[], | ||
| show_subscription_ui=False, | ||
| phone_call_quota=unlimited_phone_quota, | ||
| ) | ||
|
|
||
| if users_db.is_byok_active(uid) and has_byok_keys(): | ||
| return UserSubscriptionResponse( | ||
| subscription=_byok_unlimited_subscription(), | ||
|
|
@@ -1053,6 +1111,18 @@ def get_user_chat_usage_quota( | |
| # BYOK free plan: user brings their own keys, so there's no Omi-side cost | ||
| # to meter. Only return unlimited when BYOK headers are on the request (desktop). | ||
| # Mobile (no headers) should see real quota. | ||
| if chatgpt_request_grants_bypass(uid): | ||
| return ChatUsageQuota( | ||
| plan='Free (ChatGPT)', | ||
| plan_type=PlanType.unlimited.value, | ||
| unit=ChatQuotaUnit.questions, | ||
| used=0.0, | ||
| limit=None, | ||
| percent=0.0, | ||
| allowed=True, | ||
| reset_at=None, | ||
| ) | ||
|
|
||
| if users_db.is_byok_active(uid) and has_byok_keys(): | ||
| return ChatUsageQuota( | ||
| plan='Free (BYOK)', | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| """Unit tests for ChatGPT / Codex tier enrollment (standalone, no Firestore imports).""" | ||
|
|
||
| import re | ||
| from datetime import datetime, timedelta, timezone | ||
|
|
||
| _SHA256_HEX_RE = re.compile(r'^[a-f0-9]{64}$') | ||
| _SHA256 = 'a' * 64 | ||
| _CHATGPT_TTL_SECONDS = 7 * 24 * 60 * 60 | ||
|
|
||
|
|
||
| def _is_chatgpt_active_state(state: dict) -> bool: | ||
| if not state.get('active'): | ||
| return False | ||
| last_seen = state.get('last_seen_at') | ||
| if not isinstance(last_seen, datetime): | ||
| return False | ||
| age = (datetime.now(timezone.utc) - last_seen).total_seconds() | ||
| return age <= _CHATGPT_TTL_SECONDS | ||
|
|
||
|
|
||
| def test_fingerprint_must_be_sha256_hex(): | ||
| assert _SHA256_HEX_RE.match(_SHA256) | ||
| assert not _SHA256_HEX_RE.match('not-hex') | ||
| assert not _SHA256_HEX_RE.match('A' * 64) | ||
|
|
||
|
|
||
| def test_chatgpt_active_ttl(): | ||
| fresh = {'active': True, 'last_seen_at': datetime.now(timezone.utc) - timedelta(days=1)} | ||
| assert _is_chatgpt_active_state(fresh) is True | ||
|
|
||
| stale = {'active': True, 'last_seen_at': datetime.now(timezone.utc) - timedelta(days=30)} | ||
| assert _is_chatgpt_active_state(stale) is False | ||
|
|
||
| inactive = {'active': False, 'last_seen_at': datetime.now(timezone.utc)} | ||
| assert _is_chatgpt_active_state(inactive) is False |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| """Per-request ChatGPT / Codex tier fingerprint plumbing. | ||
|
|
||
| Desktop sends ``X-ChatGPT-Fingerprint`` (SHA-256 of Codex account_id) on requests | ||
| while Codex is active. Quota and subscription bypass require a matching enrolled | ||
| fingerprint on the same request — enrollment alone is not enough (mirrors BYOK). | ||
| """ | ||
|
|
||
| import logging | ||
| import re | ||
| from contextvars import ContextVar | ||
| from datetime import datetime, timezone | ||
| from typing import Optional | ||
|
|
||
| from starlette.middleware.base import BaseHTTPMiddleware | ||
| from starlette.requests import Request | ||
|
|
||
| import database.users as users_db | ||
|
|
||
| logger = logging.getLogger('chatgpt') | ||
|
|
||
| CHATGPT_FINGERPRINT_HEADER = 'x-chatgpt-fingerprint' | ||
| _SHA256_HEX_RE = re.compile(r'^[a-f0-9]{64}$') | ||
| # Refresh Firestore heartbeat at most once per day when desktop sends a valid fingerprint. | ||
| _HEARTBEAT_REFRESH_INTERVAL_SECONDS = 24 * 60 * 60 | ||
|
|
||
| _chatgpt_fp_ctx: ContextVar[Optional[str]] = ContextVar('chatgpt_fingerprint', default=None) | ||
|
|
||
|
|
||
| def get_chatgpt_fingerprint() -> Optional[str]: | ||
| return _chatgpt_fp_ctx.get() | ||
|
|
||
|
|
||
| def has_chatgpt_fingerprint() -> bool: | ||
| """True if the current request carries a ChatGPT enrollment fingerprint header.""" | ||
| return bool(_chatgpt_fp_ctx.get()) | ||
|
|
||
|
|
||
| def chatgpt_request_grants_bypass(uid: str) -> bool: | ||
| """True when enrolled and this request's fingerprint matches Firestore enrollment. | ||
|
|
||
| Refreshes ``last_seen_at`` on success so desktop enrollment stays alive without | ||
| re-posting to ``/chatgpt-active`` every week. | ||
| """ | ||
| fp = _chatgpt_fp_ctx.get() | ||
| if not fp or not _SHA256_HEX_RE.match(fp): | ||
| return False | ||
| if not users_db.is_chatgpt_active(uid): | ||
| return False | ||
| state = users_db.get_chatgpt_state(uid) | ||
| if state.get('fingerprint') != fp: | ||
| return False | ||
| last_seen = state.get('last_seen_at') | ||
| if not isinstance(last_seen, datetime): | ||
| users_db.touch_chatgpt_heartbeat(uid) | ||
| else: | ||
| age = (datetime.now(timezone.utc) - last_seen).total_seconds() | ||
| if age >= _HEARTBEAT_REFRESH_INTERVAL_SECONDS: | ||
| users_db.touch_chatgpt_heartbeat(uid) | ||
| return True | ||
|
|
||
|
|
||
| class ChatGPTMiddleware(BaseHTTPMiddleware): | ||
| """Extract ChatGPT fingerprint header into a per-request contextvar.""" | ||
|
|
||
| async def dispatch(self, request: Request, call_next): | ||
| raw = request.headers.get(CHATGPT_FINGERPRINT_HEADER) | ||
| fp = raw.strip() if raw else None | ||
| if fp and not _SHA256_HEX_RE.match(fp): | ||
| fp = None | ||
| token = _chatgpt_fp_ctx.set(fp) | ||
| try: | ||
| return await call_next(request) | ||
| finally: | ||
| _chatgpt_fp_ctx.reset(token) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is_chatgpt_activeexpires enrollment strictly by a 7-day TTL onlast_seen_at, but desktop enrollment currently updates that timestamp only during explicit connect flow (not on normal app startup/use). As a result, active users can silently drop out of ChatGPT-unlimited status after seven days unless they manually reconnect, which causes unexpected quota/paywall regressions.Useful? React with 👍 / 👎.