Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -104,6 +105,20 @@ Right now, Recap is more of a POC of what I am trying to make. It records system

</details>

<details>
<summary>Requesty (Cloud Processing)</summary>

| 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).

</details>


> **Note**: Intel Macs are not supported. Use it at your own risk.

Expand Down Expand Up @@ -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:**

Expand Down
3 changes: 3 additions & 0 deletions Recap/Repositories/Models/LLMProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Foundation
enum LLMProvider: String, CaseIterable, Identifiable {
case ollama = "ollama"
case openRouter = "openrouter"
case requesty = "requesty"

var id: String { rawValue }

Expand All @@ -12,6 +13,8 @@ enum LLMProvider: String, CaseIterable, Identifiable {
return "Ollama"
case .openRouter:
return "OpenRouter"
case .requesty:
return "Requesty"
}
}

Expand Down
22 changes: 22 additions & 0 deletions Recap/Services/Keychain/KeychainAPIValidator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
1 change: 1 addition & 0 deletions Recap/Services/Keychain/KeychainAPIValidatorType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Mockable
#endif
protocol KeychainAPIValidatorType {
func validateOpenRouterAPI() -> APIValidationResult
func validateRequestyAPI() -> APIValidationResult
}

enum APIValidationResult {
Expand Down
16 changes: 16 additions & 0 deletions Recap/Services/Keychain/KeychainService+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
1 change: 1 addition & 0 deletions Recap/Services/Keychain/KeychainServiceType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
12 changes: 7 additions & 5 deletions Recap/Services/LLM/LLMService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
210 changes: 210 additions & 0 deletions Recap/Services/LLM/Providers/Requesty/RequestyAPIClient.swift
Original file line number Diff line number Diff line change
@@ -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?
}
27 changes: 27 additions & 0 deletions Recap/Services/LLM/Providers/Requesty/RequestyModel.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
Loading