31 Commits
Author SHA1 Message Date
runeandClaude Sonnet 5 69dbf17f5e Add PDF text extraction to MCP read_file and search_files
MCP's read_file previously hard-required UTF-8 text decoding, so any
PDF in an allowed folder failed with "Cannot read file as UTF-8
text" — chat attachments already supported PDFs (raw bytes to
vision-capable models), but the AI couldn't read one on its own
during agentic file-tool use. search_files' content_search had the
same gap, silently skipping PDFs.

Adds MCPService.extractPDFText(atPath:) using PDFKit (built into
macOS, no new dependency) to pull text from a PDF's text layer.
Wired into both read_file and search_files' content search. Returns
a clear error for scanned/image-only PDFs with no text layer.
Automatically covers the Research Agents sub-agent tool loop too,
since it shares the same executeTool dispatcher.

Verified live: asked the AI to read a real PDF containing "The
secret code is PINEAPPLE-42." via read_file — it extracted the text
correctly through the MCP tool-call path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 11:44:27 +02:00
runeandClaude Sonnet 5 377e783a17 Mark Apple Intelligence provider as Beta throughout the UI
Apple's Foundation Models framework is still under active development
(built against macOS 27 beta) and likely to stay rough for a while —
small 4K context window, occasional generation errors, no tool
support yet. Surface that clearly wherever it appears:
- Model name: "Apple On-Device (Beta)" (shows in header/model picker)
- Model description: notes the beta status and what to expect
- Settings -> General: "⚠️ Beta — ..." disclaimer under the Apple
  Intelligence section, same style as the existing Paperless-NGX beta
  note
- Credits panel: same disclaimer for the Apple Intelligence entry

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:21:41 +02:00
runeandClaude Sonnet 5 30d0500323 Skip tool guidance, custom prompt, and Agent Skills for tool-incapable models
effectiveSystemPrompt now gates tool-usage guidelines, the user's
custom system prompt, and active Agent Skills behind whether the
selected model actually supports tools (ModelInfo.capabilities.tools).
All three assume tool/file/web access and can easily blow past small
context windows.

Confirmed live against Apple Intelligence: the default system prompt
dropped from 16,377 to ~250 tokens (well under the 4K on-device
limit), fully resolving the context-exceeded error from the initial
Phase 1 rollout. Verified end-to-end with a real successful generation
in the app after the fix.

