Skip to content

Commit d14915a

Browse files
authored
feat: Add User-Agent header with SDK identification (#6)
* feat: Add User-Agent header with SDK identification * format and lint
1 parent d8e5cfb commit d14915a

File tree

9 files changed

+175
-4
lines changed

9 files changed

+175
-4
lines changed

decart/_user_agent.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""User-Agent header construction for SDK requests."""
2+
3+
from typing import Optional
4+
from ._version import __version__
5+
6+
7+
def build_user_agent(integration: Optional[str] = None) -> str:
8+
"""
9+
Builds the User-Agent string for the SDK.
10+
11+
Format: decart-python-sdk/{version} lang/py {integration?}
12+
13+
Args:
14+
integration: Optional integration identifier (e.g., "langchain/0.1.0")
15+
16+
Returns:
17+
Complete User-Agent string
18+
19+
Examples:
20+
>>> build_user_agent()
21+
'decart-python-sdk/0.0.6 lang/py'
22+
23+
>>> build_user_agent("langchain/0.1.0")
24+
'decart-python-sdk/0.0.6 lang/py langchain/0.1.0'
25+
"""
26+
parts = [f"decart-python-sdk/{__version__}", "lang/py"]
27+
28+
if integration:
29+
parts.append(integration)
30+
31+
return " ".join(parts)

decart/_version.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""SDK version information."""
2+
3+
from importlib.metadata import version, PackageNotFoundError
4+
5+
try:
6+
__version__ = version("decart")
7+
except PackageNotFoundError:
8+
# Development version when package is not installed
9+
__version__ = "0.0.0-dev"

decart/client.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class DecartClient:
2121
Args:
2222
api_key: Your Decart API key
2323
base_url: API base URL (defaults to production)
24+
integration: Optional integration identifier (e.g., "langchain/0.1.0")
2425
2526
Example:
2627
```python
@@ -32,7 +33,12 @@ class DecartClient:
3233
```
3334
"""
3435

35-
def __init__(self, api_key: str, base_url: str = "https://api.decart.ai") -> None:
36+
def __init__(
37+
self,
38+
api_key: str,
39+
base_url: str = "https://api.decart.ai",
40+
integration: Optional[str] = None,
41+
) -> None:
3642
if not api_key or not api_key.strip():
3743
raise InvalidAPIKeyError()
3844

@@ -41,6 +47,7 @@ def __init__(self, api_key: str, base_url: str = "https://api.decart.ai") -> Non
4147

4248
self.api_key = api_key
4349
self.base_url = base_url
50+
self.integration = integration
4451
self._session: Optional[aiohttp.ClientSession] = None
4552

4653
async def _get_session(self) -> aiohttp.ClientSession:
@@ -117,6 +124,7 @@ async def process(self, options: dict[str, Any]) -> bytes:
117124
model=model,
118125
inputs=processed_inputs,
119126
cancel_token=cancel_token,
127+
integration=self.integration,
120128
)
121129

122130
return response

decart/process/request.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from ..types import FileInput
77
from ..models import ModelDefinition
88
from ..errors import InvalidInputError, ProcessingError
9+
from .._user_agent import build_user_agent
910

1011

1112
async def file_input_to_bytes(
@@ -82,6 +83,7 @@ async def send_request(
8283
model: ModelDefinition,
8384
inputs: dict[str, Any],
8485
cancel_token: Optional[asyncio.Event] = None,
86+
integration: Optional[str] = None,
8587
) -> bytes:
8688
form_data = aiohttp.FormData()
8789

@@ -98,7 +100,10 @@ async def send_request(
98100
async def make_request() -> bytes:
99101
async with session.post(
100102
endpoint,
101-
headers={"X-API-KEY": api_key},
103+
headers={
104+
"X-API-KEY": api_key,
105+
"User-Agent": build_user_agent(integration),
106+
},
102107
data=form_data,
103108
) as response:
104109
if not response.ok:

decart/realtime/client.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Callable
1+
from typing import Callable, Optional
22
import logging
33
import uuid
44
from aiortc import MediaStreamTrack
@@ -25,6 +25,7 @@ async def connect(
2525
api_key: str,
2626
local_track: MediaStreamTrack,
2727
options: RealtimeConnectOptions,
28+
integration: Optional[str] = None,
2829
) -> "RealtimeClient":
2930
session_id = str(uuid.uuid4())
3031
ws_url = f"{base_url}{options.model.url_path}"
@@ -40,6 +41,7 @@ async def connect(
4041
on_error=None,
4142
initial_state=options.initial_state,
4243
customize_offer=options.customize_offer,
44+
integration=integration,
4345
)
4446

4547
manager = WebRTCManager(config)

decart/realtime/webrtc_connection.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import json
33
import logging
44
from typing import Optional, Callable
5+
from urllib.parse import quote
56
import aiohttp
67
from aiortc import (
78
RTCPeerConnection,
@@ -13,6 +14,7 @@
1314
)
1415

1516
from ..errors import WebRTCError
17+
from .._user_agent import build_user_agent
1618
from .messages import (
1719
parse_incoming_message,
1820
message_to_json,
@@ -46,12 +48,23 @@ def __init__(
4648
self._ws_task: Optional[asyncio.Task] = None
4749
self._ice_candidates_queue: list[RTCIceCandidate] = []
4850

49-
async def connect(self, url: str, local_track: MediaStreamTrack, timeout: float = 30) -> None:
51+
async def connect(
52+
self,
53+
url: str,
54+
local_track: MediaStreamTrack,
55+
timeout: float = 30,
56+
integration: Optional[str] = None,
57+
) -> None:
5058
try:
5159
await self._set_state("connecting")
5260

5361
ws_url = url.replace("https://", "wss://").replace("http://", "ws://")
5462

63+
# Add user agent as query parameter (browsers don't support WS headers)
64+
user_agent = build_user_agent(integration)
65+
separator = "&" if "?" in ws_url else "?"
66+
ws_url = f"{ws_url}{separator}user_agent={quote(user_agent)}"
67+
5568
self._session = aiohttp.ClientSession()
5669
self._ws = await self._session.ws_connect(ws_url)
5770

decart/realtime/webrtc_manager.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class WebRTCConfiguration:
2929
on_error: Optional[Callable[[Exception], None]] = None
3030
initial_state: Optional[ModelState] = None
3131
customize_offer: Optional[Callable] = None
32+
integration: Optional[str] = None
3233

3334

3435
def _is_retryable_error(exception: Exception) -> bool:
@@ -55,6 +56,7 @@ async def connect(self, local_track: MediaStreamTrack) -> bool:
5556
await self._connection.connect(
5657
url=self._config.webrtc_url,
5758
local_track=local_track,
59+
integration=self._config.integration,
5860
)
5961
return True
6062
except Exception as e:

tests/test_process.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,77 @@ async def test_process_with_cancellation() -> None:
101101
"cancel_token": cancel_token,
102102
}
103103
)
104+
105+
106+
@pytest.mark.asyncio
107+
async def test_process_includes_user_agent_header() -> None:
108+
"""Test that User-Agent header is included in requests."""
109+
client = DecartClient(api_key="test-key")
110+
111+
with patch("aiohttp.ClientSession") as mock_session_cls:
112+
mock_response = MagicMock()
113+
mock_response.ok = True
114+
mock_response.read = AsyncMock(return_value=b"fake video data")
115+
116+
mock_session = MagicMock()
117+
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
118+
mock_session.__aexit__ = AsyncMock(return_value=None)
119+
mock_session.post = MagicMock()
120+
mock_session.post.return_value.__aenter__ = AsyncMock(return_value=mock_response)
121+
mock_session.post.return_value.__aexit__ = AsyncMock(return_value=None)
122+
123+
mock_session_cls.return_value = mock_session
124+
125+
await client.process(
126+
{
127+
"model": models.video("lucy-pro-t2v"),
128+
"prompt": "Test prompt",
129+
}
130+
)
131+
132+
# Verify post was called with User-Agent header
133+
mock_session.post.assert_called_once()
134+
call_kwargs = mock_session.post.call_args[1]
135+
headers = call_kwargs.get("headers", {})
136+
137+
assert "User-Agent" in headers
138+
assert headers["User-Agent"].startswith("decart-python-sdk/")
139+
assert "lang/py" in headers["User-Agent"]
140+
141+
142+
@pytest.mark.asyncio
143+
async def test_process_includes_integration_in_user_agent() -> None:
144+
"""Test that integration parameter is included in User-Agent header."""
145+
client = DecartClient(api_key="test-key", integration="langchain/0.1.0")
146+
147+
with patch("aiohttp.ClientSession") as mock_session_cls:
148+
mock_response = MagicMock()
149+
mock_response.ok = True
150+
mock_response.read = AsyncMock(return_value=b"fake video data")
151+
152+
mock_session = MagicMock()
153+
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
154+
mock_session.__aexit__ = AsyncMock(return_value=None)
155+
mock_session.post = MagicMock()
156+
mock_session.post.return_value.__aenter__ = AsyncMock(return_value=mock_response)
157+
mock_session.post.return_value.__aexit__ = AsyncMock(return_value=None)
158+
159+
mock_session_cls.return_value = mock_session
160+
161+
await client.process(
162+
{
163+
"model": models.video("lucy-pro-t2v"),
164+
"prompt": "Test prompt",
165+
}
166+
)
167+
168+
# Verify post was called with User-Agent header including integration
169+
mock_session.post.assert_called_once()
170+
call_kwargs = mock_session.post.call_args[1]
171+
headers = call_kwargs.get("headers", {})
172+
173+
assert "User-Agent" in headers
174+
assert headers["User-Agent"].startswith("decart-python-sdk/")
175+
assert "lang/py" in headers["User-Agent"]
176+
assert "langchain/0.1.0" in headers["User-Agent"]
177+
assert headers["User-Agent"].endswith(" langchain/0.1.0")

tests/test_user_agent.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Tests for User-Agent header construction."""
2+
3+
from decart._user_agent import build_user_agent
4+
from decart._version import __version__
5+
6+
7+
def test_build_user_agent_without_integration():
8+
"""Test User-Agent without integration parameter."""
9+
user_agent = build_user_agent()
10+
11+
assert user_agent == f"decart-python-sdk/{__version__} lang/py"
12+
assert user_agent.startswith("decart-python-sdk/")
13+
assert "lang/py" in user_agent
14+
15+
16+
def test_build_user_agent_with_integration():
17+
"""Test User-Agent with integration parameter."""
18+
user_agent = build_user_agent("langchain/0.1.0")
19+
20+
expected = f"decart-python-sdk/{__version__} lang/py langchain/0.1.0"
21+
assert user_agent == expected
22+
23+
parts = user_agent.split(" ")
24+
assert len(parts) == 3
25+
assert parts[0].startswith("decart-python-sdk/")
26+
assert parts[1] == "lang/py"
27+
assert parts[2] == "langchain/0.1.0"

0 commit comments

Comments
 (0)