Skip to content

Commit 3b97a1f

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 3b97a1f

5 files changed

Lines changed: 140 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+
))

tests/test_url_utils.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Tests for the build_ws_url utility."""
2+
3+
import pytest
4+
5+
from elevenlabs.url_utils import build_ws_url
6+
7+
8+
class TestSchemeConversion:
9+
def test_https_to_wss(self):
10+
url = build_ws_url("https://api.example.com", ["v1"], {})
11+
assert url.startswith("wss://")
12+
13+
def test_http_to_ws(self):
14+
url = build_ws_url("http://localhost:8080", ["v1"], {})
15+
assert url.startswith("ws://")
16+
17+
def test_wss_preserved(self):
18+
url = build_ws_url("wss://api.example.com", ["v1"], {})
19+
assert url.startswith("wss://")
20+
21+
def test_ws_preserved(self):
22+
url = build_ws_url("ws://localhost:8080", ["v1"], {})
23+
assert url.startswith("ws://")
24+
25+
26+
class TestPathSegments:
27+
def test_segments_joined(self):
28+
url = build_ws_url("wss://api.example.com", ["v1", "speech", "realtime"], {})
29+
assert url == "wss://api.example.com/v1/speech/realtime"
30+
31+
def test_segments_percent_encoded(self):
32+
url = build_ws_url("wss://api.example.com", ["v1", "hello world"], {})
33+
assert "/v1/hello%20world" in url
34+
35+
def test_special_characters_encoded(self):
36+
url = build_ws_url("wss://api.example.com", ["v1", "a/b", "c?d", "e&f"], {})
37+
assert "/v1/a%2Fb/c%3Fd/e%26f" in url
38+
39+
def test_non_string_segments_converted(self):
40+
url = build_ws_url("wss://api.example.com", ["v1", 42, True], {})
41+
assert "/v1/42/True" in url
42+
43+
def test_appended_to_existing_base_path(self):
44+
url = build_ws_url("wss://api.example.com/base", ["v1", "endpoint"], {})
45+
assert url == "wss://api.example.com/base/v1/endpoint"
46+
47+
def test_base_path_trailing_slash_not_duplicated(self):
48+
url = build_ws_url("wss://api.example.com/base/", ["v1"], {})
49+
assert "//v1" not in url
50+
assert "/base/v1" in url
51+
52+
53+
class TestQueryParams:
54+
def test_dict_params(self):
55+
url = build_ws_url("wss://api.example.com", ["v1"], {"key": "value"})
56+
assert url.endswith("?key=value")
57+
58+
def test_tuple_params(self):
59+
url = build_ws_url("wss://api.example.com", ["v1"], [("a", "1"), ("b", "2")])
60+
assert "a=1" in url
61+
assert "b=2" in url
62+
63+
def test_params_percent_encoded(self):
64+
url = build_ws_url("wss://api.example.com", ["v1"], {"term": "hello world"})
65+
assert "term=hello+world" in url
66+
67+
def test_repeated_keys(self):
68+
url = build_ws_url("wss://api.example.com", ["v1"], [("k", "a"), ("k", "b")])
69+
assert "k=a" in url
70+
assert "k=b" in url
71+
72+
def test_empty_params(self):
73+
url = build_ws_url("wss://api.example.com", ["v1"], {})
74+
assert url == "wss://api.example.com/v1"
75+
76+
77+
class TestPortPreserved:
78+
def test_custom_port(self):
79+
url = build_ws_url("http://localhost:9090", ["v1"], {})
80+
assert url == "ws://localhost:9090/v1"

0 commit comments

Comments
 (0)