From bcdc7bbed2e91fc5a6019ab953f8b05745d0ea5c Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 20 May 2026 00:54:04 -0400 Subject: [PATCH 1/2] Add ChatGPT/Codex plan support via local loopback proxy. Route proactive LLM through a localhost Codex proxy using ~/.codex auth, add Settings enrollment UX, local memory wiki + FTS for search, backend tier activation, and bundle the proxy in run.sh. Adapted for upstream main without hybrid local-daemon dependencies. Co-authored-by: Cursor --- .gitignore | 2 + backend/database/users.py | 49 + backend/routers/users.py | 69 + backend/tests/unit/test_chatgpt_enrollment.py | 35 + backend/utils/subscription.py | 13 +- desktop/Desktop/Sources/APIClient.swift | 15 + .../Desktop/Sources/CodexAuthService.swift | 110 ++ .../Sources/CodexEnrollmentCoordinator.swift | 134 ++ desktop/Desktop/Sources/CodexLLMClient.swift | 401 +++++ .../Sources/CodexProviderBootstrap.swift | 16 + .../Desktop/Sources/CodexProxyService.swift | 186 +++ .../MainWindow/Pages/SettingsPage.swift | 91 ++ .../Desktop/Sources/MemoryWikiStorage.swift | 178 ++ desktop/Desktop/Sources/OmiApp.swift | 3 + .../MemoryExtraction/MemoryAssistant.swift | 15 + .../TaskExtraction/TaskAssistant.swift | 63 +- .../Core/GeminiClient.swift | 147 +- .../Sources/Rewind/Core/RewindDatabase.swift | 52 + .../Desktop/Tests/CodexAuthServiceTests.swift | 119 ++ desktop/codex-proxy/Cargo.lock | 1443 +++++++++++++++++ desktop/codex-proxy/Cargo.toml | 13 + desktop/codex-proxy/README.md | 61 + desktop/codex-proxy/e2e_smoke.sh | 63 + desktop/codex-proxy/src/main.rs | 799 +++++++++ desktop/run.sh | 11 + 25 files changed, 4074 insertions(+), 14 deletions(-) create mode 100644 backend/tests/unit/test_chatgpt_enrollment.py create mode 100644 desktop/Desktop/Sources/CodexAuthService.swift create mode 100644 desktop/Desktop/Sources/CodexEnrollmentCoordinator.swift create mode 100644 desktop/Desktop/Sources/CodexLLMClient.swift create mode 100644 desktop/Desktop/Sources/CodexProviderBootstrap.swift create mode 100644 desktop/Desktop/Sources/CodexProxyService.swift create mode 100644 desktop/Desktop/Sources/MemoryWikiStorage.swift create mode 100644 desktop/Desktop/Tests/CodexAuthServiceTests.swift create mode 100644 desktop/codex-proxy/Cargo.lock create mode 100644 desktop/codex-proxy/Cargo.toml create mode 100644 desktop/codex-proxy/README.md create mode 100755 desktop/codex-proxy/e2e_smoke.sh create mode 100644 desktop/codex-proxy/src/main.rs diff --git a/.gitignore b/.gitignore index 13d9ed256b2..cececfc894b 100644 --- a/.gitignore +++ b/.gitignore @@ -103,7 +103,9 @@ web/app/public/firebase-messaging-sw.js !app/pubspec.lock !app/ios/Podfile.lock !mcp/uv.lock +desktop/codex-proxy/target/ *.lock +!desktop/codex-proxy/Cargo.lock *.log *.swo *.swp diff --git a/backend/database/users.py b/backend/database/users.py index 448b4d28b44..b3fbf6fe539 100644 --- a/backend/database/users.py +++ b/backend/database/users.py @@ -215,6 +215,55 @@ 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 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( diff --git a/backend/routers/users.py b/backend/routers/users.py index 286a7a8e146..761dce841d3 100644 --- a/backend/routers/users.py +++ b/backend/routers/users.py @@ -813,6 +813,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( @@ -844,6 +885,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 users_db.is_chatgpt_active(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 +1110,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 users_db.is_chatgpt_active(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)', diff --git a/backend/tests/unit/test_chatgpt_enrollment.py b/backend/tests/unit/test_chatgpt_enrollment.py new file mode 100644 index 00000000000..217f1e02751 --- /dev/null +++ b/backend/tests/unit/test_chatgpt_enrollment.py @@ -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 diff --git a/backend/utils/subscription.py b/backend/utils/subscription.py index e66601cac3a..11e776ab0c9 100644 --- a/backend/utils/subscription.py +++ b/backend/utils/subscription.py @@ -73,6 +73,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: @@ -140,7 +142,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, @@ -517,6 +524,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: user pays OpenAI via subscription; LLM quota bypass only. + if users_db.is_chatgpt_active(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. diff --git a/desktop/Desktop/Sources/APIClient.swift b/desktop/Desktop/Sources/APIClient.swift index 5f24f348c28..7ea75590c1e 100644 --- a/desktop/Desktop/Sources/APIClient.swift +++ b/desktop/Desktop/Sources/APIClient.swift @@ -4546,6 +4546,21 @@ extension APIClient { try await delete("v1/users/me/byok-active") } + /// Activate ChatGPT / Codex subscription tier (LLM only; fingerprint of account_id). + func activateChatGPT(fingerprint: String) async throws { + struct Request: Encodable { + let fingerprint: String + } + struct Empty: Decodable {} + let _: Empty = try await post( + "v1/users/me/chatgpt-active", body: Request(fingerprint: fingerprint) + ) + } + + func deactivateChatGPT() async throws { + try await delete("v1/users/me/chatgpt-active") + } + /// Fetches all people for the current user func getPeople() async throws -> [Person] { return try await get("v1/users/people") diff --git a/desktop/Desktop/Sources/CodexAuthService.swift b/desktop/Desktop/Sources/CodexAuthService.swift new file mode 100644 index 00000000000..03209ea36d4 --- /dev/null +++ b/desktop/Desktop/Sources/CodexAuthService.swift @@ -0,0 +1,110 @@ +import CryptoKit +import Foundation + +/// ChatGPT / Codex subscription auth via local `~/.codex/auth.json` (same cache as Codex CLI). +/// Tokens never leave this Mac except through the loopback Codex proxy to OpenAI. +enum CodexAuthService { + private static let enrolledKey = "codex_auth_enrolled" + private static let preferredModelKey = "codex_preferred_model" + private static let defaultModel = "gpt-5.4" + + struct AuthSnapshot: Equatable { + let accessToken: String + let accountId: String + let refreshToken: String? + let authFilePath: URL + } + + /// User opted in via Settings (distinct from merely having auth.json from Codex CLI). + static var isEnrolled: Bool { + UserDefaults.standard.bool(forKey: enrolledKey) + } + + static var preferredModel: String { + let stored = UserDefaults.standard.string(forKey: preferredModelKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let stored, !stored.isEmpty { return stored } + return defaultModel + } + + static func setPreferredModel(_ model: String) { + UserDefaults.standard.set(model, forKey: preferredModelKey) + } + + /// SHA-256 fingerprint of account_id for backend enrollment (never stores tokens server-side). + static func enrollmentFingerprint(for accountId: String) -> String { + let digest = SHA256.hash(data: Data(accountId.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } + + static func resolveAuthFilePath() -> URL { + if let codexHome = ProcessInfo.processInfo.environment["CODEX_HOME"], + !codexHome.isEmpty + { + return URL(fileURLWithPath: codexHome).appendingPathComponent("auth.json") + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex/auth.json") + } + + static func loadSnapshot() -> AuthSnapshot? { + let url = resolveAuthFilePath() + guard let data = try? Data(contentsOf: url), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let fields = parseAuthFields(from: json) + else { + return nil + } + return AuthSnapshot( + accessToken: fields.accessToken, + accountId: fields.accountId, + refreshToken: fields.refreshToken, + authFilePath: url + ) + } + + /// Codex CLI stores tokens at the top level (legacy) or under `tokens` (current format). + private static func parseAuthFields(from json: [String: Any]) -> ( + accessToken: String, accountId: String, refreshToken: String? + )? { + if let parsed = parseAuthFields(fromTokenContainer: json) { + return parsed + } + if let tokens = json["tokens"] as? [String: Any] { + return parseAuthFields(fromTokenContainer: tokens) + } + return nil + } + + private static func parseAuthFields(fromTokenContainer json: [String: Any]) -> ( + accessToken: String, accountId: String, refreshToken: String? + )? { + guard let accessToken = json["access_token"] as? String, + !accessToken.isEmpty, + let accountId = json["account_id"] as? String, + !accountId.isEmpty + else { + return nil + } + let refresh = (json["refresh_token"] as? String).flatMap { $0.isEmpty ? nil : $0 } + return (accessToken, accountId, refresh) + } + + /// True when enrolled and a valid auth file is present. + static var isActive: Bool { + isEnrolled && loadSnapshot() != nil + } + + static func markEnrolled() { + UserDefaults.standard.set(true, forKey: enrolledKey) + } + + static func clearEnrollment() { + UserDefaults.standard.set(false, forKey: enrolledKey) + } + + static func enrollmentFingerprintIfActive() -> String? { + guard let snap = loadSnapshot(), isEnrolled else { return nil } + return enrollmentFingerprint(for: snap.accountId) + } +} diff --git a/desktop/Desktop/Sources/CodexEnrollmentCoordinator.swift b/desktop/Desktop/Sources/CodexEnrollmentCoordinator.swift new file mode 100644 index 00000000000..eb9bafb5bb2 --- /dev/null +++ b/desktop/Desktop/Sources/CodexEnrollmentCoordinator.swift @@ -0,0 +1,134 @@ +import AppKit +import Foundation + +/// Connects ChatGPT / Codex subscription: runs `codex login`, validates auth.json, enrolls backend. +@MainActor +enum CodexEnrollmentCoordinator { + enum EnrollmentError: LocalizedError { + case authFileMissing + case proxyFailed(String) + case backendFailed(String) + + var errorDescription: String? { + switch self { + case .authFileMissing: + return + "Sign-in timed out. Complete login in Terminal, then try again." + case .proxyFailed(let msg): + return "Codex proxy failed: \(msg)" + case .backendFailed(let msg): + return "Could not activate ChatGPT plan on account: \(msg)" + } + } + } + + private static var connectInFlight = false + private static var loginTerminalLaunched = false + + /// Opens Codex login in Terminal (user completes browser flow). + private static func launchCodexLogin() { + if loginTerminalLaunched { + activateTerminal() + return + } + loginTerminalLaunched = true + + let command = "npx @openai/codex login" + // `do script` alone always opens a new window when Terminal is already running. + // Reuse the front window (new tab) so one click does not spawn a second window. + let script = """ + tell application "Terminal" + if not running then + do script "\(command)" + else + activate + if (count of windows) is 0 then + do script "\(command)" + else + do script "\(command)" in front window + end if + end if + end tell + """ + if let appleScript = NSAppleScript(source: script) { + var error: NSDictionary? + appleScript.executeAndReturnError(&error) + if error != nil { + loginTerminalLaunched = false + NSWorkspace.shared.open(URL(string: "https://developers.openai.com/codex/auth")!) + } + } + } + + private static func activateTerminal() { + let script = """ + tell application "Terminal" + activate + end tell + """ + if let appleScript = NSAppleScript(source: script) { + var error: NSDictionary? + appleScript.executeAndReturnError(&error) + } + } + + /// Sign in: use existing auth if present, otherwise open Codex login then poll. + static func connect(pollSeconds: Int = 120) async throws { + guard !connectInFlight else { return } + connectInFlight = true + defer { + connectInFlight = false + loginTerminalLaunched = false + } + + if let snap = CodexAuthService.loadSnapshot() { + try await finalizeEnrollment(snapshot: snap) + return + } + launchCodexLogin() + try await connectAfterLogin(pollSeconds: pollSeconds) + } + + /// Poll for auth.json after login, then enroll + start proxy. + private static func connectAfterLogin(pollSeconds: Int = 120) async throws { + let deadline = Date().addingTimeInterval(TimeInterval(pollSeconds)) + while Date() < deadline { + if let snap = CodexAuthService.loadSnapshot() { + try await finalizeEnrollment(snapshot: snap) + return + } + try await Task.sleep(nanoseconds: 2_000_000_000) + } + throw EnrollmentError.authFileMissing + } + + static func disconnect() async { + CodexAuthService.clearEnrollment() + await CodexProxyService.shared.stop() + await CodexProviderBootstrap.clearDaemonProviders() + try? await APIClient.shared.deactivateChatGPT() + await FloatingBarUsageLimiter.shared.fetchPlan() + } + + private static func finalizeEnrollment(snapshot: CodexAuthService.AuthSnapshot) async throws { + CodexAuthService.markEnrolled() + await CodexProxyService.shared.ensureRunning() + guard CodexProxyService.shared.isRunning else { + CodexAuthService.clearEnrollment() + throw EnrollmentError.proxyFailed(CodexProxyService.shared.lastError ?? "unknown") + } + + let fingerprint = CodexAuthService.enrollmentFingerprint(for: snapshot.accountId) + do { + try await APIClient.shared.activateChatGPT(fingerprint: fingerprint) + } catch { + CodexAuthService.clearEnrollment() + await CodexProxyService.shared.stop() + throw EnrollmentError.backendFailed(error.localizedDescription) + } + + await CodexProviderBootstrap.applyIfNeeded() + await FloatingBarUsageLimiter.shared.fetchPlan() + AppState.current?.isPaywalled = false + } +} diff --git a/desktop/Desktop/Sources/CodexLLMClient.swift b/desktop/Desktop/Sources/CodexLLMClient.swift new file mode 100644 index 00000000000..57f662b1b28 --- /dev/null +++ b/desktop/Desktop/Sources/CodexLLMClient.swift @@ -0,0 +1,401 @@ +import Foundation +import Vision + +/// OpenAI-compatible chat completions via the local Codex loopback proxy (ChatGPT subscription tier). +enum CodexLLMClient { + + struct ProviderConfig: Equatable { + let baseURL: String + let model: String + let apiKey: String + } + + enum ClientError: LocalizedError { + case notConfigured + case invalidSettings + case invalidResponse + case httpFailure(status: Int, body: String) + + var errorDescription: String? { + switch self { + case .notConfigured: + return "ChatGPT plan is not active. Sign in with ChatGPT in Settings." + case .invalidSettings: + return "Codex proxy settings are invalid." + case .invalidResponse: + return "Codex proxy returned an unexpected response." + case .httpFailure(let status, _): + return "Codex proxy request failed (HTTP \(status))." + } + } + } + + static func providerConfig() -> ProviderConfig? { + guard CodexAuthService.isActive else { return nil } + return ProviderConfig( + baseURL: CodexProxyEndpoints.baseURL, + model: CodexAuthService.preferredModel, + apiKey: "" + ) + } + + private static func completionsURL(config: ProviderConfig) throws -> URL { + let trimmed = config.baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + let base = trimmed.hasSuffix("/") ? String(trimmed.dropLast()) : trimmed + guard let url = URL(string: "\(base)/chat/completions") else { + throw ClientError.invalidSettings + } + return url + } + + private static func postJSON(url: URL, body: [String: Any], apiKey: String, timeout: TimeInterval) async throws + -> [String: Any] + { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if !apiKey.isEmpty { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + request.timeoutInterval = timeout + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw ClientError.invalidResponse + } + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw ClientError.invalidResponse + } + guard (200..<300).contains(http.statusCode) else { + let body = String(data: data.prefix(512), encoding: .utf8) ?? "" + throw ClientError.httpFailure(status: http.statusCode, body: body) + } + return json + } + + static func chatCompletionText( + config: ProviderConfig, + systemPrompt: String, + userText: String, + jsonMode: Bool, + timeout: TimeInterval = 300 + ) async throws -> String { + let content: [[String: Any]] = [ + ["type": "text", "text": userText] + ] + let messages: [[String: Any]] = [ + ["role": "system", "content": systemPrompt], + ["role": "user", "content": content], + ] + return try await chatCompletionRaw( + config: config, messages: messages, jsonMode: jsonMode, tools: nil, toolChoice: nil, timeout: timeout) + } + + static func chatCompletionMultimodalJPEG( + config: ProviderConfig, + systemPrompt: String, + userText: String, + jpegData: Data, + jsonMode: Bool, + timeout: TimeInterval = 300 + ) async throws -> String { + let b64 = jpegData.base64EncodedString() + let dataUrl = "data:image/jpeg;base64,\(b64)" + let content: [[String: Any]] = [ + ["type": "text", "text": userText], + ["type": "image_url", "image_url": ["url": dataUrl]], + ] + let messages: [[String: Any]] = [ + ["role": "system", "content": systemPrompt], + ["role": "user", "content": content], + ] + return try await chatCompletionRaw( + config: config, messages: messages, jsonMode: jsonMode, tools: nil, toolChoice: nil, timeout: timeout) + } + + private static func chatCompletionRaw( + config: ProviderConfig, + messages: [[String: Any]], + jsonMode: Bool, + tools: [[String: Any]]?, + toolChoice: Any?, + timeout: TimeInterval + ) async throws -> String { + var body: [String: Any] = [ + "model": config.model, + "messages": messages, + "temperature": 0.4, + ] + if jsonMode { + body["response_format"] = ["type": "json_object"] + } + if let tools { + body["tools"] = tools + } + if let toolChoice { + body["tool_choice"] = toolChoice + } + + let json = try await postJSON( + url: try completionsURL(config: config), body: body, apiKey: config.apiKey, timeout: timeout) + return try extractAssistantText(from: json) + } + + private static func extractAssistantText(from json: [String: Any]) throws -> String { + guard let choices = json["choices"] as? [[String: Any]], + let first = choices.first, + let message = first["message"] as? [String: Any] + else { + throw ClientError.invalidResponse + } + if let toolCalls = message["tool_calls"] as? [[String: Any]], !toolCalls.isEmpty { + throw ClientError.invalidResponse + } + if let str = message["content"] as? String { + return str + } + if let parts = message["content"] as? [[String: Any]] { + let texts = parts.compactMap { $0["text"] as? String } + return texts.joined(separator: "\n") + } + throw ClientError.invalidResponse + } + + static func performGeminiCompatibleToolRound( + config: ProviderConfig, + systemPrompt: String, + contents: [GeminiImageToolRequest.Content], + tools: [GeminiTool], + forceToolCall: Bool, + allowVisionInlineJPEG: Bool, + timeout: TimeInterval = 300 + ) async throws -> ToolChatResult { + let messages = try openAIMessages(from: contents, allowVisionInlineJPEG: allowVisionInlineJPEG) + let openAITools = openAITools(from: tools) + + var toolChoice: Any = "auto" + if forceToolCall { + toolChoice = "required" + } + + var body: [String: Any] = [ + "model": config.model, + "messages": [["role": "system", "content": systemPrompt]] + messages, + "tools": openAITools, + "tool_choice": toolChoice, + "temperature": 0.4, + ] + + let json = try await postJSON( + url: try completionsURL(config: config), body: body, apiKey: config.apiKey, timeout: timeout) + return try parseToolChatResult(from: json) + } + + private static func parseToolChatResult(from json: [String: Any]) throws -> ToolChatResult { + guard let choices = json["choices"] as? [[String: Any]], + let first = choices.first, + let message = first["message"] as? [String: Any] + else { + throw ClientError.invalidResponse + } + + var toolCalls: [ToolCall] = [] + if let rawCalls = message["tool_calls"] as? [[String: Any]] { + for tc in rawCalls { + guard let fn = tc["function"] as? [String: Any], + let name = fn["name"] as? String + else { + continue + } + let argsStr = fn["arguments"] as? String ?? "{}" + let argsAny = + (try? JSONSerialization.jsonObject(with: Data(argsStr.utf8))) as? [String: Any] ?? [:] + toolCalls.append( + ToolCall(name: name, arguments: argsAny, thoughtSignature: nil)) + } + } + + var textResponse = "" + if let content = message["content"] as? String { + textResponse = content + } else if let parts = message["content"] as? [[String: Any]] { + textResponse = parts.compactMap { $0["text"] as? String }.joined(separator: "\n") + } + + return ToolChatResult( + text: textResponse, + toolCalls: toolCalls, + requiresToolExecution: !toolCalls.isEmpty + ) + } + + private static func openAIMessages( + from contents: [GeminiImageToolRequest.Content], + allowVisionInlineJPEG: Bool + ) throws -> [[String: Any]] { + var out: [[String: Any]] = [] + var pendingToolCallIds: [String] = [] + + for content in contents { + let role = content.role + if role == "user" { + var userParts: [[String: Any]] = [] + var toolResults: [[String: Any]] = [] + + for part in content.parts { + if let fr = part.functionResponse { + let toolCallId = + pendingToolCallIds.isEmpty ? "call_codex_fallback_\(fr.name)" : pendingToolCallIds.removeFirst() + toolResults.append([ + "role": "tool", + "tool_call_id": toolCallId, + "content": fr.response.result, + ]) + continue + } + if let t = part.text, !t.isEmpty { + userParts.append(["type": "text", "text": t]) + } + if let img = part.inlineData, allowVisionInlineJPEG { + let mime = img.mimeType + let dataUrl = "data:\(mime);base64,\(img.data)" + userParts.append(["type": "image_url", "image_url": ["url": dataUrl]]) + } + } + + if !userParts.isEmpty { + out.append(["role": "user", "content": userParts]) + } + for tr in toolResults { + out.append(tr) + } + } else if role == "model" { + var textAccum = "" + var oaToolCalls: [[String: Any]] = [] + + for part in content.parts { + if let t = part.text, !t.isEmpty { + textAccum += t + } + if let fc = part.functionCall { + let id = "call_" + UUID().uuidString.replacingOccurrences(of: "-", with: "") + pendingToolCallIds.append(id) + let argData = try JSONSerialization.data(withJSONObject: fc.args, options: []) + let argStr = String(data: argData, encoding: .utf8) ?? "{}" + oaToolCalls.append([ + "id": id, + "type": "function", + "function": ["name": fc.name, "arguments": argStr], + ]) + } + } + + var msg: [String: Any] = ["role": "assistant"] + if !textAccum.isEmpty { + msg["content"] = textAccum + } else if oaToolCalls.isEmpty { + msg["content"] = "" + } + if !oaToolCalls.isEmpty { + msg["tool_calls"] = oaToolCalls + } + out.append(msg) + } + } + return out + } + + private static func openAITools(from tools: [GeminiTool]) -> [[String: Any]] { + tools.flatMap(\.functionDeclarations).compactMap { fd in + guard let schema = jsonSchema(from: fd.parameters) else { return nil } + return [ + "type": "function", + "function": [ + "name": fd.name, + "description": fd.description, + "parameters": schema, + ], + ] + } + } + + private static func jsonSchema(from params: GeminiTool.FunctionDeclaration.Parameters) -> [String: Any]? { + var properties: [String: Any] = [:] + for (name, prop) in params.properties { + if let nested = propJSONSchema(prop) { + properties[name] = nested + } + } + return [ + "type": params.type, + "properties": properties, + "required": params.required, + ] + } + + private static func propJSONSchema(_ prop: GeminiTool.FunctionDeclaration.Parameters.Property) -> [String: Any]? { + if let nested = prop.nestedProperties, let req = prop.nestedRequired { + var childProps: [String: Any] = [:] + for (k, v) in nested { + if let sch = propJSONSchema(v) { + childProps[k] = sch + } + } + var obj: [String: Any] = [ + "type": "object", + "properties": childProps, + "required": req, + ] + if let d = prop.description, !d.isEmpty { + obj["description"] = d + } + return obj + } + + var out: [String: Any] = [ + "type": prop.type + ] + if let d = prop.description, !d.isEmpty { + out["description"] = d + } + if let `enum` = prop.`enum` { + out["enum"] = `enum` + } + if let items = prop.items { + out["items"] = ["type": items.type] + } + return out + } + + enum ScreenOCR { + static func recognizeTextFromJPEG(_ jpegData: Data) async throws -> String { + try await Task.detached(priority: .userInitiated) { + try await Self.recognizeTextFromJPEGSync(jpegData) + }.value + } + + private static func recognizeTextFromJPEGSync(_ jpegData: Data) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + let request = VNRecognizeTextRequest { request, error in + if let error { + continuation.resume(throwing: error) + return + } + let observations = (request.results as? [VNRecognizedTextObservation]) ?? [] + let text = observations.compactMap { $0.topCandidates(1).first?.string }.joined(separator: "\n") + continuation.resume(returning: text) + } + request.recognitionLevel = .accurate + request.usesLanguageCorrection = true + + let handler = VNImageRequestHandler(data: jpegData, options: [:]) + do { + try handler.perform([request]) + } catch { + continuation.resume(throwing: error) + } + } + } + } +} diff --git a/desktop/Desktop/Sources/CodexProviderBootstrap.swift b/desktop/Desktop/Sources/CodexProviderBootstrap.swift new file mode 100644 index 00000000000..a132c99dd65 --- /dev/null +++ b/desktop/Desktop/Sources/CodexProviderBootstrap.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Starts the Codex loopback proxy when ChatGPT tier is enrolled (cloud desktop mode). +enum CodexProviderBootstrap { + + @MainActor + static func applyIfNeeded() async { + guard CodexAuthService.isActive else { return } + await CodexProxyService.shared.ensureRunning() + } + + @MainActor + static func clearDaemonProviders() async { + await CodexProxyService.shared.stop() + } +} diff --git a/desktop/Desktop/Sources/CodexProxyService.swift b/desktop/Desktop/Sources/CodexProxyService.swift new file mode 100644 index 00000000000..6eecf3570af --- /dev/null +++ b/desktop/Desktop/Sources/CodexProxyService.swift @@ -0,0 +1,186 @@ +import Foundation + +/// Loopback Codex proxy endpoints (nonisolated constants). +enum CodexProxyEndpoints { + static let defaultPort: Int = 10531 + + static var baseURL: String { + let port: Int + if let raw = ProcessInfo.processInfo.environment["OMI_CODEX_PROXY_PORT"], + let value = Int(raw), value > 0 + { + port = value + } else { + port = defaultPort + } + return "http://127.0.0.1:\(port)/v1" + } + + static var healthURL: String { + baseURL.replacingOccurrences(of: "/v1", with: "") + "/health" + } +} + +/// Manages the loopback Codex OpenAI-compatible proxy (`desktop/codex-proxy`). +@MainActor +final class CodexProxyService: ObservableObject { + static let shared = CodexProxyService() + + static var defaultBaseURL: String { CodexProxyEndpoints.baseURL } + + private static var port: Int { + if let raw = ProcessInfo.processInfo.environment["OMI_CODEX_PROXY_PORT"], + let value = Int(raw), value > 0 + { + return value + } + return CodexProxyEndpoints.defaultPort + } + + @Published private(set) var isRunning = false + @Published private(set) var lastError: String? + + private var process: Process? + private var healthTask: Task? + + private init() {} + + /// Start proxy when ChatGPT tier is active. Idempotent. + func ensureRunning() async { + guard CodexAuthService.isActive else { + await stop() + return + } + if isRunning, await healthCheck() { return } + await stop() + guard let executable = resolveExecutableURL() else { + lastError = + "Codex proxy binary not found. Build with: cd desktop/codex-proxy && cargo build --release" + isRunning = false + return + } + guard CodexAuthService.loadSnapshot() != nil else { + lastError = "Sign in with ChatGPT first (run Codex login or connect in Settings)." + isRunning = false + return + } + + let proc = Process() + proc.executableURL = executable + proc.arguments = [] + var env = ProcessInfo.processInfo.environment + env["OMI_CODEX_PROXY_PORT"] = String(Self.port) + proc.environment = env + proc.standardOutput = FileHandle.nullDevice + proc.standardError = FileHandle.nullDevice + + do { + try proc.run() + process = proc + for _ in 0..<30 { + try? await Task.sleep(nanoseconds: 100_000_000) + if await healthCheck() { + isRunning = true + lastError = nil + startHealthMonitor() + log("CodexProxyService: proxy running at \(Self.defaultBaseURL)") + return + } + } + lastError = "Codex proxy failed to start (health check timeout)." + await stop() + } catch { + lastError = error.localizedDescription + await stop() + } + } + + func stop() async { + healthTask?.cancel() + healthTask = nil + if let process, process.isRunning { + process.terminate() + } + process = nil + isRunning = false + } + + private func startHealthMonitor() { + healthTask?.cancel() + healthTask = Task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 15_000_000_000) + guard CodexAuthService.isActive else { + await stop() + return + } + if !(await healthCheck()) { + log("CodexProxyService: health failed — restarting") + await ensureRunning() + return + } + } + } + } + + private func healthCheck() async -> Bool { + guard let url = URL(string: CodexProxyEndpoints.healthURL) else { + return false + } + var request = URLRequest(url: url) + request.timeoutInterval = 2 + do { + let (_, response) = try await URLSession.shared.data(for: request) + return (response as? HTTPURLResponse)?.statusCode == 200 + } catch { + return false + } + } + + private func resolveExecutableURL() -> URL? { + let names = ["omi-codex-proxy", "codex-proxy"] + if let resource = Bundle.main.resourceURL { + for name in names { + let candidate = resource.appendingPathComponent(name) + if FileManager.default.isExecutableFile(atPath: candidate.path) { + return candidate + } + } + } + let repoRelative = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("codex-proxy/target/release/omi-codex-proxy") + if FileManager.default.isExecutableFile(atPath: repoRelative.path) { + return repoRelative + } + for name in names { + if let path = which(name) { + return URL(fileURLWithPath: path) + } + } + return nil + } + + private func which(_ name: String) -> String? { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/which") + proc.arguments = [name] + let pipe = Pipe() + proc.standardOutput = pipe + proc.standardError = FileHandle.nullDevice + do { + try proc.run() + proc.waitUntilExit() + guard proc.terminationStatus == 0 else { return nil } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let path = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let path, !path.isEmpty else { return nil } + return path + } catch { + return nil + } + } +} diff --git a/desktop/Desktop/Sources/MainWindow/Pages/SettingsPage.swift b/desktop/Desktop/Sources/MainWindow/Pages/SettingsPage.swift index ebb5b42ff06..26f73c27f4a 100644 --- a/desktop/Desktop/Sources/MainWindow/Pages/SettingsPage.swift +++ b/desktop/Desktop/Sources/MainWindow/Pages/SettingsPage.swift @@ -379,6 +379,8 @@ struct SettingsContentView: View { @AppStorage("dev_deepgram_api_key") private var devDeepgramKey: String = "" @State private var byokKeyStatuses: [BYOKProvider: BYOKValidator.Status] = [:] @State private var byokActivationError: String? + @State private var codexEnrollmentError: String? + @State private var codexEnrollmentBusy = false init( appState: AppState, @@ -3205,6 +3207,8 @@ struct SettingsContentView: View { preferencesSubsection advancedCategoryHeader(title: "Troubleshooting", icon: "wrench.and.screwdriver") troubleshootingSubsection + advancedCategoryHeader(title: "ChatGPT plan", icon: "bubble.left.and.bubble.right") + chatGPTPlanSubsection advancedCategoryHeader(title: "Developer API Keys", icon: "key") developerKeysSubsection @@ -5285,6 +5289,93 @@ struct SettingsContentView: View { return formatter } + // MARK: - ChatGPT / Codex plan + + private var chatGPTPlanSubsection: some View { + VStack(spacing: 20) { + settingsCard(settingId: "advanced.chatgpt.info") { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + Image(systemName: CodexAuthService.isActive ? "checkmark.seal.fill" : "person.crop.circle.badge.checkmark") + .foregroundColor(CodexAuthService.isActive ? OmiColors.success : OmiColors.textTertiary) + Text(CodexAuthService.isActive ? "ChatGPT plan active" : "Use your ChatGPT subscription") + .scaledFont(size: 14, weight: .semibold) + .foregroundColor(OmiColors.textPrimary) + } + Text( + CodexAuthService.isActive + ? "LLM features use your ChatGPT/Codex subscription via a local proxy on this Mac. Memory search uses local wiki + keyword search (no embedding API). Live transcription is unchanged." + : "Sign in with ChatGPT to route chat and proactive AI through your subscription via a local proxy on this Mac. A Terminal window opens for Codex login — complete sign-in there and Omi will connect automatically. Unofficial community integration — use at your own risk per OpenAI terms. Tokens stay on this Mac." + ) + .scaledFont(size: 12) + .foregroundColor(OmiColors.textTertiary) + if CodexProxyService.shared.isRunning { + Text("Proxy: \(CodexProxyService.defaultBaseURL)") + .scaledFont(size: 11) + .foregroundColor(OmiColors.textTertiary) + } + } + } + + if let codexEnrollmentError { + settingsCard(settingId: "advanced.chatgpt.error") { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(OmiColors.warning) + Text(codexEnrollmentError) + .scaledFont(size: 12) + .foregroundColor(OmiColors.textSecondary) + Spacer() + } + } + } + + HStack(spacing: 12) { + if CodexAuthService.isActive { + Button(action: disconnectChatGPTPlan) { + Text("Disconnect") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } else { + Button(action: signInChatGPTPlan) { + Text(codexEnrollmentBusy ? "Signing in…" : "Sign in with ChatGPT") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(codexEnrollmentBusy) + } + } + } + } + + private func signInChatGPTPlan() { + guard !codexEnrollmentBusy else { return } + codexEnrollmentBusy = true + codexEnrollmentError = nil + Task { + do { + try await CodexEnrollmentCoordinator.connect() + await MainActor.run { + codexEnrollmentBusy = false + codexEnrollmentError = nil + } + } catch { + await MainActor.run { + codexEnrollmentBusy = false + codexEnrollmentError = error.localizedDescription + } + } + } + } + + private func disconnectChatGPTPlan() { + Task { + await CodexEnrollmentCoordinator.disconnect() + await MainActor.run { codexEnrollmentError = nil } + } + } + // MARK: - Developer API Keys Subsection private var developerKeysSubsection: some View { diff --git a/desktop/Desktop/Sources/MemoryWikiStorage.swift b/desktop/Desktop/Sources/MemoryWikiStorage.swift new file mode 100644 index 00000000000..4af96ab2b96 --- /dev/null +++ b/desktop/Desktop/Sources/MemoryWikiStorage.swift @@ -0,0 +1,178 @@ +import Foundation +import GRDB + +// MARK: - Memory wiki page + +struct MemoryWikiPageRecord: Codable, FetchableRecord, PersistableRecord, Identifiable { + var id: Int64? + var slug: String + var title: String + var body: String + var tagsJson: String? + var linksJson: String? + var category: String + var sourceType: String? + var sourceId: String? + var createdAt: Date + var updatedAt: Date + + static let databaseTableName = "memory_pages" +} + +struct MemoryWikiSearchHit: Identifiable, Equatable { + let id: Int64 + let slug: String + let title: String + let snippet: String + let category: String + let rank: Double +} + +/// Local structured wiki + FTS5 search (no embedding API). +actor MemoryWikiStorage { + static let shared = MemoryWikiStorage() + + private var dbQueue: DatabasePool? + + private init() {} + + func invalidateCache() { + dbQueue = nil + } + + private func ensureDB() async throws -> DatabasePool { + if let dbQueue { return dbQueue } + try await RewindDatabase.shared.initialize() + guard let queue = await RewindDatabase.shared.getDatabaseQueue() else { + throw MemoryWikiError.databaseNotInitialized + } + dbQueue = queue + return queue + } + + func upsertPage( + slug: String, + title: String, + body: String, + tags: [String] = [], + links: [String] = [], + category: String = "system", + sourceType: String? = nil, + sourceId: String? = nil + ) async throws -> Int64 { + let db = try await ensureDB() + let now = Date() + let tagsJson = tags.isEmpty ? nil : String(data: try JSONEncoder().encode(tags), encoding: .utf8) + let linksJson = links.isEmpty ? nil : String(data: try JSONEncoder().encode(links), encoding: .utf8) + + return try await db.write { database in + if let existing = try MemoryWikiPageRecord + .filter(Column("slug") == slug) + .fetchOne(database) + { + var row = existing + row.title = title + row.body = body + row.tagsJson = tagsJson + row.linksJson = linksJson + row.category = category + row.sourceType = sourceType + row.sourceId = sourceId + row.updatedAt = now + try row.update(database) + return existing.id ?? 0 + } + var row = MemoryWikiPageRecord( + id: nil, + slug: slug, + title: title, + body: body, + tagsJson: tagsJson, + linksJson: linksJson, + category: category, + sourceType: sourceType, + sourceId: sourceId, + createdAt: now, + updatedAt: now + ) + try row.insert(database) + return row.id ?? 0 + } + } + + func search(query: String, limit: Int = 20) async throws -> [MemoryWikiSearchHit] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + let words = trimmed.components(separatedBy: .whitespaces) + .map { $0.filter { $0.isLetter || $0.isNumber } } + .filter { $0.count >= 2 } + guard !words.isEmpty else { return [] } + let ftsQuery = words.map { "\($0)*" }.joined(separator: " OR ") + + let db = try await ensureDB() + return try await db.read { database in + let rows = try Row.fetchAll( + database, + sql: """ + SELECT memory_pages.id, memory_pages.slug, memory_pages.title, memory_pages.category, + snippet(memory_pages_fts, 1, '', '', '…', 12) AS snippet, + bm25(memory_pages_fts) AS rank + FROM memory_pages_fts + JOIN memory_pages ON memory_pages.id = memory_pages_fts.rowid + WHERE memory_pages_fts MATCH ? + ORDER BY rank + LIMIT ? + """, + arguments: [ftsQuery, limit] + ) + return rows.compactMap { row -> MemoryWikiSearchHit? in + guard let id: Int64 = row["id"], + let slug: String = row["slug"], + let title: String = row["title"], + let category: String = row["category"] + else { return nil } + let snippet: String = row["snippet"] ?? title + let rank: Double = row["rank"] ?? 0 + return MemoryWikiSearchHit( + id: id, slug: slug, title: title, snippet: snippet, category: category, rank: rank + ) + } + } + } + + static func slugify(_ title: String) -> String { + let lowered = title.lowercased() + let allowed = lowered.map { char -> Character in + if char.isLetter || char.isNumber { return char } + if char == " " || char == "-" || char == "_" { return "-" } + return "-" + } + let collapsed = String(allowed) + .replacingOccurrences(of: "--+", with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + return collapsed.isEmpty ? "page-\(UUID().uuidString.prefix(8))" : collapsed + } +} + +enum MemoryWikiError: Error { + case databaseNotInitialized +} + +/// Feature flag: local wiki search instead of vector embeddings. +enum MemorySearchMode { + case localWiki + case vectorEmbeddings + + static var current: MemorySearchMode { + if CodexAuthService.isActive { + return .localWiki + } + let raw = UserDefaults.standard.string(forKey: "memory_search_mode") ?? "local_wiki" + return raw == "vector" ? .vectorEmbeddings : .localWiki + } + + static var usesVectorEmbeddings: Bool { + current == .vectorEmbeddings + } +} diff --git a/desktop/Desktop/Sources/OmiApp.swift b/desktop/Desktop/Sources/OmiApp.swift index 0f6462b4eb6..b47e6c8ca84 100644 --- a/desktop/Desktop/Sources/OmiApp.swift +++ b/desktop/Desktop/Sources/OmiApp.swift @@ -107,6 +107,9 @@ struct OMIApp: App { .withFontScaling() .onAppear { log("OmiApp: Main window content appeared (mode: \(Self.launchMode.rawValue))") + if CodexAuthService.isActive { + Task { await CodexProxyService.shared.ensureRunning() } + } } } .windowStyle(.titleBar) diff --git a/desktop/Desktop/Sources/ProactiveAssistants/Assistants/MemoryExtraction/MemoryAssistant.swift b/desktop/Desktop/Sources/ProactiveAssistants/Assistants/MemoryExtraction/MemoryAssistant.swift index 82c2e838607..bd2f3402a75 100644 --- a/desktop/Desktop/Sources/ProactiveAssistants/Assistants/MemoryExtraction/MemoryAssistant.swift +++ b/desktop/Desktop/Sources/ProactiveAssistants/Assistants/MemoryExtraction/MemoryAssistant.swift @@ -245,6 +245,21 @@ actor MemoryAssistant: ProactiveAssistant { do { let inserted = try await MemoryStorage.shared.insertLocalMemory(record) log("Memory: Saved to SQLite (id: \(inserted.id ?? -1))") + if !MemorySearchMode.usesVectorEmbeddings { + let title = memory.content.prefix(80).trimmingCharacters(in: .whitespacesAndNewlines) + let slug = MemoryWikiStorage.slugify(String(title)) + Task { + _ = try? await MemoryWikiStorage.shared.upsertPage( + slug: slug, + title: String(title), + body: memory.content, + tags: ["memory", category], + category: category, + sourceType: "memory", + sourceId: inserted.id.map(String.init) + ) + } + } return inserted } catch { logError("Memory: Failed to save to SQLite", error: error) diff --git a/desktop/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/TaskAssistant.swift b/desktop/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/TaskAssistant.swift index 694186f80ee..1ee94866c92 100644 --- a/desktop/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/TaskAssistant.swift +++ b/desktop/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/TaskAssistant.swift @@ -177,8 +177,12 @@ actor TaskAssistant: ProactiveAssistant { // MARK: - Embedding Lifecycle - /// Load embedding index and kick off backfill + /// Load embedding index and kick off backfill (skipped when using local wiki search). private func initializeEmbeddings() async { + guard MemorySearchMode.usesVectorEmbeddings else { + log("Task: Skipping embedding index — local wiki / FTS memory search mode") + return + } await EmbeddingService.shared.loadIndex() // Backfill in background Task { @@ -367,10 +371,16 @@ actor TaskAssistant: ProactiveAssistant { windowTitle: windowTitle ) - // Generate embedding for new staged task in background + // Generate embedding or wiki index for new staged task in background if let recordId = extractionRecord?.id { - Task { - await self.generateEmbeddingForTask(id: recordId, text: task.title) + if MemorySearchMode.usesVectorEmbeddings { + Task { + await self.generateEmbeddingForTask(id: recordId, text: task.title) + } + } else { + Task { + await self.indexStagedTaskInWiki(id: recordId, description: task.title) + } } } @@ -409,6 +419,23 @@ actor TaskAssistant: ProactiveAssistant { } } + private func indexStagedTaskInWiki(id: Int64, description: String) async { + let slug = "task-\(id)" + do { + _ = try await MemoryWikiStorage.shared.upsertPage( + slug: slug, + title: description, + body: description, + tags: ["task", "staged"], + category: "task", + sourceType: "staged_task", + sourceId: String(id) + ) + } catch { + logError("Task: Failed to index staged task in memory wiki", error: error) + } + } + /// Save extracted task to staged_tasks SQLite table private func saveTaskToSQLite( task: ExtractedTask, @@ -1244,8 +1271,12 @@ actor TaskAssistant: ProactiveAssistant { ) } - /// Execute vector similarity search + /// Execute vector similarity search (or FTS + wiki when embeddings disabled). private func executeVectorSearch(query: String) async -> [TaskSearchResult] { + guard MemorySearchMode.usesVectorEmbeddings else { + return await executeKeywordAndWikiSearch(query: query) + } + var results: [TaskSearchResult] = [] do { @@ -1291,6 +1322,28 @@ actor TaskAssistant: ProactiveAssistant { return results.sorted { ($0.similarity ?? 0) > ($1.similarity ?? 0) } } + /// Keyword FTS across tasks plus memory wiki (used when vector embeddings are off). + private func executeKeywordAndWikiSearch(query: String) async -> [TaskSearchResult] { + var results = await executeKeywordSearch(query: query) + do { + let wikiHits = try await MemoryWikiStorage.shared.search(query: query, limit: 8) + for hit in wikiHits { + results.append( + TaskSearchResult( + id: hit.id, + description: "\(hit.title): \(hit.snippet)", + status: "active", + similarity: nil, + matchType: "wiki_fts", + relevanceScore: nil + )) + } + } catch { + logError("Task: Wiki search failed", error: error) + } + return results + } + /// Execute FTS5 keyword search (searches both action_items and staged_tasks) private func executeKeywordSearch(query: String) async -> [TaskSearchResult] { var results: [TaskSearchResult] = [] diff --git a/desktop/Desktop/Sources/ProactiveAssistants/Core/GeminiClient.swift b/desktop/Desktop/Sources/ProactiveAssistants/Core/GeminiClient.swift index f9d48a6c237..d0206091054 100644 --- a/desktop/Desktop/Sources/ProactiveAssistants/Core/GeminiClient.swift +++ b/desktop/Desktop/Sources/ProactiveAssistants/Core/GeminiClient.swift @@ -177,10 +177,15 @@ struct GeminiResponse: Decodable { // MARK: - GeminiClient -/// Low-level client for communicating with the Gemini API via backend proxy. -/// All requests route through the Rust backend (/v1/proxy/gemini/*) which adds -/// the Gemini API key server-side. Auth uses Firebase Bearer token. +/// Low-level client for proactive AI. Default path uses the Gemini backend proxy; +/// when ChatGPT/Codex is enrolled, requests use the local Codex loopback proxy instead. actor GeminiClient { + private enum Transport { + case geminiProxy + case codexOpenAICompatible + } + + private let transport: Transport private let model: String /// Backend proxy base URL (from OMI_DESKTOP_API_URL env var) @@ -248,14 +253,96 @@ actor GeminiClient { } init(apiKey: String? = nil, model: String = ModelQoS.Gemini.proactive) throws { - // BREAKING CHANGE (issue #5861): apiKey parameter is ignored. - // All Gemini requests now route through the backend proxy which supplies - // the key server-side. Defaults to production when OMI_DESKTOP_API_URL is absent - // so installed test bundles launched from Finder still have AI features. + // BREAKING CHANGE (issue #5861): apiKey parameter is ignored for cloud proxy mode. + self.model = model + if CodexAuthService.isActive { + self.transport = .codexOpenAICompatible + return + } guard !Self.proxyBaseURL.isEmpty else { throw GeminiClientError.missingAPIKey } - self.model = model + self.transport = .geminiProxy + } + + private func mapCodexError(_ error: CodexLLMClient.ClientError) -> GeminiClientError { + switch error { + case .notConfigured: + return .missingAPIKey + case .invalidSettings: + return .apiError(error.localizedDescription) + case .invalidResponse: + return .invalidResponse + case .httpFailure(let status, let body): + return .apiError("HTTP \(status): \(body)") + } + } + + private func codexConfig() throws -> CodexLLMClient.ProviderConfig { + guard let config = CodexLLMClient.providerConfig() else { + throw GeminiClientError.missingAPIKey + } + return config + } + + private func codexChatText( + systemPrompt: String, + userText: String, + jsonMode: Bool, + timeout: TimeInterval = 300 + ) async throws -> String { + await CodexProxyService.shared.ensureRunning() + let config = try codexConfig() + do { + return try await CodexLLMClient.chatCompletionText( + config: config, + systemPrompt: systemPrompt, + userText: userText, + jsonMode: jsonMode, + timeout: timeout + ) + } catch let error as CodexLLMClient.ClientError { + throw mapCodexError(error) + } + } + + private func codexChatImageOrOCR( + prompt: String, + imageData: Data, + systemPrompt: String, + jsonMode: Bool, + timeout: TimeInterval = 300 + ) async throws -> String { + await CodexProxyService.shared.ensureRunning() + let config = try codexConfig() + do { + return try await CodexLLMClient.chatCompletionMultimodalJPEG( + config: config, + systemPrompt: systemPrompt, + userText: prompt, + jpegData: imageData, + jsonMode: jsonMode, + timeout: timeout + ) + } catch let error as CodexLLMClient.ClientError { + if case .httpFailure = error { + let ocr = try await CodexLLMClient.ScreenOCR.recognizeTextFromJPEG(imageData) + let user = + prompt + + "\n\n--- ON-SCREEN TEXT (macOS OCR) ---\n\(ocr)\n--- END OCR ---\n" + let sysp = + systemPrompt + + "\n\nReturn a single JSON object only (no prose or markdown fences)." + return try await CodexLLMClient.chatCompletionText( + config: config, + systemPrompt: sysp, + userText: user, + jsonMode: jsonMode, + timeout: timeout + ) + } + throw mapCodexError(error) + } } /// Get Firebase auth header for proxy requests @@ -354,6 +441,15 @@ actor GeminiClient { for attempt in 0...maxRetries { do { + if transport == .codexOpenAICompatible { + return try await codexChatImageOrOCR( + prompt: prompt, + imageData: imageData, + systemPrompt: systemPrompt, + jsonMode: true + ) + } + // Wrap base64 encoding + JSON serialization in autoreleasepool. // These create bridged Obj-C objects (NSString, NSData) that accumulate // in Swift concurrency's cooperative thread pool without being drained. @@ -438,6 +534,15 @@ actor GeminiClient { for attempt in 0...maxRetries { do { + if transport == .codexOpenAICompatible { + return try await codexChatText( + systemPrompt: systemPrompt, + userText: prompt, + jsonMode: false, + timeout: timeout + ) + } + let request = GeminiRequest( contents: [ GeminiRequest.Content(parts: [ @@ -509,6 +614,14 @@ actor GeminiClient { for attempt in 0...maxRetries { do { + if transport == .codexOpenAICompatible { + return try await codexChatText( + systemPrompt: systemPrompt, + userText: prompt, + jsonMode: true + ) + } + let request = GeminiRequest( contents: [ GeminiRequest.Content(parts: [ @@ -800,6 +913,24 @@ extension GeminiClient { for attempt in 0...maxRetries { do { + if transport == .codexOpenAICompatible { + await CodexProxyService.shared.ensureRunning() + let config = try codexConfig() + do { + return try await CodexLLMClient.performGeminiCompatibleToolRound( + config: config, + systemPrompt: systemPrompt, + contents: contents, + tools: tools, + forceToolCall: forceToolCall, + allowVisionInlineJPEG: true, + timeout: 300 + ) + } catch let error as CodexLLMClient.ClientError { + throw mapCodexError(error) + } + } + // Wrap JSON serialization in autoreleasepool (contents may include // large base64 image data that creates bridged Obj-C intermediaries). let requestBody: Data = try autoreleasepool { diff --git a/desktop/Desktop/Sources/Rewind/Core/RewindDatabase.swift b/desktop/Desktop/Sources/Rewind/Core/RewindDatabase.swift index 7381d242b73..334b0eb8416 100644 --- a/desktop/Desktop/Sources/Rewind/Core/RewindDatabase.swift +++ b/desktop/Desktop/Sources/Rewind/Core/RewindDatabase.swift @@ -2156,6 +2156,58 @@ actor RewindDatabase { } } + migrator.registerMigration("createMemoryWikiPages") { db in + try db.create(table: "memory_pages") { t in + t.autoIncrementedPrimaryKey("id") + t.column("slug", .text).notNull().unique() + t.column("title", .text).notNull() + t.column("body", .text).notNull() + t.column("tagsJson", .text) + t.column("linksJson", .text) + t.column("category", .text).notNull().defaults(to: "system") + t.column("sourceType", .text) + t.column("sourceId", .text) + t.column("createdAt", .datetime).notNull() + t.column("updatedAt", .datetime).notNull() + } + try db.create(index: "idx_memory_pages_slug", on: "memory_pages", columns: ["slug"]) + try db.create(index: "idx_memory_pages_updated", on: "memory_pages", columns: ["updatedAt"]) + + try db.execute(sql: """ + CREATE VIRTUAL TABLE memory_pages_fts USING fts5( + title, + body, + tagsJson, + content='memory_pages', + content_rowid='id', + tokenize='unicode61' + ) + """) + + try db.execute(sql: """ + CREATE TRIGGER memory_pages_fts_ai AFTER INSERT ON memory_pages BEGIN + INSERT INTO memory_pages_fts(rowid, title, body, tagsJson) + VALUES (new.id, new.title, new.body, COALESCE(new.tagsJson, '')); + END + """) + + try db.execute(sql: """ + CREATE TRIGGER memory_pages_fts_ad AFTER DELETE ON memory_pages BEGIN + INSERT INTO memory_pages_fts(memory_pages_fts, rowid, title, body, tagsJson) + VALUES ('delete', old.id, old.title, old.body, COALESCE(old.tagsJson, '')); + END + """) + + try db.execute(sql: """ + CREATE TRIGGER memory_pages_fts_au AFTER UPDATE ON memory_pages BEGIN + INSERT INTO memory_pages_fts(memory_pages_fts, rowid, title, body, tagsJson) + VALUES ('delete', old.id, old.title, old.body, COALESCE(old.tagsJson, '')); + INSERT INTO memory_pages_fts(rowid, title, body, tagsJson) + VALUES (new.id, new.title, new.body, COALESCE(new.tagsJson, '')); + END + """) + } + try migrator.migrate(queue) } diff --git a/desktop/Desktop/Tests/CodexAuthServiceTests.swift b/desktop/Desktop/Tests/CodexAuthServiceTests.swift new file mode 100644 index 00000000000..1f1c9062318 --- /dev/null +++ b/desktop/Desktop/Tests/CodexAuthServiceTests.swift @@ -0,0 +1,119 @@ +import XCTest + +@testable import Omi_Computer + +final class CodexAuthServiceTests: XCTestCase { + + override func setUp() { + super.setUp() + UserDefaults.standard.removeObject(forKey: "codex_auth_enrolled") + UserDefaults.standard.removeObject(forKey: "codex_preferred_model") + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: "codex_auth_enrolled") + UserDefaults.standard.removeObject(forKey: "codex_preferred_model") + super.tearDown() + } + + func testEnrollmentFingerprintIsStableHex() { + let fp = CodexAuthService.enrollmentFingerprint(for: "account-123") + XCTAssertEqual(fp.count, 64) + XCTAssertTrue(fp.allSatisfy { $0.isHexDigit }) + XCTAssertEqual(fp, CodexAuthService.enrollmentFingerprint(for: "account-123")) + } + + func testIsActiveRequiresEnrollmentAndSnapshot() { + let tempAuth = makeTempCodexHomeWithoutAuth() + defer { tempAuth.cleanup() } + + XCTAssertFalse(CodexAuthService.isActive) + CodexAuthService.markEnrolled() + XCTAssertFalse(CodexAuthService.isActive) + } + + func testLoadSnapshotParsesNestedTokensFormat() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-auth-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let authURL = dir.appendingPathComponent("auth.json") + let payload = """ + { + "auth_mode": "chatgpt", + "tokens": { + "access_token": "test-access", + "refresh_token": "test-refresh", + "account_id": "acct-nested" + } + } + """ + try payload.write(to: authURL, atomically: true, encoding: .utf8) + + let previous = ProcessInfo.processInfo.environment["CODEX_HOME"] + setenv("CODEX_HOME", dir.path, 1) + defer { + if let previous { + setenv("CODEX_HOME", previous, 1) + } else { + unsetenv("CODEX_HOME") + } + } + + let snap = CodexAuthService.loadSnapshot() + XCTAssertEqual(snap?.accessToken, "test-access") + XCTAssertEqual(snap?.accountId, "acct-nested") + XCTAssertEqual(snap?.refreshToken, "test-refresh") + } + + func testMemorySearchModeDefaultsToWikiWhenCodexEnrolled() { + CodexAuthService.markEnrolled() + XCTAssertEqual(MemorySearchMode.current, .localWiki) + } +} + +final class CodexLLMClientTests: XCTestCase { + + override func setUp() { + super.setUp() + UserDefaults.standard.removeObject(forKey: "codex_auth_enrolled") + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: "codex_auth_enrolled") + super.tearDown() + } + + func testProviderConfigRequiresEnrollmentAndAuthFile() throws { + let tempAuth = makeTempCodexHomeWithoutAuth() + defer { tempAuth.cleanup() } + + XCTAssertNil(CodexLLMClient.providerConfig()) + CodexAuthService.markEnrolled() + XCTAssertNil(CodexLLMClient.providerConfig()) + } +} + +private struct TempCodexHome { + let path: String + let previous: String? + + func cleanup() { + if let previous { + setenv("CODEX_HOME", previous, 1) + } else { + unsetenv("CODEX_HOME") + } + try? FileManager.default.removeItem(atPath: path) + } +} + +private func makeTempCodexHomeWithoutAuth() -> TempCodexHome { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-auth-empty-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let previous = ProcessInfo.processInfo.environment["CODEX_HOME"] + setenv("CODEX_HOME", dir.path, 1) + return TempCodexHome(path: dir.path, previous: previous) +} diff --git a/desktop/codex-proxy/Cargo.lock b/desktop/codex-proxy/Cargo.lock new file mode 100644 index 00000000000..f1acce4a8e4 --- /dev/null +++ b/desktop/codex-proxy/Cargo.lock @@ -0,0 +1,1443 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "omi-codex-proxy" +version = "0.1.0" +dependencies = [ + "axum", + "reqwest", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/desktop/codex-proxy/Cargo.toml b/desktop/codex-proxy/Cargo.toml new file mode 100644 index 00000000000..a435f949738 --- /dev/null +++ b/desktop/codex-proxy/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "omi-codex-proxy" +version = "0.1.0" +edition = "2021" +description = "Minimal loopback proxy: OpenAI chat/completions ↔ ChatGPT Codex /responses" +license = "MIT" + +[dependencies] +axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] } diff --git a/desktop/codex-proxy/README.md b/desktop/codex-proxy/README.md new file mode 100644 index 00000000000..a21b24d7ad1 --- /dev/null +++ b/desktop/codex-proxy/README.md @@ -0,0 +1,61 @@ +# omi-codex-proxy + +Small **localhost-only** HTTP proxy that turns `POST /v1/chat/completions` (OpenAI-style JSON) into `POST https://chatgpt.com/backend-api/codex/responses`, using OAuth tokens stored in **`~/.codex/auth.json`**. + +## Prerequisites + +Rust toolchain (`cargo`). HTTPS uses **rustls** (no OpenSSL dependency). + +Create `~/.codex/auth.json` (minimal fields): + +```json +{ + "access_token": "", + "account_id": "", + "refresh_token": "" +} +``` + +## Run + +```bash +cd desktop/codex-proxy +# Optional: defaults to 10531 when unset +export OMI_CODEX_PROXY_PORT=10531 +cargo run --release +``` + +## Endpoints + +- `GET /health` → `200 OK` (`ok`). +- `POST /v1/chat/completions` → upstream Codex `responses`; **non-stream only** (`"stream": true` returns `501`). + +Forwarded headers: + +- `Authorization: Bearer ` +- `ChatGPT-Account-Id: ` +- `Content-Type: application/json` + +On **`401`** from Codex (and if `refresh_token` is present), the proxy refreshes via `POST https://auth.openai.com/oauth/token` (`client_id=app_EMoamEEZ73f0CkXaXp7hrann`, `grant_type=refresh_token`), persists updated tokens back to `auth.json`, and retries once. + +## Request / response mapping (basic) + +**OpenAI → Codex** + +- Copies `model` and maps OpenAI chat `messages` into a Codex Responses-style `input`: + - String `content` becomes `[{ "type": "input_text", "text": "..." }]`. + - Array `content` is passed through. + +**Codex → OpenAI** + +Parses common Responses payloads: **`output[].content[]`**, looking for **`output_text` / `text`** (or string `content`). If the upstream body already resembles `choices`, it is echoed. + +If your Codex revision uses a slightly different envelope, extend `extract_assistant_text` / `codex_payload_from_openai_chat` in `src/main.rs`. + +## Example curl + +```bash +curl -sS http://127.0.0.1:${OMI_CODEX_PROXY_PORT:-10531}/v1/chat/completions \ + -H 'Content-Type: application/json' \ + -d '{"model":"gpt-5","messages":[{"role":"user","content":"Say hi in one word."}]}' +``` diff --git a/desktop/codex-proxy/e2e_smoke.sh b/desktop/codex-proxy/e2e_smoke.sh new file mode 100755 index 00000000000..d1b53a1be8a --- /dev/null +++ b/desktop/codex-proxy/e2e_smoke.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# End-to-end smoke tests for omi-codex-proxy (requires valid ~/.codex/auth.json). +set -euo pipefail + +PORT="${OMI_CODEX_PROXY_PORT:-10531}" +BASE="http://127.0.0.1:${PORT}" +PROXY_BIN="$(cd "$(dirname "$0")" && pwd)/target/release/omi-codex-proxy" + +if ! curl -fsS "${BASE}/health" >/dev/null 2>&1; then + if [[ ! -x "$PROXY_BIN" ]]; then + echo "Building proxy..." + (cd "$(dirname "$0")" && cargo build --release) + fi + echo "Starting proxy on ${PORT}..." + "$PROXY_BIN" & + PROXY_PID=$! + trap 'kill "$PROXY_PID" 2>/dev/null || true' EXIT + for _ in $(seq 1 30); do + curl -fsS "${BASE}/health" >/dev/null 2>&1 && break + sleep 0.2 + done +fi + +python3 <<'PY' +import json, urllib.request, textwrap, sys + +BASE = f"http://127.0.0.1:{__import__('os').environ.get('OMI_CODEX_PROXY_PORT', '10531')}/v1/chat/completions" + +def post(messages, label): + payload = {"model": "gpt-5.4", "messages": messages, "temperature": 0.2, "stream": False} + req = urllib.request.Request(BASE, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=120) as resp: + data = json.load(resp) + text = data["choices"][0]["message"]["content"].strip() + assert text, f"empty response for {label}" + print(f"PASS {label}: {text[:80]!r}") + return text + except Exception as e: + body = e.read().decode()[:400] if hasattr(e, "read") else str(e) + print(f"FAIL {label}: {body}", file=sys.stderr) + raise + +post([{"role": "system", "content": "You are Omi."}, {"role": "user", "content": "Reply with exactly: alpha"}], "single-turn") +post([ + {"role": "system", "content": "You are Omi."}, + {"role": "user", "content": "hey"}, + {"role": "assistant", "content": "Hey!"}, + {"role": "user", "content": "What is 2+2? Reply with just the number."}, +], "multi-turn") +post([ + {"role": "system", "content": "You are Omi.\n" + ("Context line.\n" * 20)}, + {"role": "user", "content": "Reply with exactly: ready"}, +], "large-system") +post([{"role": "user", "content": "Reply with exactly: default"}], "default-instructions") +post([ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": [{"type": "text", "text": "Reply with exactly: array-ok"}]}, +], "hybrid-llm-array-content") +print("ALL PROXY E2E CHECKS PASSED") +PY + +echo "e2e_smoke: OK" diff --git a/desktop/codex-proxy/src/main.rs b/desktop/codex-proxy/src/main.rs new file mode 100644 index 00000000000..5cfc047d2be --- /dev/null +++ b/desktop/codex-proxy/src/main.rs @@ -0,0 +1,799 @@ +//! Local OpenAI-compat proxy for ChatGPT Codex `/backend-api/codex/responses`. + +use std::{ + fs, io, + net::SocketAddr, + path::{Path, PathBuf}, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; + +use axum::{ + body::Body, + extract::{Json, State}, + http::{header, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, + Router, +}; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue, AUTHORIZATION, CONTENT_TYPE}; +use serde::Deserialize; +use serde_json::{json, Value}; +use tokio::net::TcpListener; +use tokio::sync::Mutex; + +const DEFAULT_PORT: u16 = 10531; +const CODEX_RESPONSES_URL: &str = "https://chatgpt.com/backend-api/codex/responses"; +const OPENAI_AUTH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; +const OAUTH_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; +const DEFAULT_INSTRUCTIONS: &str = "You are a helpful assistant."; + +#[derive(Clone, Deserialize)] +struct AuthCore { + access_token: String, + account_id: String, + #[serde(default)] + refresh_token: Option, +} + +impl AuthCore { + fn from_doc(doc: &Value) -> Result { + if doc.get("access_token").is_some() { + return serde_json::from_value(doc.clone()).map_err(|e| e.to_string()); + } + if let Some(tokens) = doc.get("tokens") { + return serde_json::from_value(tokens.clone()).map_err(|e| e.to_string()); + } + Err("missing access_token (expected top-level or tokens.access_token)".into()) + } +} + +struct AuthDisk { + path: PathBuf, + doc: Value, +} + +struct AppState { + http: reqwest::Client, + auth: Mutex, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let auth_path = default_auth_path()?; + let doc = load_auth_doc(&auth_path)?; + AuthCore::from_doc(&doc).map_err(|e| format!("invalid {}: {}", auth_path.display(), e))?; + + let port = std::env::var("OMI_CODEX_PROXY_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_PORT); + + let state = Arc::new(AppState { + http: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .user_agent(format!("omi-codex-proxy/{}", env!("CARGO_PKG_VERSION"))) + .build()?, + auth: Mutex::new(AuthDisk { + path: auth_path.clone(), + doc, + }), + }); + + let app = Router::new() + .route("/health", get(health_ok)) + .route("/v1/chat/completions", post(chat_completions)) + .with_state(state.clone()); + + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + let listener = TcpListener::bind(addr).await?; + println!( + "omi-codex-proxy listening on http://{}", + listener.local_addr()? + ); + println!("auth file {}", auth_path.display()); + + axum::serve(listener, app).await?; + Ok(()) +} + +async fn health_ok() -> &'static str { + "ok" +} + +async fn chat_completions(State(state): State>, Json(body): Json) -> Response { + if body.get("stream").and_then(|v| v.as_bool()) == Some(true) { + return json_error( + StatusCode::NOT_IMPLEMENTED, + "stream=true is not implemented; send non-stream chat/completions.", + ) + .into_response(); + } + + let upstream_payload = match codex_payload_from_openai_chat(&body) { + Ok(v) => v, + Err(msg) => return json_error(StatusCode::BAD_REQUEST, &msg).into_response(), + }; + + let requested_model_hint = body + .get("model") + .and_then(|m| m.as_str()) + .unwrap_or("") + .to_owned(); + + match invoke_codex(&state, &upstream_payload, requested_model_hint).await { + Ok(resp) => resp, + Err(msg) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &msg).into_response(), + } +} + +async fn invoke_codex( + state: &AppState, + upstream_payload: &Value, + requested_model_hint: String, +) -> Result { + let bytes = encode_codex_request(upstream_payload)?; + + let mut refreshed = false; + loop { + let hdrs = { + let g = state.auth.lock().await; + let core = AuthCore::from_doc(&g.doc)?; + codex_headers(&core)? + }; + + let upstream = state + .http + .post(CODEX_RESPONSES_URL) + .headers(hdrs.clone()) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .header(header::ACCEPT, HeaderValue::from_static("text/event-stream")) + .body(bytes.clone()) + .send() + .await + .map_err(|e| e.to_string())?; + + let status = upstream.status(); + let upstream_bytes = upstream.bytes().await.map_err(|e| e.to_string())?; + + if status.as_u16() == 401 { + let has_refresh_token = { + let g = state.auth.lock().await; + AuthCore::from_doc(&g.doc)? + .refresh_token + .as_ref() + .map(|t| !t.is_empty()) + .unwrap_or(false) + }; + + let should_retry = !refreshed && has_refresh_token; + + if should_retry { + let refresh_token_owned = { + let g = state.auth.lock().await; + AuthCore::from_doc(&g.doc)? + .refresh_token + .filter(|t| !t.is_empty()) + .ok_or_else(|| { + "refresh_token vanished between checks — cannot refresh access token" + .to_string() + })? + }; + + let refresh_envelope = + oauth_refresh_access_token(&state.http, refresh_token_owned).await?; + + { + let mut g = state.auth.lock().await; + apply_refresh_to_doc(&mut *g, refresh_envelope)?; + } + + refreshed = true; + continue; + } + } + + if !status.is_success() { + return Ok((status, Body::from(upstream_bytes)).into_response()); + } + + let sse_body = String::from_utf8(upstream_bytes.to_vec()) + .map_err(|e| format!("upstream SSE is not valid UTF-8: {e}"))?; + let assistant_text = collect_text_from_codex_sse(&sse_body)?; + if assistant_text.trim().is_empty() { + return Err("upstream SSE contained no assistant text".into()); + } + + let openai_completion = json!({ + "id": new_chat_completion_id(), + "object": "chat.completion", + "created": unix_secs(), + "model": if requested_model_hint.trim().is_empty() { Value::Null } else { Value::String(requested_model_hint.clone()) }, + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": assistant_text }, + "logprobs": null, + "finish_reason": "stop", + }], + "usage": Value::Null, + }); + return Ok(JsonResponse { + status: StatusCode::OK, + json: openai_completion, + } + .into_response()); + } +} + +fn encode_codex_request(payload: &Value) -> Result, String> { + serde_json::to_vec(payload).map_err(|e| e.to_string()) +} + +#[derive(Clone)] +struct JsonResponse { + status: StatusCode, + json: Value, +} + +impl IntoResponse for JsonResponse { + fn into_response(self) -> Response { + let body = serde_json::to_vec(&self.json).unwrap_or_else(|_| { + br#"{"error":{"message":"failed to serialize upstream json envelope","type":"omi_codex_proxy_error"}}"#.to_vec() + }); + + Response::builder() + .status(self.status) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .unwrap() + } +} + +fn json_error(status: StatusCode, message: impl AsRef) -> JsonResponse { + JsonResponse { + status, + json: json!({ + "error": { + "message": message.as_ref(), + "type": "omi_codex_proxy_error", + }, + }), + } +} + +#[derive(Debug, Deserialize)] +struct RefreshEnvelope { + access_token: Option, + refresh_token: Option, +} + +async fn oauth_refresh_access_token( + http: &reqwest::Client, + refresh_token: String, +) -> Result { + let response = http + .post(OPENAI_AUTH_TOKEN_URL) + .header( + CONTENT_TYPE, + HeaderValue::from_static("application/x-www-form-urlencoded"), + ) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token.as_str()), + ("client_id", OAUTH_CLIENT_ID), + ]) + .send() + .await + .map_err(|e| format!("oauth refresh transport error: {e}"))?; + + let status = response.status(); + let body_text = response + .text() + .await + .map_err(|e| format!("oauth refresh read error: {e}"))?; + + if !status.is_success() { + return Err(format!( + "oauth refresh failed ({status}): {body_text}", + status = status, + body_text = body_text + )); + } + + let env: RefreshEnvelope = serde_json::from_str(&body_text) + .map_err(|e| format!("oauth refresh json decode error ({e}): {body_text}"))?; + + Ok(env) +} + +fn apply_refresh_to_doc(disk: &mut AuthDisk, mut env: RefreshEnvelope) -> Result<(), String> { + let new_access = env + .access_token + .take() + .filter(|t| !t.is_empty()) + .ok_or_else(|| "oauth refresh succeeded but omitted access_token".to_string())?; + + if disk.doc.get("tokens").map(|t| t.is_object()).unwrap_or(false) { + if let Some(tokens) = disk.doc.get_mut("tokens").and_then(Value::as_object_mut) { + tokens.insert("access_token".to_string(), Value::String(new_access)); + if let Some(new_refresh) = env.refresh_token.take().filter(|t| !t.is_empty()) { + tokens.insert("refresh_token".to_string(), Value::String(new_refresh)); + } + } + } else { + disk.doc["access_token"] = Value::String(new_access); + if let Some(new_refresh) = env.refresh_token.take().filter(|t| !t.is_empty()) { + disk.doc["refresh_token"] = Value::String(new_refresh); + } + } + + persist_auth(&disk.path, &disk.doc)?; + println!( + "oauth: refreshed access_token (persisted {})", + disk.path.display() + ); + Ok(()) +} + +fn codex_headers(core: &AuthCore) -> Result { + let mut map = HeaderMap::new(); + let bearer = HeaderValue::from_str(format!("Bearer {}", core.access_token).as_str()) + .map_err(|e| e.to_string())?; + map.insert(AUTHORIZATION, bearer); + map.insert( + HeaderName::from_static("chatgpt-account-id"), + HeaderValue::from_str(&core.account_id).map_err(|e| e.to_string())?, + ); + map.insert( + HeaderName::from_static("originator"), + HeaderValue::from_static("pi"), + ); + Ok(map) +} + +fn default_auth_path() -> Result { + if let Ok(codex_home) = std::env::var("CODEX_HOME") { + let trimmed = codex_home.trim(); + if !trimmed.is_empty() { + return Ok(PathBuf::from(trimmed).join("auth.json")); + } + } + let home = std::env::var_os("HOME") + .filter(|v| !v.is_empty()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "HOME is not set; cannot resolve ~/.codex/auth.json", + ) + })?; + Ok(PathBuf::from(home).join(".codex").join("auth.json")) +} + +fn load_auth_doc(path: &Path) -> Result { + let raw = fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?; + serde_json::from_str(&raw).map_err(|e| format!("parse {}: {e}", path.display())) +} + +fn persist_auth(path: &Path, doc: &Value) -> Result<(), String> { + let serialized = + serde_json::to_vec_pretty(doc).map_err(|e| format!("serialize auth doc: {e}"))?; + fs::write(path, serialized).map_err(|e| format!("write {}: {e}", path.display())) +} + +fn codex_payload_from_openai_chat(openai_body: &Value) -> Result { + let model = openai_body + .get("model") + .and_then(|m| m.as_str()) + .filter(|s| !s.trim().is_empty()) + .unwrap_or("gpt-5.4"); + + let messages = openai_body + .get("messages") + .and_then(Value::as_array) + .ok_or_else(|| "missing array `messages`".to_string())?; + + if messages.is_empty() { + return Err("`messages` must be non-empty".into()); + } + + let mut instructions_parts = Vec::new(); + let mut input_items = Vec::new(); + + for (idx, msg) in messages.iter().enumerate() { + let role = msg + .get("role") + .and_then(|r| r.as_str()) + .ok_or_else(|| format!("messages[{idx}].role missing"))?; + + if role == "system" { + if let Some(text) = message_content_as_string(msg.get("content").unwrap_or(&Value::Null))? + { + if !text.is_empty() { + instructions_parts.push(text); + } + } + continue; + } + + let content_parts = + normalize_message_content(msg.get("content").unwrap_or(&Value::Null), role)?; + let parts_array = content_parts.as_array().cloned().unwrap_or_default(); + if parts_array.is_empty() { + continue; + } + input_items.push(json!({ + "type": "message", + "role": role, + "content": parts_array, + })); + } + + if input_items.is_empty() { + return Err("`messages` must include at least one non-system message".into()); + } + + let instructions = if instructions_parts.is_empty() { + DEFAULT_INSTRUCTIONS.to_string() + } else { + instructions_parts.join("\n") + }; + + Ok(json!({ + "model": model, + "store": false, + "stream": true, + "instructions": instructions, + "input": input_items, + "text": { "verbosity": "medium" }, + "include": ["reasoning.encrypted_content"], + "tool_choice": "auto", + "parallel_tool_calls": true, + })) +} + +fn message_content_as_string(raw: &Value) -> Result, String> { + Ok(match raw { + Value::String(s) => Some(s.clone()), + Value::Array(items) => { + let mut out = String::new(); + for it in items { + if let Some(t) = it.get("text").and_then(Value::as_str) { + out.push_str(t); + } + } + if out.is_empty() { + None + } else { + Some(out) + } + } + Value::Null => None, + other => Err(format!( + "unsupported message content type `{}` — expected string or array", + serde_json::to_string(other).unwrap_or_else(|_| "unknown".into()) + ))?, + }) +} + +fn collect_text_from_codex_sse(body: &str) -> Result { + let mut text = String::new(); + for line in body.lines() { + let data = line + .strip_prefix("data:") + .map(str::trim) + .filter(|s| !s.is_empty() && *s != "[DONE]"); + let Some(data) = data else { + continue; + }; + + let event: Value = + serde_json::from_str(data).map_err(|e| format!("invalid SSE data json: {e}"))?; + match event.get("type").and_then(Value::as_str) { + Some("response.output_text.delta") => { + if let Some(delta) = event.get("delta").and_then(Value::as_str) { + text.push_str(delta); + } + } + Some("response.output_text.done") => { + if text.is_empty() { + if let Some(done) = event.get("text").and_then(Value::as_str) { + text.push_str(done); + } + } + } + Some("error") => { + let message = event + .pointer("/error/message") + .and_then(Value::as_str) + .or_else(|| event.get("message").and_then(Value::as_str)) + .unwrap_or("Codex backend returned an error event"); + return Err(message.to_string()); + } + _ => {} + } + } + + Ok(text) +} + +fn normalize_message_content(raw: &Value, role: &str) -> Result { + let text_type = if role == "assistant" { + "output_text" + } else { + "input_text" + }; + Ok(match raw { + Value::String(s) => json!([ + {"type": text_type, "text": s}, + ]), + Value::Array(parts) => { + if parts.is_empty() { + Value::Array(vec![]) + } else { + Value::Array( + parts + .iter() + .map(|part| normalize_content_part(part, text_type)) + .collect(), + ) + } + } + Value::Null => Value::Array(vec![json!({ "type": text_type, "text": "" })]), + other => Err(format!( + "unsupported message content type `{}` — expected string or array", + serde_json::to_string(other).unwrap_or_else(|_| "unknown".into()) + ))?, + }) +} + +fn normalize_content_part(part: &Value, default_type: &str) -> Value { + match part { + Value::Object(map) => { + let mut out = map.clone(); + if !out.contains_key("type") { + out.insert("type".to_string(), Value::String(default_type.to_string())); + } else if let Some(Value::String(kind)) = out.get("type") { + if kind == "text" { + out.insert("type".to_string(), Value::String(default_type.to_string())); + } + } + Value::Object(out) + } + Value::String(s) => json!({ "type": default_type, "text": s }), + other => other.clone(), + } +} + +fn codex_body_to_chat_completion(model_fallback: &str, bytes: &[u8]) -> Result { + let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("upstream json: {e}"))?; + + if v.get("choices").is_some() { + let mut enriched = v; + if enriched.get("id").and_then(Value::as_str).is_none() + || enriched.get("id") == Some(&Value::Null) + { + enriched["id"] = Value::String(new_chat_completion_id()); + } + if enriched.get("object").and_then(Value::as_str).is_none() + || enriched.get("object") == Some(&Value::Null) + { + enriched["object"] = Value::from("chat.completion"); + } + if enriched.get("created").and_then(Value::as_i64).is_none() + || enriched.get("created") == Some(&Value::Null) + { + enriched["created"] = Value::Number(unix_secs().into()); + } + Ok(enriched) + } else { + let text = extract_assistant_text(&v) + .ok_or_else(|| serde_json::to_string(&v).unwrap_or_else(|_| "(unprintable)".into()))?; + let model = chat_model_choice(&v, model_fallback)?; + Ok(json!({ + "id": new_chat_completion_id(), + "object": "chat.completion", + "created": unix_secs(), + "model": model, + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": text}, + "logprobs": null, + "finish_reason": infer_finish_reason(&v), + }], + "usage": v.get("usage").cloned().unwrap_or(Value::Null), + })) + } +} + +fn infer_finish_reason(v: &Value) -> Value { + v.pointer("/choices/0/finish_reason") + .cloned() + .unwrap_or_else(|| Value::from("stop")) +} + +fn chat_model_choice(v: &Value, fallback: &str) -> Result { + if let Some(m) = v + .get("model") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + return Ok(Value::String(m.to_owned())); + } + if !fallback.trim().is_empty() { + return Ok(Value::String(fallback.to_owned())); + } + Err("upstream response missing model and original request lacked model hint".into()) +} + +fn new_chat_completion_id() -> String { + format!("chatcmpl-{}", now_millis()) +} + +fn unix_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn now_millis() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) +} + +/// Best-effort assistant text extractor for Responses-style payloads (`output`, etc.). +fn extract_assistant_text(v: &Value) -> Option { + if let Some(Value::Array(choices)) = v.get("choices") { + if let Some(first) = choices.first() { + let text = openai_choice_text(first); + if text.is_some() { + return text; + } + } + } + + if let Some(output) = v.get("output") { + if let Some(text) = flatten_output_chunks(output) { + return Some(text); + } + } + + let mut chunks = Vec::new(); + visit_collect_output_text(v, &mut chunks); + if chunks.is_empty() { + None + } else { + Some(chunks.join("")) + } +} + +fn openai_choice_text(choice: &Value) -> Option { + let msg = choice.get("message")?; + extract_message_content_as_string(msg) +} + +fn extract_message_content_as_string(msg: &Value) -> Option { + match msg.get("content") { + Some(Value::String(s)) => Some(s.clone()), + Some(Value::Array(items)) => { + let mut out = String::new(); + for it in items { + if let Some(t) = it.get("text").and_then(Value::as_str) { + out.push_str(t); + } else if let Some(inner) = it.get("content").and_then(Value::as_str) { + out.push_str(inner); + } + } + if !out.is_empty() { + Some(out) + } else { + None + } + } + Some(Value::Null) | None => None, + _ => None, + } +} + +fn flatten_output_chunks(outputs: &Value) -> Option { + let mut combined = Vec::new(); + if let Value::Array(items) = outputs { + for item in items { + visit_collect_output_text(item, &mut combined); + } + } else { + visit_collect_output_text(outputs, &mut combined); + } + + (!combined.is_empty()).then(|| combined.join("")) +} + +fn push_output_text_piece(map: &serde_json::Map, bucket: &mut Vec) { + if map.get("type").and_then(Value::as_str) != Some("output_text") { + return; + } + let Some(raw) = map.get("text").and_then(Value::as_str) else { + return; + }; + let trimmed = raw.trim(); + if !trimmed.is_empty() { + bucket.push(trimmed.to_owned()); + } +} + +fn visit_collect_output_text(v: &Value, bucket: &mut Vec) { + match v { + Value::Object(map) => { + push_output_text_piece(map, bucket); + for child in map.values() { + visit_collect_output_text(child, bucket); + } + } + Value::Array(items) => { + for item in items { + visit_collect_output_text(item, bucket); + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_openai_messages_to_codex_input() { + let openai = json!({ + "model": "gpt-test", + "messages": [ + {"role":"system","content":"You are helpful."}, + {"role":"user","content":[{"type":"input_text","text":"hi"}]}, + {"role":"assistant","content":"Hello!"}, + {"role":"user","content":"again"} + ] + }); + let out = codex_payload_from_openai_chat(&openai).expect("mapping"); + assert_eq!(out["instructions"], json!("You are helpful.")); + assert_eq!(out["stream"], json!(true)); + assert_eq!(out["input"].as_array().unwrap().len(), 3); + assert_eq!(out["input"][0]["type"], json!("message")); + assert_eq!(out["input"][0]["role"], json!("user")); + assert_eq!( + out["input"][0]["content"], + json!([{"type":"input_text","text":"hi"}]) + ); + assert_eq!( + out["input"][1]["content"], + json!([{"type":"output_text","text":"Hello!"}]) + ); + assert_eq!( + out["input"][2]["content"], + json!([{"type":"input_text","text":"again"}]) + ); + } + + #[test] + fn maps_responses_like_output_message() { + let upstream = json!({ + "model": "gpt-output", + "output": [{ + "type": "message", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "Hello"} + ] + }] + }); + + let out = + codex_body_to_chat_completion("", &serde_json::to_vec(&upstream).unwrap()).unwrap(); + assert_eq!( + out["choices"][0]["message"]["content"], + Value::from("Hello") + ); + assert_eq!(out["model"], Value::from("gpt-output")); + } +} diff --git a/desktop/run.sh b/desktop/run.sh index f4f3d58d3a7..81a6f7d8969 100755 --- a/desktop/run.sh +++ b/desktop/run.sh @@ -473,6 +473,17 @@ else echo "Warning: pi-mono-extension not found at $PI_MONO_EXT_DIR" fi +substep "Building Codex proxy (omi-codex-proxy)" +CODEX_PROXY_DIR="$(dirname "$0")/codex-proxy" +if [ -d "$CODEX_PROXY_DIR" ]; then + (cd "$CODEX_PROXY_DIR" && cargo build --release --quiet) + mkdir -p "$APP_BUNDLE/Contents/Resources" + cp -f "$CODEX_PROXY_DIR/target/release/omi-codex-proxy" "$APP_BUNDLE/Contents/Resources/omi-codex-proxy" + chmod +x "$APP_BUNDLE/Contents/Resources/omi-codex-proxy" +else + echo "Warning: codex-proxy not found at $CODEX_PROXY_DIR" +fi + substep "Copying .env.app" if [ -f ".env.app.dev" ]; then cp -f .env.app.dev "$APP_BUNDLE/Contents/Resources/.env" From 63fde7bbe828f23150c5f087e939aade8be70f6a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 20 May 2026 05:03:00 +0000 Subject: [PATCH 2/2] Fix ChatGPT tier security and desktop Codex/wiki bugs Require X-ChatGPT-Fingerprint on requests for quota and subscription bypass, refresh enrollment heartbeat from desktop launch and throttled server updates, resolve Codex transport at call time in GeminiClient, label wiki search hits distinctly from tasks, and include memory id in wiki slugs to avoid collisions. --- backend/database/users.py | 9 +++ backend/main.py | 2 + backend/routers/users.py | 5 +- backend/utils/chatgpt.py | 74 +++++++++++++++++++ backend/utils/subscription.py | 5 +- desktop/Desktop/Sources/APIClient.swift | 4 + desktop/Desktop/Sources/OmiApp.swift | 7 +- .../MemoryExtraction/MemoryAssistant.swift | 8 +- .../TaskExtraction/TaskAssistant.swift | 6 +- .../Core/GeminiClient.swift | 12 ++- 10 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 backend/utils/chatgpt.py diff --git a/backend/database/users.py b/backend/database/users.py index b3fbf6fe539..b42f8d8e3bb 100644 --- a/backend/database/users.py +++ b/backend/database/users.py @@ -250,6 +250,15 @@ def set_chatgpt_active(uid: str, fingerprint: str): ) +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( diff --git a/backend/main.py b/backend/main.py index 3ecd2546125..4be4001785b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -146,8 +146,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("startup") diff --git a/backend/routers/users.py b/backend/routers/users.py index 761dce841d3..465b60640bd 100644 --- a/backend/routers/users.py +++ b/backend/routers/users.py @@ -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__) @@ -885,7 +886,7 @@ 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 users_db.is_chatgpt_active(uid): + if chatgpt_request_grants_bypass(uid): return UserSubscriptionResponse( subscription=_chatgpt_unlimited_subscription(), transcription_seconds_used=0, @@ -1110,7 +1111,7 @@ 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 users_db.is_chatgpt_active(uid): + if chatgpt_request_grants_bypass(uid): return ChatUsageQuota( plan='Free (ChatGPT)', plan_type=PlanType.unlimited.value, diff --git a/backend/utils/chatgpt.py b/backend/utils/chatgpt.py new file mode 100644 index 00000000000..ea6a9f3f1ef --- /dev/null +++ b/backend/utils/chatgpt.py @@ -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) diff --git a/backend/utils/subscription.py b/backend/utils/subscription.py index 11e776ab0c9..e47e8956f96 100644 --- a/backend/utils/subscription.py +++ b/backend/utils/subscription.py @@ -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 @@ -524,8 +525,8 @@ 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: user pays OpenAI via subscription; LLM quota bypass only. - if users_db.is_chatgpt_active(uid): + # 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) diff --git a/desktop/Desktop/Sources/APIClient.swift b/desktop/Desktop/Sources/APIClient.swift index 7ea75590c1e..21834544b2e 100644 --- a/desktop/Desktop/Sources/APIClient.swift +++ b/desktop/Desktop/Sources/APIClient.swift @@ -98,6 +98,10 @@ actor APIClient { headers[provider.headerName] = entry.key } + if let chatgptFingerprint = CodexAuthService.enrollmentFingerprintIfActive() { + headers["X-ChatGPT-Fingerprint"] = chatgptFingerprint + } + return headers } diff --git a/desktop/Desktop/Sources/OmiApp.swift b/desktop/Desktop/Sources/OmiApp.swift index b47e6c8ca84..d49d1a36f1f 100644 --- a/desktop/Desktop/Sources/OmiApp.swift +++ b/desktop/Desktop/Sources/OmiApp.swift @@ -108,7 +108,12 @@ struct OMIApp: App { .onAppear { log("OmiApp: Main window content appeared (mode: \(Self.launchMode.rawValue))") if CodexAuthService.isActive { - Task { await CodexProxyService.shared.ensureRunning() } + Task { + await CodexProxyService.shared.ensureRunning() + if let fingerprint = CodexAuthService.enrollmentFingerprintIfActive() { + try? await APIClient.shared.activateChatGPT(fingerprint: fingerprint) + } + } } } } diff --git a/desktop/Desktop/Sources/ProactiveAssistants/Assistants/MemoryExtraction/MemoryAssistant.swift b/desktop/Desktop/Sources/ProactiveAssistants/Assistants/MemoryExtraction/MemoryAssistant.swift index bd2f3402a75..7aac804e28a 100644 --- a/desktop/Desktop/Sources/ProactiveAssistants/Assistants/MemoryExtraction/MemoryAssistant.swift +++ b/desktop/Desktop/Sources/ProactiveAssistants/Assistants/MemoryExtraction/MemoryAssistant.swift @@ -247,7 +247,13 @@ actor MemoryAssistant: ProactiveAssistant { log("Memory: Saved to SQLite (id: \(inserted.id ?? -1))") if !MemorySearchMode.usesVectorEmbeddings { let title = memory.content.prefix(80).trimmingCharacters(in: .whitespacesAndNewlines) - let slug = MemoryWikiStorage.slugify(String(title)) + let baseSlug = MemoryWikiStorage.slugify(String(title)) + let slug: String + if let memoryId = inserted.id { + slug = "\(baseSlug)-\(memoryId)" + } else { + slug = baseSlug + } Task { _ = try? await MemoryWikiStorage.shared.upsertPage( slug: slug, diff --git a/desktop/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/TaskAssistant.swift b/desktop/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/TaskAssistant.swift index 1ee94866c92..3be2be209fa 100644 --- a/desktop/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/TaskAssistant.swift +++ b/desktop/Desktop/Sources/ProactiveAssistants/Assistants/TaskExtraction/TaskAssistant.swift @@ -1330,9 +1330,9 @@ actor TaskAssistant: ProactiveAssistant { for hit in wikiHits { results.append( TaskSearchResult( - id: hit.id, - description: "\(hit.title): \(hit.snippet)", - status: "active", + id: 0, + description: "[Memory wiki] \(hit.title): \(hit.snippet)", + status: "wiki", similarity: nil, matchType: "wiki_fts", relevanceScore: nil diff --git a/desktop/Desktop/Sources/ProactiveAssistants/Core/GeminiClient.swift b/desktop/Desktop/Sources/ProactiveAssistants/Core/GeminiClient.swift index d0206091054..ea8477f504b 100644 --- a/desktop/Desktop/Sources/ProactiveAssistants/Core/GeminiClient.swift +++ b/desktop/Desktop/Sources/ProactiveAssistants/Core/GeminiClient.swift @@ -185,9 +185,12 @@ actor GeminiClient { case codexOpenAICompatible } - private let transport: Transport private let model: String + private var transport: Transport { + CodexAuthService.isActive ? .codexOpenAICompatible : .geminiProxy + } + /// Backend proxy base URL (from OMI_DESKTOP_API_URL env var) private static var proxyBaseURL: String { if let cString = getenv("OMI_DESKTOP_API_URL"), let url = String(validatingUTF8: cString), !url.isEmpty { @@ -255,14 +258,9 @@ actor GeminiClient { init(apiKey: String? = nil, model: String = ModelQoS.Gemini.proactive) throws { // BREAKING CHANGE (issue #5861): apiKey parameter is ignored for cloud proxy mode. self.model = model - if CodexAuthService.isActive { - self.transport = .codexOpenAICompatible - return - } - guard !Self.proxyBaseURL.isEmpty else { + if !CodexAuthService.isActive && Self.proxyBaseURL.isEmpty { throw GeminiClientError.missingAPIKey } - self.transport = .geminiProxy } private func mapCodexError(_ error: CodexLLMClient.ClientError) -> GeminiClientError {