diff --git a/README.md b/README.md index cfd47d2..af74669 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ Right now, Recap is more of a POC of what I am trying to make. It records system **LLM Options:** - **Ollama** (recommended): Complete privacy - everything stays on your device - **OpenRouter**: Cloud-based option if you lack local compute capacity, but data leaves your device +- **Requesty**: Cloud-based OpenAI-compatible router ([requesty.ai](https://requesty.ai)); get an API key at [app.requesty.ai/api-keys](https://app.requesty.ai/api-keys), but data leaves your device ## System Requirements @@ -104,6 +105,20 @@ Right now, Recap is more of a POC of what I am trying to make. It records system +
+ Requesty (Cloud Processing) + +| Component | Minimum | Recommended | +|-----------|---------|-------------| +| **macOS** | 15.0 or later | 15.0 or later | +| **Processor** | Apple M1 | Apple M2 or newer | +| **RAM** | 8 GB | 16 GB or more | +| **Storage** | 2 GB free space | 5 GB free space | + +Requesty is an OpenAI-compatible router (base URL `https://router.requesty.ai/v1`). Get an API key at [app.requesty.ai/api-keys](https://app.requesty.ai/api-keys). + +
+ > **Note**: Intel Macs are not supported. Use it at your own risk. @@ -217,8 +232,9 @@ Read [OpenRouter documentation](https://openrouter.ai/docs/api-keys) to get a ke 2. **Configure LLM Provider:** * Go to **Settings → LLM Models** - * Choose your preferred provider (Ollama or OpenRouter) + * Choose your preferred provider (Ollama, OpenRouter, or Requesty) * If using Ollama, ensure it's installed and running locally + * If using Requesty, add your API key from [app.requesty.ai/api-keys](https://app.requesty.ai/api-keys) 3. **Start Recording:** diff --git a/Recap/Repositories/Models/LLMProvider.swift b/Recap/Repositories/Models/LLMProvider.swift index aeabf11..5359360 100644 --- a/Recap/Repositories/Models/LLMProvider.swift +++ b/Recap/Repositories/Models/LLMProvider.swift @@ -3,6 +3,7 @@ import Foundation enum LLMProvider: String, CaseIterable, Identifiable { case ollama = "ollama" case openRouter = "openrouter" + case requesty = "requesty" var id: String { rawValue } @@ -12,6 +13,8 @@ enum LLMProvider: String, CaseIterable, Identifiable { return "Ollama" case .openRouter: return "OpenRouter" + case .requesty: + return "Requesty" } } diff --git a/Recap/Services/Keychain/KeychainAPIValidator.swift b/Recap/Services/Keychain/KeychainAPIValidator.swift index ceea460..f19b4ff 100644 --- a/Recap/Services/Keychain/KeychainAPIValidator.swift +++ b/Recap/Services/Keychain/KeychainAPIValidator.swift @@ -28,4 +28,26 @@ final class KeychainAPIValidator: KeychainAPIValidatorType { let trimmedKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) return trimmedKey.hasPrefix("sk-or-") && trimmedKey.count > 10 } + + func validateRequestyAPI() -> APIValidationResult { + do { + guard let apiKey = try keychainService.retrieve(key: KeychainKey.requestyApiKey.key), + !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return .missingApiKey + } + + guard isValidRequestyAPIKeyFormat(apiKey) else { + return .invalidApiKey + } + + return .valid + } catch { + return .missingApiKey + } + } + + private func isValidRequestyAPIKeyFormat(_ apiKey: String) -> Bool { + let trimmedKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedKey.hasPrefix("sk-") && trimmedKey.count > 10 + } } diff --git a/Recap/Services/Keychain/KeychainAPIValidatorType.swift b/Recap/Services/Keychain/KeychainAPIValidatorType.swift index ec2e574..79f6a99 100644 --- a/Recap/Services/Keychain/KeychainAPIValidatorType.swift +++ b/Recap/Services/Keychain/KeychainAPIValidatorType.swift @@ -8,6 +8,7 @@ import Mockable #endif protocol KeychainAPIValidatorType { func validateOpenRouterAPI() -> APIValidationResult + func validateRequestyAPI() -> APIValidationResult } enum APIValidationResult { diff --git a/Recap/Services/Keychain/KeychainService+Extensions.swift b/Recap/Services/Keychain/KeychainService+Extensions.swift index d96dd66..61d16cc 100644 --- a/Recap/Services/Keychain/KeychainService+Extensions.swift +++ b/Recap/Services/Keychain/KeychainService+Extensions.swift @@ -16,4 +16,20 @@ extension KeychainServiceType { func hasOpenRouterAPIKey() -> Bool { exists(key: KeychainKey.openRouterApiKey.key) } + + func storeRequestyAPIKey(_ apiKey: String) throws { + try store(key: KeychainKey.requestyApiKey.key, value: apiKey) + } + + func retrieveRequestyAPIKey() throws -> String? { + try retrieve(key: KeychainKey.requestyApiKey.key) + } + + func deleteRequestyAPIKey() throws { + try delete(key: KeychainKey.requestyApiKey.key) + } + + func hasRequestyAPIKey() -> Bool { + exists(key: KeychainKey.requestyApiKey.key) + } } diff --git a/Recap/Services/Keychain/KeychainServiceType.swift b/Recap/Services/Keychain/KeychainServiceType.swift index d6cab91..d7a2afa 100644 --- a/Recap/Services/Keychain/KeychainServiceType.swift +++ b/Recap/Services/Keychain/KeychainServiceType.swift @@ -35,6 +35,7 @@ enum KeychainError: Error, LocalizedError { enum KeychainKey: String, CaseIterable { case openRouterApiKey = "openrouter_api_key" + case requestyApiKey = "requesty_api_key" var key: String { return "com.recap.\(rawValue)" diff --git a/Recap/Services/LLM/LLMService.swift b/Recap/Services/LLM/LLMService.swift index 03fb2b1..79f80d9 100644 --- a/Recap/Services/LLM/LLMService.swift +++ b/Recap/Services/LLM/LLMService.swift @@ -33,7 +33,8 @@ final class LLMService: LLMServiceType { func initializeProviders() { let ollamaProvider = OllamaProvider() let openRouterProvider = OpenRouterProvider() - availableProviders = [ollamaProvider, openRouterProvider] + let requestyProvider = RequestyProvider() + availableProviders = [ollamaProvider, openRouterProvider, requestyProvider] Task { do { @@ -44,12 +45,13 @@ final class LLMService: LLMServiceType { } } - Publishers.CombineLatest( + Publishers.CombineLatest3( ollamaProvider.availabilityPublisher, - openRouterProvider.availabilityPublisher + openRouterProvider.availabilityPublisher, + requestyProvider.availabilityPublisher ) - .map { ollamaAvailable, openRouterAvailable in - ollamaAvailable || openRouterAvailable + .map { ollamaAvailable, openRouterAvailable, requestyAvailable in + ollamaAvailable || openRouterAvailable || requestyAvailable } .sink { [weak self] isAnyProviderAvailable in self?.isProviderAvailable = isAnyProviderAvailable diff --git a/Recap/Services/LLM/Providers/Requesty/RequestyAPIClient.swift b/Recap/Services/LLM/Providers/Requesty/RequestyAPIClient.swift new file mode 100644 index 0000000..f5d11ce --- /dev/null +++ b/Recap/Services/LLM/Providers/Requesty/RequestyAPIClient.swift @@ -0,0 +1,210 @@ +import Foundation + +@MainActor +final class RequestyAPIClient { + private let baseURL: String + private let apiKey: String? + private let session: URLSession + + init(baseURL: String = "https://router.requesty.ai/v1", apiKey: String? = nil) { + self.baseURL = baseURL + self.apiKey = apiKey + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 60.0 + configuration.timeoutIntervalForResource = 300.0 + self.session = URLSession(configuration: configuration) + } + + func checkAvailability() async -> Bool { + do { + _ = try await listModels() + return true + } catch { + return false + } + } + + func listModels() async throws -> [RequestyAPIModel] { + guard let url = URL(string: "\(baseURL)/models") else { + throw LLMError.configurationError("Invalid base URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + addHeaders(&request) + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw LLMError.apiError("Invalid response type") + } + + guard httpResponse.statusCode == 200 else { + throw LLMError.apiError("HTTP \(httpResponse.statusCode)") + } + + let modelsResponse = try JSONDecoder().decode(RequestyModelsResponse.self, from: data) + return modelsResponse.data + } + + func generateChatCompletion( + modelName: String, + messages: [LLMMessage], + options: LLMOptions + ) async throws -> String { + guard let url = URL(string: "\(baseURL)/chat/completions") else { + throw LLMError.configurationError("Invalid base URL") + } + + let requestBody = RequestyChatRequest( + model: modelName, + messages: messages.map { RequestyMessage(role: $0.role.rawValue, content: $0.content) }, + temperature: options.temperature, + maxTokens: options.maxTokens, + topP: options.topP, + stop: options.stopSequences + ) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + addHeaders(&request) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + request.httpBody = try encoder.encode(requestBody) + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw LLMError.apiError("Invalid response type") + } + + guard httpResponse.statusCode == 200 else { + if let errorData = try? JSONDecoder().decode(RequestyErrorResponse.self, from: data) { + throw LLMError.apiError(errorData.error.message) + } + throw LLMError.apiError("HTTP \(httpResponse.statusCode)") + } + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let chatResponse = try decoder.decode(RequestyChatResponse.self, from: data) + + guard let choice = chatResponse.choices.first else { + throw LLMError.invalidResponse + } + + let content = choice.message.content + guard !content.isEmpty else { + throw LLMError.invalidResponse + } + + return content + } + + private func addHeaders(_ request: inout URLRequest) { + if let apiKey = apiKey { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + request.setValue("Recap/1.0", forHTTPHeaderField: "HTTP-Referer") + request.setValue("Recap iOS App", forHTTPHeaderField: "X-Title") + } +} + +struct RequestyModelsResponse: Codable { + let data: [RequestyAPIModel] +} + +// Requesty's /v1/models is OpenAI-shaped but exposes capability metadata via +// `context_window` and boolean `supports_*` fields (NOT context_length / +// supported_parameters). Pricing may be absent. All fields are decoded +// defensively as optionals so missing metadata degrades gracefully. +struct RequestyAPIModel: Codable { + let id: String + let name: String? + let description: String? + let pricing: RequestyPricing? + let contextWindow: Int? + let maxOutputTokens: Int? + let supportsToolCalling: Bool? + let supportsReasoning: Bool? + let supportsVision: Bool? + + private enum CodingKeys: String, CodingKey { + case id + case name + case description + case pricing + case contextWindow = "context_window" + case maxOutputTokens = "max_output_tokens" + case supportsToolCalling = "supports_tool_calling" + case supportsReasoning = "supports_reasoning" + case supportsVision = "supports_vision" + } +} + +struct RequestyPricing: Codable { + let prompt: String? + let completion: String? +} + +struct RequestyChatRequest: Codable { + let model: String + let messages: [RequestyMessage] + let temperature: Double? + let maxTokens: Int? + let topP: Double? + let stop: [String]? + + private enum CodingKeys: String, CodingKey { + case model + case messages + case temperature + case maxTokens = "max_tokens" + case topP = "top_p" + case stop + } +} + +struct RequestyMessage: Codable { + let role: String + let content: String +} + +struct RequestyChatResponse: Codable { + let choices: [RequestyChoice] + let usage: RequestyUsage? +} + +struct RequestyChoice: Codable { + let message: RequestyMessage + let finishReason: String? + + private enum CodingKeys: String, CodingKey { + case message + case finishReason = "finish_reason" + } +} + +struct RequestyUsage: Codable { + let promptTokens: Int? + let completionTokens: Int? + let totalTokens: Int? + + private enum CodingKeys: String, CodingKey { + case promptTokens = "prompt_tokens" + case completionTokens = "completion_tokens" + case totalTokens = "total_tokens" + } +} + +struct RequestyErrorResponse: Codable { + let error: RequestyError +} + +struct RequestyError: Codable { + let message: String + let type: String? + let code: String? +} diff --git a/Recap/Services/LLM/Providers/Requesty/RequestyModel.swift b/Recap/Services/LLM/Providers/Requesty/RequestyModel.swift new file mode 100644 index 0000000..cb3ae01 --- /dev/null +++ b/Recap/Services/LLM/Providers/Requesty/RequestyModel.swift @@ -0,0 +1,27 @@ +import Foundation + +struct RequestyModel: LLMModelType { + let id: String + let name: String + let provider: String = "requesty" + let contextLength: Int32? + let maxCompletionTokens: Int32? + + init(apiModelId: String, displayName: String, contextLength: Int?, maxCompletionTokens: Int?) { + self.id = "requesty-\(apiModelId)" + self.name = apiModelId + self.contextLength = contextLength.map(Int32.init) + self.maxCompletionTokens = maxCompletionTokens.map(Int32.init) + } +} + +extension RequestyModel { + init(from apiModel: RequestyAPIModel) { + self.init( + apiModelId: apiModel.id, + displayName: apiModel.name ?? apiModel.id, + contextLength: apiModel.contextWindow, + maxCompletionTokens: apiModel.maxOutputTokens + ) + } +} diff --git a/Recap/Services/LLM/Providers/Requesty/RequestyProvider.swift b/Recap/Services/LLM/Providers/Requesty/RequestyProvider.swift new file mode 100644 index 0000000..eb4a99d --- /dev/null +++ b/Recap/Services/LLM/Providers/Requesty/RequestyProvider.swift @@ -0,0 +1,84 @@ +import Foundation +import Combine + +@MainActor +final class RequestyProvider: LLMProviderType, LLMTaskManageable { + typealias Model = RequestyModel + + let name = "Requesty" + + var isAvailable: Bool { + availabilityHelper.isAvailable + } + + var availabilityPublisher: AnyPublisher { + availabilityHelper.availabilityPublisher + } + + var currentTask: Task? + + private let apiClient: RequestyAPIClient + private let availabilityHelper: AvailabilityHelper + + init(apiKey: String? = nil) { + let resolvedApiKey = apiKey ?? ProcessInfo.processInfo.environment["REQUESTY_API_KEY"] + self.apiClient = RequestyAPIClient(apiKey: resolvedApiKey) + self.availabilityHelper = AvailabilityHelper( + checkInterval: 60.0, + availabilityCheck: { [weak apiClient] in + await apiClient?.checkAvailability() ?? false + } + ) + availabilityHelper.startMonitoring() + } + + deinit { + Task { [weak self] in + await self?.cancelCurrentTask() + } + } + + func checkAvailability() async -> Bool { + await availabilityHelper.checkAvailabilityNow() + } + + func listModels() async throws -> [RequestyModel] { + guard isAvailable else { + throw LLMError.providerNotAvailable + } + + return try await executeWithTaskManagement { + let apiModels = try await self.apiClient.listModels() + return apiModels.map { RequestyModel.init(from: $0) } + } + } + + func generateChatCompletion( + modelName: String, + messages: [LLMMessage], + options: LLMOptions + ) async throws -> String { + try validateProviderAvailable() + try validateMessages(messages) + + return try await executeWithTaskManagement { + try await self.apiClient.generateChatCompletion( + modelName: modelName, + messages: messages, + options: options + ) + } + } + + private func validateProviderAvailable() throws { + guard isAvailable else { + throw LLMError.providerNotAvailable + } + } + + private func validateMessages(_ messages: [LLMMessage]) throws { + guard !messages.isEmpty else { + throw LLMError.invalidPrompt + } + } +} diff --git a/Recap/Services/Utilities/Warnings/ProviderWarningCoordinator.swift b/Recap/Services/Utilities/Warnings/ProviderWarningCoordinator.swift index 34e5d69..936ccbf 100644 --- a/Recap/Services/Utilities/Warnings/ProviderWarningCoordinator.swift +++ b/Recap/Services/Utilities/Warnings/ProviderWarningCoordinator.swift @@ -8,6 +8,7 @@ final class ProviderWarningCoordinator { private let ollamaWarningId = "ollama_connectivity" private let openRouterWarningId = "openrouter_connectivity" + private let requestyWarningId = "requesty_connectivity" init(warningManager: any WarningManagerType, llmService: LLMServiceType) { self.warningManager = warningManager @@ -24,7 +25,8 @@ final class ProviderWarningCoordinator { @MainActor private func setupProviderMonitoring() { guard let ollamaProvider = llmService.availableProviders.first(where: { $0.name == "Ollama" }), - let openRouterProvider = llmService.availableProviders.first(where: { $0.name == "OpenRouter" }) else { + let openRouterProvider = llmService.availableProviders.first(where: { $0.name == "OpenRouter" }), + let requestyProvider = llmService.availableProviders.first(where: { $0.name == "Requesty" }) else { Task { try? await Task.sleep(nanoseconds: 1_000_000_000) setupProviderMonitoring() @@ -32,15 +34,17 @@ final class ProviderWarningCoordinator { return } - Publishers.CombineLatest( + Publishers.CombineLatest3( ollamaProvider.availabilityPublisher, - openRouterProvider.availabilityPublisher + openRouterProvider.availabilityPublisher, + requestyProvider.availabilityPublisher ) - .sink { [weak self] ollamaAvailable, openRouterAvailable in + .sink { [weak self] ollamaAvailable, openRouterAvailable, requestyAvailable in Task { @MainActor in await self?.updateProviderWarnings( ollamaAvailable: ollamaAvailable, - openRouterAvailable: openRouterAvailable + openRouterAvailable: openRouterAvailable, + requestyAvailable: requestyAvailable ) } } @@ -48,7 +52,11 @@ final class ProviderWarningCoordinator { } @MainActor - private func updateProviderWarnings(ollamaAvailable: Bool, openRouterAvailable: Bool) async { + private func updateProviderWarnings( + ollamaAvailable: Bool, + openRouterAvailable: Bool, + requestyAvailable: Bool + ) async { do { let preferences = try await llmService.getUserPreferences() let selectedProvider = preferences.selectedProvider @@ -57,14 +65,22 @@ final class ProviderWarningCoordinator { case .ollama: handleOllamaWarning(isAvailable: ollamaAvailable) warningManager.removeWarning(withId: openRouterWarningId) + warningManager.removeWarning(withId: requestyWarningId) case .openRouter: handleOpenRouterWarning(isAvailable: openRouterAvailable) warningManager.removeWarning(withId: ollamaWarningId) + warningManager.removeWarning(withId: requestyWarningId) + + case .requesty: + handleRequestyWarning(isAvailable: requestyAvailable) + warningManager.removeWarning(withId: ollamaWarningId) + warningManager.removeWarning(withId: openRouterWarningId) } } catch { warningManager.removeWarning(withId: ollamaWarningId) warningManager.removeWarning(withId: openRouterWarningId) + warningManager.removeWarning(withId: requestyWarningId) } } @@ -99,4 +115,20 @@ final class ProviderWarningCoordinator { warningManager.updateWarning(warning) } } + + @MainActor + private func handleRequestyWarning(isAvailable: Bool) { + if isAvailable { + warningManager.removeWarning(withId: requestyWarningId) + } else { + let warning = WarningItem( + id: requestyWarningId, + title: "Requesty Unavailable", + message: "Cannot connect to Requesty. Check your internet connection and API key.", + icon: "network.slash", + severity: .warning + ) + warningManager.updateWarning(warning) + } + } } diff --git a/Recap/UseCases/Settings/Components/RequestyAPIKeyAlert.swift b/Recap/UseCases/Settings/Components/RequestyAPIKeyAlert.swift new file mode 100644 index 0000000..d100c59 --- /dev/null +++ b/Recap/UseCases/Settings/Components/RequestyAPIKeyAlert.swift @@ -0,0 +1,146 @@ +import SwiftUI + +struct RequestyAPIKeyAlert: View { + @Binding var isPresented: Bool + @State private var apiKey: String = "" + @State private var isLoading: Bool = false + @State private var errorMessage: String? + + let existingKey: String? + let onSave: (String) async throws -> Void + + private var isUpdateMode: Bool { + existingKey != nil + } + + private var title: String { + isUpdateMode ? "Update Requesty API Key" : "Add Requesty API Key" + } + + private var buttonTitle: String { + isUpdateMode ? "Update Key" : "Save Key" + } + + var body: some View { + CenteredAlert( + isPresented: $isPresented, + title: title, + onDismiss: {} + ) { + VStack(alignment: .leading, spacing: 20) { + inputSection + + if let errorMessage = errorMessage { + errorSection(errorMessage) + } + + HStack { + Spacer() + + PillButton( + text: isLoading ? "Saving..." : buttonTitle, + icon: isLoading ? nil : "checkmark" + ) { + Task { + await saveAPIKey() + } + } + } + } + } + .onAppear { + if let existingKey = existingKey { + apiKey = existingKey + } + } + } + + private var inputSection: some View { + VStack(alignment: .leading, spacing: 12) { + CustomPasswordField( + label: "API Key", + placeholder: "sk-...", + text: $apiKey + ) + + HStack { + Text("Your API key is stored securely in the system keychain and never leaves your device. Get a key at app.requesty.ai/api-keys.") + .font(.system(size: 11, weight: .regular)) + .foregroundColor(UIConstants.Colors.textSecondary) + .multilineTextAlignment(.leading) + .lineLimit(2) + Spacer() + } + } + } + + private func errorSection(_ message: String) -> some View { + HStack { + Text(message) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.red) + .multilineTextAlignment(.leading) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.red.opacity(0.1)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.red.opacity(0.3), lineWidth: 0.5) + ) + ) + } + + + private func saveAPIKey() async { + let trimmedKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmedKey.isEmpty else { + errorMessage = "Please enter an API key" + return + } + + guard trimmedKey.hasPrefix("sk-") else { + errorMessage = "Invalid Requesty API key format. Key should start with 'sk-'" + return + } + + isLoading = true + errorMessage = nil + + do { + try await onSave(trimmedKey) + isPresented = false + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } +} + +#Preview { + VStack { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .overlay( + Text("Background Content") + .foregroundColor(.white) + ) + } + .frame(height: 400) + .overlay( + RequestyAPIKeyAlert( + isPresented: .constant(true), + existingKey: nil, + onSave: { key in + try await Task.sleep(nanoseconds: 1_000_000_000) + } + ) + .frame(height: 300) + ) + .background(Color.black) +} diff --git a/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift b/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift index f942887..eefadc8 100644 --- a/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift +++ b/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift @@ -136,17 +136,31 @@ struct GeneralSettingsView: View { .ignoresSafeArea() .transition(.opacity) - OpenRouterAPIKeyAlert( - isPresented: Binding( - get: { viewModel.showAPIKeyAlert }, - set: { _ in viewModel.dismissAPIKeyAlert() } - ), - existingKey: viewModel.existingAPIKey, - onSave: { apiKey in - try await viewModel.saveAPIKey(apiKey) - } - ) - .transition(.scale(scale: 0.8).combined(with: .opacity)) + if viewModel.pendingAPIKeyProvider == .requesty { + RequestyAPIKeyAlert( + isPresented: Binding( + get: { viewModel.showAPIKeyAlert }, + set: { _ in viewModel.dismissAPIKeyAlert() } + ), + existingKey: viewModel.existingAPIKey, + onSave: { apiKey in + try await viewModel.saveAPIKey(apiKey) + } + ) + .transition(.scale(scale: 0.8).combined(with: .opacity)) + } else { + OpenRouterAPIKeyAlert( + isPresented: Binding( + get: { viewModel.showAPIKeyAlert }, + set: { _ in viewModel.dismissAPIKeyAlert() } + ), + existingKey: viewModel.existingAPIKey, + onSave: { apiKey in + try await viewModel.saveAPIKey(apiKey) + } + ) + .transition(.scale(scale: 0.8).combined(with: .opacity)) + } } } } @@ -200,6 +214,7 @@ private final class PreviewGeneralSettingsViewModel: GeneralSettingsViewModelTyp @Published var toastMessage = "" @Published var showAPIKeyAlert = false @Published var existingAPIKey: String? + @Published var pendingAPIKeyProvider: LLMProvider? @Published var activeWarnings: [WarningItem] = [ WarningItem( id: "ollama", diff --git a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift index 8211017..ae13d54 100644 --- a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift +++ b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift @@ -29,6 +29,7 @@ final class GeneralSettingsViewModel: GeneralSettingsViewModelType { @Published private(set) var activeWarnings: [WarningItem] = [] @Published private(set) var showAPIKeyAlert = false @Published private(set) var existingAPIKey: String? + @Published private(set) var pendingAPIKeyProvider: LLMProvider? var hasModels: Bool { !availableModels.isEmpty @@ -129,6 +130,22 @@ final class GeneralSettingsViewModel: GeneralSettingsViewModelType { } catch { existingAPIKey = nil } + pendingAPIKeyProvider = .openRouter + showAPIKeyAlert = true + return + } + } + + if provider == .requesty { + let validation = keychainAPIValidator.validateRequestyAPI() + + if !validation.isValid { + do { + existingAPIKey = try keychainService.retrieveRequestyAPIKey() + } catch { + existingAPIKey = nil + } + pendingAPIKeyProvider = .requesty showAPIKeyAlert = true return } @@ -205,16 +222,25 @@ final class GeneralSettingsViewModel: GeneralSettingsViewModelType { } func saveAPIKey(_ apiKey: String) async throws { - try keychainService.storeOpenRouterAPIKey(apiKey) + let provider = pendingAPIKeyProvider ?? .openRouter + + switch provider { + case .requesty: + try keychainService.storeRequestyAPIKey(apiKey) + case .openRouter, .ollama: + try keychainService.storeOpenRouterAPIKey(apiKey) + } existingAPIKey = apiKey showAPIKeyAlert = false + pendingAPIKeyProvider = nil - await selectProvider(.openRouter) + await selectProvider(provider) } func dismissAPIKeyAlert() { showAPIKeyAlert = false existingAPIKey = nil + pendingAPIKeyProvider = nil } } diff --git a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift index cfa3dc8..6e7440d 100644 --- a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift +++ b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift @@ -19,6 +19,7 @@ protocol GeneralSettingsViewModelType: ObservableObject { var customPromptTemplate: Binding { get } var showAPIKeyAlert: Bool { get } var existingAPIKey: String? { get } + var pendingAPIKeyProvider: LLMProvider? { get } func loadModels() async func selectModel(_ model: LLMModelInfo) async