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