aidial-client 0.10.0.dev3__tar.gz → 0.10.0.dev4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/PKG-INFO +52 -1
  2. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/README.md +51 -0
  3. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/__init__.py +2 -0
  4. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_client.py +6 -0
  5. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_http_client/_async.py +63 -1
  6. aidial_client-0.10.0.dev4/aidial_client/_http_client/_sse.py +47 -0
  7. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_http_client/_sync.py +54 -1
  8. aidial_client-0.10.0.dev4/aidial_client/_internal_types/_json_rpc.py +75 -0
  9. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/resources/__init__.py +6 -0
  10. aidial_client-0.10.0.dev4/aidial_client/resources/client_channel.py +218 -0
  11. aidial_client-0.10.0.dev4/aidial_client/types/client_channel.py +9 -0
  12. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/pyproject.toml +1 -1
  13. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/LICENSE +0 -0
  14. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_auth.py +0 -0
  15. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_client_pool.py +0 -0
  16. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_compatibility/__init__.py +0 -0
  17. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_compatibility/openai.py +0 -0
  18. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_compatibility/pydantic.py +0 -0
  19. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_compatibility/pydantic_v1.py +0 -0
  20. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_constants.py +0 -0
  21. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_exception.py +0 -0
  22. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_http_client/__init__.py +0 -0
  23. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_http_client/_base.py +0 -0
  24. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_internal_types/__init__.py +0 -0
  25. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_internal_types/_defaults.py +0 -0
  26. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_internal_types/_generic.py +0 -0
  27. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_internal_types/_http_request.py +0 -0
  28. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_internal_types/_model.py +0 -0
  29. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_log.py +0 -0
  30. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_utils/__init__.py +0 -0
  31. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_utils/_alias.py +0 -0
  32. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_utils/_dict.py +0 -0
  33. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_utils/_openai.py +0 -0
  34. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_utils/_response_processing.py +0 -0
  35. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/_utils/_type_guard.py +0 -0
  36. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/helpers/__init__.py +0 -0
  37. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/helpers/_url.py +0 -0
  38. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/helpers/storage_resource.py +0 -0
  39. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/py.typed +0 -0
  40. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/resources/application.py +0 -0
  41. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/resources/base.py +0 -0
  42. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/resources/bucket.py +0 -0
  43. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/resources/chat/__init__.py +0 -0
  44. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/resources/chat/completions.py +0 -0
  45. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/resources/deployments.py +0 -0
  46. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/resources/files.py +0 -0
  47. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/resources/metadata.py +0 -0
  48. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/resources/model.py +0 -0
  49. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/resources/prompts.py +0 -0
  50. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/resources/resource_permissions.py +0 -0
  51. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/resources/toolset.py +0 -0
  52. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/types/__init__.py +0 -0
  53. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/types/application.py +0 -0
  54. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/types/bucket.py +0 -0
  55. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/types/chat/__init__.py +0 -0
  56. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/types/chat/addon.py +0 -0
  57. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/types/chat/function.py +0 -0
  58. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/types/chat/legacy/__init__.py +0 -0
  59. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/types/chat/legacy/application_request.py +0 -0
  60. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/types/chat/legacy/chat_completion.py +0 -0
  61. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/types/chat/request.py +0 -0
  62. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/types/chat/request_param.py +0 -0
  63. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/types/chat/response.py +0 -0
  64. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/types/chat/tool.py +0 -0
  65. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/types/deployment.py +0 -0
  66. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/types/file.py +0 -0
  67. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/types/metadata.py +0 -0
  68. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/types/model.py +0 -0
  69. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/types/prompt.py +0 -0
  70. {aidial_client-0.10.0.dev3 → aidial_client-0.10.0.dev4}/aidial_client/types/toolset.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aidial-client
