Skip to content

Commit 76ad933

Browse files
sebstoCopilot
andauthored
Refactor the streaming part. (#20)
* Transform Role to a struct * fix swift format * fix swift-format * rework the converse streamin part * swift-format * Update Sources/Converse/Streaming/ConverseReplyStream.swift Co-authored-by: Copilot <[email protected]> * Update Sources/Converse/Streaming/ConverseReplyStream.swift Co-authored-by: Copilot <[email protected]> * Update Sources/Converse/Streaming/ConverseReplyStream.swift Co-authored-by: Copilot <[email protected]> * fix license header * simplify guard statement * minor API update * swift-format * improve Converse Example * add customstringconvertible * convenient access to text content * swift-format * add converse stream example * update converse stream example --------- Co-authored-by: Copilot <[email protected]>
1 parent dd52fd2 commit 76ad933

24 files changed

+1019
-1150
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.DS_Store
2+
/.build
3+
/Packages
4+
xcuserdata/
5+
DerivedData/
6+
.swiftpm/configuration/registries.json
7+
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8+
.netrc
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// swift-tools-version: 6.1
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
import PackageDescription
5+
6+
let package = Package(
7+
name: "ConverseStream",
8+
platforms: [.macOS(.v15), .iOS(.v18), .tvOS(.v18)],
9+
products: [
10+
.executable(name: "ConverseStream", targets: ["ConverseStream"])
11+
],
12+
dependencies: [
13+
// for production use, uncomment the following line
14+
// .package(url: "https://github.com/build-on-aws/swift-bedrock-library.git", branch: "main"),
15+
16+
// for local development, use the following line
17+
.package(name: "swift-bedrock-library", path: "../.."),
18+
19+
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"),
20+
],
21+
targets: [
22+
// Targets are the basic building blocks of a package, defining a module or a test suite.
23+
// Targets can depend on other targets in this package and products from dependencies.
24+
.executableTarget(
25+
name: "ConverseStream",
26+
dependencies: [
27+
.product(name: "BedrockService", package: "swift-bedrock-library"),
28+
.product(name: "Logging", package: "swift-log"),
29+
]
30+
)
31+
]
32+
)
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift Bedrock Library open source project
4+
//
5+
// Copyright (c) 2025 Amazon.com, Inc. or its affiliates
6+
// and the Swift Bedrock Library project authors
7+
// Licensed under Apache License v2.0
8+
//
9+
// See LICENSE.txt for license information
10+
// See CONTRIBUTORS.txt for the list of Swift Bedrock Library project authors
11+
//
12+
// SPDX-License-Identifier: Apache-2.0
13+
//
14+
//===----------------------------------------------------------------------===//
15+
16+
import BedrockService
17+
import Logging
18+
19+
@main
20+
struct Main {
21+
static func main() async throws {
22+
do {
23+
try await Main.converseStream()
24+
} catch {
25+
print("Error:\n\(error)")
26+
}
27+
}
28+
static func converseStream() async throws {
29+
var logger = Logger(label: "Converse")
30+
logger.logLevel = .debug
31+
32+
let bedrock = try await BedrockService(
33+
region: .useast1,
34+
logger: logger
35+
// uncomment if you use SSO with AWS Identity Center
36+
// authentication: .sso
37+
)
38+
39+
// select a model that supports the converse modality
40+
// models must be enabled in your AWS account
41+
let model: BedrockModel = .nova_lite
42+
43+
guard model.hasConverseModality() else {
44+
throw MyError.incorrectModality("\(model.name) does not support converse")
45+
}
46+
47+
// create a request
48+
let builder = try ConverseRequestBuilder(with: model)
49+
.withPrompt("Tell me about rainbows")
50+
51+
// send the request
52+
let reply = try await bedrock.converseStream(with: builder)
53+
54+
// the reply gives access to two streams.
55+
// 1. `stream` is a high-level stream that provides elements of the conversation :
56+
// - messageStart: this is the start of a message, it contains the role (assistant or user)
57+
// - text: this is a delta of the text content, it contains the partial text
58+
// - reasoning: this is a delta of the reasoning content, it contains the partial reasoning text
59+
// - toolUse: this is a buffered tool use response, it contains the tool name and id, and the input parameters
60+
// - message complete: this includes the complete Message, ready to be added to the history and used for future requests
61+
// - metaData: this is the metadata about the response, it contains statitics about the response, such as the number of tokens used and the latency
62+
//
63+
// 2. `sdkStream` is the low-level stream provided by the AWS SDK. Use it when you need low level access to the stream,
64+
// such as when you want to handle the stream in a custom way or when you need to access the raw data.
65+
// see : https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference-call.html#conversation-inference-call-response-converse-stream
66+
for try await element in reply.stream {
67+
// process the stream elements
68+
switch element {
69+
case .messageStart(let role):
70+
logger.info("Message started with role: \(role)")
71+
case .text(_, let text):
72+
print(text, terminator: "")
73+
case .reasoning(let index, let reasoning):
74+
logger.info("Reasoning delta: \(reasoning)", metadata: ["index": "\(index)"])
75+
case .toolUse(let index, let toolUse):
76+
logger.info(
77+
"Tool use: \(toolUse.name) with id: \(toolUse.id) and input: \(toolUse.input)",
78+
metadata: ["index": "\(index)"]
79+
)
80+
case .messageComplete(_):
81+
print("\n")
82+
case .metaData(let metaData):
83+
logger.info("Metadata: \(metaData)")
84+
}
85+
}
86+
}
87+
88+
enum MyError: Error {
89+
case incorrectModality(String)
90+
}
91+
}

Examples/converse/Package.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ let package = Package(
1010
.executable(name: "Converse", targets: ["Converse"])
1111
],
1212
dependencies: [
13-
.package(url: "https://github.com/build-on-aws/swift-bedrock-library.git", branch: "main"),
13+
// for production use, uncomment the following line
14+
// .package(url: "https://github.com/build-on-aws/swift-bedrock-library.git", branch: "main"),
15+
16+
// for local development, use the following line
17+
.package(name: "swift-bedrock-library", path: "../.."),
18+
1419
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"),
1520
],
1621
targets: [

Examples/converse/Sources/Converse.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ struct Main {
3333
region: .useast1,
3434
logger: logger
3535
// uncomment if you use SSO with AWS Identity Center
36-
// authentication: .sso
36+
// authentication: .sso
3737
)
3838

3939
// select a model that supports the converse modality

README.md

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -125,38 +125,45 @@ The stream will contain `ConverseStreamElement` object that can either be `conte
125125
To create the next builder, with the same model and inference parameters, use the full message from the `.messageComplete`.
126126

127127
```swift
128-
let model: BedrockModel = .nova_lite
128+
let model: BedrockModel = .nova_lite
129129

130-
guard model.hasConverseModality() else {
131-
throw MyError.incorrectModality("\(model.name) does not support converse")
132-
}
133-
guard model.hasConverseModality(.reasoning) else {
134-
throw MyError.incorrectModality("\(model.name) does not support reasoning")
135-
}
130+
guard model.hasConverseModality() else {
131+
throw MyError.incorrectModality("\(model.name) does not support converse")
132+
}
136133

137-
var builder = try ConverseRequestBuilder(from: builder, with: reply)
138-
.withPrompt("Tell me more about the birds in Paris")
134+
// create a request
135+
let builder = try ConverseRequestBuilder(with: model)
136+
.withPrompt("Tell me about rainbows")
139137

140-
let stream = try await bedrock.converseStream(with: builder)
138+
// send the request
139+
let reply = try await bedrock.converseStream(with: builder)
141140

142-
for try await element in stream {
143-
switch element {
144-
case .contentSegment(let contentSegment):
145-
switch contentSegment {
146-
case .text(_, let text):
147-
print(text, terminator: "")
148-
default:
149-
break
150-
}
151-
case .contentBlockComplete:
152-
print("\n\n")
153-
case .messageComplete(let message):
154-
assistantMessage = message
155-
}
156-
}
141+
// consume the stream of elements
142+
for try await element in reply.stream {
157143

158-
builder = try ConverseRequestBuilder(from: builder, with: assistantMessage)
159-
.withPrompt("And what about the rats?")
144+
switch element {
145+
case .messageStart(let role):
146+
logger.info("Message started with role: \(role)")
147+
148+
case .text(_, let text):
149+
print(text, terminator: "")
150+
151+
case .reasoning(let index, let reasoning):
152+
logger.info("Reasoning delta: \(reasoning)", metadata: ["index": "\(index)"])
153+
154+
case .toolUse(let index, let toolUse):
155+
logger.info(
156+
"Tool use: \(toolUse.name) with id: \(toolUse.id) and input: \(toolUse.input)",
157+
metadata: ["index": "\(index)"]
158+
)
159+
160+
case .messageComplete(_):
161+
print("\n")
162+
163+
case .metaData(let metaData):
164+
logger.info("Metadata: \(metaData)")
165+
}
166+
}
160167
```
161168

162169
### Vision

Sources/Converse/BedrockService+ConverseStreaming.swift

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ extension BedrockService {
3434
/// BedrockLibraryError.invalidPrompt if the prompt is empty or too long
3535
/// BedrockLibraryError.invalidModality for invalid modality from the selected model
3636
/// BedrockLibraryError.invalidSDKResponse if the response body is missing
37-
/// - Returns: A stream of ConverseResponseStreaming objects
37+
/// - Returns: A ConverseReplyStream object that gives access to the high-level stream of ConverseStreamElements objects
38+
/// or the low-level stream provided by the AWS SDK.
3839
public func converseStream(
3940
with model: BedrockModel,
4041
conversation: [Message],
@@ -46,7 +47,7 @@ extension BedrockService {
4647
tools: [Tool]? = nil,
4748
enableReasoning: Bool? = false,
4849
maxReasoningTokens: Int? = nil
49-
) async throws -> AsyncThrowingStream<ConverseStreamElement, any Error> {
50+
) async throws -> ConverseReplyStream {
5051
do {
5152
guard model.hasConverseStreamingModality() else {
5253
throw BedrockLibraryError.invalidModality(
@@ -118,18 +119,18 @@ extension BedrockService {
118119
// - message metadata
119120
// see https://github.com/awslabs/aws-sdk-swift/blob/2697fb44f607b9c43ad0ce5ca79867d8d6c545c2/Sources/Services/AWSBedrockRuntime/Sources/AWSBedrockRuntime/Models.swift#L3478
120121
// it will be the responsibility of the user to handle the stream and re-assemble the messages and content
121-
// TODO: should we expose the SDK ConverseStreamOutput from the SDK ? or wrap it (what's the added value) ?
122122

123-
let reply = ConverseReplyStream(sdkStream)
123+
let reply = try ConverseReplyStream(sdkStream)
124124

125125
// this time, a different stream is created from the previous one, this one has the following elements
126-
// - content segment: this contains a ContentSegment, an enum which can be a .text(Int, String),
127-
// the integer is the id for the content block that the content segment is a part of,
128-
// the String is the part of text that is send from the model.
129-
// - content block complete: this includes the id of the completed content block and the complete content block itself
126+
// - messageStart: this is the start of a message, it contains the role (assistant or user)
127+
// - text: this is a delta of the text content, it contains the partial text
128+
// - reasoning: this is a delta of the reasoning content, it contains the partial reasoning text
129+
// - toolUse: this is a buffered tool use response, it contains the tool name and id, and the input parameters
130130
// - message complete: this includes the complete Message, ready to be added to the history and used for future requests
131+
// - metaData: this is the metadata about the response, it contains statitics about the response, such as the number of tokens used and the latency
131132

132-
return reply.stream
133+
return reply
133134

134135
} catch {
135136
try handleCommonError(error, context: "invoking converse stream")
@@ -143,7 +144,7 @@ extension BedrockService {
143144
/// - Returns: A stream of ConverseResponseStreaming objects
144145
public func converseStream(
145146
with builder: ConverseRequestBuilder
146-
) async throws -> AsyncThrowingStream<ConverseStreamElement, any Error> {
147+
) async throws -> ConverseReplyStream {
147148
logger.trace("Conversing and streaming")
148149
do {
149150
var history = builder.history

Sources/Converse/Message.swift

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,27 @@
1717
import Foundation
1818

1919
public struct Message: Codable, CustomStringConvertible, Sendable {
20+
21+
// https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_MessageStopEvent.html
22+
// end_turn | tool_use | max_tokens | stop_sequence | guardrail_intervened | content_filtered
23+
public enum StopReason: Codable, Sendable {
24+
case endTurn
25+
case toolUse
26+
case maxTokens
27+
case stopSequence
28+
case guardrailIntervened
29+
case contentFiltered
30+
}
2031
public let role: Role
2132
public let content: [Content]
33+
public let stopReason: StopReason?
2234

2335
// MARK - initializers
2436

25-
public init(from role: Role, content: [Content]) {
37+
public init(from role: Role, content: [Content], stopReason: StopReason? = nil) {
2638
self.role = role
2739
self.content = content
40+
self.stopReason = stopReason
2841
}
2942

3043
/// convenience initializer for message with only a user prompt
@@ -111,6 +124,15 @@ public struct Message: Codable, CustomStringConvertible, Sendable {
111124
public func hasTextContent() -> Bool {
112125
content.contains { $0.isText() }
113126
}
127+
public func textContent() -> String? {
128+
let content = content.first(where: { $0.isText() })
129+
if case .text(let text) = content {
130+
return text
131+
} else {
132+
return nil
133+
}
134+
}
135+
114136
public func hasImageContent() -> Bool {
115137
content.contains { $0.isImage() }
116138
}
@@ -137,4 +159,24 @@ public struct Message: Codable, CustomStringConvertible, Sendable {
137159
role: role.getSDKConversationRole()
138160
)
139161
}
162+
163+
public static func stopReason(fromSDK sdkStopReason: BedrockRuntimeClientTypes.StopReason?) -> StopReason? {
164+
switch sdkStopReason {
165+
case .endTurn:
166+
return .endTurn
167+
case .toolUse:
168+
return .toolUse
169+
case .maxTokens:
170+
return .maxTokens
171+
case .stopSequence:
172+
return .stopSequence
173+
case .guardrailIntervened:
174+
return .guardrailIntervened
175+
case .contentFiltered:
176+
return .contentFiltered
177+
default:
178+
return nil
179+
}
180+
}
181+
140182
}

Sources/Converse/Role.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
@preconcurrency import AWSBedrockRuntime
1717
import Foundation
1818

19-
public struct Role: Codable, Sendable, Equatable {
19+
public struct Role: Codable, Sendable, Equatable, CustomStringConvertible {
2020
private enum RoleType: Codable, Sendable, Equatable {
2121
case user
2222
case assistant
@@ -71,9 +71,19 @@ public struct Role: Codable, Sendable, Equatable {
7171
}
7272
}
7373
/// Returns the type of the role as a string.
74+
public var description: String {
75+
switch self.type {
76+
case .user: return "user"
77+
case .assistant: return "assistant"
78+
}
79+
}
80+
81+
// Equatable
7482
public static func == (lhs: Role, rhs: Role) -> Bool {
7583
lhs.type == rhs.type
7684
}
85+
86+
// convenience static properties for common roles
7787
private init(_ type: RoleType) {
7888
self.type = type
7989
}

0 commit comments

Comments
 (0)