diff --git a/Dayflow/Dayflow/Core/AI/OllamaProvider+Networking.swift b/Dayflow/Dayflow/Core/AI/OllamaProvider+Networking.swift index 362af4f35..bcc2cfa80 100644 --- a/Dayflow/Dayflow/Core/AI/OllamaProvider+Networking.swift +++ b/Dayflow/Dayflow/Core/AI/OllamaProvider+Networking.swift @@ -6,11 +6,21 @@ import Foundation extension OllamaProvider { + /// Default output cap kept low because long generations are slow on local hardware. + static let defaultMaxOutputTokens = 4000 + + /// Override for reasoning models that need more headroom (#246): + /// `defaults write teleportlabs.com.Dayflow llmLocalMaxOutputTokens -int 32000` + static var configuredMaxOutputTokens: Int { + let configured = UserDefaults.standard.integer(forKey: "llmLocalMaxOutputTokens") + return configured > 0 ? configured : defaultMaxOutputTokens + } + struct ChatRequest: Codable { let model: String let messages: [ChatMessage] var temperature: Double = 0.7 - var max_tokens: Int = 4000 + var max_tokens: Int = OllamaProvider.configuredMaxOutputTokens var stream: Bool = false } @@ -231,7 +241,7 @@ extension OllamaProvider { // Helper method for text-only requests func callTextAPI( _ prompt: String, operation: String, expectJSON: Bool = false, batchId: Int64? = nil, - maxRetries: Int = 3, maxTokens: Int = 4000 + maxRetries: Int = 3, maxTokens: Int = OllamaProvider.configuredMaxOutputTokens ) async throws -> String { let systemPrompt = expectJSON @@ -269,7 +279,8 @@ extension OllamaProvider { // MARK: - Text Generation extension OllamaProvider { - func generateText(prompt: String, maxTokens: Int = 4000) async throws + func generateText(prompt: String, maxTokens: Int = OllamaProvider.configuredMaxOutputTokens) + async throws -> (text: String, log: LLMCall) { let callStart = Date() diff --git a/Dayflow/DayflowTests/OllamaProviderMaxTokensTests.swift b/Dayflow/DayflowTests/OllamaProviderMaxTokensTests.swift new file mode 100644 index 000000000..9e729d37e --- /dev/null +++ b/Dayflow/DayflowTests/OllamaProviderMaxTokensTests.swift @@ -0,0 +1,75 @@ +import XCTest + +@testable import Dayflow + +final class OllamaProviderMaxTokensTests: XCTestCase { + private let key = "llmLocalMaxOutputTokens" + private var savedValue: Any? + + override func setUp() { + super.setUp() + savedValue = UserDefaults.standard.object(forKey: key) + UserDefaults.standard.removeObject(forKey: key) + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: key) + if let savedValue { + UserDefaults.standard.set(savedValue, forKey: key) + } + savedValue = nil + super.tearDown() + } + + func testMaxOutputTokensDefaultsTo4000WhenUnset() { + XCTAssertEqual(OllamaProvider.configuredMaxOutputTokens, 4000) + } + + func testMaxOutputTokensReadsUserDefaultsOverride() { + UserDefaults.standard.set(32000, forKey: key) + XCTAssertEqual(OllamaProvider.configuredMaxOutputTokens, 32000) + } + + func testMaxOutputTokensFallsBackToDefaultForInvalidValues() { + UserDefaults.standard.set(0, forKey: key) + XCTAssertEqual(OllamaProvider.configuredMaxOutputTokens, 4000) + + UserDefaults.standard.set(-100, forKey: key) + XCTAssertEqual(OllamaProvider.configuredMaxOutputTokens, 4000) + } + + func testChatRequestUsesConfiguredMaxOutputTokens() { + UserDefaults.standard.set(16000, forKey: key) + let request = OllamaProvider.ChatRequest(model: "test-model", messages: []) + XCTAssertEqual(request.max_tokens, 16000) + } + + // Boundary: exactly 1 is the smallest value the `configured > 0` guard + // treats as a real override (0 falls back, per the test above). Pins the + // guard's exclusive-zero semantics so a future `>= 0` regression is caught. + func testMaxOutputTokensAcceptsMinimumPositiveOverride() { + UserDefaults.standard.set(1, forKey: key) + XCTAssertEqual(OllamaProvider.configuredMaxOutputTokens, 1) + } + + // Reasoning models (the #246 use case) can legitimately need very large + // caps. Confirm a large override passes through unclamped, both via the + // computed property and into the ChatRequest the network layer sends. + func testMaxOutputTokensPassesLargeReasoningModelOverrideThrough() { + UserDefaults.standard.set(131_072, forKey: key) + XCTAssertEqual(OllamaProvider.configuredMaxOutputTokens, 131_072) + let request = OllamaProvider.ChatRequest(model: "test-model", messages: []) + XCTAssertEqual(request.max_tokens, 131_072) + } + + // The default constant and the fallback path must agree — guards against a + // future edit that changes one but not the other. + func testDefaultConstantMatchesUnsetFallback() { + XCTAssertEqual(OllamaProvider.defaultMaxOutputTokens, 4000) + UserDefaults.standard.removeObject(forKey: key) + XCTAssertEqual( + OllamaProvider.configuredMaxOutputTokens, + OllamaProvider.defaultMaxOutputTokens + ) + } +}