Skip to content

Commit f164f65

Browse files
committed
fix: use structured llama server responses
Replace human-oriented llama CLI output parsing with a private Unix-socket llama-server session and JSON chat-completions responses. Preserve existing runner settings by resolving adjacent server binaries and add an opt-in real-model integration test for the complete process lifecycle.
1 parent ff1cbcd commit f164f65

4 files changed

Lines changed: 272 additions & 123 deletions

File tree

Sources/VocaMac/Services/LocalLLMPostProcessor.swift

Lines changed: 198 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ enum TextPostProcessingError: LocalizedError {
4949
case processFailed(Int32, String)
5050
case timedOut
5151
case emptyOutput
52+
case invalidResponse(String)
5253
case unexpectedResponse(String)
5354

5455
var errorDescription: String? {
@@ -69,6 +70,10 @@ enum TextPostProcessingError: LocalizedError {
6970
return "Local LLM timed out."
7071
case .emptyOutput:
7172
return "Local LLM returned no text."
73+
case .invalidResponse(let details):
74+
return details.isEmpty
75+
? "Local LLM returned an invalid response."
76+
: "Local LLM returned an invalid response: \(details)"
7277
case .unexpectedResponse(let response):
7378
return response.isEmpty
7479
? "Local LLM returned an unexpected response."
@@ -78,15 +83,13 @@ enum TextPostProcessingError: LocalizedError {
7883
}
7984

8085
final class LocalLLMPostProcessor: TextPostProcessing {
81-
static let defaultRunnerPath = detectedRunnerPath() ?? "/opt/homebrew/bin/llama-cli"
86+
static let defaultRunnerPath = detectedRunnerPath() ?? "/opt/homebrew/bin/llama-server"
8287
static let defaultInstructions = "You are a transcription cleanup engine. Rewrite the user's dictated transcript as clean final text for immediate pasting. Keep the original meaning and intent. Remove filler words and false starts. Fix punctuation and capitalization. Make only conservative wording changes. Do not summarize, answer, invent subject lines, or add commentary. Return only the final rewritten text."
8388
static let installURL = URL(string: "https://github.com/ggml-org/llama.cpp")!
8489

8590
private static let runnerCandidates = [
86-
"/opt/homebrew/bin/llama",
87-
"/opt/homebrew/bin/llama-cli",
88-
"/usr/local/bin/llama",
89-
"/usr/local/bin/llama-cli",
91+
"/opt/homebrew/bin/llama-server",
92+
"/usr/local/bin/llama-server",
9093
]
9194

9295
private static let brewCandidates = [
@@ -131,7 +134,7 @@ final class LocalLLMPostProcessor: TextPostProcessing {
131134
userPrompt: text.trimmingCharacters(in: .whitespacesAndNewlines),
132135
configuration: configuration,
133136
timeout: timeout,
134-
maxTokens: 64
137+
maxTokens: 256
135138
)
136139
}.value
137140
}
@@ -149,7 +152,7 @@ final class LocalLLMPostProcessor: TextPostProcessing {
149152
}
150153

151154
static func runnerExists(at path: String) -> Bool {
152-
FileManager.default.isExecutableFile(atPath: expandedPath(path))
155+
serverExecutable(for: path) != nil
153156
}
154157

155158
static func installLlamaCpp() async throws -> String {
@@ -167,101 +170,181 @@ final class LocalLLMPostProcessor: TextPostProcessing {
167170
return style.isEmpty ? defaultInstructions : style
168171
}
169172

170-
static func cleanedOutput(_ output: String, userPrompt: String) -> String {
171-
var text = output.replacingOccurrences(
172-
of: "\u{001B}\\[[0-9;]*[A-Za-z]",
173-
with: "",
174-
options: .regularExpression
175-
)
176-
text = text.replacingOccurrences(of: "\r\n", with: "\n")
177-
text = text.replacingOccurrences(of: "\r", with: "\n")
173+
private static func run(
174+
systemPrompt: String,
175+
userPrompt: String,
176+
configuration: TextPostProcessingConfiguration,
177+
timeout: TimeInterval,
178+
maxTokens: Int
179+
) throws -> String {
180+
let configuredPath = expandedPath(configuration.runnerPath)
181+
guard !configuredPath.isEmpty else { throw TextPostProcessingError.missingRunnerPath }
182+
guard let runnerPath = serverExecutable(for: configuredPath) else {
183+
throw TextPostProcessingError.runnerNotExecutable(configuredPath)
184+
}
185+
186+
let socketURL = URL(fileURLWithPath: "/tmp")
187+
.appendingPathComponent("vocamac-\(UUID().uuidString).sock")
188+
let server = Process()
189+
let serverOutput = Pipe()
190+
let serverFinished = DispatchSemaphore(value: 0)
191+
let serverOutputRead = DispatchSemaphore(value: 0)
192+
var serverOutputData = Data()
193+
194+
server.executableURL = URL(fileURLWithPath: runnerPath)
195+
server.arguments = (try modelArguments(for: configuration)) + [
196+
"--host", socketURL.path,
197+
"-c", "2048",
198+
"--reasoning", "off",
199+
]
200+
server.standardInput = FileHandle.nullDevice
201+
server.standardOutput = serverOutput
202+
server.standardError = serverOutput
203+
server.terminationHandler = { _ in serverFinished.signal() }
204+
205+
try server.run()
206+
207+
DispatchQueue.global(qos: .utility).async {
208+
serverOutputData = serverOutput.fileHandleForReading.readDataToEndOfFile()
209+
serverOutputRead.signal()
210+
}
178211

179-
let promptMarker = "> \(userPrompt)"
180-
if let range = text.range(of: promptMarker) {
181-
text = String(text[range.upperBound...])
212+
defer {
213+
if server.isRunning {
214+
server.terminate()
215+
}
216+
_ = serverFinished.wait(timeout: .now() + 5)
217+
try? FileManager.default.removeItem(at: socketURL)
182218
}
183219

184-
text = text.replacingOccurrences(
185-
of: #"(?s)<think>.*?</think>"#,
186-
with: "",
187-
options: .regularExpression
220+
let deadline = Date().addingTimeInterval(timeout)
221+
try waitUntilReady(
222+
server: server,
223+
socketURL: socketURL,
224+
deadline: deadline,
225+
outputRead: serverOutputRead,
226+
outputData: { serverOutputData }
188227
)
189-
text = text.replacingOccurrences(
190-
of: #"(?m)^\[ Prompt:.*$"#,
191-
with: "",
192-
options: .regularExpression
228+
229+
let request = ChatCompletionRequest(
230+
messages: [
231+
ChatMessage(role: "system", content: systemPrompt),
232+
ChatMessage(role: "user", content: userPrompt),
233+
],
234+
maxTokens: maxTokens,
235+
temperature: 0.1,
236+
topP: 0.8,
237+
stream: false
193238
)
194-
text = text.replacingOccurrences(
195-
of: #"(?m)^Exiting\.\.\.$"#,
196-
with: "",
197-
options: .regularExpression
239+
let requestData = try JSONEncoder().encode(request)
240+
let remaining = deadline.timeIntervalSinceNow
241+
guard remaining > 0 else { throw TextPostProcessingError.timedOut }
242+
243+
let responseData = try runCurl(
244+
arguments: [
245+
"--silent",
246+
"--show-error",
247+
"--fail-with-body",
248+
"--max-time", "\(Int(ceil(remaining)))",
249+
"--unix-socket", socketURL.path,
250+
"--header", "Content-Type: application/json",
251+
"--data-binary", "@-",
252+
"http://localhost/v1/chat/completions",
253+
],
254+
input: requestData,
255+
timeout: remaining
198256
)
199-
return text.trimmingCharacters(in: .whitespacesAndNewlines)
257+
return try responseText(from: responseData)
200258
}
201259

202-
private static func run(
203-
systemPrompt: String,
204-
userPrompt: String,
205-
configuration: TextPostProcessingConfiguration,
206-
timeout: TimeInterval,
207-
maxTokens: Int
208-
) throws -> String {
209-
let runnerPath = expandedPath(configuration.runnerPath)
260+
static func responseText(from data: Data) throws -> String {
261+
do {
262+
let response = try JSONDecoder().decode(ChatCompletionResponse.self, from: data)
263+
let text = response.choices.first?.message.content
264+
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
265+
guard !text.isEmpty else { throw TextPostProcessingError.emptyOutput }
266+
return text
267+
} catch let error as TextPostProcessingError {
268+
throw error
269+
} catch {
270+
throw TextPostProcessingError.invalidResponse(error.localizedDescription)
271+
}
272+
}
210273

211-
guard !runnerPath.isEmpty else { throw TextPostProcessingError.missingRunnerPath }
212-
guard FileManager.default.isExecutableFile(atPath: runnerPath) else {
213-
throw TextPostProcessingError.runnerNotExecutable(runnerPath)
274+
private static func waitUntilReady(
275+
server: Process,
276+
socketURL: URL,
277+
deadline: Date,
278+
outputRead: DispatchSemaphore,
279+
outputData: () -> Data
280+
) throws {
281+
while Date() < deadline {
282+
guard server.isRunning else {
283+
_ = outputRead.wait(timeout: .now() + 1)
284+
let details = String(data: outputData(), encoding: .utf8)?
285+
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
286+
throw TextPostProcessingError.processFailed(server.terminationStatus, details)
287+
}
288+
289+
if FileManager.default.fileExists(atPath: socketURL.path),
290+
(try? runCurl(
291+
arguments: [
292+
"--silent",
293+
"--fail",
294+
"--max-time", "1",
295+
"--unix-socket", socketURL.path,
296+
"http://localhost/health",
297+
],
298+
timeout: 2
299+
)) != nil {
300+
return
301+
}
302+
Thread.sleep(forTimeInterval: 0.1)
214303
}
304+
throw TextPostProcessingError.timedOut
305+
}
215306

307+
private static func runCurl(
308+
arguments: [String],
309+
input: Data? = nil,
310+
timeout: TimeInterval
311+
) throws -> Data {
216312
let task = Process()
217-
let outputPipe = Pipe()
313+
let output = Pipe()
314+
let inputPipe = input.map { _ in Pipe() }
218315
let finished = DispatchSemaphore(value: 0)
219316
let outputRead = DispatchSemaphore(value: 0)
220317
var outputData = Data()
221318

222-
task.executableURL = URL(fileURLWithPath: runnerPath)
223-
task.arguments = runnerCommandPrefix(for: runnerPath) + (try modelArguments(for: configuration)) + [
224-
"-sys", systemPrompt,
225-
"-p", userPrompt,
226-
"-n", "\(maxTokens)",
227-
"-c", "2048",
228-
"--temp", "0.1",
229-
"--top-k", "20",
230-
"--top-p", "0.8",
231-
"--simple-io",
232-
"--no-display-prompt",
233-
"-st",
234-
"--reasoning", "off",
235-
]
236-
task.standardInput = FileHandle.nullDevice
237-
task.standardOutput = outputPipe
238-
task.standardError = outputPipe
319+
task.executableURL = URL(fileURLWithPath: "/usr/bin/curl")
320+
task.arguments = arguments
321+
task.standardInput = inputPipe ?? FileHandle.nullDevice
322+
task.standardOutput = output
323+
task.standardError = output
239324
task.terminationHandler = { _ in finished.signal() }
240325

241326
try task.run()
242-
243327
DispatchQueue.global(qos: .utility).async {
244-
outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
328+
outputData = output.fileHandleForReading.readDataToEndOfFile()
245329
outputRead.signal()
246330
}
331+
if let input, let inputPipe {
332+
inputPipe.fileHandleForWriting.write(input)
333+
inputPipe.fileHandleForWriting.closeFile()
334+
}
247335

248336
guard finished.wait(timeout: .now() + timeout) == .success else {
249337
task.terminate()
250338
throw TextPostProcessingError.timedOut
251339
}
252-
253340
outputRead.wait()
254-
let output = String(data: outputData, encoding: .utf8) ?? ""
341+
255342
guard task.terminationStatus == 0 else {
256-
throw TextPostProcessingError.processFailed(
257-
task.terminationStatus,
258-
output.trimmingCharacters(in: .whitespacesAndNewlines)
259-
)
343+
let details = String(data: outputData, encoding: .utf8)?
344+
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
345+
throw TextPostProcessingError.processFailed(task.terminationStatus, details)
260346
}
261-
262-
let cleaned = cleanedOutput(output, userPrompt: userPrompt)
263-
guard !cleaned.isEmpty else { throw TextPostProcessingError.emptyOutput }
264-
return cleaned
347+
return outputData
265348
}
266349

267350
static func modelArguments(for configuration: TextPostProcessingConfiguration) throws -> [String] {
@@ -283,6 +366,22 @@ final class LocalLLMPostProcessor: TextPostProcessing {
283366
.replacingOccurrences(of: #"^~(?=/|$)"#, with: NSHomeDirectory(), options: .regularExpression)
284367
}
285368

369+
private static func serverExecutable(for configuredPath: String) -> String? {
370+
let path = expandedPath(configuredPath)
371+
guard !path.isEmpty else { return nil }
372+
373+
if URL(fileURLWithPath: path).lastPathComponent == "llama-server",
374+
FileManager.default.isExecutableFile(atPath: path) {
375+
return path
376+
}
377+
378+
let sibling = URL(fileURLWithPath: path)
379+
.deletingLastPathComponent()
380+
.appendingPathComponent("llama-server")
381+
.path
382+
return FileManager.default.isExecutableFile(atPath: sibling) ? sibling : nil
383+
}
384+
286385
private static func installLlamaCpp(using brewPath: String) throws -> String {
287386
let task = Process()
288387
let finished = DispatchSemaphore(value: 0)
@@ -303,13 +402,37 @@ final class LocalLLMPostProcessor: TextPostProcessing {
303402
throw TextPostProcessingError.processFailed(task.terminationStatus, "")
304403
}
305404
guard let runner = detectedRunnerPath() else {
306-
throw TextPostProcessingError.runnerNotExecutable("llama.cpp installed, but no llama runner was found.")
405+
throw TextPostProcessingError.runnerNotExecutable("llama.cpp installed, but llama-server was not found.")
307406
}
308407
return runner
309408
}
310409

311-
private static func runnerCommandPrefix(for runnerPath: String) -> [String] {
312-
URL(fileURLWithPath: runnerPath).lastPathComponent == "llama" ? ["cli"] : []
410+
private struct ChatCompletionRequest: Encodable {
411+
let messages: [ChatMessage]
412+
let maxTokens: Int
413+
let temperature: Double
414+
let topP: Double
415+
let stream: Bool
416+
417+
enum CodingKeys: String, CodingKey {
418+
case messages
419+
case maxTokens = "max_tokens"
420+
case temperature
421+
case topP = "top_p"
422+
case stream
423+
}
424+
}
425+
426+
private struct ChatMessage: Codable {
427+
let role: String
428+
let content: String
313429
}
314430

431+
private struct ChatCompletionResponse: Decodable {
432+
let choices: [Choice]
433+
434+
struct Choice: Decodable {
435+
let message: ChatMessage
436+
}
437+
}
315438
}

Sources/VocaMac/Views/SettingsView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ struct TextSettingsTab: View {
231231
}
232232

233233
HStack {
234-
TextField("llama runner path", text: $appState.postProcessingRunnerPath)
234+
TextField("llama-server path", text: $appState.postProcessingRunnerPath)
235235
.textFieldStyle(.roundedBorder)
236236
.disabled(isPostProcessingSetupBusy)
237237
Button {
@@ -243,7 +243,7 @@ struct TextSettingsTab: View {
243243
}
244244

245245
HStack {
246-
Label(runnerReady ? "llama.cpp ready" : "llama.cpp not found", systemImage: runnerReady ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
246+
Label(runnerReady ? "llama-server ready" : "llama-server not found", systemImage: runnerReady ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
247247
.foregroundStyle(runnerReady ? .green : .orange)
248248

249249
Spacer()

0 commit comments

Comments
 (0)