Skip to content

Commit 2f5ad81

Browse files
authored
Merge branch 'development' into ci/add-to-quickapp-project
2 parents 09cb7bf + 5a01ddb commit 2f5ad81

11 files changed

Lines changed: 972 additions & 2 deletions

File tree

README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
- [Get Toolset by Id](#get-toolset-by-id)
4343
- [Resource Permissions](#resource-permissions)
4444
- [Grant Permissions](#grant-permissions)
45+
- [Client Channel](#client-channel)
46+
- [Sign In to Toolsets](#sign-in-to-toolsets)
4547
- [Client Pool](#client-pool)
4648
- [Synchronous Client Pool](#synchronous-client-pool)
4749
- [Asynchronous Client Pool](#asynchronous-client-pool)
@@ -854,6 +856,55 @@ await async_client.resource_permissions.grant(
854856

855857
The method returns `None` on success and raises `DialException` on HTTP error.
856858

859+
### Client Channel
860+
861+
DIAL Core's [client channel API](https://dialx.ai/universal_chat_api.yaml) lets a deployment ask an interactive client (e.g. the chat UI) to take some action and report the result back. The channel id is propagated to the deployment via the `X-DIAL-CLIENT-CHANNEL-ID` forwarded header on the inbound request.
862+
863+
#### Sign In to Toolsets
864+
865+
Use `client_channel.signin_toolsets()` to request interactive sign-in for one or more toolsets on the active client channel. The method returns a `dict[str, SigninResult]` mapping each input toolset id to its outcome — responses are correlated by the client, so the caller never has to deal with the underlying JSON-RPC ids.
866+
867+
```python
868+
from aidial_client import SigninResult
869+
870+
# Sync
871+
results = client.client_channel.signin_toolsets(
872+
channel_id="<channel-id-from-X-DIAL-CLIENT-CHANNEL-ID>",
873+
toolset_ids=[
874+
"toolsets/public/toolset-a",
875+
"toolsets/public/toolset-b",
876+
],
877+
timeout=120.0,
878+
)
879+
880+
# Async
881+
results = await async_client.client_channel.signin_toolsets(
882+
channel_id="<channel-id>",
883+
toolset_ids=["toolsets/public/my-toolset"],
884+
)
885+
```
886+
887+
Each value is a `SigninResult` enum:
888+
889+
```python
890+
{
891+
"toolsets/public/toolset-a": SigninResult.SUCCESS,
892+
"toolsets/public/toolset-b": SigninResult.DENIED,
893+
}
894+
```
895+
896+
- `SigninResult.SUCCESS` — the user signed in.
897+
- `SigninResult.DENIED` — the user declined.
898+
- `SigninResult.ERROR` — the server returned a JSON-RPC error, or the response was missing/unrecognized.
899+
900+
Arguments:
901+
902+
- `channel_id` — required; the channel id received via the `X-DIAL-CLIENT-CHANNEL-ID` header on the inbound request.
903+
- `toolset_ids` — sequence of toolset ids to request sign-in for; an empty sequence returns `{}` without contacting the server.
904+
- `timeout` — optional `float` seconds or `httpx.Timeout`; defaults to the client-wide timeout. Useful for interactive flows where the user may take a while to respond.
905+
906+
Raises `DialException` on HTTP errors (e.g. unauthorized, missing channel), transport failures (timeouts, network errors), or if the SSE stream closes without a response event.
907+
857908
### Client Pool
858909

859910
When you need to create multiple DIAL clients and wish to enhance performance by reusing the HTTP connection for the same DIAL instance, consider using synchronous and asynchronous **client pools**.

aidial_client/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
ParsingDataError,
1010
ResourceNotFoundError,
1111
)
12+
from aidial_client.types.client_channel import SigninResult
1213
from aidial_client.types.model import ModelInfo, ModelLimits, ModelPricing
1314
from aidial_client.types.toolset import ToolsetInfo
1415

@@ -30,4 +31,5 @@
3031
"ModelInfo",
3132
"ModelPricing",
3233
"ModelLimits",
34+
"SigninResult",
3335
]

aidial_client/_client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ def _init_resources(self) -> None:
119119
self.resource_permissions = resources.ResourcePermissions(
120120
http_client=self._http_client
121121
)
122+
self.client_channel = resources.ClientChannel(
123+
http_client=self._http_client
124+
)
122125

123126
def _create_http_client(self) -> SyncHTTPClient:
124127
return SyncHTTPClient(
@@ -207,6 +210,9 @@ def _init_resources(self) -> None:
207210
self.resource_permissions = resources.AsyncResourcePermissions(
208211
http_client=self._http_client
209212
)
213+
self.client_channel = resources.AsyncClientChannel(
214+
http_client=self._http_client
215+
)
210216

211217
def _create_http_client(self) -> AsyncHTTPClient:
212218
return AsyncHTTPClient(

aidial_client/_http_client/_async.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
import asyncio
2+
from contextlib import asynccontextmanager
23
from http import HTTPStatus
3-
from typing import Callable, Dict, Optional, Type
4+
from typing import (
5+
Any,
6+
AsyncIterator,
7+
Callable,
8+
Dict,
9+
Mapping,
10+
Optional,
11+
Type,
12+
Union,
13+
)
414

515
import httpx
616

717
from aidial_client._auth import AsyncAuthValue, aget_combined_auth_headers
818
from aidial_client._exception import DialException
919
from aidial_client._http_client._base import BaseHTTPClient
20+
from aidial_client._internal_types._defaults import NOT_GIVEN, NotGiven
1021
from aidial_client._internal_types._generic import ResponseT
1122
from aidial_client._internal_types._http_request import FinalRequestOptions
1223
from aidial_client._log import logger
@@ -108,3 +119,54 @@ async def request(
108119
raise raised_error from err
109120

110121
return process_block_response(cast_to=cast_to, response=response)
122+
123+
@asynccontextmanager
124+
async def stream_sse(
125+
self,
126+
*,
127+
method: str,
128+
url: str,
129+
json_data: Any,
130+
headers: Optional[Mapping[str, str]] = None,
131+
timeout: Union[float, httpx.Timeout, None, NotGiven] = NOT_GIVEN,
132+
) -> AsyncIterator[httpx.Response]:
133+
"""Open an SSE streaming response. Yields the open httpx.Response.
134+
135+
Auth headers are merged in. On non-2xx, reads the body and raises
136+
a DialException; transport errors (timeouts, network failures) are
137+
also wrapped so the caller always sees DialException. Retries are
138+
not performed for streaming requests.
139+
140+
``timeout`` defaults to the client-wide timeout; pass an explicit
141+
``None`` (or ``httpx.Timeout(None)``) for no timeout.
142+
"""
143+
merged_headers = {**(await self.auth_headers()), **(headers or {})}
144+
effective_timeout = (
145+
self._timeout if isinstance(timeout, NotGiven) else timeout
146+
)
147+
try:
148+
async with self._internal_http_client.stream(
149+
method=method,
150+
url=self._prepare_url(url),
151+
headers=merged_headers,
152+
json=json_data,
153+
timeout=effective_timeout,
154+
) as response:
155+
try:
156+
response.raise_for_status()
157+
except httpx.HTTPStatusError as err:
158+
try:
159+
await response.aread()
160+
except httpx.HTTPError:
161+
pass
162+
raise self._make_dial_error_from_response(
163+
err.response
164+
) from err
165+
yield response
166+
except httpx.TimeoutException as err:
167+
raise DialException(
168+
message="Request timed out",
169+
status_code=HTTPStatus.REQUEST_TIMEOUT,
170+
) from err
171+
except httpx.HTTPError as err:
172+
raise DialException(message=f"Request failed: {err}") from err

aidial_client/_http_client/_sse.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from typing import AsyncIterator, Iterator, List
2+
3+
from aidial_client._log import logger
4+
5+
_UNCOMMITTED_BUFFER_WARNING = (
6+
"Uncommitted data chunks in SSE stream "
7+
"(stream ended without a terminating blank line); discarding."
8+
)
9+
10+
11+
def _strip_field(line: str, prefix: str) -> str:
12+
"""Strip a single leading U+0020 SPACE after the field colon, per the SSE spec."""
13+
value = line[len(prefix) :]
14+
return value[1:] if value.startswith(" ") else value
15+
16+
17+
def iter_data_events(lines: Iterator[str]) -> Iterator[str]:
18+
"""Yield the payload of each complete ``data:`` event from an SSE line stream.
19+
20+
An event is complete when a blank line follows the ``data:`` line(s). Per
21+
the SSE dispatch rule, a buffer that has not been terminated by a blank
22+
line is discarded (we do NOT flush partial events at end of stream).
23+
Comment lines (``:``) and other field names are ignored.
24+
"""
25+
buffer: List[str] = []
26+
for line in lines:
27+
if line == "":
28+
if buffer:
29+
yield "\n".join(buffer)
30+
buffer = []
31+
elif line.startswith("data:"):
32+
buffer.append(_strip_field(line, "data:"))
33+
if buffer:
34+
logger.warning(_UNCOMMITTED_BUFFER_WARNING)
35+
36+
37+
async def aiter_data_events(lines: AsyncIterator[str]) -> AsyncIterator[str]:
38+
buffer: List[str] = []
39+
async for line in lines:
40+
if line == "":
41+
if buffer:
42+
yield "\n".join(buffer)
43+
buffer = []
44+
elif line.startswith("data:"):
45+
buffer.append(_strip_field(line, "data:"))
46+
if buffer:
47+
logger.warning(_UNCOMMITTED_BUFFER_WARNING)

aidial_client/_http_client/_sync.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import time
2+
from contextlib import contextmanager
23
from http import HTTPStatus
3-
from typing import Callable, Dict, Optional, Type
4+
from typing import Any, Callable, Dict, Iterator, Mapping, Optional, Type, Union
45

56
import httpx
67

78
from aidial_client._auth import SyncAuthValue, get_combined_auth_headers
89
from aidial_client._exception import DialException
910
from aidial_client._http_client._base import BaseHTTPClient
11+
from aidial_client._internal_types._defaults import NOT_GIVEN, NotGiven
1012
from aidial_client._internal_types._generic import ResponseT
1113
from aidial_client._internal_types._http_request import FinalRequestOptions
1214
from aidial_client._log import logger
@@ -108,3 +110,54 @@ def request(
108110
raise raised_error from err
109111

110112
return process_block_response(cast_to=cast_to, response=response)
113+
114+
@contextmanager
115+
def stream_sse(
116+
self,
117+
*,
118+
method: str,
119+
url: str,
120+
json_data: Any,
121+
headers: Optional[Mapping[str, str]] = None,
122+
timeout: Union[float, httpx.Timeout, None, NotGiven] = NOT_GIVEN,
123+
) -> Iterator[httpx.Response]:
124+
"""Open an SSE streaming response. Yields the open httpx.Response.
125+
126+
Auth headers are merged in. On non-2xx, reads the body and raises
127+
a DialException; transport errors (timeouts, network failures) are
128+
also wrapped so the caller always sees DialException. Retries are
129+
not performed for streaming requests.
130+
131+
``timeout`` defaults to the client-wide timeout; pass an explicit
132+
``None`` (or ``httpx.Timeout(None)``) for no timeout.
133+
"""
134+
merged_headers = {**self.auth_headers(), **(headers or {})}
135+
effective_timeout = (
136+
self._timeout if isinstance(timeout, NotGiven) else timeout
137+
)
138+
try:
139+
with self._internal_http_client.stream(
140+
method=method,
141+
url=self._prepare_url(url),
142+
headers=merged_headers,
143+
json=json_data,
144+
timeout=effective_timeout,
145+
) as response:
146+
try:
147+
response.raise_for_status()
148+
except httpx.HTTPStatusError as err:
149+
try:
150+
response.read()
151+
except httpx.HTTPError:
152+
pass
153+
raise self._make_dial_error_from_response(
154+
err.response
155+
) from err
156+
yield response
157+
except httpx.TimeoutException as err:
158+
raise DialException(
159+
message="Request timed out",
160+
status_code=HTTPStatus.REQUEST_TIMEOUT,
161+
) from err
162+
except httpx.HTTPError as err:
163+
raise DialException(message=f"Request failed: {err}") from err
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from typing import Any, Dict, List, Literal, Optional, Union
2+
3+
from aidial_client._compatibility.pydantic_v1 import (
4+
BaseModel,
5+
Extra,
6+
Field,
7+
root_validator,
8+
)
9+
10+
11+
class JsonRpcError(BaseModel):
12+
code: int
13+
message: str
14+
data: Optional[Any] = None
15+
16+
class Config:
17+
extra = Extra.allow
18+
19+
20+
class JsonRpcRequest(BaseModel):
21+
jsonrpc: Literal["2.0"] = "2.0"
22+
method: str
23+
params: Optional[Union[List[Any], Dict[str, Any]]] = None
24+
id: Optional[Union[int, str]] = None
25+
26+
class Config:
27+
smart_union = True
28+
29+
30+
class JsonRpcResponse(BaseModel):
31+
jsonrpc: Literal["2.0"]
32+
result: Optional[Any] = None
33+
error: Optional[JsonRpcError] = None
34+
id: Optional[Union[int, str]] = Field(...)
35+
36+
class Config:
37+
smart_union = True
38+
extra = Extra.allow
39+
40+
@root_validator(pre=True)
41+
def _validate_result_xor_error(cls, values):
42+
"""Per JSON-RPC 2.0 (https://www.jsonrpc.org/specification#response_object),
43+
either ``result`` or ``error`` MUST be included (presence-wise — ``null``
44+
is a valid result value), and both MUST NOT be included.
45+
"""
46+
if not isinstance(values, dict):
47+
return values
48+
has_result = "result" in values
49+
has_error = "error" in values
50+
if has_result and has_error:
51+
raise ValueError(
52+
"JSON-RPC response must not contain both 'result' and 'error'"
53+
)
54+
if not has_result and not has_error:
55+
raise ValueError(
56+
"JSON-RPC response must contain either 'result' or 'error'"
57+
)
58+
return values
59+
60+
61+
class JsonRpcResponses(BaseModel):
62+
"""Pydantic root model that accepts a single JSON-RPC response object or
63+
a batch array, normalizing both to a list via the ``responses`` property.
64+
"""
65+
66+
__root__: Union[JsonRpcResponse, List[JsonRpcResponse]]
67+
68+
class Config:
69+
smart_union = True
70+
71+
@property
72+
def responses(self) -> List[JsonRpcResponse]:
73+
if isinstance(self.__root__, list):
74+
return self.__root__
75+
return [self.__root__]

aidial_client/resources/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
from aidial_client.resources.client_channel import (
2+
AsyncClientChannel,
3+
ClientChannel,
4+
)
15
from aidial_client.resources.deployments import AsyncDeployments, Deployments
26
from aidial_client.resources.metadata import AsyncMetadata, Metadata
37
from aidial_client.resources.model import AsyncModel, Model
@@ -34,4 +38,6 @@
3438
"AsyncModel",
3539
"ResourcePermissions",
3640
"AsyncResourcePermissions",
41+
"ClientChannel",
42+
"AsyncClientChannel",
3743
]

0 commit comments

Comments
 (0)