Skip to content

Commit 2b426ef

Browse files
renal128cursoragent
andcommitted
refactor: make conversation endpoints configurable per session
Add Endpoints on ConversationConfig and wire them through TokenService and the connection managers. HTTP requests derive from a single apiBase; ws/wss endpoints stay explicit. Drop ConnectionConstants and the global apiEndpoint/websocketUrl overrides. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 67f823b commit 2b426ef

13 files changed

Lines changed: 143 additions & 106 deletions

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,16 @@ ElevenLabs.configure(
232232
)
233233
```
234234

235+
### Custom Endpoints
236+
237+
Point the HTTP API base or either WebSocket endpoint at a proxy, regional host, or staging:
238+
239+
```swift
240+
let config = ConversationConfig(
241+
endpoints: Endpoints(apiBase: URL(string: "https://my-proxy.example.com")!)
242+
)
243+
```
244+
235245
### Fine-Grained Callbacks
236246

237247
Want to handle events without Combine? Use `ConversationCallbacks`:

Sources/ElevenLabs/Internal/Authorization/ConnectionConstants.swift

Lines changed: 0 additions & 9 deletions
This file was deleted.
Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import Foundation
22

33
protocol TokenServicing: Sendable {
4-
/// Fetch connection details for ElevenLabs conversation
5-
/// - Parameter configuration: The configuration to use for fetching connection details
6-
/// - Returns: The connection details for the ElevenLabs conversation
7-
func fetchConnectionDetails(configuration: ConversationCredentials) async throws -> TokenService.ConnectionDetails
4+
/// 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
87
}
98

109
extension TokenService: TokenServicing {}

Sources/ElevenLabs/Internal/DI/Dependencies.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@ final class Dependencies: ConversationDependencyProvider {
1414
let webRTCConnectionManager: any WebRTCConnectionManaging
1515
let webSocketConnectionManager: any WebSocketConnectionManaging
1616

17-
init() {
17+
init(endpoints: Endpoints = .production) {
1818
let globalConfig = ElevenLabs.Global.shared.configuration
19-
let tokenService = TokenService(configuration: TokenService.Configuration(
20-
apiEndpoint: globalConfig.apiEndpoint?.absoluteString,
21-
websocketURL: globalConfig.websocketUrl
22-
))
2319
let logger = SDKLogger(logLevel: globalConfig.logLevel)
2420
self.logger = logger
25-
webRTCConnectionManager = WebRTCConnectionManager(logger: logger, tokenService: tokenService)
26-
webSocketConnectionManager = WebSocketConnectionManager(logger: logger)
21+
webRTCConnectionManager = WebRTCConnectionManager(
22+
logger: logger,
23+
tokenService: TokenService(endpoints: endpoints),
24+
endpoints: endpoints
25+
)
26+
webSocketConnectionManager = WebSocketConnectionManager(logger: logger, endpoints: endpoints)
2727
}
2828
}

Sources/ElevenLabs/Internal/Networking/WebRTCConnectionManager.swift

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,12 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging {
6767

6868
private let logger: any Logging
6969
private let tokenService: any TokenServicing
70+
private let endpoints: Endpoints
7071

71-
init(logger: any Logging, tokenService: any TokenServicing) {
72+
init(logger: any Logging, tokenService: any TokenServicing, endpoints: Endpoints) {
7273
self.logger = logger
7374
self.tokenService = tokenService
75+
self.endpoints = endpoints
7476
}
7577

7678
// MARK: – Public API
@@ -92,12 +94,12 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging {
9294
var metrics = ConversationStartupMetrics()
9395
logger.info("Starting conversation startup sequence", context: ["agentId": auth.agentId])
9496

95-
// 1. Resolve token / connection details.
97+
// 1. Resolve the conversation token.
9698
onStartupStateChange(.resolvingToken)
97-
let connectionDetails = try await runPhase(
99+
let token = try await runPhase(
98100
timing: \.tokenFetch, metrics: &metrics, startTime: startTime
99101
) {
100-
try await tokenService.fetchConnectionDetails(configuration: auth)
102+
try await tokenService.fetchToken(for: auth)
101103
}
102104

103105
// 2. Request microphone permission (denial doesn't block startup).
@@ -110,7 +112,7 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging {
110112
timing: \.roomConnect, metrics: &metrics, startTime: startTime
111113
) {
112114
try await connectToRoom(
113-
details: connectionDetails,
115+
token: token,
114116
enableMic: permissionGranted,
115117
throwOnMicrophoneFailure: throwOnMicFailure,
116118
networkConfiguration: config.networkConfiguration,
@@ -217,12 +219,12 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging {
217219
/// used by `connect`).
218220
///
219221
/// - Parameters:
220-
/// - details: Token-service credentials (URL + participant token).
222+
/// - token: Conversation token to authenticate the room with.
221223
/// - enableMic: Whether to enable the local microphone immediately.
222224
/// - throwOnMicrophoneFailure: If true, throws error when microphone setup fails.
223225
/// If false, logs warning and continues.
224226
private func connectToRoom(
225-
details: TokenService.ConnectionDetails,
227+
token: String,
226228
enableMic: Bool,
227229
throwOnMicrophoneFailure: Bool,
228230
networkConfiguration: WebRTCConfiguration,
@@ -258,8 +260,8 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging {
258260
let connectStart = Date()
259261
do {
260262
try await room.connect(
261-
url: details.serverUrl,
262-
token: details.participantToken,
263+
url: endpoints.voiceWebSocket.absoluteString,
264+
token: token,
263265
connectOptions: connectOptions
264266
)
265267
logger.info("LiveKit room.connect completed", context: ["duration": "\(Date().timeIntervalSince(connectStart))"])

Sources/ElevenLabs/Internal/Networking/WebSocketConnectionManager.swift

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@ final class WebSocketConnectionManager: WebSocketConnectionManaging {
1717

1818
private let urlSession: URLSession
1919
private let logger: any Logging
20+
private let endpoints: Endpoints
2021
private var task: URLSessionWebSocketTask?
2122
private var receiveTask: Task<Void, Never>?
2223
private var initiationMetadataWaiter: ConversationInitiationMetadataWaiter?
2324

24-
init(logger: any Logging) {
25+
init(logger: any Logging, endpoints: Endpoints = .production) {
2526
self.logger = logger
27+
self.endpoints = endpoints
2628
urlSession = URLSession(configuration: .default)
2729
}
2830

@@ -46,7 +48,7 @@ final class WebSocketConnectionManager: WebSocketConnectionManaging {
4648

4749
let url: URL
4850
do {
49-
url = try Self.url(for: auth)
51+
url = try Self.websocketUrl(for: auth, endpoints: endpoints)
5052
} catch {
5153
metrics.total = Date().timeIntervalSince(startTime)
5254
let convError = error as? ConversationError ?? .authenticationFailed(error.localizedDescription)
@@ -147,12 +149,16 @@ final class WebSocketConnectionManager: WebSocketConnectionManaging {
147149
}
148150
}
149151

150-
static func url(for auth: ConversationCredentials) throws -> URL {
152+
static func websocketUrl(for auth: ConversationCredentials, endpoints: Endpoints) throws -> URL {
151153
switch auth.authSource {
152154
case let .publicAgentId(agentId):
153-
var components = URLComponents(string: ConnectionConstants.textConversationUrl)
154-
components?.queryItems = [URLQueryItem(name: "agent_id", value: agentId)]
155-
guard let url = components?.url else {
155+
guard var components = URLComponents(url: endpoints.textWebSocket, resolvingAgainstBaseURL: false) else {
156+
throw ConversationError.authenticationFailed("Invalid conversation URL")
157+
}
158+
var queryItems = components.queryItems ?? []
159+
queryItems.append(URLQueryItem(name: "agent_id", value: agentId))
160+
components.queryItems = queryItems
161+
guard let url = components.url else {
156162
throw ConversationError.authenticationFailed("Invalid conversation URL")
157163
}
158164
return url

Sources/ElevenLabs/Public/Authorization/TokenService.swift

Lines changed: 24 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -17,29 +17,7 @@ import Foundation
1717
/// Service for managing ElevenLabs authentication
1818
/// This is designed to be stateless and SDK-friendly
1919
public struct TokenService: Sendable {
20-
public struct ConnectionDetails: Codable, Sendable {
21-
public let serverUrl: String
22-
public let roomName: String
23-
public let participantName: String
24-
public let participantToken: String
25-
}
26-
27-
/// Optional configuration for advanced use cases
28-
public struct Configuration: Sendable {
29-
/// Custom API endpoint (for testing or enterprise deployments)
30-
public let apiEndpoint: String?
31-
/// Custom WebSocket URL (for testing or enterprise deployments)
32-
public let websocketURL: String?
33-
34-
public init(apiEndpoint: String? = nil, websocketURL: String? = nil) {
35-
self.apiEndpoint = apiEndpoint
36-
self.websocketURL = websocketURL
37-
}
38-
39-
public static let `default` = Configuration()
40-
}
41-
42-
private let configuration: Configuration
20+
private let endpoints: Endpoints
4321
private let urlSession: URLSession
4422

4523
// Development-only API key for testing private agents
@@ -48,53 +26,45 @@ public struct TokenService: Sendable {
4826
public let debugApiKey: String?
4927

5028
public init(
51-
configuration: Configuration = .default,
29+
endpoints: Endpoints = .production,
5230
urlSession: URLSession = .shared,
5331
debugApiKey: String? = nil
5432
) {
55-
self.configuration = configuration
33+
self.endpoints = endpoints
5634
self.urlSession = urlSession
5735
self.debugApiKey = debugApiKey
5836
}
5937
#else
6038
public init(
61-
configuration: Configuration = .default,
39+
endpoints: Endpoints = .production,
6240
urlSession: URLSession = .shared
6341
) {
64-
self.configuration = configuration
42+
self.endpoints = endpoints
6543
self.urlSession = urlSession
6644
}
6745
#endif
6846

69-
/// Fetch connection details for ElevenLabs conversation.
47+
/// Resolve the token a voice conversation authenticates with.
7048
///
7149
/// Translates internal `TokenError`s into public `ConversationError`s so
7250
/// callers only ever deal with one error type.
73-
public func fetchConnectionDetails(configuration: ConversationCredentials) async throws -> ConnectionDetails {
51+
public func fetchToken(for credentials: ConversationCredentials) async throws -> String {
7452
do {
75-
let token: String = switch configuration.authSource {
53+
switch credentials.authSource {
7654
case let .publicAgentId(agentId):
77-
try await fetchTokenFromAPI(agentId: agentId, environment: configuration.environment)
55+
return try await fetchTokenFromAPI(
56+
agentId: agentId,
57+
environment: credentials.environment
58+
)
7859
case let .conversationToken(conversationToken):
79-
conversationToken
60+
return conversationToken
8061
case .signedWebSocketURL:
8162
throw ConversationError.authenticationFailed(
8263
"Signed WebSocket URLs are only supported for text-only conversations."
8364
)
8465
case let .customTokenProvider(provider):
85-
try await provider()
66+
return try await provider()
8667
}
87-
88-
let websocketURL = self.configuration.websocketURL ?? ConnectionConstants.voiceConversationUrl
89-
90-
// ElevenLabs tokens contain room name and participant identity in the JWT
91-
// LiveKit will extract these automatically, so we provide empty values
92-
return ConnectionDetails(
93-
serverUrl: websocketURL,
94-
roomName: "", // LiveKit extracts from JWT
95-
participantName: "", // LiveKit extracts from JWT
96-
participantToken: token
97-
)
9868
} catch let error as ConversationError {
9969
throw error
10070
} catch let error as TokenError {
@@ -104,14 +74,18 @@ public struct TokenService: Sendable {
10474
}
10575
}
10676

107-
private func fetchTokenFromAPI(agentId: String, environment: String? = nil) async throws -> String {
108-
// Build URL with agent ID as query parameter
109-
let apiUrl = configuration.apiEndpoint ?? ConnectionConstants.tokenUrl
110-
111-
guard var components = URLComponents(string: apiUrl) else {
77+
private func fetchTokenFromAPI(
78+
agentId: String,
79+
environment: String? = nil
80+
) async throws -> String {
81+
guard var components = URLComponents(
82+
url: endpoints.conversationToken,
83+
resolvingAgainstBaseURL: false
84+
) else {
11285
throw TokenError.invalidURL
11386
}
114-
var queryItems = [
87+
var queryItems = components.queryItems ?? []
88+
queryItems += [
11589
URLQueryItem(name: "agent_id", value: agentId),
11690
URLQueryItem(name: "source", value: "swift_sdk"),
11791
URLQueryItem(name: "version", value: SDKVersion.version)

Sources/ElevenLabs/Public/Conversation/ConversationClient.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ public final class ConversationClient: ObservableObject {
104104
) async throws -> ConversationStartResult {
105105
let previousConversation = session
106106
let conversation = Conversation(
107-
dependencyProvider: dependencyProvider ?? Dependencies(),
107+
dependencyProvider: dependencyProvider ?? Dependencies(endpoints: config.endpoints),
108108
config: config,
109109
callbacks: callbacks,
110110
initialMicMuted: isMicMuted

Sources/ElevenLabs/Public/Conversation/ConversationConfig.swift

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ public struct ConversationConfig: Sendable {
3535
/// instead of relying on LiveKit's isSpeaking detection.
3636
public var agentStateConfiguration: AgentStateConfiguration?
3737

38+
/// Network endpoints to connect to. Override for proxies, regional hosts, or staging.
39+
public var endpoints: Endpoints
40+
3841
public init(
3942
agentOverrides: AgentOverrides? = nil,
4043
ttsOverrides: TTSOverrides? = nil,
@@ -47,7 +50,8 @@ public struct ConversationConfig: Sendable {
4750
startupConfiguration: ConversationStartupConfiguration = .default,
4851
audioConfiguration: AudioPipelineConfiguration? = nil,
4952
networkConfiguration: WebRTCConfiguration = .default,
50-
agentStateConfiguration: AgentStateConfiguration? = nil
53+
agentStateConfiguration: AgentStateConfiguration? = nil,
54+
endpoints: Endpoints = .production
5155
) {
5256
self.agentOverrides = agentOverrides
5357
self.ttsOverrides = ttsOverrides
@@ -61,6 +65,7 @@ public struct ConversationConfig: Sendable {
6165
self.audioConfiguration = audioConfiguration
6266
self.networkConfiguration = networkConfiguration
6367
self.agentStateConfiguration = agentStateConfiguration
68+
self.endpoints = endpoints
6469
}
6570
}
6671

@@ -114,3 +119,36 @@ public struct ConversationOverrides: Sendable {
114119
self.clientEvents = clientEvents
115120
}
116121
}
122+
123+
/// The network endpoints the SDK talks to. Defaults to ``production``.
124+
///
125+
/// Override for proxies, regional hosts, or staging. Credentials follow these
126+
/// endpoints — conversation tokens are sent to whichever `apiBase` is configured.
127+
public struct Endpoints: Sendable, Equatable {
128+
/// Base URL for the HTTP API. Request paths are appended to it.
129+
public var apiBase: URL
130+
/// WebSocket endpoint for voice conversations.
131+
public var voiceWebSocket: URL
132+
/// WebSocket endpoint for text-only conversations.
133+
public var textWebSocket: URL
134+
135+
public init(
136+
apiBase: URL = Endpoints.production.apiBase,
137+
voiceWebSocket: URL = Endpoints.production.voiceWebSocket,
138+
textWebSocket: URL = Endpoints.production.textWebSocket
139+
) {
140+
self.apiBase = apiBase
141+
self.voiceWebSocket = voiceWebSocket
142+
self.textWebSocket = textWebSocket
143+
}
144+
145+
public static let production = Endpoints(
146+
apiBase: URL(string: "https://api.elevenlabs.io")!,
147+
voiceWebSocket: URL(string: "wss://livekit.rtc.elevenlabs.io")!,
148+
textWebSocket: URL(string: "wss://api.elevenlabs.io/v1/convai/conversation")!
149+
)
150+
151+
var conversationToken: URL {
152+
apiBase.appendingPathComponent("v1/convai/conversation/token")
153+
}
154+
}

0 commit comments

Comments
 (0)