From ad45fc681866654d0e0e0dea9bdf0872a07499b6 Mon Sep 17 00:00:00 2001 From: Sacha Servan-Schreiber <8027773+sachaservan@users.noreply.github.com> Date: Sat, 3 Jan 2026 20:15:58 +0100 Subject: [PATCH 1/9] feat: allow custom HTTP client injection like other openai sdks The official openai-go SDK (and others) supports custom HTTP client injection via option.WithHTTPClient(), enabling use cases like custom transports, proxies, and testing. This change brings the same capability to the Swift SDK. Changes: - Make URLSessionProtocol and related types public - Add public convenience init accepting customSession parameter This allows users to provide their own URLSessionProtocol implementation, similar to how openai-go allows custom http.Client injection. --- Sources/OpenAI/OpenAI.swift | 26 +++++++++++++++++++ .../Streaming/InvalidatableSession.swift | 2 +- .../OpenAI/Private/URLSessionCombine.swift | 6 ++--- .../Private/URLSessionDataTaskProtocol.swift | 4 +-- .../OpenAI/Private/URLSessionProtocol.swift | 4 +-- 5 files changed, 34 insertions(+), 8 deletions(-) diff --git a/Sources/OpenAI/OpenAI.swift b/Sources/OpenAI/OpenAI.swift index 4712fdda..6d1351b6 100644 --- a/Sources/OpenAI/OpenAI.swift +++ b/Sources/OpenAI/OpenAI.swift @@ -106,6 +106,32 @@ final public class OpenAI: OpenAIProtocol, @unchecked Sendable { ) } + /// Creates an OpenAI client with a custom URLSession protocol implementation. + /// Use this initializer to inject a custom HTTP transport for encryption or other purposes. + /// + /// - Parameters: + /// - configuration: The client configuration + /// - customSession: Custom URLSession protocol implementation + /// - middlewares: Optional middlewares for request/response interception + public convenience init( + configuration: Configuration, + customSession: any URLSessionProtocol, + middlewares: [OpenAIMiddleware] = [] + ) { + let streamingSessionFactory = ImplicitURLSessionStreamingSessionFactory( + middlewares: middlewares, + parsingOptions: configuration.parsingOptions, + sslDelegate: nil + ) + + self.init( + configuration: configuration, + session: customSession, + streamingSessionFactory: streamingSessionFactory, + middlewares: middlewares + ) + } + init( configuration: Configuration, session: URLSessionProtocol, diff --git a/Sources/OpenAI/Private/Streaming/InvalidatableSession.swift b/Sources/OpenAI/Private/Streaming/InvalidatableSession.swift index f262c69c..94e9ebf3 100644 --- a/Sources/OpenAI/Private/Streaming/InvalidatableSession.swift +++ b/Sources/OpenAI/Private/Streaming/InvalidatableSession.swift @@ -7,7 +7,7 @@ import Foundation -protocol InvalidatableSession: Sendable { +public protocol InvalidatableSession: Sendable { func invalidateAndCancel() func finishTasksAndInvalidate() } diff --git a/Sources/OpenAI/Private/URLSessionCombine.swift b/Sources/OpenAI/Private/URLSessionCombine.swift index e4c521a7..e4776bc5 100644 --- a/Sources/OpenAI/Private/URLSessionCombine.swift +++ b/Sources/OpenAI/Private/URLSessionCombine.swift @@ -14,19 +14,19 @@ import FoundationNetworking #if canImport(Combine) import Combine -protocol URLSessionCombine { +public protocol URLSessionCombine { func dataTaskPublisher(for request: URLRequest) -> AnyPublisher<(data: Data, response: URLResponse), URLError> } extension URLSession: URLSessionCombine { - func dataTaskPublisher(for request: URLRequest) -> AnyPublisher<(data: Data, response: URLResponse), URLError> { + public func dataTaskPublisher(for request: URLRequest) -> AnyPublisher<(data: Data, response: URLResponse), URLError> { let typedPublisher: URLSession.DataTaskPublisher = dataTaskPublisher(for: request) return typedPublisher.eraseToAnyPublisher() } } #else -protocol URLSessionCombine { +public protocol URLSessionCombine { } extension URLSession: URLSessionCombine {} diff --git a/Sources/OpenAI/Private/URLSessionDataTaskProtocol.swift b/Sources/OpenAI/Private/URLSessionDataTaskProtocol.swift index ab044231..a8416b8d 100644 --- a/Sources/OpenAI/Private/URLSessionDataTaskProtocol.swift +++ b/Sources/OpenAI/Private/URLSessionDataTaskProtocol.swift @@ -10,14 +10,14 @@ import Foundation import FoundationNetworking #endif -protocol URLSessionTaskProtocol: Sendable { +public protocol URLSessionTaskProtocol: Sendable { var originalRequest: URLRequest? { get } func cancel() } extension URLSessionTask: URLSessionTaskProtocol {} -protocol URLSessionDataTaskProtocol: URLSessionTaskProtocol { +public protocol URLSessionDataTaskProtocol: URLSessionTaskProtocol { func resume() } diff --git a/Sources/OpenAI/Private/URLSessionProtocol.swift b/Sources/OpenAI/Private/URLSessionProtocol.swift index cd4bccd7..396367f5 100644 --- a/Sources/OpenAI/Private/URLSessionProtocol.swift +++ b/Sources/OpenAI/Private/URLSessionProtocol.swift @@ -10,10 +10,10 @@ import Foundation import FoundationNetworking #endif -protocol URLSessionProtocol: InvalidatableSession, URLSessionCombine { +public protocol URLSessionProtocol: InvalidatableSession, URLSessionCombine { func dataTask(with request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTaskProtocol func dataTask(with request: URLRequest) -> URLSessionDataTaskProtocol - + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) func data(for request: URLRequest, delegate: (any URLSessionTaskDelegate)?) async throws -> (Data, URLResponse) } From 20779e1458bafc7925b761e7ded2c394ed11e18a Mon Sep 17 00:00:00 2001 From: Sacha Servan-Schreiber <8027773+sachaservan@users.noreply.github.com> Date: Sat, 3 Jan 2026 20:26:54 +0100 Subject: [PATCH 2/9] fix: add public modifier to extension methods --- Sources/OpenAI/Private/URLSessionProtocol.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/OpenAI/Private/URLSessionProtocol.swift b/Sources/OpenAI/Private/URLSessionProtocol.swift index 396367f5..a59506db 100644 --- a/Sources/OpenAI/Private/URLSessionProtocol.swift +++ b/Sources/OpenAI/Private/URLSessionProtocol.swift @@ -19,11 +19,11 @@ public protocol URLSessionProtocol: InvalidatableSession, URLSessionCombine { } extension URLSession: URLSessionProtocol { - func dataTask(with request: URLRequest) -> URLSessionDataTaskProtocol { + public func dataTask(with request: URLRequest) -> URLSessionDataTaskProtocol { dataTask(with: request) as URLSessionDataTask } - - func dataTask(with request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTaskProtocol { + + public func dataTask(with request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTaskProtocol { dataTask(with: request, completionHandler: completionHandler) as URLSessionDataTask } } From 52e0ffcbc9e1be2183640d0ab63b7c9d18612f2b Mon Sep 17 00:00:00 2001 From: Sacha Servan-Schreiber <8027773+sachaservan@users.noreply.github.com> Date: Mon, 5 Jan 2026 11:23:02 +0100 Subject: [PATCH 3/9] feat: add custom URLSessionFactory support for streaming requests Expose URLSessionFactory protocol publicly to allow injecting custom session factories for streaming requests. This enables custom HTTP transport implementations to intercept streaming data. Changes: - Make URLSessionFactory, URLSessionDelegateProtocol, and URLSessionDataDelegateProtocol public - Add AnyObject constraint to delegate protocols for weak references - Update ImplicitURLSessionStreamingSessionFactory to accept custom factory - Add new OpenAI initializer with streamingURLSessionFactory parameter --- Sources/OpenAI/OpenAI.swift | 32 ++++++++++++++++++- ...verSentEventsStreamingSessionFactory.swift | 22 +++++++++++-- .../Private/URLSessionDelegateProtocol.swift | 13 +++++--- .../OpenAI/Private/URLSessionFactory.swift | 14 ++++++-- 4 files changed, 70 insertions(+), 11 deletions(-) diff --git a/Sources/OpenAI/OpenAI.swift b/Sources/OpenAI/OpenAI.swift index 6d1351b6..47e13615 100644 --- a/Sources/OpenAI/OpenAI.swift +++ b/Sources/OpenAI/OpenAI.swift @@ -107,7 +107,9 @@ final public class OpenAI: OpenAIProtocol, @unchecked Sendable { } /// Creates an OpenAI client with a custom URLSession protocol implementation. - /// Use this initializer to inject a custom HTTP transport for encryption or other purposes. + /// + /// - Important: This initializer only uses the custom session for non-streaming requests. + /// For streaming requests, use the initializer that accepts a `URLSessionFactory`. /// /// - Parameters: /// - configuration: The client configuration @@ -132,6 +134,34 @@ final public class OpenAI: OpenAIProtocol, @unchecked Sendable { ) } + /// Creates an OpenAI client with custom session handling for both regular and streaming requests. + /// + /// - Parameters: + /// - configuration: The client configuration + /// - customSession: Custom URLSession protocol implementation for non-streaming requests + /// - streamingURLSessionFactory: Factory for creating sessions for streaming requests + /// - middlewares: Optional middlewares for request/response interception + public convenience init( + configuration: Configuration, + customSession: any URLSessionProtocol, + streamingURLSessionFactory: URLSessionFactory, + middlewares: [OpenAIMiddleware] = [] + ) { + let streamingSessionFactory = ImplicitURLSessionStreamingSessionFactory( + urlSessionFactory: streamingURLSessionFactory, + middlewares: middlewares, + parsingOptions: configuration.parsingOptions, + sslDelegate: nil + ) + + self.init( + configuration: configuration, + session: customSession, + streamingSessionFactory: streamingSessionFactory, + middlewares: middlewares + ) + } + init( configuration: Configuration, session: URLSessionProtocol, diff --git a/Sources/OpenAI/Private/Streaming/ServerSentEventsStreamingSessionFactory.swift b/Sources/OpenAI/Private/Streaming/ServerSentEventsStreamingSessionFactory.swift index 21fb2b14..ed67596d 100644 --- a/Sources/OpenAI/Private/Streaming/ServerSentEventsStreamingSessionFactory.swift +++ b/Sources/OpenAI/Private/Streaming/ServerSentEventsStreamingSessionFactory.swift @@ -35,10 +35,23 @@ protocol StreamingSessionFactory: Sendable { } struct ImplicitURLSessionStreamingSessionFactory: StreamingSessionFactory { + let urlSessionFactory: URLSessionFactory let middlewares: [OpenAIMiddleware] let parsingOptions: ParsingOptions let sslDelegate: SSLDelegateProtocol? - + + init( + urlSessionFactory: URLSessionFactory = FoundationURLSessionFactory(), + middlewares: [OpenAIMiddleware], + parsingOptions: ParsingOptions, + sslDelegate: SSLDelegateProtocol? + ) { + self.urlSessionFactory = urlSessionFactory + self.middlewares = middlewares + self.parsingOptions = parsingOptions + self.sslDelegate = sslDelegate + } + func makeServerSentEventsStreamingSession( urlRequest: URLRequest, onReceiveContent: @Sendable @escaping (StreamingSession>, ResultType) -> Void, @@ -46,6 +59,7 @@ struct ImplicitURLSessionStreamingSessionFactory: StreamingSessionFactory { onComplete: @Sendable @escaping (StreamingSession>, (any Error)?) -> Void ) -> StreamingSession> where ResultType : Decodable, ResultType : Encodable, ResultType : Sendable { .init( + urlSessionFactory: urlSessionFactory, urlRequest: urlRequest, interpreter: .init(parsingOptions: parsingOptions), sslDelegate: sslDelegate, @@ -55,7 +69,7 @@ struct ImplicitURLSessionStreamingSessionFactory: StreamingSessionFactory { onComplete: onComplete ) } - + func makeAudioSpeechStreamingSession( urlRequest: URLRequest, onReceiveContent: @Sendable @escaping (StreamingSession, AudioSpeechResult) -> Void, @@ -63,6 +77,7 @@ struct ImplicitURLSessionStreamingSessionFactory: StreamingSessionFactory { onComplete: @Sendable @escaping (StreamingSession, (any Error)?) -> Void ) -> StreamingSession { .init( + urlSessionFactory: urlSessionFactory, urlRequest: urlRequest, interpreter: .init(), sslDelegate: sslDelegate, @@ -72,7 +87,7 @@ struct ImplicitURLSessionStreamingSessionFactory: StreamingSessionFactory { onComplete: onComplete ) } - + func makeModelResponseStreamingSession( urlRequest: URLRequest, onReceiveContent: @Sendable @escaping (StreamingSession, ResponseStreamEvent) -> Void, @@ -80,6 +95,7 @@ struct ImplicitURLSessionStreamingSessionFactory: StreamingSessionFactory { onComplete: @Sendable @escaping (StreamingSession, (any Error)?) -> Void ) -> StreamingSession { .init( + urlSessionFactory: urlSessionFactory, urlRequest: urlRequest, interpreter: .init(), sslDelegate: sslDelegate, diff --git a/Sources/OpenAI/Private/URLSessionDelegateProtocol.swift b/Sources/OpenAI/Private/URLSessionDelegateProtocol.swift index f85fc619..4f743d07 100644 --- a/Sources/OpenAI/Private/URLSessionDelegateProtocol.swift +++ b/Sources/OpenAI/Private/URLSessionDelegateProtocol.swift @@ -11,9 +11,12 @@ import Foundation import FoundationNetworking #endif -protocol URLSessionDelegateProtocol: Sendable { // Sendable to make a better match with URLSessionDelegate, it's sendable too +/// Protocol for handling URLSession delegate callbacks. +/// Sendable to match URLSessionDelegate behavior. +/// AnyObject constraint allows weak references to delegate implementations. +public protocol URLSessionDelegateProtocol: AnyObject, Sendable { func urlSession(_ session: URLSessionProtocol, task: URLSessionTaskProtocol, didCompleteWithError error: Error?) - + func urlSession( _ session: URLSession, didReceive challenge: URLAuthenticationChallenge, @@ -21,9 +24,11 @@ protocol URLSessionDelegateProtocol: Sendable { // Sendable to make a better mat ) } -protocol URLSessionDataDelegateProtocol: URLSessionDelegateProtocol { +/// Protocol for handling URLSession data delegate callbacks. +/// Used for streaming data reception. +public protocol URLSessionDataDelegateProtocol: URLSessionDelegateProtocol { func urlSession(_ session: URLSessionProtocol, dataTask: URLSessionDataTaskProtocol, didReceive data: Data) - + func urlSession( _ session: URLSessionProtocol, dataTask: URLSessionDataTaskProtocol, diff --git a/Sources/OpenAI/Private/URLSessionFactory.swift b/Sources/OpenAI/Private/URLSessionFactory.swift index 7b99769b..3baba037 100644 --- a/Sources/OpenAI/Private/URLSessionFactory.swift +++ b/Sources/OpenAI/Private/URLSessionFactory.swift @@ -10,12 +10,20 @@ import Foundation import FoundationNetworking #endif -protocol URLSessionFactory: Sendable { +/// Factory protocol for creating URLSession instances. +/// Implement this protocol to provide custom session creation for streaming requests. +public protocol URLSessionFactory: Sendable { + /// Creates a URLSession for streaming requests. + /// - Parameter delegate: The delegate to receive streaming data callbacks + /// - Returns: A URLSession protocol implementation func makeUrlSession(delegate: URLSessionDataDelegateProtocol) -> URLSessionProtocol } -struct FoundationURLSessionFactory: URLSessionFactory { - func makeUrlSession(delegate: URLSessionDataDelegateProtocol) -> any URLSessionProtocol { +/// Default factory that creates standard Foundation URLSession instances. +public struct FoundationURLSessionFactory: URLSessionFactory { + public init() {} + + public func makeUrlSession(delegate: URLSessionDataDelegateProtocol) -> any URLSessionProtocol { let forwarder = URLSessionDataDelegateForwarder(target: delegate) return URLSession(configuration: .default, delegate: forwarder, delegateQueue: nil) } From fe1de5532bb157a56ef64617b72d1691a09c44d1 Mon Sep 17 00:00:00 2001 From: Sacha Servan-Schreiber <8027773+sachaservan@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:26:36 -0500 Subject: [PATCH 4/9] feat: add web search event model --- .../ServerSentEventsStreamInterpreter.swift | 9 ++++- .../Public/Models/Types/WebSearchEvent.swift | 38 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 Sources/OpenAI/Public/Models/Types/WebSearchEvent.swift diff --git a/Sources/OpenAI/Private/Streaming/ServerSentEventsStreamInterpreter.swift b/Sources/OpenAI/Private/Streaming/ServerSentEventsStreamInterpreter.swift index 72b2e4ef..41d880ea 100644 --- a/Sources/OpenAI/Private/Streaming/ServerSentEventsStreamInterpreter.swift +++ b/Sources/OpenAI/Private/Streaming/ServerSentEventsStreamInterpreter.swift @@ -66,7 +66,14 @@ final class ServerSentEventsStreamInterpreter : onError?(StreamingError.unknownContent) return } - + + // Skip web search intermediate events (they have "type" field instead of "object") + // These are events like web_search_call that describe search progress + if let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any], + json["type"] != nil && json["object"] == nil { + return + } + let decoder = JSONResponseDecoder(parsingOptions: parsingOptions) do { let object: ResultType = try decoder.decodeResponseData(jsonData) diff --git a/Sources/OpenAI/Public/Models/Types/WebSearchEvent.swift b/Sources/OpenAI/Public/Models/Types/WebSearchEvent.swift new file mode 100644 index 00000000..036123a4 --- /dev/null +++ b/Sources/OpenAI/Public/Models/Types/WebSearchEvent.swift @@ -0,0 +1,38 @@ +// +// WebSearchEvent.swift +// OpenAI +// +// Created on 01/02/2026. +// + +import Foundation + +/// Represents a web search event during streaming. +/// These events indicate the status of web search operations triggered by the model. +public struct WebSearchEvent: Codable, Equatable, Sendable { + /// The type of event, typically "web_search_call" + public let type: String + + /// The status of the web search operation + public let status: Status + + /// The action being performed (contains the search query) + public let action: Action? + + /// Reason for blocked or failed searches + public let reason: String? + + /// Possible statuses for a web search event + public enum Status: String, Codable, Sendable { + case inProgress = "in_progress" + case completed + case failed + case blocked + } + + /// The action associated with the web search + public struct Action: Codable, Equatable, Sendable { + /// The search query being executed + public let query: String? + } +} From e83655790d67713d28bcdeb33c351cc5b9cb0c0e Mon Sep 17 00:00:00 2001 From: Sacha Servan-Schreiber <8027773+sachaservan@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:27:57 -0500 Subject: [PATCH 5/9] feat: add citation annotation support to streaming Add Annotation struct to ChoiceDelta for parsing URL citations from web search results. Includes URLCitation with url, title, and index fields. --- .../Public/Models/ChatStreamResult.swift | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/Sources/OpenAI/Public/Models/ChatStreamResult.swift b/Sources/OpenAI/Public/Models/ChatStreamResult.swift index c1f30216..8ba132c3 100644 --- a/Sources/OpenAI/Public/Models/ChatStreamResult.swift +++ b/Sources/OpenAI/Public/Models/ChatStreamResult.swift @@ -26,6 +26,9 @@ public struct ChatStreamResult: Codable, Equatable, Sendable { public let role: Self.Role? public let toolCalls: [Self.ChoiceDeltaToolCall]? + /// URL citation annotations from web search results + public let annotations: [Self.Annotation]? + /// Value for `reasoning` field in response. /// /// Provided by: @@ -107,11 +110,44 @@ public struct ChatStreamResult: Codable, Equatable, Sendable { } } + /// An annotation containing citation information from web search + public struct Annotation: Codable, Equatable, Sendable { + /// The type of annotation (e.g., "url_citation") + public let type: String + /// URL citation details + public let urlCitation: URLCitation? + + /// URL citation information from web search results + public struct URLCitation: Codable, Equatable, Sendable { + /// The URL of the cited source + public let url: String + /// The title of the cited source + public let title: String? + /// Start index in the content where this citation applies + public let startIndex: Int? + /// End index in the content where this citation applies + public let endIndex: Int? + + public enum CodingKeys: String, CodingKey { + case url + case title + case startIndex = "start_index" + case endIndex = "end_index" + } + } + + public enum CodingKeys: String, CodingKey { + case type + case urlCitation = "url_citation" + } + } + public enum CodingKeys: String, CodingKey { case content case audio case role case toolCalls = "tool_calls" + case annotations case _reasoning = "reasoning" case _reasoningContent = "reasoning_content" } From 06ceae38042c6f6511bf9792b5fa2f85cb98ef1c Mon Sep 17 00:00:00 2001 From: Sacha Servan-Schreiber <8027773+sachaservan@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:00:41 -0500 Subject: [PATCH 6/9] feat: surface web search events during streaming Add onWebSearchEvent callback through the streaming infrastructure to expose web_search_call events from SSE streams. Includes new chatsStream overload accepting the callback parameter. --- Sources/OpenAI/OpenAI+OpenAIAsync.swift | 11 +++++++- Sources/OpenAI/OpenAI.swift | 13 ++++++++- .../Private/Client/StreamingClient.swift | 6 ++-- .../ServerSentEventsStreamInterpreter.swift | 17 ++++++++--- ...verSentEventsStreamingSessionFactory.swift | 3 ++ .../Private/Streaming/StreamingSession.swift | 28 +++++++++++++++---- .../OpenAI/Public/Protocols/OpenAIAsync.swift | 1 + .../Public/Protocols/OpenAIProtocol.swift | 4 ++- .../Mocks/MockStreamingSessionFactory.swift | 2 ++ 9 files changed, 70 insertions(+), 15 deletions(-) diff --git a/Sources/OpenAI/OpenAI+OpenAIAsync.swift b/Sources/OpenAI/OpenAI+OpenAIAsync.swift index f4d47b94..7003e0d1 100644 --- a/Sources/OpenAI/OpenAI+OpenAIAsync.swift +++ b/Sources/OpenAI/OpenAI+OpenAIAsync.swift @@ -43,7 +43,16 @@ extension OpenAI: OpenAIAsync { chatsStream(query: query, onResult: onResult, completion: completion) } } - + + public func chatsStream( + query: ChatQuery, + onWebSearchEvent: @escaping @Sendable (WebSearchEvent) -> Void + ) -> AsyncThrowingStream { + makeAsyncStream { onResult, completion in + chatsStream(query: query, onResult: onResult, onWebSearchEvent: onWebSearchEvent, completion: completion) + } + } + public func model(query: ModelQuery) async throws -> ModelResult { try await performRequestAsync( request: makeModelRequest(query: query) diff --git a/Sources/OpenAI/OpenAI.swift b/Sources/OpenAI/OpenAI.swift index 47e13615..ccfb7371 100644 --- a/Sources/OpenAI/OpenAI.swift +++ b/Sources/OpenAI/OpenAI.swift @@ -340,9 +340,19 @@ final public class OpenAI: OpenAIProtocol, @unchecked Sendable { } public func chatsStream(query: ChatQuery, onResult: @escaping @Sendable (Result) -> Void, completion: (@Sendable (Error?) -> Void)?) -> CancellableRequest { + chatsStream(query: query, onResult: onResult, onWebSearchEvent: nil, completion: completion) + } + + public func chatsStream( + query: ChatQuery, + onResult: @escaping @Sendable (Result) -> Void, + onWebSearchEvent: (@Sendable (WebSearchEvent) -> Void)?, + completion: (@Sendable (Error?) -> Void)? + ) -> CancellableRequest { performStreamingRequest( request: JSONRequest(body: query.makeStreamable(), url: buildURL(path: .chats)), onResult: onResult, + onWebSearchEvent: onWebSearchEvent, completion: completion ) } @@ -411,9 +421,10 @@ extension OpenAI { func performStreamingRequest( request: any URLRequestBuildable, onResult: @escaping @Sendable (Result) -> Void, + onWebSearchEvent: (@Sendable (WebSearchEvent) -> Void)? = nil, completion: (@Sendable (Error?) -> Void)? ) -> CancellableRequest { - streamingClient.performStreamingRequest(request: request, onResult: onResult, completion: completion) + streamingClient.performStreamingRequest(request: request, onResult: onResult, onWebSearchEvent: onWebSearchEvent, completion: completion) } func performSpeechRequest(request: any URLRequestBuildable, completion: @escaping @Sendable (Result) -> Void) -> CancellableRequest { diff --git a/Sources/OpenAI/Private/Client/StreamingClient.swift b/Sources/OpenAI/Private/Client/StreamingClient.swift index e545a586..1178e023 100644 --- a/Sources/OpenAI/Private/Client/StreamingClient.swift +++ b/Sources/OpenAI/Private/Client/StreamingClient.swift @@ -32,6 +32,7 @@ final class StreamingClient: @unchecked Sendable { func performStreamingRequest( request: any URLRequestBuildable, onResult: @escaping @Sendable (Result) -> Void, + onWebSearchEvent: (@Sendable (WebSearchEvent) -> Void)? = nil, completion: (@Sendable (Error?) -> Void)? ) -> CancellableRequest { do { @@ -44,13 +45,14 @@ final class StreamingClient: @unchecked Sendable { urlRequest: interceptedRequest ) { _, object in onResult(.success(object)) - } onProcessingError: { _, error in + } onWebSearchEvent: onWebSearchEvent + onProcessingError: { _, error in onResult(.failure(error)) } onComplete: { [weak self] session, error in completion?(error) self?.invalidateSession(session) } - + return runSession(session) } catch { completion?(error) diff --git a/Sources/OpenAI/Private/Streaming/ServerSentEventsStreamInterpreter.swift b/Sources/OpenAI/Private/Streaming/ServerSentEventsStreamInterpreter.swift index 41d880ea..f6b1e295 100644 --- a/Sources/OpenAI/Private/Streaming/ServerSentEventsStreamInterpreter.swift +++ b/Sources/OpenAI/Private/Streaming/ServerSentEventsStreamInterpreter.swift @@ -17,6 +17,7 @@ final class ServerSentEventsStreamInterpreter : private var previousChunkBuffer = "" private var onEventDispatched: ((ResultType) -> Void)? + private var onWebSearchEvent: ((WebSearchEvent) -> Void)? private var onError: ((Error) -> Void)? private let parsingOptions: ParsingOptions @@ -38,9 +39,15 @@ final class ServerSentEventsStreamInterpreter : /// /// - Parameters: /// - onEventDispatched: Can be called multiple times per `processData` + /// - onWebSearchEvent: Called when a web search event is received (optional) /// - onError: Will only be called once per `processData` - func setCallbackClosures(onEventDispatched: @escaping @Sendable (ResultType) -> Void, onError: @escaping @Sendable (Error) -> Void) { + func setCallbackClosures( + onEventDispatched: @escaping @Sendable (ResultType) -> Void, + onWebSearchEvent: (@Sendable (WebSearchEvent) -> Void)? = nil, + onError: @escaping @Sendable (Error) -> Void + ) { self.onEventDispatched = onEventDispatched + self.onWebSearchEvent = onWebSearchEvent self.onError = onError } @@ -67,10 +74,12 @@ final class ServerSentEventsStreamInterpreter : return } - // Skip web search intermediate events (they have "type" field instead of "object") - // These are events like web_search_call that describe search progress + // Handle web search events (they have "type" field instead of "object") if let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any], - json["type"] != nil && json["object"] == nil { + json["type"] as? String == "web_search_call" { + if let event = try? JSONDecoder().decode(WebSearchEvent.self, from: jsonData) { + onWebSearchEvent?(event) + } return } diff --git a/Sources/OpenAI/Private/Streaming/ServerSentEventsStreamingSessionFactory.swift b/Sources/OpenAI/Private/Streaming/ServerSentEventsStreamingSessionFactory.swift index ed67596d..25616010 100644 --- a/Sources/OpenAI/Private/Streaming/ServerSentEventsStreamingSessionFactory.swift +++ b/Sources/OpenAI/Private/Streaming/ServerSentEventsStreamingSessionFactory.swift @@ -15,6 +15,7 @@ protocol StreamingSessionFactory: Sendable { func makeServerSentEventsStreamingSession( urlRequest: URLRequest, onReceiveContent: @Sendable @escaping (StreamingSession>, ResultType) -> Void, + onWebSearchEvent: (@Sendable (WebSearchEvent) -> Void)?, onProcessingError: @Sendable @escaping (StreamingSession>, Error) -> Void, onComplete: @Sendable @escaping (StreamingSession>, Error?) -> Void ) -> StreamingSession> @@ -55,6 +56,7 @@ struct ImplicitURLSessionStreamingSessionFactory: StreamingSessionFactory { func makeServerSentEventsStreamingSession( urlRequest: URLRequest, onReceiveContent: @Sendable @escaping (StreamingSession>, ResultType) -> Void, + onWebSearchEvent: (@Sendable (WebSearchEvent) -> Void)?, onProcessingError: @Sendable @escaping (StreamingSession>, any Error) -> Void, onComplete: @Sendable @escaping (StreamingSession>, (any Error)?) -> Void ) -> StreamingSession> where ResultType : Decodable, ResultType : Encodable, ResultType : Sendable { @@ -65,6 +67,7 @@ struct ImplicitURLSessionStreamingSessionFactory: StreamingSessionFactory { sslDelegate: sslDelegate, middlewares: middlewares, onReceiveContent: onReceiveContent, + onWebSearchEvent: onWebSearchEvent, onProcessingError: onProcessingError, onComplete: onComplete ) diff --git a/Sources/OpenAI/Private/Streaming/StreamingSession.swift b/Sources/OpenAI/Private/Streaming/StreamingSession.swift index b718db6b..fe162c75 100644 --- a/Sources/OpenAI/Private/Streaming/StreamingSession.swift +++ b/Sources/OpenAI/Private/Streaming/StreamingSession.swift @@ -21,6 +21,7 @@ final class StreamingSession: NSObject, Identifi private let middlewares: [OpenAIMiddleware] private let executionSerializer: ExecutionSerializer private let onReceiveContent: (@Sendable (StreamingSession, ResultType) -> Void)? + private let onWebSearchEvent: (@Sendable (WebSearchEvent) -> Void)? private let onProcessingError: (@Sendable (StreamingSession, Error) -> Void)? private let onComplete: (@Sendable (StreamingSession, Error?) -> Void)? @@ -32,6 +33,7 @@ final class StreamingSession: NSObject, Identifi middlewares: [OpenAIMiddleware], executionSerializer: ExecutionSerializer = GCDQueueAsyncExecutionSerializer(queue: .userInitiated), onReceiveContent: @escaping @Sendable (StreamingSession, ResultType) -> Void, + onWebSearchEvent: (@Sendable (WebSearchEvent) -> Void)? = nil, onProcessingError: @escaping @Sendable (StreamingSession, Error) -> Void, onComplete: @escaping @Sendable (StreamingSession, Error?) -> Void ) { @@ -42,6 +44,7 @@ final class StreamingSession: NSObject, Identifi self.middlewares = middlewares self.executionSerializer = executionSerializer self.onReceiveContent = onReceiveContent + self.onWebSearchEvent = onWebSearchEvent self.onProcessingError = onProcessingError self.onComplete = onComplete super.init() @@ -96,12 +99,25 @@ final class StreamingSession: NSObject, Identifi } private func subscribeToParser() { - interpreter.setCallbackClosures { [weak self] content in - guard let self else { return } - self.onReceiveContent?(self, content) - } onError: { [weak self] error in - guard let self else { return } - self.onProcessingError?(self, error) + // Check if interpreter supports web search events (ServerSentEventsStreamInterpreter) + if let sseInterpreter = interpreter as? ServerSentEventsStreamInterpreter { + sseInterpreter.setCallbackClosures { [weak self] content in + guard let self else { return } + self.onReceiveContent?(self, content) + } onWebSearchEvent: { [weak self] event in + self?.onWebSearchEvent?(event) + } onError: { [weak self] error in + guard let self else { return } + self.onProcessingError?(self, error) + } + } else { + interpreter.setCallbackClosures { [weak self] content in + guard let self else { return } + self.onReceiveContent?(self, content) + } onError: { [weak self] error in + guard let self else { return } + self.onProcessingError?(self, error) + } } } } diff --git a/Sources/OpenAI/Public/Protocols/OpenAIAsync.swift b/Sources/OpenAI/Public/Protocols/OpenAIAsync.swift index 8a6a2431..fa5246af 100644 --- a/Sources/OpenAI/Public/Protocols/OpenAIAsync.swift +++ b/Sources/OpenAI/Public/Protocols/OpenAIAsync.swift @@ -14,6 +14,7 @@ public protocol OpenAIAsync: Sendable { func embeddings(query: EmbeddingsQuery) async throws -> EmbeddingsResult func chats(query: ChatQuery) async throws -> ChatResult func chatsStream(query: ChatQuery) -> AsyncThrowingStream + func chatsStream(query: ChatQuery, onWebSearchEvent: @escaping @Sendable (WebSearchEvent) -> Void) -> AsyncThrowingStream func model(query: ModelQuery) async throws -> ModelResult func models() async throws -> ModelsResult func moderations(query: ModerationsQuery) async throws -> ModerationsResult diff --git a/Sources/OpenAI/Public/Protocols/OpenAIProtocol.swift b/Sources/OpenAI/Public/Protocols/OpenAIProtocol.swift index dba875b5..f4c14093 100644 --- a/Sources/OpenAI/Public/Protocols/OpenAIProtocol.swift +++ b/Sources/OpenAI/Public/Protocols/OpenAIProtocol.swift @@ -124,7 +124,9 @@ public protocol OpenAIProtocol: OpenAIModern { - Note: This method creates and configures separate session object specifically for streaming. In order for it to work properly and don't leak memory you should hold a reference to the returned value, and when you're done - call cancel() on it. */ @discardableResult func chatsStream(query: ChatQuery, onResult: @escaping @Sendable (Result) -> Void, completion: (@Sendable (Error?) -> Void)?) -> CancellableRequest - + + @discardableResult func chatsStream(query: ChatQuery, onResult: @escaping @Sendable (Result) -> Void, onWebSearchEvent: (@Sendable (WebSearchEvent) -> Void)?, completion: (@Sendable (Error?) -> Void)?) -> CancellableRequest + /** This function sends a model query to the OpenAI API and retrieves a model instance, providing owner information. The Models API in this usage enables you to gather detailed information on the model in question, like GPT-3. diff --git a/Tests/OpenAITests/Mocks/MockStreamingSessionFactory.swift b/Tests/OpenAITests/Mocks/MockStreamingSessionFactory.swift index b7861397..3023abd9 100644 --- a/Tests/OpenAITests/Mocks/MockStreamingSessionFactory.swift +++ b/Tests/OpenAITests/Mocks/MockStreamingSessionFactory.swift @@ -19,6 +19,7 @@ class MockStreamingSessionFactory: StreamingSessionFactory, @unchecked Sendable func makeServerSentEventsStreamingSession( urlRequest: URLRequest, onReceiveContent: @Sendable @escaping (StreamingSession>, ResultType) -> Void, + onWebSearchEvent: (@Sendable (WebSearchEvent) -> Void)?, onProcessingError: @Sendable @escaping (StreamingSession>, any Error) -> Void, onComplete: @Sendable @escaping (StreamingSession>, (any Error)?) -> Void ) -> StreamingSession> where ResultType : Decodable, ResultType : Encodable, ResultType : Sendable { @@ -30,6 +31,7 @@ class MockStreamingSessionFactory: StreamingSessionFactory, @unchecked Sendable middlewares: [], executionSerializer: executionSerializer, onReceiveContent: onReceiveContent, + onWebSearchEvent: onWebSearchEvent, onProcessingError: onProcessingError, onComplete: onComplete ) From 741d5965d3daa7e00da8407f523733a1003ce366 Mon Sep 17 00:00:00 2001 From: Sacha Servan-Schreiber <8027773+sachaservan@users.noreply.github.com> Date: Sun, 1 Feb 2026 21:06:25 -0500 Subject: [PATCH 7/9] fix: web search event handling and add tests --- .../Private/Client/StreamingClient.swift | 23 ++-- .../ServerSentEventsStreamInterpreter.swift | 25 +++- .../Public/Models/Types/WebSearchEvent.swift | 18 +++ ...rverSentEventsStreamInterpreterTests.swift | 115 +++++++++++++++++- 4 files changed, 163 insertions(+), 18 deletions(-) diff --git a/Sources/OpenAI/Private/Client/StreamingClient.swift b/Sources/OpenAI/Private/Client/StreamingClient.swift index 1178e023..b127a0b8 100644 --- a/Sources/OpenAI/Private/Client/StreamingClient.swift +++ b/Sources/OpenAI/Private/Client/StreamingClient.swift @@ -42,16 +42,19 @@ final class StreamingClient: @unchecked Sendable { } let session = streamingSessionFactory.makeServerSentEventsStreamingSession( - urlRequest: interceptedRequest - ) { _, object in - onResult(.success(object)) - } onWebSearchEvent: onWebSearchEvent - onProcessingError: { _, error in - onResult(.failure(error)) - } onComplete: { [weak self] session, error in - completion?(error) - self?.invalidateSession(session) - } + urlRequest: interceptedRequest, + onReceiveContent: { _, object in + onResult(.success(object)) + }, + onWebSearchEvent: onWebSearchEvent, + onProcessingError: { _, error in + onResult(.failure(error)) + }, + onComplete: { [weak self] session, error in + completion?(error) + self?.invalidateSession(session) + } + ) return runSession(session) } catch { diff --git a/Sources/OpenAI/Private/Streaming/ServerSentEventsStreamInterpreter.swift b/Sources/OpenAI/Private/Streaming/ServerSentEventsStreamInterpreter.swift index f6b1e295..ef354bc2 100644 --- a/Sources/OpenAI/Private/Streaming/ServerSentEventsStreamInterpreter.swift +++ b/Sources/OpenAI/Private/Streaming/ServerSentEventsStreamInterpreter.swift @@ -35,6 +35,18 @@ final class ServerSentEventsStreamInterpreter : } } + /// Sets closures an instance of type. Not thread safe. + /// + /// - Parameters: + /// - onEventDispatched: Can be called multiple times per `processData` + /// - onError: Will only be called once per `processData` + func setCallbackClosures( + onEventDispatched: @escaping @Sendable (ResultType) -> Void, + onError: @escaping @Sendable (Error) -> Void + ) { + setCallbackClosures(onEventDispatched: onEventDispatched, onWebSearchEvent: nil, onError: onError) + } + /// Sets closures an instance of type. Not thread safe. /// /// - Parameters: @@ -43,7 +55,7 @@ final class ServerSentEventsStreamInterpreter : /// - onError: Will only be called once per `processData` func setCallbackClosures( onEventDispatched: @escaping @Sendable (ResultType) -> Void, - onWebSearchEvent: (@Sendable (WebSearchEvent) -> Void)? = nil, + onWebSearchEvent: (@Sendable (WebSearchEvent) -> Void)?, onError: @escaping @Sendable (Error) -> Void ) { self.onEventDispatched = onEventDispatched @@ -75,10 +87,15 @@ final class ServerSentEventsStreamInterpreter : } // Handle web search events (they have "type" field instead of "object") + // Event types include: "web_search_call", or prefixed like "response.web_search_call.*" if let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any], - json["type"] as? String == "web_search_call" { - if let event = try? JSONDecoder().decode(WebSearchEvent.self, from: jsonData) { - onWebSearchEvent?(event) + let eventType = json["type"] as? String, + eventType.contains("web_search") { + do { + let webSearchEvent = try JSONDecoder().decode(WebSearchEvent.self, from: jsonData) + onWebSearchEvent?(webSearchEvent) + } catch { + onError?(error) } return } diff --git a/Sources/OpenAI/Public/Models/Types/WebSearchEvent.swift b/Sources/OpenAI/Public/Models/Types/WebSearchEvent.swift index 036123a4..f2c5e6f7 100644 --- a/Sources/OpenAI/Public/Models/Types/WebSearchEvent.swift +++ b/Sources/OpenAI/Public/Models/Types/WebSearchEvent.swift @@ -13,6 +13,12 @@ public struct WebSearchEvent: Codable, Equatable, Sendable { /// The type of event, typically "web_search_call" public let type: String + /// Unique ID for the output item associated with the web search call + public let itemId: String? + + /// The index of the output item that the web search call is associated with + public let outputIndex: Int? + /// The status of the web search operation public let status: Status @@ -25,6 +31,7 @@ public struct WebSearchEvent: Codable, Equatable, Sendable { /// Possible statuses for a web search event public enum Status: String, Codable, Sendable { case inProgress = "in_progress" + case searching case completed case failed case blocked @@ -32,7 +39,18 @@ public struct WebSearchEvent: Codable, Equatable, Sendable { /// The action associated with the web search public struct Action: Codable, Equatable, Sendable { + /// The type of action: "search", "open_page", or "find_in_page" + public let type: String? /// The search query being executed public let query: String? } + + private enum CodingKeys: String, CodingKey { + case type + case itemId = "item_id" + case outputIndex = "output_index" + case status + case action + case reason + } } diff --git a/Tests/OpenAITests/ServerSentEventsStreamInterpreterTests.swift b/Tests/OpenAITests/ServerSentEventsStreamInterpreterTests.swift index 464ff0fa..16e99564 100644 --- a/Tests/OpenAITests/ServerSentEventsStreamInterpreterTests.swift +++ b/Tests/OpenAITests/ServerSentEventsStreamInterpreterTests.swift @@ -66,7 +66,7 @@ struct ServerSentEventsStreamInterpreterTests { @Test func parseApiError() async throws { var error: Error! - + await withCheckedContinuation { continuation in interpreter.setCallbackClosures { result in } onError: { apiError in @@ -77,13 +77,108 @@ struct ServerSentEventsStreamInterpreterTests { } } } - + interpreter.processData(chatCompletionError()) } - + #expect(error is APIErrorResponse) } - + + @Test func parseWebSearchEvent() async throws { + var webSearchEvents: [WebSearchEvent] = [] + + await withCheckedContinuation { continuation in + interpreter.setCallbackClosures { _ in + } onWebSearchEvent: { event in + Task { + await MainActor.run { + webSearchEvents.append(event) + continuation.resume() + } + } + } onError: { _ in + } + + interpreter.processData(webSearchEventData()) + } + + #expect(webSearchEvents.count == 1) + #expect(webSearchEvents.first?.type == "web_search_call") + #expect(webSearchEvents.first?.status == .inProgress) + #expect(webSearchEvents.first?.action?.query == "latest news") + } + + @Test func parseWebSearchEventCompleted() async throws { + var webSearchEvents: [WebSearchEvent] = [] + + await withCheckedContinuation { continuation in + interpreter.setCallbackClosures { _ in + } onWebSearchEvent: { event in + Task { + await MainActor.run { + webSearchEvents.append(event) + continuation.resume() + } + } + } onError: { _ in + } + + interpreter.processData(webSearchEventCompletedData()) + } + + #expect(webSearchEvents.count == 1) + #expect(webSearchEvents.first?.status == .completed) + } + + @Test func webSearchEventDoesNotTriggerOnResult() async throws { + var chatStreamResults: [ChatStreamResult] = [] + var webSearchEvents: [WebSearchEvent] = [] + + await withCheckedContinuation { continuation in + interpreter.setCallbackClosures { result in + Task { + await MainActor.run { + chatStreamResults.append(result) + } + } + } onWebSearchEvent: { event in + Task { + await MainActor.run { + webSearchEvents.append(event) + continuation.resume() + } + } + } onError: { _ in + } + + interpreter.processData(webSearchEventData()) + } + + #expect(chatStreamResults.isEmpty) + #expect(webSearchEvents.count == 1) + } + + @Test func invalidWebSearchEventReportsError() async throws { + var receivedError: Error? + + await withCheckedContinuation { continuation in + interpreter.setCallbackClosures { _ in + } onWebSearchEvent: { _ in + } onError: { error in + Task { + await MainActor.run { + receivedError = error + continuation.resume() + } + } + } + + interpreter.processData(invalidWebSearchEventData()) + } + + #expect(receivedError != nil) + } + private func chatCompletionChunk() -> Data { MockServerSentEvent.chatCompletionChunk() } @@ -100,6 +195,18 @@ struct ServerSentEventsStreamInterpreterTests { private func chatCompletionError() -> Data { MockServerSentEvent.chatCompletionError() } + + private func webSearchEventData() -> Data { + "data: {\"type\":\"web_search_call\",\"item_id\":\"ws_123\",\"output_index\":0,\"status\":\"in_progress\",\"action\":{\"type\":\"search\",\"query\":\"latest news\"}}\n\n".data(using: .utf8)! + } + + private func webSearchEventCompletedData() -> Data { + "data: {\"type\":\"web_search_call\",\"item_id\":\"ws_123\",\"output_index\":0,\"status\":\"completed\"}\n\n".data(using: .utf8)! + } + + private func invalidWebSearchEventData() -> Data { + "data: {\"type\":\"web_search_call\",\"status\":\"unknown_status\"}\n\n".data(using: .utf8)! + } } private actor ChatStreamResultsActor { From 5fa6df52209fb168b3663e3160b6e3ab3085bcff Mon Sep 17 00:00:00 2001 From: Sacha Servan-Schreiber <8027773+sachaservan@users.noreply.github.com> Date: Sun, 1 Feb 2026 21:38:25 -0500 Subject: [PATCH 8/9] fix: hanging test --- ...rverSentEventsStreamInterpreterTests.swift | 95 +++++++++++++------ 1 file changed, 64 insertions(+), 31 deletions(-) diff --git a/Tests/OpenAITests/ServerSentEventsStreamInterpreterTests.swift b/Tests/OpenAITests/ServerSentEventsStreamInterpreterTests.swift index 16e99564..dcd514c5 100644 --- a/Tests/OpenAITests/ServerSentEventsStreamInterpreterTests.swift +++ b/Tests/OpenAITests/ServerSentEventsStreamInterpreterTests.swift @@ -86,22 +86,32 @@ struct ServerSentEventsStreamInterpreterTests { @Test func parseWebSearchEvent() async throws { var webSearchEvents: [WebSearchEvent] = [] + var chatResults: [ChatStreamResult] = [] + var errors: [Error] = [] await withCheckedContinuation { continuation in - interpreter.setCallbackClosures { _ in + interpreter.setCallbackClosures { result in + Task { @MainActor in + chatResults.append(result) + continuation.resume() + } } onWebSearchEvent: { event in - Task { - await MainActor.run { - webSearchEvents.append(event) - continuation.resume() - } + Task { @MainActor in + webSearchEvents.append(event) + continuation.resume() + } + } onError: { error in + Task { @MainActor in + errors.append(error) + continuation.resume() } - } onError: { _ in } interpreter.processData(webSearchEventData()) } + #expect(chatResults.isEmpty, "Expected no chat results") + #expect(errors.isEmpty, "Expected no errors") #expect(webSearchEvents.count == 1) #expect(webSearchEvents.first?.type == "web_search_call") #expect(webSearchEvents.first?.status == .inProgress) @@ -110,22 +120,32 @@ struct ServerSentEventsStreamInterpreterTests { @Test func parseWebSearchEventCompleted() async throws { var webSearchEvents: [WebSearchEvent] = [] + var chatResults: [ChatStreamResult] = [] + var errors: [Error] = [] await withCheckedContinuation { continuation in - interpreter.setCallbackClosures { _ in + interpreter.setCallbackClosures { result in + Task { @MainActor in + chatResults.append(result) + continuation.resume() + } } onWebSearchEvent: { event in - Task { - await MainActor.run { - webSearchEvents.append(event) - continuation.resume() - } + Task { @MainActor in + webSearchEvents.append(event) + continuation.resume() + } + } onError: { error in + Task { @MainActor in + errors.append(error) + continuation.resume() } - } onError: { _ in } interpreter.processData(webSearchEventCompletedData()) } + #expect(chatResults.isEmpty, "Expected no chat results") + #expect(errors.isEmpty, "Expected no errors") #expect(webSearchEvents.count == 1) #expect(webSearchEvents.first?.status == .completed) } @@ -133,49 +153,62 @@ struct ServerSentEventsStreamInterpreterTests { @Test func webSearchEventDoesNotTriggerOnResult() async throws { var chatStreamResults: [ChatStreamResult] = [] var webSearchEvents: [WebSearchEvent] = [] + var errors: [Error] = [] await withCheckedContinuation { continuation in interpreter.setCallbackClosures { result in - Task { - await MainActor.run { - chatStreamResults.append(result) - } + Task { @MainActor in + chatStreamResults.append(result) + continuation.resume() } } onWebSearchEvent: { event in - Task { - await MainActor.run { - webSearchEvents.append(event) - continuation.resume() - } + Task { @MainActor in + webSearchEvents.append(event) + continuation.resume() + } + } onError: { error in + Task { @MainActor in + errors.append(error) + continuation.resume() } - } onError: { _ in } interpreter.processData(webSearchEventData()) } + #expect(errors.isEmpty, "Expected no errors") #expect(chatStreamResults.isEmpty) #expect(webSearchEvents.count == 1) } @Test func invalidWebSearchEventReportsError() async throws { var receivedError: Error? + var webSearchEvents: [WebSearchEvent] = [] + var chatResults: [ChatStreamResult] = [] await withCheckedContinuation { continuation in - interpreter.setCallbackClosures { _ in - } onWebSearchEvent: { _ in + interpreter.setCallbackClosures { result in + Task { @MainActor in + chatResults.append(result) + continuation.resume() + } + } onWebSearchEvent: { event in + Task { @MainActor in + webSearchEvents.append(event) + continuation.resume() + } } onError: { error in - Task { - await MainActor.run { - receivedError = error - continuation.resume() - } + Task { @MainActor in + receivedError = error + continuation.resume() } } interpreter.processData(invalidWebSearchEventData()) } + #expect(chatResults.isEmpty, "Expected no chat results") + #expect(webSearchEvents.isEmpty, "Expected no web search events") #expect(receivedError != nil) } From fb076a51a97b56c3cc2ba4c9e8bcc9e88f59d53f Mon Sep 17 00:00:00 2001 From: Sacha Servan-Schreiber <8027773+sachaservan@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:08:14 +0800 Subject: [PATCH 9/9] feat: add url field to WebSearchEvent.Action for open_page events --- Sources/OpenAI/Public/Models/Types/WebSearchEvent.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/OpenAI/Public/Models/Types/WebSearchEvent.swift b/Sources/OpenAI/Public/Models/Types/WebSearchEvent.swift index f2c5e6f7..debb6f38 100644 --- a/Sources/OpenAI/Public/Models/Types/WebSearchEvent.swift +++ b/Sources/OpenAI/Public/Models/Types/WebSearchEvent.swift @@ -41,8 +41,10 @@ public struct WebSearchEvent: Codable, Equatable, Sendable { public struct Action: Codable, Equatable, Sendable { /// The type of action: "search", "open_page", or "find_in_page" public let type: String? - /// The search query being executed + /// The search query being executed (for "search" actions) public let query: String? + /// The URL being fetched (for "open_page" actions) + public let url: String? } private enum CodingKeys: String, CodingKey {