Просмотр исходного кода

Fix AI generation across tools and gate live OpenAI behind premium.

Free users get local templates immediately while subscribers use live AI, with shared network retries, sanitized errors, and improved response parsing.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 месяц назад
Родитель
Сommit
95f6b52e3d

+ 3 - 0
Reddit App/App/RedditAppApp.swift

@@ -14,6 +14,9 @@ struct RedditAppApp: App {
                 .environmentObject(appRating)
                 .environmentObject(appRating)
                 .environment(appearanceManager)
                 .environment(appearanceManager)
                 .preferredColorScheme(appearanceManager.isDarkMode ? .dark : .light)
                 .preferredColorScheme(appearanceManager.isDarkMode ? .dark : .light)
+                .task {
+                    await NetworkClient.prepare()
+                }
         }
         }
         .defaultSize(width: AppWindow.width, height: AppWindow.height)
         .defaultSize(width: AppWindow.width, height: AppWindow.height)
         .windowStyle(.hiddenTitleBar)
         .windowStyle(.hiddenTitleBar)

+ 3 - 3
Reddit App/Managers/AIConfiguration.swift

@@ -9,10 +9,10 @@ enum AIConfiguration {
             return envKey.trimmingCharacters(in: .whitespaces)
             return envKey.trimmingCharacters(in: .whitespaces)
         }
         }
 
 
-        return EncryptedOpenAIKey.resolve()
+        return EncryptedOpenAIKey.openAIAPIKey()
     }
     }
 
 
-    static var usesLiveAI: Bool {
-        apiKey != nil
+    static func usesLiveAI(hasPremiumAccess: Bool) -> Bool {
+        hasPremiumAccess && apiKey != nil
     }
     }
 }
 }

+ 12 - 19
Reddit App/Managers/EncryptedOpenAIKey.swift

@@ -3,7 +3,9 @@ import Foundation
 /// Runtime-decoded OpenAI credentials bundled with the app.
 /// Runtime-decoded OpenAI credentials bundled with the app.
 /// The key is XOR-obfuscated so it does not appear as plain text in source or strings tables.
 /// The key is XOR-obfuscated so it does not appear as plain text in source or strings tables.
 enum EncryptedOpenAIKey {
 enum EncryptedOpenAIKey {
-    private static let obfuscatedPayload: [UInt8] = [
+    private static let xorSeed: [UInt8] = Array("ReddoraAI".utf8) + Array("ServiceKey".utf8) + Array("2026".utf8)
+
+    private static let obfuscatedOpenAIKey: [UInt8] = [
         33, 14, 73, 23, 25, 17, 0, 34, 42, 39, 72, 39, 6, 89, 13, 21, 34, 11, 40, 91,
         33, 14, 73, 23, 25, 17, 0, 34, 42, 39, 72, 39, 6, 89, 13, 21, 34, 11, 40, 91,
         120, 112, 89, 106, 63, 15, 30, 94, 23, 8, 37, 124, 10, 22, 62, 63, 47, 78, 21, 15,
         120, 112, 89, 106, 63, 15, 30, 94, 23, 8, 37, 124, 10, 22, 62, 63, 47, 78, 21, 15,
         55, 12, 104, 119, 7, 1, 57, 19, 28, 3, 48, 22, 87, 44, 61, 107, 43, 54, 46, 7, 91,
         55, 12, 104, 119, 7, 1, 57, 19, 28, 3, 48, 22, 87, 44, 61, 107, 43, 54, 46, 7, 91,
@@ -14,27 +16,18 @@ enum EncryptedOpenAIKey {
         48, 30, 3, 5, 6, 85, 44, 36, 46, 109, 65, 4, 105, 8, 40, 29, 60, 28, 51,
         48, 30, 3, 5, 6, 85, 44, 36, 46, 109, 65, 4, 105, 8, 40, 29, 60, 28, 51,
     ]
     ]
 
 
-    private static var maskBytes: [UInt8] {
-        Array("ReddoraAI".utf8) + Array("ServiceKey".utf8) + Array("2026".utf8)
-    }
-
-    static func resolve() -> String? {
-        let mask = maskBytes
-        guard !mask.isEmpty else { return nil }
+    static func openAIAPIKey() -> String? {
+        guard !xorSeed.isEmpty else { return nil }
 
 
-        var decoded = [UInt8]()
-        decoded.reserveCapacity(obfuscatedPayload.count)
-
-        for (index, byte) in obfuscatedPayload.enumerated() {
-            decoded.append(byte ^ mask[index % mask.count])
+        let decoded = obfuscatedOpenAIKey.enumerated().map { index, byte in
+            byte ^ xorSeed[index % xorSeed.count]
         }
         }
 
 
-        guard let key = String(bytes: decoded, encoding: .utf8),
-              key.hasPrefix("sk-"),
-              !key.isEmpty else {
-            return nil
-        }
+        guard let key = String(bytes: decoded, encoding: .utf8) else { return nil }
+
+        let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty, trimmed.hasPrefix("sk-") else { return nil }
 
 
-        return key
+        return trimmed
     }
     }
 }
 }

+ 16 - 13
Reddit App/Services/CommentGenerationService.swift

@@ -5,8 +5,8 @@ protocol CommentGenerationServiceProtocol: Sendable {
 }
 }
 
 
 enum CommentGenerationServiceFactory {
 enum CommentGenerationServiceFactory {
-    static func makeDefault() -> any CommentGenerationServiceProtocol {
-        if let apiKey = AIConfiguration.apiKey {
+    static func make(hasPremiumAccess: Bool) -> any CommentGenerationServiceProtocol {
+        if hasPremiumAccess, let apiKey = AIConfiguration.apiKey {
             return OpenAICommentGenerationService(apiKey: apiKey)
             return OpenAICommentGenerationService(apiKey: apiKey)
         }
         }
         return MockCommentGenerationService()
         return MockCommentGenerationService()
@@ -32,9 +32,9 @@ enum CommentGenerationError: LocalizedError {
         case .emptyParentComment:
         case .emptyParentComment:
             "Paste the parent comment you're replying to."
             "Paste the parent comment you're replying to."
         case .apiUnavailable(let message):
         case .apiUnavailable(let message):
-            message
+            UserFacingError.sanitized(message) ?? UserFacingError.generic
         case .invalidResponse:
         case .invalidResponse:
-            "The AI returned an unexpected response. Please try again."
+            UserFacingError.generic
         }
         }
     }
     }
 }
 }
