Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
fc61725
Scaffold local Rust backend
May 18, 2026
8f60686
Add local backend SQLite storage
May 18, 2026
491246a
Expose local backend MVP APIs
May 18, 2026
8cb9ac0
feat(desktop): harden APIClient with structured routing and transcrip…
May 18, 2026
9f5d918
Fix desktop local daemon Swift validation
May 18, 2026
7c26d4f
Add local conversation processing jobs
May 18, 2026
25c4bae
Add local-first capability boundaries
May 18, 2026
1977698
Validate local backend MVP end to end
May 18, 2026
1d7cef1
Add local MVP transcript import runbook
May 18, 2026
dfc3f35
Add local daemon dev supervision
May 18, 2026
d28259e
Fix local daemon action-item completion and conversation count API
cursoragent May 18, 2026
b5dd990
Close local-mode conversation cloud leaks
May 18, 2026
c8722d9
Make local ingestion retries idempotent
May 18, 2026
0d14e9f
feat(desktop): expand conversation detail view and settings with loca…
May 18, 2026
dc88c42
Polish local daemon launch path
May 19, 2026
0e4dddf
Add local-only MVP self-test
May 19, 2026
2638675
Document desktop routing test command
May 19, 2026
1d8d95b
Harden local daemon retry contract
May 19, 2026
d0ea4d4
Close desktop local-mode cloud leaks
May 19, 2026
75bb204
Localize visible local-mode surfaces
May 19, 2026
93aff70
Expand local MVP boundary verification
May 19, 2026
4e1203f
Harden local daemon processing contracts
May 19, 2026
dad7510
Polish local-mode desktop surfaces
May 19, 2026
1d3293a
Harden desktop auth secret boundaries
May 19, 2026
2792f49
Close local memory import and listing gaps
May 19, 2026
b3005d3
Close local mode proactive cloud leaks
May 19, 2026
f16e16f
Add hybrid provider parity for local daemon desktop mode.
Git-on-my-level May 19, 2026
149d1a6
Update .gitignore to include cursor and desktop build artifacts
Git-on-my-level May 19, 2026
373a37c
Add ChatGPT/Codex plan support via local loopback proxy.
Git-on-my-level May 20, 2026
6e74de8
Fix ChatGPT plan connect/disconnect UX and proxy startup reliability.
Git-on-my-level May 20, 2026
22e2787
feat(local-backend): add hybrid provider settings, routes, and storag…
May 20, 2026
f07100d
Add local model catalog defaults
May 20, 2026
cb6df9f
Run local processing through post transcript slot
May 20, 2026
0720be7
Route proactive assistants through local slots
May 20, 2026
58f06ad
Make local Ask Omi use chat slot policy
May 20, 2026
90eb0b2
Expose local provider slot readiness
May 20, 2026
37e59b2
Keep hybrid local embeddings opt-in
May 20, 2026
a82c529
Ignore local-asr-helper Rust build artifacts.
Git-on-my-level May 20, 2026
818e2dd
Add desktop transcription provider architecture
May 20, 2026
1b6ff13
Ignore nested target directories
May 20, 2026
22bb456
Add local ASR helper scaffold
May 20, 2026
0e5cc29
Wire local PTT transcription routing
May 20, 2026
b2063dc
Add local transcription desktop choice
May 20, 2026
b6bcdd3
Add real local ASR helper runtime
May 20, 2026
4f48a48
Narrow local background transcription scope
May 20, 2026
9ee7db2
Fix transcription word count corruption and main-thread engine probe …
cursoragent May 20, 2026
99e267e
Reorient desktop local transcription settings
May 20, 2026
772d264
Add local background ASR chunk queue
May 20, 2026
f0ab806
feat(desktop): wire local transcription state, ASR runtime integratio…
May 20, 2026
e7f2873
feat(desktop): add local background ASR harness
May 20, 2026
6ffb6e0
Fix local background transcription gating
May 20, 2026
e85360b
Update .gitignore files to exclude agent-generated plans and build ar…
Git-on-my-level May 20, 2026
b8d600d
Fix ChatGPT tier security and desktop Codex/wiki bugs
cursoragent May 20, 2026
5585c01
Resolve cherry-pick integration warnings
Git-on-my-level May 20, 2026
dae1f80
Fix local provider policy seeding
Git-on-my-level May 20, 2026
370e25e
Track hybrid local tmux launcher
Git-on-my-level May 20, 2026
0427505
Add local ASR add-on flow
Git-on-my-level May 20, 2026
00756cd
Polish local desktop transcription setup
Git-on-my-level May 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,16 @@ venv/
.DS_Store
dump/
/scripts/
!/scripts/
!/scripts/hybrid-local.sh
*.zip
*.wav
node_modules
yarn.lock

# Cursor (agent-generated plans; rules/hooks remain tracked)
.cursor/plans/

# Coordination (AI agent collab manifests)
.coordination/

Expand All @@ -30,7 +35,9 @@ yarn.lock
build/
dist/
.build/
**/.build-*/
.swiftpm/
**/target/

