Skip to content

Commit 64020ee

Browse files
renal128cursoragent
andcommitted
Replace ConversationCredentials with ConversationAuth.
Voice and text take different grants, passed to startVoiceConversation and startTextConversation. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 51f7119 commit 64020ee

24 files changed

Lines changed: 343 additions & 481 deletions

Examples/DemoApp/DemoApp/ContentView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,14 +89,14 @@ struct ContentView: View {
8989
}
9090
}
9191

92-
private var voiceAuth: WidgetConversationMode.VoiceAuth {
92+
private var voiceAuth: ConversationAuth.Voice {
9393
switch auth {
9494
case .publicAgent: .publicAgent(id: agentId.trimmed)
9595
case .backend: .conversationToken { [token = conversationToken.trimmed] in token }
9696
}
9797
}
9898

99-
private var textOnlyAuth: WidgetConversationMode.TextOnlyAuth {
99+
private var textOnlyAuth: ConversationAuth.TextOnly {
100100
switch auth {
101101
case .publicAgent: .publicAgent(id: agentId.trimmed)
102102
case .backend: .signedWebSocketURL { [url = signedWebSocketURL.trimmed] in url }

Sources/ElevenLabs/Internal/Authorization/TokenService.swift

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -48,22 +48,13 @@ struct TokenService: Sendable {
4848
///
4949
/// Translates internal `TokenError`s into public `ConversationError`s so
5050
/// callers only ever deal with one error type.
51-
func fetchToken(for credentials: ConversationCredentials) async throws -> String {
51+
func fetchToken(for auth: ConversationAuth.Voice, environment: String?) async throws -> String {
5252
do {
53-
switch credentials.authSource {
54-
case let .publicAgentId(agentId):
55-
return try await fetchTokenFromAPI(
56-
agentId: agentId,
57-
environment: credentials.environment
58-
)
59-
case let .conversationToken(conversationToken):
60-
return conversationToken
61-
case .signedWebSocketURL:
62-
throw ConversationError.authenticationFailed(
63-
"Signed WebSocket URLs are only supported for text-only conversations."
64-
)
65-
case let .customTokenProvider(provider):
66-
return try await provider()
53+
switch auth {
54+
case let .publicAgent(agentId):
55+
return try await fetchTokenFromAPI(agentId: agentId, environment: environment)
56+
case let .conversationToken(mint):
57+
return try await mint()
6758
}
6859
} catch let error as ConversationError {
6960
throw error

Sources/ElevenLabs/Internal/Authorization/TokenServicing.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import Foundation
22

33
protocol TokenServicing: Sendable {
44
/// Resolve the token a voice conversation authenticates with.
5-
/// - Parameter credentials: The credentials to authenticate with
6-
func fetchToken(for credentials: ConversationCredentials) async throws -> String
5+
func fetchToken(for auth: ConversationAuth.Voice, environment: String?) async throws -> String
76
}
87

98
extension TokenService: TokenServicing {}

Sources/ElevenLabs/Internal/Conversation/Conversation.swift

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,9 @@ final class Conversation: ObservableObject {
9393
self.callbacks = callbacks
9494
pendingMuteState = initialMicMuted
9595
logger = dependencyProvider.logger
96-
setupAudioManager()
9796
}
9897

9998
private func setupAudioManager() {
100-
guard !config.conversationOverrides.textOnly else { return }
10199
audioManager = ConversationAudioManager(logger: logger)
102100
}
103101

@@ -113,17 +111,29 @@ final class Conversation: ObservableObject {
113111

114112
// MARK: - API
115113

116-
/// Start a conversation using authentication configuration.
117-
func start(auth: ConversationCredentials) async throws -> ConversationStartResult {
114+
func startVoiceConversation(_ auth: ConversationAuth.Voice) async throws -> ConversationStartResult {
115+
try await runStart(isTextOnly: false) { config in
116+
try await performVoiceStart(auth, config: config)
117+
}
118+
}
119+
120+
func startTextConversation(_ auth: ConversationAuth.TextOnly) async throws -> ConversationStartResult {
121+
try await runStart(isTextOnly: true) { config in
122+
try await performTextStart(auth, config: config)
123+
}
124+
}
125+
126+
private func runStart(
127+
isTextOnly: Bool,
128+
_ work: (ConversationConfig) async throws -> ConversationStartResult
129+
) async throws -> ConversationStartResult {
118130
guard state == .idle else {
119131
throw ConversationError.alreadyStarted
120132
}
121133

122-
let result: ConversationStartResult = if config.conversationOverrides.textOnly {
123-
try await startTextOnlyConversation(auth: auth)
124-
} else {
125-
try await startVoiceConversation(auth: auth)
126-
}
134+
var startConfig = config
135+
startConfig.conversationOverrides.textOnly = isTextOnly
136+
let result = try await work(startConfig)
127137

128138
guard !Task.isCancelled, state.isConnecting else {
129139
await activeConnectionManager?.disconnect()
@@ -134,12 +144,15 @@ final class Conversation: ObservableObject {
134144
return result
135145
}
136146

137-
private func startVoiceConversation(
138-
auth: ConversationCredentials
147+
private func performVoiceStart(
148+
_ auth: ConversationAuth.Voice,
149+
config: ConversationConfig
139150
) async throws -> ConversationStartResult {
140151
let webRTCConnectionManager = dependencyProvider.webRTCConnectionManager
152+
setupAudioManager()
141153
prepareConversationStart(
142-
auth: auth,
154+
agentId: auth.agentId,
155+
isTextOnly: false,
143156
connectionManager: webRTCConnectionManager
144157
)
145158

@@ -224,12 +237,18 @@ final class Conversation: ObservableObject {
224237
micObserverRegistry.attach(to: inputTrack)
225238
}
226239

227-
private func startTextOnlyConversation(
228-
auth: ConversationCredentials
240+
private func performTextStart(
241+
_ auth: ConversationAuth.TextOnly,
242+
config: ConversationConfig
229243
) async throws -> ConversationStartResult {
230244
let connectionManager = dependencyProvider.webSocketConnectionManager
245+
let agentId = switch auth {
246+
case let .publicAgent(id): id
247+
case .signedWebSocketURL: "unknown"
248+
}
231249
prepareConversationStart(
232-
auth: auth,
250+
agentId: agentId,
251+
isTextOnly: true,
233252
connectionManager: connectionManager
234253
)
235254

@@ -377,14 +396,15 @@ final class Conversation: ObservableObject {
377396

378397
/// Common preparation shared by voice and text-only startup paths.
379398
private func prepareConversationStart(
380-
auth: ConversationCredentials,
399+
agentId: String,
400+
isTextOnly: Bool,
381401
connectionManager: any ConnectionManaging
382402
) {
383403
state = .connecting(.preparing)
384404
activeConnectionManager = connectionManager
385405

386-
activeContext = ["agentId": auth.agentId]
387-
let mode = config.conversationOverrides.textOnly ? "text-only" : "voice"
406+
activeContext = ["agentId": agentId]
407+
let mode = isTextOnly ? "text-only" : "voice"
388408
logger.info("Starting \(mode) conversation", context: activeContext)
389409

390410
setupAgentStateManager()

Sources/ElevenLabs/Internal/Networking/ConnectionManaging.swift

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,27 @@ protocol ConnectionManaging: AnyObject {
1111
var onDisconnected: (() async -> Void)? { get set }
1212
var errorHandler: ((Swift.Error?) -> Void)? { get set }
1313

14-
@MainActor
15-
func connect(
16-
auth: ConversationCredentials,
17-
config: ConversationConfig,
18-
onStartupStateChange: @escaping (ConversationStartupState) -> Void
19-
) async throws -> ConversationStartResult
20-
2114
func disconnect() async
2215
func send(data: Data) async throws
2316
}
2417

2518
@MainActor
26-
protocol WebSocketConnectionManaging: ConnectionManaging {}
19+
protocol WebSocketConnectionManaging: ConnectionManaging {
20+
func connect(
21+
auth: ConversationAuth.TextOnly,
22+
config: ConversationConfig,
23+
onStartupStateChange: @escaping (ConversationStartupState) -> Void
24+
) async throws -> ConversationStartResult
25+
}
2726

2827
@MainActor
2928
protocol WebRTCConnectionManaging: ConnectionManaging {
29+
func connect(
30+
auth: ConversationAuth.Voice,
31+
config: ConversationConfig,
32+
onStartupStateChange: @escaping (ConversationStartupState) -> Void
33+
) async throws -> ConversationStartResult
34+
3035
var onRemoteSpeakingChanged: (@Sendable (Bool) -> Void)? { get set }
3136
/// Fired when an audio track is published/subscribed/unpublished/unsubscribed.
3237
var onTracksChanged: (@Sendable () -> Void)? { get set }

Sources/ElevenLabs/Internal/Networking/WebRTCConnectionManager.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging {
8383
/// connect room → wait for agent → send init → wait for initiation metadata.
8484
@MainActor
8585
func connect(
86-
auth: ConversationCredentials,
86+
auth: ConversationAuth.Voice,
8787
config: ConversationConfig,
8888
onStartupStateChange: @escaping (ConversationStartupState) -> Void
8989
) async throws -> ConversationStartResult {
@@ -101,7 +101,7 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging {
101101
let token = try await runPhase(
102102
timing: \.tokenFetch, metrics: &metrics, startTime: startTime
103103
) {
104-
try await tokenService.fetchToken(for: auth)
104+
try await tokenService.fetchToken(for: auth, environment: config.environment)
105105
}
106106

107107
// 2. Request microphone permission (denial doesn't block startup).

Sources/ElevenLabs/Internal/Networking/WebSocketConnectionManager.swift

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ final class WebSocketConnectionManager: WebSocketConnectionManaging {
3535

3636
@MainActor
3737
func connect(
38-
auth: ConversationCredentials,
38+
auth: ConversationAuth.TextOnly,
3939
config: ConversationConfig,
4040
onStartupStateChange: @escaping (ConversationStartupState) -> Void
4141
) async throws -> ConversationStartResult {
@@ -47,14 +47,18 @@ final class WebSocketConnectionManager: WebSocketConnectionManaging {
4747
let startTime = Date()
4848
var metrics = ConversationStartupMetrics()
4949

50-
let url: URL
50+
let resolved: (url: URL, agentId: String)
5151
do {
52-
url = try Self.websocketUrl(for: auth, endpoints: endpoints)
52+
resolved = try await Self.websocketUrl(
53+
for: auth,
54+
endpoints: endpoints,
55+
environment: config.environment
56+
)
5357
} catch {
5458
metrics.total = Date().timeIntervalSince(startTime)
55-
let convError = error as? ConversationError ?? .authenticationFailed(error.localizedDescription)
56-
throw convError
59+
throw error as? ConversationError ?? .authenticationFailed(error.localizedDescription)
5760
}
61+
let url = resolved.url
5862

5963
let task = urlSession.webSocketTask(with: url)
6064
self.task = task
@@ -100,7 +104,7 @@ final class WebSocketConnectionManager: WebSocketConnectionManaging {
100104
onStartupStateChange: onStartupStateChange
101105
)
102106
return ConversationStartResult(
103-
callInfo: CallInfo(agentId: auth.agentId, conversationId: metadata.conversationId),
107+
callInfo: CallInfo(agentId: resolved.agentId, conversationId: metadata.conversationId),
104108
metrics: metrics
105109
)
106110
}
@@ -173,30 +177,44 @@ final class WebSocketConnectionManager: WebSocketConnectionManaging {
173177
await onDisconnected?()
174178
}
175179

176-
static func websocketUrl(for auth: ConversationCredentials, endpoints: Endpoints) throws -> URL {
177-
switch auth.authSource {
178-
case let .publicAgentId(agentId):
180+
static func websocketUrl(
181+
for auth: ConversationAuth.TextOnly,
182+
endpoints: Endpoints,
183+
environment: String? = nil
184+
) async throws -> (url: URL, agentId: String) {
185+
switch auth {
186+
case let .publicAgent(agentId):
179187
guard var components = URLComponents(url: endpoints.textWebSocket, resolvingAgainstBaseURL: false) else {
180188
throw ConversationError.authenticationFailed("Invalid conversation URL")
181189
}
182190
var queryItems = components.queryItems ?? []
183191
queryItems.append(URLQueryItem(name: "agent_id", value: agentId))
192+
if let environment {
193+
queryItems.append(URLQueryItem(name: "environment", value: environment))
194+
}
184195
components.queryItems = queryItems
185196
guard let url = components.url else {
186197
throw ConversationError.authenticationFailed("Invalid conversation URL")
187198
}
188-
return url
199+
return (url, agentId)
189200

190-
case let .signedWebSocketURL(urlString, _):
201+
case let .signedWebSocketURL(mint):
202+
let urlString = try await mint()
191203
guard let url = URL(string: urlString) else {
192204
throw ConversationError.authenticationFailed("Invalid signed WebSocket URL")
193205
}
194-
return url
195-
196-
case .conversationToken, .customTokenProvider:
197-
throw ConversationError.authenticationFailed(
198-
"Text-only conversations require a public agent ID or signed WebSocket URL."
199-
)
206+
guard
207+
let agentId = URLComponents(url: url, resolvingAgainstBaseURL: false)?
208+
.queryItems?
209+
.first(where: { $0.name == "agent_id" })?
210+
.value,
211+
!agentId.isEmpty
212+
else {
213+
throw ConversationError.authenticationFailed(
214+
"Signed WebSocket URL is missing the agent_id query parameter."
215+
)
216+
}
217+
return (url, agentId)
200218
}
201219
}
202220
}

Sources/ElevenLabs/Internal/Utilities/EventSerializer.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,10 @@ enum EventSerializer {
128128
json["user_id"] = userId
129129
}
130130

131+
if let environment = config.environment {
132+
json["environment"] = environment
133+
}
134+
131135
return json
132136
}
133137
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import Foundation
2+
3+
/// Grants for starting a conversation. Voice and text take different ones —
4+
/// pass them to ``ConversationClient/startVoiceConversation(_:config:)`` or
5+
/// ``ConversationClient/startTextConversation(_:config:)``.
6+
public enum ConversationAuth {
7+
public enum Voice: Sendable {
8+
case publicAgent(id: String)
9+
/// Called once per start. Use ``conversationToken(_:)`` for a pre-fetched token.
10+
case conversationToken(@Sendable () async throws -> String)
11+
12+
public var agentId: String {
13+
switch self {
14+
case let .publicAgent(id): id
15+
case .conversationToken: "unknown"
16+
}
17+
}
18+
19+
public static func conversationToken(_ token: String) -> Self {
20+
.conversationToken { token }
21+
}
22+
}
23+
24+
public enum TextOnly: Sendable {
25+
case publicAgent(id: String)
26+
case signedWebSocketURL(mint: @Sendable () async throws -> String)
27+
28+
public static func signedWebSocketURL(_ url: String) -> Self {
29+
.signedWebSocketURL(mint: { url })
30+
}
31+
}
32+
}

Sources/ElevenLabs/Public/Conversation/ConversationConfig.swift

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ public struct ConversationConfig: Sendable {
88
public var customLlmExtraBody: [String: String]? // Simplified to be Sendable
99
public var dynamicVariables: [String: String]? // Simplified to be Sendable
1010
public var userId: String?
11-
/// Optional environment for the agent (defaults to production when nil)
11+
/// Workspace environment (`production` if nil). Applied to the public-agent
12+
/// handshake and conversation init.
1213
public var environment: String?
1314

1415
/// How to handle microphone setup failures during connection.
@@ -101,14 +102,11 @@ public struct TTSOverrides: Sendable {
101102

102103
/// Conversation behavior overrides
103104
public struct ConversationOverrides: Sendable {
104-
public var textOnly: Bool
105+
/// Set when starting a text conversation. Sent as `text_only` on conversation init.
106+
var textOnly = false
105107
public var clientEvents: [String]?
106108

107-
public init(
108-
textOnly: Bool = false,
109-
clientEvents: [String]? = nil
110-
) {
111-
self.textOnly = textOnly
109+
public init(clientEvents: [String]? = nil) {
112110
self.clientEvents = clientEvents
113111
}
114112
}

0 commit comments

Comments
 (0)