@@ -53,6 +53,7 @@ struct OpenAICommentGenerationService: CommentGenerationServiceProtocol {
 
 
         var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
         var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
         request.httpMethod = "POST"
         request.httpMethod = "POST"
+        request.timeoutInterval = 60
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
 
 
@@ -78,17 +79,19 @@ struct OpenAICommentGenerationService: CommentGenerationServiceProtocol {
         ]
         ]
         request.httpBody = try JSONSerialization.data(withJSONObject: body)
         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 CommentGenerationError.apiUnavailable("Couldn't reach the AI service.")
-        }
+        let data: Data
+        let response: URLResponse
 
 
-        if http.statusCode == 401 {
-            throw CommentGenerationError.apiUnavailable("Invalid OpenAI API key.")
+        do {
+            (data, response) = try await NetworkClient.data(for: request)
+        } catch let error as URLError {
+            throw CommentGenerationError.apiUnavailable(UserFacingError.networkMessage(for: error))
         }
         }
-        guard (200 ... 299).contains(http.statusCode) else {
-            let message = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)"
-            throw CommentGenerationError.apiUnavailable("AI request failed: \(message)")
+
+        if let http = response as? HTTPURLResponse, !(200 ... 299).contains(http.statusCode) {
+            throw CommentGenerationError.apiUnavailable(
+                OpenAIHTTPError.userMessage(statusCode: http.statusCode, data: data)
+            )
         }
         }
 
 
         return try parseResponse(data)
         return try parseResponse(data)

+ 19 - 15
Reddit App/Services/PostGenerationService.swift

