nucliadb-agentic-api 1.0.0.post156__py3-none-any.whl

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 (37) hide show
  1. nucliadb_agentic_api/VERSION +1 -0
  2. nucliadb_agentic_api/__init__.py +20 -0
  3. nucliadb_agentic_api/agentic/__init__.py +0 -0
  4. nucliadb_agentic_api/agentic/ask_handler.py +66 -0
  5. nucliadb_agentic_api/agentic/ask_result.py +389 -0
  6. nucliadb_agentic_api/agentic/ask_transform_to_interaction.py +10 -0
  7. nucliadb_agentic_api/app.py +147 -0
  8. nucliadb_agentic_api/commands.py +66 -0
  9. nucliadb_agentic_api/db/__init__.py +0 -0
  10. nucliadb_agentic_api/db/agentic_configs.py +296 -0
  11. nucliadb_agentic_api/db/settings.py +11 -0
  12. nucliadb_agentic_api/db/sources.py +202 -0
  13. nucliadb_agentic_api/db/transform.py +208 -0
  14. nucliadb_agentic_api/exceptions.py +10 -0
  15. nucliadb_agentic_api/logging.py +18 -0
  16. nucliadb_agentic_api/models.py +321 -0
  17. nucliadb_agentic_api/py.typed +0 -0
  18. nucliadb_agentic_api/server/__init__.py +1 -0
  19. nucliadb_agentic_api/server/server.py +113 -0
  20. nucliadb_agentic_api/server/session.py +276 -0
  21. nucliadb_agentic_api/server/settings.py +22 -0
  22. nucliadb_agentic_api/settings.py +39 -0
  23. nucliadb_agentic_api/utils.py +284 -0
  24. nucliadb_agentic_api/v1/__init__.py +19 -0
  25. nucliadb_agentic_api/v1/agentic_configs.py +117 -0
  26. nucliadb_agentic_api/v1/ask.py +303 -0
  27. nucliadb_agentic_api/v1/ask_websocket.py +141 -0
  28. nucliadb_agentic_api/v1/mcp_content.py +310 -0
  29. nucliadb_agentic_api/v1/mcp_nucliadb.py +785 -0
  30. nucliadb_agentic_api/v1/oauth.py +60 -0
  31. nucliadb_agentic_api/v1/router.py +3 -0
  32. nucliadb_agentic_api/v1/sources.py +158 -0
  33. nucliadb_agentic_api/v1/utils.py +12 -0
  34. nucliadb_agentic_api-1.0.0.post156.dist-info/METADATA +150 -0
  35. nucliadb_agentic_api-1.0.0.post156.dist-info/RECORD +37 -0
  36. nucliadb_agentic_api-1.0.0.post156.dist-info/WHEEL +4 -0
  37. nucliadb_agentic_api-1.0.0.post156.dist-info/entry_points.txt +6 -0
@@ -0,0 +1,117 @@
1
+ from typing import TYPE_CHECKING, cast
2
+
3
+ from fastapi import Header, HTTPException, Request, Response
4
+ from nucliadb_models.resource import NucliaDBRoles
5
+ from nucliadb_utils.authentication import requires
6
+
7
+ from nucliadb_agentic_api import exceptions
8
+ from nucliadb_agentic_api.models import AgenticConfigSchema
9
+ from nucliadb_agentic_api.v1.router import router
10
+
11
+ if TYPE_CHECKING:
12
+ from nucliadb_agentic_api.app import HTTPApplication
13
+
14
+
15
+ @router.post(
16
+ "/api/v1/kb/{kbid}/agentic_configs/{agentic_id}",
17
+ status_code=201,
18
+ summary="Create agentic configuration",
19
+ tags=["Agentic configs"],
20
+ )
21
+ @requires(NucliaDBRoles.OWNER)
22
+ async def create_agentic_config_endpoint(
23
+ request: Request,
24
+ kbid: str,
25
+ agentic_id: str,
26
+ item: AgenticConfigSchema,
27
+ x_nucliadb_account: str = Header(default="", include_in_schema=False),
28
+ ) -> Response:
29
+ app = cast("HTTPApplication", request.app)
30
+ try:
31
+ await app.agent_manager.create_agentic_config(
32
+ account=x_nucliadb_account,
33
+ kbid=kbid,
34
+ agentic_id=agentic_id,
35
+ config=item,
36
+ )
37
+ except exceptions.Conflict as exc:
38
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
39
+ except exceptions.InvalidReference as exc:
40
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
41
+ return Response(status_code=201)
42
+
43
+
44
+ @router.get(
45
+ "/api/v1/kb/{kbid}/agentic_configs/{agentic_id}",
46
+ status_code=200,
47
+ summary="Get agentic configuration",
48
+ tags=["Agentic configs"],
49
+ response_model=AgenticConfigSchema,
50
+ response_model_exclude_none=True,
51
+ )
52
+ @requires(NucliaDBRoles.READER)
53
+ async def get_agentic_config_endpoint(
54
+ request: Request,
55
+ kbid: str,
56
+ agentic_id: str,
57
+ x_nucliadb_account: str = Header(default="", include_in_schema=False),
58
+ ) -> AgenticConfigSchema:
59
+ app = cast("HTTPApplication", request.app)
60
+ try:
61
+ return await app.agent_manager.get_agentic_config(
62
+ account=x_nucliadb_account,
63
+ kbid=kbid,
64
+ agentic_id=agentic_id,
65
+ )
66
+ except exceptions.NotFound as exc:
67
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
68
+
69
+
70
+ @router.get(
71
+ "/api/v1/kb/{kbid}/agentic_configs",
72
+ status_code=200,
73
+ summary="List agentic configurations",
74
+ tags=["Agentic configs"],
75
+ response_model=dict[str, AgenticConfigSchema],
76
+ response_model_exclude_none=True,
77
+ )
78
+ @requires(NucliaDBRoles.READER)
79
+ async def list_agentic_configs_endpoint(
80
+ request: Request,
81
+ kbid: str,
82
+ x_nucliadb_account: str = Header(default="", include_in_schema=False),
83
+ ) -> dict[str, AgenticConfigSchema]:
84
+ app = cast("HTTPApplication", request.app)
85
+ return await app.agent_manager.list_agentic_configs(
86
+ account=x_nucliadb_account,
87
+ kbid=kbid,
88
+ )
89
+
90
+
91
+ @router.patch(
92
+ "/api/v1/kb/{kbid}/agentic_configs/{agentic_id}",
93
+ status_code=204,
94
+ summary="Update agentic configuration",
95
+ tags=["Agentic configs"],
96
+ )
97
+ @requires(NucliaDBRoles.OWNER)
98
+ async def patch_agentic_config_endpoint(
99
+ request: Request,
100
+ kbid: str,
101
+ agentic_id: str,
102
+ item: AgenticConfigSchema,
103
+ x_nucliadb_account: str = Header(default="", include_in_schema=False),
104
+ ) -> Response:
105
+ app = cast("HTTPApplication", request.app)
106
+ try:
107
+ await app.agent_manager.patch_agentic_config(
108
+ account=x_nucliadb_account,
109
+ kbid=kbid,
110
+ agentic_id=agentic_id,
111
+ config=item,
112
+ )
113
+ except exceptions.NotFound as exc:
114
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
115
+ except exceptions.InvalidReference as exc:
116
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
117
+ return Response(status_code=204)
@@ -0,0 +1,303 @@
1
+ import json
2
+ from uuid import UUID
3
+
4
+ from fastapi import Header, Request, Response
5
+ from hyperforge_nucliadb_agentic.ask.exceptions import (
6
+ AnswerJsonSchemaTooLong,
7
+ )
8
+ from hyperforge_nucliadb_agentic.ask.model import (
9
+ AskRequest,
10
+ SyncAskResponse,
11
+ parse_max_tokens,
12
+ )
13
+ from hyperforge_nucliadb_agentic.ask.search import rpc
14
+ from hyperforge_nucliadb_agentic.ask.search.ask import (
15
+ AskResult,
16
+ ask,
17
+ handled_ask_exceptions,
18
+ )
19
+ from hyperforge_nucliadb_agentic.ask.utils.responses import (
20
+ HTTPClientError,
21
+ )
22
+ from nucliadb_models.configuration import AskConfig
23
+ from nucliadb_models.resource import NucliaDBRoles
24
+ from nucliadb_models.search import (
25
+ NucliaDBClientType,
26
+ )
27
+ from nucliadb_models.security import RequestSecurity
28
+ from nucliadb_sdk.v2.exceptions import PreconditionFailed, UnprocessableEntity
29
+ from nucliadb_utils.authentication import NucliaUser, requires
30
+ from pydantic import ValidationError
31
+ from starlette.responses import StreamingResponse
32
+
33
+ from nucliadb_agentic_api.agentic.ask_handler import create_agentic_response
34
+ from nucliadb_agentic_api.v1.router import router
35
+
36
+
37
+ @router.post(
38
+ "/api/v1/kb/{kbid}/ask",
39
+ status_code=200,
40
+ summary="Ask Knowledge Box",
41
+ description="Ask questions on a Knowledge Box",
42
+ tags=["Search"],
43
+ response_model=SyncAskResponse,
44
+ )
45
+ @requires(NucliaDBRoles.READER)
46
+ async def ask_knowledgebox_endpoint(
47
+ request: Request,
48
+ kbid: str,
49
+ item: AskRequest,
50
+ x_nucliadb_account: str = Header(default="", include_in_schema=False),
51
+ x_ndb_client: NucliaDBClientType = Header(NucliaDBClientType.API),
52
+ x_show_consumption: bool = Header(default=False),
53
+ x_nucliadb_user: str = Header(""),
54
+ x_forwarded_for: str = Header(""),
55
+ x_synchronous: bool = Header(
56
+ default=False,
57
+ description="When set to true, outputs response as JSON in a non-streaming way. "
58
+ "This is slower and requires waiting for entire answer to be ready.",
59
+ ),
60
+ ) -> StreamingResponse | HTTPClientError | Response:
61
+ current_user: NucliaUser = request.user
62
+ # If present, security groups from AuthorizationBackend overrides any
63
+ # security group of the payload
64
+ if current_user.security_groups:
65
+ if item.security is None:
66
+ item.security = RequestSecurity(groups=current_user.security_groups)
67
+ else:
68
+ item.security.groups = current_user.security_groups
69
+
70
+ if item.search_configuration is not None:
71
+ search_config = await rpc.get_search_configuration(
72
+ rpc.get_sdk("reader"), kbid, name=item.search_configuration
73
+ )
74
+ if search_config is None:
75
+ return HTTPClientError(
76
+ status_code=400, detail="Search configuration not found"
77
+ )
78
+
79
+ if not isinstance(search_config.config, AskConfig):
80
+ return HTTPClientError(
81
+ status_code=400,
82
+ detail="This search configuration is not valid for `ask`",
83
+ )
84
+
85
+ try:
86
+ item = AskRequest.model_validate(
87
+ search_config.config.model_dump(exclude_unset=True)
88
+ | item.model_dump(exclude_unset=True)
89
+ )
90
+ except ValidationError as e:
91
+ detail = json.loads(e.json())
92
+ return HTTPClientError(status_code=422, detail=detail)
93
+
94
+ if item.agentic_config_id is not None:
95
+ return await create_agentic_response(
96
+ app=request.app,
97
+ agentic_config_id=item.agentic_config_id,
98
+ kbid=kbid,
99
+ ask_request=item,
100
+ user_id=x_nucliadb_user,
101
+ account=x_nucliadb_account,
102
+ client_type=x_ndb_client,
103
+ origin=x_forwarded_for,
104
+ x_synchronous=x_synchronous,
105
+ extra_predict_headers={
106
+ "X-Show-Consumption": str(x_show_consumption).lower()
107
+ },
108
+ )
109
+
110
+ return await create_ask_response(
111
+ kbid=kbid,
112
+ ask_request=item,
113
+ user_id=x_nucliadb_user,
114
+ client_type=x_ndb_client,
115
+ origin=x_forwarded_for,
116
+ x_synchronous=x_synchronous,
117
+ extra_predict_headers={"X-Show-Consumption": str(x_show_consumption).lower()},
118
+ )
119
+
120
+
121
+ @router.post(
122
+ "/api/v1/kb/{kbid}/resource/{rid}/ask",
123
+ status_code=200,
124
+ summary="Ask a resource (by id)",
125
+ description="Ask questions to a resource",
126
+ tags=["Search"],
127
+ response_model=SyncAskResponse,
128
+ )
129
+ @requires(NucliaDBRoles.READER)
130
+ async def resource_ask_endpoint_by_uuid(
131
+ request: Request,
132
+ kbid: str,
133
+ rid: UUID,
134
+ item: AskRequest,
135
+ x_show_consumption: bool = Header(default=False),
136
+ x_nucliadb_account: str = Header(default="", include_in_schema=False),
137
+ x_ndb_client: NucliaDBClientType = Header(NucliaDBClientType.API),
138
+ x_nucliadb_user: str = Header(""),
139
+ x_forwarded_for: str = Header(""),
140
+ x_synchronous: bool = Header(
141
+ False,
142
+ description="When set to true, outputs response as JSON in a non-streaming way. "
143
+ "This is slower and requires waiting for entire answer to be ready.",
144
+ ),
145
+ ) -> StreamingResponse | HTTPClientError | Response:
146
+ current_user: NucliaUser = request.user
147
+ # If present, security groups from AuthorizationBackend overrides any
148
+ # security group of the payload
149
+ if current_user.security_groups:
150
+ if item.security is None:
151
+ item.security = RequestSecurity(groups=current_user.security_groups)
152
+ else:
153
+ item.security.groups = current_user.security_groups
154
+
155
+ if item.agentic_config_id is not None:
156
+ return await create_agentic_response(
157
+ app=request.app,
158
+ agentic_config_id=item.agentic_config_id,
159
+ kbid=kbid,
160
+ account=x_nucliadb_account,
161
+ ask_request=item,
162
+ user_id=x_nucliadb_user,
163
+ client_type=x_ndb_client,
164
+ origin=x_forwarded_for,
165
+ x_synchronous=x_synchronous,
166
+ extra_predict_headers={
167
+ "X-Show-Consumption": str(x_show_consumption).lower()
168
+ },
169
+ )
170
+
171
+ return await create_ask_response(
172
+ kbid=kbid,
173
+ ask_request=item,
174
+ user_id=x_nucliadb_user,
175
+ client_type=x_ndb_client,
176
+ origin=x_forwarded_for,
177
+ x_synchronous=x_synchronous,
178
+ resource=str(rid),
179
+ extra_predict_headers={"X-Show-Consumption": str(x_show_consumption).lower()},
180
+ )
181
+
182
+
183
+ @router.post(
184
+ "/api/v1/kb/{kbid}/slug/{slug}/ask",
185
+ status_code=200,
186
+ summary="Ask a resource (by slug)",
187
+ description="Ask questions to a resource",
188
+ tags=["Search"],
189
+ response_model=SyncAskResponse,
190
+ )
191
+ @requires(NucliaDBRoles.READER)
192
+ async def resource_ask_endpoint_by_slug(
193
+ request: Request,
194
+ kbid: str,
195
+ slug: str,
196
+ item: AskRequest,
197
+ x_show_consumption: bool = Header(default=False),
198
+ x_ndb_client: NucliaDBClientType = Header(NucliaDBClientType.API),
199
+ x_nucliadb_user: str = Header(""),
200
+ x_nucliadb_account: str = Header(default="", include_in_schema=False),
201
+ x_forwarded_for: str = Header(""),
202
+ x_synchronous: bool = Header(
203
+ False,
204
+ description="When set to true, outputs response as JSON in a non-streaming way. "
205
+ "This is slower and requires waiting for entire answer to be ready.",
206
+ ),
207
+ ) -> StreamingResponse | HTTPClientError | Response:
208
+ resource_id = await rpc.get_resource_uuid_from_slug(
209
+ rpc.get_sdk("reader"), kbid, slug
210
+ )
211
+ if resource_id is None:
212
+ return HTTPClientError(status_code=404, detail="Resource not found")
213
+
214
+ current_user: NucliaUser = request.user
215
+ # If present, security groups from AuthorizationBackend overrides any
216
+ # security group of the payload
217
+ if current_user.security_groups:
218
+ if item.security is None:
219
+ item.security = RequestSecurity(groups=current_user.security_groups)
220
+ else:
221
+ item.security.groups = current_user.security_groups
222
+
223
+ if item.agentic_config_id is not None:
224
+ return await create_agentic_response(
225
+ app=request.app,
226
+ agentic_config_id=item.agentic_config_id,
227
+ kbid=kbid,
228
+ account=x_nucliadb_account,
229
+ ask_request=item,
230
+ user_id=x_nucliadb_user,
231
+ client_type=x_ndb_client,
232
+ origin=x_forwarded_for,
233
+ x_synchronous=x_synchronous,
234
+ resource=str(resource_id),
235
+ extra_predict_headers={
236
+ "X-Show-Consumption": str(x_show_consumption).lower()
237
+ },
238
+ )
239
+
240
+ return await create_ask_response(
241
+ kbid=kbid,
242
+ ask_request=item,
243
+ user_id=x_nucliadb_user,
244
+ client_type=x_ndb_client,
245
+ origin=x_forwarded_for,
246
+ x_synchronous=x_synchronous,
247
+ resource=resource_id,
248
+ extra_predict_headers={"X-Show-Consumption": str(x_show_consumption).lower()},
249
+ )
250
+
251
+
252
+ @handled_ask_exceptions
253
+ async def create_ask_response(
254
+ kbid: str,
255
+ ask_request: AskRequest,
256
+ user_id: str,
257
+ client_type: NucliaDBClientType,
258
+ origin: str,
259
+ x_synchronous: bool,
260
+ resource: str | None = None,
261
+ extra_predict_headers: dict[str, str] | None = None,
262
+ ) -> Response:
263
+ ask_request.max_tokens = parse_max_tokens(ask_request.max_tokens)
264
+ try:
265
+ ask_result: AskResult = await ask(
266
+ search_sdk=rpc.get_sdk("search"),
267
+ reader_sdk=rpc.get_sdk("reader"),
268
+ kbid=kbid,
269
+ ask_request=ask_request,
270
+ user_id=user_id,
271
+ client_type=client_type,
272
+ origin=origin,
273
+ resource=resource,
274
+ extra_predict_headers=extra_predict_headers,
275
+ )
276
+
277
+ except AnswerJsonSchemaTooLong as err:
278
+ return HTTPClientError(status_code=400, detail=str(err))
279
+
280
+ # forward 412 and 422 from nucliadb to the client
281
+ except PreconditionFailed as err:
282
+ return HTTPClientError(status_code=412, detail=err.message)
283
+ except UnprocessableEntity as err:
284
+ return HTTPClientError(status_code=422, detail=err.message)
285
+
286
+ headers = {
287
+ "NUCLIA-LEARNING-ID": ask_result.nuclia_learning_id or "unknown",
288
+ "Access-Control-Expose-Headers": "NUCLIA-LEARNING-ID",
289
+ }
290
+ if x_synchronous:
291
+ return Response(
292
+ content=await ask_result.json(),
293
+ status_code=200,
294
+ headers=headers,
295
+ media_type="application/json",
296
+ )
297
+ else:
298
+ return StreamingResponse(
299
+ content=ask_result.ndjson_stream(),
300
+ status_code=200,
301
+ headers=headers,
302
+ media_type="application/x-ndjson",
303
+ )
@@ -0,0 +1,141 @@
1
+ import asyncio
2
+ import json
3
+
4
+ from fastapi import Header, Query, WebSocket, WebSocketDisconnect
5
+ from hyperforge.api.authentication import requires
6
+ from hyperforge.api.v1.interaction import WebsocketReceiver, stream_response
7
+ from hyperforge.interaction import AnswerOperation, AragAnswer, ARAGException
8
+ from hyperforge_nucliadb_agentic.ask.model import (
9
+ AskRequest,
10
+ )
11
+ from hyperforge_nucliadb_agentic.ask.search import rpc
12
+ from hyperforge_nucliadb_agentic.ask.utils.responses import (
13
+ HTTPClientError,
14
+ )
15
+ from nucliadb_models.configuration import AskConfig
16
+ from nucliadb_models.resource import NucliaDBRoles
17
+ from nucliadb_models.search import (
18
+ NucliaDBClientType,
19
+ )
20
+ from nucliadb_models.security import RequestSecurity
21
+ from nucliadb_utils.authentication import NucliaUser
22
+ from pydantic import ValidationError
23
+
24
+ from nucliadb_agentic_api.v1.router import router
25
+
26
+
27
+ @router.websocket("/api/v1/kb/{kbid}/ask")
28
+ @requires(NucliaDBRoles.READER)
29
+ async def websocket_endpoint(
30
+ websocket: WebSocket,
31
+ kbid: str,
32
+ agentic_config_id: str = Query(
33
+ ..., description="ID of the agentic configuration to use for this session"
34
+ ),
35
+ search_configuration: str | None = Query(
36
+ default=None,
37
+ description="Optional search configuration to use for this session",
38
+ ),
39
+ groups: list[str] = Query(
40
+ default=[],
41
+ description="List of group ids to do the request with. Overrides any security group from the Authorization header.",
42
+ ),
43
+ keep_open: bool = Query(
44
+ default=False,
45
+ description="Whether to keep the websocket open after the first question",
46
+ ),
47
+ x_stf_user: str = Header(..., include_in_schema=False),
48
+ x_stf_account: str = Header(..., include_in_schema=False),
49
+ x_stf_account_type: str = Header(..., include_in_schema=False),
50
+ x_nucliadb_account: str = Header(default="", include_in_schema=False),
51
+ x_ndb_client: NucliaDBClientType = Header(NucliaDBClientType.API),
52
+ x_show_consumption: bool = Header(default=False),
53
+ x_nucliadb_user: str = Header(""),
54
+ x_forwarded_for: str = Header(""),
55
+ ):
56
+ await websocket.accept()
57
+ receiver = WebsocketReceiver(websocket)
58
+ task = asyncio.create_task(receiver.run())
59
+
60
+ current_user: NucliaUser = websocket.user
61
+ # If present, security groups from AuthorizationBackend overrides any
62
+ # security group of the payload
63
+ if current_user.security_groups:
64
+ security = RequestSecurity(groups=current_user.security_groups)
65
+ else:
66
+ security = RequestSecurity(groups=groups)
67
+
68
+ item = AskRequest(query="")
69
+ item.security = security
70
+
71
+ if search_configuration is not None:
72
+ search_config = await rpc.get_search_configuration(
73
+ rpc.get_sdk("reader"), kbid, name=search_configuration
74
+ )
75
+ if search_config is None:
76
+ return HTTPClientError(
77
+ status_code=400, detail="Search configuration not found"
78
+ )
79
+
80
+ if not isinstance(search_config.config, AskConfig):
81
+ return HTTPClientError(
82
+ status_code=400,
83
+ detail="This search configuration is not valid for `ask`",
84
+ )
85
+
86
+ try:
87
+ item = AskRequest.model_validate(
88
+ search_config.config.model_dump(exclude_unset=True)
89
+ | item.model_dump(exclude_unset=True)
90
+ )
91
+ except ValidationError as e:
92
+ detail = json.loads(e.json())
93
+ return HTTPClientError(status_code=422, detail=detail)
94
+
95
+ # Wait for questions
96
+ first_question = True
97
+ while True:
98
+ if not keep_open and not first_question:
99
+ break
100
+ try:
101
+ interaction = await receiver.receive_question()
102
+ except WebSocketDisconnect:
103
+ break
104
+ except ValueError as e:
105
+ # Wrong message type received
106
+ await websocket.send_json(
107
+ AragAnswer(
108
+ exception=ARAGException(detail=f"Unexpected message: {str(e)}"),
109
+ operation=AnswerOperation.ERROR,
110
+ ).model_dump()
111
+ )
112
+ break
113
+
114
+ first_question = False
115
+ for header, header_value in websocket.headers.items():
116
+ interaction.headers[header] = header_value
117
+
118
+ item.query = interaction.question
119
+ interaction.arguments["ask_request"] = item.model_dump_json()
120
+
121
+ async for msg in stream_response(
122
+ websocket.app,
123
+ receiver,
124
+ x_stf_account,
125
+ kbid,
126
+ "ephemeral",
127
+ interaction,
128
+ workflow_id=agentic_config_id,
129
+ ):
130
+ try:
131
+ await websocket.send_text(msg.model_dump_json())
132
+ except (RuntimeError, WebSocketDisconnect):
133
+ # WebSocket already closed
134
+ pass
135
+
136
+ try:
137
+ task.cancel()
138
+ await websocket.close()
139
+ except RuntimeError:
140
+ # WebSocket already closed
141
+ pass