# VS Code
.vscode/*
Expand Down Expand Up @@ -103,7 +110,10 @@ web/app/public/firebase-messaging-sw.js
!app/pubspec.lock
!app/ios/Podfile.lock
!mcp/uv.lock
desktop/codex-proxy/target/
desktop/local-asr-helper/target/
*.lock
!desktop/codex-proxy/Cargo.lock
*.log
*.swo
*.swp
Expand Down Expand Up @@ -221,3 +231,7 @@ app/macos/Runner/LocalDev.entitlements
# Generated browser snapshots (contain PII)
builds-snapshot.md
*-snapshot.md

# Cursor / agent local artifacts
.cursor/plans/
desktop/Desktop/.build-*/
26 changes: 26 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Hybrid local development (desktop local daemon + Omi Dev app).
# See desktop/local-backend/docs/local-mvp-runbook.md

.PHONY: help serve-local down-local local-asr-fixture

help:
@echo "Hybrid local development targets:"
@echo " make serve-local Start omi-local-backend + Omi Dev (tmux when available)"
@echo " make down-local Stop tmux session, daemon, and dev desktop app"
@echo " make local-asr-fixture"
@echo " Build a production-shaped Local Whisper fixture manifest"
@echo ""
@echo "Optional env: OMI_LOCAL_DAEMON_URL, OMI_LOCAL_BACKEND_DATA_DIR,"
@echo " OMI_HYBRID_LOCAL_TMUX_SESSION (default: omi-hybrid-local)"
@echo " OMI_HYBRID_LOCAL_ATTACH=0 start tmux detached"
@echo " OMI_LOCAL_ASR_PYTHON Python with mlx-whisper for local ASR fixture"
@echo " OMI_LOCAL_ASR_FIXTURE_DIR fixture output dir (default: /tmp/omi-local-asr-fixture)"

serve-local:
@bash "$(CURDIR)/scripts/hybrid-local.sh" up

down-local:
@bash "$(CURDIR)/scripts/hybrid-local.sh" down

local-asr-fixture:
@bash "$(CURDIR)/desktop/local-asr-addon/build_dev_fixture.sh"
58 changes: 58 additions & 0 deletions backend/database/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
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(
Expand Down
2 changes: 2 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,10 @@
app.add_middleware(TimeoutMiddleware, methods_timeout=methods_timeout)

from utils.byok import BYOKMiddleware
from utils.chatgpt import ChatGPTMiddleware

app.add_middleware(BYOKMiddleware)
app.add_middleware(ChatGPTMiddleware)


@app.on_event("shutdown")
Expand Down
70 changes: 70 additions & 0 deletions backend/routers/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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)
clear_trial_paywall_cache(uid)
return {"active": True}


@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(
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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)',
Expand Down
35 changes: 35 additions & 0 deletions backend/tests/unit/test_chatgpt_enrollment.py
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
74 changes: 74 additions & 0 deletions backend/utils/chatgpt.py
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)
14 changes: 13 additions & 1 deletion backend/utils/subscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from database.announcements import compare_versions
from models.users import PlanType, SubscriptionStatus, Subscription, PlanLimits, TrialMetadata
from utils.byok import get_byok_key, get_byok_keys
from utils.chatgpt import chatgpt_request_grants_bypass
from utils.log_sanitizer import sanitize
import logging

Expand Down Expand Up @@ -86,6 +87,8 @@ def _is_trial_expired_uncached(uid: str) -> bool:
return False
if users_db.is_byok_active(uid):
return False
if users_db.is_chatgpt_active(uid):
return False
user_record = firebase_auth.get_user(uid)
creation_ms = user_record.user_metadata.creation_timestamp
if not creation_ms:
Expand Down Expand Up @@ -157,7 +160,12 @@ def get_trial_metadata(uid: str) -> TrialMetadata:
# Same request-level escape hatch as `_is_trial_expired_cached`: a request
# carrying all 4 BYOK provider headers is treated as BYOK-active even if
# Firestore hasn't caught up yet.
if plan != PlanType.basic or users_db.is_byok_active(uid) or _request_has_all_byok_keys():
if (
plan != PlanType.basic
or users_db.is_byok_active(uid)
or users_db.is_chatgpt_active(uid)
or _request_has_all_byok_keys()
):
return TrialMetadata(
trial_expired=False,
trial_duration_seconds=TRIAL_LENGTH_SECONDS,
Expand Down Expand Up @@ -534,6 +542,10 @@ def enforce_chat_quota(uid: str, platform: Optional[str] = None) -> None:
)

# BYOK users pay their own LLM provider — no Omi-side cost to cap.
# ChatGPT/Codex tier: bypass only when this request proves Codex enrollment (header).
if chatgpt_request_grants_bypass(uid):
return

# Require an LLM provider key on this request (not just any BYOK header)
# so a user can't activate with fake fingerprints or send only x-byok-deepgram
# to bypass chat quota while chat falls back to Omi's OpenAI/Anthropic keys.
Expand Down
Loading