Also: .gitignore now excludes *.profraw (stray code-coverage artifact
picked up while testing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:12:39 +02:00
runeandClaude Sonnet 5 30fbb4162e Revive Apple Intelligence provider (Phase 1 — on-device)
Apple Intelligence / Foundation Models is now genuinely available on
this machine (macOS 27 beta 4) — it was reverted back in June
(f63226b) when it wasn't. Ports the reverted AppleFoundationProvider
forward onto 2.4.3, adapted for everything that's changed since:
PolyForm license headers, current AIProvider protocol shape, current
Settings.Provider/ProviderRegistry/CreditsView/SettingsView structure.

Fixes a real bug found via live testing: LanguageModelSession.
GenerationError was deprecated in macOS 27.0 in favor of a new
LanguageModelError type. On a macOS 27+ runtime, generation failures
now throw LanguageModelError, not GenerationError, so the original
error-mapping catch never matched and Apple's raw error text leaked
to the user instead of oAI's friendly message. Now dispatches to
whichever type the runtime actually throws, gated with
@available(macOS 27.0, *), keeping the old GenerationError path as
a fallback for macOS 26.x (the app's actual deployment target).

Confirmed end-to-end in the live app: provider selectable, Settings
shows a live "Available" badge, chat header shows correct branding,
and — a real, expected Phase 1 limitation — oAI's default system
prompt (active Agent Skills + MCP tool guidance, ~16K tokens on this
machine) exceeds the on-device model's 4K context window on the
very first message. The friendly error message now correctly reports
this instead of Apple's raw string. Tool calling remains out of scope
until Phase 3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 09:46:17 +02:00
rune f3c6271c9e Phase 4 tests: DatabaseService migrations + ContextSelectionService DB paths
17 tests against throwaway in-memory DatabaseService instances: all
v1-v8 tables/columns exist post-migration, settings CRUD, conversation
save/load round-trip, instance isolation between separate in-memory
queues, and ContextSelectionService's DB-coupled paths (starring,
excluded-range summaries, smartSelection end to end) that were
previously untestable.
2026-07-24 09:16:46 +02:00
rune c6a7439648 Phase 4 seam: in-memory DatabaseService + DB injection for ContextSelectionService
DatabaseService gains a testable init(dbQueue:) plus a makeInMemory()
convenience and schema-introspection helpers (tableExists/columnNames),
so migration tests don't need the test target to import GRDB directly.
ContextSelectionService now takes an injected DatabaseService (defaults
to .shared) and its DB-coupled methods (smartSelection,
getSummariesForExcludedRange, isMessageStarred) are bumped to internal.
No logic changes to existing call sites.
2026-07-24 09:16:42 +02:00
rune 02bf73fec6 Actually commit the oAITests target wiring in project.pbxproj
The oAITests PBXNativeTarget (PBXContainerItemProxy, PBXTargetDependency,
XCBuildConfiguration with TEST_HOST/BUNDLE_LOADER, the "recommended
settings" changes accepted when the target was created -- DEAD_CODE_STRIPPING,
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED, STRING_CATALOG_GENERATE_SYMBOLS,
DEVELOPMENT_TEAM moved to project-level inheritance) was somehow never
actually staged in the very first "Add real oAITests target" commit
(8c7fb59) despite every xcodebuild test run since then depending on it
being present on disk. Every subsequent commit this session only staged
specific file paths (never oAI.xcodeproj again), so the gap went
unnoticed until a full `git status` review here.

Without this, anyone else pulling the branch (or a truly clean checkout
on this machine) would have all the .swift test files but no target to
compile them into -- xcodebuild test would fail to find oAITests at all.
Confirmed the diff is exactly the expected target-wiring content, nothing
unrelated or corrupted, before committing.
2026-07-23 14:19:53 +02:00
rune a053d4a983 Phase 2: tests for the provider layer and other exposed pure helpers
66 new tests across 8 files (93 total in the suite now):

- GitSyncServiceTests: convertToSSH/injectCredentials/sanitizeFilename/
  detectSecretsInText, including a below-threshold false-positive check
  on the secret regex.
- ChatViewModelPureLogicTests: detectGoodbyePhrase, inferProvider,
  calculateCost -- including the cache-read (0.1x) / cache-write (1.25x)
  pricing multipliers, which is real billing-affecting logic.
- EmbeddingServiceTests / ContextSelectionServiceTests: embedding
  (de)serialization round-trip, importance-score weighting, and the
  token-estimate fallback (content.count / 4) when no real count exists.
- OpenRouterProviderTests / OllamaProviderTests / OpenAIProviderTests /
  AnthropicProviderTests: request-building (attachments, online mode,
  cache_control breakpoints, o1/o3 temperature omission, tool schema
  conversion) and response-parsing (text, tool_use blocks, empty-choices
  fallback behavior) for all four providers, with no network involved.

Also marks calculateCost/inferProvider/detectGoodbyePhrase (ChatViewModel)
and serializeEmbedding/deserializeEmbedding (EmbeddingService) as
`nonisolated` -- discovered via the actual test failures, not
speculation: the project's `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`
setting isolates the classes that carry an explicit `@MainActor`
(ChatViewModel), so calling their static members from a plain
synchronous @Test needs the pure ones marked `nonisolated`. Matches
the existing convention already used elsewhere in EmbeddingService
(cosineSimilarity was already nonisolated before this change).

Phase 2 of the test-suite rollout plan (peaceful-baking-kurzweil).
2026-07-23 14:11:50 +02:00
rune f39527b1d8 Phase 2 seam: expose pure helpers for testing (no logic changes)
Every change here is either dropping `private` (still invisible
outside the module, @testable import just needs internal-or-wider)
or converting a self-independent instance method to `static func`
(ChatViewModel.inferProvider/calculateCost/detectGoodbyePhrase --
none of the three ever touched `self`, and constructing a real
ChatViewModel triggers a real network call in init, so static-ifying
them sidesteps that entirely rather than fighting it). Call sites
updated to `Self.foo(...)` where the static conversion required it.

Touches: GitSyncService's URL/secret-scanning helpers,
EmbeddingService's embedding (de)serialization, ContextSelectionService's
importance scoring, and the request-building/response-parsing helpers
on all four providers (OpenRouter, Ollama, OpenAI, Anthropic) -- the
core AI request/response layer, previously 100% untested.

Verified: full existing test suite (27 tests) still green, no
regressions. Tests for these functions land in the next commit.
Phase 2 of the test-suite rollout plan (peaceful-baking-kurzweil).
2026-07-23 13:48:41 +02:00
rune 1f4a2caf67 Phase 1: pure-logic tests requiring zero production code changes
27 tests across 4 files, all targeting code that was already testable
the moment the target existed -- no visibility bumps, no refactors:

- GitignoreParserTests: MCPService.GitignoreParser's glob-to-regex
  matching (wildcards, **, directory anchors, negation, comments).
- OpenRouterModelsTests: the string-vs-content-block-array decoders
  on both the request side (APIMessage.MessageContent/ContentItem)
  and response side (Choice.MessageContent, StreamChoice.Delta),
  including image extraction from content blocks.
- AIProviderTests: ChatResponse/Usage decoding, with an explicit
  regression guard that Usage.rawCostUSD always decodes to nil (it's
  only ever set programmatically, never from API JSON).
- MessageCodableTests: confirms Message's transient fields
  (isStreaming, isStarred, generatedImages, toolCalls,
  thinkingContent) don't survive an encode/decode round-trip, and
  documents that its custom == deliberately ignores role/timestamp/
  attachments/modelId -- a real but non-obvious behavior worth
  locking in with a test.

Removed the placeholder oAITests.swift example test now that there's
real coverage. Phase 1 of the test-suite rollout plan
(peaceful-baking-kurzweil).
2026-07-23 13:37:49 +02:00
rune 8c7fb59d41 Add real oAITests target; remove disconnected SPM test scaffold
The old Tests/oAITests/oAITests.swift + root Package.swift/Sources/
were a swift package init stub that @testable imported a fake empty
oAI module, completely disconnected from the real ~31K-line app
(built only via oAI.xcodeproj). Running `swift test` there would
have silently "passed" while testing nothing.

oAITests is a proper Unit Testing Bundle target added via Xcode's
target editor, wired into the app's scheme (TestAction references
oAITests.xctest). Verified with `xcodebuild test` before and after
removing the dead scaffold.

