Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65fe057aa9 |
@@ -31,9 +31,6 @@ DerivedData/
|
||||
*.dSYM.zip
|
||||
*.dSYM
|
||||
|
||||
## Code coverage
|
||||
*.profraw
|
||||
|
||||
## Playgrounds
|
||||
timeline.xctimeline
|
||||
playground.xcworkspace
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// 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"]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
// The Swift Programming Language
|
||||
// https://docs.swift.org/swift-book
|
||||
@@ -0,0 +1,6 @@
|
||||
import Testing
|
||||
@testable import oAI
|
||||
|
||||
@Test func example() async throws {
|
||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||
}
|
||||
@@ -11,19 +11,8 @@
|
||||
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 */
|
||||
@@ -45,11 +34,6 @@
|
||||
path = oAI;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A586FF5230122589002CFF95 /* oAITests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = oAITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -62,13 +46,6 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A586FF4E30122589002CFF95 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -76,7 +53,6 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A550A6642F3B72EA00136F2B /* oAI */,
|
||||
A586FF5230122589002CFF95 /* oAITests */,
|
||||
A550A6632F3B72EA00136F2B /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -85,7 +61,6 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A550A6622F3B72EA00136F2B /* oAI.app */,
|
||||
A586FF5130122589002CFF95 /* oAITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -117,29 +92,6 @@
|
||||
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 */
|
||||
@@ -147,16 +99,12 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2700;
|
||||
LastUpgradeCheck = 2700;
|
||||
LastSwiftUpdateCheck = 2620;
|
||||
LastUpgradeCheck = 2620;
|
||||
TargetAttributes = {
|
||||
A550A6612F3B72EA00136F2B = {
|
||||
CreatedOnToolsVersion = 26.2;
|
||||
};
|
||||
A586FF5030122589002CFF95 = {
|
||||
CreatedOnToolsVersion = 27.0;
|
||||
TestTargetID = A550A6612F3B72EA00136F2B;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = A550A65D2F3B72EA00136F2B /* Build configuration list for PBXProject "oAI" */;
|
||||
@@ -183,7 +131,6 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
A550A6612F3B72EA00136F2B /* oAI */,
|
||||
A586FF5030122589002CFF95 /* oAITests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -196,13 +143,6 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A586FF4F30122589002CFF95 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -213,30 +153,14 @@
|
||||
);
|
||||
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";
|
||||
@@ -266,9 +190,7 @@
|
||||
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;
|
||||
@@ -290,7 +212,6 @@
|
||||
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";
|
||||
};
|
||||
@@ -301,7 +222,6 @@
|
||||
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";
|
||||
@@ -331,9 +251,7 @@
|
||||
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;
|
||||
@@ -348,7 +266,6 @@
|
||||
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;
|
||||
@@ -361,12 +278,12 @@
|
||||
CODE_SIGN_ENTITLEMENTS = oAI/oAI.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 6RJQ2QZYPG;
|
||||
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.";
|
||||
@@ -411,12 +328,12 @@
|
||||
CODE_SIGN_ENTITLEMENTS = oAI/oAI.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 6RJQ2QZYPG;
|
||||
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.";
|
||||
@@ -453,50 +370,6 @@
|
||||
};
|
||||
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 */
|
||||
@@ -518,15 +391,6 @@
|
||||
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 */
|
||||
|
||||
@@ -56,22 +56,17 @@ struct Settings: Codable {
|
||||
case anthropic
|
||||
case openai
|
||||
case ollama
|
||||
case appleOnDevice = "apple_on_device"
|
||||
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .appleOnDevice: return "Apple Intelligence"
|
||||
default: return rawValue.capitalized
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,21 +186,6 @@ 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] {
|
||||
@@ -238,7 +223,11 @@ class AnthropicProvider: AIProvider {
|
||||
// Exact match first
|
||||
if let known = enrichment[id] { return known }
|
||||
// Fuzzy fallback: find the longest prefix that matches
|
||||
let pricing = Self.resolveFallbackPricing(for: id)
|
||||
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)
|
||||
return ModelInfo(
|
||||
id: id,
|
||||
name: displayName,
|
||||
@@ -582,7 +571,7 @@ class AnthropicProvider: AIProvider {
|
||||
|
||||
// MARK: - Request Building
|
||||
|
||||
func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> (URLRequest, Data) {
|
||||
private func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> (URLRequest, Data) {
|
||||
let url = messagesURL
|
||||
|
||||
// Separate system message
|
||||
@@ -696,7 +685,7 @@ class AnthropicProvider: AIProvider {
|
||||
return (urlRequest, bodyData)
|
||||
}
|
||||
|
||||
func parseResponse(data: Data) throws -> ChatResponse {
|
||||
private func parseResponse(data: Data) throws -> ChatResponse {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw ProviderError.invalidResponse
|
||||
}
|
||||
@@ -752,7 +741,7 @@ class AnthropicProvider: AIProvider {
|
||||
)
|
||||
}
|
||||
|
||||
func convertParametersToDict(_ params: Tool.Function.Parameters) -> [String: Any] {
|
||||
private func convertParametersToDict(_ params: Tool.Function.Parameters) -> [String: Any] {
|
||||
var props: [String: Any] = [:]
|
||||
for (key, prop) in params.properties {
|
||||
var propDict: [String: Any] = [
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||
// the full license text. For commercial licensing, contact Rune
|
||||
// Olsen via <https://oai.pm>.
|
||||
|
||||
|
||||
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<StreamChunk, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
Task {
|
||||
do {
|
||||
let session = try self.makeSession(for: request)
|
||||
let prompt = self.lastUserMessage(from: request)
|
||||
|
||||
// streamResponse(to: String) → ResponseStream<String>
|
||||
// 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<String> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,7 +275,7 @@ class OllamaProvider: AIProvider {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
func buildRequestBody(from request: ChatRequest, stream: Bool) -> [String: Any] {
|
||||
private 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
|
||||
}
|
||||
|
||||
func parseOllamaResponse(_ json: [String: Any], model: String) -> ChatResponse {
|
||||
private 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
|
||||
|
||||
@@ -117,7 +117,7 @@ class OpenAIProvider: AIProvider {
|
||||
}
|
||||
}
|
||||
|
||||
func fallbackModels() -> [ModelInfo] {
|
||||
private func fallbackModels() -> [ModelInfo] {
|
||||
Self.knownModels.map { id, info in
|
||||
ModelInfo(
|
||||
id: id,
|
||||
@@ -283,7 +283,7 @@ class OpenAIProvider: AIProvider {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> URLRequest {
|
||||
private 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
|
||||
}
|
||||
|
||||
func convertToChatResponse(_ apiResponse: OpenRouterChatResponse) -> ChatResponse {
|
||||
private 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())
|
||||
}
|
||||
|
||||
@@ -416,7 +416,7 @@ class OpenRouterProvider: AIProvider {
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
func buildAPIRequest(from request: ChatRequest) throws -> OpenRouterChatRequest {
|
||||
private 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 {
|
||||
)
|
||||
}
|
||||
|
||||
func convertToChatResponse(_ apiResponse: OpenRouterChatResponse) throws -> ChatResponse {
|
||||
private func convertToChatResponse(_ apiResponse: OpenRouterChatResponse) throws -> ChatResponse {
|
||||
guard let choice = apiResponse.choices.first else {
|
||||
throw ProviderError.invalidResponse
|
||||
}
|
||||
@@ -540,7 +540,7 @@ class OpenRouterProvider: AIProvider {
|
||||
)
|
||||
}
|
||||
|
||||
func convertToStreamChunk(_ apiChunk: OpenRouterStreamChunk) throws -> StreamChunk {
|
||||
private 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
|
||||
func decodeImageOutputs(_ outputs: [OpenRouterChatResponse.ImageOutput]) -> [Data]? {
|
||||
private func decodeImageOutputs(_ outputs: [OpenRouterChatResponse.ImageOutput]) -> [Data]? {
|
||||
let decoded = outputs.compactMap { output -> Data? in
|
||||
let url = output.imageUrl.url
|
||||
// Strip "data:image/...;base64," prefix
|
||||
|
||||
@@ -67,9 +67,6 @@ class ProviderRegistry {
|
||||
|
||||
case .ollama:
|
||||
provider = OllamaProvider(baseURL: settings.ollamaEffectiveURL)
|
||||
|
||||
case .appleOnDevice:
|
||||
provider = AppleFoundationProvider()
|
||||
}
|
||||
|
||||
// Cache and return
|
||||
@@ -107,8 +104,6 @@ class ProviderRegistry {
|
||||
return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty
|
||||
case .ollama:
|
||||
return settings.ollamaConfigured
|
||||
case .appleOnDevice:
|
||||
return true // no API key needed
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -206,68 +206,37 @@ 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<String>, updatedAt: String)
|
||||
case pushLocal
|
||||
case mergeAndPush(ids: Set<String>, 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<String>,
|
||||
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()
|
||||
|
||||
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
|
||||
|
||||
switch Self.decideFavoritesSync(localIds: localIds, localUpdatedAt: settings.favoriteModelsUpdatedAt, remote: remote, now: Date()) {
|
||||
case .pushLocal:
|
||||
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()
|
||||
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 ?? 0) remote")
|
||||
case .noop:
|
||||
break
|
||||
return
|
||||
}
|
||||
|
||||
let localUpdatedAt = settings.favoriteModelsUpdatedAt
|
||||
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 {
|
||||
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())
|
||||
await pushFavorites()
|
||||
log.info("Merged favorites from iCloud (tied timestamps) — union of \(localIds.count) local + \(remote.ids.count) remote")
|
||||
}
|
||||
// Equal timestamps and identical sets: already in sync, nothing to do.
|
||||
}
|
||||
|
||||
// MARK: - Automatic Backup
|
||||
@@ -283,27 +252,17 @@ final class BackupService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 = 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 }
|
||||
let interval: TimeInterval
|
||||
switch frequency {
|
||||
case "daily": interval = Self.dailyInterval
|
||||
case "weekly": interval = Self.weeklyInterval
|
||||
default: return // "manual" — user triggers backups by hand
|
||||
}
|
||||
|
||||
checkForExistingBackup()
|
||||
guard Self.isBackupDue(frequency: frequency, lastBackupDate: lastBackupDate, now: Date()) else {
|
||||
if let last = lastBackupDate, Date().timeIntervalSince(last) < interval {
|
||||
return // not due yet
|
||||
}
|
||||
|
||||
|
||||
@@ -47,12 +47,7 @@ enum SelectionStrategy {
|
||||
final class ContextSelectionService {
|
||||
static let shared = ContextSelectionService()
|
||||
|
||||
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
|
||||
}
|
||||
private init() {}
|
||||
|
||||
/// Select context messages using the specified strategy
|
||||
func selectContext(
|
||||
@@ -103,7 +98,7 @@ final class ContextSelectionService {
|
||||
|
||||
// MARK: - Smart Selection Algorithm
|
||||
|
||||
func smartSelection(allMessages: [Message], maxTokens: Int, conversationId: UUID? = nil) -> ContextWindow {
|
||||
private func smartSelection(allMessages: [Message], maxTokens: Int, conversationId: UUID? = nil) -> ContextWindow {
|
||||
guard !allMessages.isEmpty else {
|
||||
return ContextWindow(messages: [], summaries: [], totalTokens: 0, excludedCount: 0)
|
||||
}
|
||||
@@ -196,12 +191,12 @@ final class ContextSelectionService {
|
||||
}
|
||||
|
||||
/// Get summaries for excluded message ranges
|
||||
func getSummariesForExcludedRange(
|
||||
private func getSummariesForExcludedRange(
|
||||
conversationId: UUID,
|
||||
totalMessages: Int,
|
||||
selectedCount: Int
|
||||
) -> [String] {
|
||||
guard let summaryRecords = try? db.getConversationSummaries(conversationId: conversationId) else {
|
||||
guard let summaryRecords = try? DatabaseService.shared.getConversationSummaries(conversationId: conversationId) else {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -219,7 +214,7 @@ final class ContextSelectionService {
|
||||
// MARK: - Importance Scoring
|
||||
|
||||
/// Calculate importance score (0.0 - 1.0) for a message
|
||||
func getImportanceScore(_ message: Message) -> Double {
|
||||
private func getImportanceScore(_ message: Message) -> Double {
|
||||
var score = 0.0
|
||||
|
||||
// Factor 1: Cost (expensive calls are important)
|
||||
@@ -243,8 +238,8 @@ final class ContextSelectionService {
|
||||
}
|
||||
|
||||
/// Check if a message is starred by the user
|
||||
func isMessageStarred(_ message: Message) -> Bool {
|
||||
guard let metadata = try? db.getMessageMetadata(messageId: message.id) else {
|
||||
private func isMessageStarred(_ message: Message) -> Bool {
|
||||
guard let metadata = try? DatabaseService.shared.getMessageMetadata(messageId: message.id) else {
|
||||
return false
|
||||
}
|
||||
return metadata.user_starred == 1
|
||||
@@ -253,7 +248,7 @@ final class ContextSelectionService {
|
||||
// MARK: - Token Estimation
|
||||
|
||||
/// Estimate token count for messages (rough approximation)
|
||||
func estimateTokens(_ messages: [Message]) -> Int {
|
||||
private func estimateTokens(_ messages: [Message]) -> Int {
|
||||
var total = 0
|
||||
for message in messages {
|
||||
if let tokens = message.tokens {
|
||||
|
||||
@@ -154,14 +154,7 @@ final class DatabaseService: Sendable {
|
||||
(try? isoStyle.parse(string)) ?? (try? Date(string, strategy: .iso8601))
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
nonisolated private init() {
|
||||
let fileManager = FileManager.default
|
||||
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true)
|
||||
@@ -170,25 +163,12 @@ final class DatabaseService: Sendable {
|
||||
|
||||
let dbPath = dbDirectory.appendingPathComponent("oai_conversations.db").path
|
||||
Log.db.info("Opening database at \(dbPath)")
|
||||
self.init(dbQueue: try! DatabaseQueue(path: dbPath))
|
||||
dbQueue = try! DatabaseQueue(path: dbPath)
|
||||
|
||||
try! migrator.migrate(dbQueue)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
private nonisolated var migrator: DatabaseMigrator {
|
||||
var migrator = DatabaseMigrator()
|
||||
|
||||
migrator.registerMigration("v1") { db in
|
||||
|
||||
@@ -348,7 +348,7 @@ final class EmbeddingService {
|
||||
// MARK: - Serialization
|
||||
|
||||
/// Serialize embedding to binary data (4 bytes per float, little-endian)
|
||||
nonisolated func serializeEmbedding(_ embedding: [Float]) -> Data {
|
||||
private 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
|
||||
nonisolated func deserializeEmbedding(_ data: Data) -> [Float] {
|
||||
private func deserializeEmbedding(_ data: Data) -> [Float] {
|
||||
var embedding: [Float] = []
|
||||
embedding.reserveCapacity(data.count / 4)
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ class GitSyncService {
|
||||
func testConnection() async throws -> String {
|
||||
let url = try buildAuthenticatedURL()
|
||||
_ = try await runGit(["ls-remote", url])
|
||||
return "Connected to \(Self.extractProvider(from: settings.syncRepoURL))"
|
||||
return "Connected to \(extractProvider())"
|
||||
}
|
||||
|
||||
/// Clone repository to local path
|
||||
@@ -507,7 +507,7 @@ class GitSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
func detectSecretsInText(_ text: String) -> [String] {
|
||||
private 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 {
|
||||
}
|
||||
}
|
||||
|
||||
func convertToSSH(_ url: String) -> String {
|
||||
private 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
|
||||
}
|
||||
|
||||
func injectCredentials(_ url: String, username: String, password: String) -> String {
|
||||
private 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,13 +697,14 @@ class GitSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeFilename(_ name: String) -> String {
|
||||
private func sanitizeFilename(_ name: String) -> String {
|
||||
// Remove invalid filename characters
|
||||
let invalid = CharacterSet(charactersIn: "/\\:*?\"<>|")
|
||||
return name.components(separatedBy: invalid).joined(separator: "-")
|
||||
}
|
||||
|
||||
static func extractProvider(from url: String) -> String {
|
||||
private func extractProvider() -> String {
|
||||
let url = settings.syncRepoURL
|
||||
if url.contains("github.com") {
|
||||
return "GitHub"
|
||||
} else if url.contains("gitlab.com") {
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
import PDFKit
|
||||
|
||||
@Observable
|
||||
class MCPService {
|
||||
@@ -156,7 +155,7 @@ class MCPService {
|
||||
var tools: [Tool] = [
|
||||
makeTool(
|
||||
name: "read_file",
|
||||
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.",
|
||||
description: "Read the contents of a file. Returns the text content of the file. Maximum file size is 10MB.",
|
||||
properties: [
|
||||
"file_path": prop("string", "The absolute path to the file to read")
|
||||
],
|
||||
@@ -173,7 +172,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 (PDF text layers are searched too).",
|
||||
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.",
|
||||
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)"),
|
||||
@@ -537,17 +536,8 @@ class MCPService {
|
||||
return ["error": "File too large (\(sizeMB) MB, max 10 MB)"]
|
||||
}
|
||||
|
||||
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
|
||||
guard let content = try? String(contentsOfFile: resolved, encoding: .utf8) else {
|
||||
return ["error": "Cannot read file as UTF-8 text: \(filePath)"]
|
||||
}
|
||||
|
||||
var finalContent = content
|
||||
@@ -564,19 +554,6 @@ 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..<document.pageCount {
|
||||
guard let page = document.page(at: i), let pageText = page.string else { continue }
|
||||
text += pageText + "\n\n"
|
||||
}
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
private func listDirectory(dirPath: String, recursive: Bool) -> [String: Any] {
|
||||
let resolved = ((dirPath as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||
|
||||
@@ -683,13 +660,10 @@ class MCPService {
|
||||
|
||||
// Content search if requested
|
||||
if let searchText = contentSearch, !searchText.isEmpty {
|
||||
let content: String?
|
||||
if (fullPath as NSString).pathExtension.lowercased() == "pdf" {
|
||||
content = extractPDFText(atPath: fullPath)
|
||||
} else {
|
||||
content = try? String(contentsOfFile: fullPath, encoding: .utf8)
|
||||
guard let content = try? String(contentsOfFile: fullPath, encoding: .utf8) else {
|
||||
continue
|
||||
}
|
||||
guard let content, content.localizedCaseInsensitiveContains(searchText) else {
|
||||
if !content.localizedCaseInsensitiveContains(searchText) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -970,7 +944,7 @@ class MCPService {
|
||||
let readOnlyTools: [Tool] = [
|
||||
makeTool(
|
||||
name: "read_file",
|
||||
description: "Read the contents of a file. PDFs are supported (text layer extracted automatically). Maximum file size is 10MB.",
|
||||
description: "Read the contents of a file. Maximum file size is 10MB.",
|
||||
properties: ["file_path": prop("string", "The absolute path to the file to read")],
|
||||
required: ["file_path"]
|
||||
),
|
||||
|
||||
@@ -62,7 +62,6 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -151,14 +151,8 @@ 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 modelSupportsTools,
|
||||
settings.customPromptMode == .replace,
|
||||
if settings.customPromptMode == .replace,
|
||||
let customPrompt = settings.systemPrompt,
|
||||
!customPrompt.isEmpty {
|
||||
// BYOP: Use ONLY the custom prompt
|
||||
@@ -176,32 +170,29 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
}
|
||||
|
||||
// Add tool-specific guidelines if MCP is enabled (tools are available)
|
||||
if mcpEnabled && modelSupportsTools {
|
||||
if mcpEnabled {
|
||||
prompt += toolUsageGuidelines
|
||||
}
|
||||
|
||||
// Append custom prompt if in append mode and custom prompt exists
|
||||
if modelSupportsTools,
|
||||
settings.customPromptMode == .append,
|
||||
if 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)
|
||||
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"
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -422,17 +413,15 @@ 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 = Self.inferProvider(from: model.id) ?? currentProvider
|
||||
let newProvider = inferProvider(from: model.id) ?? currentProvider
|
||||
selectedModel = model
|
||||
currentProvider = newProvider
|
||||
MCPService.shared.resetBashSessionApproval()
|
||||
}
|
||||
|
||||
func inferProviderPublic(from modelId: String) -> Settings.Provider? { Self.inferProvider(from: modelId) }
|
||||
func inferProviderPublic(from modelId: String) -> Settings.Provider? { inferProvider(from: modelId) }
|
||||
|
||||
nonisolated static func inferProvider(from modelId: String) -> Settings.Provider? {
|
||||
// Apple Foundation Models
|
||||
if modelId.hasPrefix("apple-") { return .appleOnDevice }
|
||||
private func inferProvider(from modelId: String) -> Settings.Provider? {
|
||||
// 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")
|
||||
@@ -448,7 +437,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 = Self.inferProvider(from: modelId) else {
|
||||
guard let targetProvider = inferProvider(from: modelId) else {
|
||||
showSystemMessage("⚠️ Could not determine provider for model '\(modelId)' — keeping current model")
|
||||
return
|
||||
}
|
||||
@@ -953,7 +942,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 ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||
messages[index].cost = cost
|
||||
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
||||
}
|
||||
@@ -1017,7 +1006,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 ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||
messages[index].cost = cost
|
||||
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
||||
}
|
||||
@@ -1317,7 +1306,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
||||
}
|
||||
}
|
||||
_ = Self.detectGoodbyePhrase(in: "")
|
||||
_ = detectGoodbyePhrase(in: "")
|
||||
} catch {
|
||||
if let index = messages.firstIndex(where: { $0.id == messageId }) {
|
||||
messages[index].content = "❌ Image generation failed: \(error.localizedDescription)"
|
||||
@@ -1593,7 +1582,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 ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||
sessionStats.addMessage(
|
||||
inputTokens: usage.promptTokens,
|
||||
outputTokens: usage.completionTokens,
|
||||
@@ -1618,7 +1607,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 ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||
if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) {
|
||||
messages[index].cost = cost
|
||||
}
|
||||
@@ -1899,43 +1888,40 @@ 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 }
|
||||
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
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
/// Auto-save the current conversation with background summarization
|
||||
@@ -2052,7 +2038,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
}
|
||||
|
||||
/// Detect goodbye phrases in user message
|
||||
nonisolated static func detectGoodbyePhrase(in text: String) -> Bool {
|
||||
func detectGoodbyePhrase(in text: String) -> Bool {
|
||||
let lowercased = text.lowercased()
|
||||
let goodbyePhrases = [
|
||||
"bye", "goodbye", "bye bye", "good bye",
|
||||
@@ -2092,7 +2078,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
updateConversationTracking()
|
||||
|
||||
// Check for goodbye phrase
|
||||
if Self.detectGoodbyePhrase(in: text) {
|
||||
if 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))
|
||||
@@ -2273,7 +2259,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.
|
||||
nonisolated static func calculateCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double {
|
||||
private 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
|
||||
|
||||
@@ -78,20 +78,6 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import FoundationModels
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@@ -320,36 +319,6 @@ 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")
|
||||
@@ -3120,28 +3089,6 @@ 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 {
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
//
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
//
|
||||
// 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"))
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
//
|
||||
// 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<String> = ["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")
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
//
|
||||
// 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\"g<h>i|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")
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
//
|
||||
// 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"))
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
//
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user