From b4bf7ec5afcd66e494500c77fe4bdbce4f4c2bfe Mon Sep 17 00:00:00 2001 From: nezhyborets Date: Sat, 19 Sep 2026 00:20:21 +0300 Subject: [PATCH 1/9] fix: migrate Demo to ExyteChat 3.x and unblock the build on Xcode 27 Demo/Demo.xcodeproj fails to link on Xcode 27 because ExyteChat's ChatView declares its @State properties in one file and its designated initializer in another, which trips a Swift 6.4/Xcode 27 compiler bug (swiftlang/swift#91700): the linker can't find the "variable initialization expression" symbol for each @State property's default value. - Bump exyte/Chat from 2.5.7 to 3.3.0, which required three follow-up fixes: - drop .messageUseMarkdown(true): ChatView 3.x always applies markdown attributes to message text now, the modifier no longer exists - drop Hashable from ResponsesStore.ConversationTurn: ExyteChat.Message lost Hashable in 3.x (Equatable only), and the conformance was unused - pin exyte/AnchoredPopup to >=1.2.2 explicitly: MediaPicker 3.4.x calls PopupParameters.displayMode(_:), added in AnchoredPopup 1.2.0, but MediaPicker's manifest still understates its own requirement as `from: "1.1.3"`, so SwiftPM could otherwise resolve an incompatible 1.1.3 and fail to build - Point Chat at a fork branch (nezhyborets/Chat@macpaw-xcode27-init-fix) that moves ChatView's designated init into the same file as its @State declarations, working around the Xcode 27 linker bug. Upstreamed as exyte/Chat#302. Revert to the tagged release once that lands and a fixed Xcode toolchain ships. Verified with a full clean build (not incremental) and by installing and launching Demo in the iOS Simulator. Fixes #444 Co-Authored-By: Claude Sonnet 5 --- .../xcshareddata/swiftpm/Package.resolved | 66 +++++++++---------- Demo/DemoChat/Package.resolved | 66 +++++++++---------- Demo/DemoChat/Package.swift | 17 ++++- Demo/DemoChat/Sources/ResponsesStore.swift | 2 +- Demo/DemoChat/Sources/UI/DetailView.swift | 1 - .../Sources/UI/ResponsesChatDetailView.swift | 1 - 6 files changed, 82 insertions(+), 71 deletions(-) diff --git a/Demo/Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demo/Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7b68eb0e..333970e8 100644 --- a/Demo/Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Demo/Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "bcd79666ae410601a4f1ded1094921cb30aa100078efb734690a128ae00006a7", + "originHash" : "b59e45c156047708fcdfb0d1fe8756e421ca9d409879fd2f07ea0f0991951c22", "pins" : [ { "identity" : "activityindicatorview", "kind" : "remoteSourceControl", - "location" : "https://github.com/exyte/ActivityIndicatorView", + "location" : "https://github.com/exyte/ActivityIndicatorView.git", "state" : { - "revision" : "36140867802ae4a1d2b11490bcbbefe058001d14", - "version" : "1.2.1" + "revision" : "41f89372c018decaf965e8dddeb6e5347741beb4", + "version" : "2.0.2" } }, { @@ -15,17 +15,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/exyte/AnchoredPopup.git", "state" : { - "revision" : "2fb9d1ac101b86cbcc12a3f8e571648ce4469d18", - "version" : "1.1.3" + "revision" : "8051eae56e20567b52b1ee30261d4b292a081da0", + "version" : "1.2.2" } }, { "identity" : "chat", "kind" : "remoteSourceControl", - "location" : "https://github.com/exyte/Chat.git", + "location" : "https://github.com/nezhyborets/Chat.git", "state" : { - "revision" : "056ee35312811844a5d257bad076423f69f4b707", - "version" : "2.7.10" + "branch" : "macpaw-xcode27-init-fix", + "revision" : "85152a86a97152139fe7c887cb7c32eb579fbac7" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/mattt/eventsource.git", "state" : { - "revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e", - "version" : "1.4.1" + "revision" : "86b5096ac59ab46e66bd1f6377c604bc1dab0bc2", + "version" : "1.5.1" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Giphy/giphy-ios-sdk", "state" : { - "revision" : "3ba672878445c53c8cb87d57c3ae5cc1f3166b5b", - "version" : "2.3.2" + "revision" : "37f5b1ff6cf8bc4a78c0bc5eb1b814381bac9580", + "version" : "2.2.16" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher", "state" : { - "revision" : "cf8be20d07654570554c8a8a4952bc8a5766a8b0", - "version" : "8.9.0" + "revision" : "be0d257b9bd47a4e6e1265fc8c237411825f107a", + "version" : "8.12.0" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/libwebp-Xcode", "state" : { - "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2", - "version" : "1.5.0" + "revision" : "2b5256c29ff4e20f2a0d5ee863b62b1a22144434", + "version" : "1.6.0" } }, { @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/exyte/MediaPicker.git", "state" : { - "revision" : "ce2eda6300337d1478a78fc033bce8dd9bf4bb2c", - "version" : "3.3.2" + "revision" : "96dc0e21adaa9266bbb5698052c7ddfd22f69f0c", + "version" : "3.4.6" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-atomics.git", "state" : { - "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", - "version" : "1.3.0" + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a", - "version" : "1.5.1" + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-types", "state" : { - "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", - "version" : "1.5.1" + "revision" : "bff4b6903cdc99dda49649dd52f46c11cfd3ed50", + "version" : "1.8.0" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", - "version" : "1.12.0" + "revision" : "9c6fb14227f55d8f711ce3847dc2f419fb0ecacb", + "version" : "1.15.1" } }, { @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "f71c8d2a5e74a2c6d11a0fbe324774b5d6084237", - "version" : "2.99.0" + "revision" : "21de5f08c1a166a6dd293d0e587ad977bf8dac5d", + "version" : "2.103.0" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-openapi-runtime", "state" : { - "revision" : "f039fa6d6338aab5164f3d1be16281524c9a8f89", - "version" : "1.11.0" + "revision" : "9e53df53e043b53046a371b31aaf0a1e26eef946", + "version" : "1.12.1" } }, { @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system.git", "state" : { - "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", - "version" : "1.6.4" + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" } } ], diff --git a/Demo/DemoChat/Package.resolved b/Demo/DemoChat/Package.resolved index 7b68eb0e..333970e8 100644 --- a/Demo/DemoChat/Package.resolved +++ b/Demo/DemoChat/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "bcd79666ae410601a4f1ded1094921cb30aa100078efb734690a128ae00006a7", + "originHash" : "b59e45c156047708fcdfb0d1fe8756e421ca9d409879fd2f07ea0f0991951c22", "pins" : [ { "identity" : "activityindicatorview", "kind" : "remoteSourceControl", - "location" : "https://github.com/exyte/ActivityIndicatorView", + "location" : "https://github.com/exyte/ActivityIndicatorView.git", "state" : { - "revision" : "36140867802ae4a1d2b11490bcbbefe058001d14", - "version" : "1.2.1" + "revision" : "41f89372c018decaf965e8dddeb6e5347741beb4", + "version" : "2.0.2" } }, { @@ -15,17 +15,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/exyte/AnchoredPopup.git", "state" : { - "revision" : "2fb9d1ac101b86cbcc12a3f8e571648ce4469d18", - "version" : "1.1.3" + "revision" : "8051eae56e20567b52b1ee30261d4b292a081da0", + "version" : "1.2.2" } }, { "identity" : "chat", "kind" : "remoteSourceControl", - "location" : "https://github.com/exyte/Chat.git", + "location" : "https://github.com/nezhyborets/Chat.git", "state" : { - "revision" : "056ee35312811844a5d257bad076423f69f4b707", - "version" : "2.7.10" + "branch" : "macpaw-xcode27-init-fix", + "revision" : "85152a86a97152139fe7c887cb7c32eb579fbac7" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/mattt/eventsource.git", "state" : { - "revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e", - "version" : "1.4.1" + "revision" : "86b5096ac59ab46e66bd1f6377c604bc1dab0bc2", + "version" : "1.5.1" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Giphy/giphy-ios-sdk", "state" : { - "revision" : "3ba672878445c53c8cb87d57c3ae5cc1f3166b5b", - "version" : "2.3.2" + "revision" : "37f5b1ff6cf8bc4a78c0bc5eb1b814381bac9580", + "version" : "2.2.16" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher", "state" : { - "revision" : "cf8be20d07654570554c8a8a4952bc8a5766a8b0", - "version" : "8.9.0" + "revision" : "be0d257b9bd47a4e6e1265fc8c237411825f107a", + "version" : "8.12.0" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/libwebp-Xcode", "state" : { - "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2", - "version" : "1.5.0" + "revision" : "2b5256c29ff4e20f2a0d5ee863b62b1a22144434", + "version" : "1.6.0" } }, { @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/exyte/MediaPicker.git", "state" : { - "revision" : "ce2eda6300337d1478a78fc033bce8dd9bf4bb2c", - "version" : "3.3.2" + "revision" : "96dc0e21adaa9266bbb5698052c7ddfd22f69f0c", + "version" : "3.4.6" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-atomics.git", "state" : { - "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", - "version" : "1.3.0" + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a", - "version" : "1.5.1" + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-types", "state" : { - "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", - "version" : "1.5.1" + "revision" : "bff4b6903cdc99dda49649dd52f46c11cfd3ed50", + "version" : "1.8.0" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", - "version" : "1.12.0" + "revision" : "9c6fb14227f55d8f711ce3847dc2f419fb0ecacb", + "version" : "1.15.1" } }, { @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "f71c8d2a5e74a2c6d11a0fbe324774b5d6084237", - "version" : "2.99.0" + "revision" : "21de5f08c1a166a6dd293d0e587ad977bf8dac5d", + "version" : "2.103.0" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-openapi-runtime", "state" : { - "revision" : "f039fa6d6338aab5164f3d1be16281524c9a8f89", - "version" : "1.11.0" + "revision" : "9e53df53e043b53046a371b31aaf0a1e26eef946", + "version" : "1.12.1" } }, { @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system.git", "state" : { - "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", - "version" : "1.6.4" + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" } } ], diff --git a/Demo/DemoChat/Package.swift b/Demo/DemoChat/Package.swift index ec56f58d..9105f360 100644 --- a/Demo/DemoChat/Package.swift +++ b/Demo/DemoChat/Package.swift @@ -14,8 +14,21 @@ let package = Package( ], dependencies: [ .package(name: "OpenAI", path: "../.."), - .package(url: "https://github.com/exyte/Chat.git", from: "2.5.7"), - .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.9.0") + // Pinned to a fork branch, not upstream: ChatView's designated init lives in a + // different file than its @State properties, which trips a Swift 6.4/Xcode 27 + // compiler bug (https://github.com/swiftlang/swift/issues/91700) and fails to link + // with "variable initialization expression of ExyteChat.ChatView.(__... in ...)". + // This fork branch just moves that init into the same file as the @State + // declarations, which the upstream issue confirms as a workaround. Revert to + // `.package(url: "https://github.com/exyte/Chat.git", from: "3.3.0")` once Apple + // ships a fixed toolchain (a fix is in progress upstream). + .package(url: "https://github.com/nezhyborets/Chat.git", branch: "macpaw-xcode27-init-fix"), + .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.9.0"), + // Pinned explicitly: MediaPicker 3.4.x calls PopupParameters.displayMode(_:), which + // AnchoredPopup only added in 1.2.0. MediaPicker's own manifest still declares + // `from: "1.1.3"`, so without this override SwiftPM's version resolution can pick + // 1.1.3 and fail to build. + .package(url: "https://github.com/exyte/AnchoredPopup.git", from: "1.2.2") ], targets: [ .target( diff --git a/Demo/DemoChat/Sources/ResponsesStore.swift b/Demo/DemoChat/Sources/ResponsesStore.swift index d4e71571..13e1fac4 100644 --- a/Demo/DemoChat/Sources/ResponsesStore.swift +++ b/Demo/DemoChat/Sources/ResponsesStore.swift @@ -11,7 +11,7 @@ import OpenAI @MainActor public final class ResponsesStore: ObservableObject { - struct ConversationTurn: Identifiable, Hashable, Sendable { + struct ConversationTurn: Identifiable, Sendable { public enum TurnType: Sendable { case userInput case response diff --git a/Demo/DemoChat/Sources/UI/DetailView.swift b/Demo/DemoChat/Sources/UI/DetailView.swift index 6c29e855..efa083cb 100644 --- a/Demo/DemoChat/Sources/UI/DetailView.swift +++ b/Demo/DemoChat/Sources/UI/DetailView.swift @@ -154,7 +154,6 @@ struct DetailView: View { } }) .setAvailableInputs([.text, .media]) - .messageUseMarkdown(true) .betweenListAndInputViewBuilder(infoMessage) } diff --git a/Demo/DemoChat/Sources/UI/ResponsesChatDetailView.swift b/Demo/DemoChat/Sources/UI/ResponsesChatDetailView.swift index 3f7736f6..b48311f1 100644 --- a/Demo/DemoChat/Sources/UI/ResponsesChatDetailView.swift +++ b/Demo/DemoChat/Sources/UI/ResponsesChatDetailView.swift @@ -204,7 +204,6 @@ public struct ResponsesChatDetailView: View { } }) .setAvailableInputs([.text, .media]) - .messageUseMarkdown(true) } } From dd4a03bd60499bc331c4a5e4a200fb9b59258652 Mon Sep 17 00:00:00 2001 From: nezhyborets Date: Sat, 19 Sep 2026 18:49:36 +0300 Subject: [PATCH 2/9] fix: switch Chat back to upstream now that the Xcode 27 fix is released exyte/Chat#302 merged and shipped in 3.3.3, so the fork branch used as a stopgap is no longer needed. Point back at the upstream package. Verified with a full clean build of Demo on Xcode 27. Co-Authored-By: Claude Sonnet 5 --- .../xcshareddata/swiftpm/Package.resolved | 8 ++++---- Demo/DemoChat/Package.resolved | 8 ++++---- Demo/DemoChat/Package.swift | 10 +--------- 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/Demo/Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demo/Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 333970e8..0246b097 100644 --- a/Demo/Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Demo/Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b59e45c156047708fcdfb0d1fe8756e421ca9d409879fd2f07ea0f0991951c22", + "originHash" : "413e0f11af60153179b28c3af1a3e2f551ab5eec31cb11e96335bcd5b4cb99e9", "pins" : [ { "identity" : "activityindicatorview", @@ -22,10 +22,10 @@ { "identity" : "chat", "kind" : "remoteSourceControl", - "location" : "https://github.com/nezhyborets/Chat.git", + "location" : "https://github.com/exyte/Chat.git", "state" : { - "branch" : "macpaw-xcode27-init-fix", - "revision" : "85152a86a97152139fe7c887cb7c32eb579fbac7" + "revision" : "60ff30bc7065581d3e231fd475b269d416b427f5", + "version" : "3.3.3" } }, { diff --git a/Demo/DemoChat/Package.resolved b/Demo/DemoChat/Package.resolved index 333970e8..0246b097 100644 --- a/Demo/DemoChat/Package.resolved +++ b/Demo/DemoChat/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b59e45c156047708fcdfb0d1fe8756e421ca9d409879fd2f07ea0f0991951c22", + "originHash" : "413e0f11af60153179b28c3af1a3e2f551ab5eec31cb11e96335bcd5b4cb99e9", "pins" : [ { "identity" : "activityindicatorview", @@ -22,10 +22,10 @@ { "identity" : "chat", "kind" : "remoteSourceControl", - "location" : "https://github.com/nezhyborets/Chat.git", + "location" : "https://github.com/exyte/Chat.git", "state" : { - "branch" : "macpaw-xcode27-init-fix", - "revision" : "85152a86a97152139fe7c887cb7c32eb579fbac7" + "revision" : "60ff30bc7065581d3e231fd475b269d416b427f5", + "version" : "3.3.3" } }, { diff --git a/Demo/DemoChat/Package.swift b/Demo/DemoChat/Package.swift index 9105f360..f30330f7 100644 --- a/Demo/DemoChat/Package.swift +++ b/Demo/DemoChat/Package.swift @@ -14,15 +14,7 @@ let package = Package( ], dependencies: [ .package(name: "OpenAI", path: "../.."), - // Pinned to a fork branch, not upstream: ChatView's designated init lives in a - // different file than its @State properties, which trips a Swift 6.4/Xcode 27 - // compiler bug (https://github.com/swiftlang/swift/issues/91700) and fails to link - // with "variable initialization expression of ExyteChat.ChatView.(__... in ...)". - // This fork branch just moves that init into the same file as the @State - // declarations, which the upstream issue confirms as a workaround. Revert to - // `.package(url: "https://github.com/exyte/Chat.git", from: "3.3.0")` once Apple - // ships a fixed toolchain (a fix is in progress upstream). - .package(url: "https://github.com/nezhyborets/Chat.git", branch: "macpaw-xcode27-init-fix"), + .package(url: "https://github.com/exyte/Chat.git", from: "3.3.3"), .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.9.0"), // Pinned explicitly: MediaPicker 3.4.x calls PopupParameters.displayMode(_:), which // AnchoredPopup only added in 1.2.0. MediaPicker's own manifest still declares From e48828014c9ca86a57c6f331966389889e3c85d3 Mon Sep 17 00:00:00 2001 From: nezhyborets Date: Mon, 21 Sep 2026 17:00:15 +0300 Subject: [PATCH 3/9] fix: surface the real error body for failed streaming requests Streaming requests cancelled the task as soon as a >=400 response came in, before the body was ever read, so OpenAIError.statusError only carried response headers and discarded OpenAI's actual error message (e.g. an invalid model name). Buffer the error body instead and decode it the same way the non-streaming path does, falling back to statusError only when the body isn't decodable. Co-Authored-By: Claude Sonnet 5 --- .../Private/Streaming/StreamingSession.swift | 29 +++- .../StreamingSessionErrorHandlingTests.swift | 126 ++++++++++++++++++ 2 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 Tests/OpenAITests/StreamingSessionErrorHandlingTests.swift diff --git a/Sources/OpenAI/Private/Streaming/StreamingSession.swift b/Sources/OpenAI/Private/Streaming/StreamingSession.swift index b718db6b..d31439cb 100644 --- a/Sources/OpenAI/Private/Streaming/StreamingSession.swift +++ b/Sources/OpenAI/Private/Streaming/StreamingSession.swift @@ -24,6 +24,11 @@ final class StreamingSession: NSObject, Identifi private let onProcessingError: (@Sendable (StreamingSession, Error) -> Void)? private let onComplete: (@Sendable (StreamingSession, Error?) -> Void)? + /// Set once a response with a non-2xx status code is received. + /// While set, incoming data is treated as an error body rather than being fed to the interpreter. + private var errorResponse: HTTPURLResponse? + private var errorData = Data() + init( urlSessionFactory: URLSessionFactory = FoundationURLSessionFactory(), urlRequest: URLRequest, @@ -55,16 +60,29 @@ final class StreamingSession: NSObject, Identifi func urlSession(_ session: any URLSessionProtocol, task: any URLSessionTaskProtocol, didCompleteWithError error: (any Error)?) { executionSerializer.dispatch { + if let httpResponse = self.errorResponse { + let decodedError = JSONResponseErrorDecoder(decoder: JSONDecoder()).decodeErrorResponse(data: self.errorData) as (any Error)? + let resolvedError = decodedError + ?? OpenAIError.statusError(response: httpResponse, statusCode: httpResponse.statusCode) + self.onProcessingError?(self, resolvedError) + self.onComplete?(self, resolvedError) + return + } self.onComplete?(self,error) } } - + func urlSession(_ session: any URLSessionProtocol, dataTask: any URLSessionDataTaskProtocol, didReceive data: Data) { executionSerializer.dispatch { let data = self.middlewares.reduce(data) { current, middleware in middleware.interceptStreamingData(request: dataTask.originalRequest, current) } - + + if self.errorResponse != nil { + self.errorData.append(data) + return + } + self.interpreter.processData(data) } } @@ -77,9 +95,10 @@ final class StreamingSession: NSObject, Identifi ) { executionSerializer.dispatch { if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode >= 400 { - let error = OpenAIError.statusError(response: httpResponse, statusCode: httpResponse.statusCode) - self.onProcessingError?(self, error) - completionHandler(.cancel) + // Keep the connection open so the error body (with the actual failure reason) can be + // read in didReceive(data:) and decoded once the task completes in didCompleteWithError. + self.errorResponse = httpResponse + completionHandler(.allow) return } completionHandler(.allow) diff --git a/Tests/OpenAITests/StreamingSessionErrorHandlingTests.swift b/Tests/OpenAITests/StreamingSessionErrorHandlingTests.swift new file mode 100644 index 00000000..e38a0d49 --- /dev/null +++ b/Tests/OpenAITests/StreamingSessionErrorHandlingTests.swift @@ -0,0 +1,126 @@ +// +// StreamingSessionErrorHandlingTests.swift +// OpenAI +// + +import XCTest +@testable import OpenAI + +final class StreamingSessionErrorHandlingTests: XCTestCase { + private let urlSessionFactory = MockURLSessionFactory() + private let streamInterpreter = MockDataStreamInterpreter() + private let executionSerializer: ExecutionSerializer = NoDispatchExecutionSerializer() + + private var receivedContentCount = 0 + private var processingErrors: [Error] = [] + private var completionErrors: [Error?] = [] + + private lazy var streamingSession = StreamingSession( + urlSessionFactory: urlSessionFactory, + urlRequest: .init(url: URL(string: "https://api.openai.com/v1/chat/completions")!), + interpreter: streamInterpreter, + sslDelegate: nil, + middlewares: [], + executionSerializer: executionSerializer, + onReceiveContent: { _, _ in + self.receivedContentCount += 1 + }, + onProcessingError: { _, error in + self.processingErrors.append(error) + }, + onComplete: { _, error in + self.completionErrors.append(error) + } + ) + + private func makeErrorResponse(statusCode: Int = 400) -> HTTPURLResponse { + HTTPURLResponse( + url: URL(string: "https://api.openai.com/v1/chat/completions")!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + )! + } + + func testErrorBodyIsDecodedInsteadOfBeingDiscarded() throws { + _ = streamingSession + let dataTask = DataTaskMock() + + var disposition: URLSession.ResponseDisposition? + streamingSession.urlSession( + urlSessionFactory.urlSession, + dataTask: dataTask, + didReceive: makeErrorResponse() + ) { disposition = $0 } + + // The connection must be kept open (not cancelled) so the error body can still be read. + XCTAssertEqual(disposition, .allow) + + let errorBody = """ + {"error": {"message": "The model `gpt-5.6-terra` does not exist", "type": "invalid_request_error", "param": null, "code": "model_not_found"}} + """.data(using: .utf8)! + + // Body can arrive in multiple chunks; make sure they're accumulated correctly. + let midpoint = errorBody.index(errorBody.startIndex, offsetBy: errorBody.count / 2) + streamingSession.urlSession(urlSessionFactory.urlSession, dataTask: dataTask, didReceive: Data(errorBody[.. Date: Mon, 21 Sep 2026 19:45:41 +0300 Subject: [PATCH 4/9] fix: import FoundationNetworking for HTTPURLResponse on Linux in tests StreamingSessionErrorHandlingTests used HTTPURLResponse and URLSession.ResponseDisposition without the FoundationNetworking import that every other file touching these types already guards with, so Linux CI (Swift 5.10/6.0/6.3) failed to compile the test target. Co-Authored-By: Claude Sonnet 5 --- Tests/OpenAITests/StreamingSessionErrorHandlingTests.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Tests/OpenAITests/StreamingSessionErrorHandlingTests.swift b/Tests/OpenAITests/StreamingSessionErrorHandlingTests.swift index e38a0d49..899e5e1f 100644 --- a/Tests/OpenAITests/StreamingSessionErrorHandlingTests.swift +++ b/Tests/OpenAITests/StreamingSessionErrorHandlingTests.swift @@ -4,6 +4,9 @@ // import XCTest +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif @testable import OpenAI final class StreamingSessionErrorHandlingTests: XCTestCase { From 8f9efa3ccef88a0f812fc767d63efd5fcb49a787 Mon Sep 17 00:00:00 2001 From: nezhyborets Date: Mon, 21 Sep 2026 19:47:03 +0300 Subject: [PATCH 5/9] docs: note the FoundationNetworking-on-Linux gotcha in CLAUDE.md Prompted by a real CI failure: a new test file used HTTPURLResponse / URLSession.ResponseDisposition without the FoundationNetworking import Linux needs, which compiled fine on macOS but broke all three Linux CI jobs. Add a standing reminder so future changes check for this. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index b239a5c3..f287b5f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,3 +19,29 @@ that still calls a removed/renamed API. The goal is for Demo to stay complete and up to date with the package, not silently lag behind. Only skip this build when the change clearly cannot affect Demo (docs, CI config, tests-only changes, etc.). + +## Foundation types that don't exist on Linux + +CI builds and tests this package on Linux (Swift 5.10, 6.0, 6.3) in addition +to Apple platforms. Several `Foundation` types you'd normally reach for +without a second thought — `HTTPURLResponse`, `URLSession.*` nested types +(e.g. `ResponseDisposition`), `URLRequest` in some contexts, etc. — live in +the separate `FoundationNetworking` module on Linux, not in `Foundation` +itself. Code that uses them compiles fine on macOS and fails on Linux CI +only, with an error like `'HTTPURLResponse' is unavailable: This type has +moved to the FoundationNetworking module.` + +Whenever a new file (source *or* test) references one of these types, guard +the import the way existing files already do: + +```swift +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +``` + +`swift build` / `swift test` passing locally on macOS does not confirm this — +the failure only shows up on Linux. If you can't run the Linux job yourself, +at least double-check new files against this pattern before considering the +change done. From a1076ddbdf89045dc94f6eface8f49ad37736032 Mon Sep 17 00:00:00 2001 From: nezhyborets Date: Mon, 21 Sep 2026 20:58:02 +0300 Subject: [PATCH 6/9] fix: avoid mutating a captured local var in a @Sendable closure The response-disposition assertions captured and mutated a local var from inside the @Sendable completionHandler closure, which Linux Swift 5.10's stricter checker rejects as a data race (macOS/newer toolchains only warn). Route the value through an instance array instead, matching how the other callback closures in this file already record their results. Co-Authored-By: Claude Sonnet 5 --- .../StreamingSessionErrorHandlingTests.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Tests/OpenAITests/StreamingSessionErrorHandlingTests.swift b/Tests/OpenAITests/StreamingSessionErrorHandlingTests.swift index 899e5e1f..e86940d0 100644 --- a/Tests/OpenAITests/StreamingSessionErrorHandlingTests.swift +++ b/Tests/OpenAITests/StreamingSessionErrorHandlingTests.swift @@ -17,6 +17,7 @@ final class StreamingSessionErrorHandlingTests: XCTestCase { private var receivedContentCount = 0 private var processingErrors: [Error] = [] private var completionErrors: [Error?] = [] + private var receivedDispositions: [URLSession.ResponseDisposition] = [] private lazy var streamingSession = StreamingSession( urlSessionFactory: urlSessionFactory, @@ -49,15 +50,14 @@ final class StreamingSessionErrorHandlingTests: XCTestCase { _ = streamingSession let dataTask = DataTaskMock() - var disposition: URLSession.ResponseDisposition? streamingSession.urlSession( urlSessionFactory.urlSession, dataTask: dataTask, didReceive: makeErrorResponse() - ) { disposition = $0 } + ) { self.receivedDispositions.append($0) } // The connection must be kept open (not cancelled) so the error body can still be read. - XCTAssertEqual(disposition, .allow) + XCTAssertEqual(receivedDispositions, [.allow]) let errorBody = """ {"error": {"message": "The model `gpt-5.6-terra` does not exist", "type": "invalid_request_error", "param": null, "code": "model_not_found"}} @@ -112,11 +112,10 @@ final class StreamingSessionErrorHandlingTests: XCTestCase { headerFields: nil )! - var disposition: URLSession.ResponseDisposition? streamingSession.urlSession(urlSessionFactory.urlSession, dataTask: dataTask, didReceive: successResponse) { - disposition = $0 + self.receivedDispositions.append($0) } - XCTAssertEqual(disposition, .allow) + XCTAssertEqual(receivedDispositions, [.allow]) streamingSession.urlSession(urlSessionFactory.urlSession, dataTask: dataTask, didReceive: Data("chunk".utf8)) streamingSession.urlSession(urlSessionFactory.urlSession, task: dataTask, didCompleteWithError: nil) From b253406308d651b192abc4985c50bf9f51c492cc Mon Sep 17 00:00:00 2001 From: nezhyborets Date: Mon, 21 Sep 2026 22:21:04 +0300 Subject: [PATCH 7/9] fix: don't crash decoding an empty Gemini error array decodeErrorResponse indexed element 0 after decoding [GeminiAPIErrorResponse] without checking it was non-empty. A body that happens to decode as [] (e.g. literally "[]") traps with an index-out-of-range fatal error instead of falling through to the statusError fallback. This was reachable from the non-streaming path already, and became reachable from the streaming path too once it started decoding error bodies. Use .first instead of [0]. Co-Authored-By: Claude Sonnet 5 --- .../Private/JSONResponseErrorDecoder.swift | 4 ++-- .../JSONResponseErrorDecoderTests.swift | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 Tests/OpenAITests/JSONResponseErrorDecoderTests.swift diff --git a/Sources/OpenAI/Private/JSONResponseErrorDecoder.swift b/Sources/OpenAI/Private/JSONResponseErrorDecoder.swift index 4d2b1a73..a72d5c77 100644 --- a/Sources/OpenAI/Private/JSONResponseErrorDecoder.swift +++ b/Sources/OpenAI/Private/JSONResponseErrorDecoder.swift @@ -13,8 +13,8 @@ struct JSONResponseErrorDecoder { func decodeErrorResponse(data: Data) -> (any ErrorResponse)? { if let decoded = try? decoder.decode(APIErrorResponse.self, from: data) { return decoded - } else if let decoded = try? decoder.decode([GeminiAPIErrorResponse].self, from: data) { - return decoded[0] + } else if let decoded = try? decoder.decode([GeminiAPIErrorResponse].self, from: data), let first = decoded.first { + return first } else { return nil } diff --git a/Tests/OpenAITests/JSONResponseErrorDecoderTests.swift b/Tests/OpenAITests/JSONResponseErrorDecoderTests.swift new file mode 100644 index 00000000..6df56462 --- /dev/null +++ b/Tests/OpenAITests/JSONResponseErrorDecoderTests.swift @@ -0,0 +1,19 @@ +// +// JSONResponseErrorDecoderTests.swift +// OpenAI +// + +import XCTest +@testable import OpenAI + +final class JSONResponseErrorDecoderTests: XCTestCase { + private let decoder = JSONResponseErrorDecoder(decoder: JSONDecoder()) + + // A body that decodes as an empty Gemini error array must not be treated as a + // single Gemini error: decodeErrorResponse indexes element 0 unconditionally, + // which traps on `[]` instead of falling through to the statusError fallback. + func testDecodeErrorResponseDoesNotCrashOnEmptyArrayBody() { + let emptyArrayBody = Data("[]".utf8) + XCTAssertNil(decoder.decodeErrorResponse(data: emptyArrayBody)) + } +} From 545dc83a28878a22b01a0fa6fc9d5eb04161f3ca Mon Sep 17 00:00:00 2001 From: nezhyborets Date: Mon, 21 Sep 2026 22:25:19 +0300 Subject: [PATCH 8/9] docs: add instruction to wait for explicit push instructions CI takes several minutes per push; pushing after every small commit means paying that wait repeatedly. Commit locally as work progresses, but only push when explicitly asked to. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index f287b5f8..42214cf6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,3 +45,8 @@ import FoundationNetworking the failure only shows up on Linux. If you can't run the Linux job yourself, at least double-check new files against this pattern before considering the change done. + +## Wait for explicit instructions to push + +CI takes several minutes to run, so commit locally as you go but don't run +`git push` unless the user explicitly asks for it at that point. From 1ff75d23cf9c7d99e45ee1a015f07722c61e80d9 Mon Sep 17 00:00:00 2001 From: nezhyborets Date: Mon, 21 Sep 2026 23:30:47 +0300 Subject: [PATCH 9/9] fix: cap buffered streaming error body to bound memory growth Once a >=400 response was accepted, every subsequent chunk was appended to errorData with no size limit and no cancellation path, so a malformed or malicious server/proxy could force unbounded memory growth by never ending the error body. Cap it at 256 KB (real API error bodies are a few hundred bytes) and cancel the task once exceeded; didCompleteWithError already falls back to statusError when the buffered body doesn't decode, so a truncated body degrades gracefully into the existing bounded-failure behavior. Co-Authored-By: Claude Sonnet 5 --- .../Private/Streaming/StreamingSession.swift | 9 ++++++ .../StreamingSessionErrorHandlingTests.swift | 29 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/Sources/OpenAI/Private/Streaming/StreamingSession.swift b/Sources/OpenAI/Private/Streaming/StreamingSession.swift index d31439cb..4ec4bdb3 100644 --- a/Sources/OpenAI/Private/Streaming/StreamingSession.swift +++ b/Sources/OpenAI/Private/Streaming/StreamingSession.swift @@ -11,6 +11,10 @@ import Foundation import FoundationNetworking #endif +/// Real API error bodies are at most a few KB. Cap how much of a non-2xx body we'll buffer so a +/// malformed or malicious server can't force unbounded memory growth by never ending the response. +private let maxErrorBodyByteCount = 256 * 1024 + final class StreamingSession: NSObject, Identifiable, URLSessionDataDelegateProtocol, @unchecked Sendable { typealias ResultType = Interpreter.ResultType @@ -80,6 +84,11 @@ final class StreamingSession: NSObject, Identifi if self.errorResponse != nil { self.errorData.append(data) + if self.errorData.count > maxErrorBodyByteCount { + // Give up on this body: stop letting the server grow it further and let + // didCompleteWithError fall back to statusError with whatever we have. + dataTask.cancel() + } return } diff --git a/Tests/OpenAITests/StreamingSessionErrorHandlingTests.swift b/Tests/OpenAITests/StreamingSessionErrorHandlingTests.swift index e86940d0..e49ff9ae 100644 --- a/Tests/OpenAITests/StreamingSessionErrorHandlingTests.swift +++ b/Tests/OpenAITests/StreamingSessionErrorHandlingTests.swift @@ -101,6 +101,35 @@ final class StreamingSessionErrorHandlingTests: XCTestCase { XCTAssertEqual(statusCode, 400) } + func testErrorBodyIsCappedAndTaskIsCancelledWhenBodyNeverEnds() throws { + _ = streamingSession + let dataTask = DataTaskMock() + + streamingSession.urlSession( + urlSessionFactory.urlSession, + dataTask: dataTask, + didReceive: makeErrorResponse() + ) { _ in } + + // Simulate a server that keeps sending a never-ending error body: this must not be + // buffered without limit, and the task must be cancelled once the cap is exceeded. + let chunk = Data(repeating: 0x61, count: 64 * 1024) + for _ in 0..<8 { + streamingSession.urlSession(urlSessionFactory.urlSession, dataTask: dataTask, didReceive: chunk) + } + + XCTAssertGreaterThan(dataTask.cancelCallCount, 0, "The task should be cancelled once the buffered error body exceeds the cap") + + streamingSession.urlSession(urlSessionFactory.urlSession, task: dataTask, didCompleteWithError: nil) + + // The truncated body isn't valid JSON, so this must still resolve to a bounded failure + // (statusError), not hang or crash. + XCTAssertEqual(processingErrors.count, 1) + guard case .statusError? = processingErrors.first as? OpenAIError else { + return XCTFail("Expected OpenAIError.statusError, got \(String(describing: processingErrors.first))") + } + } + func testSuccessfulResponseIsUnaffected() { _ = streamingSession let dataTask = DataTaskMock()