@@ -5,8 +5,8 @@ protocol PostGenerationServiceProtocol: Sendable {
 }
 }
 
 
 enum PostGenerationServiceFactory {
 enum PostGenerationServiceFactory {
-    static func makeDefault() -> any PostGenerationServiceProtocol {
-        if let apiKey = AIConfiguration.apiKey {
+    static func make(hasPremiumAccess: Bool) -> any PostGenerationServiceProtocol {
+        if hasPremiumAccess, let apiKey = AIConfiguration.apiKey {
             return OpenAIPostGenerationService(apiKey: apiKey)
             return OpenAIPostGenerationService(apiKey: apiKey)
         }
         }
         return MockPostGenerationService()
         return MockPostGenerationService()
@@ -29,9 +29,9 @@ enum PostGenerationError: LocalizedError {
         case .invalidSubreddit:
         case .invalidSubreddit:
             "Subreddit names must be 3–21 characters and use only letters, numbers, or underscores."
             "Subreddit names must be 3–21 characters and use only letters, numbers, or underscores."
         case .apiUnavailable(let message):
         case .apiUnavailable(let message):
-            message
+            UserFacingError.sanitized(message) ?? UserFacingError.generic
         case .invalidResponse:
         case .invalidResponse:
-            "The AI returned an unexpected response. Please try again."
+            UserFacingError.generic
         }
         }
     }
     }
 }
 }
@@ -51,6 +51,7 @@ struct OpenAIPostGenerationService: PostGenerationServiceProtocol {
 
 
         var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
         var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
         request.httpMethod = "POST"
         request.httpMethod = "POST"
+        request.timeoutInterval = 60
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
 
 
@@ -73,17 +74,19 @@ struct OpenAIPostGenerationService: PostGenerationServiceProtocol {
         ]
         ]
         request.httpBody = try JSONSerialization.data(withJSONObject: body)
         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 PostGenerationError.apiUnavailable("Couldn't reach the AI service.")
-        }
+        let data: Data
+        let response: URLResponse
 
 
-        if http.statusCode == 401 {
-            throw PostGenerationError.apiUnavailable("Invalid OpenAI API key.")
+        do {
+            (data, response) = try await NetworkClient.data(for: request)
+        } catch let error as URLError {
+            throw PostGenerationError.apiUnavailable(UserFacingError.networkMessage(for: error))
         }
         }
-        guard (200 ... 299).contains(http.statusCode) else {
-            let message = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)"
-            throw PostGenerationError.apiUnavailable("AI request failed: \(message)")
+
+        if let http = response as? HTTPURLResponse, !(200 ... 299).contains(http.statusCode) {
+            throw PostGenerationError.apiUnavailable(
+                OpenAIHTTPError.userMessage(statusCode: http.statusCode, data: data)
+            )
         }
         }
 
 
         return try parseResponse(data, postType: draft.postType)
         return try parseResponse(data, postType: draft.postType)
@@ -137,11 +140,12 @@ struct OpenAIPostGenerationService: PostGenerationServiceProtocol {
         }
         }
 
 
         let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data)
         let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data)
-        guard let content = envelope.choices.first?.message.content.data(using: .utf8) else {
+        guard let content = envelope.choices.first?.message.content,
+              let payloadData = AIJSONDecoding.payloadData(from: content) else {
             throw PostGenerationError.invalidResponse
             throw PostGenerationError.invalidResponse
         }
         }
 
 
-        let payload = try JSONDecoder().decode(Payload.self, from: content)
+        let payload = try JSONDecoder().decode(Payload.self, from: payloadData)
         let title = String(payload.title.prefix(PostDraftValidator.maxTitleLength))
         let title = String(payload.title.prefix(PostDraftValidator.maxTitleLength))
         let body = String(payload.body.prefix(PostDraftValidator.maxBodyLength))
         let body = String(payload.body.prefix(PostDraftValidator.maxBodyLength))
 
 

+ 19 - 14
Reddit App/Services/TitleOptimizationService.swift

