Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0af644456e |
@@ -31,9 +31,6 @@ DerivedData/
|
|||||||
*.dSYM.zip
|
*.dSYM.zip
|
||||||
*.dSYM
|
*.dSYM
|
||||||
|
|
||||||
## Code coverage
|
|
||||||
*.profraw
|
|
||||||
|
|
||||||
## Playgrounds
|
## Playgrounds
|
||||||
timeline.xctimeline
|
timeline.xctimeline
|
||||||
playground.xcworkspace
|
playground.xcworkspace
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// swift-tools-version: 6.2
|
||||||
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "oAI",
|
||||||
|
products: [
|
||||||
|
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||||
|
.library(
|
||||||
|
name: "oAI",
|
||||||
|
targets: ["oAI"]
|
||||||
|
),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||||
|
// Targets can depend on other targets in this package and products from dependencies.
|
||||||
|
.target(
|
||||||
|
name: "oAI"
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "oAITests",
|
||||||
|
dependencies: ["oAI"]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
-29
@@ -1,29 +0,0 @@
|
|||||||
# Security Policy
|
|
||||||
|
|
||||||
## Supported Versions
|
|
||||||
|
|
||||||
Only the latest publicly released version of oAI is supported with security fixes. Please update to the latest version before reporting an issue, and confirm it still reproduces there.
|
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
|
||||||
|
|
||||||
If you discover a security vulnerability in oAI, please report it privately rather than opening a public GitHub issue.
|
|
||||||
|
|
||||||
To report a security concern, use the contact form at **[https://oai.pm/#contact](https://oai.pm/#contact)**.
|
|
||||||
|
|
||||||
Please include as much detail as possible:
|
|
||||||
- A description of the vulnerability and its potential impact
|
|
||||||
- Steps to reproduce the issue
|
|
||||||
- The oAI version and macOS version you're using
|
|
||||||
- Any relevant logs (`~/Library/Logs/oAI.log`), with sensitive data redacted
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
oAI is a native macOS app that stores conversations, settings, and API keys locally (SQLite database and Keychain). Areas of particular interest for security reports include:
|
|
||||||
- API key handling and Keychain storage
|
|
||||||
- MCP file access permission checks
|
|
||||||
- Bash execution approval flow
|
|
||||||
- Any path that could lead to data exfiltration or unauthorized local file/system access
|
|
||||||
|
|
||||||
## Response
|
|
||||||
|
|
||||||
Reports submitted through the contact form will be reviewed and acknowledged as soon as possible. Please allow time for a fix to be developed and released before any public disclosure.
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// The Swift Programming Language
|
||||||
|
// https://docs.swift.org/swift-book
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import oAI
|
||||||
|
|
||||||
|
@Test func example() async throws {
|
||||||
|
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||||
|
}
|
||||||
@@ -11,45 +11,16 @@
|
|||||||
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 */
|
|
||||||
911C4D0E69E11B84C61453DC /* Exceptions for "oAI" folder in "oAI" target */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
|
||||||
membershipExceptions = (
|
|
||||||
Info.plist,
|
|
||||||
);
|
|
||||||
target = A550A6612F3B72EA00136F2B /* oAI */;
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
A550A6642F3B72EA00136F2B /* oAI */ = {
|
A550A6642F3B72EA00136F2B /* oAI */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
911C4D0E69E11B84C61453DC /* Exceptions for "oAI" folder in "oAI" target */,
|
|
||||||
);
|
|
||||||
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 */
|
||||||
@@ -62,13 +33,6 @@
|
|||||||
);
|
);
|
||||||
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 */
|
||||||
@@ -76,7 +40,6 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A550A6642F3B72EA00136F2B /* oAI */,
|
A550A6642F3B72EA00136F2B /* oAI */,
|
||||||
A586FF5230122589002CFF95 /* oAITests */,
|
|
||||||
A550A6632F3B72EA00136F2B /* Products */,
|
A550A6632F3B72EA00136F2B /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -85,7 +48,6 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A550A6622F3B72EA00136F2B /* oAI.app */,
|
A550A6622F3B72EA00136F2B /* oAI.app */,
|
||||||
A586FF5130122589002CFF95 /* oAITests.xctest */,
|
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -117,29 +79,6 @@
|
|||||||
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 */
|
||||||
@@ -147,16 +86,12 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastSwiftUpdateCheck = 2700;
|
LastSwiftUpdateCheck = 2620;
|
||||||
LastUpgradeCheck = 2700;
|
LastUpgradeCheck = 2620;
|
||||||
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" */;
|
||||||
@@ -183,7 +118,6 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
A550A6612F3B72EA00136F2B /* oAI */,
|
A550A6612F3B72EA00136F2B /* oAI */,
|
||||||
A586FF5030122589002CFF95 /* oAITests */,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -196,13 +130,6 @@
|
|||||||
);
|
);
|
||||||
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 */
|
||||||
@@ -213,30 +140,14 @@
|
|||||||
);
|
);
|
||||||
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";
|
||||||
@@ -266,9 +177,7 @@
|
|||||||
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;
|
||||||
@@ -290,7 +199,6 @@
|
|||||||
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";
|
||||||
};
|
};
|
||||||
@@ -301,7 +209,6 @@
|
|||||||
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";
|
||||||
@@ -331,9 +238,7 @@
|
|||||||
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;
|
||||||
@@ -348,7 +253,6 @@
|
|||||||
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;
|
||||||
@@ -361,12 +265,11 @@
|
|||||||
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;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEVELOPMENT_TEAM = 6RJQ2QZYPG;
|
||||||
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_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.";
|
||||||
@@ -386,7 +289,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 2.4.2;
|
MARKETING_VERSION = 2.4.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -411,12 +314,11 @@
|
|||||||
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;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEVELOPMENT_TEAM = 6RJQ2QZYPG;
|
||||||
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_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.";
|
||||||
@@ -436,7 +338,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 2.4.2;
|
MARKETING_VERSION = 2.4.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -453,50 +355,6 @@
|
|||||||
};
|
};
|
||||||
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 */
|
||||||
@@ -518,15 +376,6 @@
|
|||||||
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,22 +56,17 @@ 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 {
|
||||||
switch self {
|
rawValue.capitalized
|
||||||
case .appleOnDevice: return "Apple Intelligence"
|
|
||||||
default: return rawValue.capitalized
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var iconName: String {
|
var iconName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .openrouter: return "network"
|
case .openrouter: return "network"
|
||||||
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,21 +186,6 @@ 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] {
|
||||||
@@ -238,7 +223,11 @@ 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 pricing = Self.resolveFallbackPricing(for: id)
|
let fallback = Self.pricingFallback
|
||||||
|
.filter { id.hasPrefix($0.prefix) }
|
||||||
|
.max(by: { $0.prefix.count < $1.prefix.count })
|
||||||
|
let pricing = fallback.map { ModelInfo.Pricing(prompt: $0.prompt, completion: $0.completion) }
|
||||||
|
?? ModelInfo.Pricing(prompt: 0, completion: 0)
|
||||||
return ModelInfo(
|
return ModelInfo(
|
||||||
id: id,
|
id: id,
|
||||||
name: displayName,
|
name: displayName,
|
||||||
@@ -582,7 +571,7 @@ class AnthropicProvider: AIProvider {
|
|||||||
|
|
||||||
// MARK: - Request Building
|
// MARK: - Request Building
|
||||||
|
|
||||||
func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> (URLRequest, Data) {
|
private func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> (URLRequest, Data) {
|
||||||
let url = messagesURL
|
let url = messagesURL
|
||||||
|
|
||||||
// Separate system message
|
// Separate system message
|
||||||
@@ -696,7 +685,7 @@ class AnthropicProvider: AIProvider {
|
|||||||
return (urlRequest, bodyData)
|
return (urlRequest, bodyData)
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseResponse(data: Data) throws -> ChatResponse {
|
private func parseResponse(data: Data) throws -> ChatResponse {
|
||||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
throw ProviderError.invalidResponse
|
throw ProviderError.invalidResponse
|
||||||
}
|
}
|
||||||
@@ -752,7 +741,7 @@ class AnthropicProvider: AIProvider {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertParametersToDict(_ params: Tool.Function.Parameters) -> [String: Any] {
|
private func convertParametersToDict(_ params: Tool.Function.Parameters) -> [String: Any] {
|
||||||
var props: [String: Any] = [:]
|
var props: [String: Any] = [:]
|
||||||
for (key, prop) in params.properties {
|
for (key, prop) in params.properties {
|
||||||
var propDict: [String: Any] = [
|
var propDict: [String: Any] = [
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
//
|
|
||||||
// AppleFoundationProvider.swift
|
|
||||||
// oAI
|
|
||||||
//
|
|
||||||
// Apple Foundation Models provider (on-device Apple Intelligence)
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
//
|
|
||||||
// This file is part of oAI.
|
|
||||||
//
|
|
||||||
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
|
||||||
// You may use, study, modify, and share it for any noncommercial
|
|
||||||
// purpose. Commercial use — including selling oAI or any part of
|
|
||||||
// it, standalone or bundled into another product or service —
|
|
||||||
// requires a separate commercial license from the copyright holder.
|
|
||||||
//
|
|
||||||
// See the LICENSE file or
|
|
||||||
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
|
||||||
// the full license text. For commercial licensing, contact Rune
|
|
||||||
// Olsen via <https://oai.pm>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import FoundationModels
|
|
||||||
import os
|
|
||||||
|
|
||||||
final class AppleFoundationProvider: AIProvider {
|
|
||||||
let name = "Apple Intelligence"
|
|
||||||
|
|
||||||
let capabilities = ProviderCapabilities(
|
|
||||||
supportsStreaming: true,
|
|
||||||
supportsVision: false,
|
|
||||||
supportsTools: false,
|
|
||||||
supportsOnlineSearch: false,
|
|
||||||
maxContextLength: 4096
|
|
||||||
)
|
|
||||||
|
|
||||||
// MARK: - Models
|
|
||||||
|
|
||||||
func listModels() async throws -> [ModelInfo] {
|
|
||||||
[
|
|
||||||
ModelInfo(
|
|
||||||
id: "apple-on-device",
|
|
||||||
name: "Apple On-Device (Beta)",
|
|
||||||
description: "On-device Apple Intelligence model. Private, free, and works offline. 4K context window. Apple's Foundation Models framework is still in active beta (currently macOS 27 beta) — expect occasional generation errors and rough edges.",
|
|
||||||
contextLength: 4096,
|
|
||||||
pricing: ModelInfo.Pricing(prompt: 0, completion: 0),
|
|
||||||
capabilities: ModelInfo.ModelCapabilities(
|
|
||||||
vision: false,
|
|
||||||
tools: false,
|
|
||||||
online: false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
func getModel(_ id: String) async throws -> ModelInfo? {
|
|
||||||
try await listModels().first { $0.id == id }
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCredits() async throws -> Credits? { nil }
|
|
||||||
|
|
||||||
// MARK: - Streaming chat
|
|
||||||
|
|
||||||
func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error> {
|
|
||||||
AsyncThrowingStream { continuation in
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let session = try self.makeSession(for: request)
|
|
||||||
let prompt = self.lastUserMessage(from: request)
|
|
||||||
|
|
||||||
// streamResponse(to: String) → ResponseStream<String>
|
|
||||||
// Each snapshot.content is the full accumulated text so far (snapshot model).
|
|
||||||
// We compute deltas by comparing each snapshot to the previous.
|
|
||||||
let stream = session.streamResponse(to: prompt)
|
|
||||||
var lastContent = ""
|
|
||||||
|
|
||||||
for try await snapshot in stream {
|
|
||||||
let current = snapshot.content
|
|
||||||
if current.count > lastContent.count {
|
|
||||||
let delta = String(current.dropFirst(lastContent.count))
|
|
||||||
continuation.yield(StreamChunk(
|
|
||||||
id: UUID().uuidString,
|
|
||||||
model: request.model,
|
|
||||||
delta: StreamChunk.Delta(content: delta, role: "assistant"),
|
|
||||||
finishReason: nil,
|
|
||||||
usage: nil
|
|
||||||
))
|
|
||||||
lastContent = current
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
continuation.yield(StreamChunk(
|
|
||||||
id: UUID().uuidString,
|
|
||||||
model: request.model,
|
|
||||||
delta: StreamChunk.Delta(content: nil, role: nil),
|
|
||||||
finishReason: "stop",
|
|
||||||
usage: nil
|
|
||||||
))
|
|
||||||
continuation.finish()
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
continuation.finish(throwing: self.mapProviderError(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Non-streaming chat
|
|
||||||
|
|
||||||
func chat(request: ChatRequest) async throws -> ChatResponse {
|
|
||||||
let session = try makeSession(for: request)
|
|
||||||
let prompt = lastUserMessage(from: request)
|
|
||||||
do {
|
|
||||||
let response: LanguageModelSession.Response<String> = try await session.respond(to: prompt)
|
|
||||||
return ChatResponse(
|
|
||||||
id: UUID().uuidString,
|
|
||||||
model: request.model,
|
|
||||||
content: response.content,
|
|
||||||
role: "assistant",
|
|
||||||
finishReason: "stop",
|
|
||||||
usage: nil,
|
|
||||||
created: Date()
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
throw mapProviderError(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Tool messages (not supported in Phase 1)
|
|
||||||
|
|
||||||
func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse {
|
|
||||||
throw ProviderError.unknown("Tool calling requires Apple Foundation Models Phase 3.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Session construction
|
|
||||||
|
|
||||||
private func makeSession(for request: ChatRequest) throws -> LanguageModelSession {
|
|
||||||
guard case .available = SystemLanguageModel.default.availability else {
|
|
||||||
throw availabilityError()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build instructions: system prompt + prior conversation turns as formatted text.
|
|
||||||
// Foundation Models sessions don't accept a message array — we inject history inline.
|
|
||||||
var instructions = request.systemPrompt ?? ""
|
|
||||||
let priorMessages = request.messages.dropLast().filter { $0.role != .system }
|
|
||||||
|
|
||||||
if !priorMessages.isEmpty {
|
|
||||||
let history = priorMessages
|
|
||||||
.map { m -> String in
|
|
||||||
let label = m.role == .user ? "User" : "Assistant"
|
|
||||||
return "\(label): \(m.content)"
|
|
||||||
}
|
|
||||||
.joined(separator: "\n")
|
|
||||||
instructions += "\n\nConversation so far:\n\(history)\n\nContinue from here."
|
|
||||||
}
|
|
||||||
|
|
||||||
return instructions.isEmpty
|
|
||||||
? LanguageModelSession()
|
|
||||||
: LanguageModelSession(instructions: instructions)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func lastUserMessage(from request: ChatRequest) -> String {
|
|
||||||
request.messages.last(where: { $0.role == .user })?.content ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Error mapping
|
|
||||||
|
|
||||||
private func availabilityError() -> Error {
|
|
||||||
switch SystemLanguageModel.default.availability {
|
|
||||||
case .unavailable(.deviceNotEligible):
|
|
||||||
return ProviderError.unknown("This Mac doesn't support Apple Intelligence. Apple Silicon is required.")
|
|
||||||
case .unavailable(.appleIntelligenceNotEnabled):
|
|
||||||
return ProviderError.unknown("Apple Intelligence is not enabled. Open System Settings → Apple Intelligence to turn it on.")
|
|
||||||
case .unavailable(.modelNotReady):
|
|
||||||
return ProviderError.unknown("Apple Intelligence model is still downloading. Please wait and try again.")
|
|
||||||
default:
|
|
||||||
return ProviderError.unknown("Apple Intelligence is not available on this device.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispatches to whichever generation-error type the running OS actually throws.
|
|
||||||
/// `LanguageModelSession.GenerationError` was deprecated in macOS 27.0 in favor of the new
|
|
||||||
/// top-level `LanguageModelError` — on a macOS 27+ runtime, generation failures come through
|
|
||||||
/// as `LanguageModelError`, not `GenerationError`, even though the app's deployment target
|
|
||||||
/// (26.2) still needs to handle macOS 26.x runtimes throwing the older type.
|
|
||||||
private func mapProviderError(_ error: Error) -> Error {
|
|
||||||
if #available(macOS 27.0, *), let lmError = error as? LanguageModelError {
|
|
||||||
return mapLanguageModelError(lmError)
|
|
||||||
}
|
|
||||||
if let genError = error as? LanguageModelSession.GenerationError {
|
|
||||||
return mapGenerationError(genError)
|
|
||||||
}
|
|
||||||
return error
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(macOS 27.0, *)
|
|
||||||
private func mapLanguageModelError(_ error: LanguageModelError) -> Error {
|
|
||||||
switch error {
|
|
||||||
case .contextSizeExceeded(let detail):
|
|
||||||
return ProviderError.unknown("Apple Intelligence context limit exceeded (\(detail.tokenCount)/\(detail.contextSize) tokens). Start a new chat or enable Progressive Summarization in Settings → Advanced.")
|
|
||||||
case .rateLimited:
|
|
||||||
return ProviderError.rateLimitExceeded
|
|
||||||
case .guardrailViolation:
|
|
||||||
return ProviderError.unknown("Apple Intelligence declined to respond to this message.")
|
|
||||||
case .timeout:
|
|
||||||
return ProviderError.timeout
|
|
||||||
default:
|
|
||||||
return error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// macOS 26.x fallback — `GenerationError` is deprecated on macOS 27+ but still the type
|
|
||||||
/// actually thrown on 26.x runtimes, which the 26.2 deployment target must keep supporting.
|
|
||||||
private func mapGenerationError(_ error: LanguageModelSession.GenerationError) -> Error {
|
|
||||||
switch error {
|
|
||||||
case .exceededContextWindowSize:
|
|
||||||
return ProviderError.unknown("Apple Intelligence context limit exceeded (4,096 tokens). Start a new chat or enable Progressive Summarization in Settings → Advanced.")
|
|
||||||
case .rateLimited:
|
|
||||||
return ProviderError.rateLimitExceeded
|
|
||||||
case .guardrailViolation:
|
|
||||||
return ProviderError.unknown("Apple Intelligence declined to respond to this message.")
|
|
||||||
default:
|
|
||||||
return error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -275,7 +275,7 @@ class OllamaProvider: AIProvider {
|
|||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
func buildRequestBody(from request: ChatRequest, stream: Bool) -> [String: Any] {
|
private func buildRequestBody(from request: ChatRequest, stream: Bool) -> [String: Any] {
|
||||||
var messages: [[String: Any]] = []
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseOllamaResponse(_ json: [String: Any], model: String) -> ChatResponse {
|
private func parseOllamaResponse(_ json: [String: Any], model: String) -> ChatResponse {
|
||||||
let message = json["message"] as? [String: Any]
|
let 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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fallbackModels() -> [ModelInfo] {
|
private 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
|
||||||
|
|
||||||
func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> URLRequest {
|
private func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> URLRequest {
|
||||||
let url = URL(string: "\(baseURL)/chat/completions")!
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertToChatResponse(_ apiResponse: OpenRouterChatResponse) -> ChatResponse {
|
private 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
|
||||||
|
|
||||||
func buildAPIRequest(from request: ChatRequest) throws -> OpenRouterChatRequest {
|
private func buildAPIRequest(from request: ChatRequest) throws -> OpenRouterChatRequest {
|
||||||
let apiMessages = request.messages.map { message -> OpenRouterChatRequest.APIMessage in
|
let 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 {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertToChatResponse(_ apiResponse: OpenRouterChatResponse) throws -> ChatResponse {
|
private 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 {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertToStreamChunk(_ apiChunk: OpenRouterStreamChunk) throws -> StreamChunk {
|
private 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
|
||||||
func decodeImageOutputs(_ outputs: [OpenRouterChatResponse.ImageOutput]) -> [Data]? {
|
private 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,9 +67,6 @@ 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
|
||||||
@@ -107,8 +104,6 @@ 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,7 @@
|
|||||||
|
|
||||||
<nav class="toc">
|
<nav class="toc">
|
||||||
<h2>Contents</h2>
|
<h2>Contents</h2>
|
||||||
<div class="toc-search">
|
<ul>
|
||||||
<input type="search" id="tocSearch" placeholder="Search help topics…" aria-label="Search help topics">
|
|
||||||
</div>
|
|
||||||
<p id="tocNoResults" class="toc-no-results" hidden>No topics match your search.</p>
|
|
||||||
<ul id="tocList">
|
|
||||||
<li><a href="#getting-started">Getting Started</a></li>
|
<li><a href="#getting-started">Getting Started</a></li>
|
||||||
<li><a href="#providers">AI Providers & API Keys</a></li>
|
<li><a href="#providers">AI Providers & API Keys</a></li>
|
||||||
<li><a href="#models">Selecting Models</a></li>
|
<li><a href="#models">Selecting Models</a></li>
|
||||||
@@ -1530,6 +1526,9 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
|||||||
<dt><kbd>⌘,</kbd></dt>
|
<dt><kbd>⌘,</kbd></dt>
|
||||||
<dd>Open Settings</dd>
|
<dd>Open Settings</dd>
|
||||||
|
|
||||||
|
<dt><kbd>⌘/</kbd></dt>
|
||||||
|
<dd>Show in-app Help</dd>
|
||||||
|
|
||||||
<dt><kbd>⌘?</kbd></dt>
|
<dt><kbd>⌘?</kbd></dt>
|
||||||
<dd>Open this Help (macOS Help)</dd>
|
<dd>Open this Help (macOS Help)</dd>
|
||||||
|
|
||||||
@@ -1816,37 +1815,5 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
|||||||
<p>© 2026 oAI - Rune Olsen. For support or feedback, visit <a href="https://gitlab.pm/rune/oai-swift">gitlab.pm</a> or <a href="mailto:support@fubar.pm?subject=oAI Support&body=What can I help you with?">Contact Us</a>.</p>
|
<p>© 2026 oAI - Rune Olsen. For support or feedback, visit <a href="https://gitlab.pm/rune/oai-swift">gitlab.pm</a> or <a href="mailto:support@fubar.pm?subject=oAI Support&body=What can I help you with?">Contact Us</a>.</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
var searchInput = document.getElementById('tocSearch');
|
|
||||||
var tocList = document.getElementById('tocList');
|
|
||||||
var noResults = document.getElementById('tocNoResults');
|
|
||||||
if (!searchInput || !tocList) return;
|
|
||||||
|
|
||||||
var entries = Array.prototype.map.call(tocList.querySelectorAll('li'), function (li) {
|
|
||||||
var link = li.querySelector('a');
|
|
||||||
var id = link ? link.getAttribute('href').slice(1) : null;
|
|
||||||
var section = id ? document.getElementById(id) : null;
|
|
||||||
return {
|
|
||||||
li: li,
|
|
||||||
text: (section ? section.textContent : li.textContent).toLowerCase()
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
searchInput.addEventListener('input', function () {
|
|
||||||
var query = searchInput.value.trim().toLowerCase();
|
|
||||||
var visibleCount = 0;
|
|
||||||
|
|
||||||
entries.forEach(function (entry) {
|
|
||||||
var matches = query === '' || entry.text.indexOf(query) !== -1;
|
|
||||||
entry.li.hidden = !matches;
|
|
||||||
if (matches) visibleCount++;
|
|
||||||
});
|
|
||||||
|
|
||||||
noResults.hidden = visibleCount > 0;
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -101,32 +101,6 @@ nav.toc h2 {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toc-search {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toc-search input[type="search"] {
|
|
||||||
width: 100%;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 15px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--background);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toc-search input[type="search"]:focus {
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toc-no-results {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav.toc ul {
|
nav.toc ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,15 +34,6 @@ struct BackupManifest: Codable {
|
|||||||
let credentials: [String: String]?
|
let credentials: [String: String]?
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - FavoritesPayload
|
|
||||||
|
|
||||||
/// Small standalone file (separate from the full settings backup) so starring a model
|
|
||||||
/// syncs near-instantly across machines instead of waiting for the next full backup.
|
|
||||||
struct FavoritesPayload: Codable {
|
|
||||||
let updatedAt: String
|
|
||||||
let ids: [String]
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - BackupService
|
// MARK: - BackupService
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
@@ -60,8 +51,6 @@ final class BackupService {
|
|||||||
/// URL of the last backup file
|
/// URL of the last backup file
|
||||||
var lastBackupURL: URL?
|
var lastBackupURL: URL?
|
||||||
|
|
||||||
private var autoBackupTimer: Timer?
|
|
||||||
|
|
||||||
// Keys excluded from backup — encrypted_ prefix + internal migration flags
|
// Keys excluded from backup — encrypted_ prefix + internal migration flags
|
||||||
private static let excludedKeys: Set<String> = [
|
private static let excludedKeys: Set<String> = [
|
||||||
"encrypted_openrouterAPIKey",
|
"encrypted_openrouterAPIKey",
|
||||||
@@ -82,7 +71,6 @@ final class BackupService {
|
|||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
checkForExistingBackup()
|
checkForExistingBackup()
|
||||||
startAutoBackupTimer()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - iCloud Path Resolution
|
// MARK: - iCloud Path Resolution
|
||||||
@@ -188,129 +176,6 @@ final class BackupService {
|
|||||||
log.info("Restored \(manifest.settings.count) settings from backup (v\(manifest.version))")
|
log.info("Restored \(manifest.settings.count) settings from backup (v\(manifest.version))")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Favorite Models Sync
|
|
||||||
|
|
||||||
private func favoritesFileURL() -> URL {
|
|
||||||
resolveBackupDirectory().appendingPathComponent("oai_favorites.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write the current local favorites to iCloud Drive. Called whenever a favorite is toggled.
|
|
||||||
func pushFavorites() async {
|
|
||||||
let settings = SettingsService.shared
|
|
||||||
let payload = FavoritesPayload(
|
|
||||||
updatedAt: settings.favoriteModelsUpdatedAt,
|
|
||||||
ids: settings.favoriteModelIds.sorted()
|
|
||||||
)
|
|
||||||
guard let data = try? JSONEncoder().encode(payload) else { return }
|
|
||||||
try? data.write(to: favoritesFileURL(), options: .atomic)
|
|
||||||
log.debug("Pushed \(payload.ids.count) favorite model(s) to iCloud")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Outcome of reconciling local favorites against the iCloud copy. Pure result type --
|
|
||||||
/// see `decideFavoritesSync` for the actual decision logic.
|
|
||||||
enum FavoritesSyncDecision: Equatable {
|
|
||||||
case applyRemote(ids: Set<String>, updatedAt: String)
|
|
||||||
case pushLocal
|
|
||||||
case mergeAndPush(ids: Set<String>, updatedAt: String)
|
|
||||||
case noop
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Last-write-wins reconciliation, pulled out of `syncFavoritesOnLaunch` so the branching
|
|
||||||
/// logic (5 cases: remote absent / remote newer / local newer / tied-but-different /
|
|
||||||
/// tied-and-identical) can be tested without touching iCloud Drive or SettingsService.
|
|
||||||
static func decideFavoritesSync(
|
|
||||||
localIds: Set<String>,
|
|
||||||
localUpdatedAt: String,
|
|
||||||
remote: FavoritesPayload?,
|
|
||||||
now: Date
|
|
||||||
) -> FavoritesSyncDecision {
|
|
||||||
guard let remote else {
|
|
||||||
return .pushLocal
|
|
||||||
}
|
|
||||||
if remote.updatedAt > localUpdatedAt {
|
|
||||||
return .applyRemote(ids: Set(remote.ids), updatedAt: remote.updatedAt)
|
|
||||||
} else if localUpdatedAt > remote.updatedAt {
|
|
||||||
return .pushLocal
|
|
||||||
} else if Set(remote.ids) != localIds {
|
|
||||||
// Tied timestamps (both empty is the common case: two machines that already had
|
|
||||||
// favorites before this sync feature existed, neither ever bumped the timestamp).
|
|
||||||
// Union rather than silently dropping one side's favorites.
|
|
||||||
let merged = localIds.union(remote.ids)
|
|
||||||
return .mergeAndPush(ids: merged, updatedAt: ISO8601DateFormatter().string(from: now))
|
|
||||||
}
|
|
||||||
return .noop
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reconcile local favorites with the iCloud copy using last-write-wins (by timestamp).
|
|
||||||
/// Call on launch (and optionally on app-become-active) to pick up changes from other machines.
|
|
||||||
func syncFavoritesOnLaunch() async {
|
|
||||||
let settings = SettingsService.shared
|
|
||||||
let fileURL = favoritesFileURL()
|
|
||||||
|
|
||||||
let remote: FavoritesPayload? = {
|
|
||||||
guard let data = try? Data(contentsOf: fileURL) else { return nil }
|
|
||||||
return try? JSONDecoder().decode(FavoritesPayload.self, from: data)
|
|
||||||
}()
|
|
||||||
let localIds = settings.favoriteModelIds
|
|
||||||
|
|
||||||
switch Self.decideFavoritesSync(localIds: localIds, localUpdatedAt: settings.favoriteModelsUpdatedAt, remote: remote, now: Date()) {
|
|
||||||
case .pushLocal:
|
|
||||||
await pushFavorites()
|
|
||||||
case .applyRemote(let ids, let updatedAt):
|
|
||||||
settings.favoriteModelIds = ids
|
|
||||||
settings.favoriteModelsUpdatedAt = updatedAt
|
|
||||||
log.info("Applied \(ids.count) favorite model(s) from iCloud (remote was newer)")
|
|
||||||
case .mergeAndPush(let ids, let updatedAt):
|
|
||||||
settings.favoriteModelIds = ids
|
|
||||||
settings.favoriteModelsUpdatedAt = updatedAt
|
|
||||||
await pushFavorites()
|
|
||||||
log.info("Merged favorites from iCloud (tied timestamps) — union of \(localIds.count) local + \(remote?.ids.count ?? 0) remote")
|
|
||||||
case .noop:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Automatic Backup
|
|
||||||
|
|
||||||
private static let dailyInterval: TimeInterval = 24 * 3600
|
|
||||||
private static let weeklyInterval: TimeInterval = 7 * 24 * 3600
|
|
||||||
|
|
||||||
/// Checked once at launch and hourly thereafter while the app is running.
|
|
||||||
private func startAutoBackupTimer() {
|
|
||||||
Task { await checkAndPerformAutoBackupIfDue() }
|
|
||||||
autoBackupTimer = Timer.scheduledTimer(withTimeInterval: 3600, repeats: true) { [weak self] _ in
|
|
||||||
Task { await self?.checkAndPerformAutoBackupIfDue() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether an automatic backup should run now, given the chosen frequency and the last
|
|
||||||
/// known backup time. Pulled out of `checkAndPerformAutoBackupIfDue` for testability --
|
|
||||||
/// "manual" is never due, a missing last-backup-date is always due, otherwise it's due
|
|
||||||
/// once the elapsed time reaches the frequency's interval.
|
|
||||||
static func isBackupDue(frequency: String, lastBackupDate: Date?, now: Date) -> Bool {
|
|
||||||
let interval: TimeInterval
|
|
||||||
switch frequency {
|
|
||||||
case "daily": interval = dailyInterval
|
|
||||||
case "weekly": interval = weeklyInterval
|
|
||||||
default: return false // "manual" — user triggers backups by hand
|
|
||||||
}
|
|
||||||
guard let last = lastBackupDate else { return true }
|
|
||||||
return now.timeIntervalSince(last) >= interval
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkAndPerformAutoBackupIfDue() async {
|
|
||||||
let frequency = SettingsService.shared.autoBackupFrequency
|
|
||||||
guard frequency == "daily" || frequency == "weekly" else { return }
|
|
||||||
|
|
||||||
checkForExistingBackup()
|
|
||||||
guard Self.isBackupDue(frequency: frequency, lastBackupDate: lastBackupDate, now: Date()) else {
|
|
||||||
return // not due yet
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("Automatic backup (\(frequency, privacy: .public)) is due — backing up now")
|
|
||||||
_ = try? await exportSettings()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func appVersion() -> String {
|
private func appVersion() -> String {
|
||||||
|
|||||||
@@ -63,8 +63,7 @@ class ContactsService {
|
|||||||
store.requestAccess(for: .contacts) { granted, error in
|
store.requestAccess(for: .contacts) { granted, error in
|
||||||
let after = CNContactStore.authorizationStatus(for: .contacts)
|
let after = CNContactStore.authorizationStatus(for: .contacts)
|
||||||
if let error {
|
if let error {
|
||||||
let nsError = error as NSError
|
Log.mcp.error("ContactsService.requestAccess: error=\(error.localizedDescription); granted=\(granted); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||||
Log.mcp.error("ContactsService.requestAccess: error=\(error.localizedDescription) domain=\(nsError.domain) code=\(nsError.code) userInfo=\(nsError.userInfo); granted=\(granted); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
|
|
||||||
} else {
|
} else {
|
||||||
Log.mcp.info("ContactsService.requestAccess: granted=\(granted); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
|
Log.mcp.info("ContactsService.requestAccess: granted=\(granted); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,12 +47,7 @@ enum SelectionStrategy {
|
|||||||
final class ContextSelectionService {
|
final class ContextSelectionService {
|
||||||
static let shared = ContextSelectionService()
|
static let shared = ContextSelectionService()
|
||||||
|
|
||||||
private let db: DatabaseService
|
private init() {}
|
||||||
|
|
||||||
/// `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(
|
||||||
@@ -103,7 +98,7 @@ final class ContextSelectionService {
|
|||||||
|
|
||||||
// MARK: - Smart Selection Algorithm
|
// MARK: - Smart Selection Algorithm
|
||||||
|
|
||||||
func smartSelection(allMessages: [Message], maxTokens: Int, conversationId: UUID? = nil) -> ContextWindow {
|
private func smartSelection(allMessages: [Message], maxTokens: Int, conversationId: UUID? = nil) -> ContextWindow {
|
||||||
guard !allMessages.isEmpty else {
|
guard !allMessages.isEmpty else {
|
||||||
return ContextWindow(messages: [], summaries: [], totalTokens: 0, excludedCount: 0)
|
return ContextWindow(messages: [], summaries: [], totalTokens: 0, excludedCount: 0)
|
||||||
}
|
}
|
||||||
@@ -196,12 +191,12 @@ final class ContextSelectionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get summaries for excluded message ranges
|
/// Get summaries for excluded message ranges
|
||||||
func getSummariesForExcludedRange(
|
private func getSummariesForExcludedRange(
|
||||||
conversationId: UUID,
|
conversationId: UUID,
|
||||||
totalMessages: Int,
|
totalMessages: Int,
|
||||||
selectedCount: Int
|
selectedCount: Int
|
||||||
) -> [String] {
|
) -> [String] {
|
||||||
guard let summaryRecords = try? db.getConversationSummaries(conversationId: conversationId) else {
|
guard let summaryRecords = try? DatabaseService.shared.getConversationSummaries(conversationId: conversationId) else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +214,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
|
||||||
func getImportanceScore(_ message: Message) -> Double {
|
private 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)
|
||||||
@@ -243,8 +238,8 @@ final class ContextSelectionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a message is starred by the user
|
/// Check if a message is starred by the user
|
||||||
func isMessageStarred(_ message: Message) -> Bool {
|
private func isMessageStarred(_ message: Message) -> Bool {
|
||||||
guard let metadata = try? db.getMessageMetadata(messageId: message.id) else {
|
guard let metadata = try? DatabaseService.shared.getMessageMetadata(messageId: message.id) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return metadata.user_starred == 1
|
return metadata.user_starred == 1
|
||||||
@@ -253,7 +248,7 @@ final class ContextSelectionService {
|
|||||||
// MARK: - Token Estimation
|
// MARK: - Token Estimation
|
||||||
|
|
||||||
/// Estimate token count for messages (rough approximation)
|
/// Estimate token count for messages (rough approximation)
|
||||||
func estimateTokens(_ messages: [Message]) -> Int {
|
private 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,14 +154,7 @@ final class DatabaseService: Sendable {
|
|||||||
(try? isoStyle.parse(string)) ?? (try? Date(string, strategy: .iso8601))
|
(try? isoStyle.parse(string)) ?? (try? Date(string, strategy: .iso8601))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test seam: build a DatabaseService around a caller-provided queue (e.g. an in-memory `DatabaseQueue()`).
|
nonisolated private init() {
|
||||||
/// 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)
|
||||||
@@ -170,25 +163,12 @@ 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)")
|
||||||
self.init(dbQueue: try! DatabaseQueue(path: dbPath))
|
dbQueue = try! DatabaseQueue(path: dbPath)
|
||||||
|
|
||||||
|
try! migrator.migrate(dbQueue)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test seam: a throwaway in-memory instance with all migrations applied. Avoids requiring
|
private nonisolated var migrator: DatabaseMigrator {
|
||||||
/// `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)
|
||||||
nonisolated func serializeEmbedding(_ embedding: [Float]) -> Data {
|
private 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
|
||||||
nonisolated func deserializeEmbedding(_ data: Data) -> [Float] {
|
private 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 \(Self.extractProvider(from: settings.syncRepoURL))"
|
return "Connected to \(extractProvider())"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clone repository to local path
|
/// Clone repository to local path
|
||||||
@@ -507,7 +507,7 @@ class GitSyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectSecretsInText(_ text: String) -> [String] {
|
private func detectSecretsInText(_ text: String) -> [String] {
|
||||||
let patterns: [(name: String, pattern: String)] = [
|
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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertToSSH(_ url: String) -> String {
|
private 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
|
||||||
}
|
}
|
||||||
|
|
||||||
func injectCredentials(_ url: String, username: String, password: String) -> String {
|
private func injectCredentials(_ url: String, username: String, password: String) -> String {
|
||||||
// Convert https://github.com/user/repo.git
|
// 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,13 +697,14 @@ class GitSyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeFilename(_ name: String) -> String {
|
private 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: "-")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func extractProvider(from url: String) -> String {
|
private func extractProvider() -> 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,7 +23,6 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
import PDFKit
|
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
class MCPService {
|
class MCPService {
|
||||||
@@ -156,7 +155,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. PDFs are supported (text layer is extracted automatically) — scanned/image-only PDFs with no text layer will return an error. Maximum file size is 10MB.",
|
description: "Read the contents of a file. Returns the text content of the file. Maximum file size is 10MB.",
|
||||||
properties: [
|
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")
|
||||||
],
|
],
|
||||||
@@ -173,7 +172,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 (PDF text layers are searched too).",
|
description: "Search for files by name pattern or content. Use 'pattern' for filename glob matching (e.g. '*.swift'). Use 'content_search' for searching inside file contents.",
|
||||||
properties: [
|
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)"),
|
||||||
@@ -537,17 +536,8 @@ class MCPService {
|
|||||||
return ["error": "File too large (\(sizeMB) MB, max 10 MB)"]
|
return ["error": "File too large (\(sizeMB) MB, max 10 MB)"]
|
||||||
}
|
}
|
||||||
|
|
||||||
let content: String
|
guard let content = try? String(contentsOfFile: resolved, encoding: .utf8) else {
|
||||||
if (resolved as NSString).pathExtension.lowercased() == "pdf" {
|
return ["error": "Cannot read file as UTF-8 text: \(filePath)"]
|
||||||
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
|
||||||
@@ -564,19 +554,6 @@ 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
|
||||||
|
|
||||||
@@ -683,13 +660,10 @@ class MCPService {
|
|||||||
|
|
||||||
// Content search if requested
|
// Content search if requested
|
||||||
if let searchText = contentSearch, !searchText.isEmpty {
|
if let searchText = contentSearch, !searchText.isEmpty {
|
||||||
let content: String?
|
guard let content = try? String(contentsOfFile: fullPath, encoding: .utf8) else {
|
||||||
if (fullPath as NSString).pathExtension.lowercased() == "pdf" {
|
continue
|
||||||
content = extractPDFText(atPath: fullPath)
|
|
||||||
} else {
|
|
||||||
content = try? String(contentsOfFile: fullPath, encoding: .utf8)
|
|
||||||
}
|
}
|
||||||
guard let content, content.localizedCaseInsensitiveContains(searchText) else {
|
if !content.localizedCaseInsensitiveContains(searchText) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -970,7 +944,7 @@ class MCPService {
|
|||||||
let readOnlyTools: [Tool] = [
|
let readOnlyTools: [Tool] = [
|
||||||
makeTool(
|
makeTool(
|
||||||
name: "read_file",
|
name: "read_file",
|
||||||
description: "Read the contents of a file. PDFs are supported (text layer extracted automatically). Maximum file size is 10MB.",
|
description: "Read the contents of a file. Maximum file size is 10MB.",
|
||||||
properties: ["file_path": prop("string", "The absolute path to the file to read")],
|
properties: ["file_path": prop("string", "The absolute path to the file to read")],
|
||||||
required: ["file_path"]
|
required: ["file_path"]
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -25,11 +25,16 @@ import Foundation
|
|||||||
import os
|
import os
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
/// Kill switch for the entire Personal Data tools section (Calendar/Reminders/Contacts/
|
/// Kill switch for the Personal Data tools (Calendar/Reminders/Contacts/Location & Maps).
|
||||||
/// Location & Maps). Hides the UI and forces every `*Enabled` getter to return `false`
|
/// Flip `isHiddenPendingAppleFix` back to `false` once Apple fixes the macOS 27 beta TCC bug
|
||||||
/// regardless of the persisted DB value — no code deleted, just inert.
|
/// (filed with Apple). Each flag hides the relevant UI and forces the `*Enabled` getter to
|
||||||
|
/// return `false` regardless of the persisted DB value — no code deleted, just inert.
|
||||||
enum PersonalDataTools {
|
enum PersonalDataTools {
|
||||||
|
/// Hides the entire Personal Data section. Flip to `false` once all four services work.
|
||||||
static let isHiddenPendingAppleFix = false
|
static let isHiddenPendingAppleFix = false
|
||||||
|
/// Hides only the Contacts row. Contacts TCC still broken under hardened runtime on
|
||||||
|
/// macOS 27 beta 2 while Calendar/Reminders/Location are fixed. Flip to `false` once fixed.
|
||||||
|
static let isContactsHiddenPendingAppleFix = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
@@ -515,33 +520,10 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ISO8601 timestamp of the last local change to favoriteModelIds — used to
|
|
||||||
/// resolve last-write-wins conflicts when syncing favorites across machines.
|
|
||||||
var favoriteModelsUpdatedAt: String {
|
|
||||||
get { cache["favoriteModelsUpdatedAt"] ?? "" }
|
|
||||||
set {
|
|
||||||
cache["favoriteModelsUpdatedAt"] = newValue
|
|
||||||
DatabaseService.shared.setSetting(key: "favoriteModelsUpdatedAt", value: newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toggleFavoriteModel(_ id: String) {
|
func toggleFavoriteModel(_ id: String) {
|
||||||
var favs = favoriteModelIds
|
var favs = favoriteModelIds
|
||||||
if favs.contains(id) { favs.remove(id) } else { favs.insert(id) }
|
if favs.contains(id) { favs.remove(id) } else { favs.insert(id) }
|
||||||
favoriteModelIds = favs
|
favoriteModelIds = favs
|
||||||
favoriteModelsUpdatedAt = ISO8601DateFormatter().string(from: Date())
|
|
||||||
Task { await BackupService.shared.pushFavorites() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Automatic Backup
|
|
||||||
|
|
||||||
/// "manual" (default), "daily", or "weekly"
|
|
||||||
var autoBackupFrequency: String {
|
|
||||||
get { cache["autoBackupFrequency"] ?? "manual" }
|
|
||||||
set {
|
|
||||||
cache["autoBackupFrequency"] = newValue
|
|
||||||
DatabaseService.shared.setSetting(key: "autoBackupFrequency", value: newValue)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Anytype MCP Settings
|
// MARK: - Anytype MCP Settings
|
||||||
@@ -713,7 +695,7 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var contactsEnabled: Bool {
|
var contactsEnabled: Bool {
|
||||||
get { !PersonalDataTools.isHiddenPendingAppleFix && cache["contactsEnabled"] == "true" }
|
get { !PersonalDataTools.isHiddenPendingAppleFix && !PersonalDataTools.isContactsHiddenPendingAppleFix && cache["contactsEnabled"] == "true" }
|
||||||
set {
|
set {
|
||||||
cache["contactsEnabled"] = String(newValue)
|
cache["contactsEnabled"] = String(newValue)
|
||||||
DatabaseService.shared.setSetting(key: "contactsEnabled", value: String(newValue))
|
DatabaseService.shared.setSetting(key: "contactsEnabled", value: String(newValue))
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ You are a helpful AI assistant. Follow these core principles:
|
|||||||
- **Be Direct**: Provide concise, relevant answers. No unnecessary preambles.
|
- **Be Direct**: Provide concise, relevant answers. No unnecessary preambles.
|
||||||
- **Show Your Work**: If you use capabilities (tools, web search, etc.), demonstrate what you did.
|
- **Show Your Work**: If you use capabilities (tools, web search, etc.), demonstrate what you did.
|
||||||
- **Complete Tasks Properly**: If you start something, finish it correctly.
|
- **Complete Tasks Properly**: If you start something, finish it correctly.
|
||||||
- **Match the User's Language**: Always reply in the same language the user is writing in, even when the request itself involves another language (e.g. a translation or spelling request) — the target language applies to the content you produce, not to your own reply.
|
|
||||||
|
|
||||||
## FORMATTING
|
## FORMATTING
|
||||||
|
|
||||||
@@ -151,14 +150,8 @@ 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 modelSupportsTools,
|
if settings.customPromptMode == .replace,
|
||||||
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
|
||||||
@@ -176,32 +169,29 @@ 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 && modelSupportsTools {
|
if mcpEnabled {
|
||||||
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 modelSupportsTools,
|
if settings.customPromptMode == .append,
|
||||||
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)
|
||||||
if modelSupportsTools {
|
let activeSkills = settings.agentSkills.filter { $0.isActive }
|
||||||
let activeSkills = settings.agentSkills.filter { $0.isActive }
|
if !activeSkills.isEmpty {
|
||||||
if !activeSkills.isEmpty {
|
prompt += "\n\n---\n\n## Installed Skills\n\nThe following skills are active. Apply them when relevant:\n\n"
|
||||||
prompt += "\n\n---\n\n## Installed Skills\n\nThe following skills are active. Apply them when relevant:\n\n"
|
for skill in activeSkills {
|
||||||
for skill in activeSkills {
|
prompt += "### \(skill.name)\n\n\(skill.content)\n\n"
|
||||||
prompt += "### \(skill.name)\n\n\(skill.content)\n\n"
|
let files = AgentSkillFilesService.shared.readTextFiles(for: skill.id)
|
||||||
let files = AgentSkillFilesService.shared.readTextFiles(for: skill.id)
|
if !files.isEmpty {
|
||||||
if !files.isEmpty {
|
prompt += "**Skill Data Files:**\n\n"
|
||||||
prompt += "**Skill Data Files:**\n\n"
|
for (name, content) in files {
|
||||||
for (name, content) in files {
|
let ext = URL(fileURLWithPath: name).pathExtension.lowercased()
|
||||||
let ext = URL(fileURLWithPath: name).pathExtension.lowercased()
|
prompt += "**\(name):**\n```\(ext)\n\(content)\n```\n\n"
|
||||||
prompt += "**\(name):**\n```\(ext)\n\(content)\n```\n\n"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -422,17 +412,15 @@ 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 = Self.inferProvider(from: model.id) ?? currentProvider
|
let newProvider = 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? { Self.inferProvider(from: modelId) }
|
func inferProviderPublic(from modelId: String) -> Settings.Provider? { inferProvider(from: modelId) }
|
||||||
|
|
||||||
nonisolated static func inferProvider(from modelId: String) -> Settings.Provider? {
|
private 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")
|
||||||
@@ -448,7 +436,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 = Self.inferProvider(from: modelId) else {
|
guard let targetProvider = 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
|
||||||
}
|
}
|
||||||
@@ -953,7 +941,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 ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
|
let cost: Double? = hasPricing ? 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)
|
||||||
}
|
}
|
||||||
@@ -1017,7 +1005,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 ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
|
let cost: Double? = hasPricing ? 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)
|
||||||
}
|
}
|
||||||
@@ -1317,7 +1305,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = Self.detectGoodbyePhrase(in: "")
|
_ = 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)"
|
||||||
@@ -1593,7 +1581,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 ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
|
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||||
sessionStats.addMessage(
|
sessionStats.addMessage(
|
||||||
inputTokens: usage.promptTokens,
|
inputTokens: usage.promptTokens,
|
||||||
outputTokens: usage.completionTokens,
|
outputTokens: usage.completionTokens,
|
||||||
@@ -1618,7 +1606,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 ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
|
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||||
if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) {
|
if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) {
|
||||||
messages[index].cost = cost
|
messages[index].cost = cost
|
||||||
}
|
}
|
||||||
@@ -1899,43 +1887,40 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure auto-save eligibility check, pulled out of `shouldAutoSave()` so the criteria
|
|
||||||
/// (enabled, configured, cloned, min message count, not-already-saved) can be tested
|
|
||||||
/// without a live ChatViewModel/GitSyncService/SettingsService.
|
|
||||||
nonisolated static func shouldAutoSave(
|
|
||||||
syncEnabled: Bool,
|
|
||||||
syncAutoSave: Bool,
|
|
||||||
syncConfigured: Bool,
|
|
||||||
isCloned: Bool,
|
|
||||||
chatMessageCount: Int,
|
|
||||||
minMessages: Int,
|
|
||||||
lastSavedConversationId: String?,
|
|
||||||
currentConversationHash: String
|
|
||||||
) -> Bool {
|
|
||||||
guard syncEnabled && syncAutoSave else { return false }
|
|
||||||
guard syncConfigured else { return false }
|
|
||||||
guard isCloned else { return false }
|
|
||||||
guard chatMessageCount >= minMessages else { return false }
|
|
||||||
if let lastSavedId = lastSavedConversationId, lastSavedId == currentConversationHash {
|
|
||||||
return false // Already saved this exact conversation
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if conversation should be auto-saved based on criteria
|
/// 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 }
|
||||||
let currentHash = chatMessages.map { $0.content }.joined()
|
guard chatMessages.count >= settings.syncAutoSaveMinMessages else {
|
||||||
return Self.shouldAutoSave(
|
return false
|
||||||
syncEnabled: settings.syncEnabled,
|
}
|
||||||
syncAutoSave: settings.syncAutoSave,
|
|
||||||
syncConfigured: settings.syncConfigured,
|
// Check if already auto-saved this conversation
|
||||||
isCloned: GitSyncService.shared.syncStatus.isCloned,
|
// (prevent duplicate saves)
|
||||||
chatMessageCount: chatMessages.count,
|
if let lastSavedId = settings.syncLastAutoSaveConversationId {
|
||||||
minMessages: settings.syncAutoSaveMinMessages,
|
// Create a hash of current conversation to detect if it's the same one
|
||||||
lastSavedConversationId: settings.syncLastAutoSaveConversationId,
|
let currentHash = chatMessages.map { $0.content }.joined()
|
||||||
currentConversationHash: currentHash
|
if lastSavedId == 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
|
||||||
@@ -2052,7 +2037,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Detect goodbye phrases in user message
|
/// Detect goodbye phrases in user message
|
||||||
nonisolated static func detectGoodbyePhrase(in text: String) -> Bool {
|
func detectGoodbyePhrase(in text: String) -> Bool {
|
||||||
let lowercased = text.lowercased()
|
let lowercased = text.lowercased()
|
||||||
let goodbyePhrases = [
|
let goodbyePhrases = [
|
||||||
"bye", "goodbye", "bye bye", "good bye",
|
"bye", "goodbye", "bye bye", "good bye",
|
||||||
@@ -2092,7 +2077,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
updateConversationTracking()
|
updateConversationTracking()
|
||||||
|
|
||||||
// Check for goodbye phrase
|
// Check for goodbye phrase
|
||||||
if Self.detectGoodbyePhrase(in: text) {
|
if 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))
|
||||||
@@ -2273,7 +2258,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.
|
||||||
nonisolated static func calculateCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double {
|
private func calculateCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double {
|
||||||
let inputCost = Double(usage.promptTokens) * pricing.prompt / 1_000_000
|
let 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
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ import Darwin // uname, sysctlbyname
|
|||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(ChatViewModel.self) var chatViewModel
|
@Environment(ChatViewModel.self) var chatViewModel
|
||||||
private var updateService = UpdateCheckService.shared
|
private var updateService = UpdateCheckService.shared
|
||||||
@State private var columnVisibility: NavigationSplitViewVisibility =
|
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||||
SettingsService.shared.sidebarVisible ? .all : .detailOnly
|
|
||||||
@State private var showIntelWarning = false
|
@State private var showIntelWarning = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -47,9 +46,6 @@ struct ContentView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.frame(minWidth: 860, minHeight: 560)
|
.frame(minWidth: 860, minHeight: 560)
|
||||||
.onChange(of: columnVisibility) { _, newValue in
|
|
||||||
SettingsService.shared.sidebarVisible = newValue != .detailOnly
|
|
||||||
}
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed }
|
NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed }
|
||||||
|
|||||||
@@ -78,20 +78,6 @@ 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ private let keyboardShortcuts: [(key: String, description: String)] = [
|
|||||||
("\u{2318}L", "Conversations"),
|
("\u{2318}L", "Conversations"),
|
||||||
("\u{21E7}\u{2318}S", "Statistics"),
|
("\u{21E7}\u{2318}S", "Statistics"),
|
||||||
("\u{2318},", "Settings"),
|
("\u{2318},", "Settings"),
|
||||||
|
("\u{2318}/", "Help"),
|
||||||
]
|
]
|
||||||
|
|
||||||
// MARK: - HelpView
|
// MARK: - HelpView
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
import FoundationModels
|
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@@ -126,7 +125,6 @@ You are a helpful AI assistant. Follow these core principles:
|
|||||||
- **Be Direct**: Provide concise, relevant answers. No unnecessary preambles.
|
- **Be Direct**: Provide concise, relevant answers. No unnecessary preambles.
|
||||||
- **Show Your Work**: If you use capabilities (tools, web search, etc.), demonstrate what you did.
|
- **Show Your Work**: If you use capabilities (tools, web search, etc.), demonstrate what you did.
|
||||||
- **Complete Tasks Properly**: If you start something, finish it correctly.
|
- **Complete Tasks Properly**: If you start something, finish it correctly.
|
||||||
- **Match the User's Language**: Always reply in the same language the user is writing in, even when the request itself involves another language (e.g. a translation or spelling request) — the target language applies to the content you produce, not to your own reply.
|
|
||||||
|
|
||||||
## FORMATTING
|
## FORMATTING
|
||||||
|
|
||||||
@@ -320,36 +318,6 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apple Intelligence
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
sectionHeader("Apple Intelligence")
|
|
||||||
formSection {
|
|
||||||
row("Status") {
|
|
||||||
appleIntelligenceStatusBadge
|
|
||||||
}
|
|
||||||
rowDivider()
|
|
||||||
row("Model") {
|
|
||||||
Text("On-Device (4K context)")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
rowDivider()
|
|
||||||
row("") {
|
|
||||||
Button("Open Apple Intelligence Settings") {
|
|
||||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.aisettings") {
|
|
||||||
NSWorkspace.shared.open(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("⚠️ Beta — Apple's on-device model is still in active development (currently macOS 27 beta). Expect rough edges: a small 4K context window, occasional generation errors, and no tool support yet.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.bottom, 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Features
|
// Features
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
sectionHeader("Features")
|
sectionHeader("Features")
|
||||||
@@ -860,6 +828,10 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
externalMCPSection
|
externalMCPSection
|
||||||
|
|
||||||
// MARK: Personal Data
|
// MARK: Personal Data
|
||||||
|
// isHiddenPendingAppleFix hides the entire section (macOS 27 beta TCC bug).
|
||||||
|
// isContactsHiddenPendingAppleFix hides just the Contacts row (still broken in beta 2
|
||||||
|
// under hardened runtime while Calendar/Reminders/Location are fixed). Flip each flag
|
||||||
|
// to false once Apple ships a fix.
|
||||||
if !PersonalDataTools.isHiddenPendingAppleFix {
|
if !PersonalDataTools.isHiddenPendingAppleFix {
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
@@ -870,8 +842,18 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
.foregroundStyle(.teal)
|
.foregroundStyle(.teal)
|
||||||
Text("Personal Data")
|
Text("Personal Data")
|
||||||
.font(.system(size: 18, weight: .semibold))
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
if PersonalDataTools.isContactsHiddenPendingAppleFix {
|
||||||
|
Text("β")
|
||||||
|
.font(.system(size: 11, weight: .bold))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.padding(.horizontal, 5)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.orange.opacity(0.15))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||||
|
.help("Beta Feature. May change.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text("Let the AI access your Calendar, Reminders, Contacts, and Location & Maps to answer questions about your schedule and surroundings. Each service is opt-in and uses standard macOS permission prompts. This functionality is in beta and may change.")
|
Text("Let the AI access your Calendar, Reminders, and Location & Maps to answer questions about your schedule and surroundings. Each service is opt-in and uses standard macOS permission prompts. This functionality is in beta and may change.")
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -906,14 +888,16 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
requestAccess: { remindersAccessState = await EventKitService.shared.requestReminderAccess() ? .granted : EventKitService.shared.reminderAccessState }
|
requestAccess: { remindersAccessState = await EventKitService.shared.requestReminderAccess() ? .granted : EventKitService.shared.reminderAccessState }
|
||||||
)
|
)
|
||||||
rowDivider()
|
rowDivider()
|
||||||
personalDataRow(
|
if !PersonalDataTools.isContactsHiddenPendingAppleFix {
|
||||||
title: "Contacts",
|
personalDataRow(
|
||||||
isEnabled: $settingsService.contactsEnabled,
|
title: "Contacts",
|
||||||
state: contactsAccessState,
|
isEnabled: $settingsService.contactsEnabled,
|
||||||
systemSettingsAnchor: "Privacy_Contacts",
|
state: contactsAccessState,
|
||||||
requestAccess: { contactsAccessState = await ContactsService.shared.requestAccess() ? .granted : ContactsService.shared.accessState }
|
systemSettingsAnchor: "Privacy_Contacts",
|
||||||
)
|
requestAccess: { contactsAccessState = await ContactsService.shared.requestAccess() ? .granted : ContactsService.shared.accessState }
|
||||||
rowDivider()
|
)
|
||||||
|
rowDivider()
|
||||||
|
}
|
||||||
personalDataRow(
|
personalDataRow(
|
||||||
title: "Location & Maps",
|
title: "Location & Maps",
|
||||||
isEnabled: $settingsService.locationMapsEnabled,
|
isEnabled: $settingsService.locationMapsEnabled,
|
||||||
@@ -2611,37 +2595,6 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatic Backup
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
sectionHeader("Automatic Backup")
|
|
||||||
formSection {
|
|
||||||
row("Frequency") {
|
|
||||||
Picker("", selection: $settingsService.autoBackupFrequency) {
|
|
||||||
Text("Off").tag("manual")
|
|
||||||
Text("Daily").tag("daily")
|
|
||||||
Text("Weekly").tag("weekly")
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.frame(width: 220)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text("When enabled, oAI backs up automatically in the background (checked at launch and hourly while running) — no need to press \"Back Up Now\" yourself.")
|
|
||||||
.font(.system(size: 13))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Favorites Sync
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
sectionHeader("Favorite Models")
|
|
||||||
Text("Starred models sync automatically via the same iCloud Drive folder — star a model on one Mac and it appears starred on your others within a launch or two.")
|
|
||||||
.font(.system(size: 13))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
sectionHeader("Actions")
|
sectionHeader("Actions")
|
||||||
@@ -3120,28 +3073,6 @@ 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 {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.personal-information.reminders</key>
|
<key>com.apple.security.personal-information.reminders</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.personal-information.addressbook</key>
|
<key>com.apple.security.personal-information.contacts</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.personal-information.location</key>
|
<key>com.apple.security.personal-information.location</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|||||||
+8
-19
@@ -43,11 +43,6 @@ struct oAIApp: App {
|
|||||||
await GitSyncService.shared.syncOnStartup()
|
await GitSyncService.shared.syncOnStartup()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconcile starred models with the iCloud copy (cross-machine favorites sync)
|
|
||||||
Task {
|
|
||||||
await BackupService.shared.syncFavoritesOnLaunch()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for updates in the background
|
// Check for updates in the background
|
||||||
UpdateCheckService.shared.checkForUpdates()
|
UpdateCheckService.shared.checkForUpdates()
|
||||||
}
|
}
|
||||||
@@ -61,11 +56,6 @@ struct oAIApp: App {
|
|||||||
AboutView()
|
AboutView()
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
|
||||||
Task {
|
|
||||||
await BackupService.shared.syncFavoritesOnLaunch()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
|
||||||
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
|
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
|
||||||
Task {
|
Task {
|
||||||
@@ -126,11 +116,8 @@ struct oAIApp: App {
|
|||||||
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
|
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Chat menu ─────────────────────────────────────────────────
|
// ── View menu ─────────────────────────────────────────────────
|
||||||
// Named "Chat" (not "View") to avoid colliding with the "View" menu
|
CommandMenu("View") {
|
||||||
// macOS auto-adds for NavigationSplitView (Enter Full Screen, etc.) —
|
|
||||||
// two menus both titled "View" would otherwise appear in the menu bar.
|
|
||||||
CommandMenu("Chat") {
|
|
||||||
Button("Select Model") { chatViewModel.showModelSelector = true }
|
Button("Select Model") { chatViewModel.showModelSelector = true }
|
||||||
.keyboardShortcut("m", modifiers: .command)
|
.keyboardShortcut("m", modifiers: .command)
|
||||||
|
|
||||||
@@ -145,6 +132,9 @@ struct oAIApp: App {
|
|||||||
Button("Command History") { chatViewModel.showHistory = true }
|
Button("Command History") { chatViewModel.showHistory = true }
|
||||||
.keyboardShortcut("h", modifiers: [.command, .shift])
|
.keyboardShortcut("h", modifiers: [.command, .shift])
|
||||||
|
|
||||||
|
Button("In-App Help") { chatViewModel.showHelp = true }
|
||||||
|
.keyboardShortcut("/", modifiers: .command)
|
||||||
|
|
||||||
Button("Credits") { chatViewModel.showCredits = true }
|
Button("Credits") { chatViewModel.showCredits = true }
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
@@ -171,12 +161,11 @@ struct oAIApp: App {
|
|||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
private func openHelp() {
|
private func openHelp() {
|
||||||
// Opens the Help Book's index.html directly in the default browser rather than
|
|
||||||
// through NSHelpManager/Help Viewer — see CLAUDE.md's macOS 27 beta note for why
|
|
||||||
// (Apple's Tips.app replacement for Help Viewer can't resolve anchors on this beta).
|
|
||||||
// Revisit once macOS 27 reaches RC.
|
|
||||||
if let helpBookURL = Bundle.main.url(forResource: "oAI.help", withExtension: nil) {
|
if let helpBookURL = Bundle.main.url(forResource: "oAI.help", withExtension: nil) {
|
||||||
NSWorkspace.shared.open(helpBookURL.appendingPathComponent("Contents/Resources/en.lproj/index.html"))
|
NSWorkspace.shared.open(helpBookURL.appendingPathComponent("Contents/Resources/en.lproj/index.html"))
|
||||||
|
} else {
|
||||||
|
// Fallback to Apple Help if help book not found
|
||||||
|
NSHelpManager.shared.openHelpAnchor("", inBook: Bundle.main.object(forInfoDictionaryKey: "CFBundleHelpBookName") as? String)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
//
|
|
||||||
// AIProviderTests.swift
|
|
||||||
// oAITests
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import oAI
|
|
||||||
|
|
||||||
@Suite("ChatResponse / Usage decoding")
|
|
||||||
struct AIProviderTests {
|
|
||||||
|
|
||||||
@Test("Usage decodes token counts and cache fields from snake_case keys")
|
|
||||||
func usageDecodesTokenCounts() throws {
|
|
||||||
let json = """
|
|
||||||
{
|
|
||||||
"prompt_tokens": 100,
|
|
||||||
"completion_tokens": 50,
|
|
||||||
"total_tokens": 150,
|
|
||||||
"cache_creation_input_tokens": 20,
|
|
||||||
"cache_read_input_tokens": 10
|
|
||||||
}
|
|
||||||
""".data(using: .utf8)!
|
|
||||||
let usage = try JSONDecoder().decode(ChatResponse.Usage.self, from: json)
|
|
||||||
#expect(usage.promptTokens == 100)
|
|
||||||
#expect(usage.completionTokens == 50)
|
|
||||||
#expect(usage.totalTokens == 150)
|
|
||||||
#expect(usage.cacheCreationInputTokens == 20)
|
|
||||||
#expect(usage.cacheReadInputTokens == 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Usage.rawCostUSD is always nil after decoding, even if a matching key were present")
|
|
||||||
func usageRawCostNeverDecoded() throws {
|
|
||||||
// rawCostUSD has no CodingKeys entry at all -- it's only ever set via the
|
|
||||||
// memberwise init, never from API JSON. Confirm decode always yields nil,
|
|
||||||
// which is exactly the "$0.0000 cost" regression this guards against.
|
|
||||||
let json = """
|
|
||||||
{
|
|
||||||
"prompt_tokens": 1,
|
|
||||||
"completion_tokens": 1,
|
|
||||||
"total_tokens": 2,
|
|
||||||
"raw_cost_u_s_d": 5.0
|
|
||||||
}
|
|
||||||
""".data(using: .utf8)!
|
|
||||||
let usage = try JSONDecoder().decode(ChatResponse.Usage.self, from: json)
|
|
||||||
#expect(usage.rawCostUSD == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Usage cache fields are nil when absent from the response")
|
|
||||||
func usageMissingCacheFields() throws {
|
|
||||||
let json = #"{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}"#.data(using: .utf8)!
|
|
||||||
let usage = try JSONDecoder().decode(ChatResponse.Usage.self, from: json)
|
|
||||||
#expect(usage.cacheCreationInputTokens == nil)
|
|
||||||
#expect(usage.cacheReadInputTokens == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("ChatResponse decodes core fields and forces toolCalls/generatedImages to nil")
|
|
||||||
func chatResponseDecodesCoreFieldsOnly() throws {
|
|
||||||
let json = """
|
|
||||||
{
|
|
||||||
"id": "resp-1",
|
|
||||||
"model": "test-model",
|
|
||||||
"content": "hello there",
|
|
||||||
"role": "assistant",
|
|
||||||
"finishReason": "stop",
|
|
||||||
"created": 1700000000
|
|
||||||
}
|
|
||||||
""".data(using: .utf8)!
|
|
||||||
let response = try JSONDecoder().decode(ChatResponse.self, from: json)
|
|
||||||
#expect(response.id == "resp-1")
|
|
||||||
#expect(response.model == "test-model")
|
|
||||||
#expect(response.content == "hello there")
|
|
||||||
#expect(response.role == "assistant")
|
|
||||||
#expect(response.finishReason == "stop")
|
|
||||||
// Not part of CodingKeys -- always nil straight out of decode.
|
|
||||||
#expect(response.toolCalls == nil)
|
|
||||||
#expect(response.generatedImages == nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
//
|
|
||||||
// AnthropicProviderTests.swift
|
|
||||||
// oAITests
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import oAI
|
|
||||||
|
|
||||||
@Suite("AnthropicProvider request/response conversion")
|
|
||||||
struct AnthropicProviderTests {
|
|
||||||
|
|
||||||
private var provider: AnthropicProvider { AnthropicProvider(apiKey: "test-key") }
|
|
||||||
|
|
||||||
private func decodeBody(_ urlRequest: URLRequest) throws -> [String: Any] {
|
|
||||||
try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any]
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - buildURLRequest
|
|
||||||
|
|
||||||
@Test("System messages are pulled out of the message list into a top-level system field")
|
|
||||||
func systemMessageExtracted() throws {
|
|
||||||
let messages = [
|
|
||||||
Message(role: .system, content: "be concise"),
|
|
||||||
Message(role: .user, content: "hi")
|
|
||||||
]
|
|
||||||
let request = ChatRequest(messages: messages, model: "claude-sonnet-4-5")
|
|
||||||
let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false)
|
|
||||||
let body = try decodeBody(urlRequest)
|
|
||||||
|
|
||||||
let apiMessages = body["messages"] as? [[String: Any]]
|
|
||||||
#expect(apiMessages?.count == 1) // system message removed from the array
|
|
||||||
#expect(apiMessages?.first?["role"] as? String == "user")
|
|
||||||
|
|
||||||
let system = body["system"] as? [[String: Any]]
|
|
||||||
#expect(system?.first?["text"] as? String == "be concise")
|
|
||||||
let cacheControl = system?.first?["cache_control"] as? [String: String]
|
|
||||||
#expect(cacheControl?["type"] == "ephemeral")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("The last message gets a cache_control breakpoint on its content block")
|
|
||||||
func lastMessageGetsCacheBreakpoint() throws {
|
|
||||||
let messages = [
|
|
||||||
Message(role: .user, content: "first"),
|
|
||||||
Message(role: .assistant, content: "second"),
|
|
||||||
Message(role: .user, content: "third")
|
|
||||||
]
|
|
||||||
let request = ChatRequest(messages: messages, model: "claude-sonnet-4-5")
|
|
||||||
let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false)
|
|
||||||
let body = try decodeBody(urlRequest)
|
|
||||||
let apiMessages = body["messages"] as? [[String: Any]]
|
|
||||||
|
|
||||||
// Earlier messages keep plain string content (no cache breakpoint).
|
|
||||||
#expect(apiMessages?[0]["content"] as? String == "first")
|
|
||||||
#expect(apiMessages?[1]["content"] as? String == "second")
|
|
||||||
|
|
||||||
// The last message's content becomes an array with a cache_control block.
|
|
||||||
let lastContent = apiMessages?[2]["content"] as? [[String: Any]]
|
|
||||||
#expect(lastContent?.first?["text"] as? String == "third")
|
|
||||||
let cacheControl = lastContent?.first?["cache_control"] as? [String: String]
|
|
||||||
#expect(cacheControl?["type"] == "ephemeral")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("max_tokens defaults to 16000 when not specified on the request")
|
|
||||||
func defaultMaxTokens() throws {
|
|
||||||
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "claude-sonnet-4-5")
|
|
||||||
let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false)
|
|
||||||
let body = try decodeBody(urlRequest)
|
|
||||||
#expect(body["max_tokens"] as? Int == 16000)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Online mode adds a web_search tool even with no explicit tools requested")
|
|
||||||
func onlineModeAddsWebSearchTool() throws {
|
|
||||||
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "claude-sonnet-4-5", onlineMode: true)
|
|
||||||
let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false)
|
|
||||||
let body = try decodeBody(urlRequest)
|
|
||||||
let tools = body["tools"] as? [[String: Any]]
|
|
||||||
#expect(tools?.first?["name"] as? String == "web_search")
|
|
||||||
// tool_choice is only added when the caller supplied explicit tools.
|
|
||||||
#expect(body["tool_choice"] == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Empty message content is replaced with a placeholder rather than sent blank")
|
|
||||||
func emptyContentGetsPlaceholder() throws {
|
|
||||||
let request = ChatRequest(messages: [Message(role: .user, content: " ")], model: "claude-sonnet-4-5")
|
|
||||||
let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false)
|
|
||||||
let body = try decodeBody(urlRequest)
|
|
||||||
let apiMessages = body["messages"] as? [[String: Any]]
|
|
||||||
// This is also the last message, so it gets wrapped in a cache-control array.
|
|
||||||
let content = apiMessages?.first?["content"] as? [[String: Any]]
|
|
||||||
#expect(content?.first?["text"] as? String == "[Image]")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - parseResponse
|
|
||||||
|
|
||||||
@Test("Parses text content, usage, and stop_reason from a well-formed response")
|
|
||||||
func parsesWellFormedResponse() throws {
|
|
||||||
let json: [String: Any] = [
|
|
||||||
"id": "msg_123",
|
|
||||||
"model": "claude-sonnet-4-5",
|
|
||||||
"content": [["type": "text", "text": "hello there"]],
|
|
||||||
"stop_reason": "end_turn",
|
|
||||||
"usage": ["input_tokens": 10, "output_tokens": 5]
|
|
||||||
]
|
|
||||||
let data = try JSONSerialization.data(withJSONObject: json)
|
|
||||||
let response = try provider.parseResponse(data: data)
|
|
||||||
#expect(response.id == "msg_123")
|
|
||||||
#expect(response.content == "hello there")
|
|
||||||
#expect(response.finishReason == "end_turn")
|
|
||||||
#expect(response.usage?.promptTokens == 10)
|
|
||||||
#expect(response.usage?.completionTokens == 5)
|
|
||||||
#expect(response.toolCalls == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Parses a tool_use block into a ToolCallInfo with JSON-encoded arguments")
|
|
||||||
func parsesToolUseBlock() throws {
|
|
||||||
let json: [String: Any] = [
|
|
||||||
"id": "msg_1",
|
|
||||||
"model": "claude-sonnet-4-5",
|
|
||||||
"content": [
|
|
||||||
["type": "tool_use", "id": "tool_1", "name": "get_weather", "input": ["city": "Oslo"]]
|
|
||||||
],
|
|
||||||
"usage": ["input_tokens": 1, "output_tokens": 1]
|
|
||||||
]
|
|
||||||
let data = try JSONSerialization.data(withJSONObject: json)
|
|
||||||
let response = try provider.parseResponse(data: data)
|
|
||||||
#expect(response.toolCalls?.count == 1)
|
|
||||||
#expect(response.toolCalls?.first?.functionName == "get_weather")
|
|
||||||
#expect(response.toolCalls?.first?.arguments.contains("Oslo") == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Throws invalidResponse for non-JSON data")
|
|
||||||
func throwsOnInvalidJSON() {
|
|
||||||
let data = "not json".data(using: .utf8)!
|
|
||||||
#expect(throws: (any Error).self) {
|
|
||||||
try provider.parseResponse(data: data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - convertParametersToDict
|
|
||||||
|
|
||||||
@Test("Converts tool parameters to Anthropic's input_schema dict shape")
|
|
||||||
func convertsParametersToInputSchema() {
|
|
||||||
let params = Tool.Function.Parameters(
|
|
||||||
type: "object",
|
|
||||||
properties: [
|
|
||||||
"city": Tool.Function.Parameters.Property(type: "string", description: "City name"),
|
|
||||||
"unit": Tool.Function.Parameters.Property(type: "string", description: "Unit", enum: ["celsius", "fahrenheit"])
|
|
||||||
],
|
|
||||||
required: ["city"]
|
|
||||||
)
|
|
||||||
let dict = provider.convertParametersToDict(params)
|
|
||||||
#expect(dict["type"] as? String == "object")
|
|
||||||
#expect(dict["required"] as? [String] == ["city"])
|
|
||||||
let properties = dict["properties"] as? [String: Any]
|
|
||||||
let cityProp = properties?["city"] as? [String: Any]
|
|
||||||
#expect(cityProp?["type"] as? String == "string")
|
|
||||||
let unitProp = properties?["unit"] as? [String: Any]
|
|
||||||
#expect(unitProp?["enum"] as? [String] == ["celsius", "fahrenheit"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Array-typed properties include a nested items schema")
|
|
||||||
func convertsArrayItemsSchema() {
|
|
||||||
let params = Tool.Function.Parameters(
|
|
||||||
type: "object",
|
|
||||||
properties: [
|
|
||||||
"tags": Tool.Function.Parameters.Property(type: "array", description: "Tags", items: .init(type: "string"))
|
|
||||||
],
|
|
||||||
required: nil
|
|
||||||
)
|
|
||||||
let dict = provider.convertParametersToDict(params)
|
|
||||||
let properties = dict["properties"] as? [String: Any]
|
|
||||||
let tagsProp = properties?["tags"] as? [String: Any]
|
|
||||||
let items = tagsProp?["items"] as? [String: Any]
|
|
||||||
#expect(items?["type"] as? String == "string")
|
|
||||||
#expect(dict["required"] == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - resolveFallbackPricing
|
|
||||||
|
|
||||||
@Test("Exact-length single prefix match returns that tier's pricing")
|
|
||||||
func fallbackPricingSingleMatch() {
|
|
||||||
let pricing = AnthropicProvider.resolveFallbackPricing(for: "claude-haiku-4-5-20251001")
|
|
||||||
#expect(pricing.prompt == 0.80)
|
|
||||||
#expect(pricing.completion == 4.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Longest matching prefix wins when multiple prefixes could match")
|
|
||||||
func fallbackPricingLongestPrefixWins() {
|
|
||||||
let fallback: [(prefix: String, prompt: Double, completion: Double)] = [
|
|
||||||
("claude", 1.0, 2.0),
|
|
||||||
("claude-sonnet", 3.0, 15.0),
|
|
||||||
]
|
|
||||||
let pricing = AnthropicProvider.resolveFallbackPricing(for: "claude-sonnet-5", fallback: fallback)
|
|
||||||
#expect(pricing.prompt == 3.0)
|
|
||||||
#expect(pricing.completion == 15.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("An unmatched model ID prices at zero rather than guessing")
|
|
||||||
func fallbackPricingNoMatch() {
|
|
||||||
let pricing = AnthropicProvider.resolveFallbackPricing(for: "some-future-unknown-model")
|
|
||||||
#expect(pricing.prompt == 0)
|
|
||||||
#expect(pricing.completion == 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
//
|
|
||||||
// BackupServiceTests.swift
|
|
||||||
// oAITests
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import oAI
|
|
||||||
|
|
||||||
@Suite("BackupService.decideFavoritesSync")
|
|
||||||
struct BackupServiceFavoritesSyncTests {
|
|
||||||
|
|
||||||
private let now = Date(timeIntervalSince1970: 1_700_000_000)
|
|
||||||
|
|
||||||
@Test("No remote copy yet -- push local state")
|
|
||||||
func noRemoteCopy() {
|
|
||||||
let decision = BackupService.decideFavoritesSync(localIds: ["a", "b"], localUpdatedAt: "", remote: nil, now: now)
|
|
||||||
#expect(decision == .pushLocal)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Remote is newer -- apply it locally")
|
|
||||||
func remoteNewer() {
|
|
||||||
let remote = FavoritesPayload(updatedAt: "2026-02-01T00:00:00Z", ids: ["x", "y"])
|
|
||||||
let decision = BackupService.decideFavoritesSync(
|
|
||||||
localIds: ["a"],
|
|
||||||
localUpdatedAt: "2026-01-01T00:00:00Z",
|
|
||||||
remote: remote,
|
|
||||||
now: now
|
|
||||||
)
|
|
||||||
#expect(decision == .applyRemote(ids: ["x", "y"], updatedAt: "2026-02-01T00:00:00Z"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Local is newer -- push local state, don't touch remote")
|
|
||||||
func localNewer() {
|
|
||||||
let remote = FavoritesPayload(updatedAt: "2026-01-01T00:00:00Z", ids: ["x"])
|
|
||||||
let decision = BackupService.decideFavoritesSync(
|
|
||||||
localIds: ["a"],
|
|
||||||
localUpdatedAt: "2026-02-01T00:00:00Z",
|
|
||||||
remote: remote,
|
|
||||||
now: now
|
|
||||||
)
|
|
||||||
#expect(decision == .pushLocal)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Tied timestamps with different sets -- merge via union and push")
|
|
||||||
func tiedTimestampsDifferentSets() {
|
|
||||||
let remote = FavoritesPayload(updatedAt: "", ids: ["b", "c"])
|
|
||||||
let decision = BackupService.decideFavoritesSync(localIds: ["a", "b"], localUpdatedAt: "", remote: remote, now: now)
|
|
||||||
guard case .mergeAndPush(let ids, let updatedAt) = decision else {
|
|
||||||
Issue.record("Expected .mergeAndPush")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
#expect(ids == ["a", "b", "c"])
|
|
||||||
#expect(!updatedAt.isEmpty) // stamped with `now`, not left blank
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Tied timestamps with identical sets -- no-op, already in sync")
|
|
||||||
func tiedTimestampsIdenticalSets() {
|
|
||||||
let remote = FavoritesPayload(updatedAt: "2026-01-01T00:00:00Z", ids: ["a", "b"])
|
|
||||||
let decision = BackupService.decideFavoritesSync(
|
|
||||||
localIds: ["a", "b"],
|
|
||||||
localUpdatedAt: "2026-01-01T00:00:00Z",
|
|
||||||
remote: remote,
|
|
||||||
now: now
|
|
||||||
)
|
|
||||||
#expect(decision == .noop)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suite("BackupService.isBackupDue")
|
|
||||||
struct BackupServiceAutoBackupTests {
|
|
||||||
|
|
||||||
private let now = Date(timeIntervalSince1970: 1_700_000_000)
|
|
||||||
private let oneHour: TimeInterval = 3600
|
|
||||||
|
|
||||||
@Test("Manual frequency is never due")
|
|
||||||
func manualNeverDue() {
|
|
||||||
#expect(!BackupService.isBackupDue(frequency: "manual", lastBackupDate: nil, now: now))
|
|
||||||
#expect(!BackupService.isBackupDue(frequency: "manual", lastBackupDate: now.addingTimeInterval(-1_000_000), now: now))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("No prior backup at all is always due, regardless of frequency")
|
|
||||||
func noPriorBackupIsAlwaysDue() {
|
|
||||||
#expect(BackupService.isBackupDue(frequency: "daily", lastBackupDate: nil, now: now))
|
|
||||||
#expect(BackupService.isBackupDue(frequency: "weekly", lastBackupDate: nil, now: now))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Daily: not due before 24 hours have passed, due at or after")
|
|
||||||
func dailyThreshold() {
|
|
||||||
let justUnder = now.addingTimeInterval(-(24 * oneHour - 1))
|
|
||||||
let exactly = now.addingTimeInterval(-(24 * oneHour))
|
|
||||||
#expect(!BackupService.isBackupDue(frequency: "daily", lastBackupDate: justUnder, now: now))
|
|
||||||
#expect(BackupService.isBackupDue(frequency: "daily", lastBackupDate: exactly, now: now))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Weekly: not due before 7 days have passed, due at or after")
|
|
||||||
func weeklyThreshold() {
|
|
||||||
let justUnder = now.addingTimeInterval(-(7 * 24 * oneHour - 1))
|
|
||||||
let exactly = now.addingTimeInterval(-(7 * 24 * oneHour))
|
|
||||||
#expect(!BackupService.isBackupDue(frequency: "weekly", lastBackupDate: justUnder, now: now))
|
|
||||||
#expect(BackupService.isBackupDue(frequency: "weekly", lastBackupDate: exactly, now: now))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
//
|
|
||||||
// ChatViewModelPureLogicTests.swift
|
|
||||||
// oAITests
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
@testable import oAI
|
|
||||||
|
|
||||||
@Suite("ChatViewModel pure static helpers")
|
|
||||||
struct ChatViewModelPureLogicTests {
|
|
||||||
|
|
||||||
// MARK: - detectGoodbyePhrase
|
|
||||||
|
|
||||||
@Test("Recognizes a clear farewell phrase")
|
|
||||||
func detectsGoodbye() {
|
|
||||||
#expect(ChatViewModel.detectGoodbyePhrase(in: "OK, bye!"))
|
|
||||||
#expect(ChatViewModel.detectGoodbyePhrase(in: "That's all, thanks."))
|
|
||||||
#expect(ChatViewModel.detectGoodbyePhrase(in: "Have a good day"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Does not false-positive on a word that merely contains a phrase as a substring")
|
|
||||||
func doesNotMatchSubstring() {
|
|
||||||
// "bye" is a whole-word match target -- "goodbyeee" should not match "bye"
|
|
||||||
// as a substring because of the \b word-boundary regex.
|
|
||||||
#expect(!ChatViewModel.detectGoodbyePhrase(in: "goodbyeee is not a real word"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Does not match ordinary polite phrases that aren't farewells")
|
|
||||||
func doesNotMatchPoliteRequests() {
|
|
||||||
#expect(!ChatViewModel.detectGoodbyePhrase(in: "Thanks, that's helpful!"))
|
|
||||||
#expect(!ChatViewModel.detectGoodbyePhrase(in: "Done with step one, what's next?"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Matching is case-insensitive")
|
|
||||||
func matchIsCaseInsensitive() {
|
|
||||||
#expect(ChatViewModel.detectGoodbyePhrase(in: "BYE BYE"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - inferProvider
|
|
||||||
|
|
||||||
@Test("Model ID with a slash is inferred as OpenRouter")
|
|
||||||
func infersOpenRouter() {
|
|
||||||
#expect(ChatViewModel.inferProvider(from: "anthropic/claude-3-5-sonnet") == .openrouter)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("claude- prefixed model is inferred as direct Anthropic")
|
|
||||||
func infersAnthropic() {
|
|
||||||
#expect(ChatViewModel.inferProvider(from: "claude-sonnet-4-5-20250929") == .anthropic)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("gpt-/o1/o3/dall-e/chatgpt prefixed models are inferred as OpenAI")
|
|
||||||
func infersOpenAI() {
|
|
||||||
#expect(ChatViewModel.inferProvider(from: "gpt-4o") == .openai)
|
|
||||||
#expect(ChatViewModel.inferProvider(from: "o1-preview") == .openai)
|
|
||||||
#expect(ChatViewModel.inferProvider(from: "o3-mini") == .openai)
|
|
||||||
#expect(ChatViewModel.inferProvider(from: "dall-e-3") == .openai)
|
|
||||||
#expect(ChatViewModel.inferProvider(from: "chatgpt-4o-latest") == .openai)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("A bare local model name with no recognized prefix falls back to Ollama")
|
|
||||||
func fallsBackToOllama() {
|
|
||||||
#expect(ChatViewModel.inferProvider(from: "llama3.2") == .ollama)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("apple- prefixed model is inferred as Apple on-device")
|
|
||||||
func infersAppleOnDevice() {
|
|
||||||
#expect(ChatViewModel.inferProvider(from: "apple-on-device") == .appleOnDevice)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - calculateCost
|
|
||||||
|
|
||||||
@Test("Base prompt and completion cost with no cache usage")
|
|
||||||
func calculatesBaseCost() {
|
|
||||||
let usage = ChatResponse.Usage(promptTokens: 1_000_000, completionTokens: 1_000_000, totalTokens: 2_000_000)
|
|
||||||
let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0)
|
|
||||||
#expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 18.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Cache read tokens are charged at 0.1x the prompt rate")
|
|
||||||
func calculatesCacheReadCost() {
|
|
||||||
let usage = ChatResponse.Usage(
|
|
||||||
promptTokens: 0,
|
|
||||||
completionTokens: 0,
|
|
||||||
totalTokens: 1_000_000,
|
|
||||||
cacheReadInputTokens: 1_000_000
|
|
||||||
)
|
|
||||||
let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0)
|
|
||||||
#expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 0.3)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Cache write tokens are charged at 1.25x the prompt rate")
|
|
||||||
func calculatesCacheWriteCost() {
|
|
||||||
let usage = ChatResponse.Usage(
|
|
||||||
promptTokens: 0,
|
|
||||||
completionTokens: 0,
|
|
||||||
totalTokens: 1_000_000,
|
|
||||||
cacheCreationInputTokens: 1_000_000
|
|
||||||
)
|
|
||||||
let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0)
|
|
||||||
#expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 3.75)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Zero usage produces zero cost")
|
|
||||||
func zeroUsageIsZeroCost() {
|
|
||||||
let usage = ChatResponse.Usage(promptTokens: 0, completionTokens: 0, totalTokens: 0)
|
|
||||||
let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0)
|
|
||||||
#expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 0.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - shouldAutoSave
|
|
||||||
|
|
||||||
private func autoSaveEligible(
|
|
||||||
syncEnabled: Bool = true,
|
|
||||||
syncAutoSave: Bool = true,
|
|
||||||
syncConfigured: Bool = true,
|
|
||||||
isCloned: Bool = true,
|
|
||||||
chatMessageCount: Int = 10,
|
|
||||||
minMessages: Int = 4,
|
|
||||||
lastSavedConversationId: String? = nil,
|
|
||||||
currentConversationHash: String = "hash-a"
|
|
||||||
) -> Bool {
|
|
||||||
ChatViewModel.shouldAutoSave(
|
|
||||||
syncEnabled: syncEnabled,
|
|
||||||
syncAutoSave: syncAutoSave,
|
|
||||||
syncConfigured: syncConfigured,
|
|
||||||
isCloned: isCloned,
|
|
||||||
chatMessageCount: chatMessageCount,
|
|
||||||
minMessages: minMessages,
|
|
||||||
lastSavedConversationId: lastSavedConversationId,
|
|
||||||
currentConversationHash: currentConversationHash
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Eligible when every criterion is satisfied")
|
|
||||||
func autoSaveEligibleWhenAllCriteriaMet() {
|
|
||||||
#expect(autoSaveEligible())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Not eligible when sync or auto-save is disabled")
|
|
||||||
func autoSaveIneligibleWhenDisabled() {
|
|
||||||
#expect(!autoSaveEligible(syncEnabled: false))
|
|
||||||
#expect(!autoSaveEligible(syncAutoSave: false))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Not eligible when sync isn't configured or the repo isn't cloned")
|
|
||||||
func autoSaveIneligibleWhenNotConfiguredOrCloned() {
|
|
||||||
#expect(!autoSaveEligible(syncConfigured: false))
|
|
||||||
#expect(!autoSaveEligible(isCloned: false))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Not eligible below the minimum message count")
|
|
||||||
func autoSaveIneligibleBelowMinimumMessages() {
|
|
||||||
#expect(!autoSaveEligible(chatMessageCount: 2, minMessages: 4))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Not eligible if this exact conversation was already auto-saved")
|
|
||||||
func autoSaveIneligibleWhenAlreadySaved() {
|
|
||||||
#expect(!autoSaveEligible(lastSavedConversationId: "hash-a", currentConversationHash: "hash-a"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Eligible when the last saved hash differs from the current conversation")
|
|
||||||
func autoSaveEligibleWhenConversationChanged() {
|
|
||||||
#expect(autoSaveEligible(lastSavedConversationId: "hash-a", currentConversationHash: "hash-b"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
//
|
|
||||||
// ContextSelectionServiceDBTests.swift
|
|
||||||
// oAITests
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import oAI
|
|
||||||
|
|
||||||
@Suite("ContextSelectionService DB-coupled paths, against a throwaway in-memory queue")
|
|
||||||
struct ContextSelectionServiceDBTests {
|
|
||||||
|
|
||||||
/// Persists messages, then loads them back so their `id`s match the DB rows
|
|
||||||
/// (saveConversation assigns fresh row ids internally, distinct from the ids
|
|
||||||
/// on the Message values passed in).
|
|
||||||
private func persistedMessages(_ db: DatabaseService, _ messages: [Message]) throws -> (conversationId: UUID, messages: [Message]) {
|
|
||||||
let saved = try db.saveConversation(name: "Test", messages: messages)
|
|
||||||
let loaded = try db.loadConversation(id: saved.id)
|
|
||||||
return (saved.id, loaded?.1 ?? [])
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - isMessageStarred
|
|
||||||
|
|
||||||
@Test("A message with no metadata row is not starred")
|
|
||||||
func unstarredByDefault() throws {
|
|
||||||
let db = DatabaseService.makeInMemory()
|
|
||||||
let service = ContextSelectionService(db: db)
|
|
||||||
let (_, messages) = try persistedMessages(db, [Message(role: .user, content: "hi")])
|
|
||||||
|
|
||||||
#expect(service.isMessageStarred(messages[0]) == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("A message starred via setMessageStarred reports starred")
|
|
||||||
func starredAfterSetting() throws {
|
|
||||||
let db = DatabaseService.makeInMemory()
|
|
||||||
let service = ContextSelectionService(db: db)
|
|
||||||
let (_, messages) = try persistedMessages(db, [Message(role: .user, content: "hi")])
|
|
||||||
|
|
||||||
try db.setMessageStarred(messageId: messages[0].id, starred: true)
|
|
||||||
#expect(service.isMessageStarred(messages[0]) == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - getSummariesForExcludedRange
|
|
||||||
|
|
||||||
@Test("No summaries recorded returns an empty array")
|
|
||||||
func noSummariesReturnsEmpty() throws {
|
|
||||||
let db = DatabaseService.makeInMemory()
|
|
||||||
let service = ContextSelectionService(db: db)
|
|
||||||
let (conversationId, _) = try persistedMessages(db, [Message(role: .user, content: "hi")])
|
|
||||||
|
|
||||||
let summaries = service.getSummariesForExcludedRange(conversationId: conversationId, totalMessages: 30, selectedCount: 10)
|
|
||||||
#expect(summaries.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("A summary covering only excluded messages is included")
|
|
||||||
func summaryForExcludedRangeIncluded() throws {
|
|
||||||
let db = DatabaseService.makeInMemory()
|
|
||||||
let service = ContextSelectionService(db: db)
|
|
||||||
let (conversationId, _) = try persistedMessages(db, [Message(role: .user, content: "hi")])
|
|
||||||
|
|
||||||
// 30 total, 10 selected -> messages 0..<20 are excluded
|
|
||||||
try db.saveConversationSummary(conversationId: conversationId, startIndex: 0, endIndex: 15, summary: "early stuff", model: nil, tokenCount: nil)
|
|
||||||
|
|
||||||
let summaries = service.getSummariesForExcludedRange(conversationId: conversationId, totalMessages: 30, selectedCount: 10)
|
|
||||||
#expect(summaries == ["early stuff"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("A summary covering messages that were kept is excluded")
|
|
||||||
func summaryForKeptRangeExcluded() throws {
|
|
||||||
let db = DatabaseService.makeInMemory()
|
|
||||||
let service = ContextSelectionService(db: db)
|
|
||||||
let (conversationId, _) = try persistedMessages(db, [Message(role: .user, content: "hi")])
|
|
||||||
|
|
||||||
// 30 total, 10 selected -> messages 0..<20 are excluded; this summary ends at 25, inside the kept range
|
|
||||||
try db.saveConversationSummary(conversationId: conversationId, startIndex: 15, endIndex: 25, summary: "later stuff", model: nil, tokenCount: nil)
|
|
||||||
|
|
||||||
let summaries = service.getSummariesForExcludedRange(conversationId: conversationId, totalMessages: 30, selectedCount: 10)
|
|
||||||
#expect(summaries.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - smartSelection (end to end through the DB seam)
|
|
||||||
|
|
||||||
@Test("A starred older message is pulled in alongside the recent window")
|
|
||||||
func starredMessagePulledIntoSelection() throws {
|
|
||||||
let db = DatabaseService.makeInMemory()
|
|
||||||
let service = ContextSelectionService(db: db)
|
|
||||||
|
|
||||||
var all: [Message] = []
|
|
||||||
let base = Date()
|
|
||||||
for i in 0..<15 {
|
|
||||||
all.append(Message(role: i % 2 == 0 ? .user : .assistant, content: "msg \(i)", timestamp: base.addingTimeInterval(Double(i))))
|
|
||||||
}
|
|
||||||
let (_, persisted) = try persistedMessages(db, all)
|
|
||||||
|
|
||||||
// Star an old message that's outside the last-10 window (index 2)
|
|
||||||
try db.setMessageStarred(messageId: persisted[2].id, starred: true)
|
|
||||||
|
|
||||||
let window = service.smartSelection(allMessages: persisted, maxTokens: 100_000, conversationId: nil)
|
|
||||||
#expect(window.messages.contains { $0.id == persisted[2].id })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("An unstarred, unimportant old message outside the recent window is excluded")
|
|
||||||
func unstarredOldMessageExcluded() throws {
|
|
||||||
let db = DatabaseService.makeInMemory()
|
|
||||||
let service = ContextSelectionService(db: db)
|
|
||||||
|
|
||||||
var all: [Message] = []
|
|
||||||
let base = Date()
|
|
||||||
for i in 0..<15 {
|
|
||||||
all.append(Message(role: i % 2 == 0 ? .user : .assistant, content: "msg \(i)", timestamp: base.addingTimeInterval(Double(i))))
|
|
||||||
}
|
|
||||||
let (_, persisted) = try persistedMessages(db, all)
|
|
||||||
|
|
||||||
let window = service.smartSelection(allMessages: persisted, maxTokens: 100_000, conversationId: nil)
|
|
||||||
#expect(window.messages.contains { $0.id == persisted[0].id } == false)
|
|
||||||
#expect(window.excludedCount == 5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
//
|
|
||||||
// ContextSelectionServiceTests.swift
|
|
||||||
// oAITests
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
@testable import oAI
|
|
||||||
|
|
||||||
@Suite("ContextSelectionService pure scoring helpers")
|
|
||||||
struct ContextSelectionServiceTests {
|
|
||||||
|
|
||||||
private var service: ContextSelectionService { ContextSelectionService.shared }
|
|
||||||
|
|
||||||
// MARK: - getImportanceScore
|
|
||||||
|
|
||||||
@Test("A short message with no cost or token data scores from length alone")
|
|
||||||
func lowScoreForPlainShortMessage() {
|
|
||||||
let message = Message(role: .user, content: "hi")
|
|
||||||
#expect(service.getImportanceScore(message) < 0.01)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Cost, length, and token factors combine to the exact weighted sum")
|
|
||||||
func combinesWeightedFactors() {
|
|
||||||
// cost 0.005 / 0.01 = 0.5 -> * 0.5 weight = 0.25
|
|
||||||
// 1000 chars / 2000 = 0.5 -> * 0.3 weight = 0.15
|
|
||||||
// 500 tokens / 1000 = 0.5 -> * 0.2 weight = 0.1
|
|
||||||
// total = 0.5
|
|
||||||
let message = Message(role: .assistant, content: String(repeating: "a", count: 1000), tokens: 500, cost: 0.005)
|
|
||||||
#expect(abs(service.getImportanceScore(message) - 0.5) < 0.0001)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Score is capped at 1.0 even when every factor maxes out and would otherwise overflow it")
|
|
||||||
func scoreIsCappedAtOne() {
|
|
||||||
let message = Message(
|
|
||||||
role: .assistant,
|
|
||||||
content: String(repeating: "a", count: 5000), // well past the 2000-char max
|
|
||||||
tokens: 5000, // well past the 1000-token max
|
|
||||||
cost: 1.0 // well past the $0.01 max
|
|
||||||
)
|
|
||||||
#expect(service.getImportanceScore(message) == 1.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - estimateTokens
|
|
||||||
|
|
||||||
@Test("Uses the message's actual token count when present")
|
|
||||||
func usesActualTokenCountWhenAvailable() {
|
|
||||||
let messages = [Message(role: .user, content: "irrelevant for this test", tokens: 42)]
|
|
||||||
#expect(service.estimateTokens(messages) == 42)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Falls back to content.count / 4 when tokens is nil")
|
|
||||||
func fallsBackToCharacterEstimate() {
|
|
||||||
let messages = [Message(role: .user, content: String(repeating: "x", count: 40))]
|
|
||||||
#expect(service.estimateTokens(messages) == 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Sums estimates across a mix of messages with and without token counts")
|
|
||||||
func sumsAcrossMixedMessages() {
|
|
||||||
let messages = [
|
|
||||||
Message(role: .user, content: "ignored", tokens: 100),
|
|
||||||
Message(role: .assistant, content: String(repeating: "y", count: 20)), // 20/4 = 5
|
|
||||||
]
|
|
||||||
#expect(service.estimateTokens(messages) == 105)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
//
|
|
||||||
// DatabaseServiceTests.swift
|
|
||||||
// oAITests
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import oAI
|
|
||||||
|
|
||||||
@Suite("DatabaseService migrations, against a throwaway in-memory queue")
|
|
||||||
struct DatabaseServiceMigrationTests {
|
|
||||||
|
|
||||||
@Test("All v1-v8 tables exist after migration")
|
|
||||||
func allTablesExist() {
|
|
||||||
let db = DatabaseService.makeInMemory()
|
|
||||||
let expectedTables = [
|
|
||||||
"conversations", "messages", "settings", "command_history",
|
|
||||||
"email_logs", "message_metadata", "message_embeddings",
|
|
||||||
"conversation_embeddings", "conversation_summaries",
|
|
||||||
]
|
|
||||||
for table in expectedTables {
|
|
||||||
#expect(db.tableExists(table), "expected table \(table) to exist")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("v4 adds modelId to messages and primaryModel to conversations")
|
|
||||||
func v4AddsModelColumns() {
|
|
||||||
let db = DatabaseService.makeInMemory()
|
|
||||||
#expect(db.columnNames(in: "messages").contains("modelId"))
|
|
||||||
#expect(db.columnNames(in: "conversations").contains("primaryModel"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("messages table has the expected v1 columns")
|
|
||||||
func messagesTableColumns() {
|
|
||||||
let db = DatabaseService.makeInMemory()
|
|
||||||
let columns = Set(db.columnNames(in: "messages"))
|
|
||||||
let expected: Set<String> = ["id", "conversationId", "role", "content", "tokens", "cost", "timestamp", "sortOrder"]
|
|
||||||
#expect(expected.isSubset(of: columns))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("message_metadata table has the expected v6 columns")
|
|
||||||
func messageMetadataColumns() {
|
|
||||||
let db = DatabaseService.makeInMemory()
|
|
||||||
let columns = Set(db.columnNames(in: "message_metadata"))
|
|
||||||
#expect(columns == ["message_id", "importance_score", "user_starred", "summary", "chunk_index"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("conversation_summaries table has the expected v8 columns")
|
|
||||||
func conversationSummariesColumns() {
|
|
||||||
let db = DatabaseService.makeInMemory()
|
|
||||||
let columns = Set(db.columnNames(in: "conversation_summaries"))
|
|
||||||
#expect(columns == ["id", "conversation_id", "start_message_index", "end_message_index", "summary", "token_count", "created_at", "summary_model"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("A nonexistent table reports as absent, not a crash")
|
|
||||||
func nonexistentTableReportsAbsent() {
|
|
||||||
let db = DatabaseService.makeInMemory()
|
|
||||||
#expect(db.tableExists("not_a_real_table") == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Two in-memory instances are isolated from each other")
|
|
||||||
func instancesAreIsolated() throws {
|
|
||||||
let dbA = DatabaseService.makeInMemory()
|
|
||||||
let dbB = DatabaseService.makeInMemory()
|
|
||||||
|
|
||||||
_ = try dbA.saveConversation(name: "only in A", messages: [Message(role: .user, content: "hi")])
|
|
||||||
|
|
||||||
#expect(try dbA.listConversations().count == 1)
|
|
||||||
#expect(try dbB.listConversations().count == 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suite("DatabaseService settings CRUD, against a throwaway in-memory queue")
|
|
||||||
struct DatabaseServiceSettingsTests {
|
|
||||||
|
|
||||||
@Test("Round-trips a plain setting")
|
|
||||||
func roundTripsSetting() {
|
|
||||||
let db = DatabaseService.makeInMemory()
|
|
||||||
db.setSetting(key: "theme", value: "dark")
|
|
||||||
#expect((try? db.loadAllSettings()["theme"]) == "dark")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Deletes a setting")
|
|
||||||
func deletesSetting() {
|
|
||||||
let db = DatabaseService.makeInMemory()
|
|
||||||
db.setSetting(key: "temp", value: "1")
|
|
||||||
db.deleteSetting(key: "temp")
|
|
||||||
#expect((try? db.loadAllSettings()["temp"]) == nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suite("DatabaseService conversation + message persistence, against a throwaway in-memory queue")
|
|
||||||
struct DatabaseServiceConversationTests {
|
|
||||||
|
|
||||||
@Test("Saving a conversation round-trips its messages via loadConversation")
|
|
||||||
func savesAndLoadsConversation() throws {
|
|
||||||
let db = DatabaseService.makeInMemory()
|
|
||||||
let messages = [
|
|
||||||
Message(role: .user, content: "hello"),
|
|
||||||
Message(role: .assistant, content: "hi there"),
|
|
||||||
]
|
|
||||||
let saved = try db.saveConversation(name: "Test Chat", messages: messages)
|
|
||||||
|
|
||||||
let loaded = try db.loadConversation(id: saved.id)
|
|
||||||
#expect(loaded?.0.name == "Test Chat")
|
|
||||||
#expect(loaded?.1.map(\.content) == ["hello", "hi there"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("System messages are excluded from persistence")
|
|
||||||
func systemMessagesExcluded() throws {
|
|
||||||
let db = DatabaseService.makeInMemory()
|
|
||||||
let messages = [
|
|
||||||
Message(role: .system, content: "tool call"),
|
|
||||||
Message(role: .user, content: "hello"),
|
|
||||||
]
|
|
||||||
let saved = try db.saveConversation(name: "Test", messages: messages)
|
|
||||||
let loaded = try db.loadConversation(id: saved.id)
|
|
||||||
#expect(loaded?.1.count == 1)
|
|
||||||
#expect(loaded?.1.first?.content == "hello")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
//
|
|
||||||
// EmbeddingServiceTests.swift
|
|
||||||
// oAITests
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
@testable import oAI
|
|
||||||
|
|
||||||
@Suite("EmbeddingService serialization")
|
|
||||||
struct EmbeddingServiceTests {
|
|
||||||
|
|
||||||
@Test("Serialize then deserialize round-trips an embedding exactly")
|
|
||||||
func roundTrip() {
|
|
||||||
let original: [Float] = [0.0, 1.0, -1.0, 3.14159, -0.0001, 1_000_000.5]
|
|
||||||
let data = EmbeddingService.shared.serializeEmbedding(original)
|
|
||||||
let restored = EmbeddingService.shared.deserializeEmbedding(data)
|
|
||||||
#expect(restored == original)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Serialized data is exactly 4 bytes per float")
|
|
||||||
func serializedSizeIsFourBytesPerFloat() {
|
|
||||||
let embedding: [Float] = Array(repeating: 0.5, count: 10)
|
|
||||||
let data = EmbeddingService.shared.serializeEmbedding(embedding)
|
|
||||||
#expect(data.count == 40)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Empty embedding serializes to empty data and back")
|
|
||||||
func emptyEmbedding() {
|
|
||||||
let data = EmbeddingService.shared.serializeEmbedding([])
|
|
||||||
#expect(data.isEmpty)
|
|
||||||
#expect(EmbeddingService.shared.deserializeEmbedding(data).isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
//
|
|
||||||
// GitSyncServiceTests.swift
|
|
||||||
// oAITests
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
@testable import oAI
|
|
||||||
|
|
||||||
@Suite("GitSyncService pure helpers")
|
|
||||||
struct GitSyncServiceTests {
|
|
||||||
|
|
||||||
private var service: GitSyncService { GitSyncService.shared }
|
|
||||||
|
|
||||||
// MARK: - convertToSSH
|
|
||||||
|
|
||||||
@Test("Already-SSH URLs pass through unchanged")
|
|
||||||
func convertToSSHAlreadySSH() {
|
|
||||||
#expect(service.convertToSSH("git@github.com:user/repo.git") == "git@github.com:user/repo.git")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("HTTPS URL converts to SSH form")
|
|
||||||
func convertToSSHFromHTTPS() {
|
|
||||||
#expect(service.convertToSSH("https://gitlab.pm/rune/oAI-Sync.git") == "git@gitlab.pm:rune/oAI-Sync.git")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("HTTP URL converts to SSH form")
|
|
||||||
func convertToSSHFromHTTP() {
|
|
||||||
#expect(service.convertToSSH("http://example.com/user/repo.git") == "git@example.com:user/repo.git")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Malformed URL with no path segment is returned unchanged")
|
|
||||||
func convertToSSHMalformedNoSlash() {
|
|
||||||
#expect(service.convertToSSH("https://onlyhost") == "https://onlyhost")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Unknown scheme is returned unchanged")
|
|
||||||
func convertToSSHUnknownScheme() {
|
|
||||||
#expect(service.convertToSSH("ftp://example.com/repo") == "ftp://example.com/repo")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - injectCredentials
|
|
||||||
|
|
||||||
@Test("Injects username and password into an HTTPS URL")
|
|
||||||
func injectCredentialsHTTPS() {
|
|
||||||
let result = service.injectCredentials("https://gitlab.pm/rune/repo.git", username: "rune", password: "secret")
|
|
||||||
#expect(result == "https://rune:secret@gitlab.pm/rune/repo.git")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Non-HTTPS URLs are returned unchanged (credentials not injected)")
|
|
||||||
func injectCredentialsNonHTTPS() {
|
|
||||||
let result = service.injectCredentials("git@gitlab.pm:rune/repo.git", username: "rune", password: "secret")
|
|
||||||
#expect(result == "git@gitlab.pm:rune/repo.git")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - sanitizeFilename
|
|
||||||
|
|
||||||
@Test("Strips filesystem-invalid characters, replacing each with a dash")
|
|
||||||
func sanitizeFilenameStripsInvalidChars() {
|
|
||||||
#expect(service.sanitizeFilename("a/b\\c:d*e?f\"g<h>i|j") == "a-b-c-d-e-f-g-h-i-j")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Leaves an already-valid filename untouched")
|
|
||||||
func sanitizeFilenameValidInput() {
|
|
||||||
#expect(service.sanitizeFilename("My Chat 2026-01-15") == "My Chat 2026-01-15")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - detectSecretsInText
|
|
||||||
|
|
||||||
@Test("Detects an OpenAI-style API key")
|
|
||||||
func detectSecretsOpenAIKey() {
|
|
||||||
let text = "here's my key: sk-abcdefghijklmnopqrstuvwxyz123456"
|
|
||||||
#expect(service.detectSecretsInText(text).contains("OpenAI Key"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Detects a GitHub personal access token")
|
|
||||||
func detectSecretsGitHubToken() {
|
|
||||||
let text = "token=ghp_abcdefghijklmnopqrstuvwxyz1234567890"
|
|
||||||
#expect(service.detectSecretsInText(text).contains("Access Token"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Plain conversational text has no detected secrets")
|
|
||||||
func detectSecretsCleanText() {
|
|
||||||
#expect(service.detectSecretsInText("Just a normal conversation about the weather.").isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Does not flag an OpenAI-shaped string one character short of the length threshold")
|
|
||||||
func detectSecretsJustBelowThreshold() {
|
|
||||||
// "sk-" + 31 chars = 34 total, but the pattern requires 32+ chars after "sk-"
|
|
||||||
let shortKey = "sk-" + String(repeating: "a", count: 31)
|
|
||||||
#expect(!service.detectSecretsInText(shortKey).contains("OpenAI Key"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - extractProvider
|
|
||||||
|
|
||||||
@Test("Recognizes github.com, gitlab.com, and gitea URLs by name")
|
|
||||||
func extractProviderRecognizesKnownHosts() {
|
|
||||||
#expect(GitSyncService.extractProvider(from: "https://github.com/user/repo.git") == "GitHub")
|
|
||||||
#expect(GitSyncService.extractProvider(from: "https://gitlab.com/user/repo.git") == "GitLab")
|
|
||||||
#expect(GitSyncService.extractProvider(from: "https://my-gitea-instance.example.com/user/repo.git") == "Gitea")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Falls back to a generic label for an unrecognized host")
|
|
||||||
func extractProviderFallsBackForUnknownHost() {
|
|
||||||
#expect(GitSyncService.extractProvider(from: "https://gitlab.pm/rune/oai-swift.git") == "Git repository")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
//
|
|
||||||
// GitignoreParserTests.swift
|
|
||||||
// oAITests
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
@testable import oAI
|
|
||||||
|
|
||||||
@Suite("GitignoreParser")
|
|
||||||
struct GitignoreParserTests {
|
|
||||||
|
|
||||||
private func parser(for content: String) -> GitignoreParser {
|
|
||||||
var parser = GitignoreParser(rootPath: "/tmp/fake-root")
|
|
||||||
parser.parseContent(content)
|
|
||||||
return parser
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Simple extension wildcard matches at any depth")
|
|
||||||
func extensionWildcard() {
|
|
||||||
let p = parser(for: "*.log")
|
|
||||||
#expect(p.isIgnored("debug.log"))
|
|
||||||
#expect(p.isIgnored("nested/debug.log"))
|
|
||||||
#expect(!p.isIgnored("debug.txt"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Directory pattern matches the directory and everything under it")
|
|
||||||
func directoryPattern() {
|
|
||||||
let p = parser(for: "build/")
|
|
||||||
#expect(p.isIgnored("build"))
|
|
||||||
#expect(p.isIgnored("build/output.txt"))
|
|
||||||
#expect(p.isIgnored("foo/build/output.txt"))
|
|
||||||
#expect(!p.isIgnored("rebuild"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Path-anchored pattern only matches that exact relative path, not nested occurrences")
|
|
||||||
func pathAnchoredWildcard() {
|
|
||||||
let p = parser(for: "src/*.tmp")
|
|
||||||
#expect(p.isIgnored("src/foo.tmp"))
|
|
||||||
#expect(!p.isIgnored("other/src/foo.tmp"))
|
|
||||||
#expect(!p.isIgnored("src/sub/foo.tmp"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Double-star matches across any number of intermediate directories")
|
|
||||||
func doubleStarPattern() {
|
|
||||||
let p = parser(for: "**/logs")
|
|
||||||
#expect(p.isIgnored("logs"))
|
|
||||||
#expect(p.isIgnored("a/logs"))
|
|
||||||
#expect(p.isIgnored("a/b/logs"))
|
|
||||||
#expect(p.isIgnored("a/logs/file.txt"))
|
|
||||||
#expect(!p.isIgnored("logsfile"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Negation un-ignores a path matched by an earlier rule")
|
|
||||||
func negation() {
|
|
||||||
let p = parser(for: "*.log\n!important.log")
|
|
||||||
#expect(p.isIgnored("debug.log"))
|
|
||||||
#expect(!p.isIgnored("important.log"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Comments and blank lines are skipped, not treated as patterns")
|
|
||||||
func commentsAndBlankLines() {
|
|
||||||
let p = parser(for: "# a comment\n\n*.log\n \n")
|
|
||||||
#expect(p.isIgnored("x.log"))
|
|
||||||
#expect(!p.isIgnored("# a comment"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Leading slash still matches (root-anchoring is not enforced by this implementation)")
|
|
||||||
func leadingSlashPattern() {
|
|
||||||
// Documents actual current behavior: the leading "/" is stripped without
|
|
||||||
// truly restricting the match to the root level, unlike real git.
|
|
||||||
let p = parser(for: "/dist")
|
|
||||||
#expect(p.isIgnored("dist"))
|
|
||||||
#expect(p.isIgnored("dist/file.txt"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("No rules means nothing is ignored")
|
|
||||||
func noRules() {
|
|
||||||
let p = parser(for: "")
|
|
||||||
#expect(!p.isIgnored("anything.txt"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
//
|
|
||||||
// MCPServicePDFTests.swift
|
|
||||||
// oAITests
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
import CoreGraphics
|
|
||||||
import AppKit
|
|
||||||
@testable import oAI
|
|
||||||
|
|
||||||
@Suite("MCPService PDF text extraction")
|
|
||||||
struct MCPServicePDFTests {
|
|
||||||
|
|
||||||
/// Builds a real, well-formed single-page PDF (via CoreGraphics) containing the given text,
|
|
||||||
/// writes it to a temp file, and returns the path. Using CoreGraphics rather than hand-rolled
|
|
||||||
/// PDF bytes avoids flakiness from malformed xref tables etc.
|
|
||||||
private func makeTestPDF(text: String) throws -> String {
|
|
||||||
let path = FileManager.default.temporaryDirectory
|
|
||||||
.appendingPathComponent(UUID().uuidString)
|
|
||||||
.appendingPathExtension("pdf")
|
|
||||||
|
|
||||||
let pdfData = NSMutableData()
|
|
||||||
let consumer = CGDataConsumer(data: pdfData as CFMutableData)!
|
|
||||||
var mediaBox = CGRect(x: 0, y: 0, width: 200, height: 200)
|
|
||||||
let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil)!
|
|
||||||
|
|
||||||
context.beginPDFPage(nil)
|
|
||||||
let attributedString = NSAttributedString(string: text, attributes: [.font: NSFont.systemFont(ofSize: 24)])
|
|
||||||
let line = CTLineCreateWithAttributedString(attributedString)
|
|
||||||
context.textPosition = CGPoint(x: 10, y: 100)
|
|
||||||
CTLineDraw(line, context)
|
|
||||||
context.endPDFPage()
|
|
||||||
context.closePDF()
|
|
||||||
|
|
||||||
try (pdfData as Data).write(to: path)
|
|
||||||
return path.path
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Extracts text from a real PDF's text layer")
|
|
||||||
func extractsTextFromRealPDF() throws {
|
|
||||||
let path = try makeTestPDF(text: "Hello World")
|
|
||||||
defer { try? FileManager.default.removeItem(atPath: path) }
|
|
||||||
|
|
||||||
let extracted = MCPService.shared.extractPDFText(atPath: path)
|
|
||||||
#expect(extracted?.contains("Hello World") == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Returns nil for a nonexistent path")
|
|
||||||
func returnsNilForMissingFile() {
|
|
||||||
let extracted = MCPService.shared.extractPDFText(atPath: "/tmp/definitely-does-not-exist-\(UUID().uuidString).pdf")
|
|
||||||
#expect(extracted == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Returns nil for a file that isn't a valid PDF")
|
|
||||||
func returnsNilForGarbageBytes() throws {
|
|
||||||
let path = FileManager.default.temporaryDirectory
|
|
||||||
.appendingPathComponent(UUID().uuidString)
|
|
||||||
.appendingPathExtension("pdf")
|
|
||||||
try Data("not a real pdf".utf8).write(to: path)
|
|
||||||
defer { try? FileManager.default.removeItem(at: path) }
|
|
||||||
|
|
||||||
let extracted = MCPService.shared.extractPDFText(atPath: path.path)
|
|
||||||
#expect(extracted == nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageCodableTests.swift
|
|
||||||
// oAITests
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import oAI
|
|
||||||
|
|
||||||
@Suite("Message Codable + Equatable")
|
|
||||||
struct MessageCodableTests {
|
|
||||||
|
|
||||||
@Test("Transient fields don't survive an encode/decode round-trip")
|
|
||||||
func transientFieldsDoNotPersist() throws {
|
|
||||||
var message = Message(role: .assistant, content: "hi", isStreaming: true, generatedImages: [Data([0x01])])
|
|
||||||
message.isStarred = true
|
|
||||||
message.toolCalls = [ToolCallDetail(name: "test_tool", input: "{}", result: nil)]
|
|
||||||
message.thinkingContent = "reasoning..."
|
|
||||||
|
|
||||||
let data = try JSONEncoder().encode(message)
|
|
||||||
let decoded = try JSONDecoder().decode(Message.self, from: data)
|
|
||||||
|
|
||||||
#expect(decoded.content == "hi")
|
|
||||||
#expect(decoded.isStreaming == false)
|
|
||||||
#expect(decoded.isStarred == false)
|
|
||||||
#expect(decoded.generatedImages == nil)
|
|
||||||
#expect(decoded.toolCalls == nil)
|
|
||||||
#expect(decoded.thinkingContent == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Persisted fields survive an encode/decode round-trip")
|
|
||||||
func persistedFieldsSurvive() throws {
|
|
||||||
let message = Message(
|
|
||||||
role: .user,
|
|
||||||
content: "what's the weather",
|
|
||||||
tokens: 42,
|
|
||||||
cost: 0.0012,
|
|
||||||
responseTime: 1.5,
|
|
||||||
wasInterrupted: true,
|
|
||||||
modelId: "claude-sonnet-5"
|
|
||||||
)
|
|
||||||
let data = try JSONEncoder().encode(message)
|
|
||||||
let decoded = try JSONDecoder().decode(Message.self, from: data)
|
|
||||||
|
|
||||||
#expect(decoded.id == message.id)
|
|
||||||
#expect(decoded.role == message.role)
|
|
||||||
#expect(decoded.content == message.content)
|
|
||||||
#expect(decoded.tokens == message.tokens)
|
|
||||||
#expect(decoded.cost == message.cost)
|
|
||||||
#expect(decoded.responseTime == message.responseTime)
|
|
||||||
#expect(decoded.wasInterrupted == message.wasInterrupted)
|
|
||||||
#expect(decoded.modelId == message.modelId)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Custom == ignores role, timestamp, attachments, and modelId")
|
|
||||||
func equalityIgnoresSomeFields() {
|
|
||||||
let base = Message(id: UUID(), role: .user, content: "same", timestamp: Date())
|
|
||||||
let differentRoleAndModel = Message(
|
|
||||||
id: base.id,
|
|
||||||
role: .assistant,
|
|
||||||
content: "same",
|
|
||||||
timestamp: Date(timeIntervalSince1970: 0),
|
|
||||||
modelId: "some-other-model"
|
|
||||||
)
|
|
||||||
// Documents actual current behavior: these fields are intentionally
|
|
||||||
// excluded from Message's custom == -- surprising if you don't know it.
|
|
||||||
#expect(base == differentRoleAndModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Custom == does distinguish on content")
|
|
||||||
func equalityDistinguishesContent() {
|
|
||||||
let a = Message(id: UUID(), role: .user, content: "one")
|
|
||||||
let b = Message(id: a.id, role: .user, content: "two")
|
|
||||||
#expect(a != b)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("FileAttachment.typeFromExtension recognizes common types")
|
|
||||||
func attachmentTypeFromExtension() {
|
|
||||||
#expect(FileAttachment.typeFromExtension("photo.PNG") == .image)
|
|
||||||
#expect(FileAttachment.typeFromExtension("photo.jpg") == .image)
|
|
||||||
#expect(FileAttachment.typeFromExtension("doc.pdf") == .pdf)
|
|
||||||
#expect(FileAttachment.typeFromExtension("notes.md") == .text)
|
|
||||||
#expect(FileAttachment.typeFromExtension("noextension") == .text)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("FileAttachment.mimeType matches the detected extension")
|
|
||||||
func attachmentMimeType() {
|
|
||||||
let attachment = FileAttachment(path: "image.webp", type: .image, data: nil)
|
|
||||||
#expect(attachment.mimeType == "image/webp")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
//
|
|
||||||
// OllamaProviderTests.swift
|
|
||||||
// oAITests
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import oAI
|
|
||||||
|
|
||||||
@Suite("OllamaProvider request/response conversion")
|
|
||||||
struct OllamaProviderTests {
|
|
||||||
|
|
||||||
private var provider: OllamaProvider { OllamaProvider() }
|
|
||||||
|
|
||||||
// MARK: - buildRequestBody
|
|
||||||
|
|
||||||
@Test("Builds a basic request body with model, messages, and stream flag")
|
|
||||||
func basicRequestBody() {
|
|
||||||
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "llama3.2")
|
|
||||||
let body = provider.buildRequestBody(from: request, stream: true)
|
|
||||||
#expect(body["model"] as? String == "llama3.2")
|
|
||||||
#expect(body["stream"] as? Bool == true)
|
|
||||||
let messages = body["messages"] as? [[String: Any]]
|
|
||||||
#expect(messages?.count == 1)
|
|
||||||
#expect(messages?.first?["role"] as? String == "user")
|
|
||||||
#expect(messages?.first?["content"] as? String == "hi")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("System prompt is prepended as its own system-role message")
|
|
||||||
func systemPromptPrepended() {
|
|
||||||
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "llama3.2", systemPrompt: "be concise")
|
|
||||||
let body = provider.buildRequestBody(from: request, stream: false)
|
|
||||||
let messages = body["messages"] as? [[String: Any]]
|
|
||||||
#expect(messages?.count == 2)
|
|
||||||
#expect(messages?.first?["role"] as? String == "system")
|
|
||||||
#expect(messages?.first?["content"] as? String == "be concise")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("maxTokens and temperature are nested under options only when present")
|
|
||||||
func optionsOnlyWhenPresent() {
|
|
||||||
let withOptions = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "llama3.2", maxTokens: 500, temperature: 0.7)
|
|
||||||
let bodyWithOptions = provider.buildRequestBody(from: withOptions, stream: false)
|
|
||||||
let options = bodyWithOptions["options"] as? [String: Any]
|
|
||||||
#expect(options?["num_predict"] as? Int == 500)
|
|
||||||
#expect(options?["temperature"] as? Double == 0.7)
|
|
||||||
|
|
||||||
let withoutOptions = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "llama3.2")
|
|
||||||
let bodyWithoutOptions = provider.buildRequestBody(from: withoutOptions, stream: false)
|
|
||||||
#expect(bodyWithoutOptions["options"] == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - parseOllamaResponse
|
|
||||||
|
|
||||||
@Test("Parses content and token counts from a well-formed response")
|
|
||||||
func parsesWellFormedResponse() {
|
|
||||||
let json: [String: Any] = [
|
|
||||||
"message": ["content": "the answer"],
|
|
||||||
"prompt_eval_count": 10,
|
|
||||||
"eval_count": 20
|
|
||||||
]
|
|
||||||
let response = provider.parseOllamaResponse(json, model: "llama3.2")
|
|
||||||
#expect(response.content == "the answer")
|
|
||||||
#expect(response.role == "assistant")
|
|
||||||
#expect(response.finishReason == "stop")
|
|
||||||
#expect(response.usage?.promptTokens == 10)
|
|
||||||
#expect(response.usage?.completionTokens == 20)
|
|
||||||
#expect(response.usage?.totalTokens == 30)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Missing fields default to empty content and zero token counts")
|
|
||||||
func missingFieldsDefaultGracefully() {
|
|
||||||
let response = provider.parseOllamaResponse([:], model: "llama3.2")
|
|
||||||
#expect(response.content == "")
|
|
||||||
#expect(response.usage?.promptTokens == 0)
|
|
||||||
#expect(response.usage?.completionTokens == 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
//
|
|
||||||
// OpenAIProviderTests.swift
|
|
||||||
// oAITests
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import oAI
|
|
||||||
|
|
||||||
@Suite("OpenAIProvider request/response conversion")
|
|
||||||
struct OpenAIProviderTests {
|
|
||||||
|
|
||||||
private var provider: OpenAIProvider { OpenAIProvider(apiKey: "test-key") }
|
|
||||||
|
|
||||||
// MARK: - buildURLRequest
|
|
||||||
|
|
||||||
@Test("Sets the Authorization header and JSON content type")
|
|
||||||
func setsAuthAndContentTypeHeaders() throws {
|
|
||||||
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "gpt-4o")
|
|
||||||
let urlRequest = try provider.buildURLRequest(from: request, stream: false)
|
|
||||||
#expect(urlRequest.value(forHTTPHeaderField: "Authorization") == "Bearer test-key")
|
|
||||||
#expect(urlRequest.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
|
||||||
#expect(urlRequest.value(forHTTPHeaderField: "Accept") == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Streaming requests add an SSE Accept header and stream_options")
|
|
||||||
func streamingAddsAcceptHeaderAndUsageOption() throws {
|
|
||||||
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "gpt-4o")
|
|
||||||
let urlRequest = try provider.buildURLRequest(from: request, stream: true)
|
|
||||||
#expect(urlRequest.value(forHTTPHeaderField: "Accept") == "text/event-stream")
|
|
||||||
|
|
||||||
let body = try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any]
|
|
||||||
let streamOptions = body["stream_options"] as? [String: Any]
|
|
||||||
#expect(streamOptions?["include_usage"] as? Bool == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("o1/o3 reasoning models omit temperature even when one is requested")
|
|
||||||
func reasoningModelsOmitTemperature() throws {
|
|
||||||
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "o1-preview", temperature: 0.7)
|
|
||||||
let urlRequest = try provider.buildURLRequest(from: request, stream: false)
|
|
||||||
let body = try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any]
|
|
||||||
#expect(body["temperature"] == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Non-reasoning models include the requested temperature")
|
|
||||||
func nonReasoningModelsIncludeTemperature() throws {
|
|
||||||
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "gpt-4o", temperature: 0.7)
|
|
||||||
let urlRequest = try provider.buildURLRequest(from: request, stream: false)
|
|
||||||
let body = try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any]
|
|
||||||
#expect(body["temperature"] as? Double == 0.7)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("A message with an image attachment produces multi-part vision-format content")
|
|
||||||
func imageAttachmentProducesMultipartContent() throws {
|
|
||||||
let attachment = FileAttachment(path: "photo.png", type: .image, data: Data([0x01]))
|
|
||||||
let message = Message(role: .user, content: "look at this", attachments: [attachment])
|
|
||||||
let request = ChatRequest(messages: [message], model: "gpt-4o")
|
|
||||||
let urlRequest = try provider.buildURLRequest(from: request, stream: false)
|
|
||||||
let body = try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any]
|
|
||||||
let messages = body["messages"] as? [[String: Any]]
|
|
||||||
let content = messages?.first?["content"] as? [[String: Any]]
|
|
||||||
#expect(content?.count == 2)
|
|
||||||
#expect(content?.first?["type"] as? String == "text")
|
|
||||||
#expect(content?.last?["type"] as? String == "image_url")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - convertToChatResponse
|
|
||||||
|
|
||||||
@Test("Returns an empty-content response when there are no choices, rather than throwing")
|
|
||||||
func convertToChatResponseEmptyChoicesFallback() {
|
|
||||||
let apiResponse = OpenRouterChatResponse(id: "x", model: "m", choices: [], usage: nil, created: 0)
|
|
||||||
let response = provider.convertToChatResponse(apiResponse)
|
|
||||||
#expect(response.content == "")
|
|
||||||
#expect(response.role == "assistant")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - fallbackModels
|
|
||||||
|
|
||||||
@Test("Fallback models are non-empty, all support tools, and are sorted by name")
|
|
||||||
func fallbackModelsAreWellFormed() {
|
|
||||||
let models = provider.fallbackModels()
|
|
||||||
#expect(!models.isEmpty)
|
|
||||||
#expect(models.allSatisfy { $0.capabilities.tools == true })
|
|
||||||
#expect(models.allSatisfy { $0.capabilities.online == false })
|
|
||||||
#expect(models.map(\.name) == models.map(\.name).sorted())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
//
|
|
||||||
// OpenRouterModelsTests.swift
|
|
||||||
// oAITests
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import oAI
|
|
||||||
|
|
||||||
@Suite("OpenRouterModels decoding")
|
|
||||||
struct OpenRouterModelsTests {
|
|
||||||
|
|
||||||
// MARK: - Request-side: APIMessage.MessageContent (string-or-array union)
|
|
||||||
|
|
||||||
@Test("Request message content decodes a plain string")
|
|
||||||
func requestContentPlainString() throws {
|
|
||||||
let json = #"{"role":"user","content":"hello"}"#.data(using: .utf8)!
|
|
||||||
let message = try JSONDecoder().decode(OpenRouterChatRequest.APIMessage.self, from: json)
|
|
||||||
guard case .string(let text) = message.content else {
|
|
||||||
Issue.record("Expected .string case")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
#expect(text == "hello")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Request message content decodes an array of content items")
|
|
||||||
func requestContentArray() throws {
|
|
||||||
let json = #"{"role":"user","content":[{"type":"text","text":"hi"}]}"#.data(using: .utf8)!
|
|
||||||
let message = try JSONDecoder().decode(OpenRouterChatRequest.APIMessage.self, from: json)
|
|
||||||
guard case .array(let items) = message.content else {
|
|
||||||
Issue.record("Expected .array case")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
#expect(items.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Request-side: ContentItem (text / image union, with string fallback)
|
|
||||||
|
|
||||||
@Test("ContentItem decodes a text block")
|
|
||||||
func contentItemText() throws {
|
|
||||||
let json = #"{"type":"text","text":"hi there"}"#.data(using: .utf8)!
|
|
||||||
let item = try JSONDecoder().decode(OpenRouterChatRequest.APIMessage.ContentItem.self, from: json)
|
|
||||||
guard case .text(let text) = item else {
|
|
||||||
Issue.record("Expected .text case")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
#expect(text == "hi there")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("ContentItem decodes an image_url block")
|
|
||||||
func contentItemImage() throws {
|
|
||||||
let json = #"{"type":"image_url","image_url":{"url":"https://example.com/x.png"}}"#.data(using: .utf8)!
|
|
||||||
let item = try JSONDecoder().decode(OpenRouterChatRequest.APIMessage.ContentItem.self, from: json)
|
|
||||||
guard case .image(let image) = item else {
|
|
||||||
Issue.record("Expected .image case")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
#expect(image.imageUrl.url == "https://example.com/x.png")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("ContentItem falls back to .text for a bare string")
|
|
||||||
func contentItemBareStringFallback() throws {
|
|
||||||
let json = #""just text""#.data(using: .utf8)!
|
|
||||||
let item = try JSONDecoder().decode(OpenRouterChatRequest.APIMessage.ContentItem.self, from: json)
|
|
||||||
guard case .text(let text) = item else {
|
|
||||||
Issue.record("Expected .text fallback case")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
#expect(text == "just text")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Response-side: Choice.MessageContent (plain string vs content-block array)
|
|
||||||
|
|
||||||
@Test("Response message content decodes a plain string with no image blocks")
|
|
||||||
func responseContentPlainString() throws {
|
|
||||||
let json = #"{"role":"assistant","content":"the answer"}"#.data(using: .utf8)!
|
|
||||||
let content = try JSONDecoder().decode(OpenRouterChatResponse.Choice.MessageContent.self, from: json)
|
|
||||||
#expect(content.content == "the answer")
|
|
||||||
#expect(content.contentBlockImages.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Response message content extracts text and images from a content-block array")
|
|
||||||
func responseContentBlockArrayWithImage() throws {
|
|
||||||
let json = """
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [
|
|
||||||
{"type": "text", "text": "here you go"},
|
|
||||||
{"type": "image_url", "image_url": {"url": "https://example.com/out.png"}}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
""".data(using: .utf8)!
|
|
||||||
let content = try JSONDecoder().decode(OpenRouterChatResponse.Choice.MessageContent.self, from: json)
|
|
||||||
#expect(content.content == "here you go")
|
|
||||||
#expect(content.contentBlockImages.count == 1)
|
|
||||||
#expect(content.contentBlockImages.first?.imageUrl.url == "https://example.com/out.png")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Response message content with no content field at all decodes to nil content, no images")
|
|
||||||
func responseContentMissing() throws {
|
|
||||||
let json = #"{"role":"assistant"}"#.data(using: .utf8)!
|
|
||||||
let content = try JSONDecoder().decode(OpenRouterChatResponse.Choice.MessageContent.self, from: json)
|
|
||||||
#expect(content.content == nil)
|
|
||||||
#expect(content.contentBlockImages.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Streaming: StreamChoice.Delta (same string-or-block-array shape, streamed)
|
|
||||||
|
|
||||||
@Test("Stream delta decodes a plain string content chunk")
|
|
||||||
func streamDeltaPlainString() throws {
|
|
||||||
let json = #"{"role":"assistant","content":"partial"}"#.data(using: .utf8)!
|
|
||||||
let delta = try JSONDecoder().decode(OpenRouterStreamChunk.StreamChoice.Delta.self, from: json)
|
|
||||||
#expect(delta.content == "partial")
|
|
||||||
#expect(delta.contentBlockImages.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Stream delta extracts images from a content-block array")
|
|
||||||
func streamDeltaContentBlockArrayWithImage() throws {
|
|
||||||
let json = """
|
|
||||||
{
|
|
||||||
"content": [
|
|
||||||
{"type": "image_url", "image_url": {"url": "https://example.com/stream.png"}}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
""".data(using: .utf8)!
|
|
||||||
let delta = try JSONDecoder().decode(OpenRouterStreamChunk.StreamChoice.Delta.self, from: json)
|
|
||||||
#expect(delta.contentBlockImages.count == 1)
|
|
||||||
#expect(delta.contentBlockImages.first?.imageUrl.url == "https://example.com/stream.png")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
//
|
|
||||||
// OpenRouterProviderTests.swift
|
|
||||||
// oAITests
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import oAI
|
|
||||||
|
|
||||||
@Suite("OpenRouterProvider request/response conversion")
|
|
||||||
struct OpenRouterProviderTests {
|
|
||||||
|
|
||||||
private var provider: OpenRouterProvider { OpenRouterProvider(apiKey: "test-key") }
|
|
||||||
|
|
||||||
// MARK: - buildAPIRequest
|
|
||||||
|
|
||||||
@Test("Plain text message with no attachments uses simple string content")
|
|
||||||
func buildRequestPlainText() throws {
|
|
||||||
let request = ChatRequest(messages: [Message(role: .user, content: "hello")], model: "openai/gpt-4o")
|
|
||||||
let apiRequest = try provider.buildAPIRequest(from: request)
|
|
||||||
guard case .string(let text) = apiRequest.messages.first?.content else {
|
|
||||||
Issue.record("Expected .string content for a message with no attachments")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
#expect(text == "hello")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Message with an image attachment uses array content with a base64 data URL")
|
|
||||||
func buildRequestWithImageAttachment() throws {
|
|
||||||
let attachment = FileAttachment(path: "photo.png", type: .image, data: Data([0x01, 0x02]))
|
|
||||||
let message = Message(role: .user, content: "check this out", attachments: [attachment])
|
|
||||||
let request = ChatRequest(messages: [message], model: "openai/gpt-4o")
|
|
||||||
let apiRequest = try provider.buildAPIRequest(from: request)
|
|
||||||
guard case .array(let items) = apiRequest.messages.first?.content else {
|
|
||||||
Issue.record("Expected .array content for a message with attachments")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
#expect(items.count == 2) // text + image
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Online mode appends :online suffix, but not for image generation requests")
|
|
||||||
func onlineModeSuffix() throws {
|
|
||||||
let base = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "openai/gpt-4o", onlineMode: true)
|
|
||||||
let request = try provider.buildAPIRequest(from: base)
|
|
||||||
#expect(request.model == "openai/gpt-4o:online")
|
|
||||||
|
|
||||||
let imageGenRequest = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "openai/gpt-4o", onlineMode: true, imageGeneration: true)
|
|
||||||
let noSuffix = try provider.buildAPIRequest(from: imageGenRequest)
|
|
||||||
#expect(noSuffix.model == "openai/gpt-4o")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Online mode does not double-append :online if the model already has it")
|
|
||||||
func onlineModeNoDoubleSuffix() throws {
|
|
||||||
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "openai/gpt-4o:online", onlineMode: true)
|
|
||||||
let apiRequest = try provider.buildAPIRequest(from: request)
|
|
||||||
#expect(apiRequest.model == "openai/gpt-4o:online")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Anthropic models get an explicit cache_control opt-in; others don't")
|
|
||||||
func cacheControlOnlyForAnthropic() throws {
|
|
||||||
let anthropicRequest = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "anthropic/claude-sonnet-4-5")
|
|
||||||
let anthropicAPI = try provider.buildAPIRequest(from: anthropicRequest)
|
|
||||||
#expect(anthropicAPI.cacheControl?.type == "ephemeral")
|
|
||||||
|
|
||||||
let openAIRequest = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "openai/gpt-4o")
|
|
||||||
let openAIAPI = try provider.buildAPIRequest(from: openAIRequest)
|
|
||||||
#expect(openAIAPI.cacheControl == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - convertToChatResponse
|
|
||||||
|
|
||||||
@Test("Throws invalidResponse when there are no choices")
|
|
||||||
func convertToChatResponseThrowsOnEmptyChoices() {
|
|
||||||
let apiResponse = OpenRouterChatResponse(id: "x", model: "m", choices: [], usage: nil, created: 0)
|
|
||||||
#expect(throws: (any Error).self) {
|
|
||||||
try provider.convertToChatResponse(apiResponse)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - decodeImageOutputs
|
|
||||||
|
|
||||||
@Test("Decodes a base64 data URL into raw Data")
|
|
||||||
func decodeImageOutputsValidDataURL() {
|
|
||||||
let payload = Data([0xDE, 0xAD, 0xBE, 0xEF])
|
|
||||||
let dataURL = "data:image/png;base64,\(payload.base64EncodedString())"
|
|
||||||
let output = OpenRouterChatResponse.ImageOutput(imageUrl: .init(url: dataURL))
|
|
||||||
let decoded = provider.decodeImageOutputs([output])
|
|
||||||
#expect(decoded?.first == payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Returns nil for an empty outputs array")
|
|
||||||
func decodeImageOutputsEmpty() {
|
|
||||||
#expect(provider.decodeImageOutputs([]) == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Skips a malformed URL with no comma separator")
|
|
||||||
func decodeImageOutputsMalformedURL() {
|
|
||||||
let output = OpenRouterChatResponse.ImageOutput(imageUrl: .init(url: "not-a-data-url"))
|
|
||||||
#expect(provider.decodeImageOutputs([output]) == nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
//
|
|
||||||
// SettingsProviderTests.swift
|
|
||||||
// oAITests
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
@testable import oAI
|
|
||||||
|
|
||||||
@Suite("Settings.Provider display metadata")
|
|
||||||
struct SettingsProviderTests {
|
|
||||||
|
|
||||||
@Test("Apple on-device gets a dedicated display name, not a raw-value capitalization")
|
|
||||||
func appleOnDeviceDisplayName() {
|
|
||||||
#expect(Settings.Provider.appleOnDevice.displayName == "Apple Intelligence")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Apple on-device uses the Apple logo icon")
|
|
||||||
func appleOnDeviceIconName() {
|
|
||||||
#expect(Settings.Provider.appleOnDevice.iconName == "apple.logo")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Other providers still fall back to a capitalized raw value")
|
|
||||||
func otherProvidersUseCapitalizedRawValue() {
|
|
||||||
#expect(Settings.Provider.openrouter.displayName == "Openrouter")
|
|
||||||
#expect(Settings.Provider.anthropic.displayName == "Anthropic")
|
|
||||||
#expect(Settings.Provider.openai.displayName == "Openai")
|
|
||||||
#expect(Settings.Provider.ollama.displayName == "Ollama")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user