From 2e7b1a103d40165b7f8216fa9ef505a7f9856b8d Mon Sep 17 00:00:00 2001 From: The Best Developer ever lived Date: Tue, 21 Jul 2026 11:42:03 +0200 Subject: [PATCH 1/7] feat: add Ask AI SSE stream event model and incremental parser --- .../project.pbxproj | 34 +++++- .../AskAI/Streaming/AskAIStreamEvent.swift | 13 +++ .../AskAI/Streaming/AskAIStreamParser.swift | 101 +++++++++++++++++ .../APISpec/AI/AskAIStreamParserSpec.swift | 105 ++++++++++++++++++ 4 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/AskAIStreamEvent.swift create mode 100644 Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/AskAIStreamParser.swift create mode 100644 Cryptohopper-iOS-SDKTests/APISpec/AI/AskAIStreamParserSpec.swift diff --git a/Cryptohopper-iOS-SDK.xcodeproj/project.pbxproj b/Cryptohopper-iOS-SDK.xcodeproj/project.pbxproj index 2f75c2e..0bbca53 100644 --- a/Cryptohopper-iOS-SDK.xcodeproj/project.pbxproj +++ b/Cryptohopper-iOS-SDK.xcodeproj/project.pbxproj @@ -7,7 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 375AE192EF5DDEF2CA81F7ED /* AskAIStreamParserSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7933BD30DEFF47AD510CCDC9 /* AskAIStreamParserSpec.swift */; }; 59576957B76E88660EDCD9F6 /* Pods_Cryptohopper_iOS_SDKTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F006D690718FB3AD4ACF2C61 /* Pods_Cryptohopper_iOS_SDKTests.framework */; }; + 631307BD7693BAAF7B72B206 /* AskAIStreamEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 211C9DED44706861D3FE369A /* AskAIStreamEvent.swift */; }; D0021D3C2718789F0050D7CC /* HopperAPIAuthWithCodeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0021D3B2718789F0050D7CC /* HopperAPIAuthWithCodeRequest.swift */; }; D002BE81292B9F5300E74B6A /* HopperAPIAuthDeviceResendEmailRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D002BE80292B9F5300E74B6A /* HopperAPIAuthDeviceResendEmailRequest.swift */; }; D002BE83292B9F6C00E74B6A /* HopperAPIAuthDeviceCheckIfAuthorizedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D002BE82292B9F6C00E74B6A /* HopperAPIAuthDeviceCheckIfAuthorizedRequest.swift */; }; @@ -237,7 +239,7 @@ D02AAFC3283B83050044BCE7 /* HopperAPIV2GetAllCurrenciesRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02AAFC2283B83050044BCE7 /* HopperAPIV2GetAllCurrenciesRequest.swift */; }; D02AAFD0283B887D0044BCE7 /* V2Currency.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02AAFCF283B887D0044BCE7 /* V2Currency.swift */; }; D02AAFD5283B8B370044BCE7 /* CurrencyAPIV2Spec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02AAFD4283B8B370044BCE7 /* CurrencyAPIV2Spec.swift */; }; - D02AB037283BA72A0044BCE7 /* (null) in Embed Frameworks */ = {isa = PBXBuildFile; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + D02AB037283BA72A0044BCE7 /* BuildFile in Embed Frameworks */ = {isa = PBXBuildFile; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D02AB054283BBD850044BCE7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02AB053283BBD850044BCE7 /* AppDelegate.swift */; }; D02AB056283BBD850044BCE7 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02AB055283BBD850044BCE7 /* SceneDelegate.swift */; }; D02AB058283BBD850044BCE7 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02AB057283BBD850044BCE7 /* ViewController.swift */; }; @@ -449,6 +451,7 @@ D0F8C511283E5E0C006A8CE7 /* HopperAPIV2OnboardingCreateRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F8C510283E5E0C006A8CE7 /* HopperAPIV2OnboardingCreateRequest.swift */; }; D0F8C513283E5F1B006A8CE7 /* HopperAPIV2OnboardingInitResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F8C512283E5F1B006A8CE7 /* HopperAPIV2OnboardingInitResponse.swift */; }; D0F8C516283E692F006A8CE7 /* OnboardingAPIV2Spec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F8C515283E692F006A8CE7 /* OnboardingAPIV2Spec.swift */; }; + D6C357EB6E8B7C3E1D33FC41 /* AskAIStreamParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8CE3F96121E01342108AEAE /* AskAIStreamParser.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -476,7 +479,7 @@ dstSubfolderSpec = 10; files = ( D0F8C503283E5273006A8CE7 /* Pods_Cryptohopper_iOS_SDK.framework in Embed Frameworks */, - D02AB037283BA72A0044BCE7 /* (null) in Embed Frameworks */, + D02AB037283BA72A0044BCE7 /* BuildFile in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -485,8 +488,11 @@ /* Begin PBXFileReference section */ 1A1FB7E16BBDF708C509A103 /* Pods-Cryptohopper-iOS-SDKTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Cryptohopper-iOS-SDKTests.debug.xcconfig"; path = "Target Support Files/Pods-Cryptohopper-iOS-SDKTests/Pods-Cryptohopper-iOS-SDKTests.debug.xcconfig"; sourceTree = ""; }; + 211C9DED44706861D3FE369A /* AskAIStreamEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AskAIStreamEvent.swift; sourceTree = ""; }; 59BF9CCB664C3FC5A211DB13 /* Pods_Cryptohopper_iOS_SDK.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Cryptohopper_iOS_SDK.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 7933BD30DEFF47AD510CCDC9 /* AskAIStreamParserSpec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AskAIStreamParserSpec.swift; sourceTree = ""; }; ACA94F67F40DFEC2AE6EB5E6 /* Pods-Cryptohopper-iOS-SDK.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Cryptohopper-iOS-SDK.debug.xcconfig"; path = "Target Support Files/Pods-Cryptohopper-iOS-SDK/Pods-Cryptohopper-iOS-SDK.debug.xcconfig"; sourceTree = ""; }; + B8CE3F96121E01342108AEAE /* AskAIStreamParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AskAIStreamParser.swift; sourceTree = ""; }; D0021D3B2718789F0050D7CC /* HopperAPIAuthWithCodeRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HopperAPIAuthWithCodeRequest.swift; sourceTree = ""; }; D002BE80292B9F5300E74B6A /* HopperAPIAuthDeviceResendEmailRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HopperAPIAuthDeviceResendEmailRequest.swift; sourceTree = ""; }; D002BE82292B9F6C00E74B6A /* HopperAPIAuthDeviceCheckIfAuthorizedRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HopperAPIAuthDeviceCheckIfAuthorizedRequest.swift; sourceTree = ""; }; @@ -964,6 +970,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 1591570A537B53DD3C26F25D /* AskAI */ = { + isa = PBXGroup; + children = ( + C4A9C4CBF2CB789F5B2682AC /* Streaming */, + ); + name = AskAI; + path = AskAI; + sourceTree = ""; + }; 1AF7BFB7D52C96D9AE8AE457 /* Pods */ = { isa = PBXGroup; children = ( @@ -984,6 +999,16 @@ name = Frameworks; sourceTree = ""; }; + C4A9C4CBF2CB789F5B2682AC /* Streaming */ = { + isa = PBXGroup; + children = ( + 211C9DED44706861D3FE369A /* AskAIStreamEvent.swift */, + B8CE3F96121E01342108AEAE /* AskAIStreamParser.swift */, + ); + name = Streaming; + path = Streaming; + sourceTree = ""; + }; D0021D3A2718788F0050D7CC /* AuthWithCode */ = { isa = PBXGroup; children = ( @@ -1413,6 +1438,7 @@ D0231D1025514BE40057A293 /* Subscription */, D0AC8A11285B570B0071B90E /* CheckPasswordReset */, D0AC8A10285B56FC0071B90E /* Check2FAReset */, + 1591570A537B53DD3C26F25D /* AskAI */, ); path = User; sourceTree = ""; @@ -2660,6 +2686,7 @@ isa = PBXGroup; children = ( D047A8BD2779CAC4007D5485 /* AISpec.swift */, + 7933BD30DEFF47AD510CCDC9 /* AskAIStreamParserSpec.swift */, ); path = AI; sourceTree = ""; @@ -4685,6 +4712,8 @@ D0AF9E092625A3AC001FFDF3 /* AnyCodable.swift in Sources */, D0231ECA2552D3A80057A293 /* HopperAPIGetTradingPairOfExchangeRequest.swift in Sources */, D0231F5B2552F0AE0057A293 /* HopperAPIGetStrategyByIdResponse.swift in Sources */, + 631307BD7693BAAF7B72B206 /* AskAIStreamEvent.swift in Sources */, + D6C357EB6E8B7C3E1D33FC41 /* AskAIStreamParser.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4709,6 +4738,7 @@ D0F8C501283E0D40006A8CE7 /* ExchangeAPIV2Spec.swift in Sources */, D09CD4942840C83B009B503F /* TournamentAPIV2Spec.swift in Sources */, D0F3D4EF25655F020036B130 /* UserSpec.swift in Sources */, + 375AE192EF5DDEF2CA81F7ED /* AskAIStreamParserSpec.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/AskAIStreamEvent.swift b/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/AskAIStreamEvent.swift new file mode 100644 index 0000000..197a380 --- /dev/null +++ b/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/AskAIStreamEvent.swift @@ -0,0 +1,13 @@ +// +// AskAIStreamEvent.swift +// Cryptohopper-iOS-SDK +// + +import Foundation + +/// One normalized event from the Ask AI SSE stream. +public enum AskAIStreamEvent { + case delta(text: String) + case done(runId: String?, sessionId: String?) + case error(message: String) +} diff --git a/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/AskAIStreamParser.swift b/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/AskAIStreamParser.swift new file mode 100644 index 0000000..d1ae925 --- /dev/null +++ b/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/AskAIStreamParser.swift @@ -0,0 +1,101 @@ +// +// AskAIStreamParser.swift +// Cryptohopper-iOS-SDK +// + +import Foundation + +/// Incremental parser for the Ask AI SSE stream. +/// Feed raw body chunks as they arrive; complete frames (separated by a +/// blank line) are parsed into events. Buffers as Data so multi-byte +/// UTF-8 characters split across chunks survive. +public final class AskAIStreamParser { + + private var buffer = Data() + + public init() {} + + public func feed(_ chunk: Data) -> [AskAIStreamEvent] { + buffer.append(chunk) + var events: [AskAIStreamEvent] = [] + let lflf = Data("\n\n".utf8) + let crlfcrlf = Data("\r\n\r\n".utf8) + while true { + let lfRange = buffer.range(of: lflf) + let crRange = buffer.range(of: crlfcrlf) + let separator: Range + switch (lfRange, crRange) { + case (nil, nil): + return events + case (let lf?, nil): + separator = lf + case (nil, let cr?): + separator = cr + case (let lf?, let cr?): + separator = lf.lowerBound < cr.lowerBound ? lf : cr + } + let frameData = buffer.subdata(in: buffer.startIndex.. [AskAIStreamEvent] { + defer { buffer.removeAll() } + guard !buffer.isEmpty, let event = Self.parseFrame(buffer) else { return [] } + return [event] + } + + static func parseFrame(_ frameData: Data) -> AskAIStreamEvent? { + guard let raw = String(data: frameData, encoding: .utf8) else { return nil } + // Normalize CRLF to LF before splitting: Swift's String treats "\r\n" + // as a single Character (grapheme cluster), so splitting on the "\n" + // Character alone would never break at a CRLF line ending. + let normalized = raw.replacingOccurrences(of: "\r\n", with: "\n") + let lines = normalized.split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "\r")) } + + var eventName: String? + var dataLines: [String] = [] + for line in lines { + if line.hasPrefix("event:") { + if eventName == nil { + eventName = String(line.dropFirst("event:".count)).trimmingCharacters(in: .whitespaces) + } + } else if line.hasPrefix("data:") { + var value = String(line.dropFirst("data:".count)) + if value.hasPrefix(" ") { value.removeFirst() } + dataLines.append(value) + } + // Lines starting with ":" are comments (pings); ignore. + } + + let joined = dataLines.joined(separator: "\n") + guard let jsonData = joined.data(using: .utf8), + let payload = (try? JSONSerialization.jsonObject(with: jsonData)) as? [String: Any] else { + return nil + } + + let kind = eventName ?? (payload["type"] as? String) + switch kind { + case "delta": + guard let content = payload["content"] as? String else { return nil } + return .delta(text: content) + case "done", "final": + if let errorMessage = payload["error"] as? String, !errorMessage.isEmpty { + return .error(message: errorMessage) + } + return .done(runId: payload["run_id"] as? String, + sessionId: payload["session_id"] as? String) + case "error": + return .error(message: (payload["message"] as? String) ?? "Unknown stream error") + default: + // conversation / tool_call / tool_result / metadata / unknown: ignore. + return nil + } + } +} diff --git a/Cryptohopper-iOS-SDKTests/APISpec/AI/AskAIStreamParserSpec.swift b/Cryptohopper-iOS-SDKTests/APISpec/AI/AskAIStreamParserSpec.swift new file mode 100644 index 0000000..4793ca2 --- /dev/null +++ b/Cryptohopper-iOS-SDKTests/APISpec/AI/AskAIStreamParserSpec.swift @@ -0,0 +1,105 @@ +import Foundation +import XCTest +import Quick +import Nimble +@testable import Cryptohopper_iOS_SDK + +class AskAIStreamParserSpec: QuickSpec { + + override func spec() { + + var parser: AskAIStreamParser! + + beforeEach { + parser = AskAIStreamParser() + } + + func feed(_ text: String) -> [AskAIStreamEvent] { + return parser.feed(text.data(using: .utf8)!) + } + + context("single complete frames") { + it("parses a delta frame") { + let events = feed("event: delta\ndata: {\"content\": \"Hello\"}\n\n") + expect(events.count).to(equal(1)) + guard case .delta(let text)? = events.first else { return fail("expected delta") } + expect(text).to(equal("Hello")) + } + it("parses a done frame with run and session ids") { + let events = feed("event: done\ndata: {\"run_id\": \"r1\", \"session_id\": \"s1\"}\n\n") + guard case .done(let runId, let sessionId)? = events.first else { return fail("expected done") } + expect(runId).to(equal("r1")) + expect(sessionId).to(equal("s1")) + } + it("maps a done frame carrying an error field to an error event") { + let events = feed("event: done\ndata: {\"error\": \"boom\"}\n\n") + guard case .error(let message)? = events.first else { return fail("expected error") } + expect(message).to(equal("boom")) + } + it("parses final as done") { + let events = feed("event: final\ndata: {\"run_id\": \"r2\"}\n\n") + guard case .done(let runId, _)? = events.first else { return fail("expected done") } + expect(runId).to(equal("r2")) + } + it("parses an error frame") { + let events = feed("event: error\ndata: {\"message\": \"nope\"}\n\n") + guard case .error(let message)? = events.first else { return fail("expected error") } + expect(message).to(equal("nope")) + } + it("uses the payload type field when the event line is missing") { + let events = feed("data: {\"type\": \"delta\", \"content\": \"x\"}\n\n") + guard case .delta(let text)? = events.first else { return fail("expected delta") } + expect(text).to(equal("x")) + } + } + + context("frames to ignore") { + it("ignores conversation, tool and metadata events without failing") { + let raw = "event: conversation\ndata: {\"conversation_id\": \"c1\"}\n\n" + + "event: tool_call\ndata: {\"tool_call_id\": \"t\", \"tool_name\": \"n\"}\n\n" + + "event: metadata\ndata: {\"outcome\": \"answered\"}\n\n" + + "event: delta\ndata: {\"content\": \"kept\"}\n\n" + let events = feed(raw) + expect(events.count).to(equal(1)) + } + it("ignores comment/ping frames") { + let events = feed(": ping\n\nevent: delta\ndata: {\"content\": \"a\"}\n\n") + expect(events.count).to(equal(1)) + } + it("ignores malformed json frames") { + let events = feed("event: delta\ndata: {broken\n\nevent: delta\ndata: {\"content\": \"ok\"}\n\n") + expect(events.count).to(equal(1)) + } + } + + context("chunk boundaries") { + it("assembles a frame split across chunks, including mid-character splits") { + let full = "event: delta\ndata: {\"content\": \"héllo\"}\n\n".data(using: .utf8)! + // split inside the two-byte "é" + let splitIndex = full.range(of: "h".data(using: .utf8)!)!.upperBound + 1 + var events = parser.feed(full.subdata(in: 0.. Date: Tue, 21 Jul 2026 11:56:38 +0200 Subject: [PATCH 2/7] feat: add performAskAIStreaming with SSE + JSON fallback --- .../project.pbxproj | 4 + .../HopperAPIAskAIStreamingRequest.swift | 152 ++++++++++++++++++ .../APIContext/CryptohopperUser.swift | 16 +- 3 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/HopperAPIAskAIStreamingRequest.swift diff --git a/Cryptohopper-iOS-SDK.xcodeproj/project.pbxproj b/Cryptohopper-iOS-SDK.xcodeproj/project.pbxproj index 0bbca53..8e5811f 100644 --- a/Cryptohopper-iOS-SDK.xcodeproj/project.pbxproj +++ b/Cryptohopper-iOS-SDK.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 0CCD96FB134CBEA7B93F5E8A /* HopperAPIAskAIStreamingRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5587C9978A676EF22081303D /* HopperAPIAskAIStreamingRequest.swift */; }; 375AE192EF5DDEF2CA81F7ED /* AskAIStreamParserSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7933BD30DEFF47AD510CCDC9 /* AskAIStreamParserSpec.swift */; }; 59576957B76E88660EDCD9F6 /* Pods_Cryptohopper_iOS_SDKTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F006D690718FB3AD4ACF2C61 /* Pods_Cryptohopper_iOS_SDKTests.framework */; }; 631307BD7693BAAF7B72B206 /* AskAIStreamEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 211C9DED44706861D3FE369A /* AskAIStreamEvent.swift */; }; @@ -489,6 +490,7 @@ /* Begin PBXFileReference section */ 1A1FB7E16BBDF708C509A103 /* Pods-Cryptohopper-iOS-SDKTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Cryptohopper-iOS-SDKTests.debug.xcconfig"; path = "Target Support Files/Pods-Cryptohopper-iOS-SDKTests/Pods-Cryptohopper-iOS-SDKTests.debug.xcconfig"; sourceTree = ""; }; 211C9DED44706861D3FE369A /* AskAIStreamEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AskAIStreamEvent.swift; sourceTree = ""; }; + 5587C9978A676EF22081303D /* HopperAPIAskAIStreamingRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HopperAPIAskAIStreamingRequest.swift; sourceTree = ""; }; 59BF9CCB664C3FC5A211DB13 /* Pods_Cryptohopper_iOS_SDK.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Cryptohopper_iOS_SDK.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7933BD30DEFF47AD510CCDC9 /* AskAIStreamParserSpec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AskAIStreamParserSpec.swift; sourceTree = ""; }; ACA94F67F40DFEC2AE6EB5E6 /* Pods-Cryptohopper-iOS-SDK.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Cryptohopper-iOS-SDK.debug.xcconfig"; path = "Target Support Files/Pods-Cryptohopper-iOS-SDK/Pods-Cryptohopper-iOS-SDK.debug.xcconfig"; sourceTree = ""; }; @@ -1004,6 +1006,7 @@ children = ( 211C9DED44706861D3FE369A /* AskAIStreamEvent.swift */, B8CE3F96121E01342108AEAE /* AskAIStreamParser.swift */, + 5587C9978A676EF22081303D /* HopperAPIAskAIStreamingRequest.swift */, ); name = Streaming; path = Streaming; @@ -4714,6 +4717,7 @@ D0231F5B2552F0AE0057A293 /* HopperAPIGetStrategyByIdResponse.swift in Sources */, 631307BD7693BAAF7B72B206 /* AskAIStreamEvent.swift in Sources */, D6C357EB6E8B7C3E1D33FC41 /* AskAIStreamParser.swift in Sources */, + 0CCD96FB134CBEA7B93F5E8A /* HopperAPIAskAIStreamingRequest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/HopperAPIAskAIStreamingRequest.swift b/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/HopperAPIAskAIStreamingRequest.swift new file mode 100644 index 0000000..ce4ad9d --- /dev/null +++ b/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/HopperAPIAskAIStreamingRequest.swift @@ -0,0 +1,152 @@ +// +// HopperAPIAskAIStreamingRequest.swift +// Cryptohopper-iOS-SDK +// + +import Foundation +import UIKit + +/// Calls POST /v1/user/askai and handles BOTH response shapes: +/// - text/event-stream (AI Gateway active): incremental AskAIStreamEvents +/// - application/json (legacy path): decoded AskAIAnswer in the completion +final class HopperAPIAskAIStreamingRequest: NSObject, URLSessionDataDelegate { + + private let page: String + private let question: String + private let onEvent: (AskAIStreamEvent) -> Void + private let completion: (Result) -> Void + + private let parser = AskAIStreamParser() + private var urlSession: URLSession? + private var isEventStream = false + private var jsonBody = Data() + private var completed = false + + init(page: String, + question: String, + onEvent: @escaping (AskAIStreamEvent) -> Void, + completion: @escaping (Result) -> Void) { + self.page = page + self.question = question + self.onEvent = onEvent + self.completion = completion + super.init() + } + + func start() { + HopperAPISessionManager.shared.checkAuthentication(onSuccess: { [weak self] in + guard let self = self else { return } + guard let accessToken = HopperAPISessionManager.shared.session?.accessToken else { + self.finish(.failure(HopperError.missingAccessToken)) + return + } + self.startRequest(accessToken: accessToken) + }, onFail: { [weak self] error in + self?.finish(.failure(error)) + }) + } + + private func startRequest(accessToken: String) { + var components = URLComponents() + let config = HopperAPIConfigurationManager.shared.config + components.scheme = config.urlScheme + components.host = config.host + components.path = "/v1/user/askai" + guard let url = components.url else { + finish(.failure(HopperError.missingUrl)) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("text/event-stream, application/json", forHTTPHeaderField: "Accept") + request.setValue("iOS", forHTTPHeaderField: "Platform") + request.setValue(UIDevice.current.identifierForVendor?.uuidString ?? "", forHTTPHeaderField: "DeviceId") + request.setValue(config.apiBasicValidationValue, forHTTPHeaderField: config.apiBasicValidationKey) + request.setValue(accessToken, forHTTPHeaderField: "access-token") + request.httpBody = try? JSONSerialization.data(withJSONObject: ["page": page, "question": question]) + + let sessionConfig = URLSessionConfiguration.default + sessionConfig.timeoutIntervalForRequest = 120 // gateway upstream timeout is 90s + let session = URLSession(configuration: sessionConfig, delegate: self, delegateQueue: nil) + urlSession = session + session.dataTask(with: request).resume() + } + + // MARK: - URLSessionDataDelegate + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + let contentType = Self.headerValue("Content-Type", in: response)?.lowercased() ?? "" + isEventStream = contentType.contains("text/event-stream") + completionHandler(.allow) + } + + /// Case-insensitive header lookup. `HTTPURLResponse.value(forHTTPHeaderField:)` + /// is iOS 13+ only; this SDK's deployment target is iOS 12, so headers are + /// read directly from `allHeaderFields` instead. + private static func headerValue(_ name: String, in response: URLResponse?) -> String? { + guard let httpResponse = response as? HTTPURLResponse else { return nil } + for (key, value) in httpResponse.allHeaderFields { + if let key = key as? String, key.caseInsensitiveCompare(name) == .orderedSame { + return value as? String + } + } + return nil + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + if isEventStream { + emit(parser.feed(data)) + } else { + jsonBody.append(data) + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + defer { urlSession?.finishTasksAndInvalidate() } + if let error = error { + finish(.failure(error)) + return + } + if isEventStream { + emit(parser.flush()) + finish(.success(nil)) + return + } + // JSON fallback: decode like HopperAPIPerformAskAIRequest does. + let statusCode = (task.response as? HTTPURLResponse)?.statusCode ?? 0 + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + if statusCode >= 200 && statusCode < 300 { + let response = try decoder.decode(HopperAPIPerformAskAIResponse.self, from: jsonBody) + finish(.success(response.answer)) + } else { + let apiError = try decoder.decode(HopperAPIError.self, from: jsonBody) + finish(.failure(apiError.error)) + } + } catch { + finish(.failure(error)) + } + } + + // MARK: - Helpers + + private func emit(_ events: [AskAIStreamEvent]) { + guard !events.isEmpty else { return } + DispatchQueue.main.async { + events.forEach { self.onEvent($0) } + } + } + + private func finish(_ result: Result) { + guard !completed else { return } + completed = true + DispatchQueue.main.async { + self.completion(result) + } + } +} diff --git a/Cryptohopper-iOS-SDK/APIContext/CryptohopperUser.swift b/Cryptohopper-iOS-SDK/APIContext/CryptohopperUser.swift index 8332a78..4744465 100644 --- a/Cryptohopper-iOS-SDK/APIContext/CryptohopperUser.swift +++ b/Cryptohopper-iOS-SDK/APIContext/CryptohopperUser.swift @@ -434,7 +434,21 @@ import UIKit completion(.failure(err)) } } - + + /// Ask AI with streaming support. onEvent fires per SSE event (main + /// queue). completion fires once: AskAIAnswer for a plain-JSON reply, + /// nil after a successful stream, or an error. + public static func performAskAIStreaming(page: String, + question: String, + onEvent: @escaping (AskAIStreamEvent) -> Void, + completion: @escaping (Result) -> Void) { + let request = HopperAPIAskAIStreamingRequest(page: page, + question: question, + onEvent: onEvent, + completion: completion) + request.start() + } + /*! * @discussion Vote Ask AI (Feedback) * From 2701ee34fe5b4e7f1fcd56df10b5545ed71f8d75 Mon Sep 17 00:00:00 2001 From: The Best Developer ever lived Date: Tue, 21 Jul 2026 11:58:04 +0200 Subject: [PATCH 3/7] chore: restore pbxproj comment lines rewritten by xcodeproj gem Co-Authored-By: Claude Fable 5 --- Cryptohopper-iOS-SDK.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cryptohopper-iOS-SDK.xcodeproj/project.pbxproj b/Cryptohopper-iOS-SDK.xcodeproj/project.pbxproj index 8e5811f..23d5cc4 100644 --- a/Cryptohopper-iOS-SDK.xcodeproj/project.pbxproj +++ b/Cryptohopper-iOS-SDK.xcodeproj/project.pbxproj @@ -240,7 +240,7 @@ D02AAFC3283B83050044BCE7 /* HopperAPIV2GetAllCurrenciesRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02AAFC2283B83050044BCE7 /* HopperAPIV2GetAllCurrenciesRequest.swift */; }; D02AAFD0283B887D0044BCE7 /* V2Currency.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02AAFCF283B887D0044BCE7 /* V2Currency.swift */; }; D02AAFD5283B8B370044BCE7 /* CurrencyAPIV2Spec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02AAFD4283B8B370044BCE7 /* CurrencyAPIV2Spec.swift */; }; - D02AB037283BA72A0044BCE7 /* BuildFile in Embed Frameworks */ = {isa = PBXBuildFile; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + D02AB037283BA72A0044BCE7 /* (null) in Embed Frameworks */ = {isa = PBXBuildFile; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D02AB054283BBD850044BCE7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02AB053283BBD850044BCE7 /* AppDelegate.swift */; }; D02AB056283BBD850044BCE7 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02AB055283BBD850044BCE7 /* SceneDelegate.swift */; }; D02AB058283BBD850044BCE7 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02AB057283BBD850044BCE7 /* ViewController.swift */; }; @@ -480,7 +480,7 @@ dstSubfolderSpec = 10; files = ( D0F8C503283E5273006A8CE7 /* Pods_Cryptohopper_iOS_SDK.framework in Embed Frameworks */, - D02AB037283BA72A0044BCE7 /* BuildFile in Embed Frameworks */, + D02AB037283BA72A0044BCE7 /* (null) in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; From 5b0bfa7185dfa2f7fe947d6ee678af04169f9ece Mon Sep 17 00:00:00 2001 From: The Best Developer ever lived Date: Tue, 21 Jul 2026 12:07:51 +0200 Subject: [PATCH 4/7] fix: retain HopperAPIAskAIStreamingRequest through auth-check window start() captured self weakly in checkAuthentication's onSuccess/onFail closures, but the only strong reference to the request lives in the caller's local variable, which goes out of scope when performAskAIStreaming returns. checkAuthentication's token-refresh path is async, so on an expired access token the request object could deallocate before the callback ran, silently dropping the request and never firing completion. Capture self strongly instead so the closure keeps the request alive until startRequest(accessToken:) hands retention to the URLSession delegate, or the fail path calls finish(...). --- .../Streaming/HopperAPIAskAIStreamingRequest.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/HopperAPIAskAIStreamingRequest.swift b/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/HopperAPIAskAIStreamingRequest.swift index ce4ad9d..5ae021e 100644 --- a/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/HopperAPIAskAIStreamingRequest.swift +++ b/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/HopperAPIAskAIStreamingRequest.swift @@ -34,15 +34,21 @@ final class HopperAPIAskAIStreamingRequest: NSObject, URLSessionDataDelegate { } func start() { - HopperAPISessionManager.shared.checkAuthentication(onSuccess: { [weak self] in - guard let self = self else { return } + // Capture self STRONGLY here: the only strong reference to this request + // lives in the caller's local variable, which goes out of scope as soon + // as `start()` returns. checkAuthentication's refresh path is async, so + // a weak capture would let this object deallocate before the callback + // runs, silently dropping the request. This closure keeps it alive + // until startRequest(accessToken:) hands retention to the URLSession + // delegate, or the fail path calls finish(...). + HopperAPISessionManager.shared.checkAuthentication(onSuccess: { guard let accessToken = HopperAPISessionManager.shared.session?.accessToken else { self.finish(.failure(HopperError.missingAccessToken)) return } self.startRequest(accessToken: accessToken) - }, onFail: { [weak self] error in - self?.finish(.failure(error)) + }, onFail: { error in + self.finish(.failure(error)) }) } From c64a8fdb0d26b48ae2cc89fc7692f2624c4fe5b6 Mon Sep 17 00:00:00 2001 From: The Best Developer ever lived Date: Tue, 21 Jul 2026 12:47:07 +0200 Subject: [PATCH 5/7] fix: post CH_DEVICE_UNAUTHORIZED on 402 in streaming request HopperAPIAskAIStreamingRequest's JSON fallback completion path decoded non-2xx responses into an error but never posted CH_DEVICE_UNAUTHORIZED on HTTP 402, unlike HopperAPIRequest.startRequest's common path. Mirror both of that path's 402 checks: a non-2xx HTTP 402, and a 2xx response whose body is a HopperCommonMessageResponse with error != nil and status == 402. Both now post the notification and fail with HopperError.DeviceUnauthorized. --- .../HopperAPIAskAIStreamingRequest.swift | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/HopperAPIAskAIStreamingRequest.swift b/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/HopperAPIAskAIStreamingRequest.swift index 5ae021e..2b063a7 100644 --- a/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/HopperAPIAskAIStreamingRequest.swift +++ b/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/HopperAPIAskAIStreamingRequest.swift @@ -128,9 +128,22 @@ final class HopperAPIAskAIStreamingRequest: NSObject, URLSessionDataDelegate { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .secondsSince1970 if statusCode >= 200 && statusCode < 300 { + // Mirror HopperAPIRequest.startRequest's responseOK check: a 2xx + // body can still carry a common error payload with status 402. + if let errCode = try? decoder.decode(HopperCommonMessageResponse.self, from: jsonBody), + errCode.error != nil, errCode.status == 402 { + triggerDeviceUnauthorized() + finish(.failure(HopperError.DeviceUnauthorized)) + return + } let response = try decoder.decode(HopperAPIPerformAskAIResponse.self, from: jsonBody) finish(.success(response.answer)) } else { + if statusCode == 402 { + triggerDeviceUnauthorized() + finish(.failure(HopperError.DeviceUnauthorized)) + return + } let apiError = try decoder.decode(HopperAPIError.self, from: jsonBody) finish(.failure(apiError.error)) } @@ -141,6 +154,13 @@ final class HopperAPIAskAIStreamingRequest: NSObject, URLSessionDataDelegate { // MARK: - Helpers + /// Mirrors HopperAPIRequest.triggerDeviceUnauthorized(): posts the same + /// notification so observers (e.g. a global logout handler) react the + /// same way regardless of which request path hit the 402. + private func triggerDeviceUnauthorized() { + NotificationCenter.default.post(name: NSNotification.Name(rawValue: "CH_DEVICE_UNAUTHORIZED"), object: nil) + } + private func emit(_ events: [AskAIStreamEvent]) { guard !events.isEmpty else { return } DispatchQueue.main.async { From 9848dda2b136b7a5cd5f591191fe15e3415d60ba Mon Sep 17 00:00:00 2001 From: The Best Developer ever lived Date: Tue, 21 Jul 2026 15:17:52 +0200 Subject: [PATCH 6/7] fix: detect SSE streams mislabeled as application/json The public API router sets a global application/json header before the askai SSE branch runs, so streams arrive with the wrong Content-Type. Sniff the body (SSE starts with "event:", "data:" or a comment line) both on first chunk and at completion. Co-Authored-By: Claude Fable 5 --- .../HopperAPIAskAIStreamingRequest.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/HopperAPIAskAIStreamingRequest.swift b/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/HopperAPIAskAIStreamingRequest.swift index 2b063a7..2e16a5b 100644 --- a/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/HopperAPIAskAIStreamingRequest.swift +++ b/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/HopperAPIAskAIStreamingRequest.swift @@ -90,6 +90,14 @@ final class HopperAPIAskAIStreamingRequest: NSObject, URLSessionDataDelegate { completionHandler(.allow) } + /// SSE bodies start with "event:", "data:" or a ":" comment line + /// (optionally after whitespace); JSON starts with "{" or "[". + private static func looksLikeEventStream(_ body: Data) -> Bool { + guard let prefix = String(data: body.prefix(32), encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), !prefix.isEmpty else { return false } + return prefix.hasPrefix("event:") || prefix.hasPrefix("data:") || prefix.hasPrefix(":") + } + /// Case-insensitive header lookup. `HTTPURLResponse.value(forHTTPHeaderField:)` /// is iOS 13+ only; this SDK's deployment target is iOS 12, so headers are /// read directly from `allHeaderFields` instead. @@ -104,6 +112,15 @@ final class HopperAPIAskAIStreamingRequest: NSObject, URLSessionDataDelegate { } func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + if !isEventStream && jsonBody.isEmpty { + // The public API router sets a global application/json header + // before the askai SSE branch runs, so a stream can arrive + // mislabeled. Sniff the first chunk: SSE bodies start with + // "event:", "data:" or a ":" comment line; JSON never does. + if Self.looksLikeEventStream(data) { + isEventStream = true + } + } if isEventStream { emit(parser.feed(data)) } else { @@ -117,6 +134,12 @@ final class HopperAPIAskAIStreamingRequest: NSObject, URLSessionDataDelegate { finish(.failure(error)) return } + // Belt and braces: a fully buffered, mislabeled stream (or one whose + // first chunk was too short to classify) is still parseable here. + if !isEventStream && Self.looksLikeEventStream(jsonBody) { + isEventStream = true + emit(parser.feed(jsonBody)) + } if isEventStream { emit(parser.flush()) finish(.success(nil)) From 63a07ecf79e1c78a60c603753b7801302e4399df Mon Sep 17 00:00:00 2001 From: The Best Developer ever lived Date: Tue, 21 Jul 2026 21:45:33 +0200 Subject: [PATCH 7/7] fix: tolerate bare-string askai answers in data The monolith's suggestion branch returns the answer as a plain string in `data` instead of the {id, content, ...} object the typed-question path uses; decode fell over on the type mismatch. Fall back to wrapping the string in an AskAIAnswer. Co-Authored-By: Claude Opus 4.8 --- .../HopperAPIPerformAskAIResponse.swift | 18 +++++++++++++++--- .../V1Models/User/Models/AskAIAnswer.swift | 8 +++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/Cryptohopper-iOS-SDK/API/User/AskAI/PerformAskAI/HopperAPIPerformAskAIResponse.swift b/Cryptohopper-iOS-SDK/API/User/AskAI/PerformAskAI/HopperAPIPerformAskAIResponse.swift index 7c11a3d..f7825fc 100644 --- a/Cryptohopper-iOS-SDK/API/User/AskAI/PerformAskAI/HopperAPIPerformAskAIResponse.swift +++ b/Cryptohopper-iOS-SDK/API/User/AskAI/PerformAskAI/HopperAPIPerformAskAIResponse.swift @@ -6,12 +6,24 @@ import Foundation class HopperAPIPerformAskAIResponse: Codable { - + var answer: AskAIAnswer? - + private enum CodingKeys: String, CodingKey { case answer = "data" } - + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let object = try? container.decodeIfPresent(AskAIAnswer.self, forKey: .answer) { + answer = object + } else if let text = try? container.decodeIfPresent(String.self, forKey: .answer) { + // Suggestion answers come back as a bare string in `data` + // (askAiSuggestion in the monolith) instead of the object the + // typed-question path returns. + answer = AskAIAnswer(content: text) + } + } + } diff --git a/Cryptohopper-iOS-SDK/SharedModels/V1Models/User/Models/AskAIAnswer.swift b/Cryptohopper-iOS-SDK/SharedModels/V1Models/User/Models/AskAIAnswer.swift index 95dc129..93c7390 100644 --- a/Cryptohopper-iOS-SDK/SharedModels/V1Models/User/Models/AskAIAnswer.swift +++ b/Cryptohopper-iOS-SDK/SharedModels/V1Models/User/Models/AskAIAnswer.swift @@ -20,6 +20,12 @@ public class AskAIAnswer: Codable { case question = "question" case voteId = "vote_id" } - + + /// Suggestion answers arrive from the API as a bare string in `data` + /// instead of an object; this lets the response wrap them. + internal init(content: String?) { + self.content = content + } + }