@@ -5,8 +5,8 @@ protocol TitleOptimizationServiceProtocol: Sendable {
 }
 }
 
 
 enum TitleOptimizationServiceFactory {
 enum TitleOptimizationServiceFactory {
-    static func makeDefault() -> any TitleOptimizationServiceProtocol {
-        if let apiKey = AIConfiguration.apiKey {
+    static func make(hasPremiumAccess: Bool) -> any TitleOptimizationServiceProtocol {
+        if hasPremiumAccess, let apiKey = AIConfiguration.apiKey {
             return OpenAITitleOptimizationService(apiKey: apiKey)
             return OpenAITitleOptimizationService(apiKey: apiKey)
         }
         }
         return MockTitleOptimizationService()
         return MockTitleOptimizationService()
@@ -25,8 +25,10 @@ enum TitleOptimizationError: LocalizedError {
         case .emptyTopic: "Enter a topic or keywords to guide title generation."
         case .emptyTopic: "Enter a topic or keywords to guide title generation."
         case .emptySubreddit: "Enter a subreddit to tailor titles for that community."
         case .emptySubreddit: "Enter a subreddit to tailor titles for that community."
         case .emptyTitle: "Enter a title to optimize, or provide a topic to generate from scratch."
         case .emptyTitle: "Enter a title to optimize, or provide a topic to generate from scratch."
-        case .apiUnavailable(let message): message
-        case .invalidResponse: "The AI returned an unexpected response. Please try again."
+        case .apiUnavailable(let message):
+            UserFacingError.sanitized(message) ?? UserFacingError.generic
+        case .invalidResponse:
+            UserFacingError.generic
         }
         }
     }
     }
 }
 }
@@ -53,6 +55,7 @@ struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
 
 
         var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
         var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
         request.httpMethod = "POST"
         request.httpMethod = "POST"
+        request.timeoutInterval = 60
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
 
 
@@ -76,17 +79,19 @@ struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
         ]
         ]
         request.httpBody = try JSONSerialization.data(withJSONObject: body)
         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 TitleOptimizationError.apiUnavailable("Couldn't reach the AI service.")
-        }
+        let data: Data
+        let response: URLResponse
 
 
-        if http.statusCode == 401 {
-            throw TitleOptimizationError.apiUnavailable("Invalid OpenAI API key.")
+        do {
+            (data, response) = try await NetworkClient.data(for: request)
+        } catch let error as URLError {
+            throw TitleOptimizationError.apiUnavailable(UserFacingError.networkMessage(for: error))
         }
         }
-        guard (200 ... 299).contains(http.statusCode) else {
-            let message = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)"
-            throw TitleOptimizationError.apiUnavailable("AI request failed: \(message)")
+
+        if let http = response as? HTTPURLResponse, !(200 ... 299).contains(http.statusCode) {
+            throw TitleOptimizationError.apiUnavailable(
+                OpenAIHTTPError.userMessage(statusCode: http.statusCode, data: data)
+            )
         }
         }
 
 
         return try parseResponse(data)
         return try parseResponse(data)
