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