First phase of the test-suite rollout plan (peaceful-baking-kurzweil).
2026-07-23 13:30:45 +02:00
rune 4fe3bde7e9 Merge 2.4.2 into 2.4.3 (bring in favorites sync, Contacts entitlement fix, Help search, DMG notarization, etc.) 2026-07-22 16:07:20 +02:00
rune 854fd02fad Add live search to the Help Book's table of contents
A search box above the Contents list filters TOC entries by each
linked section's full text content (not just the link title), so
e.g. searching "european" surfaces "Slash Commands" via its command
history date-format note. Pure vanilla JS/CSS, no dependencies --
the page is a static file opened directly in the default browser.

Tested interactively in Safari Technology Preview: filtering,
no-results state, reset, and click-through navigation all verified
working, in both the matched and empty-query cases.
2026-07-22 15:21:30 +02:00
rune e9aceca4e7 Remove now-dead isContactsHiddenPendingAppleFix kill switch
Contacts was never actually broken by an Apple/OS bug -- it was a
wrong entitlement key in oAI.entitlements, now fixed (abf25bd). No
functional change here: the flag was already false in both places
this session, so behavior is identical; this just removes the
now-pointless beta-badge/conditional-row scaffolding built around it.
2026-07-21 15:01:19 +02:00
rune abf25bd897 Fix Contacts TCC entitlement key: contacts -> addressbook
Confirmed root cause via tccd's own log output: the hardened-runtime
hardened-runtime prompting policy checks for
com.apple.security.personal-information.addressbook (the Contacts
framework's TCC service is still internally named kTCCServiceAddressBook,
a holdover from the old AddressBook framework), not
com.apple.security.personal-information.contacts as the entitlements
file had. With the correct key, tccd allows the prompt and access is
now granted correctly on both macOS 27 beta and 26.5.1 stable — this
was never an OS bug, notarization requirement, or beta-only issue.

Also keeps the fuller CNError domain/code/userInfo logging added while
diagnosing this, in case Contacts TCC issues resurface.
2026-07-21 14:59:39 +02:00
rune 77cc646ee0 Re-enable Contacts row to retest on macOS 27 beta 4
isContactsHiddenPendingAppleFix was flipped true on beta 2 after
CNContactStore.requestAccess returned instant "Access Denied" under
hardened runtime, while Calendar/Reminders/Location worked fine.
Rune just installed beta 4 and wants to retest.
2026-07-21 14:16:03 +02:00
rune 57f407ea50 Bump version to 2.4.2 2026-07-21 14:07:22 +02:00
rune 5db1b40552 Union favorites on tied timestamps instead of dropping one side
Two machines that already had favorites before this sync feature
shipped will both have an empty favoriteModelsUpdatedAt, so the
first sync between them ties. Previously that meant the second
machine silently kept only its own set; now tied timestamps merge
via union and re-push, so no pre-existing favorites are lost.
2026-07-21 14:06:05 +02:00
rune fc786d48f8 Sync starred models across machines; add automatic backup scheduling
Favorites now push/pull through a small oai_favorites.json file in the
same iCloud Drive folder used by Settings > Backup, reconciled by
last-write-wins timestamp on launch and app-become-active. Also adds
an Off/Daily/Weekly frequency picker so the full settings backup can
run itself (checked at launch and hourly) instead of requiring a
manual "Back Up Now" click every time.
2026-07-21 13:19:50 +02:00
rune 86027001c7 Roll back native Help Viewer integration; fix duplicate View menu
Per Rune: the NSHelpManager-based Cmd+? fix from the last few commits
technically worked (registration was correct) but opened Apple's
broken generic Tips landing page instead of oAI's own content on the
macOS 27 beta this is built against — worse than the original
browser-tab behavior. Revisit at macOS 27 RC1 (see CLAUDE.md).

- openHelp() reverts to NSWorkspace.open() on index.html directly.
- Removed the "In-App Help" Ctrl+Cmd+H menu item entirely — HelpView's
  panel (search already fully working) is reachable only via /help
  from the input field now, by design.
- Renamed the custom CommandMenu("View") to CommandMenu("Chat") — it
  was colliding with the "View" menu macOS auto-adds for
  NavigationSplitView (Enter Full Screen, etc.), producing two
  identically-titled top-level menus in the menu bar.
2026-07-20 08:17:33 +02:00
rune 98979584fb Fix dead Cmd+/ shortcut for In-App Help; rebind to Ctrl+Cmd+H
Cmd+/ was never reaching the "In-App Help" menu item — AppKit
auto-reserves Cmd+/ to open/focus the app's own Help menu (same
mechanism as Cmd+?), silently pre-empting any custom binding on that
combo, exactly like the earlier Cmd+H (Hide Application) conflict.

Verified live with computer-use before landing on Ctrl+Cmd+H:
Option+Cmd+/ has unpredictable menu-glyph rendering and didn't fire;
Option+Cmd+H is macOS's reserved "Hide Others" shortcut (visibly hid
other app windows when tested). Ctrl+Cmd+H showed no OS-level effect
and correctly opens the panel.

HelpView's search bar was already fully implemented and working —
this was purely a matter of the shortcut never reaching it.
2026-07-20 08:00:25 +02:00
rune 333e288418 Fix Help Book keys missing from the built Info.plist
GENERATE_INFOPLIST_FILE = YES with no INFOPLIST_FILE meant Xcode
synthesized Info.plist purely from INFOPLIST_KEY_* build settings and
silently ignored oAI/Info.plist on disk — so CFBundleHelpBookName/
CFBundleHelpBookFolder never made it into the built app. NSHelpManager
therefore couldn't resolve the book and macOS showed the generic Help
Center instead of oAI's help content (previous commit 74c8be8 fixed
openHelp() to call NSHelpManager, but that call had nothing to find).

Fix: point INFOPLIST_FILE at oAI/Info.plist so Xcode merges its extra
keys into the generated Info.plist, and add a synchronized-group
membership exception so Info.plist isn't also copied into Resources
(would otherwise produce a duplicate-file build warning).
2026-07-20 07:33:51 +02:00
rune 74c8be8f94 Fix Cmd+? opening Help Book in the default browser instead of Help Viewer
openHelp() always found the bundled oAI.help folder, so it always took
the NSWorkspace.open(index.html) branch — handing the page to the
default web browser. The NSHelpManager branch, which launches the
native Help Viewer (with its built-in full-text search), was
unreachable dead code. Now always routes through NSHelpManager.
2026-07-20 07:24:20 +02:00
rune 75e5b40f45 Persist sidebar visibility; add language-matching rule to default prompt
- ContentView now reads/writes SettingsService.sidebarVisible so the
  NavigationSplitView sidebar's shown/hidden state survives relaunch,
  matching the existing window size/position persistence.
- Default system prompt gains a rule: always reply in the user's
  language, even for requests (e.g. translation) targeting another one.
2026-07-19 16:01:04 +02:00
runeandClaude Sonnet 5 6d7f11d705 Clarify that only the latest release is supported for security fixes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 09:07:39 +02:00
runeandClaude Sonnet 5 d38b5442ed Clarify that only the latest release is supported for security fixes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 09:07:37 +02:00
runeandClaude Sonnet 5 e3fae25198 Add SECURITY.md with vulnerability reporting policy
Points reporters to the contact form at oai.pm instead of public issues.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 09:05:25 +02:00
runeandClaude Sonnet 5 b9c3c97dc0 Add SECURITY.md with vulnerability reporting policy
Points reporters to the contact form at oai.pm instead of public issues.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 09:05:18 +02:00
runeandClaude Sonnet 5 bd686873c4 Update commercial licensing contact URL to oai.pm
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.

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