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