Skip to content

Commit f5eb1af

Browse files
kraenhansenclaude
andcommitted
refactor: use urllib.parse for WebSocket URL construction
Replace manual f-string query parameter concatenation and scheme swapping with a shared build_ws_url helper that uses urllib.parse for proper percent-encoding. This fixes free-form values (e.g. keyterms with spaces) producing malformed URLs. Affected files: - realtime/scribe.py — _build_websocket_url - realtime_tts.py — convert_realtime - conversational_ai/conversation.py — _get_wss_url and _get_signed_url Closes #779 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1f19ff1 commit f5eb1af

4 files changed

Lines changed: 60 additions & 38 deletions

File tree

src/elevenlabs/conversational_ai/conversation.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from websockets.sync.client import Connection, connect
1616

1717
from ..base_client import BaseElevenLabs
18+
from ..url_utils import build_ws_url
1819
from ..version import __version__
1920

2021

@@ -422,18 +423,14 @@ def _get_wss_url(self):
422423
return self.on_prem_config.on_prem_conversation_url
423424

424425
base_http_url = self.client._client_wrapper.get_base_url()
425-
base_ws_url = (
426-
urllib.parse.urlparse(base_http_url)
427-
._replace(scheme="wss" if base_http_url.startswith("https") else "ws")
428-
.geturl()
429-
)
430-
# Ensure base URL ends with '/' for proper joining
431-
if not base_ws_url.endswith("/"):
432-
base_ws_url += "/"
433-
url = f"{base_ws_url}v1/convai/conversation?agent_id={self.agent_id}&source=python_sdk&version={__version__}"
426+
params = [
427+
("agent_id", self.agent_id),
428+
("source", "python_sdk"),
429+
("version", __version__),
430+
]
434431
if self.environment:
435-
url += f"&environment={self.environment}"
436-
return url
432+
params.append(("environment", self.environment))
433+
return build_ws_url(base_http_url, ["v1", "convai", "conversation"], params)
437434

438435
def _get_signed_url(self):
439436
response = self.client.conversational_ai.conversations.get_signed_url(
@@ -442,8 +439,10 @@ def _get_signed_url(self):
442439
)
443440
signed_url = response.signed_url
444441
# Append source and version query parameters to the signed URL
445-
separator = "&" if "?" in signed_url else "?"
446-
return f"{signed_url}{separator}source=python_sdk&version={__version__}"
442+
parsed = urllib.parse.urlparse(signed_url)
443+
existing_params = urllib.parse.parse_qsl(parsed.query)
444+
existing_params.extend([("source", "python_sdk"), ("version", __version__)])
445+
return urllib.parse.urlunparse(parsed._replace(query=urllib.parse.urlencode(existing_params)))
447446

448447
def _create_on_prem_initiation_message(self):
449448
return json.dumps(

src/elevenlabs/realtime/scribe.py

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"Install it with: pip install websockets"
1616
)
1717

18+
from ..url_utils import build_ws_url
1819
from .connection import RealtimeConnection
1920

2021

@@ -367,30 +368,22 @@ def _build_websocket_url(
367368
include_timestamps: typing.Optional[bool] = None
368369
) -> str:
369370
"""Build the WebSocket URL with query parameters"""
370-
# Extract base domain
371-
base = self.base_url.replace("https://", "wss://").replace("http://", "ws://")
372-
373-
# Build query parameters
374371
params = [
375-
f"model_id={model_id}",
376-
f"audio_format={audio_format}",
377-
f"commit_strategy={commit_strategy}"
372+
("model_id", model_id),
373+
("audio_format", audio_format),
374+
("commit_strategy", commit_strategy),
378375
]
379-
380-
# Add optional VAD parameters
381-
if vad_silence_threshold_secs is not None:
382-
params.append(f"vad_silence_threshold_secs={vad_silence_threshold_secs}")
383-
if vad_threshold is not None:
384-
params.append(f"vad_threshold={vad_threshold}")
385-
if min_speech_duration_ms is not None:
386-
params.append(f"min_speech_duration_ms={min_speech_duration_ms}")
387-
if min_silence_duration_ms is not None:
388-
params.append(f"min_silence_duration_ms={min_silence_duration_ms}")
389-
if language_code is not None:
390-
params.append(f"language_code={language_code}")
376+
for key, value in [
377+
("vad_silence_threshold_secs", vad_silence_threshold_secs),
378+
("vad_threshold", vad_threshold),
379+
("min_speech_duration_ms", min_speech_duration_ms),
380+
("min_silence_duration_ms", min_silence_duration_ms),
381+
("language_code", language_code),
382+
]:
383+
if value is not None:
384+
params.append((key, str(value)))
391385
if include_timestamps is not None:
392-
params.append(f"include_timestamps={str(include_timestamps).lower()}")
386+
params.append(("include_timestamps", str(include_timestamps).lower()))
393387

394-
query_string = "&".join(params)
395-
return f"{base}/v1/speech-to-text/realtime?{query_string}"
388+
return build_ws_url(self.base_url, ["v1", "speech-to-text", "realtime"], params)
396389

src/elevenlabs/realtime_tts.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from .types.voice_settings import VoiceSettings
1717
from .text_to_speech.client import TextToSpeechClient
1818
from .types import OutputFormat
19+
from .url_utils import build_ws_url
1920

2021
# this is used as the default value for optional parameters
2122
OMIT = typing.cast(typing.Any, ...)
@@ -92,9 +93,10 @@ def get_text() -> typing.Iterator[str]:
9293
)
9394
"""
9495
with connect(
95-
urllib.parse.urljoin(
96-
self._ws_base_url,
97-
f"v1/text-to-speech/{jsonable_encoder(voice_id)}/stream-input?model_id={model_id}&output_format={output_format}"
96+
build_ws_url(
97+
self._ws_base_url,
98+
["v1", "text-to-speech", voice_id, "stream-input"],
99+
{"model_id": model_id, "output_format": output_format},
98100
),
99101
additional_headers=jsonable_encoder(
100102
remove_none_from_dict(

src/elevenlabs/url_utils.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import urllib.parse
2+
from typing import Any, Sequence, Tuple, Union
3+
4+
5+
_WS_SCHEME = {"https": "wss", "http": "ws"}
6+
7+
8+
def build_ws_url(
9+
base_url: str,
10+
path_segments: Sequence[Any],
11+
params: Union[Sequence[Tuple[str, str]], dict],
12+
) -> str:
13+
"""Build a WebSocket URL with proper percent-encoding.
14+
15+
Converts http(s) schemes to ws(s), appends percent-encoded
16+
*path_segments* beneath the existing base path, and encodes
17+
*params* as the query string.
18+
"""
19+
parsed = urllib.parse.urlparse(base_url)
20+
path = "/".join(urllib.parse.quote(str(seg), safe="") for seg in path_segments)
21+
return urllib.parse.urlunparse((
22+
_WS_SCHEME.get(parsed.scheme, parsed.scheme),
23+
parsed.netloc,
24+
parsed.path.rstrip("/") + "/" + path,
25+
"",
26+
urllib.parse.urlencode(params),
27+
"",
28+
))

0 commit comments

Comments
 (0)