Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Fluid.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand All @@ -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 = "<group>"; };
7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = "<group>"; };
343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMClientRequestBodyTests.swift; sourceTree = "<group>"; };
7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = "<group>"; };
7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = dictation_fixture.wav; sourceTree = "<group>"; };
7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
Expand Down Expand Up @@ -104,6 +106,7 @@
7CDB0A272F3C4D5600FB7CAD /* Resources */,
7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */,
7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */,
343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */,
);
path = FluidDictationIntegrationTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -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;
};
Expand Down
17 changes: 7 additions & 10 deletions Sources/Fluid/Services/LLMClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading