Эх сурвалжийг харах

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 сар өмнө
parent
commit
95f6b52e3d

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

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

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

@@ -9,10 +9,10 @@ enum AIConfiguration {
             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.
 /// The key is XOR-obfuscated so it does not appear as plain text in source or strings tables.
 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,
         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,
@@ -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,
     ]
 
-    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 {
-    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 MockCommentGenerationService()
@@ -32,9 +32,9 @@ enum CommentGenerationError: LocalizedError {
         case .emptyParentComment:
             "Paste the parent comment you're replying to."
         case .apiUnavailable(let message):
-            message
+            UserFacingError.sanitized(message) ?? UserFacingError.generic
         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")!)
         request.httpMethod = "POST"
+        request.timeoutInterval = 60
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
 
@@ -78,17 +79,19 @@ struct OpenAICommentGenerationService: CommentGenerationServiceProtocol {
         ]
         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)

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

@@ -5,8 +5,8 @@ protocol PostGenerationServiceProtocol: Sendable {
 }
 
 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 MockPostGenerationService()
@@ -29,9 +29,9 @@ enum PostGenerationError: LocalizedError {
         case .invalidSubreddit:
             "Subreddit names must be 3–21 characters and use only letters, numbers, or underscores."
         case .apiUnavailable(let message):
-            message
+            UserFacingError.sanitized(message) ?? UserFacingError.generic
         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")!)
         request.httpMethod = "POST"
+        request.timeoutInterval = 60
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
 
@@ -73,17 +74,19 @@ struct OpenAIPostGenerationService: PostGenerationServiceProtocol {
         ]
         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)
@@ -137,11 +140,12 @@ struct OpenAIPostGenerationService: PostGenerationServiceProtocol {
         }
 
         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
         }
 
-        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 body = String(payload.body.prefix(PostDraftValidator.maxBodyLength))
 

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

@@ -5,8 +5,8 @@ protocol TitleOptimizationServiceProtocol: Sendable {
 }
 
 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 MockTitleOptimizationService()
@@ -25,8 +25,10 @@ enum TitleOptimizationError: LocalizedError {
         case .emptyTopic: "Enter a topic or keywords to guide title generation."
         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 .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")!)
         request.httpMethod = "POST"
+        request.timeoutInterval = 60
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
 
@@ -76,17 +79,19 @@ struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
         ]
         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)
@@ -174,7 +179,7 @@ struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
         do {
             payload = try JSONDecoder().decode(Payload.self, from: payloadData)
         } catch {
-            throw TitleOptimizationError.apiUnavailable("Couldn't parse the AI response. Please try again.")
+            throw TitleOptimizationError.invalidResponse
         }
         guard !payload.variants.isEmpty else {
             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 showOverwriteConfirmation = false
 
-    private let generationService: any CommentGenerationServiceProtocol
+    private var hasPremiumAccess = false
+    private let injectedGenerationService: (any CommentGenerationServiceProtocol)?
     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 {
@@ -27,7 +40,7 @@ final class CommentWriterViewModel {
     }
 
     var usesLiveAI: Bool {
-        AIConfiguration.usesLiveAI
+        AIConfiguration.usesLiveAI(hasPremiumAccess: hasPremiumAccess)
     }
 
     var hasDraftChanges: Bool {
@@ -152,7 +165,7 @@ final class CommentWriterViewModel {
             let engine = usesLiveAI ? "AI" : "local AI templates"
             successMessage = "Generated comment with \(result.variants.count) variants using \(engine)."
         } catch {
-            errorMessage = error.localizedDescription
+            errorMessage = UserFacingError.message(for: error)
         }
 
         isGenerating = false

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

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

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

@@ -13,14 +13,27 @@ final class TitleOptimizerViewModel {
     var variants: [TitleVariant] = []
     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 {
-        AIConfiguration.usesLiveAI
+        AIConfiguration.usesLiveAI(hasPremiumAccess: hasPremiumAccess)
     }
 
     var formattedSubreddit: String {
@@ -111,7 +124,7 @@ final class TitleOptimizerViewModel {
             let engine = usesLiveAI ? "AI" : "local AI templates"
             successMessage = "Generated \(result.variants.count) title suggestions using \(engine)."
         } 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()
             }
             Button("Replace", role: .destructive) {
-                guard requirePremiumAccess() else { return }
+                guard !viewModel.usesLiveAI || requirePremiumAccess() else { return }
                 Task { await viewModel.confirmOverwriteAndGenerate() }
             }
         } message: {
@@ -126,7 +126,7 @@ struct CommentWriterView: View {
             .disabled(!viewModel.canExport)
 
             Button {
-                guard requirePremiumAccess() else { return }
+                guard !viewModel.usesLiveAI || requirePremiumAccess() else { return }
                 Task { await viewModel.generateComment() }
             } label: {
                 HStack(spacing: 6) {

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

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

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

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

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

@@ -108,10 +108,7 @@ struct TitleOptimizerView: View {
             .disabled(viewModel.activeTitle.trimmingCharacters(in: .whitespaces).isEmpty)
 
             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() }
             } label: {
                 HStack(spacing: 6) {
@@ -133,7 +130,7 @@ struct TitleOptimizerView: View {
                 .clipShape(RoundedRectangle(cornerRadius: 8))
             }
             .buttonStyle(AppPrimaryButtonStyle())
-            .disabled(viewModel.isOptimizing)
+            .disabled(!viewModel.canOptimize)
         }
     }