commit 42f54954c10a2932834914d6f7c583dccc7971dd Author: Rune Olsen Date: Wed Feb 11 22:22:55 2026 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29f4778 --- /dev/null +++ b/.gitignore @@ -0,0 +1,114 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..50271b7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright 2025 Rune Olsen + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d9f492 --- /dev/null +++ b/README.md @@ -0,0 +1,150 @@ +# oAI + +A native macOS AI chat application with support for multiple providers and advanced features. + +## Features + +### Multi-Provider Support +- **OpenAI** - GPT models with native API support +- **Anthropic** - Claude models with OAuth integration +- **OpenRouter** - Access to 100+ AI models +- **Ollama** - Local model inference + +### Core Capabilities +- **Streaming Responses** - Real-time token streaming for faster interactions +- **Conversation Management** - Save, load, and delete chat conversations +- **File Attachments** - Support for text files, images, and PDFs +- **Image Generation** - Create images with supported models +- **Online Mode** - Web search integration for up-to-date information +- **Session Statistics** - Track token usage and costs +- **Model Context Protocol (MCP)** - Filesystem access for AI models with configurable permissions + +### UI/UX +- Native macOS interface with dark mode support +- Markdown rendering with syntax highlighting +- Command history navigation +- Model selector with detailed information +- Footer stats display (tokens, cost, response time) + +## Installation + +1. Clone this repository +2. Open `oAI.xcodeproj` in Xcode +3. Build and run (⌘R) + +## Configuration + +### API Keys +Add your API keys in Settings (⌘,): +- OpenAI API key +- Anthropic API key (or use OAuth) +- OpenRouter API key +- Ollama base URL (default: http://localhost:11434) + +### Settings +- **Provider** - Select default AI provider +- **Streaming** - Enable/disable response streaming +- **Memory** - Control conversation context (on/off) +- **Online Mode** - Enable web search integration +- **Max Tokens** - Set maximum response length +- **Temperature** - Control response randomness + +## Slash Commands + +### Model & Chat +- `/help` - Show help and available commands +- `/model` - Open model selector (⌘M) +- `/clear` - Clear current conversation +- `/retry` - Regenerate last response +- `/info [model]` - Display model information + +### Conversation Management +- `/save ` - Save current conversation +- `/load` or `/list` - List and load saved conversations +- `/delete ` - Delete a saved conversation +- `/export [filename]` - Export conversation + +### Provider & Settings +- `/provider [name]` - Switch or display current provider +- `/config` or `/settings` - Open settings (⌘,) +- `/stats` - View session statistics +- `/credits` - Check API credits/balance + +### Features +- `/memory ` - Toggle conversation memory +- `/online ` - Toggle online/web search mode +- `/mcp ` - Manage MCP filesystem access + +### MCP (Model Context Protocol) +- `/mcp add ` - Grant AI access to a folder +- `/mcp remove ` - Revoke folder access +- `/mcp list` - Show allowed folders +- `/mcp write ` - Enable/disable file write permissions +- `/mcp status` - Display MCP configuration + +## File Attachments + +Attach files to your messages using the syntax: `@/path/to/file` + +Example: +``` +Can you review this code? @~/project/main.swift +``` + +Supported formats: +- **Text files** - Any UTF-8 text file (.txt, .md, .swift, .py, etc.) +- **Images** - PNG, JPG, WebP (for vision-capable models) +- **PDFs** - Document analysis with vision models + +Limits: +- Maximum file size: 10 MB +- Text files truncated after 50 KB (head + tail shown) + +## Keyboard Shortcuts + +- `⌘M` - Open model selector +- `⌘,` - Open settings +- `⌘N` - New conversation +- `↑/↓` - Navigate command history + +## Development + +### Project Structure +``` +oAI/ +├── Models/ # Data models (Message, Conversation, Settings) +├── Views/ # SwiftUI views +│ ├── Main/ # Chat, header, footer, input +│ └── Screens/ # Settings, stats, model selector +├── ViewModels/ # ChatViewModel +├── Providers/ # AI provider implementations +├── Services/ # Database, MCP, web search, settings +└── Utilities/ # Extensions, logging, syntax highlighting +``` + +### Key Components + +- **ChatViewModel** - Main state management and message handling +- **ProviderRegistry** - Provider selection and initialization +- **AIProvider Protocol** - Common interface for all AI providers +- **MCPService** - Filesystem tool integration +- **DatabaseService** - Conversation persistence +- **WebSearchService** - Online search integration + +## Requirements + +- macOS 14.0+ +- Xcode 15.0+ +- Swift 5.9+ + +## License + +[Add your license here] + +## Contributing + +[Add contribution guidelines here] + +## Support + +For issues or questions, please [open an issue](link-to-issues). diff --git a/oAI.xcodeproj/project.pbxproj b/oAI.xcodeproj/project.pbxproj new file mode 100644 index 0000000..fff3c41 --- /dev/null +++ b/oAI.xcodeproj/project.pbxproj @@ -0,0 +1,380 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + A550A8342F3C5C9300136F2B /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = A550A6812F3B730000136F2B /* GRDB */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + A550A6622F3B72EA00136F2B /* oAI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = oAI.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + A550A6642F3B72EA00136F2B /* oAI */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = oAI; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + A550A65F2F3B72EA00136F2B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A550A8342F3C5C9300136F2B /* GRDB in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A550A6592F3B72EA00136F2B = { + isa = PBXGroup; + children = ( + A550A6642F3B72EA00136F2B /* oAI */, + A550A6632F3B72EA00136F2B /* Products */, + ); + sourceTree = ""; + }; + A550A6632F3B72EA00136F2B /* Products */ = { + isa = PBXGroup; + children = ( + A550A6622F3B72EA00136F2B /* oAI.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A550A6612F3B72EA00136F2B /* oAI */ = { + isa = PBXNativeTarget; + buildConfigurationList = A550A66D2F3B72EC00136F2B /* Build configuration list for PBXNativeTarget "oAI" */; + buildPhases = ( + A550A65E2F3B72EA00136F2B /* Sources */, + A550A65F2F3B72EA00136F2B /* Frameworks */, + A550A6602F3B72EA00136F2B /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + A550A6642F3B72EA00136F2B /* oAI */, + ); + name = oAI; + packageProductDependencies = ( + A550A6812F3B730000136F2B /* GRDB */, + ); + productName = oAI; + productReference = A550A6622F3B72EA00136F2B /* oAI.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A550A65A2F3B72EA00136F2B /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2620; + LastUpgradeCheck = 2620; + TargetAttributes = { + A550A6612F3B72EA00136F2B = { + CreatedOnToolsVersion = 26.2; + }; + }; + }; + buildConfigurationList = A550A65D2F3B72EA00136F2B /* Build configuration list for PBXProject "oAI" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A550A6592F3B72EA00136F2B; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + A550A6802F3B730000136F2B /* XCRemoteSwiftPackageReference "GRDB" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = A550A6632F3B72EA00136F2B /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A550A6612F3B72EA00136F2B /* oAI */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A550A6602F3B72EA00136F2B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A550A65E2F3B72EA00136F2B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A550A66B2F3B72EC00136F2B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + A550A66C2F3B72EC00136F2B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + A550A66E2F3B72EC00136F2B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = oAI/oAI.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_APP_SANDBOX = NO; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 26.2; + }; + name = Debug; + }; + A550A66F2F3B72EC00136F2B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = oAI/oAI.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_APP_SANDBOX = NO; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 26.2; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A550A65D2F3B72EA00136F2B /* Build configuration list for PBXProject "oAI" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A550A66B2F3B72EC00136F2B /* Debug */, + A550A66C2F3B72EC00136F2B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A550A66D2F3B72EC00136F2B /* Build configuration list for PBXNativeTarget "oAI" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A550A66E2F3B72EC00136F2B /* Debug */, + A550A66F2F3B72EC00136F2B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + A550A6802F3B730000136F2B /* XCRemoteSwiftPackageReference "GRDB" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/groue/GRDB.swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 7.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + A550A6812F3B730000136F2B /* GRDB */ = { + isa = XCSwiftPackageProductDependency; + package = A550A6802F3B730000136F2B /* XCRemoteSwiftPackageReference "GRDB" */; + productName = GRDB; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = A550A65A2F3B72EA00136F2B /* Project object */; +} diff --git a/oAI.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/oAI.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/oAI.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/oAI.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/oAI.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..17fbdb6 --- /dev/null +++ b/oAI.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "d77223ea3cadaebd2154378ec5005b6ebefcef3b34a4dafa368b0c4f16c0561c", + "pins" : [ + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift", + "state" : { + "revision" : "aa0079aeb82a4bf00324561a40bffe68c6fe1c26", + "version" : "7.9.0" + } + } + ], + "version" : 3 +} diff --git a/oAI/Assets.xcassets/AppIcon.appiconset/Contents.json b/oAI/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..dbb8b60 --- /dev/null +++ b/oAI/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,74 @@ +{ + "images" : [ + { + "filename" : "icon_1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "filename" : "icon_16.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "icon_32.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "icon_32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "icon_64.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "icon_128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "icon_256.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "icon_256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "icon_512.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "icon_512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "icon_1024.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/oAI/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/oAI/Assets.xcassets/AppIcon.appiconset/icon_1024.png new file mode 100644 index 0000000..0b81ed9 Binary files /dev/null and b/oAI/Assets.xcassets/AppIcon.appiconset/icon_1024.png differ diff --git a/oAI/Assets.xcassets/AppIcon.appiconset/icon_128.png b/oAI/Assets.xcassets/AppIcon.appiconset/icon_128.png new file mode 100644 index 0000000..6320d48 Binary files /dev/null and b/oAI/Assets.xcassets/AppIcon.appiconset/icon_128.png differ diff --git a/oAI/Assets.xcassets/AppIcon.appiconset/icon_16.png b/oAI/Assets.xcassets/AppIcon.appiconset/icon_16.png new file mode 100644 index 0000000..123664f Binary files /dev/null and b/oAI/Assets.xcassets/AppIcon.appiconset/icon_16.png differ diff --git a/oAI/Assets.xcassets/AppIcon.appiconset/icon_256.png b/oAI/Assets.xcassets/AppIcon.appiconset/icon_256.png new file mode 100644 index 0000000..b149660 Binary files /dev/null and b/oAI/Assets.xcassets/AppIcon.appiconset/icon_256.png differ diff --git a/oAI/Assets.xcassets/AppIcon.appiconset/icon_32.png b/oAI/Assets.xcassets/AppIcon.appiconset/icon_32.png new file mode 100644 index 0000000..c9e6b6c Binary files /dev/null and b/oAI/Assets.xcassets/AppIcon.appiconset/icon_32.png differ diff --git a/oAI/Assets.xcassets/AppIcon.appiconset/icon_512.png b/oAI/Assets.xcassets/AppIcon.appiconset/icon_512.png new file mode 100644 index 0000000..9e60866 Binary files /dev/null and b/oAI/Assets.xcassets/AppIcon.appiconset/icon_512.png differ diff --git a/oAI/Assets.xcassets/AppIcon.appiconset/icon_64.png b/oAI/Assets.xcassets/AppIcon.appiconset/icon_64.png new file mode 100644 index 0000000..5c2fb8b Binary files /dev/null and b/oAI/Assets.xcassets/AppIcon.appiconset/icon_64.png differ diff --git a/oAI/Assets.xcassets/AppLogo.imageset/AppLogo.png b/oAI/Assets.xcassets/AppLogo.imageset/AppLogo.png new file mode 100644 index 0000000..a3a1f58 Binary files /dev/null and b/oAI/Assets.xcassets/AppLogo.imageset/AppLogo.png differ diff --git a/oAI/Assets.xcassets/AppLogo.imageset/Contents.json b/oAI/Assets.xcassets/AppLogo.imageset/Contents.json new file mode 100644 index 0000000..b12e46b --- /dev/null +++ b/oAI/Assets.xcassets/AppLogo.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "AppLogo.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/oAI/Assets.xcassets/Contents.json b/oAI/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/oAI/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/oAI/Models/Conversation.swift b/oAI/Models/Conversation.swift new file mode 100644 index 0000000..33dab32 --- /dev/null +++ b/oAI/Models/Conversation.swift @@ -0,0 +1,38 @@ +// +// Conversation.swift +// oAI +// +// Model for saved conversations +// + +import Foundation + +struct Conversation: Identifiable, Codable { + let id: UUID + var name: String + var messages: [Message] + let createdAt: Date + var updatedAt: Date + + init( + id: UUID = UUID(), + name: String, + messages: [Message] = [], + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.id = id + self.name = name + self.messages = messages + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + var messageCount: Int { + messages.count + } + + var lastMessageDate: Date { + messages.last?.timestamp ?? updatedAt + } +} diff --git a/oAI/Models/Message.swift b/oAI/Models/Message.swift new file mode 100644 index 0000000..9667d2b --- /dev/null +++ b/oAI/Models/Message.swift @@ -0,0 +1,125 @@ +// +// Message.swift +// oAI +// +// Core message model for chat conversations +// + +import Foundation + +enum MessageRole: String, Codable { + case user + case assistant + case system +} + +struct Message: Identifiable, Codable, Equatable { + let id: UUID + let role: MessageRole + var content: String + var tokens: Int? + var cost: Double? + let timestamp: Date + let attachments: [FileAttachment]? + + // Streaming state (not persisted) + var isStreaming: Bool = false + + // Generated images from image-output models (base64-decoded PNG/JPEG data) + var generatedImages: [Data]? = nil + + init( + id: UUID = UUID(), + role: MessageRole, + content: String, + tokens: Int? = nil, + cost: Double? = nil, + timestamp: Date = Date(), + attachments: [FileAttachment]? = nil, + isStreaming: Bool = false, + generatedImages: [Data]? = nil + ) { + self.id = id + self.role = role + self.content = content + self.tokens = tokens + self.cost = cost + self.timestamp = timestamp + self.attachments = attachments + self.isStreaming = isStreaming + self.generatedImages = generatedImages + } + + enum CodingKeys: String, CodingKey { + case id, role, content, tokens, cost, timestamp, attachments + } + + static func == (lhs: Message, rhs: Message) -> Bool { + lhs.id == rhs.id && + lhs.content == rhs.content && + lhs.tokens == rhs.tokens && + lhs.cost == rhs.cost && + lhs.isStreaming == rhs.isStreaming && + lhs.generatedImages == rhs.generatedImages + } +} + +struct FileAttachment: Codable, Equatable { + let path: String + let type: AttachmentType + let data: Data? // file contents: raw bytes for images/PDFs, UTF-8 for text + + enum AttachmentType: String, Codable { + case image + case pdf + case text + } + + /// Detect attachment type from file extension + static func typeFromExtension(_ path: String) -> AttachmentType { + let ext = (path as NSString).pathExtension.lowercased() + switch ext { + case "png", "jpg", "jpeg", "gif", "webp", "bmp", "svg": + return .image + case "pdf": + return .pdf + default: + return .text + } + } + + /// MIME type string for the file (used in base64 data URLs) + var mimeType: String { + let ext = (path as NSString).pathExtension.lowercased() + switch ext { + case "png": return "image/png" + case "jpg", "jpeg": return "image/jpeg" + case "gif": return "image/gif" + case "webp": return "image/webp" + case "bmp": return "image/bmp" + case "svg": return "image/svg+xml" + case "pdf": return "application/pdf" + default: return "text/plain" + } + } +} + +// MARK: - Display Helpers + +extension MessageRole { + var displayName: String { + switch self { + case .user: return "You" + case .assistant: return "Assistant" + case .system: return "System" + } + } + + var iconName: String { + switch self { + case .user: return "person.circle.fill" + case .assistant: return "cpu" + case .system: return "info.circle.fill" + } + } +} diff --git a/oAI/Models/MockData.swift b/oAI/Models/MockData.swift new file mode 100644 index 0000000..078cfd9 --- /dev/null +++ b/oAI/Models/MockData.swift @@ -0,0 +1,240 @@ +// +// MockData.swift +// oAI +// +// Mock data for Phase 1 testing +// + +import Foundation + +extension Message { + static let mockUser1 = Message( + role: .user, + content: "What is SwiftUI and how does it compare to UIKit?", + tokens: 12, + cost: 0.00002 + ) + + static let mockAssistant1 = Message( + role: .assistant, + content: """ +SwiftUI is Apple's modern declarative framework for building user interfaces across all Apple platforms. Here's how it compares to UIKit: + +## Key Differences + +**SwiftUI (Declarative)** +- Describe WHAT the UI should look like +- State-driven updates +- Less boilerplate code +- Cross-platform by default + +**UIKit (Imperative)** +- Describe HOW to build the UI step-by-step +- Manual state management +- More verbose +- iOS/tvOS focused + +## Example + +```swift +// SwiftUI +struct ContentView: View { + @State private var count = 0 + + var body: some View { + VStack { + Text("Count: \\(count)") + Button("Increment") { + count += 1 + } + } + } +} +``` + +```swift +// UIKit equivalent +class ViewController: UIViewController { + var count = 0 + let label = UILabel() + let button = UIButton() + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + } + + @objc func incrementTapped() { + count += 1 + label.text = "Count: \\(count)" + } + + func setupUI() { + // ... lots more setup code + } +} +``` + +SwiftUI is recommended for new projects, especially if targeting iOS 14+ and multiple platforms. +""", + tokens: 245, + cost: 0.00049 + ) + + static let mockUser2 = Message( + role: .user, + content: "Can you explain async/await in Swift?", + tokens: 8, + cost: 0.00001 + ) + + static let mockAssistant2 = Message( + role: .assistant, + content: """ +Async/await is Swift's modern concurrency model introduced in Swift 5.5. It makes asynchronous code look and behave like synchronous code. + +## Basic Example + +```swift +// Old way with completion handlers +func fetchData(completion: @escaping (Result) -> Void) { + URLSession.shared.dataTask(with: url) { data, response, error in + if let error = error { + completion(.failure(error)) + } else if let data = data { + completion(.success(data)) + } + }.resume() +} + +// New way with async/await +func fetchData() async throws -> Data { + let (data, _) = try await URLSession.shared.data(from: url) + return data +} +``` + +## Key Benefits + +1. **Readability**: No callback pyramids +2. **Error handling**: Standard try/catch +3. **Sequential logic**: Reads top-to-bottom +4. **Cancellation**: Built-in with Task + +## Usage + +```swift +Task { + do { + let data = try await fetchData() + await MainActor.run { + // Update UI + } + } catch { + print("Error: \\(error)") + } +} +``` + +Much cleaner than completion handlers! +""", + tokens: 189, + cost: 0.00038 + ) + + static let mockSystem = Message( + role: .system, + content: "Conversation cleared. Starting fresh.", + tokens: nil, + cost: nil + ) + + static let mockMessages = [mockUser1, mockAssistant1, mockUser2, mockAssistant2] +} + +extension ModelInfo { + static let mockModels = [ + ModelInfo( + id: "anthropic/claude-sonnet-4", + name: "Claude Sonnet 4", + description: "Balanced intelligence and speed for most tasks", + contextLength: 200_000, + pricing: Pricing(prompt: 3.0, completion: 15.0), + capabilities: ModelCapabilities(vision: true, tools: true, online: true) + ), + ModelInfo( + id: "anthropic/claude-opus-4", + name: "Claude Opus 4", + description: "Most capable model for complex tasks", + contextLength: 200_000, + pricing: Pricing(prompt: 15.0, completion: 75.0), + capabilities: ModelCapabilities(vision: true, tools: true, online: true) + ), + ModelInfo( + id: "anthropic/claude-haiku-4", + name: "Claude Haiku 4", + description: "Fast and efficient for simple tasks", + contextLength: 200_000, + pricing: Pricing(prompt: 0.8, completion: 4.0), + capabilities: ModelCapabilities(vision: true, tools: true, online: true) + ), + ModelInfo( + id: "openai/gpt-4o", + name: "GPT-4o", + description: "OpenAI's flagship multimodal model", + contextLength: 128_000, + pricing: Pricing(prompt: 2.5, completion: 10.0), + capabilities: ModelCapabilities(vision: true, tools: true, online: false) + ), + ModelInfo( + id: "openai/gpt-4o-mini", + name: "GPT-4o Mini", + description: "Faster and cheaper GPT-4o variant", + contextLength: 128_000, + pricing: Pricing(prompt: 0.15, completion: 0.6), + capabilities: ModelCapabilities(vision: true, tools: true, online: false) + ), + ModelInfo( + id: "openai/o1", + name: "o1", + description: "Advanced reasoning model for complex problems", + contextLength: 200_000, + pricing: Pricing(prompt: 15.0, completion: 60.0), + capabilities: ModelCapabilities(vision: false, tools: false, online: false) + ), + ModelInfo( + id: "google/gemini-pro-1.5", + name: "Gemini Pro 1.5", + description: "Google's advanced multimodal model", + contextLength: 2_000_000, + pricing: Pricing(prompt: 1.25, completion: 5.0), + capabilities: ModelCapabilities(vision: true, tools: true, online: false) + ), + ModelInfo( + id: "meta-llama/llama-3.1-405b", + name: "Llama 3.1 405B", + description: "Meta's largest open source model", + contextLength: 128_000, + pricing: Pricing(prompt: 2.7, completion: 2.7), + capabilities: ModelCapabilities(vision: false, tools: true, online: false) + ) + ] +} + +extension Conversation { + static let mockConversation1 = Conversation( + name: "SwiftUI Discussion", + messages: [Message.mockUser1, Message.mockAssistant1], + createdAt: Date().addingTimeInterval(-86400), // 1 day ago + updatedAt: Date().addingTimeInterval(-3600) // 1 hour ago + ) + + static let mockConversation2 = Conversation( + name: "Async/Await Tutorial", + messages: [Message.mockUser2, Message.mockAssistant2], + createdAt: Date().addingTimeInterval(-172800), // 2 days ago + updatedAt: Date().addingTimeInterval(-7200) // 2 hours ago + ) + + static let mockConversations = [mockConversation1, mockConversation2] +} diff --git a/oAI/Models/ModelInfo.swift b/oAI/Models/ModelInfo.swift new file mode 100644 index 0000000..1d6941f --- /dev/null +++ b/oAI/Models/ModelInfo.swift @@ -0,0 +1,56 @@ +// +// ModelInfo.swift +// oAI +// +// Model information and capabilities +// + +import Foundation + +struct ModelInfo: Identifiable, Codable, Hashable { + let id: String + let name: String + let description: String? + let contextLength: Int + let pricing: Pricing + let capabilities: ModelCapabilities + var architecture: Architecture? = nil + var topProvider: String? = nil + + struct Pricing: Codable, Hashable { + let prompt: Double // per 1M tokens + let completion: Double + } + + struct ModelCapabilities: Codable, Hashable { + let vision: Bool // Images/PDFs + let tools: Bool // Function calling + let online: Bool // Web search + var imageGeneration: Bool = false // Image output + } + + struct Architecture: Codable, Hashable { + let tokenizer: String? + let instructType: String? + let modality: String? + } + + // Computed properties + var contextLengthDisplay: String { + if contextLength >= 1_000_000 { + return "\(contextLength / 1_000_000)M" + } else if contextLength >= 1000 { + return "\(contextLength / 1000)K" + } else { + return "\(contextLength)" + } + } + + var promptPriceDisplay: String { + String(format: "$%.2f", pricing.prompt) + } + + var completionPriceDisplay: String { + String(format: "$%.2f", pricing.completion) + } +} diff --git a/oAI/Models/SessionStats.swift b/oAI/Models/SessionStats.swift new file mode 100644 index 0000000..bc0d46a --- /dev/null +++ b/oAI/Models/SessionStats.swift @@ -0,0 +1,58 @@ +// +// SessionStats.swift +// oAI +// +// Session statistics tracking +// + +import Foundation + +struct SessionStats { + var totalInputTokens: Int = 0 + var totalOutputTokens: Int = 0 + var totalCost: Double = 0.0 + var messageCount: Int = 0 + + var totalTokens: Int { + totalInputTokens + totalOutputTokens + } + + var totalTokensDisplay: String { + if totalTokens >= 1_000_000 { + return String(format: "%.1fM", Double(totalTokens) / 1_000_000) + } else if totalTokens >= 1000 { + return String(format: "%.1fK", Double(totalTokens) / 1000) + } else { + return "\(totalTokens)" + } + } + + var totalCostDisplay: String { + String(format: "$%.4f", totalCost) + } + + var averageCostPerMessage: Double { + guard messageCount > 0 else { return 0.0 } + return totalCost / Double(messageCount) + } + + mutating func addMessage(inputTokens: Int?, outputTokens: Int?, cost: Double?) { + if let input = inputTokens { + totalInputTokens += input + } + if let output = outputTokens { + totalOutputTokens += output + } + if let messageCost = cost { + totalCost += messageCost + } + messageCount += 1 + } + + mutating func reset() { + totalInputTokens = 0 + totalOutputTokens = 0 + totalCost = 0.0 + messageCount = 0 + } +} diff --git a/oAI/Models/Settings.swift b/oAI/Models/Settings.swift new file mode 100644 index 0000000..aa17d74 --- /dev/null +++ b/oAI/Models/Settings.swift @@ -0,0 +1,90 @@ +// +// Settings.swift +// oAI +// +// Application settings and configuration +// + +import Foundation + +struct Settings: Codable { + // Provider settings + var defaultProvider: Provider + var openrouterAPIKey: String? + var anthropicAPIKey: String? + var openaiAPIKey: String? + var ollamaBaseURL: String + + // Model settings + var defaultModel: String? + var streamEnabled: Bool + var maxTokens: Int + var systemPrompt: String? + + // Feature flags + var onlineMode: Bool + var memoryEnabled: Bool + var mcpEnabled: Bool + + // Web search + var searchProvider: SearchProvider + var googleAPIKey: String? + var googleSearchEngineID: String? + + // UI + var costWarningThreshold: Double + + enum Provider: String, Codable, CaseIterable { + case openrouter + case anthropic + case openai + case ollama + + var displayName: String { + rawValue.capitalized + } + + var iconName: String { + switch self { + case .openrouter: return "network" + case .anthropic: return "brain" + case .openai: return "sparkles" + case .ollama: return "server.rack" + } + } + } + + enum SearchProvider: String, Codable, CaseIterable { + case anthropicNative = "anthropic_native" + case duckduckgo + case google + + var displayName: String { + switch self { + case .anthropicNative: return "Anthropic Native" + case .duckduckgo: return "DuckDuckGo" + case .google: return "Google" + } + } + } + + // Default settings + static let `default` = Settings( + defaultProvider: .openrouter, + openrouterAPIKey: nil, + anthropicAPIKey: nil, + openaiAPIKey: nil, + ollamaBaseURL: "http://localhost:11434", + defaultModel: nil, + streamEnabled: true, + maxTokens: 4096, + systemPrompt: nil, + onlineMode: false, + memoryEnabled: true, + mcpEnabled: false, + searchProvider: .duckduckgo, + googleAPIKey: nil, + googleSearchEngineID: nil, + costWarningThreshold: 1.0 + ) +} diff --git a/oAI/Providers/AIProvider.swift b/oAI/Providers/AIProvider.swift new file mode 100644 index 0000000..966876b --- /dev/null +++ b/oAI/Providers/AIProvider.swift @@ -0,0 +1,241 @@ +// +// AIProvider.swift +// oAI +// +// Protocol for AI provider implementations +// + +import Foundation + +// MARK: - Provider Protocol + +protocol AIProvider { + var name: String { get } + var capabilities: ProviderCapabilities { get } + + func listModels() async throws -> [ModelInfo] + func getModel(_ id: String) async throws -> ModelInfo? + func chat(request: ChatRequest) async throws -> ChatResponse + func streamChat(request: ChatRequest) -> AsyncThrowingStream + func getCredits() async throws -> Credits? + + /// Chat completion with pre-encoded messages for the MCP tool call loop. + func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse +} + +// MARK: - Provider Capabilities + +struct ProviderCapabilities: Codable { + let supportsStreaming: Bool + let supportsVision: Bool + let supportsTools: Bool + let supportsOnlineSearch: Bool + let maxContextLength: Int? + + static let `default` = ProviderCapabilities( + supportsStreaming: true, + supportsVision: false, + supportsTools: false, + supportsOnlineSearch: false, + maxContextLength: nil + ) +} + +// MARK: - Chat Request + +struct ChatRequest { + let messages: [Message] + let model: String + let stream: Bool + let maxTokens: Int? + let temperature: Double? + let topP: Double? + let systemPrompt: String? + let tools: [Tool]? + let onlineMode: Bool + let imageGeneration: Bool + + init( + messages: [Message], + model: String, + stream: Bool = true, + maxTokens: Int? = nil, + temperature: Double? = nil, + topP: Double? = nil, + systemPrompt: String? = nil, + tools: [Tool]? = nil, + onlineMode: Bool = false, + imageGeneration: Bool = false + ) { + self.messages = messages + self.model = model + self.stream = stream + self.maxTokens = maxTokens + self.temperature = temperature + self.topP = topP + self.systemPrompt = systemPrompt + self.tools = tools + self.onlineMode = onlineMode + self.imageGeneration = imageGeneration + } +} + +// MARK: - Chat Response + +struct ToolCallInfo { + let id: String + let type: String + let functionName: String + let arguments: String +} + +struct ChatResponse: Codable { + let id: String + let model: String + let content: String + let role: String + let finishReason: String? + let usage: Usage? + let created: Date + let toolCalls: [ToolCallInfo]? + let generatedImages: [Data]? + + struct Usage: Codable { + let promptTokens: Int + let completionTokens: Int + let totalTokens: Int + + enum CodingKeys: String, CodingKey { + case promptTokens = "prompt_tokens" + case completionTokens = "completion_tokens" + case totalTokens = "total_tokens" + } + } + + // Custom Codable since ToolCallInfo/generatedImages are not from API directly + enum CodingKeys: String, CodingKey { + case id, model, content, role, finishReason, usage, created + } + + init(id: String, model: String, content: String, role: String, finishReason: String?, usage: Usage?, created: Date, toolCalls: [ToolCallInfo]? = nil, generatedImages: [Data]? = nil) { + self.id = id + self.model = model + self.content = content + self.role = role + self.finishReason = finishReason + self.usage = usage + self.created = created + self.toolCalls = toolCalls + self.generatedImages = generatedImages + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + model = try container.decode(String.self, forKey: .model) + content = try container.decode(String.self, forKey: .content) + role = try container.decode(String.self, forKey: .role) + finishReason = try container.decodeIfPresent(String.self, forKey: .finishReason) + usage = try container.decodeIfPresent(Usage.self, forKey: .usage) + created = try container.decode(Date.self, forKey: .created) + toolCalls = nil + generatedImages = nil + } +} + +// MARK: - Stream Chunk + +struct StreamChunk { + let id: String + let model: String + let delta: Delta + let finishReason: String? + let usage: ChatResponse.Usage? + + struct Delta { + let content: String? + let role: String? + let images: [Data]? + } + + var deltaContent: String? { + delta.content + } +} + +// MARK: - Tool Definition + +struct Tool: Codable { + let type: String + let function: Function + + struct Function: Codable { + let name: String + let description: String + let parameters: Parameters + + struct Parameters: Codable { + let type: String + let properties: [String: Property] + let required: [String]? + + struct Property: Codable { + let type: String + let description: String + let `enum`: [String]? + } + } + } +} + +// MARK: - Credits + +struct Credits: Codable { + let balance: Double + let currency: String + let usage: Double? + let limit: Double? + + var balanceDisplay: String { + String(format: "$%.2f", balance) + } + + var usageDisplay: String? { + guard let usage = usage else { return nil } + return String(format: "$%.2f", usage) + } +} + +// MARK: - Provider Errors + +enum ProviderError: LocalizedError { + case invalidAPIKey + case networkError(Error) + case invalidResponse + case rateLimitExceeded + case modelNotFound(String) + case insufficientCredits + case timeout + case unknown(String) + + var errorDescription: String? { + switch self { + case .invalidAPIKey: + return "Invalid API key. Please check your settings." + case .networkError(let error): + return "Network error: \(error.localizedDescription)" + case .invalidResponse: + return "Received invalid response from API" + case .rateLimitExceeded: + return "Rate limit exceeded. Please try again later." + case .modelNotFound(let model): + return "Model '\(model)' not found" + case .insufficientCredits: + return "Insufficient credits" + case .timeout: + return "Request timed out" + case .unknown(let message): + return "Unknown error: \(message)" + } + } +} diff --git a/oAI/Providers/AnthropicProvider.swift b/oAI/Providers/AnthropicProvider.swift new file mode 100644 index 0000000..094f958 --- /dev/null +++ b/oAI/Providers/AnthropicProvider.swift @@ -0,0 +1,534 @@ +// +// AnthropicProvider.swift +// oAI +// +// Anthropic Messages API provider with SSE streaming and tool support +// + +import Foundation +import os + +class AnthropicProvider: AIProvider { + let name = "Anthropic" + let capabilities = ProviderCapabilities( + supportsStreaming: true, + supportsVision: true, + supportsTools: true, + supportsOnlineSearch: false, + maxContextLength: nil + ) + + enum AuthMode { + case apiKey(String) + case oauth + } + + private let authMode: AuthMode + private let baseURL = "https://api.anthropic.com/v1" + private let apiVersion = "2023-06-01" + private let session: URLSession + + /// Create with a standard API key + init(apiKey: String) { + self.authMode = .apiKey(apiKey) + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 60 + config.timeoutIntervalForResource = 300 + self.session = URLSession(configuration: config) + } + + /// Create with OAuth (Pro/Max subscription) + init(oauth: Bool) { + self.authMode = .oauth + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 60 + config.timeoutIntervalForResource = 300 + self.session = URLSession(configuration: config) + } + + /// Whether this provider is using OAuth authentication + var isOAuth: Bool { + if case .oauth = authMode { return true } + return false + } + + // MARK: - Models (hardcoded — Anthropic has no public models list endpoint) + + private static let knownModels: [ModelInfo] = [ + ModelInfo( + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + description: "Most capable and intelligent model", + contextLength: 200_000, + pricing: .init(prompt: 15.0, completion: 75.0), + capabilities: .init(vision: true, tools: true, online: true) + ), + ModelInfo( + id: "claude-opus-4-5-20251101", + name: "Claude Opus 4.5", + description: "Previous generation Opus", + contextLength: 200_000, + pricing: .init(prompt: 15.0, completion: 75.0), + capabilities: .init(vision: true, tools: true, online: true) + ), + ModelInfo( + id: "claude-sonnet-4-5-20250929", + name: "Claude Sonnet 4.5", + description: "Best balance of speed and capability", + contextLength: 200_000, + pricing: .init(prompt: 3.0, completion: 15.0), + capabilities: .init(vision: true, tools: true, online: true) + ), + ModelInfo( + id: "claude-haiku-4-5-20251001", + name: "Claude Haiku 4.5", + description: "Fastest and most affordable", + contextLength: 200_000, + pricing: .init(prompt: 0.80, completion: 4.0), + capabilities: .init(vision: true, tools: true, online: true) + ), + ModelInfo( + id: "claude-3-7-sonnet-20250219", + name: "Claude 3.7 Sonnet", + description: "Previous generation Sonnet", + contextLength: 200_000, + pricing: .init(prompt: 3.0, completion: 15.0), + capabilities: .init(vision: true, tools: true, online: true) + ), + ModelInfo( + id: "claude-3-haiku-20240307", + name: "Claude 3 Haiku", + description: "Previous generation Haiku", + contextLength: 200_000, + pricing: .init(prompt: 0.25, completion: 1.25), + capabilities: .init(vision: true, tools: true, online: true) + ), + ] + + func listModels() async throws -> [ModelInfo] { + return Self.knownModels + } + + func getModel(_ id: String) async throws -> ModelInfo? { + return Self.knownModels.first { $0.id == id } + } + + // MARK: - Chat Completion + + func chat(request: ChatRequest) async throws -> ChatResponse { + Log.api.info("Anthropic chat request: model=\(request.model), messages=\(request.messages.count)") + var (urlRequest, _) = try buildURLRequest(from: request, stream: false) + try await applyAuth(to: &urlRequest) + let (data, response) = try await session.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + Log.api.error("Anthropic chat: invalid response (not HTTP)") + throw ProviderError.invalidResponse + } + guard httpResponse.statusCode == 200 else { + if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = errorObj["error"] as? [String: Any], + let message = error["message"] as? String { + Log.api.error("Anthropic chat HTTP \(httpResponse.statusCode): \(message)") + throw ProviderError.unknown(message) + } + Log.api.error("Anthropic chat HTTP \(httpResponse.statusCode)") + throw ProviderError.unknown("HTTP \(httpResponse.statusCode)") + } + + return try parseResponse(data: data) + } + + // MARK: - Chat with raw tool messages + + func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse { + Log.api.info("Anthropic tool chat: model=\(model), messages=\(messages.count)") + let url = messagesURL + + // Separate system message from conversation messages + var systemText: String? = nil + var conversationMessages: [[String: Any]] = [] + + for msg in messages { + let role = msg["role"] as? String ?? "" + if role == "system" { + systemText = msg["content"] as? String + } else if role == "tool" { + // Convert OpenAI tool result format to Anthropic tool_result format + let toolCallId = msg["tool_call_id"] as? String ?? "" + let content = msg["content"] as? String ?? "" + conversationMessages.append([ + "role": "user", + "content": [ + ["type": "tool_result", "tool_use_id": toolCallId, "content": content] + ] + ]) + } else if role == "assistant" { + // Check for tool_calls — convert to Anthropic content blocks + if let toolCalls = msg["tool_calls"] as? [[String: Any]], !toolCalls.isEmpty { + var contentBlocks: [[String: Any]] = [] + if let text = msg["content"] as? String, !text.isEmpty { + contentBlocks.append(["type": "text", "text": text]) + } + for tc in toolCalls { + let fn = tc["function"] as? [String: Any] ?? [:] + let name = fn["name"] as? String ?? "" + let argsStr = fn["arguments"] as? String ?? "{}" + let argsObj = (try? JSONSerialization.jsonObject(with: Data(argsStr.utf8))) ?? [:] + contentBlocks.append([ + "type": "tool_use", + "id": tc["id"] as? String ?? UUID().uuidString, + "name": name, + "input": argsObj + ]) + } + conversationMessages.append(["role": "assistant", "content": contentBlocks]) + } else { + conversationMessages.append(["role": "assistant", "content": msg["content"] as? String ?? ""]) + } + } else { + conversationMessages.append(["role": role, "content": msg["content"] as? String ?? ""]) + } + } + + var body: [String: Any] = [ + "model": model, + "messages": conversationMessages, + "max_tokens": maxTokens ?? 4096, + "stream": false + ] + if let systemText = systemText { + body["system"] = systemText + } + if let temperature = temperature { + body["temperature"] = temperature + } + if let tools = tools { + body["tools"] = tools.map { tool -> [String: Any] in + [ + "name": tool.function.name, + "description": tool.function.description, + "input_schema": convertParametersToDict(tool.function.parameters) + ] + } + body["tool_choice"] = ["type": "auto"] + } + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body) + try await applyAuth(to: &urlRequest) + + let (data, response) = try await session.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + Log.api.error("Anthropic tool chat: invalid response (not HTTP)") + throw ProviderError.invalidResponse + } + guard httpResponse.statusCode == 200 else { + if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = errorObj["error"] as? [String: Any], + let message = error["message"] as? String { + Log.api.error("Anthropic tool chat HTTP \(httpResponse.statusCode): \(message)") + throw ProviderError.unknown(message) + } + Log.api.error("Anthropic tool chat HTTP \(httpResponse.statusCode)") + throw ProviderError.unknown("HTTP \(httpResponse.statusCode)") + } + + return try parseResponse(data: data) + } + + // MARK: - Streaming Chat + + func streamChat(request: ChatRequest) -> AsyncThrowingStream { + Log.api.info("Anthropic stream request: model=\(request.model), messages=\(request.messages.count)") + return AsyncThrowingStream { continuation in + Task { + do { + var (urlRequest, _) = try buildURLRequest(from: request, stream: true) + try await self.applyAuth(to: &urlRequest) + let (bytes, response) = try await session.bytes(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + Log.api.error("Anthropic stream: invalid response (not HTTP)") + continuation.finish(throwing: ProviderError.invalidResponse) + return + } + guard httpResponse.statusCode == 200 else { + Log.api.error("Anthropic stream HTTP \(httpResponse.statusCode)") + continuation.finish(throwing: ProviderError.unknown("HTTP \(httpResponse.statusCode)")) + return + } + + var currentId = "" + var currentModel = request.model + + for try await line in bytes.lines { + // Anthropic SSE: "event: ..." and "data: {...}" + guard line.hasPrefix("data: ") else { continue } + let jsonString = String(line.dropFirst(6)) + + guard let jsonData = jsonString.data(using: .utf8), + let event = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any], + let eventType = event["type"] as? String else { + continue + } + + switch eventType { + case "message_start": + if let message = event["message"] as? [String: Any] { + currentId = message["id"] as? String ?? "" + currentModel = message["model"] as? String ?? request.model + } + + case "content_block_delta": + if let delta = event["delta"] as? [String: Any], + let deltaType = delta["type"] as? String, + deltaType == "text_delta", + let text = delta["text"] as? String { + continuation.yield(StreamChunk( + id: currentId, + model: currentModel, + delta: .init(content: text, role: nil, images: nil), + finishReason: nil, + usage: nil + )) + } + + case "message_delta": + let delta = event["delta"] as? [String: Any] + let stopReason = delta?["stop_reason"] as? String + var usage: ChatResponse.Usage? = nil + if let usageDict = event["usage"] as? [String: Any] { + let outputTokens = usageDict["output_tokens"] as? Int ?? 0 + usage = ChatResponse.Usage(promptTokens: 0, completionTokens: outputTokens, totalTokens: outputTokens) + } + continuation.yield(StreamChunk( + id: currentId, + model: currentModel, + delta: .init(content: nil, role: nil, images: nil), + finishReason: stopReason, + usage: usage + )) + + case "message_stop": + continuation.finish() + return + + default: + break + } + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + // MARK: - Credits + + func getCredits() async throws -> Credits? { + // Anthropic doesn't have a public credits API + return nil + } + + // MARK: - Auth Helpers + + /// Apply auth headers based on mode (API key or OAuth Bearer) + private func applyAuth(to request: inout URLRequest) async throws { + switch authMode { + case .apiKey(let key): + request.addValue(key, forHTTPHeaderField: "x-api-key") + request.addValue(apiVersion, forHTTPHeaderField: "anthropic-version") + + case .oauth: + let token = try await AnthropicOAuthService.shared.getValidAccessToken() + request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.addValue(apiVersion, forHTTPHeaderField: "anthropic-version") + request.addValue("oauth-2025-04-20,interleaved-thinking-2025-05-14", forHTTPHeaderField: "anthropic-beta") + } + } + + /// Build the messages endpoint URL, appending ?beta=true for OAuth + private var messagesURL: URL { + switch authMode { + case .apiKey: + return URL(string: "\(baseURL)/messages")! + case .oauth: + return URL(string: "\(baseURL)/messages?beta=true")! + } + } + + // MARK: - Request Building + + private func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> (URLRequest, Data) { + let url = messagesURL + + // Separate system message + var systemText: String? = request.systemPrompt + var apiMessages: [[String: Any]] = [] + + for msg in request.messages { + if msg.role == .system { + systemText = msg.content + continue + } + + let hasAttachments = msg.attachments?.contains(where: { $0.data != nil }) ?? false + + if hasAttachments, let attachments = msg.attachments { + var contentBlocks: [[String: Any]] = [] + contentBlocks.append(["type": "text", "text": msg.content]) + + for attachment in attachments { + guard let data = attachment.data else { continue } + switch attachment.type { + case .image, .pdf: + let base64 = data.base64EncodedString() + contentBlocks.append([ + "type": "image", + "source": [ + "type": "base64", + "media_type": attachment.mimeType, + "data": base64 + ] + ]) + case .text: + let filename = (attachment.path as NSString).lastPathComponent + let textContent = String(data: data, encoding: .utf8) ?? "" + contentBlocks.append(["type": "text", "text": "File: \(filename)\n\n\(textContent)"]) + } + } + apiMessages.append(["role": msg.role.rawValue, "content": contentBlocks]) + } else { + apiMessages.append(["role": msg.role.rawValue, "content": msg.content]) + } + } + + var body: [String: Any] = [ + "model": request.model, + "messages": apiMessages, + "max_tokens": request.maxTokens ?? 4096, + "stream": stream + ] + + if let systemText = systemText { + body["system"] = systemText + } + if let temperature = request.temperature { + body["temperature"] = temperature + } + var toolsArray: [[String: Any]] = [] + if let tools = request.tools { + toolsArray += tools.map { tool -> [String: Any] in + [ + "name": tool.function.name, + "description": tool.function.description, + "input_schema": convertParametersToDict(tool.function.parameters) + ] + } + } + if request.onlineMode { + toolsArray.append([ + "type": "web_search_20250305", + "name": "web_search", + "max_uses": 5 + ]) + } + if !toolsArray.isEmpty { + body["tools"] = toolsArray + if request.tools != nil { + body["tool_choice"] = ["type": "auto"] + } + } + + let bodyData = try JSONSerialization.data(withJSONObject: body) + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") + if stream { + urlRequest.addValue("text/event-stream", forHTTPHeaderField: "Accept") + } + urlRequest.httpBody = bodyData + + // Auth is applied async in the caller (chat/streamChat) + return (urlRequest, bodyData) + } + + private func parseResponse(data: Data) throws -> ChatResponse { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw ProviderError.invalidResponse + } + + let id = json["id"] as? String ?? "" + let model = json["model"] as? String ?? "" + let contentBlocks = json["content"] as? [[String: Any]] ?? [] + + var textContent = "" + var toolCalls: [ToolCallInfo] = [] + + for block in contentBlocks { + let blockType = block["type"] as? String ?? "" + switch blockType { + case "text": + textContent += block["text"] as? String ?? "" + case "tool_use": + let tcId = block["id"] as? String ?? UUID().uuidString + let tcName = block["name"] as? String ?? "" + let tcInput = block["input"] ?? [:] + let argsData = try JSONSerialization.data(withJSONObject: tcInput) + let argsStr = String(data: argsData, encoding: .utf8) ?? "{}" + toolCalls.append(ToolCallInfo(id: tcId, type: "function", functionName: tcName, arguments: argsStr)) + default: + break + } + } + + let usageDict = json["usage"] as? [String: Any] + let inputTokens = usageDict?["input_tokens"] as? Int ?? 0 + let outputTokens = usageDict?["output_tokens"] as? Int ?? 0 + + return ChatResponse( + id: id, + model: model, + content: textContent, + role: "assistant", + finishReason: json["stop_reason"] as? String, + usage: ChatResponse.Usage( + promptTokens: inputTokens, + completionTokens: outputTokens, + totalTokens: inputTokens + outputTokens + ), + created: Date(), + toolCalls: toolCalls.isEmpty ? nil : toolCalls + ) + } + + private func convertParametersToDict(_ params: Tool.Function.Parameters) -> [String: Any] { + var props: [String: Any] = [:] + for (key, prop) in params.properties { + var propDict: [String: Any] = [ + "type": prop.type, + "description": prop.description + ] + if let enumVals = prop.enum { + propDict["enum"] = enumVals + } + props[key] = propDict + } + var dict: [String: Any] = [ + "type": params.type, + "properties": props + ] + if let required = params.required { + dict["required"] = required + } + return dict + } +} diff --git a/oAI/Providers/OllamaProvider.swift b/oAI/Providers/OllamaProvider.swift new file mode 100644 index 0000000..22c10cd --- /dev/null +++ b/oAI/Providers/OllamaProvider.swift @@ -0,0 +1,308 @@ +// +// OllamaProvider.swift +// oAI +// +// Ollama local AI provider with JSON-lines streaming +// + +import Foundation +import os + +class OllamaProvider: AIProvider { + let name = "Ollama" + let capabilities = ProviderCapabilities( + supportsStreaming: true, + supportsVision: false, + supportsTools: false, + supportsOnlineSearch: false, + maxContextLength: nil + ) + + private let baseURL: String + private let session: URLSession + + init(baseURL: String = "http://localhost:11434") { + self.baseURL = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 120 + config.timeoutIntervalForResource = 600 + self.session = URLSession(configuration: config) + } + + // MARK: - Models + + func listModels() async throws -> [ModelInfo] { + Log.api.info("Fetching model list from Ollama at \(self.baseURL)") + let url = URL(string: "\(baseURL)/api/tags")! + var request = URLRequest(url: url) + request.timeoutInterval = 5 + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + Log.api.warning("Cannot connect to Ollama at \(self.baseURL). Is Ollama running?") + throw ProviderError.unknown("Cannot connect to Ollama at \(baseURL). Is Ollama running? Start it with: ollama serve") + } + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw ProviderError.unknown("Ollama returned an error. Is it running?") + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let modelsArray = json["models"] as? [[String: Any]] else { + return [] + } + + return modelsArray.compactMap { model -> ModelInfo? in + guard let name = model["name"] as? String else { return nil } + let sizeBytes = model["size"] as? Int64 ?? 0 + let sizeGB = String(format: "%.1f GB", Double(sizeBytes) / 1_073_741_824) + + return ModelInfo( + id: name, + name: name, + description: "Local model (\(sizeGB))", + contextLength: 0, + pricing: .init(prompt: 0, completion: 0), + capabilities: .init(vision: false, tools: false, online: false) + ) + } + } + + func getModel(_ id: String) async throws -> ModelInfo? { + let models = try await listModels() + return models.first { $0.id == id } + } + + // MARK: - Chat Completion + + func chat(request: ChatRequest) async throws -> ChatResponse { + Log.api.info("Ollama chat request: model=\(request.model), messages=\(request.messages.count)") + let url = URL(string: "\(baseURL)/api/chat")! + let body = buildRequestBody(from: request, stream: false) + let bodyData = try JSONSerialization.data(withJSONObject: body) + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.httpBody = bodyData + + let (data, response) = try await session.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + Log.api.error("Ollama chat: invalid response (not HTTP)") + throw ProviderError.invalidResponse + } + guard httpResponse.statusCode == 200 else { + if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let errorMsg = errorObj["error"] as? String { + Log.api.error("Ollama chat HTTP \(httpResponse.statusCode): \(errorMsg)") + throw ProviderError.unknown(errorMsg) + } + Log.api.error("Ollama chat HTTP \(httpResponse.statusCode)") + throw ProviderError.unknown("Ollama HTTP \(httpResponse.statusCode)") + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw ProviderError.invalidResponse + } + + return parseOllamaResponse(json, model: request.model) + } + + // MARK: - Chat with raw tool messages + + func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse { + // Ollama doesn't support tool calls natively — just send messages as plain chat + let url = URL(string: "\(baseURL)/api/chat")! + + // Convert messages, stripping tool-specific fields + var ollamaMessages: [[String: Any]] = [] + for msg in messages { + let role = msg["role"] as? String ?? "user" + let content = msg["content"] as? String ?? "" + + if role == "tool" { + // Convert tool results to assistant context + let toolName = msg["name"] as? String ?? "tool" + ollamaMessages.append(["role": "user", "content": "[\(toolName) result]: \(content)"]) + } else if role == "assistant" { + // Strip tool_calls, just keep content + if let tc = msg["tool_calls"] as? [[String: Any]], !tc.isEmpty { + let toolNames = tc.compactMap { ($0["function"] as? [String: Any])?["name"] as? String } + let text = (msg["content"] as? String) ?? "" + let combined = text.isEmpty ? "Calling: \(toolNames.joined(separator: ", "))" : text + ollamaMessages.append(["role": "assistant", "content": combined]) + } else { + ollamaMessages.append(["role": "assistant", "content": content]) + } + } else { + ollamaMessages.append(["role": role, "content": content]) + } + } + + var body: [String: Any] = [ + "model": model, + "messages": ollamaMessages, + "stream": false + ] + var options: [String: Any] = [:] + if let maxTokens = maxTokens { options["num_predict"] = maxTokens } + if let temperature = temperature { options["temperature"] = temperature } + if !options.isEmpty { body["options"] = options } + + let bodyData = try JSONSerialization.data(withJSONObject: body) + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.httpBody = bodyData + + let (data, response) = try await session.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw ProviderError.unknown("Ollama error") + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw ProviderError.invalidResponse + } + + return parseOllamaResponse(json, model: model) + } + + // MARK: - Streaming Chat + + func streamChat(request: ChatRequest) -> AsyncThrowingStream { + Log.api.info("Ollama stream request: model=\(request.model), messages=\(request.messages.count)") + return AsyncThrowingStream { continuation in + Task { + do { + let url = URL(string: "\(baseURL)/api/chat")! + let body = buildRequestBody(from: request, stream: true) + let bodyData = try JSONSerialization.data(withJSONObject: body) + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.httpBody = bodyData + + let (bytes, response) = try await session.bytes(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + Log.api.error("Ollama stream: invalid response (not HTTP)") + continuation.finish(throwing: ProviderError.invalidResponse) + return + } + guard httpResponse.statusCode == 200 else { + Log.api.error("Ollama stream HTTP \(httpResponse.statusCode)") + continuation.finish(throwing: ProviderError.unknown("Ollama HTTP \(httpResponse.statusCode)")) + return + } + + // Ollama streams JSON lines (one complete JSON object per line) + for try await line in bytes.lines { + guard !line.isEmpty, + let lineData = line.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] else { + continue + } + + let done = json["done"] as? Bool ?? false + let message = json["message"] as? [String: Any] + let content = message?["content"] as? String + + if done { + // Final chunk has usage stats + let promptTokens = json["prompt_eval_count"] as? Int ?? 0 + let completionTokens = json["eval_count"] as? Int ?? 0 + continuation.yield(StreamChunk( + id: "", + model: request.model, + delta: .init(content: content, role: nil, images: nil), + finishReason: "stop", + usage: ChatResponse.Usage( + promptTokens: promptTokens, + completionTokens: completionTokens, + totalTokens: promptTokens + completionTokens + ) + )) + continuation.finish() + return + } else if let content = content { + continuation.yield(StreamChunk( + id: "", + model: request.model, + delta: .init(content: content, role: nil, images: nil), + finishReason: nil, + usage: nil + )) + } + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + // MARK: - Credits + + func getCredits() async throws -> Credits? { + // Local models — no credits needed + return nil + } + + // MARK: - Helpers + + private func buildRequestBody(from request: ChatRequest, stream: Bool) -> [String: Any] { + var messages: [[String: Any]] = [] + + // Add system prompt as a system message + if let systemPrompt = request.systemPrompt { + messages.append(["role": "system", "content": systemPrompt]) + } + + for msg in request.messages { + messages.append(["role": msg.role.rawValue, "content": msg.content]) + } + + var body: [String: Any] = [ + "model": request.model, + "messages": messages, + "stream": stream + ] + + var options: [String: Any] = [:] + if let maxTokens = request.maxTokens { options["num_predict"] = maxTokens } + if let temperature = request.temperature { options["temperature"] = temperature } + if !options.isEmpty { body["options"] = options } + + return body + } + + private func parseOllamaResponse(_ json: [String: Any], model: String) -> ChatResponse { + let message = json["message"] as? [String: Any] + let content = message?["content"] as? String ?? "" + let promptTokens = json["prompt_eval_count"] as? Int ?? 0 + let completionTokens = json["eval_count"] as? Int ?? 0 + + return ChatResponse( + id: UUID().uuidString, + model: model, + content: content, + role: "assistant", + finishReason: "stop", + usage: ChatResponse.Usage( + promptTokens: promptTokens, + completionTokens: completionTokens, + totalTokens: promptTokens + completionTokens + ), + created: Date() + ) + } +} diff --git a/oAI/Providers/OpenAIProvider.swift b/oAI/Providers/OpenAIProvider.swift new file mode 100644 index 0000000..83842f3 --- /dev/null +++ b/oAI/Providers/OpenAIProvider.swift @@ -0,0 +1,367 @@ +// +// OpenAIProvider.swift +// oAI +// +// OpenAI API provider with SSE streaming and tool support +// + +import Foundation +import os + +class OpenAIProvider: AIProvider { + let name = "OpenAI" + let capabilities = ProviderCapabilities( + supportsStreaming: true, + supportsVision: true, + supportsTools: true, + supportsOnlineSearch: false, + maxContextLength: nil + ) + + private let apiKey: String + private let baseURL = "https://api.openai.com/v1" + private let session: URLSession + + init(apiKey: String) { + self.apiKey = apiKey + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 60 + config.timeoutIntervalForResource = 300 + self.session = URLSession(configuration: config) + } + + // MARK: - Models + + /// Known models with pricing, used as fallback and for enrichment + private static let knownModels: [String: (name: String, desc: String?, ctx: Int, prompt: Double, completion: Double, vision: Bool)] = [ + "gpt-4o": ("GPT-4o", "Most capable GPT-4 model", 128_000, 2.50, 10.0, true), + "gpt-4o-mini": ("GPT-4o Mini", "Affordable and fast", 128_000, 0.15, 0.60, true), + "gpt-4-turbo": ("GPT-4 Turbo", "GPT-4 Turbo with vision", 128_000, 10.0, 30.0, true), + "gpt-3.5-turbo": ("GPT-3.5 Turbo", "Fast and affordable", 16_385, 0.50, 1.50, false), + "o1": ("o1", "Advanced reasoning model", 200_000, 15.0, 60.0, true), + "o1-mini": ("o1 Mini", "Fast reasoning model", 128_000, 3.0, 12.0, false), + "o3-mini": ("o3 Mini", "Latest fast reasoning model", 200_000, 1.10, 4.40, false), + ] + + func listModels() async throws -> [ModelInfo] { + Log.api.info("Fetching model list from OpenAI") + let url = URL(string: "\(baseURL)/models")! + var request = URLRequest(url: url) + request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + + do { + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + Log.api.warning("OpenAI models endpoint failed, using fallback models") + return fallbackModels() + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let modelsArray = json["data"] as? [[String: Any]] else { + return fallbackModels() + } + + // Filter to chat models + let chatModelIds = modelsArray + .compactMap { $0["id"] as? String } + .filter { id in + id.contains("gpt") || id.hasPrefix("o1") || id.hasPrefix("o3") + } + .sorted() + + var models: [ModelInfo] = [] + for id in chatModelIds { + if let known = Self.knownModels[id] { + models.append(ModelInfo( + id: id, + name: known.name, + description: known.desc, + contextLength: known.ctx, + pricing: .init(prompt: known.prompt, completion: known.completion), + capabilities: .init(vision: known.vision, tools: true, online: false) + )) + } else { + models.append(ModelInfo( + id: id, + name: id, + description: nil, + contextLength: 128_000, + pricing: .init(prompt: 0, completion: 0), + capabilities: .init(vision: false, tools: true, online: false) + )) + } + } + + Log.api.info("OpenAI loaded \(models.count) models") + return models.isEmpty ? fallbackModels() : models + } catch { + Log.api.warning("OpenAI models fetch failed: \(error.localizedDescription), using fallback") + return fallbackModels() + } + } + + private func fallbackModels() -> [ModelInfo] { + Self.knownModels.map { id, info in + ModelInfo( + id: id, + name: info.name, + description: info.desc, + contextLength: info.ctx, + pricing: .init(prompt: info.prompt, completion: info.completion), + capabilities: .init(vision: info.vision, tools: true, online: false) + ) + }.sorted { $0.name < $1.name } + } + + func getModel(_ id: String) async throws -> ModelInfo? { + let models = try await listModels() + return models.first { $0.id == id } + } + + // MARK: - Chat Completion + + func chat(request: ChatRequest) async throws -> ChatResponse { + Log.api.info("OpenAI chat request: model=\(request.model), messages=\(request.messages.count)") + let urlRequest = try buildURLRequest(from: request, stream: false) + let (data, response) = try await session.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + Log.api.error("OpenAI chat: invalid response (not HTTP)") + throw ProviderError.invalidResponse + } + guard httpResponse.statusCode == 200 else { + if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = errorObj["error"] as? [String: Any], + let message = error["message"] as? String { + Log.api.error("OpenAI chat HTTP \(httpResponse.statusCode): \(message)") + throw ProviderError.unknown(message) + } + Log.api.error("OpenAI chat HTTP \(httpResponse.statusCode)") + throw ProviderError.unknown("HTTP \(httpResponse.statusCode)") + } + + // Reuse the OpenRouter response format — OpenAI is identical + let apiResponse = try JSONDecoder().decode(OpenRouterChatResponse.self, from: data) + return convertToChatResponse(apiResponse) + } + + // MARK: - Chat with raw tool messages + + func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse { + Log.api.info("OpenAI tool chat: model=\(model), messages=\(messages.count)") + let url = URL(string: "\(baseURL)/chat/completions")! + + var body: [String: Any] = [ + "model": model, + "messages": messages, + "stream": false + ] + if let tools = tools { + let toolsData = try JSONEncoder().encode(tools) + body["tools"] = try JSONSerialization.jsonObject(with: toolsData) + body["tool_choice"] = "auto" + } + if let maxTokens = maxTokens { body["max_tokens"] = maxTokens } + // o1/o3 models don't support temperature + if let temperature = temperature, !model.hasPrefix("o1"), !model.hasPrefix("o3") { + body["temperature"] = temperature + } + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await session.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + Log.api.error("OpenAI tool chat: invalid response (not HTTP)") + throw ProviderError.invalidResponse + } + guard httpResponse.statusCode == 200 else { + if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = errorObj["error"] as? [String: Any], + let message = error["message"] as? String { + Log.api.error("OpenAI tool chat HTTP \(httpResponse.statusCode): \(message)") + throw ProviderError.unknown(message) + } + Log.api.error("OpenAI tool chat HTTP \(httpResponse.statusCode)") + throw ProviderError.unknown("HTTP \(httpResponse.statusCode)") + } + + let apiResponse = try JSONDecoder().decode(OpenRouterChatResponse.self, from: data) + return convertToChatResponse(apiResponse) + } + + // MARK: - Streaming Chat + + func streamChat(request: ChatRequest) -> AsyncThrowingStream { + Log.api.info("OpenAI stream request: model=\(request.model), messages=\(request.messages.count)") + return AsyncThrowingStream { continuation in + Task { + do { + let urlRequest = try buildURLRequest(from: request, stream: true) + let (bytes, response) = try await session.bytes(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + Log.api.error("OpenAI stream: invalid response (not HTTP)") + continuation.finish(throwing: ProviderError.invalidResponse) + return + } + guard httpResponse.statusCode == 200 else { + Log.api.error("OpenAI stream HTTP \(httpResponse.statusCode)") + continuation.finish(throwing: ProviderError.unknown("HTTP \(httpResponse.statusCode)")) + return + } + + var buffer = "" + + for try await line in bytes.lines { + guard line.hasPrefix("data: ") else { continue } + let jsonString = String(line.dropFirst(6)) + + if jsonString == "[DONE]" { + continuation.finish() + return + } + + buffer += jsonString + + if let jsonData = buffer.data(using: .utf8) { + do { + let chunk = try JSONDecoder().decode(OpenRouterStreamChunk.self, from: jsonData) + guard let choice = chunk.choices.first else { continue } + + continuation.yield(StreamChunk( + id: chunk.id, + model: chunk.model, + delta: .init(content: choice.delta.content, role: choice.delta.role, images: nil), + finishReason: choice.finishReason, + usage: chunk.usage.map { + ChatResponse.Usage(promptTokens: $0.promptTokens, completionTokens: $0.completionTokens, totalTokens: $0.totalTokens) + } + )) + buffer = "" + } catch { + continue // Partial JSON, keep buffering + } + } + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + // MARK: - Credits + + func getCredits() async throws -> Credits? { + // OpenAI doesn't have a public credits API + return nil + } + + // MARK: - Helpers + + private func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> URLRequest { + let url = URL(string: "\(baseURL)/chat/completions")! + + var apiMessages: [[String: Any]] = [] + + // Add system prompt if present + if let systemPrompt = request.systemPrompt { + apiMessages.append(["role": "system", "content": systemPrompt]) + } + + for msg in request.messages { + let hasAttachments = msg.attachments?.contains(where: { $0.data != nil }) ?? false + + if hasAttachments, let attachments = msg.attachments { + // Multi-part content (OpenAI vision format) + var contentArray: [[String: Any]] = [ + ["type": "text", "text": msg.content] + ] + for attachment in attachments { + guard let data = attachment.data else { continue } + switch attachment.type { + case .image, .pdf: + let base64 = data.base64EncodedString() + let dataURL = "data:\(attachment.mimeType);base64,\(base64)" + contentArray.append([ + "type": "image_url", + "image_url": ["url": dataURL] + ]) + case .text: + let filename = (attachment.path as NSString).lastPathComponent + let textContent = String(data: data, encoding: .utf8) ?? "" + contentArray.append(["type": "text", "text": "File: \(filename)\n\n\(textContent)"]) + } + } + apiMessages.append(["role": msg.role.rawValue, "content": contentArray]) + } else { + apiMessages.append(["role": msg.role.rawValue, "content": msg.content]) + } + } + + var body: [String: Any] = [ + "model": request.model, + "messages": apiMessages, + "stream": stream + ] + + if let maxTokens = request.maxTokens { body["max_tokens"] = maxTokens } + // o1/o3 reasoning models don't support temperature + if let temperature = request.temperature, !request.model.hasPrefix("o1"), !request.model.hasPrefix("o3") { + body["temperature"] = temperature + } + if let tools = request.tools { + let toolsData = try JSONEncoder().encode(tools) + body["tools"] = try JSONSerialization.jsonObject(with: toolsData) + body["tool_choice"] = "auto" + } + if stream { + // Request usage in streaming mode + body["stream_options"] = ["include_usage": true] + } + + let bodyData = try JSONSerialization.data(withJSONObject: body) + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") + if stream { + urlRequest.addValue("text/event-stream", forHTTPHeaderField: "Accept") + } + urlRequest.httpBody = bodyData + + return urlRequest + } + + private func convertToChatResponse(_ apiResponse: OpenRouterChatResponse) -> ChatResponse { + guard let choice = apiResponse.choices.first else { + return ChatResponse(id: apiResponse.id, model: apiResponse.model, content: "", role: "assistant", finishReason: nil, usage: nil, created: Date()) + } + + let toolCalls: [ToolCallInfo]? = choice.message.toolCalls?.map { tc in + ToolCallInfo(id: tc.id, type: tc.type, functionName: tc.function.name, arguments: tc.function.arguments) + } + + return ChatResponse( + id: apiResponse.id, + model: apiResponse.model, + content: choice.message.content ?? "", + role: choice.message.role, + finishReason: choice.finishReason, + usage: apiResponse.usage.map { + ChatResponse.Usage(promptTokens: $0.promptTokens, completionTokens: $0.completionTokens, totalTokens: $0.totalTokens) + }, + created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)), + toolCalls: toolCalls + ) + } +} diff --git a/oAI/Providers/OpenRouterModels.swift b/oAI/Providers/OpenRouterModels.swift new file mode 100644 index 0000000..46e897f --- /dev/null +++ b/oAI/Providers/OpenRouterModels.swift @@ -0,0 +1,313 @@ +// +// OpenRouterModels.swift +// oAI +// +// OpenRouter API request and response models +// + +import Foundation + +// MARK: - API Request + +struct OpenRouterChatRequest: Codable { + let model: String + let messages: [APIMessage] + var stream: Bool + let maxTokens: Int? + let temperature: Double? + let topP: Double? + let tools: [Tool]? + let toolChoice: String? + let modalities: [String]? + + struct APIMessage: Codable { + let role: String + let content: MessageContent + + enum MessageContent: Codable { + case string(String) + case array([ContentItem]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let str = try? container.decode(String.self) { + self = .string(str) + } else if let arr = try? container.decode([ContentItem].self) { + self = .array(arr) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid content") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let str): + try container.encode(str) + case .array(let arr): + try container.encode(arr) + } + } + } + + enum ContentItem: Codable { + case text(String) + case image(ImageContent) + + struct TextContent: Codable { + let type: String // "text" + let text: String + } + + struct ImageContent: Codable { + let type: String // "image_url" + let imageUrl: ImageURL + + struct ImageURL: Codable { + let url: String + } + + enum CodingKeys: String, CodingKey { + case type + case imageUrl = "image_url" + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let textContent = try? container.decode(TextContent.self), textContent.type == "text" { + self = .text(textContent.text) + } else if let image = try? container.decode(ImageContent.self) { + self = .image(image) + } else if let str = try? container.decode(String.self) { + self = .text(str) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid content item") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .text(let text): + try container.encode(TextContent(type: "text", text: text)) + case .image(let image): + try container.encode(image) + } + } + } + } + + enum CodingKeys: String, CodingKey { + case model + case messages + case stream + case maxTokens = "max_tokens" + case temperature + case topP = "top_p" + case tools + case toolChoice = "tool_choice" + case modalities + } +} + +// MARK: - API Response + +struct OpenRouterChatResponse: Codable { + let id: String + let model: String + let choices: [Choice] + let usage: Usage? + let created: Int + + struct Choice: Codable { + let index: Int + let message: MessageContent + let finishReason: String? + + struct MessageContent: Codable { + let role: String + let content: String? + let toolCalls: [APIToolCall]? + let images: [ImageOutput]? + + enum CodingKeys: String, CodingKey { + case role + case content + case toolCalls = "tool_calls" + case images + } + } + + enum CodingKeys: String, CodingKey { + case index + case message + case finishReason = "finish_reason" + } + } + + struct ImageOutput: Codable { + let imageUrl: ImageURL + + struct ImageURL: Codable { + let url: String + } + + enum CodingKeys: String, CodingKey { + case imageUrl = "image_url" + } + } + + struct Usage: Codable { + let promptTokens: Int + let completionTokens: Int + let totalTokens: Int + + enum CodingKeys: String, CodingKey { + case promptTokens = "prompt_tokens" + case completionTokens = "completion_tokens" + case totalTokens = "total_tokens" + } + } +} + +// MARK: - Streaming Response + +struct OpenRouterStreamChunk: Codable { + let id: String + let model: String + let choices: [StreamChoice] + let usage: OpenRouterChatResponse.Usage? + + struct StreamChoice: Codable { + let index: Int + let delta: Delta + let finishReason: String? + + struct Delta: Codable { + let role: String? + let content: String? + let images: [OpenRouterChatResponse.ImageOutput]? + } + + enum CodingKeys: String, CodingKey { + case index + case delta + case finishReason = "finish_reason" + } + } +} + +// MARK: - Models List + +struct OpenRouterModelsResponse: Codable { + let data: [ModelData] + + struct ModelData: Codable { + let id: String + let name: String + let description: String? + let contextLength: Int + let pricing: PricingData + let architecture: Architecture? + let supportedParameters: [String]? + let outputModalities: [String]? + + struct PricingData: Codable { + let prompt: String + let completion: String + } + + struct Architecture: Codable { + let modality: String? + let tokenizer: String? + let instructType: String? + + enum CodingKeys: String, CodingKey { + case modality + case tokenizer + case instructType = "instruct_type" + } + } + + enum CodingKeys: String, CodingKey { + case id + case name + case description + case contextLength = "context_length" + case pricing + case architecture + case supportedParameters = "supported_parameters" + case outputModalities = "output_modalities" + } + } +} + +// MARK: - Credits Response + +struct OpenRouterCreditsResponse: Codable { + let data: CreditsData + + struct CreditsData: Codable { + let totalCredits: Double? + let totalUsage: Double? + + enum CodingKeys: String, CodingKey { + case totalCredits = "total_credits" + case totalUsage = "total_usage" + } + } +} + +// MARK: - Tool Call Models + +struct APIToolCall: Codable { + let id: String + let type: String + let function: FunctionCall + + struct FunctionCall: Codable { + let name: String + let arguments: String + } +} + +/// Message shape for encoding assistant messages that contain tool calls +struct AssistantToolCallMessage: Encodable { + let role: String + let content: String? + let toolCalls: [APIToolCall] + + enum CodingKeys: String, CodingKey { + case role + case content + case toolCalls = "tool_calls" + } +} + +/// Message shape for encoding tool result messages back to the API +struct ToolResultMessage: Encodable { + let role: String // "tool" + let toolCallId: String + let name: String + let content: String + + enum CodingKeys: String, CodingKey { + case role + case toolCallId = "tool_call_id" + case name + case content + } +} + +// MARK: - Error Response + +struct OpenRouterErrorResponse: Codable { + let error: ErrorDetail + + struct ErrorDetail: Codable { + let message: String + let type: String? + let code: String? + } +} diff --git a/oAI/Providers/OpenRouterProvider.swift b/oAI/Providers/OpenRouterProvider.swift new file mode 100644 index 0000000..a0b0009 --- /dev/null +++ b/oAI/Providers/OpenRouterProvider.swift @@ -0,0 +1,433 @@ +// +// OpenRouterProvider.swift +// oAI +// +// OpenRouter AI provider implementation with SSE streaming +// + +import Foundation +import os + +class OpenRouterProvider: AIProvider { + let name = "OpenRouter" + let capabilities = ProviderCapabilities( + supportsStreaming: true, + supportsVision: true, + supportsTools: true, + supportsOnlineSearch: true, + maxContextLength: nil + ) + + private let apiKey: String + private let baseURL = "https://openrouter.ai/api/v1" + private let session: URLSession + + init(apiKey: String) { + self.apiKey = apiKey + + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 60 + config.timeoutIntervalForResource = 300 + self.session = URLSession(configuration: config) + } + + // MARK: - List Models + + func listModels() async throws -> [ModelInfo] { + Log.api.info("Fetching model list from OpenRouter") + let url = URL(string: "\(baseURL)/models")! + var request = URLRequest(url: url) + request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + Log.api.error("OpenRouter models: invalid response (not HTTP)") + throw ProviderError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + if let errorResponse = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) { + Log.api.error("OpenRouter models HTTP \(httpResponse.statusCode): \(errorResponse.error.message)") + throw ProviderError.unknown(errorResponse.error.message) + } + Log.api.error("OpenRouter models HTTP \(httpResponse.statusCode)") + throw ProviderError.unknown("HTTP \(httpResponse.statusCode)") + } + + let modelsResponse = try JSONDecoder().decode(OpenRouterModelsResponse.self, from: data) + Log.api.info("OpenRouter loaded \(modelsResponse.data.count) models") + return modelsResponse.data.map { modelData in + let promptPrice = Double(modelData.pricing.prompt) ?? 0.0 + let completionPrice = Double(modelData.pricing.completion) ?? 0.0 + + return ModelInfo( + id: modelData.id, + name: modelData.name, + description: modelData.description, + contextLength: modelData.contextLength, + pricing: ModelInfo.Pricing( + prompt: promptPrice * 1_000_000, // Convert to per 1M tokens + completion: completionPrice * 1_000_000 + ), + capabilities: ModelInfo.ModelCapabilities( + vision: { + let mod = modelData.architecture?.modality ?? "" + return mod == "multimodal" || mod.hasPrefix("text+image") + }(), + tools: modelData.supportedParameters?.contains("tools") ?? false, + online: { + // OpenRouter supports :online suffix for all text models + let mod = modelData.architecture?.modality ?? "" + if let arrow = mod.range(of: "->") { + return !mod[arrow.upperBound...].contains("image") + } + return true + }(), + imageGeneration: { + if let mod = modelData.architecture?.modality, + let arrow = mod.range(of: "->") { + let output = mod[arrow.upperBound...] + return output.contains("image") + } + return false + }() + ), + architecture: modelData.architecture.map { arch in + ModelInfo.Architecture( + tokenizer: arch.tokenizer, + instructType: arch.instructType, + modality: arch.modality + ) + }, + topProvider: modelData.id.components(separatedBy: "/").first + ) + } + } + + func getModel(_ id: String) async throws -> ModelInfo? { + let models = try await listModels() + return models.first { $0.id == id } + } + + // MARK: - Chat Completion + + func chat(request: ChatRequest) async throws -> ChatResponse { + Log.api.info("OpenRouter chat request: model=\(request.model), messages=\(request.messages.count)") + let apiRequest = try buildAPIRequest(from: request) + let url = URL(string: "\(baseURL)/chat/completions")! + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.addValue("https://github.com/yourusername/oAI", forHTTPHeaderField: "HTTP-Referer") + urlRequest.addValue("oAI-Swift", forHTTPHeaderField: "X-Title") + urlRequest.httpBody = try JSONEncoder().encode(apiRequest) + + let (data, response) = try await session.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + throw ProviderError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + if let errorResponse = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) { + Log.api.error("OpenRouter chat HTTP \(httpResponse.statusCode): \(errorResponse.error.message)") + throw ProviderError.unknown(errorResponse.error.message) + } + Log.api.error("OpenRouter chat HTTP \(httpResponse.statusCode)") + throw ProviderError.unknown("HTTP \(httpResponse.statusCode)") + } + + let apiResponse = try JSONDecoder().decode(OpenRouterChatResponse.self, from: data) + return try convertToChatResponse(apiResponse) + } + + // MARK: - Chat with raw tool messages + + /// Chat completion that accepts pre-encoded messages (for the tool call loop where + /// message shapes vary: user, assistant+tool_calls, tool results). + func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse { + let url = URL(string: "\(baseURL)/chat/completions")! + + var body: [String: Any] = [ + "model": model, + "messages": messages, + "stream": false + ] + if let tools = tools { + let toolsData = try JSONEncoder().encode(tools) + body["tools"] = try JSONSerialization.jsonObject(with: toolsData) + body["tool_choice"] = "auto" + } + if let maxTokens = maxTokens { body["max_tokens"] = maxTokens } + if let temperature = temperature { body["temperature"] = temperature } + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.addValue("https://github.com/yourusername/oAI", forHTTPHeaderField: "HTTP-Referer") + urlRequest.addValue("oAI-Swift", forHTTPHeaderField: "X-Title") + urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await session.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + throw ProviderError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + if let errorResponse = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) { + Log.api.error("OpenRouter tool chat HTTP \(httpResponse.statusCode): \(errorResponse.error.message)") + throw ProviderError.unknown(errorResponse.error.message) + } + Log.api.error("OpenRouter tool chat HTTP \(httpResponse.statusCode)") + throw ProviderError.unknown("HTTP \(httpResponse.statusCode)") + } + + let apiResponse = try JSONDecoder().decode(OpenRouterChatResponse.self, from: data) + return try convertToChatResponse(apiResponse) + } + + // MARK: - Streaming Chat + + func streamChat(request: ChatRequest) -> AsyncThrowingStream { + Log.api.info("OpenRouter stream request: model=\(request.model), messages=\(request.messages.count)") + return AsyncThrowingStream { continuation in + Task { + do { + var apiRequest = try buildAPIRequest(from: request) + apiRequest.stream = true + + let url = URL(string: "\(baseURL)/chat/completions")! + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.addValue("text/event-stream", forHTTPHeaderField: "Accept") + urlRequest.addValue("https://github.com/yourusername/oAI", forHTTPHeaderField: "HTTP-Referer") + urlRequest.addValue("oAI-Swift", forHTTPHeaderField: "X-Title") + urlRequest.httpBody = try JSONEncoder().encode(apiRequest) + + let (bytes, response) = try await session.bytes(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + Log.api.error("OpenRouter stream: invalid response (not HTTP)") + continuation.finish(throwing: ProviderError.invalidResponse) + return + } + + guard httpResponse.statusCode == 200 else { + Log.api.error("OpenRouter stream HTTP \(httpResponse.statusCode)") + continuation.finish(throwing: ProviderError.unknown("HTTP \(httpResponse.statusCode)")) + return + } + + var buffer = "" + + for try await line in bytes.lines { + if line.hasPrefix("data: ") { + let jsonString = String(line.dropFirst(6)) + + if jsonString == "[DONE]" { + continuation.finish() + return + } + + buffer += jsonString + + if let jsonData = buffer.data(using: .utf8) { + do { + let chunk = try JSONDecoder().decode(OpenRouterStreamChunk.self, from: jsonData) + let streamChunk = try convertToStreamChunk(chunk) + continuation.yield(streamChunk) + buffer = "" + } catch { + // Partial JSON, keep buffering + continue + } + } + } + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + // MARK: - Credits + + func getCredits() async throws -> Credits? { + Log.api.info("Fetching OpenRouter credits") + let url = URL(string: "\(baseURL)/credits")! + var request = URLRequest(url: url) + request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + return nil + } + + let creditsResponse = try JSONDecoder().decode(OpenRouterCreditsResponse.self, from: data) + let totalCredits = creditsResponse.data.totalCredits ?? 0 + let totalUsage = creditsResponse.data.totalUsage ?? 0 + let remaining = totalCredits - totalUsage + + return Credits( + balance: remaining, + currency: "USD", + usage: totalUsage, + limit: totalCredits + ) + } + + // MARK: - Helper Methods + + private func buildAPIRequest(from request: ChatRequest) throws -> OpenRouterChatRequest { + let apiMessages = request.messages.map { message -> OpenRouterChatRequest.APIMessage in + + let hasAttachments = message.attachments?.contains(where: { $0.data != nil }) ?? false + + let content: OpenRouterChatRequest.APIMessage.MessageContent + + if hasAttachments { + // Use array format for messages with attachments + var contentArray: [OpenRouterChatRequest.APIMessage.ContentItem] = [] + + // Add main text content + contentArray.append(.text(message.content)) + + // Add attachments + if let attachments = message.attachments { + for attachment in attachments { + guard let data = attachment.data else { continue } + + switch attachment.type { + case .image, .pdf: + // Send as base64 data URL with correct MIME type + let base64String = data.base64EncodedString() + let dataURL = "data:\(attachment.mimeType);base64,\(base64String)" + let imageContent = OpenRouterChatRequest.APIMessage.ContentItem.ImageContent( + type: "image_url", + imageUrl: .init(url: dataURL) + ) + contentArray.append(.image(imageContent)) + + case .text: + // Inline text file content + let filename = (attachment.path as NSString).lastPathComponent + let textContent = String(data: data, encoding: .utf8) ?? "" + contentArray.append(.text("File: \(filename)\n\n\(textContent)")) + } + } + } + + content = .array(contentArray) + } else { + // Use simple string format for text-only messages + content = .string(message.content) + } + + return OpenRouterChatRequest.APIMessage( + role: message.role.rawValue, + content: content + ) + } + + // Append :online suffix for web search when online mode is enabled + let effectiveModel: String + if request.onlineMode && !request.imageGeneration && !request.model.hasSuffix(":online") { + effectiveModel = request.model + ":online" + } else { + effectiveModel = request.model + } + + return OpenRouterChatRequest( + model: effectiveModel, + messages: apiMessages, + stream: request.stream, + maxTokens: request.maxTokens, + temperature: request.temperature, + topP: request.topP, + tools: request.tools, + toolChoice: request.tools != nil ? "auto" : nil, + modalities: request.imageGeneration ? ["text", "image"] : nil + ) + } + + private func convertToChatResponse(_ apiResponse: OpenRouterChatResponse) throws -> ChatResponse { + guard let choice = apiResponse.choices.first else { + throw ProviderError.invalidResponse + } + + let toolCalls: [ToolCallInfo]? = choice.message.toolCalls?.map { tc in + ToolCallInfo(id: tc.id, type: tc.type, functionName: tc.function.name, arguments: tc.function.arguments) + } + + let images = choice.message.images.flatMap { decodeImageOutputs($0) } + + return ChatResponse( + id: apiResponse.id, + model: apiResponse.model, + content: choice.message.content ?? "", + role: choice.message.role, + finishReason: choice.finishReason, + usage: apiResponse.usage.map { usage in + ChatResponse.Usage( + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + totalTokens: usage.totalTokens + ) + }, + created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)), + toolCalls: toolCalls, + generatedImages: images + ) + } + + private func convertToStreamChunk(_ apiChunk: OpenRouterStreamChunk) throws -> StreamChunk { + guard let choice = apiChunk.choices.first else { + throw ProviderError.invalidResponse + } + + let images = choice.delta.images.flatMap { decodeImageOutputs($0) } + + return StreamChunk( + id: apiChunk.id, + model: apiChunk.model, + delta: StreamChunk.Delta( + content: choice.delta.content, + role: choice.delta.role, + images: images + ), + finishReason: choice.finishReason, + usage: apiChunk.usage.map { usage in + ChatResponse.Usage( + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + totalTokens: usage.totalTokens + ) + } + ) + } + + /// Decode base64 data URL images from API response + private func decodeImageOutputs(_ outputs: [OpenRouterChatResponse.ImageOutput]) -> [Data]? { + let decoded = outputs.compactMap { output -> Data? in + let url = output.imageUrl.url + // Strip "data:image/...;base64," prefix + guard let commaIndex = url.firstIndex(of: ",") else { return nil } + let base64String = String(url[url.index(after: commaIndex)...]) + return Data(base64Encoded: base64String) + } + return decoded.isEmpty ? nil : decoded + } +} diff --git a/oAI/Providers/ProviderRegistry.swift b/oAI/Providers/ProviderRegistry.swift new file mode 100644 index 0000000..711f3a9 --- /dev/null +++ b/oAI/Providers/ProviderRegistry.swift @@ -0,0 +1,102 @@ +// +// ProviderRegistry.swift +// oAI +// +// Registry for managing multiple AI providers +// + +import Foundation +import os + +class ProviderRegistry { + static let shared = ProviderRegistry() + + private var providers: [Settings.Provider: AIProvider] = [:] + private let settings = SettingsService.shared + + private init() {} + + // MARK: - Get Provider + + func getProvider(for providerType: Settings.Provider) -> AIProvider? { + // Return cached provider if exists + if let provider = providers[providerType] { + return provider + } + + // Create new provider based on type + let provider: AIProvider? + + switch providerType { + case .openrouter: + guard let apiKey = settings.openrouterAPIKey, !apiKey.isEmpty else { + Log.api.warning("No API key configured for OpenRouter") + return nil + } + provider = OpenRouterProvider(apiKey: apiKey) + + case .anthropic: + if AnthropicOAuthService.shared.isAuthenticated { + // OAuth (Pro/Max subscription) takes precedence + provider = AnthropicProvider(oauth: true) + } else if let apiKey = settings.anthropicAPIKey, !apiKey.isEmpty { + provider = AnthropicProvider(apiKey: apiKey) + } else { + Log.api.warning("No API key or OAuth configured for Anthropic") + return nil + } + + case .openai: + guard let apiKey = settings.openaiAPIKey, !apiKey.isEmpty else { + Log.api.warning("No API key configured for OpenAI") + return nil + } + provider = OpenAIProvider(apiKey: apiKey) + + case .ollama: + provider = OllamaProvider(baseURL: settings.ollamaEffectiveURL) + } + + // Cache and return + if let provider = provider { + Log.api.info("Created \(providerType.rawValue) provider") + providers[providerType] = provider + } + + return provider + } + + // MARK: - Current Provider + + func getCurrentProvider() -> AIProvider? { + let currentProviderType = settings.defaultProvider + return getProvider(for: currentProviderType) + } + + // MARK: - Clear Cache + + func clearCache() { + providers.removeAll() + } + + // MARK: - Validate API Key + + func hasValidAPIKey(for providerType: Settings.Provider) -> Bool { + switch providerType { + case .openrouter: + return settings.openrouterAPIKey != nil && !settings.openrouterAPIKey!.isEmpty + case .anthropic: + return AnthropicOAuthService.shared.isAuthenticated + || (settings.anthropicAPIKey != nil && !settings.anthropicAPIKey!.isEmpty) + case .openai: + return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty + case .ollama: + return settings.ollamaConfigured + } + } + + /// Providers that have credentials configured (API key or, for Ollama, a saved URL) + var configuredProviders: [Settings.Provider] { + Settings.Provider.allCases.filter { hasValidAPIKey(for: $0) } + } +} diff --git a/oAI/Services/AnthropicOAuthService.swift b/oAI/Services/AnthropicOAuthService.swift new file mode 100644 index 0000000..35d1c8e --- /dev/null +++ b/oAI/Services/AnthropicOAuthService.swift @@ -0,0 +1,298 @@ +// +// AnthropicOAuthService.swift +// oAI +// +// OAuth 2.0 PKCE flow for Anthropic Pro/Max subscription login +// + +import Foundation +import CryptoKit +import Security + +@Observable +class AnthropicOAuthService { + static let shared = AnthropicOAuthService() + + // OAuth configuration (matches Claude Code CLI) + private let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + private let redirectURI = "https://console.anthropic.com/oauth/code/callback" + private let scope = "org:create_api_key user:profile user:inference" + private let tokenEndpoint = "https://console.anthropic.com/v1/oauth/token" + + // Keychain keys + private enum Keys { + static let accessToken = "com.oai.anthropic.oauth.accessToken" + static let refreshToken = "com.oai.anthropic.oauth.refreshToken" + static let expiresAt = "com.oai.anthropic.oauth.expiresAt" + } + + // PKCE state for current flow + private var currentVerifier: String? + + // Observable state + var isAuthenticated: Bool { accessToken != nil } + var isLoggingIn = false + + // MARK: - Token Access + + var accessToken: String? { + getKeychainValue(for: Keys.accessToken) + } + + private var refreshToken: String? { + getKeychainValue(for: Keys.refreshToken) + } + + private var expiresAt: Date? { + guard let str = getKeychainValue(for: Keys.expiresAt), + let interval = Double(str) else { return nil } + return Date(timeIntervalSince1970: interval) + } + + var isTokenExpired: Bool { + guard let expires = expiresAt else { return true } + return Date() >= expires + } + + // MARK: - Step 1: Generate Authorization URL + + func generateAuthorizationURL() -> URL { + let verifier = generateCodeVerifier() + currentVerifier = verifier + let challenge = generateCodeChallenge(from: verifier) + + var components = URLComponents(string: "https://claude.ai/oauth/authorize")! + components.queryItems = [ + URLQueryItem(name: "code", value: "true"), + URLQueryItem(name: "client_id", value: clientId), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "redirect_uri", value: redirectURI), + URLQueryItem(name: "scope", value: scope), + URLQueryItem(name: "code_challenge", value: challenge), + URLQueryItem(name: "code_challenge_method", value: "S256"), + URLQueryItem(name: "state", value: verifier), + ] + + return components.url! + } + + // MARK: - Step 2: Exchange Code for Tokens + + func exchangeCode(_ pastedCode: String) async throws { + guard let verifier = currentVerifier else { + throw OAuthError.noVerifier + } + + // Code format: "auth_code#state" + let parts = pastedCode.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "#") + let authCode: String + let state: String + + if parts.count >= 2 { + authCode = parts[0] + state = parts.dropFirst().joined(separator: "#") + } else { + // If no # separator, treat entire string as the code + authCode = pastedCode.trimmingCharacters(in: .whitespacesAndNewlines) + state = verifier + } + + Log.api.info("Exchanging OAuth code for tokens") + + let body: [String: String] = [ + "code": authCode, + "state": state, + "grant_type": "authorization_code", + "client_id": clientId, + "redirect_uri": redirectURI, + "code_verifier": verifier, + ] + + let tokenResponse = try await postTokenRequest(body) + saveTokens(tokenResponse) + currentVerifier = nil + + Log.api.info("OAuth login successful, token expires in \(tokenResponse.expiresIn)s") + } + + // MARK: - Token Refresh + + func refreshAccessToken() async throws { + guard let refresh = refreshToken else { + throw OAuthError.noRefreshToken + } + + Log.api.info("Refreshing OAuth access token") + + let body: [String: String] = [ + "grant_type": "refresh_token", + "refresh_token": refresh, + "client_id": clientId, + ] + + let tokenResponse = try await postTokenRequest(body) + saveTokens(tokenResponse) + + Log.api.info("OAuth token refreshed successfully") + } + + /// Returns a valid access token, refreshing if needed + func getValidAccessToken() async throws -> String { + guard let token = accessToken else { + throw OAuthError.notAuthenticated + } + + if isTokenExpired { + try await refreshAccessToken() + guard let newToken = accessToken else { + throw OAuthError.notAuthenticated + } + return newToken + } + + return token + } + + // MARK: - Logout + + func logout() { + deleteKeychainValue(for: Keys.accessToken) + deleteKeychainValue(for: Keys.refreshToken) + deleteKeychainValue(for: Keys.expiresAt) + currentVerifier = nil + Log.api.info("OAuth logout complete") + } + + // MARK: - PKCE Helpers + + private func generateCodeVerifier() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + return Data(bytes).base64URLEncoded() + } + + private func generateCodeChallenge(from verifier: String) -> String { + let data = Data(verifier.utf8) + let hash = SHA256.hash(data: data) + return Data(hash).base64URLEncoded() + } + + // MARK: - Token Request + + private func postTokenRequest(_ body: [String: String]) async throws -> TokenResponse { + var request = URLRequest(url: URL(string: tokenEndpoint)!) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw OAuthError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error" + Log.api.error("OAuth token exchange failed HTTP \(httpResponse.statusCode): \(errorBody)") + throw OAuthError.tokenExchangeFailed(httpResponse.statusCode, errorBody) + } + + return try JSONDecoder().decode(TokenResponse.self, from: data) + } + + // MARK: - Token Storage + + private func saveTokens(_ response: TokenResponse) { + setKeychainValue(response.accessToken, for: Keys.accessToken) + if let refresh = response.refreshToken { + setKeychainValue(refresh, for: Keys.refreshToken) + } + let expiresAt = Date().addingTimeInterval(TimeInterval(response.expiresIn)) + setKeychainValue(String(expiresAt.timeIntervalSince1970), for: Keys.expiresAt) + } + + // MARK: - Keychain Helpers + + private func getKeychainValue(for key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var ref: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &ref) == errSecSuccess, + let data = ref as? Data, + let value = String(data: data, encoding: .utf8) else { + return nil + } + return value + } + + private func setKeychainValue(_ value: String, for key: String) { + guard let data = value.data(using: .utf8) else { return } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + ] + let attrs: [String: Any] = [kSecValueData as String: data] + let status = SecItemUpdate(query as CFDictionary, attrs as CFDictionary) + if status == errSecItemNotFound { + var newItem = query + newItem[kSecValueData as String] = data + SecItemAdd(newItem as CFDictionary, nil) + } + } + + private func deleteKeychainValue(for key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + ] + SecItemDelete(query as CFDictionary) + } + + // MARK: - Types + + struct TokenResponse: Decodable { + let accessToken: String + let refreshToken: String? + let expiresIn: Int + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case expiresIn = "expires_in" + } + } + + enum OAuthError: LocalizedError { + case noVerifier + case noRefreshToken + case notAuthenticated + case invalidResponse + case tokenExchangeFailed(Int, String) + + var errorDescription: String? { + switch self { + case .noVerifier: return "No PKCE verifier — start the login flow first." + case .noRefreshToken: return "No refresh token available. Please log in again." + case .notAuthenticated: return "Not authenticated. Please log in." + case .invalidResponse: return "Invalid response from Anthropic OAuth server." + case .tokenExchangeFailed(let code, let body): + return "Token exchange failed (HTTP \(code)): \(body)" + } + } + } +} + +// MARK: - Base64URL Encoding + +private extension Data { + func base64URLEncoded() -> String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/oAI/Services/DatabaseService.swift b/oAI/Services/DatabaseService.swift new file mode 100644 index 0000000..6094f08 --- /dev/null +++ b/oAI/Services/DatabaseService.swift @@ -0,0 +1,318 @@ +// +// DatabaseService.swift +// oAI +// +// SQLite persistence layer for conversations using GRDB +// + +import Foundation +import GRDB +import os + +// MARK: - Database Record Types + +struct ConversationRecord: Codable, FetchableRecord, PersistableRecord, Sendable { + static let databaseTableName = "conversations" + + var id: String + var name: String + var createdAt: String + var updatedAt: String +} + +struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable { + static let databaseTableName = "messages" + + var id: String + var conversationId: String + var role: String + var content: String + var tokens: Int? + var cost: Double? + var timestamp: String + var sortOrder: Int +} + +struct SettingRecord: Codable, FetchableRecord, PersistableRecord, Sendable { + static let databaseTableName = "settings" + + var key: String + var value: String +} + +// MARK: - DatabaseService + +final class DatabaseService: Sendable { + nonisolated static let shared = DatabaseService() + + private let dbQueue: DatabaseQueue + private let isoFormatter: ISO8601DateFormatter + + nonisolated private init() { + isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + let fileManager = FileManager.default + let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true) + + try! fileManager.createDirectory(at: dbDirectory, withIntermediateDirectories: true) + + let dbPath = dbDirectory.appendingPathComponent("oai_conversations.db").path + Log.db.info("Opening database at \(dbPath)") + dbQueue = try! DatabaseQueue(path: dbPath) + + try! migrator.migrate(dbQueue) + } + + private var migrator: DatabaseMigrator { + var migrator = DatabaseMigrator() + + migrator.registerMigration("v1") { db in + try db.create(table: "conversations") { t in + t.primaryKey("id", .text) + t.column("name", .text).notNull() + t.column("createdAt", .text).notNull() + t.column("updatedAt", .text).notNull() + } + + try db.create(table: "messages") { t in + t.primaryKey("id", .text) + t.column("conversationId", .text).notNull() + .references("conversations", onDelete: .cascade) + t.column("role", .text).notNull() + t.column("content", .text).notNull() + t.column("tokens", .integer) + t.column("cost", .double) + t.column("timestamp", .text).notNull() + t.column("sortOrder", .integer).notNull() + } + + try db.create( + index: "messages_on_conversationId", + on: "messages", + columns: ["conversationId"] + ) + } + + migrator.registerMigration("v2") { db in + try db.create(table: "settings") { t in + t.primaryKey("key", .text) + t.column("value", .text).notNull() + } + } + + return migrator + } + + // MARK: - Settings Operations + + nonisolated func loadAllSettings() throws -> [String: String] { + try dbQueue.read { db in + let records = try SettingRecord.fetchAll(db) + return Dictionary(uniqueKeysWithValues: records.map { ($0.key, $0.value) }) + } + } + + nonisolated func setSetting(key: String, value: String) { + try? dbQueue.write { db in + let record = SettingRecord(key: key, value: value) + try record.save(db) + } + } + + nonisolated func deleteSetting(key: String) { + try? dbQueue.write { db in + _ = try SettingRecord.deleteOne(db, key: key) + } + } + + // MARK: - Conversation Operations + + nonisolated func saveConversation(name: String, messages: [Message]) throws -> Conversation { + Log.db.info("Saving conversation '\(name)' with \(messages.count) messages") + let convId = UUID() + let now = Date() + let nowString = isoFormatter.string(from: now) + + let convRecord = ConversationRecord( + id: convId.uuidString, + name: name, + createdAt: nowString, + updatedAt: nowString + ) + + let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in + guard msg.role != .system else { return nil } + return MessageRecord( + id: UUID().uuidString, + conversationId: convId.uuidString, + role: msg.role.rawValue, + content: msg.content, + tokens: msg.tokens, + cost: msg.cost, + timestamp: isoFormatter.string(from: msg.timestamp), + sortOrder: index + ) + } + + try dbQueue.write { db in + try convRecord.insert(db) + for record in messageRecords { + try record.insert(db) + } + } + + let savedMessages = messages.filter { $0.role != .system } + return Conversation( + id: convId, + name: name, + messages: savedMessages, + createdAt: now, + updatedAt: now + ) + } + + nonisolated func loadConversation(id: UUID) throws -> (Conversation, [Message])? { + try dbQueue.read { db in + guard let convRecord = try ConversationRecord.fetchOne(db, key: id.uuidString) else { + return nil + } + + let messageRecords = try MessageRecord + .filter(Column("conversationId") == id.uuidString) + .order(Column("sortOrder")) + .fetchAll(db) + + let messages = messageRecords.compactMap { record -> Message? in + guard let msgId = UUID(uuidString: record.id), + let role = MessageRole(rawValue: record.role), + let timestamp = self.isoFormatter.date(from: record.timestamp) + else { return nil } + + return Message( + id: msgId, + role: role, + content: record.content, + tokens: record.tokens, + cost: record.cost, + timestamp: timestamp + ) + } + + guard let convId = UUID(uuidString: convRecord.id), + let createdAt = self.isoFormatter.date(from: convRecord.createdAt), + let updatedAt = self.isoFormatter.date(from: convRecord.updatedAt) + else { return nil } + + let conversation = Conversation( + id: convId, + name: convRecord.name, + messages: messages, + createdAt: createdAt, + updatedAt: updatedAt + ) + + return (conversation, messages) + } + } + + nonisolated func listConversations() throws -> [Conversation] { + try dbQueue.read { db in + let records = try ConversationRecord + .order(Column("updatedAt").desc) + .fetchAll(db) + + return records.compactMap { record -> Conversation? in + guard let id = UUID(uuidString: record.id), + let createdAt = self.isoFormatter.date(from: record.createdAt), + let updatedAt = self.isoFormatter.date(from: record.updatedAt) + else { return nil } + + // Fetch message count without loading all messages + let messageCount = (try? MessageRecord + .filter(Column("conversationId") == record.id) + .fetchCount(db)) ?? 0 + + // Get last message date + let lastMsg = try? MessageRecord + .filter(Column("conversationId") == record.id) + .order(Column("sortOrder").desc) + .fetchOne(db) + + let lastDate = lastMsg.flatMap { self.isoFormatter.date(from: $0.timestamp) } ?? updatedAt + + // Create conversation with empty messages array but correct metadata + var conv = Conversation( + id: id, + name: record.name, + messages: Array(repeating: Message(role: .user, content: ""), count: messageCount), + createdAt: createdAt, + updatedAt: lastDate + ) + // We store placeholder messages just for the count; lastMessageDate uses updatedAt + conv.updatedAt = lastDate + return conv + } + } + } + + nonisolated func deleteConversation(id: UUID) throws -> Bool { + Log.db.info("Deleting conversation \(id.uuidString)") + return try dbQueue.write { db in + try MessageRecord.filter(Column("conversationId") == id.uuidString).deleteAll(db) + return try ConversationRecord.deleteOne(db, key: id.uuidString) + } + } + + nonisolated func deleteConversation(name: String) throws -> Bool { + try dbQueue.write { db in + guard let record = try ConversationRecord + .filter(Column("name") == name) + .fetchOne(db) + else { return false } + + try MessageRecord.filter(Column("conversationId") == record.id).deleteAll(db) + return try ConversationRecord.deleteOne(db, key: record.id) + } + } + + nonisolated func updateConversation(id: UUID, name: String?, messages: [Message]?) throws -> Bool { + try dbQueue.write { db in + guard var convRecord = try ConversationRecord.fetchOne(db, key: id.uuidString) else { + return false + } + + if let name = name { + convRecord.name = name + } + + convRecord.updatedAt = self.isoFormatter.string(from: Date()) + try convRecord.update(db) + + if let messages = messages { + try MessageRecord.filter(Column("conversationId") == id.uuidString).deleteAll(db) + + let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in + guard msg.role != .system else { return nil } + return MessageRecord( + id: UUID().uuidString, + conversationId: id.uuidString, + role: msg.role.rawValue, + content: msg.content, + tokens: msg.tokens, + cost: msg.cost, + timestamp: self.isoFormatter.string(from: msg.timestamp), + sortOrder: index + ) + } + + for record in messageRecords { + try record.insert(db) + } + } + + return true + } + } +} diff --git a/oAI/Services/MCPService.swift b/oAI/Services/MCPService.swift new file mode 100644 index 0000000..b50eb69 --- /dev/null +++ b/oAI/Services/MCPService.swift @@ -0,0 +1,840 @@ +// +// MCPService.swift +// oAI +// +// MCP (Model Context Protocol) service for filesystem tool execution +// + +import Foundation +import os + +@Observable +class MCPService { + static let shared = MCPService() + + private(set) var allowedFolders: [String] = [] + private let settings = SettingsService.shared + private let fm = FileManager.default + + private let maxFileSize = 10 * 1024 * 1024 // 10 MB + private let maxTextDisplay = 50 * 1024 // 50 KB before truncation + private let maxDirItems = 1000 + private let maxSearchResults = 100 + + private let skipPatterns: Set = [ + ".git", "node_modules", ".DS_Store", "__pycache__", + ".build", ".swiftpm", "Pods", ".Trash", ".Spotlight-V100" + ] + + /// Cached gitignore rules per allowed folder + private var gitignoreRules: [String: GitignoreParser] = [:] + + private init() { + allowedFolders = settings.mcpAllowedFolders + if settings.mcpRespectGitignore { + loadGitignores() + } + } + + // MARK: - Folder Management + + func addFolder(_ rawPath: String) -> String? { + let expanded = (rawPath as NSString).expandingTildeInPath + let resolved = (expanded as NSString).standardizingPath + + var isDir: ObjCBool = false + guard fm.fileExists(atPath: resolved, isDirectory: &isDir), isDir.boolValue else { + return "Path is not a directory: \(rawPath)" + } + + if allowedFolders.contains(resolved) { + return "Folder already added: \(resolved)" + } + + allowedFolders.append(resolved) + settings.mcpAllowedFolders = allowedFolders + if settings.mcpRespectGitignore { + loadGitignoreForFolder(resolved) + } + return nil + } + + func removeFolder(at index: Int) -> Bool { + guard index >= 0 && index < allowedFolders.count else { return false } + let removed = allowedFolders.remove(at: index) + settings.mcpAllowedFolders = allowedFolders + gitignoreRules.removeValue(forKey: removed) + return true + } + + func removeFolder(path: String) -> Bool { + let resolved = ((path as NSString).expandingTildeInPath as NSString).standardizingPath + if let index = allowedFolders.firstIndex(of: resolved) { + allowedFolders.remove(at: index) + settings.mcpAllowedFolders = allowedFolders + gitignoreRules.removeValue(forKey: resolved) + return true + } + return false + } + + func isPathAllowed(_ path: String) -> Bool { + let resolved = ((path as NSString).expandingTildeInPath as NSString).standardizingPath + return allowedFolders.contains { resolved.hasPrefix($0) } + } + + // MARK: - Permission Helpers + + var canWriteFiles: Bool { settings.mcpCanWriteFiles } + var canDeleteFiles: Bool { settings.mcpCanDeleteFiles } + var canCreateDirectories: Bool { settings.mcpCanCreateDirectories } + var canMoveFiles: Bool { settings.mcpCanMoveFiles } + var respectGitignore: Bool { settings.mcpRespectGitignore } + + // MARK: - Tool Schema Generation + + func getToolSchemas() -> [Tool] { + var tools: [Tool] = [ + makeTool( + name: "read_file", + description: "Read the contents of a file. Returns the text content of the file. Maximum file size is 10MB.", + properties: [ + "file_path": prop("string", "The absolute path to the file to read") + ], + required: ["file_path"] + ), + makeTool( + name: "list_directory", + description: "List the contents of a directory. Returns file and directory names. Skips hidden/build directories like .git, node_modules, etc.", + properties: [ + "dir_path": prop("string", "The absolute path to the directory to list"), + "recursive": prop("boolean", "Whether to list recursively (default: false)") + ], + required: ["dir_path"] + ), + makeTool( + name: "search_files", + description: "Search for files by name pattern or content. Use 'pattern' for filename glob matching (e.g. '*.swift'). Use 'content_search' for searching inside file contents.", + properties: [ + "pattern": prop("string", "Glob pattern to match filenames (e.g. '*.py', 'README*')"), + "search_path": prop("string", "Directory to search in (defaults to first allowed folder)"), + "content_search": prop("string", "Optional text to search for inside files") + ], + required: ["pattern"] + ) + ] + + if canWriteFiles { + tools.append(makeTool( + name: "write_file", + description: "Create or overwrite a file with the given content. Parent directories are created automatically.", + properties: [ + "file_path": prop("string", "The absolute path to the file to write"), + "content": prop("string", "The text content to write to the file") + ], + required: ["file_path", "content"] + )) + tools.append(makeTool( + name: "edit_file", + description: "Find and replace text in a file. The old_text must appear exactly once in the file.", + properties: [ + "file_path": prop("string", "The absolute path to the file to edit"), + "old_text": prop("string", "The exact text to find (must be a unique match)"), + "new_text": prop("string", "The replacement text") + ], + required: ["file_path", "old_text", "new_text"] + )) + } + + if canDeleteFiles { + tools.append(makeTool( + name: "delete_file", + description: "Delete a file at the given path.", + properties: [ + "file_path": prop("string", "The absolute path to the file to delete") + ], + required: ["file_path"] + )) + } + + if canCreateDirectories { + tools.append(makeTool( + name: "create_directory", + description: "Create a directory (and any intermediate directories) at the given path.", + properties: [ + "dir_path": prop("string", "The absolute path to the directory to create") + ], + required: ["dir_path"] + )) + } + + if canMoveFiles { + tools.append(makeTool( + name: "move_file", + description: "Move or rename a file or directory.", + properties: [ + "source": prop("string", "The absolute path of the file/directory to move"), + "destination": prop("string", "The absolute destination path") + ], + required: ["source", "destination"] + )) + tools.append(makeTool( + name: "copy_file", + description: "Copy a file or directory to a new location.", + properties: [ + "source": prop("string", "The absolute path of the file/directory to copy"), + "destination": prop("string", "The absolute destination path for the copy") + ], + required: ["source", "destination"] + )) + } + + return tools + } + + private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool { + Tool( + type: "function", + function: Tool.Function( + name: name, + description: description, + parameters: Tool.Function.Parameters( + type: "object", + properties: properties, + required: required + ) + ) + ) + } + + private func prop(_ type: String, _ description: String) -> Tool.Function.Parameters.Property { + Tool.Function.Parameters.Property(type: type, description: description, enum: nil) + } + + // MARK: - Tool Execution + + func executeTool(name: String, arguments: String) -> [String: Any] { + Log.mcp.info("Executing tool: \(name)") + guard let argData = arguments.data(using: .utf8), + let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else { + Log.mcp.error("Invalid arguments JSON for tool \(name)") + return ["error": "Invalid arguments JSON"] + } + + switch name { + case "read_file": + guard let filePath = args["file_path"] as? String else { + return ["error": "Missing required parameter: file_path"] + } + return readFile(filePath: filePath) + + case "list_directory": + guard let dirPath = args["dir_path"] as? String else { + return ["error": "Missing required parameter: dir_path"] + } + let recursive = args["recursive"] as? Bool ?? false + return listDirectory(dirPath: dirPath, recursive: recursive) + + case "search_files": + guard let pattern = args["pattern"] as? String else { + return ["error": "Missing required parameter: pattern"] + } + let searchPath = args["search_path"] as? String + let contentSearch = args["content_search"] as? String + return searchFiles(pattern: pattern, searchPath: searchPath, contentSearch: contentSearch) + + case "write_file": + guard canWriteFiles else { + return ["error": "Permission denied: write_file is not enabled. Enable 'Write & Edit Files' in Settings > MCP."] + } + guard let filePath = args["file_path"] as? String, + let content = args["content"] as? String else { + return ["error": "Missing required parameters: file_path, content"] + } + return writeFile(filePath: filePath, content: content) + + case "edit_file": + guard canWriteFiles else { + return ["error": "Permission denied: edit_file is not enabled. Enable 'Write & Edit Files' in Settings > MCP."] + } + guard let filePath = args["file_path"] as? String, + let oldText = args["old_text"] as? String, + let newText = args["new_text"] as? String else { + return ["error": "Missing required parameters: file_path, old_text, new_text"] + } + return editFile(filePath: filePath, oldText: oldText, newText: newText) + + case "delete_file": + guard canDeleteFiles else { + return ["error": "Permission denied: delete_file is not enabled. Enable 'Delete Files' in Settings > MCP."] + } + guard let filePath = args["file_path"] as? String else { + return ["error": "Missing required parameter: file_path"] + } + return deleteFile(filePath: filePath) + + case "create_directory": + guard canCreateDirectories else { + return ["error": "Permission denied: create_directory is not enabled. Enable 'Create Directories' in Settings > MCP."] + } + guard let dirPath = args["dir_path"] as? String else { + return ["error": "Missing required parameter: dir_path"] + } + return createDirectory(dirPath: dirPath) + + case "move_file": + guard canMoveFiles else { + return ["error": "Permission denied: move_file is not enabled. Enable 'Move & Copy Files' in Settings > MCP."] + } + guard let source = args["source"] as? String, + let destination = args["destination"] as? String else { + return ["error": "Missing required parameters: source, destination"] + } + return moveFile(source: source, destination: destination) + + case "copy_file": + guard canMoveFiles else { + return ["error": "Permission denied: copy_file is not enabled. Enable 'Move & Copy Files' in Settings > MCP."] + } + guard let source = args["source"] as? String, + let destination = args["destination"] as? String else { + return ["error": "Missing required parameters: source, destination"] + } + return copyFile(source: source, destination: destination) + + default: + return ["error": "Unknown tool: \(name)"] + } + } + + // MARK: - Read Tool Implementations + + private func readFile(filePath: String) -> [String: Any] { + let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath + + guard isPathAllowed(resolved) else { + Log.mcp.warning("Read denied: path outside allowed folders: \(resolved)") + return ["error": "Access denied: path is outside allowed folders"] + } + + guard fm.fileExists(atPath: resolved) else { + return ["error": "File not found: \(filePath)"] + } + + guard let attrs = try? fm.attributesOfItem(atPath: resolved), + let fileSize = attrs[.size] as? Int else { + return ["error": "Cannot read file attributes: \(filePath)"] + } + + if fileSize > maxFileSize { + let sizeMB = String(format: "%.1f", Double(fileSize) / 1_000_000) + return ["error": "File too large (\(sizeMB) MB, max 10 MB)"] + } + + guard let content = try? String(contentsOfFile: resolved, encoding: .utf8) else { + return ["error": "Cannot read file as UTF-8 text: \(filePath)"] + } + + var finalContent = content + if content.utf8.count > maxTextDisplay { + let lines = content.components(separatedBy: "\n") + if lines.count > 600 { + let head = lines.prefix(500).joined(separator: "\n") + let tail = lines.suffix(100).joined(separator: "\n") + let omitted = lines.count - 600 + finalContent = head + "\n\n... [\(omitted) lines omitted] ...\n\n" + tail + } + } + + return ["content": finalContent, "path": resolved, "size": fileSize] + } + + private func listDirectory(dirPath: String, recursive: Bool) -> [String: Any] { + let resolved = ((dirPath as NSString).expandingTildeInPath as NSString).standardizingPath + + guard isPathAllowed(resolved) else { + return ["error": "Access denied: path is outside allowed folders"] + } + + var isDir: ObjCBool = false + guard fm.fileExists(atPath: resolved, isDirectory: &isDir), isDir.boolValue else { + return ["error": "Directory not found: \(dirPath)"] + } + + var items: [String] = [] + + if recursive { + guard let enumerator = fm.enumerator(atPath: resolved) else { + return ["error": "Cannot enumerate directory"] + } + while let item = enumerator.nextObject() as? String { + let components = item.components(separatedBy: "/") + if components.contains(where: { skipPatterns.contains($0) }) { + enumerator.skipDescendants() + continue + } + let fullPath = (resolved as NSString).appendingPathComponent(item) + if respectGitignore && isGitignored(fullPath) { + // Skip gitignored directories entirely + var itemIsDir: ObjCBool = false + if fm.fileExists(atPath: fullPath, isDirectory: &itemIsDir), itemIsDir.boolValue { + enumerator.skipDescendants() + } + continue + } + items.append(item) + if items.count >= maxDirItems { break } + } + } else { + guard let contents = try? fm.contentsOfDirectory(atPath: resolved) else { + return ["error": "Cannot list directory"] + } + items = contents.filter { !skipPatterns.contains($0) } + .filter { entry in + if respectGitignore { + let fullPath = (resolved as NSString).appendingPathComponent(entry) + return !isGitignored(fullPath) + } + return true + } + .prefix(maxDirItems).map { entry in + var entryIsDir: ObjCBool = false + let fullPath = (resolved as NSString).appendingPathComponent(entry) + fm.fileExists(atPath: fullPath, isDirectory: &entryIsDir) + return entryIsDir.boolValue ? "\(entry)/" : entry + } + } + + let truncated = items.count >= maxDirItems + return ["items": items, "count": items.count, "truncated": truncated, "path": resolved] + } + + private func searchFiles(pattern: String, searchPath: String?, contentSearch: String?) -> [String: Any] { + let basePath: String + if let sp = searchPath { + basePath = ((sp as NSString).expandingTildeInPath as NSString).standardizingPath + } else if let first = allowedFolders.first { + basePath = first + } else { + return ["error": "No search path specified and no allowed folders configured"] + } + + guard isPathAllowed(basePath) else { + return ["error": "Access denied: search path is outside allowed folders"] + } + + guard let enumerator = fm.enumerator(atPath: basePath) else { + return ["error": "Cannot enumerate directory: \(basePath)"] + } + + var results: [String] = [] + + while let item = enumerator.nextObject() as? String { + let components = item.components(separatedBy: "/") + if components.contains(where: { skipPatterns.contains($0) }) { + enumerator.skipDescendants() + continue + } + + let fullPath = (basePath as NSString).appendingPathComponent(item) + + if respectGitignore && isGitignored(fullPath) { + var itemIsDir: ObjCBool = false + if fm.fileExists(atPath: fullPath, isDirectory: &itemIsDir), itemIsDir.boolValue { + enumerator.skipDescendants() + } + continue + } + + let filename = (item as NSString).lastPathComponent + + // Filename pattern match + if fnmatch(pattern, filename, 0) != 0 { + continue + } + + // Content search if requested + if let searchText = contentSearch, !searchText.isEmpty { + guard let content = try? String(contentsOfFile: fullPath, encoding: .utf8) else { + continue + } + if !content.localizedCaseInsensitiveContains(searchText) { + continue + } + } + + results.append(item) + if results.count >= maxSearchResults { break } + } + + let truncated = results.count >= maxSearchResults + return ["matches": results, "count": results.count, "truncated": truncated, "base_path": basePath] + } + + // MARK: - Write Tool Implementations + + private func writeFile(filePath: String, content: String) -> [String: Any] { + let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath + + guard isPathAllowed(resolved) else { + Log.mcp.warning("Write denied: path outside allowed folders: \(resolved)") + return ["error": "Access denied: path is outside allowed folders"] + } + + // Create parent directories if needed + let parentDir = (resolved as NSString).deletingLastPathComponent + if !fm.fileExists(atPath: parentDir) { + do { + try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true) + } catch { + return ["error": "Cannot create parent directories: \(error.localizedDescription)"] + } + } + + do { + try content.write(toFile: resolved, atomically: true, encoding: .utf8) + } catch { + Log.mcp.error("Failed to write file \(resolved): \(error.localizedDescription)") + return ["error": "Cannot write file: \(error.localizedDescription)"] + } + + Log.mcp.info("Wrote \(content.utf8.count) bytes to \(resolved)") + return ["success": true, "path": resolved, "bytes_written": content.utf8.count] + } + + private func editFile(filePath: String, oldText: String, newText: String) -> [String: Any] { + let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath + + guard isPathAllowed(resolved) else { + Log.mcp.warning("Edit denied: path outside allowed folders: \(resolved)") + return ["error": "Access denied: path is outside allowed folders"] + } + + guard fm.fileExists(atPath: resolved) else { + return ["error": "File not found: \(filePath)"] + } + + guard let content = try? String(contentsOfFile: resolved, encoding: .utf8) else { + return ["error": "Cannot read file as UTF-8 text: \(filePath)"] + } + + // Count occurrences + let occurrences = content.components(separatedBy: oldText).count - 1 + if occurrences == 0 { + return ["error": "old_text not found in file"] + } + if occurrences > 1 { + return ["error": "old_text appears \(occurrences) times in the file — it must be unique (exactly 1 match). Provide more surrounding context to make it unique."] + } + + let newContent = content.replacingOccurrences(of: oldText, with: newText) + do { + try newContent.write(toFile: resolved, atomically: true, encoding: .utf8) + } catch { + return ["error": "Cannot write file: \(error.localizedDescription)"] + } + + return ["success": true, "path": resolved] + } + + private func deleteFile(filePath: String) -> [String: Any] { + let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath + + guard isPathAllowed(resolved) else { + Log.mcp.warning("Delete denied: path outside allowed folders: \(resolved)") + return ["error": "Access denied: path is outside allowed folders"] + } + + guard fm.fileExists(atPath: resolved) else { + return ["error": "File not found: \(filePath)"] + } + + do { + try fm.removeItem(atPath: resolved) + } catch { + Log.mcp.error("Failed to delete \(resolved): \(error.localizedDescription)") + return ["error": "Cannot delete file: \(error.localizedDescription)"] + } + + Log.mcp.info("Deleted \(resolved)") + return ["success": true, "path": resolved] + } + + private func createDirectory(dirPath: String) -> [String: Any] { + let resolved = ((dirPath as NSString).expandingTildeInPath as NSString).standardizingPath + + guard isPathAllowed(resolved) else { + return ["error": "Access denied: path is outside allowed folders"] + } + + do { + try fm.createDirectory(atPath: resolved, withIntermediateDirectories: true) + } catch { + return ["error": "Cannot create directory: \(error.localizedDescription)"] + } + + return ["success": true, "path": resolved] + } + + private func moveFile(source: String, destination: String) -> [String: Any] { + let resolvedSrc = ((source as NSString).expandingTildeInPath as NSString).standardizingPath + let resolvedDst = ((destination as NSString).expandingTildeInPath as NSString).standardizingPath + + guard isPathAllowed(resolvedSrc) else { + return ["error": "Access denied: source path is outside allowed folders"] + } + guard isPathAllowed(resolvedDst) else { + return ["error": "Access denied: destination path is outside allowed folders"] + } + guard fm.fileExists(atPath: resolvedSrc) else { + return ["error": "Source not found: \(source)"] + } + + // Create parent directory of destination if needed + let parentDir = (resolvedDst as NSString).deletingLastPathComponent + if !fm.fileExists(atPath: parentDir) { + do { + try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true) + } catch { + return ["error": "Cannot create destination directory: \(error.localizedDescription)"] + } + } + + do { + try fm.moveItem(atPath: resolvedSrc, toPath: resolvedDst) + } catch { + return ["error": "Cannot move file: \(error.localizedDescription)"] + } + + return ["success": true, "source": resolvedSrc, "destination": resolvedDst] + } + + private func copyFile(source: String, destination: String) -> [String: Any] { + let resolvedSrc = ((source as NSString).expandingTildeInPath as NSString).standardizingPath + let resolvedDst = ((destination as NSString).expandingTildeInPath as NSString).standardizingPath + + guard isPathAllowed(resolvedSrc) else { + return ["error": "Access denied: source path is outside allowed folders"] + } + guard isPathAllowed(resolvedDst) else { + return ["error": "Access denied: destination path is outside allowed folders"] + } + guard fm.fileExists(atPath: resolvedSrc) else { + return ["error": "Source not found: \(source)"] + } + + // Create parent directory of destination if needed + let parentDir = (resolvedDst as NSString).deletingLastPathComponent + if !fm.fileExists(atPath: parentDir) { + do { + try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true) + } catch { + return ["error": "Cannot create destination directory: \(error.localizedDescription)"] + } + } + + do { + try fm.copyItem(atPath: resolvedSrc, toPath: resolvedDst) + } catch { + return ["error": "Cannot copy file: \(error.localizedDescription)"] + } + + return ["success": true, "source": resolvedSrc, "destination": resolvedDst] + } + + // MARK: - Gitignore Support + + /// Reload gitignore rules for all allowed folders + func reloadGitignores() { + gitignoreRules.removeAll() + if settings.mcpRespectGitignore { + loadGitignores() + } + } + + private func loadGitignores() { + for folder in allowedFolders { + loadGitignoreForFolder(folder) + } + } + + private func loadGitignoreForFolder(_ folder: String) { + var parser = GitignoreParser(rootPath: folder) + parser.loadRules(fileManager: fm) + gitignoreRules[folder] = parser + } + + /// Check if an absolute path is gitignored by any loaded gitignore rules + func isGitignored(_ absolutePath: String) -> Bool { + guard settings.mcpRespectGitignore else { return false } + + for (folder, parser) in gitignoreRules { + if absolutePath.hasPrefix(folder) { + let relativePath = String(absolutePath.dropFirst(folder.count).drop(while: { $0 == "/" })) + if !relativePath.isEmpty && parser.isIgnored(relativePath) { + return true + } + } + } + return false + } +} + +// MARK: - GitignoreParser + +/// Parses .gitignore files and checks paths against the patterns. +/// Supports: wildcards (*), double wildcards (**), directory patterns (/), negation (!), comments (#). +struct GitignoreParser { + let rootPath: String + private var rules: [GitignoreRule] = [] + + struct GitignoreRule { + let pattern: String + let isNegation: Bool + let isDirectoryOnly: Bool + /// Regex compiled from the gitignore glob pattern + let regex: NSRegularExpression? + } + + init(rootPath: String) { + self.rootPath = rootPath + } + + /// Load .gitignore from the root path (non-recursive — only the root .gitignore) + mutating func loadRules(fileManager fm: FileManager) { + let gitignorePath = (rootPath as NSString).appendingPathComponent(".gitignore") + guard let content = try? String(contentsOfFile: gitignorePath, encoding: .utf8) else { + return + } + parseContent(content) + } + + mutating func parseContent(_ content: String) { + let lines = content.components(separatedBy: .newlines) + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + // Skip empty lines and comments + if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } + + var pattern = trimmed + let isNegation = pattern.hasPrefix("!") + if isNegation { + pattern = String(pattern.dropFirst()) + } + + // Remove trailing spaces (unless escaped) + while pattern.hasSuffix(" ") && !pattern.hasSuffix("\\ ") { + pattern = String(pattern.dropLast()) + } + + let isDirectoryOnly = pattern.hasSuffix("/") + if isDirectoryOnly { + pattern = String(pattern.dropLast()) + } + + // Remove leading slash (anchors to root, but we match relative paths) + if pattern.hasPrefix("/") { + pattern = String(pattern.dropFirst()) + } + + guard !pattern.isEmpty else { continue } + + let regex = gitignorePatternToRegex(pattern) + rules.append(GitignoreRule( + pattern: pattern, + isNegation: isNegation, + isDirectoryOnly: isDirectoryOnly, + regex: regex + )) + } + } + + /// Check if a relative path (from rootPath) is ignored + func isIgnored(_ relativePath: String) -> Bool { + var ignored = false + for rule in rules { + let matches: Bool + if let regex = rule.regex { + let range = NSRange(relativePath.startIndex..., in: relativePath) + matches = regex.firstMatch(in: relativePath, range: range) != nil + } else { + // Fallback: simple contains check for the pattern basename + let basename = (relativePath as NSString).lastPathComponent + matches = basename == rule.pattern + } + + if matches { + if rule.isNegation { + ignored = false + } else { + ignored = true + } + } + } + return ignored + } + + /// Convert a gitignore glob pattern to a regex + private func gitignorePatternToRegex(_ pattern: String) -> NSRegularExpression? { + var regex = "" + let chars = Array(pattern) + var i = 0 + + // If the pattern contains no slash, it matches against the filename only + let matchesPath = pattern.contains("/") + + if !matchesPath { + // Match against any path component — the pattern can appear as the last component + regex += "(?:^|/)" + } else { + regex += "^" + } + + while i < chars.count { + let c = chars[i] + switch c { + case "*": + if i + 1 < chars.count && chars[i + 1] == "*" { + // ** + if i + 2 < chars.count && chars[i + 2] == "/" { + // **/ — matches zero or more directories + regex += "(?:.+/)?" + i += 3 + continue + } else { + // ** at end — matches everything + regex += ".*" + i += 2 + continue + } + } else { + // Single * — matches anything except / + regex += "[^/]*" + } + case "?": + regex += "[^/]" + case ".": + regex += "\\." + case "[": + // Character class — pass through + regex += "[" + case "]": + regex += "]" + case "\\": + // Escape next character + if i + 1 < chars.count { + i += 1 + regex += NSRegularExpression.escapedPattern(for: String(chars[i])) + } + default: + regex += NSRegularExpression.escapedPattern(for: String(c)) + } + i += 1 + } + + // Allow matching as a prefix (directory) or exact match + regex += "(?:/.*)?$" + + return try? NSRegularExpression(pattern: regex, options: []) + } +} diff --git a/oAI/Services/SettingsService.swift b/oAI/Services/SettingsService.swift new file mode 100644 index 0000000..79e2bb9 --- /dev/null +++ b/oAI/Services/SettingsService.swift @@ -0,0 +1,408 @@ +// +// SettingsService.swift +// oAI +// +// Settings persistence: SQLite for preferences, Keychain for API keys +// + +import Foundation +import os +import Security + +@Observable +class SettingsService { + static let shared = SettingsService() + + // In-memory cache of DB settings for fast reads + private var cache: [String: String] = [:] + + // Keychain keys (secrets only) + private enum KeychainKeys { + static let openrouterAPIKey = "com.oai.apikey.openrouter" + static let anthropicAPIKey = "com.oai.apikey.anthropic" + static let openaiAPIKey = "com.oai.apikey.openai" + static let googleAPIKey = "com.oai.apikey.google" + static let googleSearchEngineID = "com.oai.google.searchEngineID" + } + + private init() { + // Load all settings from DB into cache + cache = (try? DatabaseService.shared.loadAllSettings()) ?? [:] + Log.settings.info("Settings initialized with \(self.cache.count) cached entries") + + // Migrate from UserDefaults on first launch + migrateFromUserDefaultsIfNeeded() + } + + // MARK: - Provider Settings + + var defaultProvider: Settings.Provider { + get { + if let raw = cache["defaultProvider"], + let provider = Settings.Provider(rawValue: raw) { + return provider + } + return .openrouter + } + set { + cache["defaultProvider"] = newValue.rawValue + DatabaseService.shared.setSetting(key: "defaultProvider", value: newValue.rawValue) + } + } + + var defaultModel: String? { + get { cache["defaultModel"] } + set { + if let value = newValue { + cache["defaultModel"] = value + DatabaseService.shared.setSetting(key: "defaultModel", value: value) + } else { + cache.removeValue(forKey: "defaultModel") + DatabaseService.shared.deleteSetting(key: "defaultModel") + } + } + } + + // MARK: - Model Settings + + var streamEnabled: Bool { + get { cache["streamEnabled"].map { $0 == "true" } ?? true } + set { + cache["streamEnabled"] = String(newValue) + DatabaseService.shared.setSetting(key: "streamEnabled", value: String(newValue)) + } + } + + var maxTokens: Int { + get { cache["maxTokens"].flatMap(Int.init) ?? 0 } + set { + cache["maxTokens"] = String(newValue) + DatabaseService.shared.setSetting(key: "maxTokens", value: String(newValue)) + } + } + + var temperature: Double { + get { cache["temperature"].flatMap(Double.init) ?? 0.0 } + set { + cache["temperature"] = String(newValue) + DatabaseService.shared.setSetting(key: "temperature", value: String(newValue)) + } + } + + // MARK: - Feature Settings + + var onlineMode: Bool { + get { cache["onlineMode"] == "true" } + set { + cache["onlineMode"] = String(newValue) + DatabaseService.shared.setSetting(key: "onlineMode", value: String(newValue)) + } + } + + var memoryEnabled: Bool { + get { cache["memoryEnabled"].map { $0 == "true" } ?? true } + set { + cache["memoryEnabled"] = String(newValue) + DatabaseService.shared.setSetting(key: "memoryEnabled", value: String(newValue)) + } + } + + var mcpEnabled: Bool { + get { cache["mcpEnabled"] == "true" } + set { + cache["mcpEnabled"] = String(newValue) + DatabaseService.shared.setSetting(key: "mcpEnabled", value: String(newValue)) + } + } + + // MARK: - Text Size Settings + + /// GUI text size (headers, labels, buttons) — default 13 + var guiTextSize: Double { + get { cache["guiTextSize"].flatMap(Double.init) ?? 13.0 } + set { + cache["guiTextSize"] = String(newValue) + DatabaseService.shared.setSetting(key: "guiTextSize", value: String(newValue)) + } + } + + /// Dialog/chat message text size — default 14 + var dialogTextSize: Double { + get { cache["dialogTextSize"].flatMap(Double.init) ?? 14.0 } + set { + cache["dialogTextSize"] = String(newValue) + DatabaseService.shared.setSetting(key: "dialogTextSize", value: String(newValue)) + } + } + + /// Input box text size — default 14 + var inputTextSize: Double { + get { cache["inputTextSize"].flatMap(Double.init) ?? 14.0 } + set { + cache["inputTextSize"] = String(newValue) + DatabaseService.shared.setSetting(key: "inputTextSize", value: String(newValue)) + } + } + + // MARK: - MCP Permissions + + var mcpCanWriteFiles: Bool { + get { cache["mcpCanWriteFiles"] == "true" } + set { + cache["mcpCanWriteFiles"] = String(newValue) + DatabaseService.shared.setSetting(key: "mcpCanWriteFiles", value: String(newValue)) + } + } + + var mcpCanDeleteFiles: Bool { + get { cache["mcpCanDeleteFiles"] == "true" } + set { + cache["mcpCanDeleteFiles"] = String(newValue) + DatabaseService.shared.setSetting(key: "mcpCanDeleteFiles", value: String(newValue)) + } + } + + var mcpCanCreateDirectories: Bool { + get { cache["mcpCanCreateDirectories"] == "true" } + set { + cache["mcpCanCreateDirectories"] = String(newValue) + DatabaseService.shared.setSetting(key: "mcpCanCreateDirectories", value: String(newValue)) + } + } + + var mcpCanMoveFiles: Bool { + get { cache["mcpCanMoveFiles"] == "true" } + set { + cache["mcpCanMoveFiles"] = String(newValue) + DatabaseService.shared.setSetting(key: "mcpCanMoveFiles", value: String(newValue)) + } + } + + var mcpRespectGitignore: Bool { + get { cache["mcpRespectGitignore"].map { $0 == "true" } ?? true } + set { + cache["mcpRespectGitignore"] = String(newValue) + DatabaseService.shared.setSetting(key: "mcpRespectGitignore", value: String(newValue)) + } + } + + // MARK: - MCP Allowed Folders + + var mcpAllowedFolders: [String] { + get { + guard let json = cache["mcpAllowedFolders"], + let data = json.data(using: .utf8), + let folders = try? JSONDecoder().decode([String].self, from: data) else { + return [] + } + return folders + } + set { + if let data = try? JSONEncoder().encode(newValue), + let json = String(data: data, encoding: .utf8) { + cache["mcpAllowedFolders"] = json + DatabaseService.shared.setSetting(key: "mcpAllowedFolders", value: json) + } + } + } + + // MARK: - Search Settings + + var searchProvider: Settings.SearchProvider { + get { + if let raw = cache["searchProvider"], + let provider = Settings.SearchProvider(rawValue: raw) { + return provider + } + return .duckduckgo + } + set { + cache["searchProvider"] = newValue.rawValue + DatabaseService.shared.setSetting(key: "searchProvider", value: newValue.rawValue) + } + } + + // MARK: - Ollama Settings + + var ollamaBaseURL: String { + get { cache["ollamaBaseURL"] ?? "" } + set { + let trimmed = newValue.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + cache.removeValue(forKey: "ollamaBaseURL") + DatabaseService.shared.deleteSetting(key: "ollamaBaseURL") + } else { + cache["ollamaBaseURL"] = trimmed + DatabaseService.shared.setSetting(key: "ollamaBaseURL", value: trimmed) + } + } + } + + /// Resolved Ollama URL — returns the user value or the default + var ollamaEffectiveURL: String { + let url = ollamaBaseURL + return url.isEmpty ? "http://localhost:11434" : url + } + + /// Whether the user has explicitly configured an Ollama URL + var ollamaConfigured: Bool { + cache["ollamaBaseURL"] != nil && !(cache["ollamaBaseURL"]!.isEmpty) + } + + // MARK: - API Keys (Keychain) + + var openrouterAPIKey: String? { + get { getKeychainValue(for: KeychainKeys.openrouterAPIKey) } + set { + if let value = newValue { + setKeychainValue(value, for: KeychainKeys.openrouterAPIKey) + } else { + deleteKeychainValue(for: KeychainKeys.openrouterAPIKey) + } + } + } + + var anthropicAPIKey: String? { + get { getKeychainValue(for: KeychainKeys.anthropicAPIKey) } + set { + if let value = newValue { + setKeychainValue(value, for: KeychainKeys.anthropicAPIKey) + } else { + deleteKeychainValue(for: KeychainKeys.anthropicAPIKey) + } + } + } + + var openaiAPIKey: String? { + get { getKeychainValue(for: KeychainKeys.openaiAPIKey) } + set { + if let value = newValue { + setKeychainValue(value, for: KeychainKeys.openaiAPIKey) + } else { + deleteKeychainValue(for: KeychainKeys.openaiAPIKey) + } + } + } + + var googleAPIKey: String? { + get { getKeychainValue(for: KeychainKeys.googleAPIKey) } + set { + if let value = newValue { + setKeychainValue(value, for: KeychainKeys.googleAPIKey) + } else { + deleteKeychainValue(for: KeychainKeys.googleAPIKey) + } + } + } + + var googleSearchEngineID: String? { + get { getKeychainValue(for: KeychainKeys.googleSearchEngineID) } + set { + if let value = newValue { + setKeychainValue(value, for: KeychainKeys.googleSearchEngineID) + } else { + deleteKeychainValue(for: KeychainKeys.googleSearchEngineID) + } + } + } + + // MARK: - UserDefaults Migration + + private func migrateFromUserDefaultsIfNeeded() { + // Skip if already migrated + guard cache["_migrated"] == nil else { return } + + let defaults = UserDefaults.standard + let migrations: [(udKey: String, dbKey: String)] = [ + ("defaultProvider", "defaultProvider"), + ("defaultModel", "defaultModel"), + ("streamEnabled", "streamEnabled"), + ("maxTokens", "maxTokens"), + ("temperature", "temperature"), + ("onlineMode", "onlineMode"), + ("memoryEnabled", "memoryEnabled"), + ("mcpEnabled", "mcpEnabled"), + ("searchProvider", "searchProvider"), + ("ollamaBaseURL", "ollamaBaseURL"), + ] + + for (udKey, dbKey) in migrations { + guard cache[dbKey] == nil else { continue } + + if let stringVal = defaults.string(forKey: udKey) { + cache[dbKey] = stringVal + DatabaseService.shared.setSetting(key: dbKey, value: stringVal) + } else if defaults.object(forKey: udKey) != nil { + // Handle bool/int/double stored as non-string + let value: String + if let boolVal = defaults.object(forKey: udKey) as? Bool { + value = String(boolVal) + } else if defaults.integer(forKey: udKey) != 0 { + value = String(defaults.integer(forKey: udKey)) + } else if defaults.double(forKey: udKey) != 0.0 { + value = String(defaults.double(forKey: udKey)) + } else { + continue + } + cache[dbKey] = value + DatabaseService.shared.setSetting(key: dbKey, value: value) + } + } + + // Mark migration complete + cache["_migrated"] = "true" + DatabaseService.shared.setSetting(key: "_migrated", value: "true") + } + + // MARK: - Keychain Helpers + + private func getKeychainValue(for key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var dataTypeRef: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) + + guard status == errSecSuccess, + let data = dataTypeRef as? Data, + let value = String(data: data, encoding: .utf8) else { + return nil + } + + return value + } + + private func setKeychainValue(_ value: String, for key: String) { + guard let data = value.data(using: .utf8) else { return } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key + ] + + let attributes: [String: Any] = [ + kSecValueData as String: data + ] + + let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + + if updateStatus == errSecItemNotFound { + var newItem = query + newItem[kSecValueData as String] = data + SecItemAdd(newItem as CFDictionary, nil) + } + } + + private func deleteKeychainValue(for key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key + ] + + SecItemDelete(query as CFDictionary) + } +} diff --git a/oAI/Services/WebSearchService.swift b/oAI/Services/WebSearchService.swift new file mode 100644 index 0000000..450ac0a --- /dev/null +++ b/oAI/Services/WebSearchService.swift @@ -0,0 +1,143 @@ +// +// WebSearchService.swift +// oAI +// +// DuckDuckGo web search for non-OpenRouter providers +// + +import Foundation +import os + +struct SearchResult: Sendable { + let title: String + let url: String + let snippet: String +} + +final class WebSearchService: Sendable { + nonisolated static let shared = WebSearchService() + + private let session: URLSession + + nonisolated private init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 10 + session = URLSession(configuration: config) + } + + /// Search DuckDuckGo HTML interface (no API key needed) + nonisolated func search(query: String, maxResults: Int = 5) async -> [SearchResult] { + Log.search.info("Web search: \(query)") + guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://html.duckduckgo.com/html/?q=\(encoded)") + else { return [] } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + forHTTPHeaderField: "User-Agent" + ) + + do { + let (data, _) = try await session.data(for: request) + guard let html = String(data: data, encoding: .utf8) else { return [] } + return parseResults(from: html, maxResults: maxResults) + } catch { + Log.search.error("Web search failed: \(error.localizedDescription)") + return [] + } + } + + /// Format search results as markdown for prompt injection + nonisolated func formatResults(_ results: [SearchResult], maxLength: Int = 2000) -> String { + if results.isEmpty { return "No search results found." } + + var formatted = "**Web Search Results:**\n\n" + + for (i, result) in results.enumerated() { + var entry = "\(i + 1). **\(result.title)**\n" + entry += " URL: \(result.url)\n" + if !result.snippet.isEmpty { + entry += " \(result.snippet)\n" + } + entry += "\n" + + if formatted.count + entry.count > maxLength { + formatted += "... (\(results.count - i) more results truncated)\n" + break + } + + formatted += entry + } + + return formatted.trimmingCharacters(in: .whitespacesAndNewlines) + } + + // MARK: - HTML Parsing + + private nonisolated func parseResults(from html: String, maxResults: Int) -> [SearchResult] { + var results: [SearchResult] = [] + + // Match result blocks: