From e3fae25198367756b982b53646800d9289ba35c4 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Thu, 16 Jul 2026 09:05:18 +0200 Subject: [PATCH 01/13] 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 --- SECURITY.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d54b0d3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security Policy + +## 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.54.0 From 6d7f11d7057cad37ab15e515c8f0f8782ea4e16c Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Thu, 16 Jul 2026 09:07:37 +0200 Subject: [PATCH 02/13] Clarify that only the latest release is supported for security fixes Co-Authored-By: Claude Sonnet 5 --- SECURITY.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/SECURITY.md b/SECURITY.md index d54b0d3..4b469d4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,9 @@ # 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. -- 2.54.0 From 8c7fb59d4192814a2565217e1d580a81bd440eb5 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Thu, 23 Jul 2026 13:30:45 +0200 Subject: [PATCH 03/13] 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). --- Package.swift | 26 -------------------------- Sources/oAI/oAI.swift | 2 -- Tests/oAITests/oAITests.swift | 6 ------ oAITests/oAITests.swift | 18 ++++++++++++++++++ 4 files changed, 18 insertions(+), 34 deletions(-) delete mode 100644 Package.swift delete mode 100644 Sources/oAI/oAI.swift delete mode 100644 Tests/oAITests/oAITests.swift create mode 100644 oAITests/oAITests.swift diff --git a/Package.swift b/Package.swift deleted file mode 100644 index 977bb97..0000000 --- a/Package.swift +++ /dev/null @@ -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"] - ), - ] -) diff --git a/Sources/oAI/oAI.swift b/Sources/oAI/oAI.swift deleted file mode 100644 index 08b22b8..0000000 --- a/Sources/oAI/oAI.swift +++ /dev/null @@ -1,2 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book diff --git a/Tests/oAITests/oAITests.swift b/Tests/oAITests/oAITests.swift deleted file mode 100644 index f3c6191..0000000 --- a/Tests/oAITests/oAITests.swift +++ /dev/null @@ -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. -} diff --git a/oAITests/oAITests.swift b/oAITests/oAITests.swift new file mode 100644 index 0000000..c99a30f --- /dev/null +++ b/oAITests/oAITests.swift @@ -0,0 +1,18 @@ +// +// oAITests.swift +// oAITests +// +// Created by Rune Olsen on 23/07/2026. +// + +import Testing + +struct oAITests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + // Swift Testing Documentation + // https://developer.apple.com/documentation/testing + } + +} -- 2.54.0 From 1f4a2caf670aac16ce0ca9637228438b1037e68e Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Thu, 23 Jul 2026 13:37:49 +0200 Subject: [PATCH 04/13] 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). --- oAITests/AIProviderTests.swift | 81 ++++++++++++++++ oAITests/GitignoreParserTests.swift | 83 +++++++++++++++++ oAITests/MessageCodableTests.swift | 93 +++++++++++++++++++ oAITests/OpenRouterModelsTests.swift | 132 +++++++++++++++++++++++++++ oAITests/oAITests.swift | 18 ---- 5 files changed, 389 insertions(+), 18 deletions(-) create mode 100644 oAITests/AIProviderTests.swift create mode 100644 oAITests/GitignoreParserTests.swift create mode 100644 oAITests/MessageCodableTests.swift create mode 100644 oAITests/OpenRouterModelsTests.swift delete mode 100644 oAITests/oAITests.swift diff --git a/oAITests/AIProviderTests.swift b/oAITests/AIProviderTests.swift new file mode 100644 index 0000000..65e391e --- /dev/null +++ b/oAITests/AIProviderTests.swift @@ -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) + } +} diff --git a/oAITests/GitignoreParserTests.swift b/oAITests/GitignoreParserTests.swift new file mode 100644 index 0000000..ac77942 --- /dev/null +++ b/oAITests/GitignoreParserTests.swift @@ -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")) + } +} diff --git a/oAITests/MessageCodableTests.swift b/oAITests/MessageCodableTests.swift new file mode 100644 index 0000000..5bfb14b --- /dev/null +++ b/oAITests/MessageCodableTests.swift @@ -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") + } +} diff --git a/oAITests/OpenRouterModelsTests.swift b/oAITests/OpenRouterModelsTests.swift new file mode 100644 index 0000000..460d7df --- /dev/null +++ b/oAITests/OpenRouterModelsTests.swift @@ -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") + } +} diff --git a/oAITests/oAITests.swift b/oAITests/oAITests.swift deleted file mode 100644 index c99a30f..0000000 --- a/oAITests/oAITests.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// oAITests.swift -// oAITests -// -// Created by Rune Olsen on 23/07/2026. -// - -import Testing - -struct oAITests { - - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - // Swift Testing Documentation - // https://developer.apple.com/documentation/testing - } - -} -- 2.54.0 From f39527b1d88140b78ae2bbfe702b59a3b4b20902 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Thu, 23 Jul 2026 13:48:41 +0200 Subject: [PATCH 05/13] 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). --- oAI/Providers/AnthropicProvider.swift | 6 +++--- oAI/Providers/OllamaProvider.swift | 4 ++-- oAI/Providers/OpenAIProvider.swift | 6 +++--- oAI/Providers/OpenRouterProvider.swift | 8 ++++---- oAI/Services/ContextSelectionService.swift | 4 ++-- oAI/Services/EmbeddingService.swift | 4 ++-- oAI/Services/GitSyncService.swift | 8 ++++---- oAI/ViewModels/ChatViewModel.swift | 24 +++++++++++----------- 8 files changed, 32 insertions(+), 32 deletions(-) diff --git a/oAI/Providers/AnthropicProvider.swift b/oAI/Providers/AnthropicProvider.swift index 1f9ea94..dcebe80 100644 --- a/oAI/Providers/AnthropicProvider.swift +++ b/oAI/Providers/AnthropicProvider.swift @@ -571,7 +571,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 +685,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 +741,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] = [ diff --git a/oAI/Providers/OllamaProvider.swift b/oAI/Providers/OllamaProvider.swift index 2a5bd19..8ebcab3 100644 --- a/oAI/Providers/OllamaProvider.swift +++ b/oAI/Providers/OllamaProvider.swift @@ -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 diff --git a/oAI/Providers/OpenAIProvider.swift b/oAI/Providers/OpenAIProvider.swift index 037ef60..c99d647 100644 --- a/oAI/Providers/OpenAIProvider.swift +++ b/oAI/Providers/OpenAIProvider.swift @@ -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()) } diff --git a/oAI/Providers/OpenRouterProvider.swift b/oAI/Providers/OpenRouterProvider.swift index 2e63b87..309e6a9 100644 --- a/oAI/Providers/OpenRouterProvider.swift +++ b/oAI/Providers/OpenRouterProvider.swift @@ -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 diff --git a/oAI/Services/ContextSelectionService.swift b/oAI/Services/ContextSelectionService.swift index c77d5ae..310f735 100644 --- a/oAI/Services/ContextSelectionService.swift +++ b/oAI/Services/ContextSelectionService.swift @@ -214,7 +214,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) @@ -248,7 +248,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 { diff --git a/oAI/Services/EmbeddingService.swift b/oAI/Services/EmbeddingService.swift index 2461c15..5d523e8 100644 --- a/oAI/Services/EmbeddingService.swift +++ b/oAI/Services/EmbeddingService.swift @@ -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 { + 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] { + func deserializeEmbedding(_ data: Data) -> [Float] { var embedding: [Float] = [] embedding.reserveCapacity(data.count / 4) diff --git a/oAI/Services/GitSyncService.swift b/oAI/Services/GitSyncService.swift index f087afb..d6dde1f 100644 --- a/oAI/Services/GitSyncService.swift +++ b/oAI/Services/GitSyncService.swift @@ -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,7 +697,7 @@ 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: "-") diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index 7b3f88e..0561643 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -413,15 +413,15 @@ Don't narrate future actions ("Let me...") - just use the tools. /// Update the selected model and keep currentProvider + settings in sync. /// Call this whenever the user picks a model in the model selector. func selectModel(_ model: ModelInfo) { - let newProvider = 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? { + static func inferProvider(from modelId: String) -> Settings.Provider? { // OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet") if modelId.contains("/") { return .openrouter } // Anthropic direct (e.g. "claude-sonnet-4-5-20250929") @@ -437,7 +437,7 @@ Don't narrate future actions ("Let me...") - just use the tools. /// Shows a system message only on failure or on a successful switch. @MainActor private func switchToConversationModel(_ modelId: String) async { - guard let targetProvider = inferProvider(from: modelId) else { + guard let targetProvider = Self.inferProvider(from: modelId) else { showSystemMessage("⚠️ Could not determine provider for model '\(modelId)' — keeping current model") return } @@ -942,7 +942,7 @@ Don't narrate future actions ("Let me...") - just use the tools. messages[index].tokens = usage.completionTokens if let model = selectedModel { let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 - let cost: Double? = hasPricing ? 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) } @@ -1006,7 +1006,7 @@ Don't narrate future actions ("Let me...") - just use the tools. messages[index].tokens = usage.completionTokens if let model = selectedModel { let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 - let cost: Double? = hasPricing ? 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) } @@ -1306,7 +1306,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)" @@ -1582,7 +1582,7 @@ Don't narrate future actions ("Let me...") - just use the tools. // Nothing worth showing yet — still record usage/cost for this turn. if let usage = totalUsage, let model = selectedModel { let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 - let cost: Double? = hasPricing ? 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, @@ -1607,7 +1607,7 @@ Don't narrate future actions ("Let me...") - just use the tools. // Calculate cost if let usage = totalUsage, let model = selectedModel { let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0 - let cost: Double? = hasPricing ? 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 } @@ -2038,7 +2038,7 @@ Don't narrate future actions ("Let me...") - just use the tools. } /// Detect goodbye phrases in user message - func detectGoodbyePhrase(in text: String) -> Bool { + static func detectGoodbyePhrase(in text: String) -> Bool { let lowercased = text.lowercased() let goodbyePhrases = [ "bye", "goodbye", "bye bye", "good bye", @@ -2078,7 +2078,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)) @@ -2259,7 +2259,7 @@ Don't narrate future actions ("Let me...") - just use the tools. /// pricing when present: cache writes cost 1.25x the base input rate, cache /// reads cost 0.1x. `usage.promptTokens` is already the uncached remainder — /// it does not need cache tokens subtracted from it. - private func calculateCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double { + 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 -- 2.54.0 From a053d4a9838cff1f387e177fe9cb09a99050bc20 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Thu, 23 Jul 2026 14:11:50 +0200 Subject: [PATCH 06/13] 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). --- oAI/Services/EmbeddingService.swift | 4 +- oAI/ViewModels/ChatViewModel.swift | 6 +- oAITests/AnthropicProviderTests.swift | 180 ++++++++++++++++++++ oAITests/ChatViewModelPureLogicTests.swift | 106 ++++++++++++ oAITests/ContextSelectionServiceTests.swift | 67 ++++++++ oAITests/EmbeddingServiceTests.swift | 35 ++++ oAITests/GitSyncServiceTests.swift | 94 ++++++++++ oAITests/OllamaProviderTests.swift | 79 +++++++++ oAITests/OpenAIProviderTests.swift | 89 ++++++++++ oAITests/OpenRouterProviderTests.swift | 103 +++++++++++ 10 files changed, 758 insertions(+), 5 deletions(-) create mode 100644 oAITests/AnthropicProviderTests.swift create mode 100644 oAITests/ChatViewModelPureLogicTests.swift create mode 100644 oAITests/ContextSelectionServiceTests.swift create mode 100644 oAITests/EmbeddingServiceTests.swift create mode 100644 oAITests/GitSyncServiceTests.swift create mode 100644 oAITests/OllamaProviderTests.swift create mode 100644 oAITests/OpenAIProviderTests.swift create mode 100644 oAITests/OpenRouterProviderTests.swift diff --git a/oAI/Services/EmbeddingService.swift b/oAI/Services/EmbeddingService.swift index 5d523e8..f096bc5 100644 --- a/oAI/Services/EmbeddingService.swift +++ b/oAI/Services/EmbeddingService.swift @@ -348,7 +348,7 @@ final class EmbeddingService { // MARK: - Serialization /// Serialize embedding to binary data (4 bytes per float, little-endian) - 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 - func deserializeEmbedding(_ data: Data) -> [Float] { + nonisolated func deserializeEmbedding(_ data: Data) -> [Float] { var embedding: [Float] = [] embedding.reserveCapacity(data.count / 4) diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index 0561643..1eebbe1 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -421,7 +421,7 @@ Don't narrate future actions ("Let me...") - just use the tools. func inferProviderPublic(from modelId: String) -> Settings.Provider? { Self.inferProvider(from: modelId) } - static func inferProvider(from modelId: String) -> Settings.Provider? { + nonisolated static func inferProvider(from modelId: String) -> Settings.Provider? { // OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet") if modelId.contains("/") { return .openrouter } // Anthropic direct (e.g. "claude-sonnet-4-5-20250929") @@ -2038,7 +2038,7 @@ Don't narrate future actions ("Let me...") - just use the tools. } /// Detect goodbye phrases in user message - static 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", @@ -2259,7 +2259,7 @@ Don't narrate future actions ("Let me...") - just use the tools. /// pricing when present: cache writes cost 1.25x the base input rate, cache /// reads cost 0.1x. `usage.promptTokens` is already the uncached remainder — /// it does not need cache tokens subtracted from it. - static 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 diff --git a/oAITests/AnthropicProviderTests.swift b/oAITests/AnthropicProviderTests.swift new file mode 100644 index 0000000..5aa54ee --- /dev/null +++ b/oAITests/AnthropicProviderTests.swift @@ -0,0 +1,180 @@ +// +// 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) + } +} diff --git a/oAITests/ChatViewModelPureLogicTests.swift b/oAITests/ChatViewModelPureLogicTests.swift new file mode 100644 index 0000000..f577f6a --- /dev/null +++ b/oAITests/ChatViewModelPureLogicTests.swift @@ -0,0 +1,106 @@ +// +// 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) + } + + // 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) + } +} diff --git a/oAITests/ContextSelectionServiceTests.swift b/oAITests/ContextSelectionServiceTests.swift new file mode 100644 index 0000000..de96c0e --- /dev/null +++ b/oAITests/ContextSelectionServiceTests.swift @@ -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) + } +} diff --git a/oAITests/EmbeddingServiceTests.swift b/oAITests/EmbeddingServiceTests.swift new file mode 100644 index 0000000..f135ffd --- /dev/null +++ b/oAITests/EmbeddingServiceTests.swift @@ -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) + } +} diff --git a/oAITests/GitSyncServiceTests.swift b/oAITests/GitSyncServiceTests.swift new file mode 100644 index 0000000..e5e7b2d --- /dev/null +++ b/oAITests/GitSyncServiceTests.swift @@ -0,0 +1,94 @@ +// +// 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\"gi|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")) + } +} diff --git a/oAITests/OllamaProviderTests.swift b/oAITests/OllamaProviderTests.swift new file mode 100644 index 0000000..b3a758f --- /dev/null +++ b/oAITests/OllamaProviderTests.swift @@ -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) + } +} diff --git a/oAITests/OpenAIProviderTests.swift b/oAITests/OpenAIProviderTests.swift new file mode 100644 index 0000000..46c1f85 --- /dev/null +++ b/oAITests/OpenAIProviderTests.swift @@ -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()) + } +} diff --git a/oAITests/OpenRouterProviderTests.swift b/oAITests/OpenRouterProviderTests.swift new file mode 100644 index 0000000..ff24c4b --- /dev/null +++ b/oAITests/OpenRouterProviderTests.swift @@ -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) + } +} -- 2.54.0 From 02bf73fec6f5802315842dea1112b38ed9130a66 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Thu, 23 Jul 2026 14:19:53 +0200 Subject: [PATCH 07/13] 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. --- oAI.xcodeproj/project.pbxproj | 148 ++++++++++++++++++++- oAI/Providers/AnthropicProvider.swift | 21 ++- oAI/Services/BackupService.swift | 95 +++++++++---- oAI/Services/GitSyncService.swift | 5 +- oAI/ViewModels/ChatViewModel.swift | 65 ++++----- oAITests/AnthropicProviderTests.swift | 27 ++++ oAITests/BackupServiceTests.swift | 105 +++++++++++++++ oAITests/ChatViewModelPureLogicTests.swift | 56 ++++++++ oAITests/GitSyncServiceTests.swift | 14 ++ 9 files changed, 464 insertions(+), 72 deletions(-) create mode 100644 oAITests/BackupServiceTests.swift diff --git a/oAI.xcodeproj/project.pbxproj b/oAI.xcodeproj/project.pbxproj index 10b326d..f6df3a3 100644 --- a/oAI.xcodeproj/project.pbxproj +++ b/oAI.xcodeproj/project.pbxproj @@ -11,8 +11,19 @@ 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 */ @@ -34,6 +45,11 @@ path = oAI; sourceTree = ""; }; + A586FF5230122589002CFF95 /* oAITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = oAITests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -46,6 +62,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A586FF4E30122589002CFF95 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -53,6 +76,7 @@ isa = PBXGroup; children = ( A550A6642F3B72EA00136F2B /* oAI */, + A586FF5230122589002CFF95 /* oAITests */, A550A6632F3B72EA00136F2B /* Products */, ); sourceTree = ""; @@ -61,6 +85,7 @@ isa = PBXGroup; children = ( A550A6622F3B72EA00136F2B /* oAI.app */, + A586FF5130122589002CFF95 /* oAITests.xctest */, ); name = Products; sourceTree = ""; @@ -92,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 */ @@ -99,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" */; @@ -131,6 +183,7 @@ projectRoot = ""; targets = ( A550A6612F3B72EA00136F2B /* oAI */, + A586FF5030122589002CFF95 /* oAITests */, ); }; /* End PBXProject section */ @@ -143,6 +196,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A586FF4F30122589002CFF95 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -153,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"; @@ -190,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; @@ -212,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"; }; @@ -222,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"; @@ -251,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; @@ -266,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; @@ -278,12 +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_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."; @@ -328,12 +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_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."; @@ -370,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 */ @@ -391,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 */ diff --git a/oAI/Providers/AnthropicProvider.swift b/oAI/Providers/AnthropicProvider.swift index dcebe80..34cb0fd 100644 --- a/oAI/Providers/AnthropicProvider.swift +++ b/oAI/Providers/AnthropicProvider.swift @@ -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, diff --git a/oAI/Services/BackupService.swift b/oAI/Services/BackupService.swift index e65b89a..534f4b1 100644 --- a/oAI/Services/BackupService.swift +++ b/oAI/Services/BackupService.swift @@ -206,37 +206,68 @@ final class BackupService { log.debug("Pushed \(payload.ids.count) favorite model(s) to iCloud") } + /// Outcome of reconciling local favorites against the iCloud copy. Pure result type -- + /// see `decideFavoritesSync` for the actual decision logic. + enum FavoritesSyncDecision: Equatable { + case applyRemote(ids: Set, updatedAt: String) + case pushLocal + case mergeAndPush(ids: Set, 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, + 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() - guard let data = try? Data(contentsOf: fileURL), - let remote = try? JSONDecoder().decode(FavoritesPayload.self, from: data) else { - // No remote copy yet — push local state (may be empty, that's fine). - await pushFavorites() - return - } - - let localUpdatedAt = settings.favoriteModelsUpdatedAt + 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 - if remote.updatedAt > localUpdatedAt { - settings.favoriteModelIds = Set(remote.ids) - settings.favoriteModelsUpdatedAt = remote.updatedAt - log.info("Applied \(remote.ids.count) favorite model(s) from iCloud (remote was newer)") - } else if localUpdatedAt > remote.updatedAt { + + switch Self.decideFavoritesSync(localIds: localIds, localUpdatedAt: settings.favoriteModelsUpdatedAt, remote: remote, now: Date()) { + case .pushLocal: await pushFavorites() - } else if Set(remote.ids) != localIds { - // Tied timestamps (both empty is the common case: two machines that already had - // favorites before this sync feature existed, neither ever bumped the timestamp). - // Union rather than silently dropping one side's favorites. - settings.favoriteModelIds = localIds.union(remote.ids) - settings.favoriteModelsUpdatedAt = ISO8601DateFormatter().string(from: Date()) + 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) remote") + log.info("Merged favorites from iCloud (tied timestamps) — union of \(localIds.count) local + \(remote?.ids.count ?? 0) remote") + case .noop: + break } - // Equal timestamps and identical sets: already in sync, nothing to do. } // MARK: - Automatic Backup @@ -252,17 +283,27 @@ final class BackupService { } } - func checkAndPerformAutoBackupIfDue() async { - let frequency = SettingsService.shared.autoBackupFrequency + /// 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 = Self.dailyInterval - case "weekly": interval = Self.weeklyInterval - default: return // "manual" — user triggers backups by hand + 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() - if let last = lastBackupDate, Date().timeIntervalSince(last) < interval { + guard Self.isBackupDue(frequency: frequency, lastBackupDate: lastBackupDate, now: Date()) else { return // not due yet } diff --git a/oAI/Services/GitSyncService.swift b/oAI/Services/GitSyncService.swift index d6dde1f..b9827ae 100644 --- a/oAI/Services/GitSyncService.swift +++ b/oAI/Services/GitSyncService.swift @@ -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 @@ -703,8 +703,7 @@ class GitSyncService { 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") { diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index 1eebbe1..3af0d2e 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -1888,40 +1888,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 diff --git a/oAITests/AnthropicProviderTests.swift b/oAITests/AnthropicProviderTests.swift index 5aa54ee..3a859ec 100644 --- a/oAITests/AnthropicProviderTests.swift +++ b/oAITests/AnthropicProviderTests.swift @@ -177,4 +177,31 @@ struct AnthropicProviderTests { #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) + } } diff --git a/oAITests/BackupServiceTests.swift b/oAITests/BackupServiceTests.swift new file mode 100644 index 0000000..c3fa605 --- /dev/null +++ b/oAITests/BackupServiceTests.swift @@ -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)) + } +} diff --git a/oAITests/ChatViewModelPureLogicTests.swift b/oAITests/ChatViewModelPureLogicTests.swift index f577f6a..c6a163f 100644 --- a/oAITests/ChatViewModelPureLogicTests.swift +++ b/oAITests/ChatViewModelPureLogicTests.swift @@ -103,4 +103,60 @@ struct ChatViewModelPureLogicTests { 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")) + } } diff --git a/oAITests/GitSyncServiceTests.swift b/oAITests/GitSyncServiceTests.swift index e5e7b2d..67a2b4d 100644 --- a/oAITests/GitSyncServiceTests.swift +++ b/oAITests/GitSyncServiceTests.swift @@ -91,4 +91,18 @@ struct GitSyncServiceTests { 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") + } } -- 2.54.0 From c6a74396482ff472ab656a6cf4cda4158eb0a95b Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Fri, 24 Jul 2026 09:16:42 +0200 Subject: [PATCH 08/13] 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. --- oAI/Services/ContextSelectionService.swift | 17 +++++++----- oAI/Services/DatabaseService.swift | 30 ++++++++++++++++++---- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/oAI/Services/ContextSelectionService.swift b/oAI/Services/ContextSelectionService.swift index 310f735..1311b85 100644 --- a/oAI/Services/ContextSelectionService.swift +++ b/oAI/Services/ContextSelectionService.swift @@ -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 [] } @@ -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 diff --git a/oAI/Services/DatabaseService.swift b/oAI/Services/DatabaseService.swift index 9bf46c2..a56b195 100644 --- a/oAI/Services/DatabaseService.swift +++ b/oAI/Services/DatabaseService.swift @@ -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.54.0 From f3c6271c9ea72c396ef5bdd30783d8e297fab17f Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Fri, 24 Jul 2026 09:16:46 +0200 Subject: [PATCH 09/13] 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. --- oAITests/ContextSelectionServiceDBTests.swift | 120 +++++++++++++++++ oAITests/DatabaseServiceTests.swift | 123 ++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 oAITests/ContextSelectionServiceDBTests.swift create mode 100644 oAITests/DatabaseServiceTests.swift diff --git a/oAITests/ContextSelectionServiceDBTests.swift b/oAITests/ContextSelectionServiceDBTests.swift new file mode 100644 index 0000000..f63f4fc --- /dev/null +++ b/oAITests/ContextSelectionServiceDBTests.swift @@ -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) + } +} diff --git a/oAITests/DatabaseServiceTests.swift b/oAITests/DatabaseServiceTests.swift new file mode 100644 index 0000000..680f738 --- /dev/null +++ b/oAITests/DatabaseServiceTests.swift @@ -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 = ["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") + } +} -- 2.54.0 From 30fbb4162e4a9f24b98b833d5cea1bbcec147048 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Fri, 24 Jul 2026 09:46:17 +0200 Subject: [PATCH 10/13] =?UTF-8?q?Revive=20Apple=20Intelligence=20provider?= =?UTF-8?q?=20(Phase=201=20=E2=80=94=20on-device)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- oAI/Models/Settings.swift | 11 +- oAI/Providers/AppleFoundationProvider.swift | 228 ++++++++++++++++++ oAI/Providers/ProviderRegistry.swift | 5 + .../Extensions/Color+Extensions.swift | 1 + oAI/ViewModels/ChatViewModel.swift | 2 + oAI/Views/Screens/CreditsView.swift | 11 + oAI/Views/Screens/SettingsView.swift | 46 ++++ oAITests/ChatViewModelPureLogicTests.swift | 5 + oAITests/SettingsProviderTests.swift | 31 +++ 9 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 oAI/Providers/AppleFoundationProvider.swift create mode 100644 oAITests/SettingsProviderTests.swift diff --git a/oAI/Models/Settings.swift b/oAI/Models/Settings.swift index b4cfd40..effe640 100644 --- a/oAI/Models/Settings.swift +++ b/oAI/Models/Settings.swift @@ -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" } } } diff --git a/oAI/Providers/AppleFoundationProvider.swift b/oAI/Providers/AppleFoundationProvider.swift new file mode 100644 index 0000000..9071b01 --- /dev/null +++ b/oAI/Providers/AppleFoundationProvider.swift @@ -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 +// for +// the full license text. For commercial licensing, contact Rune +// Olsen via . + + +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", + description: "On-device Apple Intelligence model. Private, free, and works offline. 4K context window.", + 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 { + AsyncThrowingStream { continuation in + Task { + do { + let session = try self.makeSession(for: request) + let prompt = self.lastUserMessage(from: request) + + // streamResponse(to: String) → ResponseStream + // 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 = 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 + } + } +} diff --git a/oAI/Providers/ProviderRegistry.swift b/oAI/Providers/ProviderRegistry.swift index cf6c5ee..e5d9a2a 100644 --- a/oAI/Providers/ProviderRegistry.swift +++ b/oAI/Providers/ProviderRegistry.swift @@ -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 } } diff --git a/oAI/Utilities/Extensions/Color+Extensions.swift b/oAI/Utilities/Extensions/Color+Extensions.swift index 95eec0a..e0579d7 100644 --- a/oAI/Utilities/Extensions/Color+Extensions.swift +++ b/oAI/Utilities/Extensions/Color+Extensions.swift @@ -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 } } diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index 3af0d2e..0687d70 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -422,6 +422,8 @@ Don't narrate future actions ("Let me...") - just use the tools. func inferProviderPublic(from modelId: String) -> Settings.Provider? { Self.inferProvider(from: modelId) } 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") diff --git a/oAI/Views/Screens/CreditsView.swift b/oAI/Views/Screens/CreditsView.swift index 7008925..b9b0d84 100644 --- a/oAI/Views/Screens/CreditsView.swift +++ b/oAI/Views/Screens/CreditsView.swift @@ -78,6 +78,17 @@ 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) + Image(systemName: "apple.logo") + .font(.system(size: 40)) + .foregroundColor(.secondary) + .padding(.top) } } diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index 0f3c8e7..4e0551f 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -23,6 +23,7 @@ import SwiftUI import UniformTypeIdentifiers +import FoundationModels struct SettingsView: View { @Environment(\.dismiss) var dismiss @@ -319,6 +320,29 @@ 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) + } + } + } + } + } + // Features VStack(alignment: .leading, spacing: 6) { sectionHeader("Features") @@ -3089,6 +3113,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 { diff --git a/oAITests/ChatViewModelPureLogicTests.swift b/oAITests/ChatViewModelPureLogicTests.swift index c6a163f..36e4a5d 100644 --- a/oAITests/ChatViewModelPureLogicTests.swift +++ b/oAITests/ChatViewModelPureLogicTests.swift @@ -64,6 +64,11 @@ struct ChatViewModelPureLogicTests { #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") diff --git a/oAITests/SettingsProviderTests.swift b/oAITests/SettingsProviderTests.swift new file mode 100644 index 0000000..ca2255a --- /dev/null +++ b/oAITests/SettingsProviderTests.swift @@ -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") + } +} -- 2.54.0 From 30d0500323b313803a24c152e309b92fb5e21473 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Fri, 24 Jul 2026 10:12:39 +0200 Subject: [PATCH 11/13] 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 --- .gitignore | 3 +++ oAI/ViewModels/ChatViewModel.swift | 37 +++++++++++++++++++----------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 163d5e2..4dc106a 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ DerivedData/ *.dSYM.zip *.dSYM +## Code coverage +*.profraw + ## Playgrounds timeline.xctimeline playground.xcworkspace diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index 0687d70..5847a93 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -151,8 +151,14 @@ Don't narrate future actions ("Let me...") - just use the tools. /// Builds the complete system prompt by combining default + conditional sections + custom 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 @@ -170,29 +176,32 @@ Don't narrate future actions ("Let me...") - just use the tools. } // Add tool-specific guidelines if MCP is enabled (tools are available) - 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" + } } } } -- 2.54.0 From 377e783a17c601bb762a574bf9f36691d0c4ab38 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Fri, 24 Jul 2026 10:21:41 +0200 Subject: [PATCH 12/13] Mark Apple Intelligence provider as Beta throughout the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- oAI/Providers/AppleFoundationProvider.swift | 4 ++-- oAI/Views/Screens/CreditsView.swift | 3 +++ oAI/Views/Screens/SettingsView.swift | 7 +++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/oAI/Providers/AppleFoundationProvider.swift b/oAI/Providers/AppleFoundationProvider.swift index 9071b01..9cdb041 100644 --- a/oAI/Providers/AppleFoundationProvider.swift +++ b/oAI/Providers/AppleFoundationProvider.swift @@ -42,8 +42,8 @@ final class AppleFoundationProvider: AIProvider { [ ModelInfo( id: "apple-on-device", - name: "Apple On-Device", - description: "On-device Apple Intelligence model. Private, free, and works offline. 4K context window.", + 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( diff --git a/oAI/Views/Screens/CreditsView.swift b/oAI/Views/Screens/CreditsView.swift index b9b0d84..9d9e5ba 100644 --- a/oAI/Views/Screens/CreditsView.swift +++ b/oAI/Views/Screens/CreditsView.swift @@ -85,6 +85,9 @@ struct CreditsView: View { 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) diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index 4e0551f..14755d7 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -340,6 +340,13 @@ It's better to admit "I need more information" or "I cannot do that" than to fak } } } + 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) } } -- 2.54.0 From 69dbf17f5e50445b1c2bfcf628bd9379f8b3c490 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Sat, 25 Jul 2026 11:44:27 +0200 Subject: [PATCH 13/13] Add PDF text extraction to MCP read_file and search_files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- oAI/Services/MCPService.swift | 42 +++++++++++++++---- oAITests/MCPServicePDFTests.swift | 68 +++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 oAITests/MCPServicePDFTests.swift diff --git a/oAI/Services/MCPService.swift b/oAI/Services/MCPService.swift index b4afc75..4a7ce74 100644 --- a/oAI/Services/MCPService.swift +++ b/oAI/Services/MCPService.swift @@ -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.. [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"] ), diff --git a/oAITests/MCPServicePDFTests.swift b/oAITests/MCPServicePDFTests.swift new file mode 100644 index 0000000..794f22b --- /dev/null +++ b/oAITests/MCPServicePDFTests.swift @@ -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) + } +} -- 2.54.0