From b337dcd8ec72d83e089398f0d9d05adc93414283 Mon Sep 17 00:00:00 2001 From: "J.Conover" Date: Wed, 24 Sep 2025 16:04:47 -0400 Subject: [PATCH 1/7] use event type in payload --- ...ModelResponseEventsStreamInterpreter.swift | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/Sources/OpenAI/Private/Streaming/ModelResponseEventsStreamInterpreter.swift b/Sources/OpenAI/Private/Streaming/ModelResponseEventsStreamInterpreter.swift index 559073c5..8a7959f3 100644 --- a/Sources/OpenAI/Private/Streaming/ModelResponseEventsStreamInterpreter.swift +++ b/Sources/OpenAI/Private/Streaming/ModelResponseEventsStreamInterpreter.swift @@ -55,22 +55,30 @@ final class ModelResponseEventsStreamInterpreter: @unchecked Sendable, StreamInt } private func processEvent(_ event: ServerSentEventsStreamParser.Event) throws { - var finalEvent = event - if event.eventType == "response.output_text.annotation.added" { - // Remove when they have fixed (unified)! - // - // By looking at [API Reference](https://platform.openai.com/docs/api-reference/responses-streaming/response/output_text_annotation/added) - // and generated type `Schemas.ResponseOutputTextAnnotationAddedEvent` - // We can see that "output_text.annotation" is incorrect, whereas output_text_annotation is the correct one - let fixedDataString = event.decodedData.replacingOccurrences(of: "response.output_text.annotation.added", with: "response.output_text_annotation.added") - finalEvent = .init(id: event.id, data: fixedDataString.data(using: .utf8) ?? event.data, decodedData: fixedDataString, eventType: "response.output_text_annotation.added", retry: event.retry) + var eventType = event.eventType + + if eventType == "message" { // This is currently the default if no SSE event name is specified + struct _TypeEnvelope: Decodable { let type: String } + + if var payloadType = try? JSONDecoder().decode(_TypeEnvelope.self, from: event.data).type { + if payloadType == "response.output_text.annotation.added" { + // Remove when they have fixed (unified)! + // + // By looking at [API Reference](https://platform.openai.com/docs/api-reference/responses-streaming/response/output_text_annotation/added) + // and generated type `Schemas.ResponseOutputTextAnnotationAddedEvent` + // We can see that "output_text.annotation" is incorrect, whereas output_text_annotation is the correct one + payloadType = "response.output_text_annotation.added" + } + + eventType = payloadType + } } - - guard let modelResponseEventType = ModelResponseStreamEventType(rawValue: finalEvent.eventType) else { - throw InterpreterError.unknownEventType(finalEvent.eventType) + + guard let modelResponseEventType = ModelResponseStreamEventType(rawValue: eventType) else { + throw InterpreterError.unknownEventType(eventType) } - let responseStreamEvent = try responseStreamEvent(modelResponseEventType: modelResponseEventType, data: finalEvent.data) + let responseStreamEvent = try responseStreamEvent(modelResponseEventType: modelResponseEventType, data: event.data) onEventDispatched?(responseStreamEvent) } From 2285827d442dbc0042468d9fbf547a669916ef2e Mon Sep 17 00:00:00 2001 From: "J.Conover" Date: Wed, 24 Sep 2025 21:43:46 -0400 Subject: [PATCH 2/7] cleanup logic --- ...ModelResponseEventsStreamInterpreter.swift | 51 +++++++++++++------ 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/Sources/OpenAI/Private/Streaming/ModelResponseEventsStreamInterpreter.swift b/Sources/OpenAI/Private/Streaming/ModelResponseEventsStreamInterpreter.swift index 8a7959f3..4ff9ad2b 100644 --- a/Sources/OpenAI/Private/Streaming/ModelResponseEventsStreamInterpreter.swift +++ b/Sources/OpenAI/Private/Streaming/ModelResponseEventsStreamInterpreter.swift @@ -55,23 +55,10 @@ final class ModelResponseEventsStreamInterpreter: @unchecked Sendable, StreamInt } private func processEvent(_ event: ServerSentEventsStreamParser.Event) throws { - var eventType = event.eventType + var eventType = event.fixMappingError().eventType if eventType == "message" { // This is currently the default if no SSE event name is specified - struct _TypeEnvelope: Decodable { let type: String } - - if var payloadType = try? JSONDecoder().decode(_TypeEnvelope.self, from: event.data).type { - if payloadType == "response.output_text.annotation.added" { - // Remove when they have fixed (unified)! - // - // By looking at [API Reference](https://platform.openai.com/docs/api-reference/responses-streaming/response/output_text_annotation/added) - // and generated type `Schemas.ResponseOutputTextAnnotationAddedEvent` - // We can see that "output_text.annotation" is incorrect, whereas output_text_annotation is the correct one - payloadType = "response.output_text_annotation.added" - } - - eventType = payloadType - } + eventType = self.getPayloadType() } guard let modelResponseEventType = ModelResponseStreamEventType(rawValue: eventType) else { @@ -81,7 +68,7 @@ final class ModelResponseEventsStreamInterpreter: @unchecked Sendable, StreamInt let responseStreamEvent = try responseStreamEvent(modelResponseEventType: modelResponseEventType, data: event.data) onEventDispatched?(responseStreamEvent) } - + private func processError(_ error: Error) { onError?(error) } @@ -218,3 +205,35 @@ final class ModelResponseEventsStreamInterpreter: @unchecked Sendable, StreamInt try decoder.decode(T.self, from: data) } } + +private extension ServerSentEventsStreamParser.Event { + + // Remove when they have fixed (unified)! + // + // By looking at [API Reference](https://platform.openai.com/docs/api-reference/responses-streaming/response/output_text_annotation/added) + // and generated type `Schemas.ResponseOutputTextAnnotationAddedEvent` + // We can see that "output_text.annotation" is incorrect, whereas output_text_annotation is the correct one + func fixMappingError() -> Self { + let incorrectEventType = "response.output_text.annotation.added" + let correctEventType = "response.output_text_annotation.added" + + guard self.eventType == incorrectEventType || self.getPayloadType() == incorrectEventType else { + return self + } + + let fixedDataString = event.decodedData.replacingOccurrences(of: incorrectEventType, with: correctEventType) + return .init( + id: event.id, + data: fixedDataString.data(using: .utf8) ?? event.data, + decodedData: fixedDataString, + eventType: correctEventType, + retry: event.retry + ) + } + + struct TypeEnvelope: Decodable { let type: String } + + func getPayloadType() -> String? { + try? JSONDecoder().decode(TypeEnvelope.self, from: event.data).type + } +} From 788dfe106c6de805452997ea8aada52d06683f3c Mon Sep 17 00:00:00 2001 From: "J.Conover" Date: Wed, 24 Sep 2025 22:16:45 -0400 Subject: [PATCH 3/7] fix errors --- ...ModelResponseEventsStreamInterpreter.swift | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/Sources/OpenAI/Private/Streaming/ModelResponseEventsStreamInterpreter.swift b/Sources/OpenAI/Private/Streaming/ModelResponseEventsStreamInterpreter.swift index 4ff9ad2b..a2c21730 100644 --- a/Sources/OpenAI/Private/Streaming/ModelResponseEventsStreamInterpreter.swift +++ b/Sources/OpenAI/Private/Streaming/ModelResponseEventsStreamInterpreter.swift @@ -55,17 +55,20 @@ final class ModelResponseEventsStreamInterpreter: @unchecked Sendable, StreamInt } private func processEvent(_ event: ServerSentEventsStreamParser.Event) throws { - var eventType = event.fixMappingError().eventType + let finalEvent = event.fixMappingError() + var eventType = finalEvent.eventType - if eventType == "message" { // This is currently the default if no SSE event name is specified - eventType = self.getPayloadType() + // "message" is currently the default if no SSE event name is specified + if eventType == "message" || eventType.isEmpty, + let payloadEventType = finalEvent.getPayloadType() { + eventType = payloadEventType } guard let modelResponseEventType = ModelResponseStreamEventType(rawValue: eventType) else { throw InterpreterError.unknownEventType(eventType) } - let responseStreamEvent = try responseStreamEvent(modelResponseEventType: modelResponseEventType, data: event.data) + let responseStreamEvent = try responseStreamEvent(modelResponseEventType: modelResponseEventType, data: finalEvent.data) onEventDispatched?(responseStreamEvent) } @@ -221,19 +224,19 @@ private extension ServerSentEventsStreamParser.Event { return self } - let fixedDataString = event.decodedData.replacingOccurrences(of: incorrectEventType, with: correctEventType) + let fixedDataString = self.decodedData.replacingOccurrences(of: incorrectEventType, with: correctEventType) return .init( - id: event.id, - data: fixedDataString.data(using: .utf8) ?? event.data, + id: self.id, + data: fixedDataString.data(using: .utf8) ?? self.data, decodedData: fixedDataString, eventType: correctEventType, - retry: event.retry + retry: self.retry ) } struct TypeEnvelope: Decodable { let type: String } func getPayloadType() -> String? { - try? JSONDecoder().decode(TypeEnvelope.self, from: event.data).type + try? JSONDecoder().decode(TypeEnvelope.self, from: self.data).type } } From 30210478b9c4d00f965e2b595edc2aa715177fc9 Mon Sep 17 00:00:00 2001 From: "J.Conover" Date: Thu, 25 Sep 2025 10:15:47 -0400 Subject: [PATCH 4/7] add test --- Tests/OpenAITests/MockServerSentEvent.swift | 13 ++++++ ...ResponseEventsStreamInterpreterTests.swift | 44 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/Tests/OpenAITests/MockServerSentEvent.swift b/Tests/OpenAITests/MockServerSentEvent.swift index 39cea945..03456c1f 100644 --- a/Tests/OpenAITests/MockServerSentEvent.swift +++ b/Tests/OpenAITests/MockServerSentEvent.swift @@ -20,4 +20,17 @@ struct MockServerSentEvent { static func chatCompletionError() -> Data { "{\n \"error\": {\n \"message\": \"The model `o3-mini` does not exist or you do not have access to it.\",\n \"type\": \"invalid_request_error\",\n \"param\": null,\n \"code\": \"model_not_found\"\n }\n}\n".data(using: .utf8)! } + + static func responseOutputTextDelta( + itemId: String = "msg_1", + outputIndex: Int = 0, + contentIndex: Int = 0, + delta: String = "Hi", + sequenceNumber: Int = 1 + ) -> Data { + let json = """ + {"type":"response.output_text.delta","output_index":\(outputIndex),"item_id":"\(itemId)","content_index":\(contentIndex),"delta":"\(delta)","sequence_number":\(sequenceNumber)} + """ + return "data: \(json)\n\n".data(using: .utf8)! + } } diff --git a/Tests/OpenAITests/ModelResponseEventsStreamInterpreterTests.swift b/Tests/OpenAITests/ModelResponseEventsStreamInterpreterTests.swift index 9e3952af..40b82ca4 100644 --- a/Tests/OpenAITests/ModelResponseEventsStreamInterpreterTests.swift +++ b/Tests/OpenAITests/ModelResponseEventsStreamInterpreterTests.swift @@ -39,4 +39,48 @@ final class ModelResponseEventsStreamInterpreterTests: XCTestCase { XCTAssertNotNil(receivedError, "Expected an error to be received, but got nil.") XCTAssertTrue(receivedError is APIErrorResponse, "Expected received error to be of type APIErrorResponse.") } + + func testParsesOutputTextDeltaUsingPayloadType() async throws { + let expectation = XCTestExpectation(description: "OutputText delta event received") + var receivedEvent: ResponseStreamEvent? + + interpreter.setCallbackClosures { event in + Task { + await MainActor.run { + receivedEvent = event + expectation.fulfill() + } + } + } onError: { error in + XCTFail("Unexpected error received: \(error)") + } + + interpreter.processData( + MockServerSentEvent.responseOutputTextDelta( + itemId: "msg_1", + outputIndex: 0, + contentIndex: 0, + delta: "Hi", + sequenceNumber: 1 + ) + ) + + await fulfillment(of: [expectation], timeout: 1.0) + + guard let receivedEvent else { + XCTFail("No event received") + return + } + + switch receivedEvent { + case .outputText(.delta(let deltaEvent)): + XCTAssertEqual(deltaEvent.itemId, "msg_1") + XCTAssertEqual(deltaEvent.outputIndex, 0) + XCTAssertEqual(deltaEvent.contentIndex, 0) + XCTAssertEqual(deltaEvent.delta, "Hi") + XCTAssertEqual(deltaEvent.sequenceNumber, 1) + default: + XCTFail("Expected .outputText(.delta), got \(receivedEvent)") + } + } } From 08409406c207212516292a321cd56d2b8304f46f Mon Sep 17 00:00:00 2001 From: "J.Conover" Date: Thu, 25 Sep 2025 10:27:29 -0400 Subject: [PATCH 5/7] make helper func more flexible --- Tests/OpenAITests/MockServerSentEvent.swift | 7 ++++--- .../ModelResponseEventsStreamInterpreterTests.swift | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Tests/OpenAITests/MockServerSentEvent.swift b/Tests/OpenAITests/MockServerSentEvent.swift index 03456c1f..f09a183e 100644 --- a/Tests/OpenAITests/MockServerSentEvent.swift +++ b/Tests/OpenAITests/MockServerSentEvent.swift @@ -21,15 +21,16 @@ struct MockServerSentEvent { "{\n \"error\": {\n \"message\": \"The model `o3-mini` does not exist or you do not have access to it.\",\n \"type\": \"invalid_request_error\",\n \"param\": null,\n \"code\": \"model_not_found\"\n }\n}\n".data(using: .utf8)! } - static func responseOutputTextDelta( + static func responseStreamEvent( itemId: String = "msg_1", + payloadType: String, outputIndex: Int = 0, contentIndex: Int = 0, - delta: String = "Hi", + delta: String = "", sequenceNumber: Int = 1 ) -> Data { let json = """ - {"type":"response.output_text.delta","output_index":\(outputIndex),"item_id":"\(itemId)","content_index":\(contentIndex),"delta":"\(delta)","sequence_number":\(sequenceNumber)} + {"type":"\(payloadType)","output_index":\(outputIndex),"item_id":"\(itemId)","content_index":\(contentIndex),"delta":"\(delta)","sequence_number":\(sequenceNumber)} """ return "data: \(json)\n\n".data(using: .utf8)! } diff --git a/Tests/OpenAITests/ModelResponseEventsStreamInterpreterTests.swift b/Tests/OpenAITests/ModelResponseEventsStreamInterpreterTests.swift index 40b82ca4..37422ce1 100644 --- a/Tests/OpenAITests/ModelResponseEventsStreamInterpreterTests.swift +++ b/Tests/OpenAITests/ModelResponseEventsStreamInterpreterTests.swift @@ -56,8 +56,9 @@ final class ModelResponseEventsStreamInterpreterTests: XCTestCase { } interpreter.processData( - MockServerSentEvent.responseOutputTextDelta( + MockServerSentEvent.responseStreamEvent( itemId: "msg_1", + payloadType: "response.output_text.delta", outputIndex: 0, contentIndex: 0, delta: "Hi", From 4fb18d72026c134518cda715ce6d670fa76212f6 Mon Sep 17 00:00:00 2001 From: "J.Conover" Date: Thu, 25 Sep 2025 13:23:14 -0400 Subject: [PATCH 6/7] Update Sources/OpenAI/Private/Streaming/ModelResponseEventsStreamInterpreter.swift --- .../Streaming/ModelResponseEventsStreamInterpreter.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/OpenAI/Private/Streaming/ModelResponseEventsStreamInterpreter.swift b/Sources/OpenAI/Private/Streaming/ModelResponseEventsStreamInterpreter.swift index a2c21730..0ba61274 100644 --- a/Sources/OpenAI/Private/Streaming/ModelResponseEventsStreamInterpreter.swift +++ b/Sources/OpenAI/Private/Streaming/ModelResponseEventsStreamInterpreter.swift @@ -58,7 +58,8 @@ final class ModelResponseEventsStreamInterpreter: @unchecked Sendable, StreamInt let finalEvent = event.fixMappingError() var eventType = finalEvent.eventType - // "message" is currently the default if no SSE event name is specified + /// If the SSE `event` property is not specified by the provider service, our parser defaults it to "message" which is not a valid model response type. + /// In this case we check the `data.type` property for a valid model response type. if eventType == "message" || eventType.isEmpty, let payloadEventType = finalEvent.getPayloadType() { eventType = payloadEventType From 381c0f175a2c22d1df35ebff66760eadc8afcc3d Mon Sep 17 00:00:00 2001 From: "J.Conover" Date: Thu, 25 Sep 2025 13:25:12 -0400 Subject: [PATCH 7/7] Apply suggestion from @JaredConover --- .../Streaming/ModelResponseEventsStreamInterpreter.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/OpenAI/Private/Streaming/ModelResponseEventsStreamInterpreter.swift b/Sources/OpenAI/Private/Streaming/ModelResponseEventsStreamInterpreter.swift index 0ba61274..3b472b97 100644 --- a/Sources/OpenAI/Private/Streaming/ModelResponseEventsStreamInterpreter.swift +++ b/Sources/OpenAI/Private/Streaming/ModelResponseEventsStreamInterpreter.swift @@ -59,7 +59,7 @@ final class ModelResponseEventsStreamInterpreter: @unchecked Sendable, StreamInt var eventType = finalEvent.eventType /// If the SSE `event` property is not specified by the provider service, our parser defaults it to "message" which is not a valid model response type. - /// In this case we check the `data.type` property for a valid model response type. + /// In this case we check the `data.type` property for a valid model response type. if eventType == "message" || eventType.isEmpty, let payloadEventType = finalEvent.getPayloadType() { eventType = payloadEventType