From 88783964fab36dff1f5a9583e526e619a7687d24 Mon Sep 17 00:00:00 2001 From: Georgii Bukharov Date: Tue, 8 Sep 2026 07:59:31 +0400 Subject: [PATCH 1/9] feat(local-api): add OpenAI-compatible transcription endpoint --- .../LocalAPI/InferenceAPIController.swift | 296 +++++++++++++++++- .../Services/LocalAPI/LocalAPIRouter.swift | 2 + 2 files changed, 282 insertions(+), 16 deletions(-) diff --git a/Sources/Fluid/Services/LocalAPI/InferenceAPIController.swift b/Sources/Fluid/Services/LocalAPI/InferenceAPIController.swift index 96c5b20bd..6be212fa9 100644 --- a/Sources/Fluid/Services/LocalAPI/InferenceAPIController.swift +++ b/Sources/Fluid/Services/LocalAPI/InferenceAPIController.swift @@ -25,21 +25,124 @@ final class InferenceAPIController: LocalAPIRouteHandler { let model: String } - func handle(_ request: LocalAPI.Request) async -> LocalAPI.Response { - guard request.method == "POST" else { - return LocalAPI.error("Method not allowed.", status: 405) + private struct TranscriptionPayload { + let text: String + let confidence: Float + let sampleCount: Int + } + + private struct OpenAITranscriptionResponse: Encodable { + let text: String + } + + private struct OpenAIModel: Encodable { + let id: String + let object = "model" + let created = 0 + let ownedBy = "fluidvoice" + + enum CodingKeys: String, CodingKey { + case id, object, created + case ownedBy = "owned_by" } + } + + private struct OpenAIModelListResponse: Encodable { + let object = "list" + let data: [OpenAIModel] + } - switch request.path { - case "/v1/transcribe": + private struct OpenAITranscriptionUpload { + let data: Data + let filename: String + let responseFormat: String + } + + func handle(_ request: LocalAPI.Request) async -> LocalAPI.Response { + switch (request.method, request.path) { + case ("GET", "/v1/models"): + return self.openAIModels() + case ("POST", "/v1/audio/transcriptions"): + return await self.openAITranscription(request) + case ("POST", "/v1/transcribe"): return await self.transcribe(request) - case "/v1/postprocess": + case ("POST", "/v1/postprocess"): return await self.postprocess(request) default: return LocalAPI.error("Route not found.", status: 404) } } + private func openAIModels() -> LocalAPI.Response { + LocalAPI.json(OpenAIModelListResponse(data: [OpenAIModel(id: "fluidvoice")])) + } + + private func openAITranscription(_ request: LocalAPI.Request) async -> LocalAPI.Response { + do { + let upload = try self.decodeOpenAITranscriptionUpload(from: request) + let fileURL = try await LocalAPIAudioDecoder.temporaryFile( + fromAudioData: upload.data, + suggestedExtension: URL(fileURLWithPath: upload.filename).pathExtension + ) + + do { + let payload = try await self.transcriptionPayload(for: fileURL) + await LocalAPIAudioDecoder.removeTemporaryFile(at: fileURL) + return try self.openAIResponse(text: payload.text, responseFormat: upload.responseFormat) + } catch { + await LocalAPIAudioDecoder.removeTemporaryFile(at: fileURL) + throw error + } + } catch { + return LocalAPI.error(error.localizedDescription, status: 400) + } + } + + private func openAIResponse(text: String, responseFormat: String) throws -> LocalAPI.Response { + switch responseFormat.lowercased() { + case "", "json": + return LocalAPI.json(OpenAITranscriptionResponse(text: text)) + case "text": + return LocalAPI.Response( + status: 200, + headers: ["Content-Type": "text/plain; charset=utf-8"], + body: Data(text.utf8) + ) + default: + throw self.makeError( + "Unsupported response_format '\(responseFormat)'. FluidVoice currently supports 'json' and 'text'.", + code: -7 + ) + } + } + + private func decodeOpenAITranscriptionUpload(from request: LocalAPI.Request) throws -> OpenAITranscriptionUpload { + guard let contentType = request.headers["content-type"], + contentType.lowercased().contains("multipart/form-data") + else { + throw self.makeError("OpenAI transcription requests must use multipart/form-data.", code: -5) + } + + let parts = try LocalAPIMultipartFormData.parse(body: request.body, contentType: contentType) + guard let filePart = parts.first(where: { $0.name == "file" }), !filePart.body.isEmpty else { + throw self.makeError("Missing multipart 'file' field.", code: -6) + } + + let filename = filePart.filename?.trimmingCharacters(in: .whitespacesAndNewlines) + let safeFilename = (filename?.isEmpty == false ? filename : nil) ?? "audio.wav" + let responseFormat = parts.first(where: { $0.name == "response_format" })? + .stringValue? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "json" + + // OpenAI clients also send a `model` field. FluidVoice intentionally accepts + // it without routing on it: the active Voice Engine remains the source of truth. + return OpenAITranscriptionUpload( + data: filePart.body, + filename: safeFilename, + responseFormat: responseFormat + ) + } + private func transcribe(_ request: LocalAPI.Request) async -> LocalAPI.Response { do { if let fileURL = try self.decodeFilePath(from: request) { @@ -61,24 +164,33 @@ final class InferenceAPIController: LocalAPIRouteHandler { } private func transcribeFile(_ fileURL: URL) async throws -> LocalAPI.Response { - let apiResult = try await AppServices.shared.asr.transcribeFileForAPI(fileURL) + let payload = try await self.transcriptionPayload(for: fileURL) return LocalAPI.json( TranscribeResponse( - text: apiResult.result.text, - confidence: apiResult.result.confidence, - sampleCount: apiResult.sampleCount, + text: payload.text, + confidence: payload.confidence, + sampleCount: payload.sampleCount, provider: SettingsStore.shared.selectedSpeechModel.displayName ) ) } + private func transcriptionPayload(for fileURL: URL) async throws -> TranscriptionPayload { + let apiResult = try await AppServices.shared.asr.transcribeFileForAPI(fileURL) + return TranscriptionPayload( + text: apiResult.result.text, + confidence: apiResult.result.confidence, + sampleCount: apiResult.sampleCount + ) + } + private func decodeFilePath(from request: LocalAPI.Request) throws -> URL? { guard self.isJSON(request) else { return nil } let payload: TranscribeJSONRequest do { payload = try LocalAPI.decoder.decode(TranscribeJSONRequest.self, from: request.body) } catch { - throw NSError(domain: "InferenceAPIController", code: -3, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON audio payload."]) + throw self.makeError("Invalid JSON audio payload.", code: -3) } guard let path = payload.path, !path.isEmpty else { return nil } @@ -109,7 +221,7 @@ final class InferenceAPIController: LocalAPIRouteHandler { do { payload = try LocalAPI.decoder.decode(TranscribeJSONRequest.self, from: request.body) } catch { - throw NSError(domain: "InferenceAPIController", code: -3, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON audio payload."]) + throw self.makeError("Invalid JSON audio payload.", code: -3) } if let audioBase64 = payload.audioBase64, @@ -118,11 +230,11 @@ final class InferenceAPIController: LocalAPIRouteHandler { data = decodedData suggestedExtension = payload.filename.flatMap { URL(fileURLWithPath: $0).pathExtension } ?? "wav" } else { - throw NSError(domain: "InferenceAPIController", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing audio path or audioBase64."]) + throw self.makeError("Missing audio path or audioBase64.", code: -1) } } else { guard !request.body.isEmpty else { - throw NSError(domain: "InferenceAPIController", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing audio body."]) + throw self.makeError("Missing audio body.", code: -1) } data = request.body @@ -142,13 +254,13 @@ final class InferenceAPIController: LocalAPIRouteHandler { do { payload = try LocalAPI.decoder.decode(TextRequest.self, from: request.body) } catch { - throw NSError(domain: "InferenceAPIController", code: -4, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON text payload."]) + throw self.makeError("Invalid JSON text payload.", code: -4) } return payload.text } guard let text = String(data: request.body, encoding: .utf8) else { - throw NSError(domain: "InferenceAPIController", code: -2, userInfo: [NSLocalizedDescriptionKey: "Text body must be UTF-8."]) + throw self.makeError("Text body must be UTF-8.", code: -2) } return text } @@ -156,4 +268,156 @@ final class InferenceAPIController: LocalAPIRouteHandler { private func isJSON(_ request: LocalAPI.Request) -> Bool { request.headers["content-type"]?.lowercased().contains("application/json") == true } + + private func makeError(_ message: String, code: Int) -> NSError { + NSError( + domain: "InferenceAPIController", + code: code, + userInfo: [NSLocalizedDescriptionKey: message] + ) + } +} + +enum LocalAPIMultipartFormData { + struct Part { + let name: String + let filename: String? + let headers: [String: String] + let body: Data + + var stringValue: String? { + String(data: self.body, encoding: .utf8) + } + } + + static func parse(body: Data, contentType: String) throws -> [Part] { + let boundary = try self.boundary(from: contentType) + let delimiter = Data("--\(boundary)".utf8) + let headerSeparator = Data("\r\n\r\n".utf8) + + var parts: [Part] = [] + var searchStart = body.startIndex + + while let boundaryRange = body.range(of: delimiter, in: searchStart..= 2, self.hasBytes([13, 10], at: partEnd - 2, in: body) { + partEnd -= 2 + } + + guard let headerRange = body.range(of: headerSeparator, in: partStart.. String { + for component in contentType.split(separator: ";", omittingEmptySubsequences: true) { + let parameter = component.trimmingCharacters(in: .whitespacesAndNewlines) + guard let separator = parameter.firstIndex(of: "=") else { continue } + + let key = parameter[..= 2, value.first == "\"", value.last == "\"" { + value.removeFirst() + value.removeLast() + } + guard !value.isEmpty else { break } + return value + } + throw self.error("Missing multipart boundary.") + } + + private static func headers(from data: Data) throws -> [String: String] { + guard let text = String(data: data, encoding: .utf8) else { + throw self.error("Multipart headers must be UTF-8.") + } + + var headers: [String: String] = [:] + for line in text.components(separatedBy: "\r\n") where !line.isEmpty { + guard let separator = line.firstIndex(of: ":") else { + throw self.error("Malformed multipart header.") + } + let key = line[.. [String: String] { + var parameters: [String: String] = [:] + for component in disposition.split(separator: ";").dropFirst() { + let parameter = component.trimmingCharacters(in: .whitespacesAndNewlines) + guard let separator = parameter.firstIndex(of: "=") else { continue } + let key = parameter[..= 2, value.first == "\"", value.last == "\"" { + value.removeFirst() + value.removeLast() + } + parameters[key] = value + } + return parameters + } + + private static func hasBytes(_ bytes: [UInt8], at index: Data.Index, in data: Data) -> Bool { + guard index >= data.startIndex, index + bytes.count <= data.endIndex else { return false } + return zip(bytes, data[index..<(index + bytes.count)]).allSatisfy(==) + } + + private static func error(_ message: String) -> NSError { + NSError( + domain: "LocalAPIMultipartFormData", + code: -1, + userInfo: [NSLocalizedDescriptionKey: message] + ) + } } diff --git a/Sources/Fluid/Services/LocalAPI/LocalAPIRouter.swift b/Sources/Fluid/Services/LocalAPI/LocalAPIRouter.swift index bc79f42a5..708e9daab 100644 --- a/Sources/Fluid/Services/LocalAPI/LocalAPIRouter.swift +++ b/Sources/Fluid/Services/LocalAPI/LocalAPIRouter.swift @@ -21,6 +21,8 @@ final class LocalAPIRouter { self.register(method: "POST", path: "/v1/dictionary/custom-words", handler: dictionary) let inference = InferenceAPIController() + self.register(method: "GET", path: "/v1/models", handler: inference) + self.register(method: "POST", path: "/v1/audio/transcriptions", handler: inference) self.register(method: "POST", path: "/v1/transcribe", handler: inference) self.register(method: "POST", path: "/v1/postprocess", handler: inference) } From 2f93141ca459c25cffd5112d55ef5a2c281fc407 Mon Sep 17 00:00:00 2001 From: Georgiy Bukharov Date: Wed, 9 Sep 2026 16:12:14 +0400 Subject: [PATCH 2/9] refactor(api): isolate OpenAI transcription compatibility --- .../LocalAPI/InferenceAPIController.swift | 283 +----------------- .../LocalAPI/LocalAPIAudioDecoder.swift | 19 ++ .../LocalAPI/LocalAPIMultipartFormData.swift | 167 +++++++++++ .../Services/LocalAPI/LocalAPIRouter.swift | 6 +- .../LocalAPITranscriptionService.swift | 19 ++ .../OpenAITranscriptionAPIController.swift | 218 ++++++++++++++ 6 files changed, 439 insertions(+), 273 deletions(-) create mode 100644 Sources/Fluid/Services/LocalAPI/LocalAPIMultipartFormData.swift create mode 100644 Sources/Fluid/Services/LocalAPI/LocalAPITranscriptionService.swift create mode 100644 Sources/Fluid/Services/LocalAPI/OpenAITranscriptionAPIController.swift diff --git a/Sources/Fluid/Services/LocalAPI/InferenceAPIController.swift b/Sources/Fluid/Services/LocalAPI/InferenceAPIController.swift index 6be212fa9..8f09d53fa 100644 --- a/Sources/Fluid/Services/LocalAPI/InferenceAPIController.swift +++ b/Sources/Fluid/Services/LocalAPI/InferenceAPIController.swift @@ -25,45 +25,13 @@ final class InferenceAPIController: LocalAPIRouteHandler { let model: String } - private struct TranscriptionPayload { - let text: String - let confidence: Float - let sampleCount: Int - } - - private struct OpenAITranscriptionResponse: Encodable { - let text: String - } - - private struct OpenAIModel: Encodable { - let id: String - let object = "model" - let created = 0 - let ownedBy = "fluidvoice" - - enum CodingKeys: String, CodingKey { - case id, object, created - case ownedBy = "owned_by" - } - } - - private struct OpenAIModelListResponse: Encodable { - let object = "list" - let data: [OpenAIModel] - } - - private struct OpenAITranscriptionUpload { + private struct AudioUpload { let data: Data - let filename: String - let responseFormat: String + let suggestedExtension: String } func handle(_ request: LocalAPI.Request) async -> LocalAPI.Response { switch (request.method, request.path) { - case ("GET", "/v1/models"): - return self.openAIModels() - case ("POST", "/v1/audio/transcriptions"): - return await self.openAITranscription(request) case ("POST", "/v1/transcribe"): return await self.transcribe(request) case ("POST", "/v1/postprocess"): @@ -73,90 +41,18 @@ final class InferenceAPIController: LocalAPIRouteHandler { } } - private func openAIModels() -> LocalAPI.Response { - LocalAPI.json(OpenAIModelListResponse(data: [OpenAIModel(id: "fluidvoice")])) - } - - private func openAITranscription(_ request: LocalAPI.Request) async -> LocalAPI.Response { - do { - let upload = try self.decodeOpenAITranscriptionUpload(from: request) - let fileURL = try await LocalAPIAudioDecoder.temporaryFile( - fromAudioData: upload.data, - suggestedExtension: URL(fileURLWithPath: upload.filename).pathExtension - ) - - do { - let payload = try await self.transcriptionPayload(for: fileURL) - await LocalAPIAudioDecoder.removeTemporaryFile(at: fileURL) - return try self.openAIResponse(text: payload.text, responseFormat: upload.responseFormat) - } catch { - await LocalAPIAudioDecoder.removeTemporaryFile(at: fileURL) - throw error - } - } catch { - return LocalAPI.error(error.localizedDescription, status: 400) - } - } - - private func openAIResponse(text: String, responseFormat: String) throws -> LocalAPI.Response { - switch responseFormat.lowercased() { - case "", "json": - return LocalAPI.json(OpenAITranscriptionResponse(text: text)) - case "text": - return LocalAPI.Response( - status: 200, - headers: ["Content-Type": "text/plain; charset=utf-8"], - body: Data(text.utf8) - ) - default: - throw self.makeError( - "Unsupported response_format '\(responseFormat)'. FluidVoice currently supports 'json' and 'text'.", - code: -7 - ) - } - } - - private func decodeOpenAITranscriptionUpload(from request: LocalAPI.Request) throws -> OpenAITranscriptionUpload { - guard let contentType = request.headers["content-type"], - contentType.lowercased().contains("multipart/form-data") - else { - throw self.makeError("OpenAI transcription requests must use multipart/form-data.", code: -5) - } - - let parts = try LocalAPIMultipartFormData.parse(body: request.body, contentType: contentType) - guard let filePart = parts.first(where: { $0.name == "file" }), !filePart.body.isEmpty else { - throw self.makeError("Missing multipart 'file' field.", code: -6) - } - - let filename = filePart.filename?.trimmingCharacters(in: .whitespacesAndNewlines) - let safeFilename = (filename?.isEmpty == false ? filename : nil) ?? "audio.wav" - let responseFormat = parts.first(where: { $0.name == "response_format" })? - .stringValue? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "json" - - // OpenAI clients also send a `model` field. FluidVoice intentionally accepts - // it without routing on it: the active Voice Engine remains the source of truth. - return OpenAITranscriptionUpload( - data: filePart.body, - filename: safeFilename, - responseFormat: responseFormat - ) - } - private func transcribe(_ request: LocalAPI.Request) async -> LocalAPI.Response { do { if let fileURL = try self.decodeFilePath(from: request) { return try await self.transcribeFile(fileURL) } - let temporaryFileURL = try await self.decodeUploadedAudioFile(from: request) - do { - let response = try await self.transcribeFile(temporaryFileURL) - await LocalAPIAudioDecoder.removeTemporaryFile(at: temporaryFileURL) - return response - } catch { - await LocalAPIAudioDecoder.removeTemporaryFile(at: temporaryFileURL) - throw error + let upload = try self.decodeUploadedAudio(from: request) + return try await LocalAPIAudioDecoder.withTemporaryAudioFile( + fromAudioData: upload.data, + suggestedExtension: upload.suggestedExtension + ) { fileURL in + try await self.transcribeFile(fileURL) } } catch { return LocalAPI.error(error.localizedDescription, status: 400) @@ -164,7 +60,7 @@ final class InferenceAPIController: LocalAPIRouteHandler { } private func transcribeFile(_ fileURL: URL) async throws -> LocalAPI.Response { - let payload = try await self.transcriptionPayload(for: fileURL) + let payload = try await LocalAPITranscriptionService.transcribe(fileURL) return LocalAPI.json( TranscribeResponse( text: payload.text, @@ -175,15 +71,6 @@ final class InferenceAPIController: LocalAPIRouteHandler { ) } - private func transcriptionPayload(for fileURL: URL) async throws -> TranscriptionPayload { - let apiResult = try await AppServices.shared.asr.transcribeFileForAPI(fileURL) - return TranscriptionPayload( - text: apiResult.result.text, - confidence: apiResult.result.confidence, - sampleCount: apiResult.sampleCount - ) - } - private func decodeFilePath(from request: LocalAPI.Request) throws -> URL? { guard self.isJSON(request) else { return nil } let payload: TranscribeJSONRequest @@ -213,9 +100,10 @@ final class InferenceAPIController: LocalAPIRouteHandler { } } - private func decodeUploadedAudioFile(from request: LocalAPI.Request) async throws -> URL { + private func decodeUploadedAudio(from request: LocalAPI.Request) throws -> AudioUpload { let data: Data let suggestedExtension: String + if self.isJSON(request) { let payload: TranscribeJSONRequest do { @@ -242,10 +130,7 @@ final class InferenceAPIController: LocalAPIRouteHandler { suggestedExtension = URL(fileURLWithPath: filename).pathExtension } - return try await LocalAPIAudioDecoder.temporaryFile( - fromAudioData: data, - suggestedExtension: suggestedExtension - ) + return AudioUpload(data: data, suggestedExtension: suggestedExtension) } private func decodeText(from request: LocalAPI.Request) throws -> String { @@ -277,147 +162,3 @@ final class InferenceAPIController: LocalAPIRouteHandler { ) } } - -enum LocalAPIMultipartFormData { - struct Part { - let name: String - let filename: String? - let headers: [String: String] - let body: Data - - var stringValue: String? { - String(data: self.body, encoding: .utf8) - } - } - - static func parse(body: Data, contentType: String) throws -> [Part] { - let boundary = try self.boundary(from: contentType) - let delimiter = Data("--\(boundary)".utf8) - let headerSeparator = Data("\r\n\r\n".utf8) - - var parts: [Part] = [] - var searchStart = body.startIndex - - while let boundaryRange = body.range(of: delimiter, in: searchStart..= 2, self.hasBytes([13, 10], at: partEnd - 2, in: body) { - partEnd -= 2 - } - - guard let headerRange = body.range(of: headerSeparator, in: partStart.. String { - for component in contentType.split(separator: ";", omittingEmptySubsequences: true) { - let parameter = component.trimmingCharacters(in: .whitespacesAndNewlines) - guard let separator = parameter.firstIndex(of: "=") else { continue } - - let key = parameter[..= 2, value.first == "\"", value.last == "\"" { - value.removeFirst() - value.removeLast() - } - guard !value.isEmpty else { break } - return value - } - throw self.error("Missing multipart boundary.") - } - - private static func headers(from data: Data) throws -> [String: String] { - guard let text = String(data: data, encoding: .utf8) else { - throw self.error("Multipart headers must be UTF-8.") - } - - var headers: [String: String] = [:] - for line in text.components(separatedBy: "\r\n") where !line.isEmpty { - guard let separator = line.firstIndex(of: ":") else { - throw self.error("Malformed multipart header.") - } - let key = line[.. [String: String] { - var parameters: [String: String] = [:] - for component in disposition.split(separator: ";").dropFirst() { - let parameter = component.trimmingCharacters(in: .whitespacesAndNewlines) - guard let separator = parameter.firstIndex(of: "=") else { continue } - let key = parameter[..= 2, value.first == "\"", value.last == "\"" { - value.removeFirst() - value.removeLast() - } - parameters[key] = value - } - return parameters - } - - private static func hasBytes(_ bytes: [UInt8], at index: Data.Index, in data: Data) -> Bool { - guard index >= data.startIndex, index + bytes.count <= data.endIndex else { return false } - return zip(bytes, data[index..<(index + bytes.count)]).allSatisfy(==) - } - - private static func error(_ message: String) -> NSError { - NSError( - domain: "LocalAPIMultipartFormData", - code: -1, - userInfo: [NSLocalizedDescriptionKey: message] - ) - } -} diff --git a/Sources/Fluid/Services/LocalAPI/LocalAPIAudioDecoder.swift b/Sources/Fluid/Services/LocalAPI/LocalAPIAudioDecoder.swift index 2b56dba45..b6252c12b 100644 --- a/Sources/Fluid/Services/LocalAPI/LocalAPIAudioDecoder.swift +++ b/Sources/Fluid/Services/LocalAPI/LocalAPIAudioDecoder.swift @@ -66,6 +66,25 @@ enum LocalAPIAudioDecoder { }.value } + static func withTemporaryAudioFile( + fromAudioData data: Data, + suggestedExtension: String, + operation: (URL) async throws -> T + ) async throws -> T { + let fileURL = try await self.temporaryFile( + fromAudioData: data, + suggestedExtension: suggestedExtension + ) + do { + let result = try await operation(fileURL) + await self.removeTemporaryFile(at: fileURL) + return result + } catch { + await self.removeTemporaryFile(at: fileURL) + throw error + } + } + static func removeTemporaryFile(at fileURL: URL) async { await Task.detached(priority: .utility) { try? FileManager.default.removeItem(at: fileURL) diff --git a/Sources/Fluid/Services/LocalAPI/LocalAPIMultipartFormData.swift b/Sources/Fluid/Services/LocalAPI/LocalAPIMultipartFormData.swift new file mode 100644 index 000000000..ac3ac5779 --- /dev/null +++ b/Sources/Fluid/Services/LocalAPI/LocalAPIMultipartFormData.swift @@ -0,0 +1,167 @@ +import Foundation + +enum LocalAPIMultipartFormData { + struct Part { + let name: String + let filename: String? + let headers: [String: String] + let body: Data + + var stringValue: String? { + String(data: self.body, encoding: .utf8) + } + } + + static func parse(body: Data, contentType: String) throws -> [Part] { + let boundary = try self.boundary(from: contentType) + let delimiter = Data("--\(boundary)".utf8) + let headerSeparator = Data("\r\n\r\n".utf8) + + guard body.starts(with: delimiter) else { + throw self.error("Malformed multipart body: missing opening boundary.") + } + + var parts: [Part] = [] + var cursor = body.startIndex + delimiter.count + + while true { + if self.hasBytes([45, 45], at: cursor, in: body) { + guard !parts.isEmpty else { + throw self.error("Malformed multipart body: no parts found.") + } + return parts + } + + guard self.hasBytes([13, 10], at: cursor, in: body) else { + throw self.error("Malformed multipart body: invalid boundary terminator.") + } + let partStart = cursor + 2 + + guard let headerRange = body.range(of: headerSeparator, in: partStart.. Range? { + let marker = Data("\r\n--\(boundary)".utf8) + var searchStart = startIndex + + while searchStart < body.endIndex, + let candidate = body.range(of: marker, in: searchStart.. String { + for component in contentType.split(separator: ";", omittingEmptySubsequences: true) { + let parameter = component.trimmingCharacters(in: .whitespacesAndNewlines) + guard let separator = parameter.firstIndex(of: "=") else { continue } + + let key = parameter[..= 2, value.first == "\"", value.last == "\"" { + value.removeFirst() + value.removeLast() + } + guard !value.isEmpty else { break } + return value + } + throw self.error("Missing multipart boundary.") + } + + private static func headers(from data: Data) throws -> [String: String] { + guard let text = String(data: data, encoding: .utf8) else { + throw self.error("Multipart headers must be UTF-8.") + } + + var headers: [String: String] = [:] + for line in text.components(separatedBy: "\r\n") where !line.isEmpty { + guard let separator = line.firstIndex(of: ":") else { + throw self.error("Malformed multipart header.") + } + let key = line[.. [String: String] { + var parameters: [String: String] = [:] + for component in disposition.split(separator: ";").dropFirst() { + let parameter = component.trimmingCharacters(in: .whitespacesAndNewlines) + guard let separator = parameter.firstIndex(of: "=") else { continue } + let key = parameter[..= 2, value.first == "\"", value.last == "\"" { + value.removeFirst() + value.removeLast() + } + parameters[key] = value + } + return parameters + } + + private static func hasBytes(_ bytes: [UInt8], at index: Data.Index, in data: Data) -> Bool { + guard index >= data.startIndex, index + bytes.count <= data.endIndex else { return false } + return zip(bytes, data[index..<(index + bytes.count)]).allSatisfy(==) + } + + private static func error(_ message: String) -> NSError { + NSError( + domain: "LocalAPIMultipartFormData", + code: -1, + userInfo: [NSLocalizedDescriptionKey: message] + ) + } +} diff --git a/Sources/Fluid/Services/LocalAPI/LocalAPIRouter.swift b/Sources/Fluid/Services/LocalAPI/LocalAPIRouter.swift index 708e9daab..41f799862 100644 --- a/Sources/Fluid/Services/LocalAPI/LocalAPIRouter.swift +++ b/Sources/Fluid/Services/LocalAPI/LocalAPIRouter.swift @@ -20,9 +20,11 @@ final class LocalAPIRouter { self.register(method: "GET", path: "/v1/dictionary/custom-words", handler: dictionary) self.register(method: "POST", path: "/v1/dictionary/custom-words", handler: dictionary) + let openAITranscription = OpenAITranscriptionAPIController() + self.register(method: "GET", path: "/v1/models", handler: openAITranscription) + self.register(method: "POST", path: "/v1/audio/transcriptions", handler: openAITranscription) + let inference = InferenceAPIController() - self.register(method: "GET", path: "/v1/models", handler: inference) - self.register(method: "POST", path: "/v1/audio/transcriptions", handler: inference) self.register(method: "POST", path: "/v1/transcribe", handler: inference) self.register(method: "POST", path: "/v1/postprocess", handler: inference) } diff --git a/Sources/Fluid/Services/LocalAPI/LocalAPITranscriptionService.swift b/Sources/Fluid/Services/LocalAPI/LocalAPITranscriptionService.swift new file mode 100644 index 000000000..dbfafad15 --- /dev/null +++ b/Sources/Fluid/Services/LocalAPI/LocalAPITranscriptionService.swift @@ -0,0 +1,19 @@ +import Foundation + +struct LocalAPITranscriptionPayload { + let text: String + let confidence: Float + let sampleCount: Int +} + +@MainActor +enum LocalAPITranscriptionService { + static func transcribe(_ fileURL: URL) async throws -> LocalAPITranscriptionPayload { + let apiResult = try await AppServices.shared.asr.transcribeFileForAPI(fileURL) + return LocalAPITranscriptionPayload( + text: apiResult.result.text, + confidence: apiResult.result.confidence, + sampleCount: apiResult.sampleCount + ) + } +} diff --git a/Sources/Fluid/Services/LocalAPI/OpenAITranscriptionAPIController.swift b/Sources/Fluid/Services/LocalAPI/OpenAITranscriptionAPIController.swift new file mode 100644 index 000000000..fe85d6e09 --- /dev/null +++ b/Sources/Fluid/Services/LocalAPI/OpenAITranscriptionAPIController.swift @@ -0,0 +1,218 @@ +import Foundation + +@MainActor +final class OpenAITranscriptionAPIController: LocalAPIRouteHandler { + private struct TranscriptionResponse: Encodable { + let text: String + } + + private struct ErrorResponse: Encodable { + struct ErrorBody: Encodable { + let message: String + let type: String + let param: String? + let code: String? + } + + let error: ErrorBody + } + + private struct RequestError: Error { + let message: String + let param: String? + let code: String + } + + private enum ResponseFormat: String { + case json + case text + } + + private struct Model: Encodable { + let id: String + let object = "model" + let created = 0 + let ownedBy = "fluidvoice" + + enum CodingKeys: String, CodingKey { + case id, object, created + case ownedBy = "owned_by" + } + } + + private struct ModelListResponse: Encodable { + let object = "list" + let data: [Model] + } + + private struct TranscriptionUpload { + let data: Data + let filename: String + let responseFormat: ResponseFormat + } + + private let transcribe: @MainActor (URL) async throws -> LocalAPITranscriptionPayload + + init( + transcribe: @escaping @MainActor (URL) async throws -> LocalAPITranscriptionPayload = { + try await LocalAPITranscriptionService.transcribe($0) + } + ) { + self.transcribe = transcribe + } + + func handle(_ request: LocalAPI.Request) async -> LocalAPI.Response { + switch (request.method, request.path) { + case ("GET", "/v1/models"): + return self.models() + case ("POST", "/v1/audio/transcriptions"): + return await self.createTranscription(request) + default: + return LocalAPI.error("Route not found.", status: 404) + } + } + + private func models() -> LocalAPI.Response { + LocalAPI.json(ModelListResponse(data: [Model(id: "fluidvoice")])) + } + + private func createTranscription(_ request: LocalAPI.Request) async -> LocalAPI.Response { + do { + let upload = try self.decodeUpload(from: request) + return try await LocalAPIAudioDecoder.withTemporaryAudioFile( + fromAudioData: upload.data, + suggestedExtension: URL(fileURLWithPath: upload.filename).pathExtension + ) { fileURL in + let payload = try await self.transcribe(fileURL) + return self.response(text: payload.text, format: upload.responseFormat) + } + } catch { + return self.errorResponse(for: error) + } + } + + private func response(text: String, format: ResponseFormat) -> LocalAPI.Response { + switch format { + case .json: + return LocalAPI.json(TranscriptionResponse(text: text)) + case .text: + return LocalAPI.Response( + status: 200, + headers: ["Content-Type": "text/plain; charset=utf-8"], + body: Data(text.utf8) + ) + } + } + + private func decodeUpload(from request: LocalAPI.Request) throws -> TranscriptionUpload { + guard let contentType = request.headers["content-type"], + contentType.lowercased().contains("multipart/form-data") + else { + throw RequestError( + message: "OpenAI transcription requests must use multipart/form-data.", + param: nil, + code: "invalid_content_type" + ) + } + + let parts: [LocalAPIMultipartFormData.Part] + do { + parts = try LocalAPIMultipartFormData.parse(body: request.body, contentType: contentType) + } catch { + throw RequestError( + message: error.localizedDescription, + param: nil, + code: "invalid_multipart_body" + ) + } + + guard let filePart = parts.first(where: { $0.name == "file" }), !filePart.body.isEmpty else { + throw RequestError( + message: "Missing multipart 'file' field.", + param: "file", + code: "missing_required_parameter" + ) + } + + let filename = filePart.filename?.trimmingCharacters(in: .whitespacesAndNewlines) + let safeFilename = (filename?.isEmpty == false ? filename : nil) ?? "audio.wav" + let rawResponseFormat = parts.first(where: { $0.name == "response_format" })? + .stringValue? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "json" + + let responseFormat: ResponseFormat + if rawResponseFormat.isEmpty { + responseFormat = .json + } else if let parsedFormat = ResponseFormat(rawValue: rawResponseFormat) { + responseFormat = parsedFormat + } else { + throw RequestError( + message: "Unsupported response_format '\(rawResponseFormat)'. FluidVoice currently supports 'json' and 'text'.", + param: "response_format", + code: "invalid_value" + ) + } + + // OpenAI clients send a `model` field. FluidVoice accepts it without routing + // because the active Voice Engine remains the source of truth. + return TranscriptionUpload( + data: filePart.body, + filename: safeFilename, + responseFormat: responseFormat + ) + } + + private func errorResponse(for error: Error) -> LocalAPI.Response { + if let requestError = error as? RequestError { + return self.error( + requestError.message, + status: 400, + type: "invalid_request_error", + param: requestError.param, + code: requestError.code + ) + } + + let nsError = error as NSError + if nsError.domain == "ASRService", nsError.code == -2 { + return self.error( + nsError.localizedDescription, + status: 503, + type: "server_error", + code: "service_unavailable" + ) + } + if nsError.domain == "LocalAPIAudioDecoder" || nsError.domain == NSOSStatusErrorDomain { + return self.error( + "The uploaded file could not be decoded as audio.", + status: 400, + type: "invalid_request_error", + param: "file", + code: "invalid_audio" + ) + } + + return self.error( + nsError.localizedDescription, + status: 500, + type: "server_error", + code: "internal_error" + ) + } + + private func error( + _ message: String, + status: Int, + type: String, + param: String? = nil, + code: String? = nil + ) -> LocalAPI.Response { + LocalAPI.json( + ErrorResponse( + error: .init(message: message, type: type, param: param, code: code) + ), + status: status + ) + } +} From 5303f577a681cd9328a4ec27b06e6262aecd2a93 Mon Sep 17 00:00:00 2001 From: Georgiy Bukharov Date: Wed, 9 Sep 2026 16:12:38 +0400 Subject: [PATCH 3/9] test(api): cover OpenAI transcription compatibility --- .../AudioBufferConverterTests.swift | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) diff --git a/Tests/FluidDictationIntegrationTests/AudioBufferConverterTests.swift b/Tests/FluidDictationIntegrationTests/AudioBufferConverterTests.swift index ccf6d20ee..a23ea20d6 100644 --- a/Tests/FluidDictationIntegrationTests/AudioBufferConverterTests.swift +++ b/Tests/FluidDictationIntegrationTests/AudioBufferConverterTests.swift @@ -144,3 +144,188 @@ final class AudioBufferConverterTests: XCTestCase { } } } + +final class LocalAPIMultipartFormDataTests: XCTestCase { + private struct TranscriptionEnvelope: Decodable { + let text: String + } + + private struct ErrorEnvelope: Decodable { + struct ErrorBody: Decodable { + let message: String + let type: String + let param: String? + let code: String? + } + + let error: ErrorBody + } + + func testParserIgnoresBoundaryBytesInsideFilePayload() throws { + let boundary = "fluidvoice-test-boundary" + var fileBody = Data([0, 1, 2]) + fileBody.append(contentsOf: Data("--\(boundary)".utf8)) + fileBody.append(contentsOf: Data("\r\n--\(boundary)X".utf8)) + fileBody.append(255) + + let body = self.multipartBody( + boundary: boundary, + fileBody: fileBody, + responseFormat: "json" + ) + let parts = try LocalAPIMultipartFormData.parse( + body: body, + contentType: "multipart/form-data; boundary=\(boundary)" + ) + + XCTAssertEqual(parts.count, 2) + XCTAssertEqual(parts[0].name, "file") + XCTAssertEqual(parts[0].filename, "audio.wav") + XCTAssertEqual(parts[0].body, fileBody) + XCTAssertEqual(parts[1].name, "response_format") + XCTAssertEqual(parts[1].stringValue, "json") + } + + func testParserRejectsBodyWithoutClosingBoundary() { + let boundary = "fluidvoice-test-boundary" + let body = Data( + "--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n\r\naudio" + .utf8 + ) + + XCTAssertThrowsError( + try LocalAPIMultipartFormData.parse( + body: body, + contentType: "multipart/form-data; boundary=\(boundary)" + ) + ) + } + + @MainActor + func testUnsupportedResponseFormatReturnsOpenAIErrorBeforeAudioDecode() async throws { + let boundary = "fluidvoice-test-boundary" + let request = LocalAPI.Request( + method: "POST", + path: "/v1/audio/transcriptions", + query: [:], + headers: ["content-type": "multipart/form-data; boundary=\(boundary)"], + body: self.multipartBody( + boundary: boundary, + fileBody: Data([0, 1, 2]), + responseFormat: "verbose_json" + ) + ) + + let response = await OpenAITranscriptionAPIController().handle(request) + let error = try LocalAPI.decoder.decode(ErrorEnvelope.self, from: response.body) + + XCTAssertEqual(response.status, 400) + XCTAssertEqual(error.error.type, "invalid_request_error") + XCTAssertEqual(error.error.param, "response_format") + XCTAssertEqual(error.error.code, "invalid_value") + XCTAssertTrue(error.error.message.contains("verbose_json")) + } + + @MainActor + func testJSONResponseUsesOpenAIShape() async throws { + let controller = OpenAITranscriptionAPIController { _ in + LocalAPITranscriptionPayload(text: "Hello from FluidVoice", confidence: 0.9, sampleCount: 42) + } + + let response = await controller.handle(self.transcriptionRequest(responseFormat: "json")) + let payload = try LocalAPI.decoder.decode(TranscriptionEnvelope.self, from: response.body) + + XCTAssertEqual(response.status, 200) + XCTAssertEqual(response.headers["Content-Type"], "application/json; charset=utf-8") + XCTAssertEqual(payload.text, "Hello from FluidVoice") + } + + @MainActor + func testTextResponseUsesPlainTextContentType() async { + let controller = OpenAITranscriptionAPIController { _ in + LocalAPITranscriptionPayload(text: "Plain transcript", confidence: 0.9, sampleCount: 42) + } + + let response = await controller.handle(self.transcriptionRequest(responseFormat: "text")) + + XCTAssertEqual(response.status, 200) + XCTAssertEqual(response.headers["Content-Type"], "text/plain; charset=utf-8") + XCTAssertEqual(String(data: response.body, encoding: .utf8), "Plain transcript") + } + + @MainActor + func testUnavailableASRReturnsOpenAI503AndRemovesTemporaryFile() async throws { + var temporaryFileURL: URL? + let controller = OpenAITranscriptionAPIController { fileURL in + temporaryFileURL = fileURL + throw NSError( + domain: "ASRService", + code: -2, + userInfo: [NSLocalizedDescriptionKey: "Transcription provider is not ready."] + ) + } + + let response = await controller.handle(self.transcriptionRequest(responseFormat: "json")) + let error = try LocalAPI.decoder.decode(ErrorEnvelope.self, from: response.body) + let fileURL = try XCTUnwrap(temporaryFileURL) + + XCTAssertEqual(response.status, 503) + XCTAssertEqual(error.error.type, "server_error") + XCTAssertEqual(error.error.code, "service_unavailable") + XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) + } + + @MainActor + func testUnexpectedASRFailureReturnsOpenAI500() async throws { + let controller = OpenAITranscriptionAPIController { _ in + throw NSError( + domain: "ASRService", + code: -99, + userInfo: [NSLocalizedDescriptionKey: "Unexpected transcription failure."] + ) + } + + let response = await controller.handle(self.transcriptionRequest(responseFormat: "json")) + let error = try LocalAPI.decoder.decode(ErrorEnvelope.self, from: response.body) + + XCTAssertEqual(response.status, 500) + XCTAssertEqual(error.error.type, "server_error") + XCTAssertEqual(error.error.code, "internal_error") + } + + private func transcriptionRequest(responseFormat: String) -> LocalAPI.Request { + let boundary = "fluidvoice-test-boundary" + return LocalAPI.Request( + method: "POST", + path: "/v1/audio/transcriptions", + query: [:], + headers: ["content-type": "multipart/form-data; boundary=\(boundary)"], + body: self.multipartBody( + boundary: boundary, + fileBody: Data([0, 1, 2]), + responseFormat: responseFormat + ) + ) + } + + private func multipartBody( + boundary: String, + fileBody: Data, + responseFormat: String + ) -> Data { + var body = Data( + "--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\nContent-Type: audio/wav\r\n\r\n" + .utf8 + ) + body.append(fileBody) + body.append(contentsOf: Data("\r\n--\(boundary)\r\n".utf8)) + body.append( + contentsOf: Data( + "Content-Disposition: form-data; name=\"response_format\"\r\n\r\n\(responseFormat)\r\n" + .utf8 + ) + ) + body.append(contentsOf: Data("--\(boundary)--\r\n".utf8)) + return body + } +} From fde4d00dea42579314ea07d68935588b7726a020 Mon Sep 17 00:00:00 2001 From: Georgii Bukharov Date: Thu, 10 Sep 2026 10:51:47 +0400 Subject: [PATCH 4/9] fix(api): parse quoted multipart parameters correctly --- .../LocalAPI/LocalAPIMultipartFormData.swift | 91 +++++++++++++++---- 1 file changed, 74 insertions(+), 17 deletions(-) diff --git a/Sources/Fluid/Services/LocalAPI/LocalAPIMultipartFormData.swift b/Sources/Fluid/Services/LocalAPI/LocalAPIMultipartFormData.swift index ac3ac5779..684158da0 100644 --- a/Sources/Fluid/Services/LocalAPI/LocalAPIMultipartFormData.swift +++ b/Sources/Fluid/Services/LocalAPI/LocalAPIMultipartFormData.swift @@ -1,7 +1,7 @@ import Foundation enum LocalAPIMultipartFormData { - struct Part { + struct Part: Sendable { let name: String let filename: String? let headers: [String: String] @@ -64,7 +64,7 @@ enum LocalAPIMultipartFormData { name: name, filename: parameters["filename"], headers: headers, - body: Data(body[payloadStart.. String { - for component in contentType.split(separator: ";", omittingEmptySubsequences: true) { + for component in self.parameterComponents(from: contentType) { let parameter = component.trimmingCharacters(in: .whitespacesAndNewlines) guard let separator = parameter.firstIndex(of: "=") else { continue } let key = parameter[..= 2, value.first == "\"", value.last == "\"" { - value.removeFirst() - value.removeLast() - } + let rawValue = parameter[parameter.index(after: separator)...] + let value = self.parameterValue(from: String(rawValue)) guard !value.isEmpty else { break } return value } @@ -135,23 +131,84 @@ enum LocalAPIMultipartFormData { private static func dispositionParameters(from disposition: String) -> [String: String] { var parameters: [String: String] = [:] - for component in disposition.split(separator: ";").dropFirst() { + for component in self.parameterComponents(from: disposition).dropFirst() { let parameter = component.trimmingCharacters(in: .whitespacesAndNewlines) guard let separator = parameter.firstIndex(of: "=") else { continue } let key = parameter[..= 2, value.first == "\"", value.last == "\"" { - value.removeFirst() - value.removeLast() - } - parameters[key] = value + let rawValue = parameter[parameter.index(after: separator)...] + parameters[key] = self.parameterValue(from: String(rawValue)) } return parameters } + /// Splits MIME-style parameters only on semicolons outside quoted strings. + /// Quoted-pair escapes are preserved here and decoded by `parameterValue(from:)`. + private static func parameterComponents(from value: String) -> [String] { + var components: [String] = [] + var current = "" + var isQuoted = false + var isEscaped = false + + for character in value { + if isEscaped { + current.append(character) + isEscaped = false + continue + } + + if character == "\\", isQuoted { + current.append(character) + isEscaped = true + continue + } + + if character == "\"" { + current.append(character) + isQuoted.toggle() + continue + } + + if character == ";", !isQuoted { + components.append(current) + current.removeAll(keepingCapacity: true) + } else { + current.append(character) + } + } + + components.append(current) + return components + } + + private static func parameterValue(from rawValue: String) -> String { + var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard value.count >= 2, value.first == "\"", value.last == "\"" else { + return value + } + + value.removeFirst() + value.removeLast() + + var decoded = "" + var isEscaped = false + for character in value { + if isEscaped { + decoded.append(character) + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else { + decoded.append(character) + } + } + if isEscaped { + decoded.append("\\") + } + return decoded + } + private static func hasBytes(_ bytes: [UInt8], at index: Data.Index, in data: Data) -> Bool { guard index >= data.startIndex, index + bytes.count <= data.endIndex else { return false } return zip(bytes, data[index..<(index + bytes.count)]).allSatisfy(==) From 97e33e4f0ed5d70975e2300108993230d1a0d7c1 Mon Sep 17 00:00:00 2001 From: Georgii Bukharov Date: Thu, 10 Sep 2026 10:52:10 +0400 Subject: [PATCH 5/9] fix(api): classify audio errors and parse uploads off-main --- .../OpenAITranscriptionAPIController.swift | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Sources/Fluid/Services/LocalAPI/OpenAITranscriptionAPIController.swift b/Sources/Fluid/Services/LocalAPI/OpenAITranscriptionAPIController.swift index fe85d6e09..1dce912ea 100644 --- a/Sources/Fluid/Services/LocalAPI/OpenAITranscriptionAPIController.swift +++ b/Sources/Fluid/Services/LocalAPI/OpenAITranscriptionAPIController.swift @@ -1,3 +1,4 @@ +import AVFoundation import Foundation @MainActor @@ -78,7 +79,7 @@ final class OpenAITranscriptionAPIController: LocalAPIRouteHandler { private func createTranscription(_ request: LocalAPI.Request) async -> LocalAPI.Response { do { - let upload = try self.decodeUpload(from: request) + let upload = try await self.decodeUpload(from: request) return try await LocalAPIAudioDecoder.withTemporaryAudioFile( fromAudioData: upload.data, suggestedExtension: URL(fileURLWithPath: upload.filename).pathExtension @@ -104,7 +105,7 @@ final class OpenAITranscriptionAPIController: LocalAPIRouteHandler { } } - private func decodeUpload(from request: LocalAPI.Request) throws -> TranscriptionUpload { + private func decodeUpload(from request: LocalAPI.Request) async throws -> TranscriptionUpload { guard let contentType = request.headers["content-type"], contentType.lowercased().contains("multipart/form-data") else { @@ -115,9 +116,12 @@ final class OpenAITranscriptionAPIController: LocalAPIRouteHandler { ) } + let body = request.body let parts: [LocalAPIMultipartFormData.Part] do { - parts = try LocalAPIMultipartFormData.parse(body: request.body, contentType: contentType) + parts = try await Task.detached(priority: .userInitiated) { + try LocalAPIMultipartFormData.parse(body: body, contentType: contentType) + }.value } catch { throw RequestError( message: error.localizedDescription, @@ -183,7 +187,10 @@ final class OpenAITranscriptionAPIController: LocalAPIRouteHandler { code: "service_unavailable" ) } - if nsError.domain == "LocalAPIAudioDecoder" || nsError.domain == NSOSStatusErrorDomain { + if nsError.domain == "LocalAPIAudioDecoder" + || nsError.domain == NSOSStatusErrorDomain + || nsError.domain == AVFoundationErrorDomain + { return self.error( "The uploaded file could not be decoded as audio.", status: 400, From dd3ee4a1b4f73814c658b432bed97c5ef8866fed Mon Sep 17 00:00:00 2001 From: Georgii Bukharov Date: Thu, 10 Sep 2026 10:53:02 +0400 Subject: [PATCH 6/9] test(api): cover quoted multipart and AVFoundation errors --- .../AudioBufferConverterTests.swift | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/Tests/FluidDictationIntegrationTests/AudioBufferConverterTests.swift b/Tests/FluidDictationIntegrationTests/AudioBufferConverterTests.swift index a23ea20d6..5cf462e9b 100644 --- a/Tests/FluidDictationIntegrationTests/AudioBufferConverterTests.swift +++ b/Tests/FluidDictationIntegrationTests/AudioBufferConverterTests.swift @@ -186,6 +186,26 @@ final class LocalAPIMultipartFormDataTests: XCTestCase { XCTAssertEqual(parts[1].stringValue, "json") } + func testParserPreservesSemicolonsInsideQuotedParameters() throws { + let boundary = "fluidvoice;test-boundary" + let fileBody = Data([0, 1, 2, 3]) + let body = self.multipartBody( + boundary: boundary, + fileBody: fileBody, + responseFormat: "json", + filename: "sample;one.wav" + ) + + let parts = try LocalAPIMultipartFormData.parse( + body: body, + contentType: "multipart/form-data; boundary=\"\(boundary)\"" + ) + + XCTAssertEqual(parts.count, 2) + XCTAssertEqual(parts[0].filename, "sample;one.wav") + XCTAssertEqual(parts[0].body, fileBody) + } + func testParserRejectsBodyWithoutClosingBoundary() { let boundary = "fluidvoice-test-boundary" let body = Data( @@ -275,6 +295,25 @@ final class LocalAPIMultipartFormDataTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) } + @MainActor + func testAVFoundationDecodeFailureReturnsOpenAI400() async throws { + let controller = OpenAITranscriptionAPIController { _ in + throw NSError( + domain: AVFoundationErrorDomain, + code: -11_800, + userInfo: [NSLocalizedDescriptionKey: "The operation could not be completed."] + ) + } + + let response = await controller.handle(self.transcriptionRequest(responseFormat: "json")) + let error = try LocalAPI.decoder.decode(ErrorEnvelope.self, from: response.body) + + XCTAssertEqual(response.status, 400) + XCTAssertEqual(error.error.type, "invalid_request_error") + XCTAssertEqual(error.error.param, "file") + XCTAssertEqual(error.error.code, "invalid_audio") + } + @MainActor func testUnexpectedASRFailureReturnsOpenAI500() async throws { let controller = OpenAITranscriptionAPIController { _ in @@ -311,10 +350,11 @@ final class LocalAPIMultipartFormDataTests: XCTestCase { private func multipartBody( boundary: String, fileBody: Data, - responseFormat: String + responseFormat: String, + filename: String = "audio.wav" ) -> Data { var body = Data( - "--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\nContent-Type: audio/wav\r\n\r\n" + "--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\nContent-Type: audio/wav\r\n\r\n" .utf8 ) body.append(fileBody) From 4ac55315b31eb6faab0e43052527b7f6365a6868 Mon Sep 17 00:00:00 2001 From: Georgii Bukharov Date: Thu, 10 Sep 2026 11:25:10 +0400 Subject: [PATCH 7/9] fix(api): normalize AVAudioFile decode errors --- .../LocalAPI/LocalAPIAudioDecoder.swift | 51 ++++++++++++++++--- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/Sources/Fluid/Services/LocalAPI/LocalAPIAudioDecoder.swift b/Sources/Fluid/Services/LocalAPI/LocalAPIAudioDecoder.swift index b6252c12b..6d7c6944c 100644 --- a/Sources/Fluid/Services/LocalAPI/LocalAPIAudioDecoder.swift +++ b/Sources/Fluid/Services/LocalAPI/LocalAPIAudioDecoder.swift @@ -13,7 +13,13 @@ enum LocalAPIAudioDecoder { fileURL: URL, chunkDurationSeconds: Double = LocalAPIAudioDecoder.maxChunkDurationSeconds ) throws { - let audioFile = try AVAudioFile(forReading: fileURL) + let audioFile: AVAudioFile + do { + audioFile = try AVAudioFile(forReading: fileURL) + } catch { + throw LocalAPIAudioDecoder.audioDecodeError(underlying: error) + } + let sourceSampleRate = audioFile.processingFormat.sampleRate guard sourceSampleRate > 0, chunkDurationSeconds > 0 else { throw NSError( @@ -46,11 +52,19 @@ enum LocalAPIAudioDecoder { ) } - try self.audioFile.read(into: sourceBuffer, frameCount: framesToRead) - return try AudioBufferConverter.monoSamples( - from: sourceBuffer, - targetSampleRate: LocalAPIAudioDecoder.sampleRate - ) + do { + try self.audioFile.read(into: sourceBuffer, frameCount: framesToRead) + return try AudioBufferConverter.monoSamples( + from: sourceBuffer, + targetSampleRate: LocalAPIAudioDecoder.sampleRate + ) + } catch { + let nsError = error as NSError + if nsError.domain == "LocalAPIAudioDecoder" { + throw error + } + throw LocalAPIAudioDecoder.audioDecodeError(underlying: error) + } } } @@ -92,12 +106,33 @@ enum LocalAPIAudioDecoder { } static func estimatedSampleCount(for fileURL: URL) throws -> Int { - let file = try AVAudioFile(forReading: fileURL) + let file: AVAudioFile + do { + file = try AVAudioFile(forReading: fileURL) + } catch { + throw self.audioDecodeError(underlying: error) + } + let sourceFormat = file.processingFormat guard sourceFormat.sampleRate > 0 else { - throw NSError(domain: "LocalAPIAudioDecoder", code: -6, userInfo: [NSLocalizedDescriptionKey: "Audio file has an invalid sample rate."]) + throw NSError( + domain: "LocalAPIAudioDecoder", + code: -6, + userInfo: [NSLocalizedDescriptionKey: "Audio file has an invalid sample rate."] + ) } return Int((Double(file.length) * self.sampleRate / sourceFormat.sampleRate).rounded()) } + + private static func audioDecodeError(underlying error: Error) -> NSError { + NSError( + domain: "LocalAPIAudioDecoder", + code: -7, + userInfo: [ + NSLocalizedDescriptionKey: "The uploaded file could not be decoded as audio.", + NSUnderlyingErrorKey: error, + ] + ) + } } From 6188dfb045b59706dc36cd88867338b9f035abe4 Mon Sep 17 00:00:00 2001 From: Georgii Bukharov Date: Thu, 10 Sep 2026 11:26:55 +0400 Subject: [PATCH 8/9] test(api): cover AVFAudio decode normalization --- .../AudioBufferConverterTests.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Tests/FluidDictationIntegrationTests/AudioBufferConverterTests.swift b/Tests/FluidDictationIntegrationTests/AudioBufferConverterTests.swift index 5cf462e9b..81766fc50 100644 --- a/Tests/FluidDictationIntegrationTests/AudioBufferConverterTests.swift +++ b/Tests/FluidDictationIntegrationTests/AudioBufferConverterTests.swift @@ -105,6 +105,20 @@ final class AudioBufferConverterTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) } + func testLocalAPIAudioDecoderNormalizesUnsupportedFileOpenError() throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("local-api-invalid-audio-\(UUID().uuidString).wav") + defer { try? FileManager.default.removeItem(at: fileURL) } + try Data("not audio".utf8).write(to: fileURL) + + XCTAssertThrowsError(try LocalAPIAudioDecoder.estimatedSampleCount(for: fileURL)) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, "LocalAPIAudioDecoder") + XCTAssertEqual(nsError.code, -7) + XCTAssertNotNil(nsError.userInfo[NSUnderlyingErrorKey]) + } + } + private func makeFloatBuffer( sampleRate: Double, channels: AVAudioChannelCount, From d084e63e6dde4c67ce7be4ab17bb89f433324361 Mon Sep 17 00:00:00 2001 From: Georgii Bukharov Date: Thu, 10 Sep 2026 11:36:22 +0400 Subject: [PATCH 9/9] fix(api): serialize 503 reason phrase --- Sources/Fluid/Services/LocalAPI/LocalAPIServer.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Fluid/Services/LocalAPI/LocalAPIServer.swift b/Sources/Fluid/Services/LocalAPI/LocalAPIServer.swift index 2da5eb1a3..1b236dbd5 100644 --- a/Sources/Fluid/Services/LocalAPI/LocalAPIServer.swift +++ b/Sources/Fluid/Services/LocalAPI/LocalAPIServer.swift @@ -280,6 +280,7 @@ private final class LocalAPIConnectionHandler { case 405: return "Method Not Allowed" case 413: return "Payload Too Large" case 500: return "Internal Server Error" + case 503: return "Service Unavailable" default: return "OK" } }