@@ -174,7 +179,7 @@ struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
         do {
         do {
             payload = try JSONDecoder().decode(Payload.self, from: payloadData)
             payload = try JSONDecoder().decode(Payload.self, from: payloadData)
         } catch {
         } catch {
-            throw TitleOptimizationError.apiUnavailable("Couldn't parse the AI response. Please try again.")
+            throw TitleOptimizationError.invalidResponse
         }
         }
         guard !payload.variants.isEmpty else {
         guard !payload.variants.isEmpty else {
             throw TitleOptimizationError.invalidResponse
             throw TitleOptimizationError.invalidResponse

+ 59 - 0
Reddit App/Utilities/NetworkClient.swift

@@ -0,0 +1,59 @@
+import Foundation
+
+enum NetworkClient {
+    static let session: URLSession = {
+        let configuration = URLSessionConfiguration.default
+        configuration.waitsForConnectivity = true
+        configuration.timeoutIntervalForRequest = 60
+        configuration.timeoutIntervalForResource = 120
+        configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
+        return URLSession(configuration: configuration)
+    }()
+
+    static func data(for request: URLRequest, retries: Int = 2) async throws -> (Data, URLResponse) {
+        var lastError: Error?
+
+        for attempt in 0...retries {
+            if attempt > 0 {
+                try await Task.sleep(nanoseconds: UInt64(attempt) * 400_000_000)
+            }
+
+            do {
+                return try await session.data(for: request)
+            } catch let error as URLError where error.isTransientNetworkFailure {
+                lastError = error
+            } catch {
+                throw error
+            }
+        }
+
+        throw lastError ?? URLError(.unknown)
+    }
+
+    static func prepare() async {
+        guard let url = URL(string: "https://api.openai.com") else { return }
+
+        var request = URLRequest(url: url)
+        request.httpMethod = "HEAD"
+        request.timeoutInterval = 15
+        _ = try? await session.data(for: request)
+    }
+}
+
+private extension URLError {
+    var isTransientNetworkFailure: Bool {
+        switch code {
+        case .networkConnectionLost,
+             .notConnectedToInternet,
+             .timedOut,
+             .cannotConnectToHost,
+             .dnsLookupFailed,
+             .cannotFindHost,
+             .internationalRoamingOff,
+             .callIsActive:
+            return true
+        default:
+            return false
+        }
+    }
+}

+ 32 - 0
Reddit App/Utilities/OpenAIHTTPError.swift

@@ -0,0 +1,32 @@
+import Foundation
+
+enum OpenAIHTTPError {
+    static func userMessage(statusCode: Int, data: Data) -> String {
+        switch statusCode {
+        case 401, 403:
+            return UserFacingError.generic
+        case 429:
+            return "Too many requests. Please wait a moment and try again."
+        case 500...599:
+            return "The service is temporarily unavailable. Please try again."
+        default:
+            if let message = errorMessage(from: data),
+               let sanitized = UserFacingError.sanitized(message) {
+                return sanitized
+            }
+            return UserFacingError.generic
+        }
+    }
+
+    private static func errorMessage(from data: Data) -> String? {
+        struct APIErrorResponse: Decodable {
+            struct APIError: Decodable {
+                let message: String
+            }
+
+            let error: APIError
+        }
+
+        return (try? JSONDecoder().decode(APIErrorResponse.self, from: data))?.error.message
+    }
+}

+ 71 - 0
Reddit App/Utilities/UserFacingError.swift

@@ -0,0 +1,71 @@
+import Foundation
+
+enum UserFacingError {
+    static let generic = "Something went wrong. Please try again."
+
+    static func message(for error: Error) -> String {
+        if shouldUseGenericMessage(for: error) {
+            return generic
+        }
+
+        let raw = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
+        return sanitized(raw) ?? generic
+    }
+
+    static func networkMessage(for error: URLError) -> String {
+        switch error.code {
+        case .notConnectedToInternet:
+            "You appear to be offline. Check your internet connection and try again."
+        case .timedOut:
+            "The request timed out. Please try again."
+        case .networkConnectionLost:
+            "The connection was interrupted. Please try again."
+        default:
+            generic
+        }
+    }
+
+    static func sanitized(_ message: String) -> String? {
+        let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else { return nil }
+        guard !containsSensitiveContent(trimmed.lowercased()) else { return nil }
+        return trimmed
+    }
+
+    private static func shouldUseGenericMessage(for error: Error) -> Bool {
+        if error is DecodingError || error is EncodingError {
+            return true
+        }
+
+        switch error {
+        case PostGenerationError.invalidResponse,
+             CommentGenerationError.invalidResponse,
+             TitleOptimizationError.invalidResponse:
+            return true
+        default:
+            return false
+        }
+    }
+
+    private static func containsSensitiveContent(_ lower: String) -> Bool {
+        lower.contains("api key")
+            || lower.contains("apikey")
+            || lower.contains("openai")
+            || lower.contains("invalid_api_key")
+            || lower.contains("incorrect api key")
+            || lower.contains("bearer ")
+            || lower.contains("sk-")
+            || lower.contains("authentication")
+            || lower.contains("not configured")
+            || lower.contains("response format")
+            || lower.contains("request failed")
+            || lower.contains("ai request failed")
+            || lower.contains("couldn't reach the ai service")
+            || lower.contains("nsurlerrordomain")
+            || lower.contains("cocoaerrordomain")
+            || lower.contains("error domain")
+            || (lower.contains("could not read") && lower.contains("response"))
+            || (lower.contains("couldn't parse") && lower.contains("ai"))
+            || (lower.contains("status") && lower.range(of: #"\b[1-5]\d{2}\b"#, options: .regularExpression) != nil)
+    }
+}

+ 18 - 5
Reddit App/ViewModels/CommentWriterViewModel.swift

@@ -13,11 +13,24 @@ final class CommentWriterViewModel {
     var selectedVariantID: UUID?
     var selectedVariantID: UUID?
     var showOverwriteConfirmation = false
     var showOverwriteConfirmation = false
 
 
-    private let generationService: any CommentGenerationServiceProtocol
+    private var hasPremiumAccess = false
+    private let injectedGenerationService: (any CommentGenerationServiceProtocol)?
     private let emptyDraft = CommentDraft()
     private let emptyDraft = CommentDraft()
 
 
-    init(generationService: (any CommentGenerationServiceProtocol)? = nil) {
-        self.generationService = generationService ?? CommentGenerationServiceFactory.makeDefault()
+    init(
+        hasPremiumAccess: Bool = false,
+        generationService: (any CommentGenerationServiceProtocol)? = nil
+    ) {
+        self.hasPremiumAccess = hasPremiumAccess
+        self.injectedGenerationService = generationService
+    }
+
+    func setPremiumAccess(_ hasPremiumAccess: Bool) {
+        self.hasPremiumAccess = hasPremiumAccess
+    }
+
+    private var generationService: any CommentGenerationServiceProtocol {
+        injectedGenerationService ?? CommentGenerationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
     }
     }
 
 
     var formattedSubreddit: String {
     var formattedSubreddit: String {
@@ -27,7 +40,7 @@ final class CommentWriterViewModel {
     }
     }
 
 
     var usesLiveAI: Bool {
     var usesLiveAI: Bool {
-        AIConfiguration.usesLiveAI
+        AIConfiguration.usesLiveAI(hasPremiumAccess: hasPremiumAccess)
     }
     }
 
 
     var hasDraftChanges: Bool {
     var hasDraftChanges: Bool {
@@ -152,7 +165,7 @@ final class CommentWriterViewModel {
             let engine = usesLiveAI ? "AI" : "local AI templates"
             let engine = usesLiveAI ? "AI" : "local AI templates"
             successMessage = "Generated comment with \(result.variants.count) variants using \(engine)."
             successMessage = "Generated comment with \(result.variants.count) variants using \(engine)."
         } catch {
         } catch {
-            errorMessage = error.localizedDescription
+            errorMessage = UserFacingError.message(for: error)
         }
         }
 
 
         isGenerating = false
         isGenerating = false

+ 20 - 7
Reddit App/ViewModels/PostGeneratorViewModel.swift

@@ -12,13 +12,26 @@ final class PostGeneratorViewModel {
     var showImageImporter = false
     var showImageImporter = false
     var showOverwriteConfirmation = false
     var showOverwriteConfirmation = false
 
 
-    private let generationService: any PostGenerationServiceProtocol
+    private var hasPremiumAccess = false
+    private let injectedGenerationService: (any PostGenerationServiceProtocol)?
     private var imageAccessURL: URL?
     private var imageAccessURL: URL?
     private var isAccessingImageResource = false
     private var isAccessingImageResource = false
     private let emptyDraft = PostDraft()
     private let emptyDraft = PostDraft()
 
 
-    init(generationService: (any PostGenerationServiceProtocol)? = nil) {
-        self.generationService = generationService ?? PostGenerationServiceFactory.makeDefault()
+    init(
+        hasPremiumAccess: Bool = false,
+        generationService: (any PostGenerationServiceProtocol)? = nil
+    ) {
+        self.hasPremiumAccess = hasPremiumAccess
+        self.injectedGenerationService = generationService
+    }
+
+    func setPremiumAccess(_ hasPremiumAccess: Bool) {
+        self.hasPremiumAccess = hasPremiumAccess
+    }
+
+    private var generationService: any PostGenerationServiceProtocol {
+        injectedGenerationService ?? PostGenerationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
     }
     }
 
 
     var formattedSubreddit: String {
     var formattedSubreddit: String {
@@ -28,7 +41,7 @@ final class PostGeneratorViewModel {
     }
     }
 
 
     var usesLiveAI: Bool {
     var usesLiveAI: Bool {
-        AIConfiguration.usesLiveAI
+        AIConfiguration.usesLiveAI(hasPremiumAccess: hasPremiumAccess)
     }
     }
 
 
     var hasDraftChanges: Bool {
     var hasDraftChanges: Bool {
@@ -96,7 +109,7 @@ final class PostGeneratorViewModel {
     }
     }
 
 
     func handleImageImportFailure(_ error: Error) {
     func handleImageImportFailure(_ error: Error) {
-        errorMessage = "Image import failed: \(error.localizedDescription)"
+        errorMessage = UserFacingError.message(for: error)
         successMessage = nil
         successMessage = nil
     }
     }
 
 
@@ -135,7 +148,7 @@ final class PostGeneratorViewModel {
             successMessage = "Copied to clipboard."
             successMessage = "Copied to clipboard."
             errorMessage = nil
             errorMessage = nil
         } catch {
         } catch {
-            errorMessage = error.localizedDescription
+            errorMessage = UserFacingError.message(for: error)
             successMessage = nil
             successMessage = nil
         }
         }
     }
     }
@@ -210,7 +223,7 @@ final class PostGeneratorViewModel {
             let engine = usesLiveAI ? "AI" : "local AI templates"
             let engine = usesLiveAI ? "AI" : "local AI templates"
             successMessage = "Post generated successfully using \(engine)."
             successMessage = "Post generated successfully using \(engine)."
         } catch {
         } catch {
-            errorMessage = error.localizedDescription
+            errorMessage = UserFacingError.message(for: error)
         }
         }
 
 
         isGenerating = false
         isGenerating = false

+ 18 - 5
Reddit App/ViewModels/TitleOptimizerViewModel.swift

@@ -13,14 +13,27 @@ final class TitleOptimizerViewModel {
     var variants: [TitleVariant] = []
     var variants: [TitleVariant] = []
     var selectedVariantID: UUID?
     var selectedVariantID: UUID?
 
 
-    private let optimizationService: any TitleOptimizationServiceProtocol
+    private var hasPremiumAccess = false
+    private let injectedOptimizationService: (any TitleOptimizationServiceProtocol)?
 
 
-    init(optimizationService: (any TitleOptimizationServiceProtocol)? = nil) {
-        self.optimizationService = optimizationService ?? TitleOptimizationServiceFactory.makeDefault()
+    init(
+        hasPremiumAccess: Bool = false,
+        optimizationService: (any TitleOptimizationServiceProtocol)? = nil
+    ) {
+        self.hasPremiumAccess = hasPremiumAccess
+        self.injectedOptimizationService = optimizationService
+    }
+
+    func setPremiumAccess(_ hasPremiumAccess: Bool) {
+        self.hasPremiumAccess = hasPremiumAccess
+    }
+
+    private var optimizationService: any TitleOptimizationServiceProtocol {
+        injectedOptimizationService ?? TitleOptimizationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
     }
     }
 
 
     var usesLiveAI: Bool {
     var usesLiveAI: Bool {
-        AIConfiguration.usesLiveAI
+        AIConfiguration.usesLiveAI(hasPremiumAccess: hasPremiumAccess)
     }
     }
 
 
     var formattedSubreddit: String {
     var formattedSubreddit: String {
@@ -111,7 +124,7 @@ final class TitleOptimizerViewModel {
             let engine = usesLiveAI ? "AI" : "local AI templates"
             let engine = usesLiveAI ? "AI" : "local AI templates"
             successMessage = "Generated \(result.variants.count) title suggestions using \(engine)."
             successMessage = "Generated \(result.variants.count) title suggestions using \(engine)."
         } catch {
         } catch {
-            errorMessage = error.localizedDescription
+            errorMessage = UserFacingError.message(for: error)
         }
         }
     }
     }
 
 

+ 2 - 2
Reddit App/Views/CommentWriterView.swift

@@ -42,7 +42,7 @@ struct CommentWriterView: View {
                 viewModel.cancelOverwriteConfirmation()
                 viewModel.cancelOverwriteConfirmation()
             }
             }
             Button("Replace", role: .destructive) {
             Button("Replace", role: .destructive) {
-                guard requirePremiumAccess() else { return }
+                guard !viewModel.usesLiveAI || requirePremiumAccess() else { return }
                 Task { await viewModel.confirmOverwriteAndGenerate() }
                 Task { await viewModel.confirmOverwriteAndGenerate() }
             }
             }
         } message: {
         } message: {
@@ -126,7 +126,7 @@ struct CommentWriterView: View {
             .disabled(!viewModel.canExport)
             .disabled(!viewModel.canExport)
 
 
             Button {
             Button {
-                guard requirePremiumAccess() else { return }
+                guard !viewModel.usesLiveAI || requirePremiumAccess() else { return }
                 Task { await viewModel.generateComment() }
                 Task { await viewModel.generateComment() }
             } label: {
             } label: {
                 HStack(spacing: 6) {
                 HStack(spacing: 6) {

+ 14 - 0
Reddit App/Views/FrontPageView.swift

@@ -45,9 +45,16 @@ struct FrontPageView: View {
             if hasPremium {
             if hasPremium {
                 viewModel.dismissPaywall()
                 viewModel.dismissPaywall()
             }
             }
+            syncToolPremiumAccess()
             appRating.updatePremiumStatus(hasPremium)
             appRating.updatePremiumStatus(hasPremium)
         }
         }
+        .onChange(of: subscriptions.hasResolvedPremiumStatus) { _, resolved in
+            if resolved {
+                syncToolPremiumAccess()
+            }
+        }
         .onAppear {
         .onAppear {
+            syncToolPremiumAccess()
             appRating.updatePremiumStatus(subscriptions.hasPremiumAccess)
             appRating.updatePremiumStatus(subscriptions.hasPremiumAccess)
         }
         }
         .frame(minWidth: AppWindow.minWidth, minHeight: AppWindow.minHeight)
         .frame(minWidth: AppWindow.minWidth, minHeight: AppWindow.minHeight)
@@ -136,6 +143,13 @@ struct FrontPageView: View {
             ToolPlaceholderView(item: item)
             ToolPlaceholderView(item: item)
         }
         }
     }
     }
+
+    private func syncToolPremiumAccess() {
+        let hasPremium = subscriptions.hasPremiumAccess
+        postGeneratorViewModel.setPremiumAccess(hasPremium)
+        titleOptimizerViewModel.setPremiumAccess(hasPremium)
+        commentWriterViewModel.setPremiumAccess(hasPremium)
+    }
 }
 }
 
 
 private struct ToolPlaceholderView: View {
 private struct ToolPlaceholderView: View {

+ 2 - 2
Reddit App/Views/PostGeneratorView.swift

@@ -57,7 +57,7 @@ struct PostGeneratorView: View {
                 viewModel.cancelOverwriteConfirmation()
                 viewModel.cancelOverwriteConfirmation()
             }
             }
             Button("Replace", role: .destructive) {
             Button("Replace", role: .destructive) {
-                guard requirePremiumAccess() else { return }
+                guard !viewModel.usesLiveAI || requirePremiumAccess() else { return }
                 Task { await viewModel.confirmOverwriteAndGenerate() }
                 Task { await viewModel.confirmOverwriteAndGenerate() }
             }
             }
         } message: {
         } message: {
@@ -143,7 +143,7 @@ struct PostGeneratorView: View {
             .disabled(!viewModel.canExport)
             .disabled(!viewModel.canExport)
 
 
             Button {
             Button {
-                guard requirePremiumAccess() else { return }
+                guard !viewModel.usesLiveAI || requirePremiumAccess() else { return }
                 Task { await viewModel.generatePost() }
                 Task { await viewModel.generatePost() }
             } label: {
             } label: {
                 HStack(spacing: 6) {
                 HStack(spacing: 6) {

+ 2 - 5
Reddit App/Views/TitleOptimizerView.swift

@@ -108,10 +108,7 @@ struct TitleOptimizerView: View {
             .disabled(viewModel.activeTitle.trimmingCharacters(in: .whitespaces).isEmpty)
             .disabled(viewModel.activeTitle.trimmingCharacters(in: .whitespaces).isEmpty)
 
 
             Button {
             Button {
-                guard requirePremiumAccess() else {
-                    viewModel.errorMessage = "Premium is required to generate AI title suggestions."
-                    return
-                }
+                guard !viewModel.usesLiveAI || requirePremiumAccess() else { return }
                 Task { await viewModel.optimizeTitles() }
                 Task { await viewModel.optimizeTitles() }
             } label: {
             } label: {
                 HStack(spacing: 6) {
                 HStack(spacing: 6) {
@@ -133,7 +130,7 @@ struct TitleOptimizerView: View {
                 .clipShape(RoundedRectangle(cornerRadius: 8))
                 .clipShape(RoundedRectangle(cornerRadius: 8))
             }
             }
             .buttonStyle(AppPrimaryButtonStyle())
             .buttonStyle(AppPrimaryButtonStyle())
-            .disabled(viewModel.isOptimizing)
+            .disabled(!viewModel.canOptimize)
         }
         }
     }
     }