Skip to content
34 changes: 34 additions & 0 deletions Cryptohopper-iOS-SDK.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
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 */; };
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 */; };
Expand Down Expand Up @@ -449,6 +452,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 */
Expand Down Expand Up @@ -485,8 +489,12 @@

/* 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 = "<group>"; };
211C9DED44706861D3FE369A /* AskAIStreamEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AskAIStreamEvent.swift; sourceTree = "<group>"; };
5587C9978A676EF22081303D /* HopperAPIAskAIStreamingRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HopperAPIAskAIStreamingRequest.swift; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
B8CE3F96121E01342108AEAE /* AskAIStreamParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AskAIStreamParser.swift; sourceTree = "<group>"; };
D0021D3B2718789F0050D7CC /* HopperAPIAuthWithCodeRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HopperAPIAuthWithCodeRequest.swift; sourceTree = "<group>"; };
D002BE80292B9F5300E74B6A /* HopperAPIAuthDeviceResendEmailRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HopperAPIAuthDeviceResendEmailRequest.swift; sourceTree = "<group>"; };
D002BE82292B9F6C00E74B6A /* HopperAPIAuthDeviceCheckIfAuthorizedRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HopperAPIAuthDeviceCheckIfAuthorizedRequest.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -964,6 +972,15 @@
/* End PBXFrameworksBuildPhase section */

/* Begin PBXGroup section */
1591570A537B53DD3C26F25D /* AskAI */ = {
isa = PBXGroup;
children = (
C4A9C4CBF2CB789F5B2682AC /* Streaming */,
);
name = AskAI;
path = AskAI;
sourceTree = "<group>";
};
1AF7BFB7D52C96D9AE8AE457 /* Pods */ = {
isa = PBXGroup;
children = (
Expand All @@ -984,6 +1001,17 @@
name = Frameworks;
sourceTree = "<group>";
};
C4A9C4CBF2CB789F5B2682AC /* Streaming */ = {
isa = PBXGroup;
children = (
211C9DED44706861D3FE369A /* AskAIStreamEvent.swift */,
B8CE3F96121E01342108AEAE /* AskAIStreamParser.swift */,
5587C9978A676EF22081303D /* HopperAPIAskAIStreamingRequest.swift */,
);
name = Streaming;
path = Streaming;
sourceTree = "<group>";
};
D0021D3A2718788F0050D7CC /* AuthWithCode */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -1413,6 +1441,7 @@
D0231D1025514BE40057A293 /* Subscription */,
D0AC8A11285B570B0071B90E /* CheckPasswordReset */,
D0AC8A10285B56FC0071B90E /* Check2FAReset */,
1591570A537B53DD3C26F25D /* AskAI */,
);
path = User;
sourceTree = "<group>";
Expand Down Expand Up @@ -2660,6 +2689,7 @@
isa = PBXGroup;
children = (
D047A8BD2779CAC4007D5485 /* AISpec.swift */,
7933BD30DEFF47AD510CCDC9 /* AskAIStreamParserSpec.swift */,
);
path = AI;
sourceTree = "<group>";
Expand Down Expand Up @@ -4685,6 +4715,9 @@
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 */,
0CCD96FB134CBEA7B93F5E8A /* HopperAPIAskAIStreamingRequest.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand All @@ -4709,6 +4742,7 @@
D0F8C501283E0D40006A8CE7 /* ExchangeAPIV2Spec.swift in Sources */,
D09CD4942840C83B009B503F /* TournamentAPIV2Spec.swift in Sources */,
D0F3D4EF25655F020036B130 /* UserSpec.swift in Sources */,
375AE192EF5DDEF2CA81F7ED /* AskAIStreamParserSpec.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — custom init(from:) never throws, so malformed/error data payloads decode as a valid answer with nil content.

Replacing the synthesized Codable init with this hand-written one changes behavior for both the new streaming fallback and the existing HopperAPIPerformAskAIRequest path (which decodes this same type via HopperAPIRequest.responseOK). Because neither try? branch rethrows, any data value that is neither an AskAIAnswer object nor a String (a number, an array, an error envelope, or a truncated body) leaves answer == nil and the decode succeeds. Previously such a body threw and reached onFail. Net effect: genuinely malformed responses are now reported to callers as an empty-but-successful answer instead of an error.

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)
}
}

}

Original file line number Diff line number Diff line change
@@ -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)
}
101 changes: 101 additions & 0 deletions Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/AskAIStreamParser.swift
Original file line number Diff line number Diff line change
@@ -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<Data.Index>
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..<separator.lowerBound)
buffer.removeSubrange(buffer.startIndex..<separator.upperBound)
if let event = Self.parseFrame(frameData) {
events.append(event)
}
}
}

/// Emit any trailing frame that never got its blank-line terminator
/// (e.g. the connection closed right after the last event).
public func flush() -> [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":

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — done/final handling drops the terminal event when data is not a JSON object, and ignores a non-string error.

parseFrame returns nil unless the joined data parses as [String: Any]. So a terminal frame whose payload is a plain sentinel (e.g. data: [DONE]) or is empty never produces a .done event, and any run_id/session_id it carried is lost. Separately, the error check payload["error"] as? String only fires for a string; if the backend sends "error": { ... } or a numeric code, it falls through to .done(...) and the failure is reported as a normal completion. Worth confirming the exact shapes the gateway emits for terminal/error frames.

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
}
}
}
Loading