diff --git a/Sources/Fluid/Services/LocalAPI/InferenceAPIController.swift b/Sources/Fluid/Services/LocalAPI/InferenceAPIController.swift index 96c5b20bd..8f09d53fa 100644 --- a/Sources/Fluid/Services/LocalAPI/InferenceAPIController.swift +++ b/Sources/Fluid/Services/LocalAPI/InferenceAPIController.swift @@ -25,15 +25,16 @@ 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 AudioUpload { + let data: Data + let suggestedExtension: String + } - switch request.path { - case "/v1/transcribe": + func handle(_ request: LocalAPI.Request) async -> LocalAPI.Response { + switch (request.method, request.path) { + 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) @@ -46,14 +47,12 @@ final class InferenceAPIController: LocalAPIRouteHandler { 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) @@ -61,12 +60,12 @@ 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 LocalAPITranscriptionService.transcribe(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 ) ) @@ -78,7 +77,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) } guard let path = payload.path, !path.isEmpty else { return nil } @@ -101,15 +100,16 @@ 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 { 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 +118,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 @@ -130,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 { @@ -142,13 +139,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 +153,12 @@ 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] + ) + } } diff --git a/Sources/Fluid/Services/LocalAPI/LocalAPIAudioDecoder.swift b/Sources/Fluid/Services/LocalAPI/LocalAPIAudioDecoder.swift index 2b56dba45..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) + } } } @@ -66,6 +80,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) @@ -73,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, + ] + ) + } } diff --git a/Sources/Fluid/Services/LocalAPI/LocalAPIMultipartFormData.swift b/Sources/Fluid/Services/LocalAPI/LocalAPIMultipartFormData.swift new file mode 100644 index 000000000..684158da0 --- /dev/null +++ b/Sources/Fluid/Services/LocalAPI/LocalAPIMultipartFormData.swift @@ -0,0 +1,224 @@ +import Foundation + +enum LocalAPIMultipartFormData { + struct Part: Sendable { + 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 self.parameterComponents(from: contentType) { + let parameter = component.trimmingCharacters(in: .whitespacesAndNewlines) + guard let separator = parameter.firstIndex(of: "=") else { continue } + + let key = parameter[.. [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 self.parameterComponents(from: disposition).dropFirst() { + let parameter = component.trimmingCharacters(in: .whitespacesAndNewlines) + guard let separator = parameter.firstIndex(of: "=") else { continue } + let key = parameter[.. [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(==) + } + + 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..41f799862 100644 --- a/Sources/Fluid/Services/LocalAPI/LocalAPIRouter.swift +++ b/Sources/Fluid/Services/LocalAPI/LocalAPIRouter.swift @@ -20,6 +20,10 @@ 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: "POST", path: "/v1/transcribe", handler: inference) self.register(method: "POST", path: "/v1/postprocess", handler: inference) 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" } } 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..1dce912ea --- /dev/null +++ b/Sources/Fluid/Services/LocalAPI/OpenAITranscriptionAPIController.swift @@ -0,0 +1,225 @@ +import AVFoundation +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 await 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) async 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 body = request.body + let parts: [LocalAPIMultipartFormData.Part] + do { + parts = try await Task.detached(priority: .userInitiated) { + try LocalAPIMultipartFormData.parse(body: body, contentType: contentType) + }.value + } 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 + || nsError.domain == AVFoundationErrorDomain + { + 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 + ) + } +} diff --git a/Tests/FluidDictationIntegrationTests/AudioBufferConverterTests.swift b/Tests/FluidDictationIntegrationTests/AudioBufferConverterTests.swift index ccf6d20ee..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, @@ -144,3 +158,228 @@ 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 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( + "--\(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 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 + 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, + filename: String = "audio.wav" + ) -> Data { + var body = Data( + "--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\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 + } +}