diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index d72a6a01..1155efef 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */; }; 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */; }; 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */; }; + 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */; }; 7CDB0A2F2F3C4D5600FB7CAD /* dictation_fixture.wav in Resources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */; }; 7CDB0A302F3C4D5600FB7CAD /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */; }; 7CE006BD2E80EBE600DDCCD6 /* AppUpdater in Frameworks */ = {isa = PBXBuildFile; productRef = 7CE006BC2E80EBE600DDCCD6 /* AppUpdater */; }; @@ -34,6 +35,7 @@ 7CDB0A202F3C4D5600FB7CAD /* FluidDictationIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FluidDictationIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = ""; }; 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = ""; }; + 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMClientRequestBodyTests.swift; sourceTree = ""; }; 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = ""; }; 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = dictation_fixture.wav; sourceTree = ""; }; 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; @@ -104,6 +106,7 @@ 7CDB0A272F3C4D5600FB7CAD /* Resources */, 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */, 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */, + 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */, ); path = FluidDictationIntegrationTests; sourceTree = ""; @@ -258,6 +261,7 @@ 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */, 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */, 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */, + 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/Fluid/Services/LLMClient.swift b/Sources/Fluid/Services/LLMClient.swift index ae907b29..3d928644 100644 --- a/Sources/Fluid/Services/LLMClient.swift +++ b/Sources/Fluid/Services/LLMClient.swift @@ -132,7 +132,7 @@ final class LLMClient { var maxRetries: Int = 3 var retryDelayMs: Int = 200 - // Timeout configuration (nil = use default) + /// Timeout configuration (nil = use default) var timeoutSeconds: TimeInterval? // Optional real-time callbacks (for streaming UI updates) @@ -310,7 +310,7 @@ final class LLMClient { request.url?.path.contains("/responses") == true } - private func buildChatCompletionsBody(_ config: Config) -> [String: Any] { + func buildChatCompletionsBody(_ config: Config) -> [String: Any] { var body: [String: Any] = [ "model": config.model, "messages": config.messages, @@ -327,10 +327,8 @@ final class LLMClient { body["tool_choice"] = "auto" } - // Add streaming flag - if config.streaming { - body["stream"] = true - } + // Always send stream explicitly — providers like Ollama treat an absent key as true + body["stream"] = config.streaming // Layer 1: Model-specific parameters (e.g., enable_thinking for Nemotron) let modelExtras = ThinkingParserFactory.getExtraParameters(for: config.model) @@ -355,16 +353,15 @@ final class LLMClient { return body } - private func buildResponsesBody(_ config: Config) -> [String: Any] { + func buildResponsesBody(_ config: Config) -> [String: Any] { var body: [String: Any] = [ "model": config.model, "input": self.responsesInput(from: config.messages), "store": false, ] - if config.streaming { - body["stream"] = true - } + // Always send stream explicitly — providers like Ollama treat an absent key as true + body["stream"] = config.streaming if !config.tools.isEmpty { body["tools"] = self.responsesTools(from: config.tools) diff --git a/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift b/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift new file mode 100644 index 00000000..2611ebeb --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift @@ -0,0 +1,46 @@ +@testable import FluidVoice_Debug +import XCTest + +// Regression tests for https://github.com/altic-dev/FluidVoice/issues/295 +// Ollama and compatible OpenAI-format providers treat an absent `stream` key as true. +// The fix is to always send the key explicitly, whether streaming or not. + +@MainActor +final class LLMClientRequestBodyTests: XCTestCase { + + private func config(streaming: Bool) -> LLMClient.Config { + LLMClient.Config( + messages: [["role": "user", "content": "hello"]], + model: "llama3", + baseURL: "http://localhost:11434/v1", + apiKey: "", + streaming: streaming + ) + } + + // MARK: - Chat Completions endpoint + + func testChatCompletionsBody_streamFalse_keyIsPresentAndFalse() { + let body = LLMClient.shared.buildChatCompletionsBody(config(streaming: false)) + XCTAssertNotNil(body["stream"], "stream key must be present when streaming=false — absent key breaks Ollama-compatible providers") + XCTAssertEqual(body["stream"] as? Bool, false) + } + + func testChatCompletionsBody_streamTrue_keyIsPresentAndTrue() { + let body = LLMClient.shared.buildChatCompletionsBody(config(streaming: true)) + XCTAssertEqual(body["stream"] as? Bool, true) + } + + // MARK: - Responses endpoint + + func testResponsesBody_streamFalse_keyIsPresentAndFalse() { + let body = LLMClient.shared.buildResponsesBody(config(streaming: false)) + XCTAssertNotNil(body["stream"], "stream key must be present when streaming=false") + XCTAssertEqual(body["stream"] as? Bool, false) + } + + func testResponsesBody_streamTrue_keyIsPresentAndTrue() { + let body = LLMClient.shared.buildResponsesBody(config(streaming: true)) + XCTAssertEqual(body["stream"] as? Bool, true) + } +}