diff --git a/.gitignore b/.gitignore index 163d5e2..4dc106a 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ DerivedData/ *.dSYM.zip *.dSYM +## Code coverage +*.profraw + ## Playgrounds timeline.xctimeline playground.xcworkspace diff --git a/Package.swift b/Package.swift deleted file mode 100644 index 977bb97..0000000 --- a/Package.swift +++ /dev/null @@ -1,26 +0,0 @@ -// swift-tools-version: 6.2 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "oAI", - products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. - .library( - name: "oAI", - targets: ["oAI"] - ), - ], - targets: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. - .target( - name: "oAI" - ), - .testTarget( - name: "oAITests", - dependencies: ["oAI"] - ), - ] -) diff --git a/Sources/oAI/oAI.swift b/Sources/oAI/oAI.swift deleted file mode 100644 index 08b22b8..0000000 --- a/Sources/oAI/oAI.swift +++ /dev/null @@ -1,2 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book diff --git a/Tests/oAITests/oAITests.swift b/Tests/oAITests/oAITests.swift deleted file mode 100644 index f3c6191..0000000 --- a/Tests/oAITests/oAITests.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Testing -@testable import oAI - -@Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. -} diff --git a/oAI.xcodeproj/project.pbxproj b/oAI.xcodeproj/project.pbxproj index 10b326d..f6df3a3 100644 --- a/oAI.xcodeproj/project.pbxproj +++ b/oAI.xcodeproj/project.pbxproj @@ -11,8 +11,19 @@ A550A8342F3C5C9300136F2B /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = A550A6812F3B730000136F2B /* GRDB */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + A586FF5530122589002CFF95 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A550A65A2F3B72EA00136F2B /* Project object */; + proxyType = 1; + remoteGlobalIDString = A550A6612F3B72EA00136F2B; + remoteInfo = oAI; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ A550A6622F3B72EA00136F2B /* oAI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = oAI.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A586FF5130122589002CFF95 /* oAITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = oAITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -34,6 +45,11 @@ path = oAI; sourceTree = ""; }; + A586FF5230122589002CFF95 /* oAITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = oAITests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -46,6 +62,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A586FF4E30122589002CFF95 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -53,6 +76,7 @@ isa = PBXGroup; children = ( A550A6642F3B72EA00136F2B /* oAI */, + A586FF5230122589002CFF95 /* oAITests */, A550A6632F3B72EA00136F2B /* Products */, ); sourceTree = ""; @@ -61,6 +85,7 @@ isa = PBXGroup; children = ( A550A6622F3B72EA00136F2B /* oAI.app */, + A586FF5130122589002CFF95 /* oAITests.xctest */, ); name = Products; sourceTree = ""; @@ -92,6 +117,29 @@ productReference = A550A6622F3B72EA00136F2B /* oAI.app */; productType = "com.apple.product-type.application"; }; + A586FF5030122589002CFF95 /* oAITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A586FF5930122589002CFF95 /* Build configuration list for PBXNativeTarget "oAITests" */; + buildPhases = ( + A586FF4D30122589002CFF95 /* Sources */, + A586FF4E30122589002CFF95 /* Frameworks */, + A586FF4F30122589002CFF95 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + A586FF5630122589002CFF95 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A586FF5230122589002CFF95 /* oAITests */, + ); + name = oAITests; + packageProductDependencies = ( + ); + productName = oAITests; + productReference = A586FF5130122589002CFF95 /* oAITests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -99,12 +147,16 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2620; - LastUpgradeCheck = 2620; + LastSwiftUpdateCheck = 2700; + LastUpgradeCheck = 2700; TargetAttributes = { A550A6612F3B72EA00136F2B = { CreatedOnToolsVersion = 26.2; }; + A586FF5030122589002CFF95 = { + CreatedOnToolsVersion = 27.0; + TestTargetID = A550A6612F3B72EA00136F2B; + }; }; }; buildConfigurationList = A550A65D2F3B72EA00136F2B /* Build configuration list for PBXProject "oAI" */; @@ -131,6 +183,7 @@ projectRoot = ""; targets = ( A550A6612F3B72EA00136F2B /* oAI */, + A586FF5030122589002CFF95 /* oAITests */, ); }; /* End PBXProject section */ @@ -143,6 +196,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A586FF4F30122589002CFF95 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -153,14 +213,30 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A586FF4D30122589002CFF95 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + A586FF5630122589002CFF95 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A550A6612F3B72EA00136F2B /* oAI */; + targetProxy = A586FF5530122589002CFF95 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ A550A66B2F3B72EC00136F2B /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -190,7 +266,9 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 6RJQ2QZYPG; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -212,6 +290,7 @@ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -222,6 +301,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -251,7 +331,9 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 6RJQ2QZYPG; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -266,6 +348,7 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; }; name = Release; @@ -278,12 +361,12 @@ CODE_SIGN_ENTITLEMENTS = oAI/oAI.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 6RJQ2QZYPG; + DEAD_CODE_STRIPPING = YES; ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = "oAI/Info.plist"; + INFOPLIST_FILE = oAI/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "oAI can read and create calendar events when you ask it to, if you enable Calendar access in Settings."; INFOPLIST_KEY_NSContactsUsageDescription = "oAI can search your contacts when you ask it to, if you enable Contacts access in Settings."; @@ -328,12 +411,12 @@ CODE_SIGN_ENTITLEMENTS = oAI/oAI.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 6RJQ2QZYPG; + DEAD_CODE_STRIPPING = YES; ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = "oAI/Info.plist"; + INFOPLIST_FILE = oAI/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "oAI can read and create calendar events when you ask it to, if you enable Calendar access in Settings."; INFOPLIST_KEY_NSContactsUsageDescription = "oAI can search your contacts when you ask it to, if you enable Contacts access in Settings."; @@ -370,6 +453,50 @@ }; name = Release; }; + A586FF5730122589002CFF95 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 27.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/oAI.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/oAI"; + }; + name = Debug; + }; + A586FF5830122589002CFF95 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 27.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/oAI.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/oAI"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -391,6 +518,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A586FF5930122589002CFF95 /* Build configuration list for PBXNativeTarget "oAITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A586FF5730122589002CFF95 /* Debug */, + A586FF5830122589002CFF95 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/oAI/Models/Settings.swift b/oAI/Models/Settings.swift index b4cfd40..effe640 100644 --- a/oAI/Models/Settings.swift +++ b/oAI/Models/Settings.swift @@ -56,17 +56,22 @@ struct Settings: Codable { case anthropic case openai case ollama - + case appleOnDevice = "apple_on_device" + var displayName: String { - rawValue.capitalized + switch self { + case .appleOnDevice: return "Apple Intelligence" + default: return rawValue.capitalized + } } - + var iconName: String { switch self { case .openrouter: return "network" case .anthropic: return "brain" case .openai: return "sparkles" case .ollama: return "server.rack" + case .appleOnDevice: return "apple.logo" } } } diff --git a/oAI/Providers/AnthropicProvider.swift b/oAI/Providers/AnthropicProvider.swift index 1f9ea94..34cb0fd 100644 --- a/oAI/Providers/AnthropicProvider.swift +++ b/oAI/Providers/AnthropicProvider.swift @@ -186,6 +186,21 @@ class AnthropicProvider: AIProvider { ("claude-haiku", 0.80, 4.0), ] + /// Fuzzy pricing lookup for a model ID not found in `knownModels`: finds the + /// longest matching prefix in `fallback` (longest match wins when several + /// prefixes match, e.g. a future "claude-sonnet-mini" against both + /// "claude-sonnet" and a shorter unrelated prefix). Unmatched IDs price at zero. + static func resolveFallbackPricing( + for modelId: String, + fallback: [(prefix: String, prompt: Double, completion: Double)] = pricingFallback + ) -> ModelInfo.Pricing { + let match = fallback + .filter { modelId.hasPrefix($0.prefix) } + .max(by: { $0.prefix.count < $1.prefix.count }) + return match.map { ModelInfo.Pricing(prompt: $0.prompt, completion: $0.completion) } + ?? ModelInfo.Pricing(prompt: 0, completion: 0) + } + /// Fetch live model list from GET /v1/models, enriched with local pricing/context metadata. /// Falls back to knownModels if the request fails (no key, offline, etc.). func listModels() async throws -> [ModelInfo] { @@ -223,11 +238,7 @@ class AnthropicProvider: AIProvider { // Exact match first if let known = enrichment[id] { return known } // Fuzzy fallback: find the longest prefix that matches - let fallback = Self.pricingFallback - .filter { id.hasPrefix($0.prefix) } - .max(by: { $0.prefix.count < $1.prefix.count }) - let pricing = fallback.map { ModelInfo.Pricing(prompt: $0.prompt, completion: $0.completion) } - ?? ModelInfo.Pricing(prompt: 0, completion: 0) + let pricing = Self.resolveFallbackPricing(for: id) return ModelInfo( id: id, name: displayName, @@ -571,7 +582,7 @@ class AnthropicProvider: AIProvider { // MARK: - Request Building - private func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> (URLRequest, Data) { + func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> (URLRequest, Data) { let url = messagesURL // Separate system message @@ -685,7 +696,7 @@ class AnthropicProvider: AIProvider { return (urlRequest, bodyData) } - private func parseResponse(data: Data) throws -> ChatResponse { + func parseResponse(data: Data) throws -> ChatResponse { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw ProviderError.invalidResponse } @@ -741,7 +752,7 @@ class AnthropicProvider: AIProvider { ) } - private func convertParametersToDict(_ params: Tool.Function.Parameters) -> [String: Any] { + func convertParametersToDict(_ params: Tool.Function.Parameters) -> [String: Any] { var props: [String: Any] = [:] for (key, prop) in params.properties { var propDict: [String: Any] = [ diff --git a/oAI/Providers/AppleFoundationProvider.swift b/oAI/Providers/AppleFoundationProvider.swift new file mode 100644 index 0000000..9cdb041 --- /dev/null +++ b/oAI/Providers/AppleFoundationProvider.swift @@ -0,0 +1,228 @@ +// +// AppleFoundationProvider.swift +// oAI +// +// Apple Foundation Models provider (on-device Apple Intelligence) +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen +// +// This file is part of oAI. +// +// oAI is licensed under the PolyForm Noncommercial License 1.0.0. +// You may use, study, modify, and share it for any noncommercial +// purpose. Commercial use — including selling oAI or any part of +// it, standalone or bundled into another product or service — +// requires a separate commercial license from the copyright holder. +// +// See the LICENSE file or +// for +// the full license text. For commercial licensing, contact Rune +// Olsen via . + + +import Foundation +import FoundationModels +import os + +final class AppleFoundationProvider: AIProvider { + let name = "Apple Intelligence" + + let capabilities = ProviderCapabilities( + supportsStreaming: true, + supportsVision: false, + supportsTools: false, + supportsOnlineSearch: false, + maxContextLength: 4096 + ) + + // MARK: - Models + + func listModels() async throws -> [ModelInfo] { + [ + ModelInfo( + id: "apple-on-device", + name: "Apple On-Device (Beta)", + description: "On-device Apple Intelligence model. Private, free, and works offline. 4K context window. Apple's Foundation Models framework is still in active beta (currently macOS 27 beta) — expect occasional generation errors and rough edges.", + contextLength: 4096, + pricing: ModelInfo.Pricing(prompt: 0, completion: 0), + capabilities: ModelInfo.ModelCapabilities( + vision: false, + tools: false, + online: false + ) + ) + ] + } + + func getModel(_ id: String) async throws -> ModelInfo? { + try await listModels().first { $0.id == id } + } + + func getCredits() async throws -> Credits? { nil } + + // MARK: - Streaming chat + + func streamChat(request: ChatRequest) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + do { + let session = try self.makeSession(for: request) + let prompt = self.lastUserMessage(from: request) + + // streamResponse(to: String) → ResponseStream + // Each snapshot.content is the full accumulated text so far (snapshot model). + // We compute deltas by comparing each snapshot to the previous. + let stream = session.streamResponse(to: prompt) + var lastContent = "" + + for try await snapshot in stream { + let current = snapshot.content + if current.count > lastContent.count { + let delta = String(current.dropFirst(lastContent.count)) + continuation.yield(StreamChunk( + id: UUID().uuidString, + model: request.model, + delta: StreamChunk.Delta(content: delta, role: "assistant"), + finishReason: nil, + usage: nil + )) + lastContent = current + } + } + + continuation.yield(StreamChunk( + id: UUID().uuidString, + model: request.model, + delta: StreamChunk.Delta(content: nil, role: nil), + finishReason: "stop", + usage: nil + )) + continuation.finish() + + } catch { + continuation.finish(throwing: self.mapProviderError(error)) + } + } + } + } + + // MARK: - Non-streaming chat + + func chat(request: ChatRequest) async throws -> ChatResponse { + let session = try makeSession(for: request) + let prompt = lastUserMessage(from: request) + do { + let response: LanguageModelSession.Response = try await session.respond(to: prompt) + return ChatResponse( + id: UUID().uuidString, + model: request.model, + content: response.content, + role: "assistant", + finishReason: "stop", + usage: nil, + created: Date() + ) + } catch { + throw mapProviderError(error) + } + } + + // MARK: - Tool messages (not supported in Phase 1) + + func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse { + throw ProviderError.unknown("Tool calling requires Apple Foundation Models Phase 3.") + } + + // MARK: - Session construction + + private func makeSession(for request: ChatRequest) throws -> LanguageModelSession { + guard case .available = SystemLanguageModel.default.availability else { + throw availabilityError() + } + + // Build instructions: system prompt + prior conversation turns as formatted text. + // Foundation Models sessions don't accept a message array — we inject history inline. + var instructions = request.systemPrompt ?? "" + let priorMessages = request.messages.dropLast().filter { $0.role != .system } + + if !priorMessages.isEmpty { + let history = priorMessages + .map { m -> String in + let label = m.role == .user ? "User" : "Assistant" + return "\(label): \(m.content)" + } + .joined(separator: "\n") + instructions += "\n\nConversation so far:\n\(history)\n\nContinue from here." + } + + return instructions.isEmpty + ? LanguageModelSession() + : LanguageModelSession(instructions: instructions) + } + + private func lastUserMessage(from request: ChatRequest) -> String { + request.messages.last(where: { $0.role == .user })?.content ?? "" + } + + // MARK: - Error mapping + + private func availabilityError() -> Error { + switch SystemLanguageModel.default.availability { + case .unavailable(.deviceNotEligible): + return ProviderError.unknown("This Mac doesn't support Apple Intelligence. Apple Silicon is required.") + case .unavailable(.appleIntelligenceNotEnabled): + return ProviderError.unknown("Apple Intelligence is not enabled. Open System Settings → Apple Intelligence to turn it on.") + case .unavailable(.modelNotReady): + return ProviderError.unknown("Apple Intelligence model is still downloading. Please wait and try again.") + default: + return ProviderError.unknown("Apple Intelligence is not available on this device.") + } + } + + /// Dispatches to whichever generation-error type the running OS actually throws. + /// `LanguageModelSession.GenerationError` was deprecated in macOS 27.0 in favor of the new + /// top-level `LanguageModelError` — on a macOS 27+ runtime, generation failures come through + /// as `LanguageModelError`, not `GenerationError`, even though the app's deployment target + /// (26.2) still needs to handle macOS 26.x runtimes throwing the older type. + private func mapProviderError(_ error: Error) -> Error { + if #available(macOS 27.0, *), let lmError = error as? LanguageModelError { + return mapLanguageModelError(lmError) + } + if let genError = error as? LanguageModelSession.GenerationError { + return mapGenerationError(genError) + } + return error + } + + @available(macOS 27.0, *) + private func mapLanguageModelError(_ error: LanguageModelError) -> Error { + switch error { + case .contextSizeExceeded(let detail): + return ProviderError.unknown("Apple Intelligence context limit exceeded (\(detail.tokenCount)/\(detail.contextSize) tokens). Start a new chat or enable Progressive Summarization in Settings → Advanced.") + case .rateLimited: + return ProviderError.rateLimitExceeded + case .guardrailViolation: + return ProviderError.unknown("Apple Intelligence declined to respond to this message.") + case .timeout: + return ProviderError.timeout + default: + return error + } + } + + /// macOS 26.x fallback — `GenerationError` is deprecated on macOS 27+ but still the type + /// actually thrown on 26.x runtimes, which the 26.2 deployment target must keep supporting. + private func mapGenerationError(_ error: LanguageModelSession.GenerationError) -> Error { + switch error { + case .exceededContextWindowSize: + return ProviderError.unknown("Apple Intelligence context limit exceeded (4,096 tokens). Start a new chat or enable Progressive Summarization in Settings → Advanced.") + case .rateLimited: + return ProviderError.rateLimitExceeded + case .guardrailViolation: + return ProviderError.unknown("Apple Intelligence declined to respond to this message.") + default: + return error + } + } +} diff --git a/oAI/Providers/OllamaProvider.swift b/oAI/Providers/OllamaProvider.swift index 2a5bd19..8ebcab3 100644 --- a/oAI/Providers/OllamaProvider.swift +++ b/oAI/Providers/OllamaProvider.swift @@ -275,7 +275,7 @@ class OllamaProvider: AIProvider { // MARK: - Helpers - private func buildRequestBody(from request: ChatRequest, stream: Bool) -> [String: Any] { + func buildRequestBody(from request: ChatRequest, stream: Bool) -> [String: Any] { var messages: [[String: Any]] = [] // Add system prompt as a system message @@ -301,7 +301,7 @@ class OllamaProvider: AIProvider { return body } - private func parseOllamaResponse(_ json: [String: Any], model: String) -> ChatResponse { + func parseOllamaResponse(_ json: [String: Any], model: String) -> ChatResponse { let message = json["message"] as? [String: Any] let content = message?["content"] as? String ?? "" let promptTokens = json["prompt_eval_count"] as? Int ?? 0 diff --git a/oAI/Providers/OpenAIProvider.swift b/oAI/Providers/OpenAIProvider.swift index 037ef60..c99d647 100644 --- a/oAI/Providers/OpenAIProvider.swift +++ b/oAI/Providers/OpenAIProvider.swift @@ -117,7 +117,7 @@ class OpenAIProvider: AIProvider { } } - private func fallbackModels() -> [ModelInfo] { + func fallbackModels() -> [ModelInfo] { Self.knownModels.map { id, info in ModelInfo( id: id, @@ -283,7 +283,7 @@ class OpenAIProvider: AIProvider { // MARK: - Helpers - private func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> URLRequest { + func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> URLRequest { let url = URL(string: "\(baseURL)/chat/completions")! var apiMessages: [[String: Any]] = [] @@ -358,7 +358,7 @@ class OpenAIProvider: AIProvider { return urlRequest } - private func convertToChatResponse(_ apiResponse: OpenRouterChatResponse) -> ChatResponse { + func convertToChatResponse(_ apiResponse: OpenRouterChatResponse) -> ChatResponse { guard let choice = apiResponse.choices.first else { return ChatResponse(id: apiResponse.id, model: apiResponse.model, content: "", role: "assistant", finishReason: nil, usage: nil, created: Date()) } diff --git a/oAI/Providers/OpenRouterProvider.swift b/oAI/Providers/OpenRouterProvider.swift index 2e63b87..309e6a9 100644 --- a/oAI/Providers/OpenRouterProvider.swift +++ b/oAI/Providers/OpenRouterProvider.swift @@ -416,7 +416,7 @@ class OpenRouterProvider: AIProvider { // MARK: - Helper Methods - private func buildAPIRequest(from request: ChatRequest) throws -> OpenRouterChatRequest { + func buildAPIRequest(from request: ChatRequest) throws -> OpenRouterChatRequest { let apiMessages = request.messages.map { message -> OpenRouterChatRequest.APIMessage in let hasAttachments = message.attachments?.contains(where: { $0.data != nil }) ?? false @@ -500,7 +500,7 @@ class OpenRouterProvider: AIProvider { ) } - private func convertToChatResponse(_ apiResponse: OpenRouterChatResponse) throws -> ChatResponse { + func convertToChatResponse(_ apiResponse: OpenRouterChatResponse) throws -> ChatResponse { guard let choice = apiResponse.choices.first else { throw ProviderError.invalidResponse } @@ -540,7 +540,7 @@ class OpenRouterProvider: AIProvider { ) } - private func convertToStreamChunk(_ apiChunk: OpenRouterStreamChunk) throws -> StreamChunk { + func convertToStreamChunk(_ apiChunk: OpenRouterStreamChunk) throws -> StreamChunk { guard let choice = apiChunk.choices.first else { throw ProviderError.invalidResponse } @@ -579,7 +579,7 @@ class OpenRouterProvider: AIProvider { } /// Decode base64 data URL images from API response - private func decodeImageOutputs(_ outputs: [OpenRouterChatResponse.ImageOutput]) -> [Data]? { + func decodeImageOutputs(_ outputs: [OpenRouterChatResponse.ImageOutput]) -> [Data]? { let decoded = outputs.compactMap { output -> Data? in let url = output.imageUrl.url // Strip "data:image/...;base64," prefix diff --git a/oAI/Providers/ProviderRegistry.swift b/oAI/Providers/ProviderRegistry.swift index cf6c5ee..e5d9a2a 100644 --- a/oAI/Providers/ProviderRegistry.swift +++ b/oAI/Providers/ProviderRegistry.swift @@ -67,6 +67,9 @@ class ProviderRegistry { case .ollama: provider = OllamaProvider(baseURL: settings.ollamaEffectiveURL) + + case .appleOnDevice: + provider = AppleFoundationProvider() } // Cache and return @@ -104,6 +107,8 @@ class ProviderRegistry { return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty case .ollama: return settings.ollamaConfigured + case .appleOnDevice: + return true // no API key needed } } diff --git a/oAI/Services/BackupService.swift b/oAI/Services/BackupService.swift index e65b89a..534f4b1 100644 --- a/oAI/Services/BackupService.swift +++ b/oAI/Services/BackupService.swift @@ -206,37 +206,68 @@ final class BackupService { log.debug("Pushed \(payload.ids.count) favorite model(s) to iCloud") } + /// Outcome of reconciling local favorites against the iCloud copy. Pure result type -- + /// see `decideFavoritesSync` for the actual decision logic. + enum FavoritesSyncDecision: Equatable { + case applyRemote(ids: Set, updatedAt: String) + case pushLocal + case mergeAndPush(ids: Set, updatedAt: String) + case noop + } + + /// Last-write-wins reconciliation, pulled out of `syncFavoritesOnLaunch` so the branching + /// logic (5 cases: remote absent / remote newer / local newer / tied-but-different / + /// tied-and-identical) can be tested without touching iCloud Drive or SettingsService. + static func decideFavoritesSync( + localIds: Set, + localUpdatedAt: String, + remote: FavoritesPayload?, + now: Date + ) -> FavoritesSyncDecision { + guard let remote else { + return .pushLocal + } + if remote.updatedAt > localUpdatedAt { + return .applyRemote(ids: Set(remote.ids), updatedAt: remote.updatedAt) + } else if localUpdatedAt > remote.updatedAt { + return .pushLocal + } else if Set(remote.ids) != localIds { + // Tied timestamps (both empty is the common case: two machines that already had + // favorites before this sync feature existed, neither ever bumped the timestamp). + // Union rather than silently dropping one side's favorites. + let merged = localIds.union(remote.ids) + return .mergeAndPush(ids: merged, updatedAt: ISO8601DateFormatter().string(from: now)) + } + return .noop + } + /// Reconcile local favorites with the iCloud copy using last-write-wins (by timestamp). /// Call on launch (and optionally on app-become-active) to pick up changes from other machines. func syncFavoritesOnLaunch() async { let settings = SettingsService.shared let fileURL = favoritesFileURL() - guard let data = try? Data(contentsOf: fileURL), - let remote = try? JSONDecoder().decode(FavoritesPayload.self, from: data) else { - // No remote copy yet — push local state (may be empty, that's fine). - await pushFavorites() - return - } - - let localUpdatedAt = settings.favoriteModelsUpdatedAt + let remote: FavoritesPayload? = { + guard let data = try? Data(contentsOf: fileURL) else { return nil } + return try? JSONDecoder().decode(FavoritesPayload.self, from: data) + }() let localIds = settings.favoriteModelIds - if remote.updatedAt > localUpdatedAt { - settings.favoriteModelIds = Set(remote.ids) - settings.favoriteModelsUpdatedAt = remote.updatedAt - log.info("Applied \(remote.ids.count) favorite model(s) from iCloud (remote was newer)") - } else if localUpdatedAt > remote.updatedAt { + + switch Self.decideFavoritesSync(localIds: localIds, localUpdatedAt: settings.favoriteModelsUpdatedAt, remote: remote, now: Date()) { + case .pushLocal: await pushFavorites() - } else if Set(remote.ids) != localIds { - // Tied timestamps (both empty is the common case: two machines that already had - // favorites before this sync feature existed, neither ever bumped the timestamp). - // Union rather than silently dropping one side's favorites. - settings.favoriteModelIds = localIds.union(remote.ids) - settings.favoriteModelsUpdatedAt = ISO8601DateFormatter().string(from: Date()) + case .applyRemote(let ids, let updatedAt): + settings.favoriteModelIds = ids + settings.favoriteModelsUpdatedAt = updatedAt + log.info("Applied \(ids.count) favorite model(s) from iCloud (remote was newer)") + case .mergeAndPush(let ids, let updatedAt): + settings.favoriteModelIds = ids + settings.favoriteModelsUpdatedAt = updatedAt await pushFavorites() - log.info("Merged favorites from iCloud (tied timestamps) — union of \(localIds.count) local + \(remote.ids.count) remote") + log.info("Merged favorites from iCloud (tied timestamps) — union of \(localIds.count) local + \(remote?.ids.count ?? 0) remote") + case .noop: + break } - // Equal timestamps and identical sets: already in sync, nothing to do. } // MARK: - Automatic Backup @@ -252,17 +283,27 @@ final class BackupService { } } - func checkAndPerformAutoBackupIfDue() async { - let frequency = SettingsService.shared.autoBackupFrequency + /// Whether an automatic backup should run now, given the chosen frequency and the last + /// known backup time. Pulled out of `checkAndPerformAutoBackupIfDue` for testability -- + /// "manual" is never due, a missing last-backup-date is always due, otherwise it's due + /// once the elapsed time reaches the frequency's interval. + static func isBackupDue(frequency: String, lastBackupDate: Date?, now: Date) -> Bool { let interval: TimeInterval switch frequency { - case "daily": interval = Self.dailyInterval - case "weekly": interval = Self.weeklyInterval - default: return // "manual" — user triggers backups by hand + case "daily": interval = dailyInterval + case "weekly": interval = weeklyInterval + default: return false // "manual" — user triggers backups by hand } + guard let last = lastBackupDate else { return true } + return now.timeIntervalSince(last) >= interval + } + + func checkAndPerformAutoBackupIfDue() async { + let frequency = SettingsService.shared.autoBackupFrequency + guard frequency == "daily" || frequency == "weekly" else { return } checkForExistingBackup() - if let last = lastBackupDate, Date().timeIntervalSince(last) < interval { + guard Self.isBackupDue(frequency: frequency, lastBackupDate: lastBackupDate, now: Date()) else { return // not due yet } diff --git a/oAI/Services/ContextSelectionService.swift b/oAI/Services/ContextSelectionService.swift index c77d5ae..1311b85 100644 --- a/oAI/Services/ContextSelectionService.swift +++ b/oAI/Services/ContextSelectionService.swift @@ -47,7 +47,12 @@ enum SelectionStrategy { final class ContextSelectionService { static let shared = ContextSelectionService() - private init() {} + private let db: DatabaseService + + /// `db` defaults to the shared on-disk instance; tests inject an in-memory `DatabaseService`. + init(db: DatabaseService = .shared) { + self.db = db + } /// Select context messages using the specified strategy func selectContext( @@ -98,7 +103,7 @@ final class ContextSelectionService { // MARK: - Smart Selection Algorithm - private func smartSelection(allMessages: [Message], maxTokens: Int, conversationId: UUID? = nil) -> ContextWindow { + func smartSelection(allMessages: [Message], maxTokens: Int, conversationId: UUID? = nil) -> ContextWindow { guard !allMessages.isEmpty else { return ContextWindow(messages: [], summaries: [], totalTokens: 0, excludedCount: 0) } @@ -191,12 +196,12 @@ final class ContextSelectionService { } /// Get summaries for excluded message ranges - private func getSummariesForExcludedRange( + func getSummariesForExcludedRange( conversationId: UUID, totalMessages: Int, selectedCount: Int ) -> [String] { - guard let summaryRecords = try? DatabaseService.shared.getConversationSummaries(conversationId: conversationId) else { + guard let summaryRecords = try? db.getConversationSummaries(conversationId: conversationId) else { return [] } @@ -214,7 +219,7 @@ final class ContextSelectionService { // MARK: - Importance Scoring /// Calculate importance score (0.0 - 1.0) for a message - private func getImportanceScore(_ message: Message) -> Double { + func getImportanceScore(_ message: Message) -> Double { var score = 0.0 // Factor 1: Cost (expensive calls are important) @@ -238,8 +243,8 @@ final class ContextSelectionService { } /// Check if a message is starred by the user - private func isMessageStarred(_ message: Message) -> Bool { - guard let metadata = try? DatabaseService.shared.getMessageMetadata(messageId: message.id) else { + func isMessageStarred(_ message: Message) -> Bool { + guard let metadata = try? db.getMessageMetadata(messageId: message.id) else { return false } return metadata.user_starred == 1 @@ -248,7 +253,7 @@ final class ContextSelectionService { // MARK: - Token Estimation /// Estimate token count for messages (rough approximation) - private func estimateTokens(_ messages: [Message]) -> Int { + func estimateTokens(_ messages: [Message]) -> Int { var total = 0 for message in messages { if let tokens = message.tokens { diff --git a/oAI/Services/DatabaseService.swift b/oAI/Services/DatabaseService.swift index 9bf46c2..a56b195 100644 --- a/oAI/Services/DatabaseService.swift +++ b/oAI/Services/DatabaseService.swift @@ -154,7 +154,14 @@ final class DatabaseService: Sendable { (try? isoStyle.parse(string)) ?? (try? Date(string, strategy: .iso8601)) } - nonisolated private init() { + /// Test seam: build a DatabaseService around a caller-provided queue (e.g. an in-memory `DatabaseQueue()`). + /// Each test should create its own fresh instance — never mutate `.shared` from a test. + nonisolated init(dbQueue: DatabaseQueue) { + self.dbQueue = dbQueue + try! migrator.migrate(dbQueue) + } + + nonisolated private convenience init() { let fileManager = FileManager.default let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true) @@ -163,12 +170,25 @@ final class DatabaseService: Sendable { let dbPath = dbDirectory.appendingPathComponent("oai_conversations.db").path Log.db.info("Opening database at \(dbPath)") - dbQueue = try! DatabaseQueue(path: dbPath) - - try! migrator.migrate(dbQueue) + self.init(dbQueue: try! DatabaseQueue(path: dbPath)) } - private nonisolated var migrator: DatabaseMigrator { + /// Test seam: a throwaway in-memory instance with all migrations applied. Avoids requiring + /// `import GRDB` from the test target just to construct a `DatabaseQueue()`. + nonisolated static func makeInMemory() -> DatabaseService { + DatabaseService(dbQueue: try! DatabaseQueue()) + } + + /// Test seam: schema introspection, so migration tests don't need `import GRDB` either. + nonisolated func tableExists(_ name: String) -> Bool { + (try? dbQueue.read { db in try db.tableExists(name) }) ?? false + } + + nonisolated func columnNames(in table: String) -> [String] { + (try? dbQueue.read { db in try db.columns(in: table).map(\.name) }) ?? [] + } + + nonisolated var migrator: DatabaseMigrator { var migrator = DatabaseMigrator() migrator.registerMigration("v1") { db in diff --git a/oAI/Services/EmbeddingService.swift b/oAI/Services/EmbeddingService.swift index 2461c15..f096bc5 100644 --- a/oAI/Services/EmbeddingService.swift +++ b/oAI/Services/EmbeddingService.swift @@ -348,7 +348,7 @@ final class EmbeddingService { // MARK: - Serialization /// Serialize embedding to binary data (4 bytes per float, little-endian) - private func serializeEmbedding(_ embedding: [Float]) -> Data { + nonisolated func serializeEmbedding(_ embedding: [Float]) -> Data { var data = Data(capacity: embedding.count * 4) for value in embedding { var littleEndian = value.bitPattern.littleEndian @@ -360,7 +360,7 @@ final class EmbeddingService { } /// Deserialize embedding from binary data - private func deserializeEmbedding(_ data: Data) -> [Float] { + nonisolated func deserializeEmbedding(_ data: Data) -> [Float] { var embedding: [Float] = [] embedding.reserveCapacity(data.count / 4) diff --git a/oAI/Services/GitSyncService.swift b/oAI/Services/GitSyncService.swift index f087afb..b9827ae 100644 --- a/oAI/Services/GitSyncService.swift +++ b/oAI/Services/GitSyncService.swift @@ -45,7 +45,7 @@ class GitSyncService { func testConnection() async throws -> String { let url = try buildAuthenticatedURL() _ = try await runGit(["ls-remote", url]) - return "Connected to \(extractProvider())" + return "Connected to \(Self.extractProvider(from: settings.syncRepoURL))" } /// Clone repository to local path @@ -507,7 +507,7 @@ class GitSyncService { } } - private func detectSecretsInText(_ text: String) -> [String] { + func detectSecretsInText(_ text: String) -> [String] { let patterns: [(name: String, pattern: String)] = [ ("OpenAI Key", "sk-[a-zA-Z0-9]{32,}"), ("Anthropic Key", "sk-ant-[a-zA-Z0-9_-]+"), @@ -610,7 +610,7 @@ class GitSyncService { } } - private func convertToSSH(_ url: String) -> String { + func convertToSSH(_ url: String) -> String { // If already SSH format, return as-is if url.hasPrefix("git@") { return url @@ -642,7 +642,7 @@ class GitSyncService { return url } - private func injectCredentials(_ url: String, username: String, password: String) -> String { + func injectCredentials(_ url: String, username: String, password: String) -> String { // Convert https://github.com/user/repo.git // To: https://username:password@github.com/user/repo.git @@ -697,14 +697,13 @@ class GitSyncService { } } - private func sanitizeFilename(_ name: String) -> String { + func sanitizeFilename(_ name: String) -> String { // Remove invalid filename characters let invalid = CharacterSet(charactersIn: "/\\:*?\"<>|") return name.components(separatedBy: invalid).joined(separator: "-") } - private func extractProvider() -> String { - let url = settings.syncRepoURL + static func extractProvider(from url: String) -> String { if url.contains("github.com") { return "GitHub" } else if url.contains("gitlab.com") { diff --git a/oAI/Services/MCPService.swift b/oAI/Services/MCPService.swift index b4afc75..4a7ce74 100644 --- a/oAI/Services/MCPService.swift +++ b/oAI/Services/MCPService.swift @@ -23,6 +23,7 @@ import Foundation import os +import PDFKit @Observable class MCPService { @@ -155,7 +156,7 @@ class MCPService { var tools: [Tool] = [ makeTool( name: "read_file", - description: "Read the contents of a file. Returns the text content of the file. Maximum file size is 10MB.", + description: "Read the contents of a file. Returns the text content of the file. PDFs are supported (text layer is extracted automatically) — scanned/image-only PDFs with no text layer will return an error. Maximum file size is 10MB.", properties: [ "file_path": prop("string", "The absolute path to the file to read") ], @@ -172,7 +173,7 @@ class MCPService { ), makeTool( name: "search_files", - description: "Search for files by name pattern or content. Use 'pattern' for filename glob matching (e.g. '*.swift'). Use 'content_search' for searching inside file contents.", + description: "Search for files by name pattern or content. Use 'pattern' for filename glob matching (e.g. '*.swift'). Use 'content_search' for searching inside file contents (PDF text layers are searched too).", properties: [ "pattern": prop("string", "Glob pattern to match filenames (e.g. '*.py', 'README*')"), "search_path": prop("string", "Directory to search in (defaults to first allowed folder)"), @@ -536,8 +537,17 @@ class MCPService { return ["error": "File too large (\(sizeMB) MB, max 10 MB)"] } - guard let content = try? String(contentsOfFile: resolved, encoding: .utf8) else { - return ["error": "Cannot read file as UTF-8 text: \(filePath)"] + let content: String + if (resolved as NSString).pathExtension.lowercased() == "pdf" { + guard let extracted = extractPDFText(atPath: resolved) else { + return ["error": "Could not extract text from PDF (it may be scanned/image-only with no text layer, encrypted, or corrupted): \(filePath)"] + } + content = extracted + } else { + guard let text = try? String(contentsOfFile: resolved, encoding: .utf8) else { + return ["error": "Cannot read file as UTF-8 text: \(filePath)"] + } + content = text } var finalContent = content @@ -554,6 +564,19 @@ class MCPService { return ["content": finalContent, "path": resolved, "size": fileSize] } + /// Extracts plain text from a PDF's text layer, page by page. Returns nil for + /// scanned/image-only PDFs (no text layer), encrypted, or otherwise unreadable files. + func extractPDFText(atPath path: String) -> String? { + guard let document = PDFDocument(url: URL(fileURLWithPath: path)) else { return nil } + var text = "" + for i in 0.. [String: Any] { let resolved = ((dirPath as NSString).expandingTildeInPath as NSString).standardizingPath @@ -660,10 +683,13 @@ class MCPService { // Content search if requested if let searchText = contentSearch, !searchText.isEmpty { - guard let content = try? String(contentsOfFile: fullPath, encoding: .utf8) else { - continue + let content: String? + if (fullPath as NSString).pathExtension.lowercased() == "pdf" { + content = extractPDFText(atPath: fullPath) + } else { + content = try? String(contentsOfFile: fullPath, encoding: .utf8) } - if !content.localizedCaseInsensitiveContains(searchText) { + guard let content, content.localizedCaseInsensitiveContains(searchText) else { continue } } @@ -944,7 +970,7 @@ class MCPService { let readOnlyTools: [Tool] = [ makeTool( name: "read_file", - description: "Read the contents of a file. Maximum file size is 10MB.", + description: "Read the contents of a file. PDFs are supported (text layer extracted automatically). Maximum file size is 10MB.", properties: ["file_path": prop("string", "The absolute path to the file to read")], required: ["file_path"] ), diff --git a/oAI/Utilities/Extensions/Color+Extensions.swift b/oAI/Utilities/Extensions/Color+Extensions.swift index 95eec0a..e0579d7 100644 --- a/oAI/Utilities/Extensions/Color+Extensions.swift +++ b/oAI/Utilities/Extensions/Color+Extensions.swift @@ -62,6 +62,7 @@ extension Color { case .anthropic: return Color(hex: "#d4895a") // Orange case .openai: return Color(hex: "#10a37f") // Green case .ollama: return Color(hex: "#ffffff") // White + case .appleOnDevice: return Color(hex: "#636366") // Apple grey } } diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index 7b3f88e..5847a93 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -151,8 +151,14 @@ Don't narrate future actions ("Let me...") - just use the tools. /// Builds the complete system prompt by combining default + conditional sections + custom private var effectiveSystemPrompt: String { + // Tool guidance, the user's custom prompt, and Agent Skills all generally assume tool + // access and can easily blow past small context windows (e.g. Apple's on-device 4K + // limit) — skip all three for tool-incapable models. + let modelSupportsTools = selectedModel?.capabilities.tools ?? true + // Check if user wants to replace the default prompt entirely (BYOP mode) - if settings.customPromptMode == .replace, + if modelSupportsTools, + settings.customPromptMode == .replace, let customPrompt = settings.systemPrompt, !customPrompt.isEmpty { // BYOP: Use ONLY the custom prompt @@ -170,29 +176,32 @@ Don't narrate future actions ("Let me...") - just use the tools. } // Add tool-specific guidelines if MCP is enabled (tools are available) - if mcpEnabled { + if mcpEnabled && modelSupportsTools { prompt += toolUsageGuidelines } // Append custom prompt if in append mode and custom prompt exists - if settings.customPromptMode == .append, + if modelSupportsTools, + settings.customPromptMode == .append, let customPrompt = settings.systemPrompt, !customPrompt.isEmpty { prompt += "\n\n---\n\nAdditional Instructions:\n" + customPrompt } // Append active agent skills (SKILL.md-style behavioral instructions) - let activeSkills = settings.agentSkills.filter { $0.isActive } - if !activeSkills.isEmpty { - prompt += "\n\n---\n\n## Installed Skills\n\nThe following skills are active. Apply them when relevant:\n\n" - for skill in activeSkills { - prompt += "### \(skill.name)\n\n\(skill.content)\n\n" - let files = AgentSkillFilesService.shared.readTextFiles(for: skill.id) - if !files.isEmpty { - prompt += "**Skill Data Files:**\n\n" - for (name, content) in files { - let ext = URL(fileURLWithPath: name).pathExtension.lowercased() - prompt += "**\(name):**\n```\(ext)\n\(content)\n```\n\n" + if modelSupportsTools { + let activeSkills = settings.agentSkills.filter { $0.isActive } + if !activeSkills.isEmpty { + prompt += "\n\n---\n\n## Installed Skills\n\nThe following skills are active. Apply them when relevant:\n\n" + for skill in activeSkills { + prompt += "### \(skill.name)\n\n\(skill.content)\n\n" + let files = AgentSkillFilesService.shared.readTextFiles(for: skill.id) + if !files.isEmpty { + prompt += "**Skill Data Files:**\n\n" + for (name, content) in files { + let ext = URL(fileURLWithPath: name).pathExtension.lowercased() + prompt += "**\(name):**\n```\(ext)\n\(content)\n```\n\n" + } } } } @@ -413,15 +422,17 @@ Don't narrate future actions ("Let me...") - just use the tools. /// Update the selected model and keep currentProvider + settings in sync. /// Call this whenever the user picks a model in the model selector. func selectModel(_ model: ModelInfo) { - let newProvider = inferProvider(from: model.id) ?? currentProvider + let newProvider = Self.inferProvider(from: model.id) ?? currentProvider selectedModel = model currentProvider = newProvider MCPService.shared.resetBashSessionApproval() } - func inferProviderPublic(from modelId: String) -> Settings.Provider? { inferProvider(from: modelId) } + func inferProviderPublic(from modelId: String) -> Settings.Provider? { Self.inferProvider(from: modelId) } - private func inferProvider(from modelId: String) -> Settings.Provider? { + nonisolated static func inferProvider(from modelId: String) -> Settings.Provider? { + // Apple Foundation Models + if modelId.hasPrefix("apple-") { return .appleOnDevice } // OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet") if modelId.contains("/") { return .openrouter } // Anthropic direct (e.g. "claude-sonnet-4-5-20250929") @@ -437,7 +448,7 @@ Don't narrate future actions ("Let me...") - just use the tools. /// Shows a system message only on failure or on a successful switch. @MainActor private func switchToConversationModel(_ modelId: String) async { - guard let targetProvider = inferProvider(from: modelId) else { + guard let targetProvider = Self.inferProvider(from: modelId) else { showSystemMessage("⚠️ Could not determine provider for model '\(modelId)' — keeping current model") return } @@ -942,7 +953,7 @@ Don't narrate future actions ("Let me...") - just use the tools. messages[index].tokens = usage.completionTokens if let model = selectedModel { let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 - let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil + let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil messages[index].cost = cost sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost) } @@ -1006,7 +1017,7 @@ Don't narrate future actions ("Let me...") - just use the tools. messages[index].tokens = usage.completionTokens if let model = selectedModel { let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 - let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil + let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil messages[index].cost = cost sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost) } @@ -1306,7 +1317,7 @@ Don't narrate future actions ("Let me...") - just use the tools. sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost) } } - _ = detectGoodbyePhrase(in: "") + _ = Self.detectGoodbyePhrase(in: "") } catch { if let index = messages.firstIndex(where: { $0.id == messageId }) { messages[index].content = "❌ Image generation failed: \(error.localizedDescription)" @@ -1582,7 +1593,7 @@ Don't narrate future actions ("Let me...") - just use the tools. // Nothing worth showing yet — still record usage/cost for this turn. if let usage = totalUsage, let model = selectedModel { let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 - let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil + let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil sessionStats.addMessage( inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, @@ -1607,7 +1618,7 @@ Don't narrate future actions ("Let me...") - just use the tools. // Calculate cost if let usage = totalUsage, let model = selectedModel { let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 - let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil + let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) { messages[index].cost = cost } @@ -1888,40 +1899,43 @@ Don't narrate future actions ("Let me...") - just use the tools. } } + /// Pure auto-save eligibility check, pulled out of `shouldAutoSave()` so the criteria + /// (enabled, configured, cloned, min message count, not-already-saved) can be tested + /// without a live ChatViewModel/GitSyncService/SettingsService. + nonisolated static func shouldAutoSave( + syncEnabled: Bool, + syncAutoSave: Bool, + syncConfigured: Bool, + isCloned: Bool, + chatMessageCount: Int, + minMessages: Int, + lastSavedConversationId: String?, + currentConversationHash: String + ) -> Bool { + guard syncEnabled && syncAutoSave else { return false } + guard syncConfigured else { return false } + guard isCloned else { return false } + guard chatMessageCount >= minMessages else { return false } + if let lastSavedId = lastSavedConversationId, lastSavedId == currentConversationHash { + return false // Already saved this exact conversation + } + return true + } + /// Check if conversation should be auto-saved based on criteria func shouldAutoSave() -> Bool { - // Check if auto-save is enabled - guard settings.syncEnabled && settings.syncAutoSave else { - return false - } - - // Check if sync is configured - guard settings.syncConfigured else { - return false - } - - // Check if repository is cloned - guard GitSyncService.shared.syncStatus.isCloned else { - return false - } - - // Check minimum message count let chatMessages = messages.filter { $0.role == .user || $0.role == .assistant } - guard chatMessages.count >= settings.syncAutoSaveMinMessages else { - return false - } - - // Check if already auto-saved this conversation - // (prevent duplicate saves) - if let lastSavedId = settings.syncLastAutoSaveConversationId { - // Create a hash of current conversation to detect if it's the same one - let currentHash = chatMessages.map { $0.content }.joined() - if lastSavedId == currentHash { - return false // Already saved this exact conversation - } - } - - return true + let currentHash = chatMessages.map { $0.content }.joined() + return Self.shouldAutoSave( + syncEnabled: settings.syncEnabled, + syncAutoSave: settings.syncAutoSave, + syncConfigured: settings.syncConfigured, + isCloned: GitSyncService.shared.syncStatus.isCloned, + chatMessageCount: chatMessages.count, + minMessages: settings.syncAutoSaveMinMessages, + lastSavedConversationId: settings.syncLastAutoSaveConversationId, + currentConversationHash: currentHash + ) } /// Auto-save the current conversation with background summarization @@ -2038,7 +2052,7 @@ Don't narrate future actions ("Let me...") - just use the tools. } /// Detect goodbye phrases in user message - func detectGoodbyePhrase(in text: String) -> Bool { + nonisolated static func detectGoodbyePhrase(in text: String) -> Bool { let lowercased = text.lowercased() let goodbyePhrases = [ "bye", "goodbye", "bye bye", "good bye", @@ -2078,7 +2092,7 @@ Don't narrate future actions ("Let me...") - just use the tools. updateConversationTracking() // Check for goodbye phrase - if detectGoodbyePhrase(in: text) { + if Self.detectGoodbyePhrase(in: text) { Log.ui.info("Goodbye phrase detected - triggering auto-save") // Wait a bit to see if user continues try? await Task.sleep(for: .seconds(30)) @@ -2259,7 +2273,7 @@ Don't narrate future actions ("Let me...") - just use the tools. /// pricing when present: cache writes cost 1.25x the base input rate, cache /// reads cost 0.1x. `usage.promptTokens` is already the uncached remainder — /// it does not need cache tokens subtracted from it. - private func calculateCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double { + nonisolated static func calculateCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double { let inputCost = Double(usage.promptTokens) * pricing.prompt / 1_000_000 let cacheReadCost = Double(usage.cacheReadInputTokens ?? 0) * pricing.prompt * 0.1 / 1_000_000 let cacheWriteCost = Double(usage.cacheCreationInputTokens ?? 0) * pricing.prompt * 1.25 / 1_000_000 diff --git a/oAI/Views/Screens/CreditsView.swift b/oAI/Views/Screens/CreditsView.swift index 7008925..9d9e5ba 100644 --- a/oAI/Views/Screens/CreditsView.swift +++ b/oAI/Views/Screens/CreditsView.swift @@ -78,6 +78,20 @@ struct CreditsView: View { .font(.system(size: 40)) .foregroundColor(.green) .padding(.top) + + case .appleOnDevice: + Text("Apple Intelligence") + .font(.headline) + Text("On-device and free — no credits or API key needed.") + .font(.body) + .foregroundColor(.secondary) + Text("⚠️ Beta — Apple's Foundation Models framework is still in active development.") + .font(.caption) + .foregroundColor(.secondary) + Image(systemName: "apple.logo") + .font(.system(size: 40)) + .foregroundColor(.secondary) + .padding(.top) } } diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index 0f3c8e7..14755d7 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -23,6 +23,7 @@ import SwiftUI import UniformTypeIdentifiers +import FoundationModels struct SettingsView: View { @Environment(\.dismiss) var dismiss @@ -319,6 +320,36 @@ It's better to admit "I need more information" or "I cannot do that" than to fak } } + // Apple Intelligence + VStack(alignment: .leading, spacing: 6) { + sectionHeader("Apple Intelligence") + formSection { + row("Status") { + appleIntelligenceStatusBadge + } + rowDivider() + row("Model") { + Text("On-Device (4K context)") + .foregroundStyle(.secondary) + } + rowDivider() + row("") { + Button("Open Apple Intelligence Settings") { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.aisettings") { + NSWorkspace.shared.open(url) + } + } + } + VStack(alignment: .leading, spacing: 2) { + Text("⚠️ Beta — Apple's on-device model is still in active development (currently macOS 27 beta). Expect rough edges: a small 4K context window, occasional generation errors, and no tool support yet.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.bottom, 8) + } + } + // Features VStack(alignment: .leading, spacing: 6) { sectionHeader("Features") @@ -3089,6 +3120,28 @@ It's better to admit "I need more information" or "I cannot do that" than to fak formatter.unitsStyle = .full return formatter.localizedString(for: date, relativeTo: .now) } + + @ViewBuilder + private var appleIntelligenceStatusBadge: some View { + let availability = SystemLanguageModel.default.availability + switch availability { + case .available: + Label("Available", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + case .unavailable(.deviceNotEligible): + Label("Not supported on this Mac", systemImage: "xmark.circle.fill") + .foregroundStyle(.red) + case .unavailable(.appleIntelligenceNotEnabled): + Label("Not enabled — open Apple Intelligence Settings", systemImage: "exclamationmark.circle.fill") + .foregroundStyle(.orange) + case .unavailable(.modelNotReady): + Label("Model downloading…", systemImage: "arrow.down.circle.fill") + .foregroundStyle(.orange) + default: + Label("Unavailable", systemImage: "questionmark.circle.fill") + .foregroundStyle(.secondary) + } + } } #Preview { diff --git a/oAITests/AIProviderTests.swift b/oAITests/AIProviderTests.swift new file mode 100644 index 0000000..65e391e --- /dev/null +++ b/oAITests/AIProviderTests.swift @@ -0,0 +1,81 @@ +// +// AIProviderTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +import Foundation +@testable import oAI + +@Suite("ChatResponse / Usage decoding") +struct AIProviderTests { + + @Test("Usage decodes token counts and cache fields from snake_case keys") + func usageDecodesTokenCounts() throws { + let json = """ + { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150, + "cache_creation_input_tokens": 20, + "cache_read_input_tokens": 10 + } + """.data(using: .utf8)! + let usage = try JSONDecoder().decode(ChatResponse.Usage.self, from: json) + #expect(usage.promptTokens == 100) + #expect(usage.completionTokens == 50) + #expect(usage.totalTokens == 150) + #expect(usage.cacheCreationInputTokens == 20) + #expect(usage.cacheReadInputTokens == 10) + } + + @Test("Usage.rawCostUSD is always nil after decoding, even if a matching key were present") + func usageRawCostNeverDecoded() throws { + // rawCostUSD has no CodingKeys entry at all -- it's only ever set via the + // memberwise init, never from API JSON. Confirm decode always yields nil, + // which is exactly the "$0.0000 cost" regression this guards against. + let json = """ + { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + "raw_cost_u_s_d": 5.0 + } + """.data(using: .utf8)! + let usage = try JSONDecoder().decode(ChatResponse.Usage.self, from: json) + #expect(usage.rawCostUSD == nil) + } + + @Test("Usage cache fields are nil when absent from the response") + func usageMissingCacheFields() throws { + let json = #"{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}"#.data(using: .utf8)! + let usage = try JSONDecoder().decode(ChatResponse.Usage.self, from: json) + #expect(usage.cacheCreationInputTokens == nil) + #expect(usage.cacheReadInputTokens == nil) + } + + @Test("ChatResponse decodes core fields and forces toolCalls/generatedImages to nil") + func chatResponseDecodesCoreFieldsOnly() throws { + let json = """ + { + "id": "resp-1", + "model": "test-model", + "content": "hello there", + "role": "assistant", + "finishReason": "stop", + "created": 1700000000 + } + """.data(using: .utf8)! + let response = try JSONDecoder().decode(ChatResponse.self, from: json) + #expect(response.id == "resp-1") + #expect(response.model == "test-model") + #expect(response.content == "hello there") + #expect(response.role == "assistant") + #expect(response.finishReason == "stop") + // Not part of CodingKeys -- always nil straight out of decode. + #expect(response.toolCalls == nil) + #expect(response.generatedImages == nil) + } +} diff --git a/oAITests/AnthropicProviderTests.swift b/oAITests/AnthropicProviderTests.swift new file mode 100644 index 0000000..3a859ec --- /dev/null +++ b/oAITests/AnthropicProviderTests.swift @@ -0,0 +1,207 @@ +// +// AnthropicProviderTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +import Foundation +@testable import oAI + +@Suite("AnthropicProvider request/response conversion") +struct AnthropicProviderTests { + + private var provider: AnthropicProvider { AnthropicProvider(apiKey: "test-key") } + + private func decodeBody(_ urlRequest: URLRequest) throws -> [String: Any] { + try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any] + } + + // MARK: - buildURLRequest + + @Test("System messages are pulled out of the message list into a top-level system field") + func systemMessageExtracted() throws { + let messages = [ + Message(role: .system, content: "be concise"), + Message(role: .user, content: "hi") + ] + let request = ChatRequest(messages: messages, model: "claude-sonnet-4-5") + let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false) + let body = try decodeBody(urlRequest) + + let apiMessages = body["messages"] as? [[String: Any]] + #expect(apiMessages?.count == 1) // system message removed from the array + #expect(apiMessages?.first?["role"] as? String == "user") + + let system = body["system"] as? [[String: Any]] + #expect(system?.first?["text"] as? String == "be concise") + let cacheControl = system?.first?["cache_control"] as? [String: String] + #expect(cacheControl?["type"] == "ephemeral") + } + + @Test("The last message gets a cache_control breakpoint on its content block") + func lastMessageGetsCacheBreakpoint() throws { + let messages = [ + Message(role: .user, content: "first"), + Message(role: .assistant, content: "second"), + Message(role: .user, content: "third") + ] + let request = ChatRequest(messages: messages, model: "claude-sonnet-4-5") + let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false) + let body = try decodeBody(urlRequest) + let apiMessages = body["messages"] as? [[String: Any]] + + // Earlier messages keep plain string content (no cache breakpoint). + #expect(apiMessages?[0]["content"] as? String == "first") + #expect(apiMessages?[1]["content"] as? String == "second") + + // The last message's content becomes an array with a cache_control block. + let lastContent = apiMessages?[2]["content"] as? [[String: Any]] + #expect(lastContent?.first?["text"] as? String == "third") + let cacheControl = lastContent?.first?["cache_control"] as? [String: String] + #expect(cacheControl?["type"] == "ephemeral") + } + + @Test("max_tokens defaults to 16000 when not specified on the request") + func defaultMaxTokens() throws { + let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "claude-sonnet-4-5") + let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false) + let body = try decodeBody(urlRequest) + #expect(body["max_tokens"] as? Int == 16000) + } + + @Test("Online mode adds a web_search tool even with no explicit tools requested") + func onlineModeAddsWebSearchTool() throws { + let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "claude-sonnet-4-5", onlineMode: true) + let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false) + let body = try decodeBody(urlRequest) + let tools = body["tools"] as? [[String: Any]] + #expect(tools?.first?["name"] as? String == "web_search") + // tool_choice is only added when the caller supplied explicit tools. + #expect(body["tool_choice"] == nil) + } + + @Test("Empty message content is replaced with a placeholder rather than sent blank") + func emptyContentGetsPlaceholder() throws { + let request = ChatRequest(messages: [Message(role: .user, content: " ")], model: "claude-sonnet-4-5") + let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false) + let body = try decodeBody(urlRequest) + let apiMessages = body["messages"] as? [[String: Any]] + // This is also the last message, so it gets wrapped in a cache-control array. + let content = apiMessages?.first?["content"] as? [[String: Any]] + #expect(content?.first?["text"] as? String == "[Image]") + } + + // MARK: - parseResponse + + @Test("Parses text content, usage, and stop_reason from a well-formed response") + func parsesWellFormedResponse() throws { + let json: [String: Any] = [ + "id": "msg_123", + "model": "claude-sonnet-4-5", + "content": [["type": "text", "text": "hello there"]], + "stop_reason": "end_turn", + "usage": ["input_tokens": 10, "output_tokens": 5] + ] + let data = try JSONSerialization.data(withJSONObject: json) + let response = try provider.parseResponse(data: data) + #expect(response.id == "msg_123") + #expect(response.content == "hello there") + #expect(response.finishReason == "end_turn") + #expect(response.usage?.promptTokens == 10) + #expect(response.usage?.completionTokens == 5) + #expect(response.toolCalls == nil) + } + + @Test("Parses a tool_use block into a ToolCallInfo with JSON-encoded arguments") + func parsesToolUseBlock() throws { + let json: [String: Any] = [ + "id": "msg_1", + "model": "claude-sonnet-4-5", + "content": [ + ["type": "tool_use", "id": "tool_1", "name": "get_weather", "input": ["city": "Oslo"]] + ], + "usage": ["input_tokens": 1, "output_tokens": 1] + ] + let data = try JSONSerialization.data(withJSONObject: json) + let response = try provider.parseResponse(data: data) + #expect(response.toolCalls?.count == 1) + #expect(response.toolCalls?.first?.functionName == "get_weather") + #expect(response.toolCalls?.first?.arguments.contains("Oslo") == true) + } + + @Test("Throws invalidResponse for non-JSON data") + func throwsOnInvalidJSON() { + let data = "not json".data(using: .utf8)! + #expect(throws: (any Error).self) { + try provider.parseResponse(data: data) + } + } + + // MARK: - convertParametersToDict + + @Test("Converts tool parameters to Anthropic's input_schema dict shape") + func convertsParametersToInputSchema() { + let params = Tool.Function.Parameters( + type: "object", + properties: [ + "city": Tool.Function.Parameters.Property(type: "string", description: "City name"), + "unit": Tool.Function.Parameters.Property(type: "string", description: "Unit", enum: ["celsius", "fahrenheit"]) + ], + required: ["city"] + ) + let dict = provider.convertParametersToDict(params) + #expect(dict["type"] as? String == "object") + #expect(dict["required"] as? [String] == ["city"]) + let properties = dict["properties"] as? [String: Any] + let cityProp = properties?["city"] as? [String: Any] + #expect(cityProp?["type"] as? String == "string") + let unitProp = properties?["unit"] as? [String: Any] + #expect(unitProp?["enum"] as? [String] == ["celsius", "fahrenheit"]) + } + + @Test("Array-typed properties include a nested items schema") + func convertsArrayItemsSchema() { + let params = Tool.Function.Parameters( + type: "object", + properties: [ + "tags": Tool.Function.Parameters.Property(type: "array", description: "Tags", items: .init(type: "string")) + ], + required: nil + ) + let dict = provider.convertParametersToDict(params) + let properties = dict["properties"] as? [String: Any] + let tagsProp = properties?["tags"] as? [String: Any] + let items = tagsProp?["items"] as? [String: Any] + #expect(items?["type"] as? String == "string") + #expect(dict["required"] == nil) + } + + // MARK: - resolveFallbackPricing + + @Test("Exact-length single prefix match returns that tier's pricing") + func fallbackPricingSingleMatch() { + let pricing = AnthropicProvider.resolveFallbackPricing(for: "claude-haiku-4-5-20251001") + #expect(pricing.prompt == 0.80) + #expect(pricing.completion == 4.0) + } + + @Test("Longest matching prefix wins when multiple prefixes could match") + func fallbackPricingLongestPrefixWins() { + let fallback: [(prefix: String, prompt: Double, completion: Double)] = [ + ("claude", 1.0, 2.0), + ("claude-sonnet", 3.0, 15.0), + ] + let pricing = AnthropicProvider.resolveFallbackPricing(for: "claude-sonnet-5", fallback: fallback) + #expect(pricing.prompt == 3.0) + #expect(pricing.completion == 15.0) + } + + @Test("An unmatched model ID prices at zero rather than guessing") + func fallbackPricingNoMatch() { + let pricing = AnthropicProvider.resolveFallbackPricing(for: "some-future-unknown-model") + #expect(pricing.prompt == 0) + #expect(pricing.completion == 0) + } +} diff --git a/oAITests/BackupServiceTests.swift b/oAITests/BackupServiceTests.swift new file mode 100644 index 0000000..c3fa605 --- /dev/null +++ b/oAITests/BackupServiceTests.swift @@ -0,0 +1,105 @@ +// +// BackupServiceTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +import Foundation +@testable import oAI + +@Suite("BackupService.decideFavoritesSync") +struct BackupServiceFavoritesSyncTests { + + private let now = Date(timeIntervalSince1970: 1_700_000_000) + + @Test("No remote copy yet -- push local state") + func noRemoteCopy() { + let decision = BackupService.decideFavoritesSync(localIds: ["a", "b"], localUpdatedAt: "", remote: nil, now: now) + #expect(decision == .pushLocal) + } + + @Test("Remote is newer -- apply it locally") + func remoteNewer() { + let remote = FavoritesPayload(updatedAt: "2026-02-01T00:00:00Z", ids: ["x", "y"]) + let decision = BackupService.decideFavoritesSync( + localIds: ["a"], + localUpdatedAt: "2026-01-01T00:00:00Z", + remote: remote, + now: now + ) + #expect(decision == .applyRemote(ids: ["x", "y"], updatedAt: "2026-02-01T00:00:00Z")) + } + + @Test("Local is newer -- push local state, don't touch remote") + func localNewer() { + let remote = FavoritesPayload(updatedAt: "2026-01-01T00:00:00Z", ids: ["x"]) + let decision = BackupService.decideFavoritesSync( + localIds: ["a"], + localUpdatedAt: "2026-02-01T00:00:00Z", + remote: remote, + now: now + ) + #expect(decision == .pushLocal) + } + + @Test("Tied timestamps with different sets -- merge via union and push") + func tiedTimestampsDifferentSets() { + let remote = FavoritesPayload(updatedAt: "", ids: ["b", "c"]) + let decision = BackupService.decideFavoritesSync(localIds: ["a", "b"], localUpdatedAt: "", remote: remote, now: now) + guard case .mergeAndPush(let ids, let updatedAt) = decision else { + Issue.record("Expected .mergeAndPush") + return + } + #expect(ids == ["a", "b", "c"]) + #expect(!updatedAt.isEmpty) // stamped with `now`, not left blank + } + + @Test("Tied timestamps with identical sets -- no-op, already in sync") + func tiedTimestampsIdenticalSets() { + let remote = FavoritesPayload(updatedAt: "2026-01-01T00:00:00Z", ids: ["a", "b"]) + let decision = BackupService.decideFavoritesSync( + localIds: ["a", "b"], + localUpdatedAt: "2026-01-01T00:00:00Z", + remote: remote, + now: now + ) + #expect(decision == .noop) + } +} + +@Suite("BackupService.isBackupDue") +struct BackupServiceAutoBackupTests { + + private let now = Date(timeIntervalSince1970: 1_700_000_000) + private let oneHour: TimeInterval = 3600 + + @Test("Manual frequency is never due") + func manualNeverDue() { + #expect(!BackupService.isBackupDue(frequency: "manual", lastBackupDate: nil, now: now)) + #expect(!BackupService.isBackupDue(frequency: "manual", lastBackupDate: now.addingTimeInterval(-1_000_000), now: now)) + } + + @Test("No prior backup at all is always due, regardless of frequency") + func noPriorBackupIsAlwaysDue() { + #expect(BackupService.isBackupDue(frequency: "daily", lastBackupDate: nil, now: now)) + #expect(BackupService.isBackupDue(frequency: "weekly", lastBackupDate: nil, now: now)) + } + + @Test("Daily: not due before 24 hours have passed, due at or after") + func dailyThreshold() { + let justUnder = now.addingTimeInterval(-(24 * oneHour - 1)) + let exactly = now.addingTimeInterval(-(24 * oneHour)) + #expect(!BackupService.isBackupDue(frequency: "daily", lastBackupDate: justUnder, now: now)) + #expect(BackupService.isBackupDue(frequency: "daily", lastBackupDate: exactly, now: now)) + } + + @Test("Weekly: not due before 7 days have passed, due at or after") + func weeklyThreshold() { + let justUnder = now.addingTimeInterval(-(7 * 24 * oneHour - 1)) + let exactly = now.addingTimeInterval(-(7 * 24 * oneHour)) + #expect(!BackupService.isBackupDue(frequency: "weekly", lastBackupDate: justUnder, now: now)) + #expect(BackupService.isBackupDue(frequency: "weekly", lastBackupDate: exactly, now: now)) + } +} diff --git a/oAITests/ChatViewModelPureLogicTests.swift b/oAITests/ChatViewModelPureLogicTests.swift new file mode 100644 index 0000000..36e4a5d --- /dev/null +++ b/oAITests/ChatViewModelPureLogicTests.swift @@ -0,0 +1,167 @@ +// +// ChatViewModelPureLogicTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +@testable import oAI + +@Suite("ChatViewModel pure static helpers") +struct ChatViewModelPureLogicTests { + + // MARK: - detectGoodbyePhrase + + @Test("Recognizes a clear farewell phrase") + func detectsGoodbye() { + #expect(ChatViewModel.detectGoodbyePhrase(in: "OK, bye!")) + #expect(ChatViewModel.detectGoodbyePhrase(in: "That's all, thanks.")) + #expect(ChatViewModel.detectGoodbyePhrase(in: "Have a good day")) + } + + @Test("Does not false-positive on a word that merely contains a phrase as a substring") + func doesNotMatchSubstring() { + // "bye" is a whole-word match target -- "goodbyeee" should not match "bye" + // as a substring because of the \b word-boundary regex. + #expect(!ChatViewModel.detectGoodbyePhrase(in: "goodbyeee is not a real word")) + } + + @Test("Does not match ordinary polite phrases that aren't farewells") + func doesNotMatchPoliteRequests() { + #expect(!ChatViewModel.detectGoodbyePhrase(in: "Thanks, that's helpful!")) + #expect(!ChatViewModel.detectGoodbyePhrase(in: "Done with step one, what's next?")) + } + + @Test("Matching is case-insensitive") + func matchIsCaseInsensitive() { + #expect(ChatViewModel.detectGoodbyePhrase(in: "BYE BYE")) + } + + // MARK: - inferProvider + + @Test("Model ID with a slash is inferred as OpenRouter") + func infersOpenRouter() { + #expect(ChatViewModel.inferProvider(from: "anthropic/claude-3-5-sonnet") == .openrouter) + } + + @Test("claude- prefixed model is inferred as direct Anthropic") + func infersAnthropic() { + #expect(ChatViewModel.inferProvider(from: "claude-sonnet-4-5-20250929") == .anthropic) + } + + @Test("gpt-/o1/o3/dall-e/chatgpt prefixed models are inferred as OpenAI") + func infersOpenAI() { + #expect(ChatViewModel.inferProvider(from: "gpt-4o") == .openai) + #expect(ChatViewModel.inferProvider(from: "o1-preview") == .openai) + #expect(ChatViewModel.inferProvider(from: "o3-mini") == .openai) + #expect(ChatViewModel.inferProvider(from: "dall-e-3") == .openai) + #expect(ChatViewModel.inferProvider(from: "chatgpt-4o-latest") == .openai) + } + + @Test("A bare local model name with no recognized prefix falls back to Ollama") + func fallsBackToOllama() { + #expect(ChatViewModel.inferProvider(from: "llama3.2") == .ollama) + } + + @Test("apple- prefixed model is inferred as Apple on-device") + func infersAppleOnDevice() { + #expect(ChatViewModel.inferProvider(from: "apple-on-device") == .appleOnDevice) + } + + // MARK: - calculateCost + + @Test("Base prompt and completion cost with no cache usage") + func calculatesBaseCost() { + let usage = ChatResponse.Usage(promptTokens: 1_000_000, completionTokens: 1_000_000, totalTokens: 2_000_000) + let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0) + #expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 18.0) + } + + @Test("Cache read tokens are charged at 0.1x the prompt rate") + func calculatesCacheReadCost() { + let usage = ChatResponse.Usage( + promptTokens: 0, + completionTokens: 0, + totalTokens: 1_000_000, + cacheReadInputTokens: 1_000_000 + ) + let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0) + #expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 0.3) + } + + @Test("Cache write tokens are charged at 1.25x the prompt rate") + func calculatesCacheWriteCost() { + let usage = ChatResponse.Usage( + promptTokens: 0, + completionTokens: 0, + totalTokens: 1_000_000, + cacheCreationInputTokens: 1_000_000 + ) + let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0) + #expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 3.75) + } + + @Test("Zero usage produces zero cost") + func zeroUsageIsZeroCost() { + let usage = ChatResponse.Usage(promptTokens: 0, completionTokens: 0, totalTokens: 0) + let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0) + #expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 0.0) + } + + // MARK: - shouldAutoSave + + private func autoSaveEligible( + syncEnabled: Bool = true, + syncAutoSave: Bool = true, + syncConfigured: Bool = true, + isCloned: Bool = true, + chatMessageCount: Int = 10, + minMessages: Int = 4, + lastSavedConversationId: String? = nil, + currentConversationHash: String = "hash-a" + ) -> Bool { + ChatViewModel.shouldAutoSave( + syncEnabled: syncEnabled, + syncAutoSave: syncAutoSave, + syncConfigured: syncConfigured, + isCloned: isCloned, + chatMessageCount: chatMessageCount, + minMessages: minMessages, + lastSavedConversationId: lastSavedConversationId, + currentConversationHash: currentConversationHash + ) + } + + @Test("Eligible when every criterion is satisfied") + func autoSaveEligibleWhenAllCriteriaMet() { + #expect(autoSaveEligible()) + } + + @Test("Not eligible when sync or auto-save is disabled") + func autoSaveIneligibleWhenDisabled() { + #expect(!autoSaveEligible(syncEnabled: false)) + #expect(!autoSaveEligible(syncAutoSave: false)) + } + + @Test("Not eligible when sync isn't configured or the repo isn't cloned") + func autoSaveIneligibleWhenNotConfiguredOrCloned() { + #expect(!autoSaveEligible(syncConfigured: false)) + #expect(!autoSaveEligible(isCloned: false)) + } + + @Test("Not eligible below the minimum message count") + func autoSaveIneligibleBelowMinimumMessages() { + #expect(!autoSaveEligible(chatMessageCount: 2, minMessages: 4)) + } + + @Test("Not eligible if this exact conversation was already auto-saved") + func autoSaveIneligibleWhenAlreadySaved() { + #expect(!autoSaveEligible(lastSavedConversationId: "hash-a", currentConversationHash: "hash-a")) + } + + @Test("Eligible when the last saved hash differs from the current conversation") + func autoSaveEligibleWhenConversationChanged() { + #expect(autoSaveEligible(lastSavedConversationId: "hash-a", currentConversationHash: "hash-b")) + } +} diff --git a/oAITests/ContextSelectionServiceDBTests.swift b/oAITests/ContextSelectionServiceDBTests.swift new file mode 100644 index 0000000..f63f4fc --- /dev/null +++ b/oAITests/ContextSelectionServiceDBTests.swift @@ -0,0 +1,120 @@ +// +// ContextSelectionServiceDBTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +import Foundation +@testable import oAI + +@Suite("ContextSelectionService DB-coupled paths, against a throwaway in-memory queue") +struct ContextSelectionServiceDBTests { + + /// Persists messages, then loads them back so their `id`s match the DB rows + /// (saveConversation assigns fresh row ids internally, distinct from the ids + /// on the Message values passed in). + private func persistedMessages(_ db: DatabaseService, _ messages: [Message]) throws -> (conversationId: UUID, messages: [Message]) { + let saved = try db.saveConversation(name: "Test", messages: messages) + let loaded = try db.loadConversation(id: saved.id) + return (saved.id, loaded?.1 ?? []) + } + + // MARK: - isMessageStarred + + @Test("A message with no metadata row is not starred") + func unstarredByDefault() throws { + let db = DatabaseService.makeInMemory() + let service = ContextSelectionService(db: db) + let (_, messages) = try persistedMessages(db, [Message(role: .user, content: "hi")]) + + #expect(service.isMessageStarred(messages[0]) == false) + } + + @Test("A message starred via setMessageStarred reports starred") + func starredAfterSetting() throws { + let db = DatabaseService.makeInMemory() + let service = ContextSelectionService(db: db) + let (_, messages) = try persistedMessages(db, [Message(role: .user, content: "hi")]) + + try db.setMessageStarred(messageId: messages[0].id, starred: true) + #expect(service.isMessageStarred(messages[0]) == true) + } + + // MARK: - getSummariesForExcludedRange + + @Test("No summaries recorded returns an empty array") + func noSummariesReturnsEmpty() throws { + let db = DatabaseService.makeInMemory() + let service = ContextSelectionService(db: db) + let (conversationId, _) = try persistedMessages(db, [Message(role: .user, content: "hi")]) + + let summaries = service.getSummariesForExcludedRange(conversationId: conversationId, totalMessages: 30, selectedCount: 10) + #expect(summaries.isEmpty) + } + + @Test("A summary covering only excluded messages is included") + func summaryForExcludedRangeIncluded() throws { + let db = DatabaseService.makeInMemory() + let service = ContextSelectionService(db: db) + let (conversationId, _) = try persistedMessages(db, [Message(role: .user, content: "hi")]) + + // 30 total, 10 selected -> messages 0..<20 are excluded + try db.saveConversationSummary(conversationId: conversationId, startIndex: 0, endIndex: 15, summary: "early stuff", model: nil, tokenCount: nil) + + let summaries = service.getSummariesForExcludedRange(conversationId: conversationId, totalMessages: 30, selectedCount: 10) + #expect(summaries == ["early stuff"]) + } + + @Test("A summary covering messages that were kept is excluded") + func summaryForKeptRangeExcluded() throws { + let db = DatabaseService.makeInMemory() + let service = ContextSelectionService(db: db) + let (conversationId, _) = try persistedMessages(db, [Message(role: .user, content: "hi")]) + + // 30 total, 10 selected -> messages 0..<20 are excluded; this summary ends at 25, inside the kept range + try db.saveConversationSummary(conversationId: conversationId, startIndex: 15, endIndex: 25, summary: "later stuff", model: nil, tokenCount: nil) + + let summaries = service.getSummariesForExcludedRange(conversationId: conversationId, totalMessages: 30, selectedCount: 10) + #expect(summaries.isEmpty) + } + + // MARK: - smartSelection (end to end through the DB seam) + + @Test("A starred older message is pulled in alongside the recent window") + func starredMessagePulledIntoSelection() throws { + let db = DatabaseService.makeInMemory() + let service = ContextSelectionService(db: db) + + var all: [Message] = [] + let base = Date() + for i in 0..<15 { + all.append(Message(role: i % 2 == 0 ? .user : .assistant, content: "msg \(i)", timestamp: base.addingTimeInterval(Double(i)))) + } + let (_, persisted) = try persistedMessages(db, all) + + // Star an old message that's outside the last-10 window (index 2) + try db.setMessageStarred(messageId: persisted[2].id, starred: true) + + let window = service.smartSelection(allMessages: persisted, maxTokens: 100_000, conversationId: nil) + #expect(window.messages.contains { $0.id == persisted[2].id }) + } + + @Test("An unstarred, unimportant old message outside the recent window is excluded") + func unstarredOldMessageExcluded() throws { + let db = DatabaseService.makeInMemory() + let service = ContextSelectionService(db: db) + + var all: [Message] = [] + let base = Date() + for i in 0..<15 { + all.append(Message(role: i % 2 == 0 ? .user : .assistant, content: "msg \(i)", timestamp: base.addingTimeInterval(Double(i)))) + } + let (_, persisted) = try persistedMessages(db, all) + + let window = service.smartSelection(allMessages: persisted, maxTokens: 100_000, conversationId: nil) + #expect(window.messages.contains { $0.id == persisted[0].id } == false) + #expect(window.excludedCount == 5) + } +} diff --git a/oAITests/ContextSelectionServiceTests.swift b/oAITests/ContextSelectionServiceTests.swift new file mode 100644 index 0000000..de96c0e --- /dev/null +++ b/oAITests/ContextSelectionServiceTests.swift @@ -0,0 +1,67 @@ +// +// ContextSelectionServiceTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +@testable import oAI + +@Suite("ContextSelectionService pure scoring helpers") +struct ContextSelectionServiceTests { + + private var service: ContextSelectionService { ContextSelectionService.shared } + + // MARK: - getImportanceScore + + @Test("A short message with no cost or token data scores from length alone") + func lowScoreForPlainShortMessage() { + let message = Message(role: .user, content: "hi") + #expect(service.getImportanceScore(message) < 0.01) + } + + @Test("Cost, length, and token factors combine to the exact weighted sum") + func combinesWeightedFactors() { + // cost 0.005 / 0.01 = 0.5 -> * 0.5 weight = 0.25 + // 1000 chars / 2000 = 0.5 -> * 0.3 weight = 0.15 + // 500 tokens / 1000 = 0.5 -> * 0.2 weight = 0.1 + // total = 0.5 + let message = Message(role: .assistant, content: String(repeating: "a", count: 1000), tokens: 500, cost: 0.005) + #expect(abs(service.getImportanceScore(message) - 0.5) < 0.0001) + } + + @Test("Score is capped at 1.0 even when every factor maxes out and would otherwise overflow it") + func scoreIsCappedAtOne() { + let message = Message( + role: .assistant, + content: String(repeating: "a", count: 5000), // well past the 2000-char max + tokens: 5000, // well past the 1000-token max + cost: 1.0 // well past the $0.01 max + ) + #expect(service.getImportanceScore(message) == 1.0) + } + + // MARK: - estimateTokens + + @Test("Uses the message's actual token count when present") + func usesActualTokenCountWhenAvailable() { + let messages = [Message(role: .user, content: "irrelevant for this test", tokens: 42)] + #expect(service.estimateTokens(messages) == 42) + } + + @Test("Falls back to content.count / 4 when tokens is nil") + func fallsBackToCharacterEstimate() { + let messages = [Message(role: .user, content: String(repeating: "x", count: 40))] + #expect(service.estimateTokens(messages) == 10) + } + + @Test("Sums estimates across a mix of messages with and without token counts") + func sumsAcrossMixedMessages() { + let messages = [ + Message(role: .user, content: "ignored", tokens: 100), + Message(role: .assistant, content: String(repeating: "y", count: 20)), // 20/4 = 5 + ] + #expect(service.estimateTokens(messages) == 105) + } +} diff --git a/oAITests/DatabaseServiceTests.swift b/oAITests/DatabaseServiceTests.swift new file mode 100644 index 0000000..680f738 --- /dev/null +++ b/oAITests/DatabaseServiceTests.swift @@ -0,0 +1,123 @@ +// +// DatabaseServiceTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +import Foundation +@testable import oAI + +@Suite("DatabaseService migrations, against a throwaway in-memory queue") +struct DatabaseServiceMigrationTests { + + @Test("All v1-v8 tables exist after migration") + func allTablesExist() { + let db = DatabaseService.makeInMemory() + let expectedTables = [ + "conversations", "messages", "settings", "command_history", + "email_logs", "message_metadata", "message_embeddings", + "conversation_embeddings", "conversation_summaries", + ] + for table in expectedTables { + #expect(db.tableExists(table), "expected table \(table) to exist") + } + } + + @Test("v4 adds modelId to messages and primaryModel to conversations") + func v4AddsModelColumns() { + let db = DatabaseService.makeInMemory() + #expect(db.columnNames(in: "messages").contains("modelId")) + #expect(db.columnNames(in: "conversations").contains("primaryModel")) + } + + @Test("messages table has the expected v1 columns") + func messagesTableColumns() { + let db = DatabaseService.makeInMemory() + let columns = Set(db.columnNames(in: "messages")) + let expected: Set = ["id", "conversationId", "role", "content", "tokens", "cost", "timestamp", "sortOrder"] + #expect(expected.isSubset(of: columns)) + } + + @Test("message_metadata table has the expected v6 columns") + func messageMetadataColumns() { + let db = DatabaseService.makeInMemory() + let columns = Set(db.columnNames(in: "message_metadata")) + #expect(columns == ["message_id", "importance_score", "user_starred", "summary", "chunk_index"]) + } + + @Test("conversation_summaries table has the expected v8 columns") + func conversationSummariesColumns() { + let db = DatabaseService.makeInMemory() + let columns = Set(db.columnNames(in: "conversation_summaries")) + #expect(columns == ["id", "conversation_id", "start_message_index", "end_message_index", "summary", "token_count", "created_at", "summary_model"]) + } + + @Test("A nonexistent table reports as absent, not a crash") + func nonexistentTableReportsAbsent() { + let db = DatabaseService.makeInMemory() + #expect(db.tableExists("not_a_real_table") == false) + } + + @Test("Two in-memory instances are isolated from each other") + func instancesAreIsolated() throws { + let dbA = DatabaseService.makeInMemory() + let dbB = DatabaseService.makeInMemory() + + _ = try dbA.saveConversation(name: "only in A", messages: [Message(role: .user, content: "hi")]) + + #expect(try dbA.listConversations().count == 1) + #expect(try dbB.listConversations().count == 0) + } +} + +@Suite("DatabaseService settings CRUD, against a throwaway in-memory queue") +struct DatabaseServiceSettingsTests { + + @Test("Round-trips a plain setting") + func roundTripsSetting() { + let db = DatabaseService.makeInMemory() + db.setSetting(key: "theme", value: "dark") + #expect((try? db.loadAllSettings()["theme"]) == "dark") + } + + @Test("Deletes a setting") + func deletesSetting() { + let db = DatabaseService.makeInMemory() + db.setSetting(key: "temp", value: "1") + db.deleteSetting(key: "temp") + #expect((try? db.loadAllSettings()["temp"]) == nil) + } +} + +@Suite("DatabaseService conversation + message persistence, against a throwaway in-memory queue") +struct DatabaseServiceConversationTests { + + @Test("Saving a conversation round-trips its messages via loadConversation") + func savesAndLoadsConversation() throws { + let db = DatabaseService.makeInMemory() + let messages = [ + Message(role: .user, content: "hello"), + Message(role: .assistant, content: "hi there"), + ] + let saved = try db.saveConversation(name: "Test Chat", messages: messages) + + let loaded = try db.loadConversation(id: saved.id) + #expect(loaded?.0.name == "Test Chat") + #expect(loaded?.1.map(\.content) == ["hello", "hi there"]) + } + + @Test("System messages are excluded from persistence") + func systemMessagesExcluded() throws { + let db = DatabaseService.makeInMemory() + let messages = [ + Message(role: .system, content: "tool call"), + Message(role: .user, content: "hello"), + ] + let saved = try db.saveConversation(name: "Test", messages: messages) + let loaded = try db.loadConversation(id: saved.id) + #expect(loaded?.1.count == 1) + #expect(loaded?.1.first?.content == "hello") + } +} diff --git a/oAITests/EmbeddingServiceTests.swift b/oAITests/EmbeddingServiceTests.swift new file mode 100644 index 0000000..f135ffd --- /dev/null +++ b/oAITests/EmbeddingServiceTests.swift @@ -0,0 +1,35 @@ +// +// EmbeddingServiceTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +@testable import oAI + +@Suite("EmbeddingService serialization") +struct EmbeddingServiceTests { + + @Test("Serialize then deserialize round-trips an embedding exactly") + func roundTrip() { + let original: [Float] = [0.0, 1.0, -1.0, 3.14159, -0.0001, 1_000_000.5] + let data = EmbeddingService.shared.serializeEmbedding(original) + let restored = EmbeddingService.shared.deserializeEmbedding(data) + #expect(restored == original) + } + + @Test("Serialized data is exactly 4 bytes per float") + func serializedSizeIsFourBytesPerFloat() { + let embedding: [Float] = Array(repeating: 0.5, count: 10) + let data = EmbeddingService.shared.serializeEmbedding(embedding) + #expect(data.count == 40) + } + + @Test("Empty embedding serializes to empty data and back") + func emptyEmbedding() { + let data = EmbeddingService.shared.serializeEmbedding([]) + #expect(data.isEmpty) + #expect(EmbeddingService.shared.deserializeEmbedding(data).isEmpty) + } +} diff --git a/oAITests/GitSyncServiceTests.swift b/oAITests/GitSyncServiceTests.swift new file mode 100644 index 0000000..67a2b4d --- /dev/null +++ b/oAITests/GitSyncServiceTests.swift @@ -0,0 +1,108 @@ +// +// GitSyncServiceTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +@testable import oAI + +@Suite("GitSyncService pure helpers") +struct GitSyncServiceTests { + + private var service: GitSyncService { GitSyncService.shared } + + // MARK: - convertToSSH + + @Test("Already-SSH URLs pass through unchanged") + func convertToSSHAlreadySSH() { + #expect(service.convertToSSH("git@github.com:user/repo.git") == "git@github.com:user/repo.git") + } + + @Test("HTTPS URL converts to SSH form") + func convertToSSHFromHTTPS() { + #expect(service.convertToSSH("https://gitlab.pm/rune/oAI-Sync.git") == "git@gitlab.pm:rune/oAI-Sync.git") + } + + @Test("HTTP URL converts to SSH form") + func convertToSSHFromHTTP() { + #expect(service.convertToSSH("http://example.com/user/repo.git") == "git@example.com:user/repo.git") + } + + @Test("Malformed URL with no path segment is returned unchanged") + func convertToSSHMalformedNoSlash() { + #expect(service.convertToSSH("https://onlyhost") == "https://onlyhost") + } + + @Test("Unknown scheme is returned unchanged") + func convertToSSHUnknownScheme() { + #expect(service.convertToSSH("ftp://example.com/repo") == "ftp://example.com/repo") + } + + // MARK: - injectCredentials + + @Test("Injects username and password into an HTTPS URL") + func injectCredentialsHTTPS() { + let result = service.injectCredentials("https://gitlab.pm/rune/repo.git", username: "rune", password: "secret") + #expect(result == "https://rune:secret@gitlab.pm/rune/repo.git") + } + + @Test("Non-HTTPS URLs are returned unchanged (credentials not injected)") + func injectCredentialsNonHTTPS() { + let result = service.injectCredentials("git@gitlab.pm:rune/repo.git", username: "rune", password: "secret") + #expect(result == "git@gitlab.pm:rune/repo.git") + } + + // MARK: - sanitizeFilename + + @Test("Strips filesystem-invalid characters, replacing each with a dash") + func sanitizeFilenameStripsInvalidChars() { + #expect(service.sanitizeFilename("a/b\\c:d*e?f\"gi|j") == "a-b-c-d-e-f-g-h-i-j") + } + + @Test("Leaves an already-valid filename untouched") + func sanitizeFilenameValidInput() { + #expect(service.sanitizeFilename("My Chat 2026-01-15") == "My Chat 2026-01-15") + } + + // MARK: - detectSecretsInText + + @Test("Detects an OpenAI-style API key") + func detectSecretsOpenAIKey() { + let text = "here's my key: sk-abcdefghijklmnopqrstuvwxyz123456" + #expect(service.detectSecretsInText(text).contains("OpenAI Key")) + } + + @Test("Detects a GitHub personal access token") + func detectSecretsGitHubToken() { + let text = "token=ghp_abcdefghijklmnopqrstuvwxyz1234567890" + #expect(service.detectSecretsInText(text).contains("Access Token")) + } + + @Test("Plain conversational text has no detected secrets") + func detectSecretsCleanText() { + #expect(service.detectSecretsInText("Just a normal conversation about the weather.").isEmpty) + } + + @Test("Does not flag an OpenAI-shaped string one character short of the length threshold") + func detectSecretsJustBelowThreshold() { + // "sk-" + 31 chars = 34 total, but the pattern requires 32+ chars after "sk-" + let shortKey = "sk-" + String(repeating: "a", count: 31) + #expect(!service.detectSecretsInText(shortKey).contains("OpenAI Key")) + } + + // MARK: - extractProvider + + @Test("Recognizes github.com, gitlab.com, and gitea URLs by name") + func extractProviderRecognizesKnownHosts() { + #expect(GitSyncService.extractProvider(from: "https://github.com/user/repo.git") == "GitHub") + #expect(GitSyncService.extractProvider(from: "https://gitlab.com/user/repo.git") == "GitLab") + #expect(GitSyncService.extractProvider(from: "https://my-gitea-instance.example.com/user/repo.git") == "Gitea") + } + + @Test("Falls back to a generic label for an unrecognized host") + func extractProviderFallsBackForUnknownHost() { + #expect(GitSyncService.extractProvider(from: "https://gitlab.pm/rune/oai-swift.git") == "Git repository") + } +} diff --git a/oAITests/GitignoreParserTests.swift b/oAITests/GitignoreParserTests.swift new file mode 100644 index 0000000..ac77942 --- /dev/null +++ b/oAITests/GitignoreParserTests.swift @@ -0,0 +1,83 @@ +// +// GitignoreParserTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +@testable import oAI + +@Suite("GitignoreParser") +struct GitignoreParserTests { + + private func parser(for content: String) -> GitignoreParser { + var parser = GitignoreParser(rootPath: "/tmp/fake-root") + parser.parseContent(content) + return parser + } + + @Test("Simple extension wildcard matches at any depth") + func extensionWildcard() { + let p = parser(for: "*.log") + #expect(p.isIgnored("debug.log")) + #expect(p.isIgnored("nested/debug.log")) + #expect(!p.isIgnored("debug.txt")) + } + + @Test("Directory pattern matches the directory and everything under it") + func directoryPattern() { + let p = parser(for: "build/") + #expect(p.isIgnored("build")) + #expect(p.isIgnored("build/output.txt")) + #expect(p.isIgnored("foo/build/output.txt")) + #expect(!p.isIgnored("rebuild")) + } + + @Test("Path-anchored pattern only matches that exact relative path, not nested occurrences") + func pathAnchoredWildcard() { + let p = parser(for: "src/*.tmp") + #expect(p.isIgnored("src/foo.tmp")) + #expect(!p.isIgnored("other/src/foo.tmp")) + #expect(!p.isIgnored("src/sub/foo.tmp")) + } + + @Test("Double-star matches across any number of intermediate directories") + func doubleStarPattern() { + let p = parser(for: "**/logs") + #expect(p.isIgnored("logs")) + #expect(p.isIgnored("a/logs")) + #expect(p.isIgnored("a/b/logs")) + #expect(p.isIgnored("a/logs/file.txt")) + #expect(!p.isIgnored("logsfile")) + } + + @Test("Negation un-ignores a path matched by an earlier rule") + func negation() { + let p = parser(for: "*.log\n!important.log") + #expect(p.isIgnored("debug.log")) + #expect(!p.isIgnored("important.log")) + } + + @Test("Comments and blank lines are skipped, not treated as patterns") + func commentsAndBlankLines() { + let p = parser(for: "# a comment\n\n*.log\n \n") + #expect(p.isIgnored("x.log")) + #expect(!p.isIgnored("# a comment")) + } + + @Test("Leading slash still matches (root-anchoring is not enforced by this implementation)") + func leadingSlashPattern() { + // Documents actual current behavior: the leading "/" is stripped without + // truly restricting the match to the root level, unlike real git. + let p = parser(for: "/dist") + #expect(p.isIgnored("dist")) + #expect(p.isIgnored("dist/file.txt")) + } + + @Test("No rules means nothing is ignored") + func noRules() { + let p = parser(for: "") + #expect(!p.isIgnored("anything.txt")) + } +} diff --git a/oAITests/MCPServicePDFTests.swift b/oAITests/MCPServicePDFTests.swift new file mode 100644 index 0000000..794f22b --- /dev/null +++ b/oAITests/MCPServicePDFTests.swift @@ -0,0 +1,68 @@ +// +// MCPServicePDFTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +import Foundation +import CoreGraphics +import AppKit +@testable import oAI + +@Suite("MCPService PDF text extraction") +struct MCPServicePDFTests { + + /// Builds a real, well-formed single-page PDF (via CoreGraphics) containing the given text, + /// writes it to a temp file, and returns the path. Using CoreGraphics rather than hand-rolled + /// PDF bytes avoids flakiness from malformed xref tables etc. + private func makeTestPDF(text: String) throws -> String { + let path = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("pdf") + + let pdfData = NSMutableData() + let consumer = CGDataConsumer(data: pdfData as CFMutableData)! + var mediaBox = CGRect(x: 0, y: 0, width: 200, height: 200) + let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil)! + + context.beginPDFPage(nil) + let attributedString = NSAttributedString(string: text, attributes: [.font: NSFont.systemFont(ofSize: 24)]) + let line = CTLineCreateWithAttributedString(attributedString) + context.textPosition = CGPoint(x: 10, y: 100) + CTLineDraw(line, context) + context.endPDFPage() + context.closePDF() + + try (pdfData as Data).write(to: path) + return path.path + } + + @Test("Extracts text from a real PDF's text layer") + func extractsTextFromRealPDF() throws { + let path = try makeTestPDF(text: "Hello World") + defer { try? FileManager.default.removeItem(atPath: path) } + + let extracted = MCPService.shared.extractPDFText(atPath: path) + #expect(extracted?.contains("Hello World") == true) + } + + @Test("Returns nil for a nonexistent path") + func returnsNilForMissingFile() { + let extracted = MCPService.shared.extractPDFText(atPath: "/tmp/definitely-does-not-exist-\(UUID().uuidString).pdf") + #expect(extracted == nil) + } + + @Test("Returns nil for a file that isn't a valid PDF") + func returnsNilForGarbageBytes() throws { + let path = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("pdf") + try Data("not a real pdf".utf8).write(to: path) + defer { try? FileManager.default.removeItem(at: path) } + + let extracted = MCPService.shared.extractPDFText(atPath: path.path) + #expect(extracted == nil) + } +} diff --git a/oAITests/MessageCodableTests.swift b/oAITests/MessageCodableTests.swift new file mode 100644 index 0000000..5bfb14b --- /dev/null +++ b/oAITests/MessageCodableTests.swift @@ -0,0 +1,93 @@ +// +// MessageCodableTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +import Foundation +@testable import oAI + +@Suite("Message Codable + Equatable") +struct MessageCodableTests { + + @Test("Transient fields don't survive an encode/decode round-trip") + func transientFieldsDoNotPersist() throws { + var message = Message(role: .assistant, content: "hi", isStreaming: true, generatedImages: [Data([0x01])]) + message.isStarred = true + message.toolCalls = [ToolCallDetail(name: "test_tool", input: "{}", result: nil)] + message.thinkingContent = "reasoning..." + + let data = try JSONEncoder().encode(message) + let decoded = try JSONDecoder().decode(Message.self, from: data) + + #expect(decoded.content == "hi") + #expect(decoded.isStreaming == false) + #expect(decoded.isStarred == false) + #expect(decoded.generatedImages == nil) + #expect(decoded.toolCalls == nil) + #expect(decoded.thinkingContent == nil) + } + + @Test("Persisted fields survive an encode/decode round-trip") + func persistedFieldsSurvive() throws { + let message = Message( + role: .user, + content: "what's the weather", + tokens: 42, + cost: 0.0012, + responseTime: 1.5, + wasInterrupted: true, + modelId: "claude-sonnet-5" + ) + let data = try JSONEncoder().encode(message) + let decoded = try JSONDecoder().decode(Message.self, from: data) + + #expect(decoded.id == message.id) + #expect(decoded.role == message.role) + #expect(decoded.content == message.content) + #expect(decoded.tokens == message.tokens) + #expect(decoded.cost == message.cost) + #expect(decoded.responseTime == message.responseTime) + #expect(decoded.wasInterrupted == message.wasInterrupted) + #expect(decoded.modelId == message.modelId) + } + + @Test("Custom == ignores role, timestamp, attachments, and modelId") + func equalityIgnoresSomeFields() { + let base = Message(id: UUID(), role: .user, content: "same", timestamp: Date()) + let differentRoleAndModel = Message( + id: base.id, + role: .assistant, + content: "same", + timestamp: Date(timeIntervalSince1970: 0), + modelId: "some-other-model" + ) + // Documents actual current behavior: these fields are intentionally + // excluded from Message's custom == -- surprising if you don't know it. + #expect(base == differentRoleAndModel) + } + + @Test("Custom == does distinguish on content") + func equalityDistinguishesContent() { + let a = Message(id: UUID(), role: .user, content: "one") + let b = Message(id: a.id, role: .user, content: "two") + #expect(a != b) + } + + @Test("FileAttachment.typeFromExtension recognizes common types") + func attachmentTypeFromExtension() { + #expect(FileAttachment.typeFromExtension("photo.PNG") == .image) + #expect(FileAttachment.typeFromExtension("photo.jpg") == .image) + #expect(FileAttachment.typeFromExtension("doc.pdf") == .pdf) + #expect(FileAttachment.typeFromExtension("notes.md") == .text) + #expect(FileAttachment.typeFromExtension("noextension") == .text) + } + + @Test("FileAttachment.mimeType matches the detected extension") + func attachmentMimeType() { + let attachment = FileAttachment(path: "image.webp", type: .image, data: nil) + #expect(attachment.mimeType == "image/webp") + } +} diff --git a/oAITests/OllamaProviderTests.swift b/oAITests/OllamaProviderTests.swift new file mode 100644 index 0000000..b3a758f --- /dev/null +++ b/oAITests/OllamaProviderTests.swift @@ -0,0 +1,79 @@ +// +// OllamaProviderTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +import Foundation +@testable import oAI + +@Suite("OllamaProvider request/response conversion") +struct OllamaProviderTests { + + private var provider: OllamaProvider { OllamaProvider() } + + // MARK: - buildRequestBody + + @Test("Builds a basic request body with model, messages, and stream flag") + func basicRequestBody() { + let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "llama3.2") + let body = provider.buildRequestBody(from: request, stream: true) + #expect(body["model"] as? String == "llama3.2") + #expect(body["stream"] as? Bool == true) + let messages = body["messages"] as? [[String: Any]] + #expect(messages?.count == 1) + #expect(messages?.first?["role"] as? String == "user") + #expect(messages?.first?["content"] as? String == "hi") + } + + @Test("System prompt is prepended as its own system-role message") + func systemPromptPrepended() { + let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "llama3.2", systemPrompt: "be concise") + let body = provider.buildRequestBody(from: request, stream: false) + let messages = body["messages"] as? [[String: Any]] + #expect(messages?.count == 2) + #expect(messages?.first?["role"] as? String == "system") + #expect(messages?.first?["content"] as? String == "be concise") + } + + @Test("maxTokens and temperature are nested under options only when present") + func optionsOnlyWhenPresent() { + let withOptions = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "llama3.2", maxTokens: 500, temperature: 0.7) + let bodyWithOptions = provider.buildRequestBody(from: withOptions, stream: false) + let options = bodyWithOptions["options"] as? [String: Any] + #expect(options?["num_predict"] as? Int == 500) + #expect(options?["temperature"] as? Double == 0.7) + + let withoutOptions = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "llama3.2") + let bodyWithoutOptions = provider.buildRequestBody(from: withoutOptions, stream: false) + #expect(bodyWithoutOptions["options"] == nil) + } + + // MARK: - parseOllamaResponse + + @Test("Parses content and token counts from a well-formed response") + func parsesWellFormedResponse() { + let json: [String: Any] = [ + "message": ["content": "the answer"], + "prompt_eval_count": 10, + "eval_count": 20 + ] + let response = provider.parseOllamaResponse(json, model: "llama3.2") + #expect(response.content == "the answer") + #expect(response.role == "assistant") + #expect(response.finishReason == "stop") + #expect(response.usage?.promptTokens == 10) + #expect(response.usage?.completionTokens == 20) + #expect(response.usage?.totalTokens == 30) + } + + @Test("Missing fields default to empty content and zero token counts") + func missingFieldsDefaultGracefully() { + let response = provider.parseOllamaResponse([:], model: "llama3.2") + #expect(response.content == "") + #expect(response.usage?.promptTokens == 0) + #expect(response.usage?.completionTokens == 0) + } +} diff --git a/oAITests/OpenAIProviderTests.swift b/oAITests/OpenAIProviderTests.swift new file mode 100644 index 0000000..46c1f85 --- /dev/null +++ b/oAITests/OpenAIProviderTests.swift @@ -0,0 +1,89 @@ +// +// OpenAIProviderTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +import Foundation +@testable import oAI + +@Suite("OpenAIProvider request/response conversion") +struct OpenAIProviderTests { + + private var provider: OpenAIProvider { OpenAIProvider(apiKey: "test-key") } + + // MARK: - buildURLRequest + + @Test("Sets the Authorization header and JSON content type") + func setsAuthAndContentTypeHeaders() throws { + let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "gpt-4o") + let urlRequest = try provider.buildURLRequest(from: request, stream: false) + #expect(urlRequest.value(forHTTPHeaderField: "Authorization") == "Bearer test-key") + #expect(urlRequest.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(urlRequest.value(forHTTPHeaderField: "Accept") == nil) + } + + @Test("Streaming requests add an SSE Accept header and stream_options") + func streamingAddsAcceptHeaderAndUsageOption() throws { + let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "gpt-4o") + let urlRequest = try provider.buildURLRequest(from: request, stream: true) + #expect(urlRequest.value(forHTTPHeaderField: "Accept") == "text/event-stream") + + let body = try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any] + let streamOptions = body["stream_options"] as? [String: Any] + #expect(streamOptions?["include_usage"] as? Bool == true) + } + + @Test("o1/o3 reasoning models omit temperature even when one is requested") + func reasoningModelsOmitTemperature() throws { + let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "o1-preview", temperature: 0.7) + let urlRequest = try provider.buildURLRequest(from: request, stream: false) + let body = try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any] + #expect(body["temperature"] == nil) + } + + @Test("Non-reasoning models include the requested temperature") + func nonReasoningModelsIncludeTemperature() throws { + let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "gpt-4o", temperature: 0.7) + let urlRequest = try provider.buildURLRequest(from: request, stream: false) + let body = try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any] + #expect(body["temperature"] as? Double == 0.7) + } + + @Test("A message with an image attachment produces multi-part vision-format content") + func imageAttachmentProducesMultipartContent() throws { + let attachment = FileAttachment(path: "photo.png", type: .image, data: Data([0x01])) + let message = Message(role: .user, content: "look at this", attachments: [attachment]) + let request = ChatRequest(messages: [message], model: "gpt-4o") + let urlRequest = try provider.buildURLRequest(from: request, stream: false) + let body = try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any] + let messages = body["messages"] as? [[String: Any]] + let content = messages?.first?["content"] as? [[String: Any]] + #expect(content?.count == 2) + #expect(content?.first?["type"] as? String == "text") + #expect(content?.last?["type"] as? String == "image_url") + } + + // MARK: - convertToChatResponse + + @Test("Returns an empty-content response when there are no choices, rather than throwing") + func convertToChatResponseEmptyChoicesFallback() { + let apiResponse = OpenRouterChatResponse(id: "x", model: "m", choices: [], usage: nil, created: 0) + let response = provider.convertToChatResponse(apiResponse) + #expect(response.content == "") + #expect(response.role == "assistant") + } + + // MARK: - fallbackModels + + @Test("Fallback models are non-empty, all support tools, and are sorted by name") + func fallbackModelsAreWellFormed() { + let models = provider.fallbackModels() + #expect(!models.isEmpty) + #expect(models.allSatisfy { $0.capabilities.tools == true }) + #expect(models.allSatisfy { $0.capabilities.online == false }) + #expect(models.map(\.name) == models.map(\.name).sorted()) + } +} diff --git a/oAITests/OpenRouterModelsTests.swift b/oAITests/OpenRouterModelsTests.swift new file mode 100644 index 0000000..460d7df --- /dev/null +++ b/oAITests/OpenRouterModelsTests.swift @@ -0,0 +1,132 @@ +// +// OpenRouterModelsTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +import Foundation +@testable import oAI + +@Suite("OpenRouterModels decoding") +struct OpenRouterModelsTests { + + // MARK: - Request-side: APIMessage.MessageContent (string-or-array union) + + @Test("Request message content decodes a plain string") + func requestContentPlainString() throws { + let json = #"{"role":"user","content":"hello"}"#.data(using: .utf8)! + let message = try JSONDecoder().decode(OpenRouterChatRequest.APIMessage.self, from: json) + guard case .string(let text) = message.content else { + Issue.record("Expected .string case") + return + } + #expect(text == "hello") + } + + @Test("Request message content decodes an array of content items") + func requestContentArray() throws { + let json = #"{"role":"user","content":[{"type":"text","text":"hi"}]}"#.data(using: .utf8)! + let message = try JSONDecoder().decode(OpenRouterChatRequest.APIMessage.self, from: json) + guard case .array(let items) = message.content else { + Issue.record("Expected .array case") + return + } + #expect(items.count == 1) + } + + // MARK: - Request-side: ContentItem (text / image union, with string fallback) + + @Test("ContentItem decodes a text block") + func contentItemText() throws { + let json = #"{"type":"text","text":"hi there"}"#.data(using: .utf8)! + let item = try JSONDecoder().decode(OpenRouterChatRequest.APIMessage.ContentItem.self, from: json) + guard case .text(let text) = item else { + Issue.record("Expected .text case") + return + } + #expect(text == "hi there") + } + + @Test("ContentItem decodes an image_url block") + func contentItemImage() throws { + let json = #"{"type":"image_url","image_url":{"url":"https://example.com/x.png"}}"#.data(using: .utf8)! + let item = try JSONDecoder().decode(OpenRouterChatRequest.APIMessage.ContentItem.self, from: json) + guard case .image(let image) = item else { + Issue.record("Expected .image case") + return + } + #expect(image.imageUrl.url == "https://example.com/x.png") + } + + @Test("ContentItem falls back to .text for a bare string") + func contentItemBareStringFallback() throws { + let json = #""just text""#.data(using: .utf8)! + let item = try JSONDecoder().decode(OpenRouterChatRequest.APIMessage.ContentItem.self, from: json) + guard case .text(let text) = item else { + Issue.record("Expected .text fallback case") + return + } + #expect(text == "just text") + } + + // MARK: - Response-side: Choice.MessageContent (plain string vs content-block array) + + @Test("Response message content decodes a plain string with no image blocks") + func responseContentPlainString() throws { + let json = #"{"role":"assistant","content":"the answer"}"#.data(using: .utf8)! + let content = try JSONDecoder().decode(OpenRouterChatResponse.Choice.MessageContent.self, from: json) + #expect(content.content == "the answer") + #expect(content.contentBlockImages.isEmpty) + } + + @Test("Response message content extracts text and images from a content-block array") + func responseContentBlockArrayWithImage() throws { + let json = """ + { + "role": "assistant", + "content": [ + {"type": "text", "text": "here you go"}, + {"type": "image_url", "image_url": {"url": "https://example.com/out.png"}} + ] + } + """.data(using: .utf8)! + let content = try JSONDecoder().decode(OpenRouterChatResponse.Choice.MessageContent.self, from: json) + #expect(content.content == "here you go") + #expect(content.contentBlockImages.count == 1) + #expect(content.contentBlockImages.first?.imageUrl.url == "https://example.com/out.png") + } + + @Test("Response message content with no content field at all decodes to nil content, no images") + func responseContentMissing() throws { + let json = #"{"role":"assistant"}"#.data(using: .utf8)! + let content = try JSONDecoder().decode(OpenRouterChatResponse.Choice.MessageContent.self, from: json) + #expect(content.content == nil) + #expect(content.contentBlockImages.isEmpty) + } + + // MARK: - Streaming: StreamChoice.Delta (same string-or-block-array shape, streamed) + + @Test("Stream delta decodes a plain string content chunk") + func streamDeltaPlainString() throws { + let json = #"{"role":"assistant","content":"partial"}"#.data(using: .utf8)! + let delta = try JSONDecoder().decode(OpenRouterStreamChunk.StreamChoice.Delta.self, from: json) + #expect(delta.content == "partial") + #expect(delta.contentBlockImages.isEmpty) + } + + @Test("Stream delta extracts images from a content-block array") + func streamDeltaContentBlockArrayWithImage() throws { + let json = """ + { + "content": [ + {"type": "image_url", "image_url": {"url": "https://example.com/stream.png"}} + ] + } + """.data(using: .utf8)! + let delta = try JSONDecoder().decode(OpenRouterStreamChunk.StreamChoice.Delta.self, from: json) + #expect(delta.contentBlockImages.count == 1) + #expect(delta.contentBlockImages.first?.imageUrl.url == "https://example.com/stream.png") + } +} diff --git a/oAITests/OpenRouterProviderTests.swift b/oAITests/OpenRouterProviderTests.swift new file mode 100644 index 0000000..ff24c4b --- /dev/null +++ b/oAITests/OpenRouterProviderTests.swift @@ -0,0 +1,103 @@ +// +// OpenRouterProviderTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +import Foundation +@testable import oAI + +@Suite("OpenRouterProvider request/response conversion") +struct OpenRouterProviderTests { + + private var provider: OpenRouterProvider { OpenRouterProvider(apiKey: "test-key") } + + // MARK: - buildAPIRequest + + @Test("Plain text message with no attachments uses simple string content") + func buildRequestPlainText() throws { + let request = ChatRequest(messages: [Message(role: .user, content: "hello")], model: "openai/gpt-4o") + let apiRequest = try provider.buildAPIRequest(from: request) + guard case .string(let text) = apiRequest.messages.first?.content else { + Issue.record("Expected .string content for a message with no attachments") + return + } + #expect(text == "hello") + } + + @Test("Message with an image attachment uses array content with a base64 data URL") + func buildRequestWithImageAttachment() throws { + let attachment = FileAttachment(path: "photo.png", type: .image, data: Data([0x01, 0x02])) + let message = Message(role: .user, content: "check this out", attachments: [attachment]) + let request = ChatRequest(messages: [message], model: "openai/gpt-4o") + let apiRequest = try provider.buildAPIRequest(from: request) + guard case .array(let items) = apiRequest.messages.first?.content else { + Issue.record("Expected .array content for a message with attachments") + return + } + #expect(items.count == 2) // text + image + } + + @Test("Online mode appends :online suffix, but not for image generation requests") + func onlineModeSuffix() throws { + let base = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "openai/gpt-4o", onlineMode: true) + let request = try provider.buildAPIRequest(from: base) + #expect(request.model == "openai/gpt-4o:online") + + let imageGenRequest = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "openai/gpt-4o", onlineMode: true, imageGeneration: true) + let noSuffix = try provider.buildAPIRequest(from: imageGenRequest) + #expect(noSuffix.model == "openai/gpt-4o") + } + + @Test("Online mode does not double-append :online if the model already has it") + func onlineModeNoDoubleSuffix() throws { + let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "openai/gpt-4o:online", onlineMode: true) + let apiRequest = try provider.buildAPIRequest(from: request) + #expect(apiRequest.model == "openai/gpt-4o:online") + } + + @Test("Anthropic models get an explicit cache_control opt-in; others don't") + func cacheControlOnlyForAnthropic() throws { + let anthropicRequest = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "anthropic/claude-sonnet-4-5") + let anthropicAPI = try provider.buildAPIRequest(from: anthropicRequest) + #expect(anthropicAPI.cacheControl?.type == "ephemeral") + + let openAIRequest = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "openai/gpt-4o") + let openAIAPI = try provider.buildAPIRequest(from: openAIRequest) + #expect(openAIAPI.cacheControl == nil) + } + + // MARK: - convertToChatResponse + + @Test("Throws invalidResponse when there are no choices") + func convertToChatResponseThrowsOnEmptyChoices() { + let apiResponse = OpenRouterChatResponse(id: "x", model: "m", choices: [], usage: nil, created: 0) + #expect(throws: (any Error).self) { + try provider.convertToChatResponse(apiResponse) + } + } + + // MARK: - decodeImageOutputs + + @Test("Decodes a base64 data URL into raw Data") + func decodeImageOutputsValidDataURL() { + let payload = Data([0xDE, 0xAD, 0xBE, 0xEF]) + let dataURL = "data:image/png;base64,\(payload.base64EncodedString())" + let output = OpenRouterChatResponse.ImageOutput(imageUrl: .init(url: dataURL)) + let decoded = provider.decodeImageOutputs([output]) + #expect(decoded?.first == payload) + } + + @Test("Returns nil for an empty outputs array") + func decodeImageOutputsEmpty() { + #expect(provider.decodeImageOutputs([]) == nil) + } + + @Test("Skips a malformed URL with no comma separator") + func decodeImageOutputsMalformedURL() { + let output = OpenRouterChatResponse.ImageOutput(imageUrl: .init(url: "not-a-data-url")) + #expect(provider.decodeImageOutputs([output]) == nil) + } +} diff --git a/oAITests/SettingsProviderTests.swift b/oAITests/SettingsProviderTests.swift new file mode 100644 index 0000000..ca2255a --- /dev/null +++ b/oAITests/SettingsProviderTests.swift @@ -0,0 +1,31 @@ +// +// SettingsProviderTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +@testable import oAI + +@Suite("Settings.Provider display metadata") +struct SettingsProviderTests { + + @Test("Apple on-device gets a dedicated display name, not a raw-value capitalization") + func appleOnDeviceDisplayName() { + #expect(Settings.Provider.appleOnDevice.displayName == "Apple Intelligence") + } + + @Test("Apple on-device uses the Apple logo icon") + func appleOnDeviceIconName() { + #expect(Settings.Provider.appleOnDevice.iconName == "apple.logo") + } + + @Test("Other providers still fall back to a capitalized raw value") + func otherProvidersUseCapitalizedRawValue() { + #expect(Settings.Provider.openrouter.displayName == "Openrouter") + #expect(Settings.Provider.anthropic.displayName == "Anthropic") + #expect(Settings.Provider.openai.displayName == "Openai") + #expect(Settings.Provider.ollama.displayName == "Ollama") + } +}