Skip to content

Commit 2cb84c7

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 2cb84c7

26 files changed

Lines changed: 404 additions & 482 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: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,8 @@
11
import Foundation
22

3-
// A service for fetching ElevenLabs authentication tokens
4-
//
5-
// This service supports two authentication methods:
6-
// 1. Public Agent ID - Fetches a token from ElevenLabs API using a public agent ID
7-
// 2. Conversation Token - Uses a pre-generated conversation token from your backend
8-
//
9-
// SECURITY NOTE:
10-
// NEVER include your ElevenLabs API key in a client application!
11-
// API keys should only be used server-side. For production apps:
12-
// - Use public agents (no authentication required)
13-
// - OR implement a backend endpoint that generates conversation tokens
3+
// Resolves a LiveKit conversation token for voice auth.
4+
// Public agents hit the token endpoint; private agents mint via the caller's closure.
5+
// Never put an ElevenLabs API key in a client app.
146

157
// MARK: - Token Service
168

@@ -48,29 +40,27 @@ struct TokenService: Sendable {
4840
///
4941
/// Translates internal `TokenError`s into public `ConversationError`s so
5042
/// callers only ever deal with one error type.
51-
func fetchToken(for credentials: ConversationCredentials) async throws -> String {
43+
func fetchToken(for auth: ConversationAuth.Voice, environment: String?) async throws -> String {
5244
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()
45+
switch auth {
46+
case let .publicAgent(agentId):
47+
return try await fetchTokenFromAPI(agentId: agentId, environment: environment)
48+
case let .conversationToken(mint):
49+
return try await mint()
6750
}
6851
} catch let error as ConversationError {
6952
throw error
53+
} catch is CancellationError {
54+
throw CancellationError()
7055
} catch let error as TokenError {
7156
throw ConversationError.authenticationFailed(error.localizedDescription)
7257
} catch {
73-
throw ConversationError.connectionFailed(error)
58+
switch auth {
59+
case .conversationToken:
60+
throw ConversationError.conversationTokenProviderFailed(error.localizedDescription)
61+
case .publicAgent:
62+
throw ConversationError.connectionFailed(error)
63+
}
7464
}
7565
}
7666

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: 44 additions & 25 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,28 @@ 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) {
116+
try await performVoiceStart(auth)
117+
}
118+
}
119+
120+
func startTextConversation(_ auth: ConversationAuth.TextOnly) async throws -> ConversationStartResult {
121+
try await runStart(isTextOnly: true) {
122+
try await performTextStart(auth)
123+
}
124+
}
125+
126+
private func runStart(
127+
isTextOnly: Bool,
128+
_ work: () 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+
config.conversationOverrides.textOnly = isTextOnly
135+
let result = try await work()
127136

128137
guard !Task.isCancelled, state.isConnecting else {
129138
await activeConnectionManager?.disconnect()
@@ -134,12 +143,14 @@ final class Conversation: ObservableObject {
134143
return result
135144
}
136145

137-
private func startVoiceConversation(
138-
auth: ConversationCredentials
146+
private func performVoiceStart(
147+
_ auth: ConversationAuth.Voice
139148
) async throws -> ConversationStartResult {
140149
let webRTCConnectionManager = dependencyProvider.webRTCConnectionManager
150+
setupAudioManager()
141151
prepareConversationStart(
142-
auth: auth,
152+
agentId: auth.agentId,
153+
isTextOnly: false,
143154
connectionManager: webRTCConnectionManager
144155
)
145156

@@ -224,18 +235,24 @@ final class Conversation: ObservableObject {
224235
micObserverRegistry.attach(to: inputTrack)
225236
}
226237

227-
private func startTextOnlyConversation(
228-
auth: ConversationCredentials
238+
private func performTextStart(
239+
_ auth: ConversationAuth.TextOnly
229240
) async throws -> ConversationStartResult {
230241
let connectionManager = dependencyProvider.webSocketConnectionManager
231-
prepareConversationStart(
232-
auth: auth,
233-
connectionManager: connectionManager
234-
)
235-
236242
do {
243+
let resolved = try await WebSocketConnectionManager.websocketUrl(
244+
for: auth,
245+
endpoints: config.endpoints,
246+
environment: config.environment
247+
)
248+
prepareConversationStart(
249+
agentId: resolved.agentId,
250+
isTextOnly: true,
251+
connectionManager: connectionManager
252+
)
237253
return try await connectionManager.connect(
238-
auth: auth,
254+
url: resolved.url,
255+
agentId: resolved.agentId,
239256
config: config,
240257
onStartupStateChange: { [weak self] stage in
241258
self?.updateStartupStage(stage)
@@ -364,7 +381,7 @@ final class Conversation: ObservableObject {
364381
activeConnectionManager as? any WebRTCConnectionManaging
365382
}
366383

367-
let config: ConversationConfig
384+
var config: ConversationConfig
368385
let callbacks: ConversationCallbacks
369386

370387
var speakingTimer: Task<Void, Never>?
@@ -377,14 +394,15 @@ final class Conversation: ObservableObject {
377394

378395
/// Common preparation shared by voice and text-only startup paths.
379396
private func prepareConversationStart(
380-
auth: ConversationCredentials,
397+
agentId: String,
398+
isTextOnly: Bool,
381399
connectionManager: any ConnectionManaging
382400
) {
383401
state = .connecting(.preparing)
384402
activeConnectionManager = connectionManager
385403

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

390408
setupAgentStateManager()
@@ -420,7 +438,8 @@ final class Conversation: ObservableObject {
420438

421439
// End/supersede may have already moved us out of connecting; don't
422440
// overwrite `.ended` or fire shared `onError` for a discarded session.
423-
guard state.isConnecting else { return }
441+
// `.idle` is a mint/parse failure before prepare — still this start.
442+
guard state.isConnecting || state == .idle else { return }
424443
state = .error(error)
425444
callbacks.onError?(error)
426445
}

Sources/ElevenLabs/Internal/DI/Dependencies.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,6 @@ final class Dependencies: ConversationDependencyProvider {
2222
tokenService: TokenService(endpoints: endpoints),
2323
endpoints: endpoints
2424
)
25-
webSocketConnectionManager = WebSocketConnectionManager(logger: logger, endpoints: endpoints)
25+
webSocketConnectionManager = WebSocketConnectionManager(logger: logger)
2626
}
2727
}

Sources/ElevenLabs/Internal/Networking/ConnectionManaging.swift

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,28 @@ 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+
url: URL,
22+
agentId: String,
23+
config: ConversationConfig,
24+
onStartupStateChange: @escaping (ConversationStartupState) -> Void
25+
) async throws -> ConversationStartResult
26+
}
2727

2828
@MainActor
2929
protocol WebRTCConnectionManaging: ConnectionManaging {
30+
func connect(
31+
auth: ConversationAuth.Voice,
32+
config: ConversationConfig,
33+
onStartupStateChange: @escaping (ConversationStartupState) -> Void
34+
) async throws -> ConversationStartResult
35+
3036
var onRemoteSpeakingChanged: (@Sendable (Bool) -> Void)? { get set }
3137
/// Fired when an audio track is published/subscribed/unpublished/unsubscribed.
3238
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).

0 commit comments

Comments
 (0)