3
- Version: 0.10.0.dev3
3
+ Version: 0.10.0.dev4
4
4
  Summary: A Python client library for the AI DIAL API
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -64,6 +64,8 @@ Description-Content-Type: text/markdown
64
64
  - [Get Toolset by Id](#get-toolset-by-id)
65
65
  - [Resource Permissions](#resource-permissions)
66
66
  - [Grant Permissions](#grant-permissions)
67
+ - [Client Channel](#client-channel)
68
+ - [Sign In to Toolsets](#sign-in-to-toolsets)
67
69
  - [Client Pool](#client-pool)
68
70
  - [Synchronous Client Pool](#synchronous-client-pool)
69
71
  - [Asynchronous Client Pool](#asynchronous-client-pool)
@@ -876,6 +878,55 @@ await async_client.resource_permissions.grant(
876
878
 
877
879
  The method returns `None` on success and raises `DialException` on HTTP error.
878
880
 
881
+ ### Client Channel
882
+
883
+ 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.
884
+
885
+ #### Sign In to Toolsets
886
+
887
+ 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.
888
+
889
+ ```python
890
+ from aidial_client import SigninResult
891
+
892
+ # Sync
893
+ results = client.client_channel.signin_toolsets(
894
+ channel_id="<channel-id-from-X-DIAL-CLIENT-CHANNEL-ID>",
895
+ toolset_ids=[
896
+ "toolsets/public/toolset-a",
897
+ "toolsets/public/toolset-b",
898
+ ],
899
+ timeout=120.0,
900
+ )
901
+
902
+ # Async
903
+ results = await async_client.client_channel.signin_toolsets(
904
+ channel_id="<channel-id>",
905
+ toolset_ids=["toolsets/public/my-toolset"],
906
+ )
907
+ ```
908
+
909
+ Each value is a `SigninResult` enum:
910
+
911
+ ```python
912
+ {
913
+ "toolsets/public/toolset-a": SigninResult.SUCCESS,
914
+ "toolsets/public/toolset-b": SigninResult.DENIED,
915
+ }
916
+ ```
917
+
918
+ - `SigninResult.SUCCESS` — the user signed in.
919
+ - `SigninResult.DENIED` — the user declined.
920
+ - `SigninResult.ERROR` — the server returned a JSON-RPC error, or the response was missing/unrecognized.
921
+
922
+ Arguments:
923
+
924
+ - `channel_id` — required; the channel id received via the `X-DIAL-CLIENT-CHANNEL-ID` header on the inbound request.
925
+ - `toolset_ids` — sequence of toolset ids to request sign-in for; an empty sequence returns `{}` without contacting the server.
926
+ - `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.
927
+
928
+ 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.
929
+
879
930
  ### Client Pool
880
931
 
881
932
  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**.
@@ -42,6 +42,8 @@
42
42
  - [Get Toolset by Id](#get-toolset-by-id)
43
43
  - [Resource Permissions](#resource-permissions)
44
44
  - [Grant Permissions](#grant-permissions)
45
+ - [Client Channel](#client-channel)
46
+ - [Sign In to Toolsets](#sign-in-to-toolsets)
45
47
  - [Client Pool](#client-pool)
46
48
  - [Synchronous Client Pool](#synchronous-client-pool)
47
49
  - [Asynchronous Client Pool](#asynchronous-client-pool)
@@ -854,6 +856,55 @@ await async_client.resource_permissions.grant(
854
856
 
855
857
  The method returns `None` on success and raises `DialException` on HTTP error.
856
858
 
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
+
857
908
  ### Client Pool
858
909
 
859
910
  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**.
@@ -9,6 +9,7 @@ from aidial_client._exception import (
9
9
  ParsingDataError,
10
10
  ResourceNotFoundError,
11
11
  )
12
+ from aidial_client.types.client_channel import SigninResult
12
13
  from aidial_client.types.model import ModelInfo, ModelLimits, ModelPricing
13
14
  from aidial_client.types.toolset import ToolsetInfo
14
15
 
@@ -30,4 +31,5 @@ __all__ = [
30
31
  "ModelInfo",
31
32
  "ModelPricing",
32
33
  "ModelLimits",
34
+ "SigninResult",
33
35
  ]
@@ -119,6 +119,9 @@ class Dial(BaseDialClient[SyncHTTPClient, SyncAuthValue]):
119
119
  self.resource_permissions = resources.ResourcePermissions(
120
120
  http_client=self._http_client
121
121
  )
122
+ self.client_channel = resources.ClientChannel(
123
+ http_client=self._http_client
124
+ )
122
125
 
123
126
  def _create_http_client(self) -> SyncHTTPClient:
124
127
  return SyncHTTPClient(
@@ -207,6 +210,9 @@ class AsyncDial(BaseDialClient[AsyncHTTPClient, AsyncAuthValue]):
207
210
  self.resource_permissions = resources.AsyncResourcePermissions(
208
211
  http_client=self._http_client
209
212
  )
213
+ self.client_channel = resources.AsyncClientChannel(
214
+ http_client=self._http_client
215
+ )
210
216
 
211
217
  def _create_http_client(self) -> AsyncHTTPClient:
212
218
  return AsyncHTTPClient(
@@ -1,12 +1,23 @@
1
1
  import asyncio
2
+ from contextlib import asynccontextmanager
2
3
  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
+ )
4
14
 
5
15
  import httpx
6
16
 
7
17
  from aidial_client._auth import AsyncAuthValue, aget_combined_auth_headers
8
18
  from aidial_client._exception import DialException
9
19
  from aidial_client._http_client._base import BaseHTTPClient
20
+ from aidial_client._internal_types._defaults import NOT_GIVEN, NotGiven
10
21
  from aidial_client._internal_types._generic import ResponseT
11
22
  from aidial_client._internal_types._http_request import FinalRequestOptions
12
23
  from aidial_client._log import logger
@@ -108,3 +119,54 @@ class AsyncHTTPClient(BaseHTTPClient[httpx.AsyncClient, AsyncAuthValue]):
108
119
  raise raised_error from err
109
120
 
110
121
  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
@@ -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)
@@ -1,12 +1,14 @@
1
1
  import time
2
+ from contextlib import contextmanager
2
3
  from http import HTTPStatus
3
- from typing import Callable, Dict, Optional, Type
4
+ from typing import Any, Callable, Dict, Iterator, Mapping, Optional, Type, Union
4
5
 
5
6
  import httpx
6
7
 
7
8
  from aidial_client._auth import SyncAuthValue, get_combined_auth_headers
8
9
  from aidial_client._exception import DialException
9
10
  from aidial_client._http_client._base import BaseHTTPClient
11
+ from aidial_client._internal_types._defaults import NOT_GIVEN, NotGiven
10
12
  from aidial_client._internal_types._generic import ResponseT
11
13
  from aidial_client._internal_types._http_request import FinalRequestOptions
12
14
  from aidial_client._log import logger
@@ -108,3 +110,54 @@ class SyncHTTPClient(BaseHTTPClient[httpx.Client, SyncAuthValue]):
108
110
  raise raised_error from err
109
111
 
110
112
  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
@@ -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__]
@@ -1,3 +1,7 @@
1
+ from aidial_client.resources.client_channel import (
2
+ AsyncClientChannel,
3
+ ClientChannel,
4
+ )
1
5
  from aidial_client.resources.deployments import AsyncDeployments, Deployments
2
6
  from aidial_client.resources.metadata import AsyncMetadata, Metadata
3
7
  from aidial_client.resources.model import AsyncModel, Model
@@ -34,4 +38,6 @@ __all__ = [
34
38
  "AsyncModel",
35
39
  "ResourcePermissions",
36
40
  "AsyncResourcePermissions",
41
+ "ClientChannel",
42
+ "AsyncClientChannel",
37
43
  ]
@@ -0,0 +1,218 @@
1
+ from http import HTTPStatus
2
+ from typing import Any, List, Optional, Sequence, Union
3
+
4
+ import httpx
5
+
6
+ from aidial_client._compatibility.pydantic_v1 import ValidationError
7
+ from aidial_client._exception import (
8
+ DialException,
9
+ InvalidRequestError,
10
+ ParsingDataError,
11
+ )
12
+ from aidial_client._http_client._sse import aiter_data_events, iter_data_events
13
+ from aidial_client._internal_types._defaults import NOT_GIVEN, NotGiven
14
+ from aidial_client._internal_types._json_rpc import (
15
+ JsonRpcRequest,
16
+ JsonRpcResponse,
17
+ JsonRpcResponses,
18
+ )
19
+ from aidial_client.resources.base import AsyncResource, Resource
20
+ from aidial_client.types.client_channel import SigninResult
21
+
22
+ _CLIENT_CHANNEL_HEADER = "X-DIAL-CLIENT-CHANNEL-ID"
23
+ _INTERACT_URL = "v1/ops/client-channel/interact"
24
+ _SIGNIN_METHOD = "toolset/signin"
25
+
26
+
27
+ def _normalize_toolset_ids(toolset_ids: Sequence[str]) -> List[str]:
28
+ """Validate ``toolset_ids`` and return a stable list.
29
+
30
+ Catches three caller mistakes that would otherwise produce silent garbage:
31
+ a single string (str is itself a ``Sequence[str]``), a one-shot iterable
32
+ (consumed by the build step, leaving the mapping step with nothing), and
33
+ duplicate ids (the per-toolset result dict cannot represent two outcomes
34
+ for the same key).
35
+ """
36
+ if isinstance(toolset_ids, str):
37
+ raise InvalidRequestError(
38
+ "toolset_ids must be a sequence of toolset ids, not a single str"
39
+ )
40
+ materialized = list(toolset_ids)
41
+ if len(set(materialized)) != len(materialized):
42
+ raise InvalidRequestError("toolset_ids must not contain duplicates")
43
+ return materialized
44
+
45
+
46
+ def _serialize_requests(requests: Sequence[JsonRpcRequest]) -> Any:
47
+ """Serialize a sequence of JsonRpcRequest to the wire form.
48
+
49
+ Always emits an array. DIAL Core accepts both an object and an array
50
+ body, but emitting a consistent shape avoids the "wire shape depends
51
+ on count" footgun and keeps the empty-input case safe.
52
+ """
53
+ return [r.dict(exclude_none=True) for r in requests]
54
+
55
+
56
+ def _parse_responses(payload: str) -> List[JsonRpcResponse]:
57
+ try:
58
+ return JsonRpcResponses.parse_raw(payload).responses
59
+ except (ValidationError, ValueError) as err:
60
+ raise ParsingDataError(
61
+ message=(
62
+ "Invalid JSON-RPC response in client-channel interact: "
63
+ f"{err}"
64
+ )
65
+ ) from err
66
+
67
+
68
+ def _no_data_error() -> DialException:
69
+ return DialException(
70
+ message="Client-channel interact stream closed without a data event",
71
+ status_code=HTTPStatus.GATEWAY_TIMEOUT,
72
+ )
73
+
74
+
75
+ def _raise_if_batch_error(responses: Sequence[JsonRpcResponse]) -> None:
76
+ """Per JSON-RPC 2.0, a response with ``id=null`` indicates the server
77
+ could not associate the response with any request (parse error, invalid
78
+ batch, etc.). Surface that as a ``DialException`` instead of silently
79
+ mapping every toolset to ERROR.
80
+ """
81
+ for r in responses:
82
+ if r.id is None and r.error is not None:
83
+ raise DialException(
84
+ message=(
85
+ f"Server-level JSON-RPC error "
86
+ f"({r.error.code}): {r.error.message}"
87
+ ),
88
+ status_code=HTTPStatus.BAD_GATEWAY,
89
+ )
90
+
91
+
92
+ _RESULT_TO_OUTCOME = {
93
+ SigninResult.SUCCESS.value: SigninResult.SUCCESS,
94
+ SigninResult.DENIED.value: SigninResult.DENIED,
95
+ }
96
+
97
+
98
+ def _outcome_for(response: Optional[JsonRpcResponse]) -> SigninResult:
99
+ if response is None or response.error is not None:
100
+ return SigninResult.ERROR
101
+ if not isinstance(response.result, str):
102
+ return SigninResult.ERROR
103
+ return _RESULT_TO_OUTCOME.get(response.result, SigninResult.ERROR)
104
+
105
+
106
+ def _build_signin_requests(
107
+ toolset_ids: Sequence[str],
108
+ ) -> List[JsonRpcRequest]:
109
+ return [
110
+ JsonRpcRequest(
111
+ method=_SIGNIN_METHOD,
112
+ params={"toolsetId": tid},
113
+ id=str(idx),
114
+ )
115
+ for idx, tid in enumerate(toolset_ids, start=1)
116
+ ]
117
+
118
+
119
+ def _map_signin_results(
120
+ toolset_ids: Sequence[str],
121
+ responses: Sequence[JsonRpcResponse],
122
+ ) -> "dict[str, SigninResult]":
123
+ by_id = {str(r.id): r for r in responses if r.id is not None}
124
+ return {
125
+ tid: _outcome_for(by_id.get(str(idx)))
126
+ for idx, tid in enumerate(toolset_ids, start=1)
127
+ }
128
+
129
+
130
+ class ClientChannel(Resource):
131
+ def signin_toolsets(
132
+ self,
133
+ *,
134
+ channel_id: str,
135
+ toolset_ids: Sequence[str],
136
+ timeout: Union[float, httpx.Timeout, None, NotGiven] = NOT_GIVEN,
137
+ ) -> "dict[str, SigninResult]":
138
+ """Request interactive sign-in for one or more toolsets on the given
139
+ client channel and return the per-toolset outcome.
140
+
141
+ ``toolset_ids`` are typically DIAL toolset ids (e.g.
142
+ ``"toolsets/public/my-toolset"``). The returned dict has one entry
143
+ per input id; toolsets for which the server does not produce a
144
+ response are mapped to :class:`SigninResult.ERROR`. Iteration order
145
+ of the returned dict matches the order of ``toolset_ids``.
146
+
147
+ Raises :class:`InvalidRequestError` if ``toolset_ids`` is a plain
148
+ string or contains duplicates. Raises :class:`DialException` on HTTP
149
+ errors, transport failures, server-level JSON-RPC errors (e.g. parse
150
+ error returned with ``id=null``), or if the SSE stream closes
151
+ without a response event.
152
+ """
153
+ ids = _normalize_toolset_ids(toolset_ids)
154
+ if not ids:
155
+ return {}
156
+ responses = self._interact(
157
+ channel_id=channel_id,
158
+ requests=_build_signin_requests(ids),
159
+ timeout=timeout,
160
+ )
161
+ _raise_if_batch_error(responses)
162
+ return _map_signin_results(ids, responses)
163
+
164
+ def _interact(
165
+ self,
166
+ *,
167
+ channel_id: str,
168
+ requests: Sequence[JsonRpcRequest],
169
+ timeout: Union[float, httpx.Timeout, None, NotGiven] = NOT_GIVEN,
170
+ ) -> List[JsonRpcResponse]:
171
+ with self.http_client.stream_sse(
172
+ method="POST",
173
+ url=_INTERACT_URL,
174
+ json_data=_serialize_requests(requests),
175
+ headers={_CLIENT_CHANNEL_HEADER: channel_id},
176
+ timeout=timeout,
177
+ ) as response:
178
+ for payload in iter_data_events(response.iter_lines()):
179
+ return _parse_responses(payload)
180
+ raise _no_data_error()
181
+
182
+
183
+ class AsyncClientChannel(AsyncResource):
184
+ async def signin_toolsets(
185
+ self,
186
+ *,
187
+ channel_id: str,
188
+ toolset_ids: Sequence[str],
189
+ timeout: Union[float, httpx.Timeout, None, NotGiven] = NOT_GIVEN,
190
+ ) -> "dict[str, SigninResult]":
191
+ ids = _normalize_toolset_ids(toolset_ids)
192
+ if not ids:
193
+ return {}
194
+ responses = await self._interact(
195
+ channel_id=channel_id,
196
+ requests=_build_signin_requests(ids),
197
+ timeout=timeout,
198
+ )
199
+ _raise_if_batch_error(responses)
200
+ return _map_signin_results(ids, responses)
201
+
202
+ async def _interact(
203
+ self,
204
+ *,
205
+ channel_id: str,
206
+ requests: Sequence[JsonRpcRequest],
207
+ timeout: Union[float, httpx.Timeout, None, NotGiven] = NOT_GIVEN,
208
+ ) -> List[JsonRpcResponse]:
209
+ async with self.http_client.stream_sse(
210
+ method="POST",
211
+ url=_INTERACT_URL,
212
+ json_data=_serialize_requests(requests),
213
+ headers={_CLIENT_CHANNEL_HEADER: channel_id},
214
+ timeout=timeout,
215
+ ) as response:
216
+ async for payload in aiter_data_events(response.aiter_lines()):
217
+ return _parse_responses(payload)
218
+ raise _no_data_error()
@@ -0,0 +1,9 @@
1
+ from enum import Enum
2
+
3
+
4
+ class SigninResult(str, Enum):
5
+ """Outcome of an interactive sign-in request for a single toolset."""
6
+
7
+ SUCCESS = "success"
8
+ DENIED = "denied"
9
+ ERROR = "error"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aidial-client"
3
- version = "0.10.0.dev3"
3
+ version = "0.10.0.dev4"
4
4
  description = "A Python client library for the AI DIAL API"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"