aidial-sdk 0.48.0.dev3__tar.gz → 0.48.0.dev5__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.
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/PKG-INFO +1 -1
- aidial_sdk-0.48.0.dev5/aidial_sdk/_errors.py +81 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/application.py +24 -26
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/exceptions.py +58 -16
- aidial_sdk-0.48.0.dev5/aidial_sdk/translators/messages_to_chat_completions/backend/http.py +161 -0
- aidial_sdk-0.48.0.dev5/aidial_sdk/translators/messages_to_chat_completions/chat_completions/_reasoning_content.py +95 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/translators/messages_to_chat_completions/chat_completions/app.py +14 -19
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/translators/messages_to_chat_completions/chat_completions/dial_extensions.py +0 -25
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/translators/messages_to_chat_completions/chat_completions/from_chat_completions.py +12 -17
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/translators/messages_to_chat_completions/chat_completions/reasoning.py +3 -7
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/translators/messages_to_chat_completions/chat_completions/streaming.py +12 -29
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/translators/messages_to_chat_completions/chat_completions/to_chat_completions.py +7 -16
- aidial_sdk-0.48.0.dev5/aidial_sdk/translators/messages_to_chat_completions/settings.py +18 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/translators/messages_to_chat_completions/sse_stream.py +17 -49
- aidial_sdk-0.48.0.dev5/aidial_sdk/utils/_logging_middleware.py +73 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/streaming.py +0 -2
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/pyproject.toml +1 -1
- aidial_sdk-0.48.0.dev3/aidial_sdk/_errors.py +0 -32
- aidial_sdk-0.48.0.dev3/aidial_sdk/translators/messages_to_chat_completions/backend/http.py +0 -92
- aidial_sdk-0.48.0.dev3/aidial_sdk/translators/messages_to_chat_completions/common.py +0 -61
- aidial_sdk-0.48.0.dev3/aidial_sdk/translators/messages_to_chat_completions/errors.py +0 -153
- aidial_sdk-0.48.0.dev3/aidial_sdk/translators/messages_to_chat_completions/settings.py +0 -21
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/LICENSE +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/README.md +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/__init__.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/_pydantic/__init__.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/_pydantic/_compat.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/_pydantic/_model_config.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/__init__.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/_cache.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/_types.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/base.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/choice.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/choice_base.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/chunks.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/enums.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/form.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/function_call.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/function_tool_call.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/request.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/response.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/stage.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/deployment/__init__.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/deployment/_headers.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/deployment/configuration.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/deployment/from_request_mixin.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/deployment/rate.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/deployment/tokenize.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/deployment/truncate_prompt.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/embeddings/__init__.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/embeddings/base.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/embeddings/request.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/embeddings/response.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/header_propagator.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/py.typed +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/pydantic/v2.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/pydantic_v1/__init__.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/response_headers.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/telemetry/__init__.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/telemetry/_context.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/telemetry/init.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/telemetry/types.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/translators/messages_to_chat_completions/__init__.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/translators/messages_to_chat_completions/chat_completions/cache_breakpoints.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/translators/messages_to_chat_completions/chat_completions/system_turns.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/translators/messages_to_chat_completions/translation_state.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/__init__.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/_attachment.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/_cancel_scope.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/_content_stream.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/_disconnect_middleware.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/_indexed_list.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/_interpolation.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/_json_log_formatter.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/_logging.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/_reflection.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/env.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/errors.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/json.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/log_config.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/logging.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/merge_chunks.py +0 -0
- {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/pydantic.py +0 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
|
|
3
|
+
from fastapi import HTTPException, Request
|
|
4
|
+
from fastapi.responses import JSONResponse, Response
|
|
5
|
+
from fastapi.routing import APIRoute
|
|
6
|
+
|
|
7
|
+
from aidial_sdk._pydantic import ValidationError
|
|
8
|
+
from aidial_sdk.exceptions import (
|
|
9
|
+
ExceptionType,
|
|
10
|
+
InternalServerError,
|
|
11
|
+
InvalidRequestError,
|
|
12
|
+
)
|
|
13
|
+
from aidial_sdk.exceptions import HTTPException as DIALException
|
|
14
|
+
from aidial_sdk.utils.logging import logger as log
|
|
15
|
+
|
|
16
|
+
INTERNAL_ERROR_MESSAGE = "Internal server error"
|
|
17
|
+
|
|
18
|
+
Handler = Callable[[Request], Awaitable[Response]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_exception_type(request: Request) -> ExceptionType:
|
|
22
|
+
route = request.scope.get("route")
|
|
23
|
+
if isinstance(route, APIRoute) and route.path.startswith("/anthropic/"):
|
|
24
|
+
return "anthropic"
|
|
25
|
+
return "openai"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def invalid_request_error(e: ValidationError) -> InvalidRequestError:
|
|
29
|
+
error = e.errors()[0]
|
|
30
|
+
path = ".".join(map(str, error["loc"]))
|
|
31
|
+
return InvalidRequestError(
|
|
32
|
+
f"Your request contained invalid structure on path {path}. {error['msg']}"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def to_dial_exception(e: Exception) -> DIALException:
|
|
37
|
+
match e:
|
|
38
|
+
case DIALException():
|
|
39
|
+
return e
|
|
40
|
+
case _:
|
|
41
|
+
log.exception("Unexpected error in the request handler")
|
|
42
|
+
return InternalServerError(INTERNAL_ERROR_MESSAGE)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _error_response(
|
|
46
|
+
request: Request, e: Exception, api: ExceptionType | None
|
|
47
|
+
) -> Response:
|
|
48
|
+
if isinstance(e, HTTPException):
|
|
49
|
+
return JSONResponse(
|
|
50
|
+
status_code=e.status_code,
|
|
51
|
+
content=e.detail,
|
|
52
|
+
headers=e.headers,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
api = api or _get_exception_type(request)
|
|
56
|
+
return to_dial_exception(e).to_fastapi_response(api)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def error_handler(api: ExceptionType | None) -> Callable[[Handler], Handler]:
|
|
60
|
+
def _decorator(handler: Handler) -> Handler:
|
|
61
|
+
async def _handler(request: Request) -> Response:
|
|
62
|
+
try:
|
|
63
|
+
return await handler(request)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
return _error_response(request, e, api)
|
|
66
|
+
|
|
67
|
+
return _handler
|
|
68
|
+
|
|
69
|
+
return _decorator
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def dial_exception_handler(request: Request, exc: Exception) -> Response:
|
|
73
|
+
return _error_response(request, exc, None)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def unhandled_exception_handler(request: Request, exc: Exception) -> Response:
|
|
77
|
+
# ServerErrorMiddleware re-raises the exception for the server to log it
|
|
78
|
+
error = InternalServerError(
|
|
79
|
+
f"Unhandled SDK exception: {INTERNAL_ERROR_MESSAGE}"
|
|
80
|
+
)
|
|
81
|
+
return _error_response(request, error, None)
|
|
@@ -10,9 +10,11 @@ from fastapi.responses import JSONResponse, Response, StreamingResponse
|
|
|
10
10
|
from starlette.types import Receive, Scope, Send
|
|
11
11
|
|
|
12
12
|
from aidial_sdk._errors import (
|
|
13
|
+
Handler,
|
|
13
14
|
dial_exception_handler,
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
error_handler,
|
|
16
|
+
invalid_request_error,
|
|
17
|
+
unhandled_exception_handler,
|
|
16
18
|
)
|
|
17
19
|
from aidial_sdk._pydantic import ValidationError
|
|
18
20
|
from aidial_sdk._pydantic._compat import BaseModel
|
|
@@ -41,11 +43,11 @@ from aidial_sdk.utils._interpolation import (
|
|
|
41
43
|
interpolate_deployment_id,
|
|
42
44
|
match_deployment_id,
|
|
43
45
|
)
|
|
46
|
+
from aidial_sdk.utils._logging_middleware import LoggingMiddleware
|
|
44
47
|
from aidial_sdk.utils._reflection import get_method_implementation
|
|
45
48
|
from aidial_sdk.utils.env import env_float, env_var_list
|
|
46
49
|
from aidial_sdk.utils.log_config import configure_sdk_logger
|
|
47
50
|
from aidial_sdk.utils.logging import (
|
|
48
|
-
log_debug,
|
|
49
51
|
reset_log_context,
|
|
50
52
|
set_log_deployment,
|
|
51
53
|
)
|
|
@@ -60,8 +62,6 @@ configure_sdk_logger()
|
|
|
60
62
|
|
|
61
63
|
RequestType = TypeVar("RequestType", bound=FromRequestMixin)
|
|
62
64
|
|
|
63
|
-
Handler = Callable[[Request], Coroutine[Any, Any, Response]]
|
|
64
|
-
|
|
65
65
|
|
|
66
66
|
class PathFilter(logging.Filter):
|
|
67
67
|
path: str
|
|
@@ -145,14 +145,12 @@ class DIALApp(FastAPI):
|
|
|
145
145
|
|
|
146
146
|
self.add_middleware(DisconnectMiddleware)
|
|
147
147
|
self.add_middleware(NonForwardableHeadersMiddleware)
|
|
148
|
+
self.add_middleware(LoggingMiddleware)
|
|
148
149
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
self.add_exception_handler(HTTPException, fastapi_exception_handler)
|
|
154
|
-
|
|
150
|
+
# The routes DIALApp adds have error_handler, these are for custom ones
|
|
151
|
+
self.add_exception_handler(HTTPException, dial_exception_handler)
|
|
155
152
|
self.add_exception_handler(DIALException, dial_exception_handler)
|
|
153
|
+
self.add_exception_handler(Exception, unhandled_exception_handler)
|
|
156
154
|
|
|
157
155
|
def configure_telemetry(self, config: TelemetryConfig | None):
|
|
158
156
|
if config is None or config.is_noop():
|
|
@@ -184,7 +182,7 @@ class DIALApp(FastAPI):
|
|
|
184
182
|
) -> "DIALApp":
|
|
185
183
|
self.add_api_route(
|
|
186
184
|
f"/openai/deployments/{deployment_name}/embeddings",
|
|
187
|
-
self._embeddings(deployment_name, impl),
|
|
185
|
+
error_handler("openai")(self._embeddings(deployment_name, impl)),
|
|
188
186
|
methods=["POST"],
|
|
189
187
|
)
|
|
190
188
|
|
|
@@ -193,6 +191,7 @@ class DIALApp(FastAPI):
|
|
|
193
191
|
"/openai/v1/embeddings",
|
|
194
192
|
self._embeddings(None, impl),
|
|
195
193
|
methods=["POST"],
|
|
194
|
+
handler_decorator=error_handler("openai"),
|
|
196
195
|
)
|
|
197
196
|
|
|
198
197
|
return self
|
|
@@ -209,7 +208,7 @@ class DIALApp(FastAPI):
|
|
|
209
208
|
):
|
|
210
209
|
self.add_api_route(
|
|
211
210
|
f"/openai/deployments/{deployment_name}/{endpoint}",
|
|
212
|
-
handler,
|
|
211
|
+
error_handler("openai")(handler),
|
|
213
212
|
methods=methods,
|
|
214
213
|
)
|
|
215
214
|
|
|
@@ -221,6 +220,7 @@ class DIALApp(FastAPI):
|
|
|
221
220
|
f"/openai/v1/{endpoint}",
|
|
222
221
|
handler,
|
|
223
222
|
methods=methods,
|
|
223
|
+
handler_decorator=error_handler("openai"),
|
|
224
224
|
)
|
|
225
225
|
|
|
226
226
|
return self
|
|
@@ -281,12 +281,13 @@ class DIALApp(FastAPI):
|
|
|
281
281
|
handler: Handler,
|
|
282
282
|
*,
|
|
283
283
|
methods: list[str],
|
|
284
|
+
handler_decorator: Callable[[Handler], Handler],
|
|
284
285
|
) -> None:
|
|
285
286
|
if endpoint not in self._generic_handlers:
|
|
286
287
|
self._generic_handlers[endpoint] = {}
|
|
287
288
|
self.add_api_route(
|
|
288
289
|
endpoint,
|
|
289
|
-
self._generic_dispatcher(endpoint),
|
|
290
|
+
handler_decorator(self._generic_dispatcher(endpoint)),
|
|
290
291
|
methods=methods,
|
|
291
292
|
)
|
|
292
293
|
self._generic_handlers[endpoint][deployment_name] = handler
|
|
@@ -337,7 +338,6 @@ class DIALApp(FastAPI):
|
|
|
337
338
|
request = await self._parse_request(
|
|
338
339
|
request_type, original_request, deployment_id
|
|
339
340
|
)
|
|
340
|
-
log_debug(f"request[{endpoint}]: {request}")
|
|
341
341
|
|
|
342
342
|
response = await endpoint_impl(request)
|
|
343
343
|
|
|
@@ -350,8 +350,6 @@ class DIALApp(FastAPI):
|
|
|
350
350
|
f"Unexpected response type from {endpoint}: {type(response)}"
|
|
351
351
|
)
|
|
352
352
|
|
|
353
|
-
log_debug(f"response[{endpoint}]: {response_json}")
|
|
354
|
-
|
|
355
353
|
return JSONResponse(content=response_json)
|
|
356
354
|
|
|
357
355
|
return _handler
|
|
@@ -379,11 +377,14 @@ class DIALApp(FastAPI):
|
|
|
379
377
|
)
|
|
380
378
|
set_log_deployment(deployment_id)
|
|
381
379
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
380
|
+
try:
|
|
381
|
+
ret = await request.from_request(
|
|
382
|
+
original_request, deployment_id, self._dial_url
|
|
383
|
+
)
|
|
384
|
+
if not self._allow_extra_request_fields:
|
|
385
|
+
model_validate_extra_fields(ret)
|
|
386
|
+
except ValidationError as e:
|
|
387
|
+
raise invalid_request_error(e) from e
|
|
387
388
|
return ret
|
|
388
389
|
|
|
389
390
|
def _chat_completion(
|
|
@@ -392,7 +393,7 @@ class DIALApp(FastAPI):
|
|
|
392
393
|
impl: ChatCompletion,
|
|
393
394
|
*,
|
|
394
395
|
heartbeat_interval: float | None,
|
|
395
|
-
):
|
|
396
|
+
) -> Handler:
|
|
396
397
|
heartbeat_interval = heartbeat_interval or env_float(
|
|
397
398
|
"DIAL_SDK_SSE_HEARTBEAT_INTERVAL"
|
|
398
399
|
)
|
|
@@ -403,7 +404,6 @@ class DIALApp(FastAPI):
|
|
|
403
404
|
)
|
|
404
405
|
|
|
405
406
|
response = ChatCompletionResponse(request)
|
|
406
|
-
|
|
407
407
|
stream = response._generate_stream(impl.chat_completion)
|
|
408
408
|
|
|
409
409
|
if request.stream:
|
|
@@ -411,7 +411,6 @@ class DIALApp(FastAPI):
|
|
|
411
411
|
stream = add_heartbeat(
|
|
412
412
|
stream,
|
|
413
413
|
heartbeat_interval=heartbeat_interval,
|
|
414
|
-
heartbeat_callback=lambda: log_debug("heartbeat"),
|
|
415
414
|
heartbeat_object=": heartbeat\n\n",
|
|
416
415
|
)
|
|
417
416
|
|
|
@@ -421,7 +420,6 @@ class DIALApp(FastAPI):
|
|
|
421
420
|
)
|
|
422
421
|
else:
|
|
423
422
|
response_json = await to_block_response(stream)
|
|
424
|
-
log_debug(f"response: {response_json}")
|
|
425
423
|
resp = JSONResponse(content=response_json)
|
|
426
424
|
|
|
427
425
|
for key, value in response.headers:
|
|
@@ -1,12 +1,38 @@
|
|
|
1
1
|
import functools
|
|
2
2
|
import warnings
|
|
3
|
+
from enum import Enum
|
|
3
4
|
from http import HTTPStatus
|
|
5
|
+
from typing import Literal, assert_never
|
|
4
6
|
|
|
5
7
|
from fastapi import HTTPException as FastAPIException
|
|
6
8
|
from fastapi.responses import JSONResponse
|
|
7
9
|
|
|
8
10
|
from aidial_sdk.utils.json import remove_nones
|
|
9
11
|
|
|
12
|
+
ExceptionType = Literal["openai", "anthropic"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _AnthropicErrorType(Enum):
|
|
16
|
+
INVALID_REQUEST = "invalid_request_error", 400, 422
|
|
17
|
+
AUTHENTICATION = "authentication_error", 401
|
|
18
|
+
PERMISSION = "permission_error", 403
|
|
19
|
+
NOT_FOUND = "not_found_error", 404
|
|
20
|
+
REQUEST_TOO_LARGE = "request_too_large", 413
|
|
21
|
+
RATE_LIMIT = "rate_limit_error", 429
|
|
22
|
+
API = "api_error", 500, 502
|
|
23
|
+
OVERLOADED = "overloaded_error", 529, 503
|
|
24
|
+
|
|
25
|
+
def __init__(self, code: str, *status_codes: int) -> None:
|
|
26
|
+
self.code = code
|
|
27
|
+
self.status_codes = status_codes
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_status_code(cls, status_code: int) -> "_AnthropicErrorType":
|
|
31
|
+
for error_type in cls:
|
|
32
|
+
if status_code in error_type.status_codes:
|
|
33
|
+
return error_type
|
|
34
|
+
return cls.API
|
|
35
|
+
|
|
10
36
|
|
|
11
37
|
class HTTPException(Exception):
|
|
12
38
|
def __init__(
|
|
@@ -47,31 +73,47 @@ class HTTPException(Exception):
|
|
|
47
73
|
args = ", ".join([f"{k}={v!r}" for k, v in error.items()])
|
|
48
74
|
return f"{self.__class__.__name__}({args})"
|
|
49
75
|
|
|
50
|
-
def json_error(self) -> dict:
|
|
51
|
-
|
|
52
|
-
"
|
|
53
|
-
{
|
|
54
|
-
"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
76
|
+
def json_error(self, api: ExceptionType = "openai") -> dict:
|
|
77
|
+
match api:
|
|
78
|
+
case "openai":
|
|
79
|
+
return {
|
|
80
|
+
"error": remove_nones(
|
|
81
|
+
{
|
|
82
|
+
"message": self.message,
|
|
83
|
+
"type": self.type,
|
|
84
|
+
"param": self.param,
|
|
85
|
+
"code": self.code,
|
|
86
|
+
"display_message": self.display_message,
|
|
87
|
+
**self.extra_fields,
|
|
88
|
+
}
|
|
89
|
+
)
|
|
60
90
|
}
|
|
61
|
-
|
|
62
|
-
|
|
91
|
+
case "anthropic":
|
|
92
|
+
error_type = _AnthropicErrorType.from_status_code(
|
|
93
|
+
self.status_code
|
|
94
|
+
)
|
|
95
|
+
return {
|
|
96
|
+
"type": "error",
|
|
97
|
+
"error": {"type": error_type.code, "message": self.message},
|
|
98
|
+
}
|
|
99
|
+
case _:
|
|
100
|
+
assert_never(api)
|
|
63
101
|
|
|
64
|
-
def to_fastapi_response(
|
|
102
|
+
def to_fastapi_response(
|
|
103
|
+
self, api: ExceptionType = "openai"
|
|
104
|
+
) -> JSONResponse:
|
|
65
105
|
return JSONResponse(
|
|
66
106
|
status_code=self.status_code,
|
|
67
|
-
content=self.json_error(),
|
|
107
|
+
content=self.json_error(api),
|
|
68
108
|
headers=self.headers,
|
|
69
109
|
)
|
|
70
110
|
|
|
71
|
-
def to_fastapi_exception(
|
|
111
|
+
def to_fastapi_exception(
|
|
112
|
+
self, api: ExceptionType = "openai"
|
|
113
|
+
) -> FastAPIException:
|
|
72
114
|
return FastAPIException(
|
|
73
115
|
status_code=self.status_code,
|
|
74
|
-
detail=self.json_error(),
|
|
116
|
+
detail=self.json_error(api),
|
|
75
117
|
headers=self.headers,
|
|
76
118
|
)
|
|
77
119
|
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from collections.abc import AsyncGenerator, Iterator
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import openai
|
|
8
|
+
from fastapi import Request
|
|
9
|
+
from openai import AsyncOpenAI, AsyncStream, Omit, omit
|
|
10
|
+
from openai.types.chat import ChatCompletion, ChatCompletionChunk
|
|
11
|
+
|
|
12
|
+
from aidial_sdk.exceptions import HTTPException as DIALException
|
|
13
|
+
from aidial_sdk.translators.messages_to_chat_completions.settings import (
|
|
14
|
+
get_dial_api_version,
|
|
15
|
+
)
|
|
16
|
+
from aidial_sdk.utils.logging import logger as log
|
|
17
|
+
|
|
18
|
+
# The headers of the inbound request that DIAL Core reads when the translator
|
|
19
|
+
# calls it back. Any other header is not forwarded.
|
|
20
|
+
_FORWARDED_HEADERS = {
|
|
21
|
+
# Core's per-request key
|
|
22
|
+
"api-key",
|
|
23
|
+
# Prompt caching policy of the request
|
|
24
|
+
"x-dial-cache-policy",
|
|
25
|
+
# Pins the request to the same upstream
|
|
26
|
+
"x-upstream-id",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _client(
|
|
31
|
+
base_url: str,
|
|
32
|
+
http_client: httpx.AsyncClient,
|
|
33
|
+
default_query: dict[str, str] | None = None,
|
|
34
|
+
) -> AsyncOpenAI:
|
|
35
|
+
return AsyncOpenAI(
|
|
36
|
+
# Required by the SDK; Core authenticates the forwarded `api-key` header.
|
|
37
|
+
api_key="-",
|
|
38
|
+
base_url=base_url,
|
|
39
|
+
default_query=default_query,
|
|
40
|
+
max_retries=0,
|
|
41
|
+
http_client=http_client,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def core_v1_client(
|
|
46
|
+
dial_url: str, http_client: httpx.AsyncClient
|
|
47
|
+
) -> AsyncOpenAI:
|
|
48
|
+
return _client(f"{dial_url}/openai/v1", http_client)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def core_legacy_client(
|
|
52
|
+
dial_url: str, deployment: str, http_client: httpx.AsyncClient
|
|
53
|
+
) -> AsyncOpenAI:
|
|
54
|
+
return _client(
|
|
55
|
+
f"{dial_url}/openai/deployments/{quote(deployment, safe='')}",
|
|
56
|
+
http_client,
|
|
57
|
+
{"api-version": get_dial_api_version()},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def core_headers(request: Request) -> dict[str, str | Omit]:
|
|
62
|
+
# Without the override the SDK sends `Authorization: Bearer -`
|
|
63
|
+
headers: dict[str, str | Omit] = {"Authorization": omit}
|
|
64
|
+
headers.update(
|
|
65
|
+
{
|
|
66
|
+
name: value
|
|
67
|
+
for name, value in request.headers.items()
|
|
68
|
+
if name.lower() in _FORWARDED_HEADERS
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
return headers
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# An error event in a stream has no status code, only a type or a code
|
|
75
|
+
_STREAM_ERROR_STATUS_CODES: dict[str | None, int] = {
|
|
76
|
+
"invalid_request_error": 400,
|
|
77
|
+
"rate_limit_exceeded": 429,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _extract_error_message(e: openai.APIStatusError) -> str:
|
|
82
|
+
body = e.body
|
|
83
|
+
if isinstance(body, dict):
|
|
84
|
+
error = body.get("error")
|
|
85
|
+
if isinstance(error, dict) and isinstance(error.get("message"), str):
|
|
86
|
+
return error["message"]
|
|
87
|
+
if isinstance(error, str) and error:
|
|
88
|
+
return error
|
|
89
|
+
if isinstance(body.get("message"), str):
|
|
90
|
+
return body["message"]
|
|
91
|
+
|
|
92
|
+
if isinstance(body, str) and body.strip():
|
|
93
|
+
return body.strip()
|
|
94
|
+
|
|
95
|
+
return e.message
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _from_core_error(e: openai.APIError) -> DIALException:
|
|
99
|
+
match e:
|
|
100
|
+
case openai.APIStatusError():
|
|
101
|
+
return DIALException(
|
|
102
|
+
_extract_error_message(e), status_code=e.status_code
|
|
103
|
+
)
|
|
104
|
+
case openai.APIConnectionError():
|
|
105
|
+
log.error(f"Failed to reach DIAL Core: {e}")
|
|
106
|
+
return DIALException("Failed to reach DIAL Core", status_code=502)
|
|
107
|
+
case _:
|
|
108
|
+
log.error(f"DIAL Core reported an error in the stream: {e}")
|
|
109
|
+
status_code = (
|
|
110
|
+
_STREAM_ERROR_STATUS_CODES.get(e.type)
|
|
111
|
+
or _STREAM_ERROR_STATUS_CODES.get(e.code)
|
|
112
|
+
or 500
|
|
113
|
+
)
|
|
114
|
+
return DIALException(e.message, status_code=status_code)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@contextmanager
|
|
118
|
+
def _core_errors() -> Iterator[None]:
|
|
119
|
+
try:
|
|
120
|
+
yield
|
|
121
|
+
except openai.APIError as e:
|
|
122
|
+
raise _from_core_error(e) from e
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def _chunks(
|
|
126
|
+
stream: AsyncStream[ChatCompletionChunk],
|
|
127
|
+
) -> AsyncGenerator[ChatCompletionChunk, None]:
|
|
128
|
+
with _core_errors():
|
|
129
|
+
async with stream:
|
|
130
|
+
async for item in stream:
|
|
131
|
+
yield item
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class ChatClientHttp:
|
|
136
|
+
client: AsyncOpenAI
|
|
137
|
+
core_headers: dict[str, str | Omit]
|
|
138
|
+
|
|
139
|
+
async def create_stream(
|
|
140
|
+
self, body: dict
|
|
141
|
+
) -> AsyncGenerator[ChatCompletionChunk, None]:
|
|
142
|
+
with _core_errors():
|
|
143
|
+
stream = await self.client.post(
|
|
144
|
+
"/chat/completions",
|
|
145
|
+
cast_to=ChatCompletion,
|
|
146
|
+
body=body,
|
|
147
|
+
options={"headers": self.core_headers},
|
|
148
|
+
stream=True,
|
|
149
|
+
stream_cls=AsyncStream[ChatCompletionChunk],
|
|
150
|
+
)
|
|
151
|
+
return _chunks(stream)
|
|
152
|
+
|
|
153
|
+
async def create_block(self, body: dict) -> ChatCompletion:
|
|
154
|
+
with _core_errors():
|
|
155
|
+
response = await self.client.post(
|
|
156
|
+
"/chat/completions",
|
|
157
|
+
cast_to=ChatCompletion,
|
|
158
|
+
body=body,
|
|
159
|
+
options={"headers": self.core_headers},
|
|
160
|
+
)
|
|
161
|
+
return response
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from openai.types.chat import ChatCompletionMessage
|
|
6
|
+
from openai.types.chat.chat_completion_chunk import ChoiceDelta
|
|
7
|
+
|
|
8
|
+
from aidial_sdk.translators.messages_to_chat_completions.chat_completions.dial_extensions import (
|
|
9
|
+
CustomContent,
|
|
10
|
+
parse_extras,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
_Source = Literal["azure", "claude", "dial"]
|
|
14
|
+
|
|
15
|
+
_REASONING_STAGE = re.compile(r"think|thought|reason", re.IGNORECASE)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ReasoningContent:
|
|
20
|
+
content: str | None
|
|
21
|
+
signature: str | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ReasoningContentProvider:
|
|
25
|
+
"""
|
|
26
|
+
Reads the reasoning of a message, or of a stream of message chunks, from
|
|
27
|
+
`reasoning_content`, the Claude state and the DIAL reasoning stages. They
|
|
28
|
+
carry the same text, chunked differently: only the text none of them has
|
|
29
|
+
delivered before is returned.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
# Every source is a prefix of the same text, so offsets suffice.
|
|
34
|
+
self._returned = 0
|
|
35
|
+
self._received: dict[_Source, int] = {
|
|
36
|
+
"azure": 0,
|
|
37
|
+
"claude": 0,
|
|
38
|
+
"dial": 0,
|
|
39
|
+
}
|
|
40
|
+
self._reasoning_stages: set[int | None] = set()
|
|
41
|
+
|
|
42
|
+
def get_reasoning_content(
|
|
43
|
+
self, message: ChatCompletionMessage | ChoiceDelta
|
|
44
|
+
) -> ReasoningContent:
|
|
45
|
+
custom_content = parse_extras(message.model_extra).custom_content
|
|
46
|
+
thinking, signature = _get_claude_reasoning_content(custom_content)
|
|
47
|
+
content = (
|
|
48
|
+
self._unseen("azure", _get_azure_reasoning_content(message))
|
|
49
|
+
+ self._unseen("claude", thinking)
|
|
50
|
+
+ self._unseen("dial", self._dial_reasoning_content(custom_content))
|
|
51
|
+
)
|
|
52
|
+
return ReasoningContent(
|
|
53
|
+
content=content or None,
|
|
54
|
+
signature=signature or None,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def _unseen(self, source: _Source, text: str | None) -> str:
|
|
58
|
+
text = text or ""
|
|
59
|
+
start = self._received[source]
|
|
60
|
+
self._received[source] += len(text)
|
|
61
|
+
unseen = text[self._returned - start :]
|
|
62
|
+
self._returned = max(self._returned, self._received[source])
|
|
63
|
+
return unseen
|
|
64
|
+
|
|
65
|
+
def _dial_reasoning_content(
|
|
66
|
+
self, custom_content: CustomContent | None
|
|
67
|
+
) -> str:
|
|
68
|
+
text = ""
|
|
69
|
+
for stage in (custom_content.stages if custom_content else None) or []:
|
|
70
|
+
# In a stream, only the first chunk of a stage carries its name.
|
|
71
|
+
if _is_reasoning_stage(stage.name):
|
|
72
|
+
self._reasoning_stages.add(stage.index)
|
|
73
|
+
if stage.index in self._reasoning_stages:
|
|
74
|
+
text += stage.content or ""
|
|
75
|
+
return text
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _get_azure_reasoning_content(
|
|
79
|
+
message: ChatCompletionMessage | ChoiceDelta,
|
|
80
|
+
) -> str | None:
|
|
81
|
+
return (message.model_extra or {}).get("reasoning_content")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _get_claude_reasoning_content(
|
|
85
|
+
custom_content: CustomContent | None,
|
|
86
|
+
) -> tuple[str | None, str | None]:
|
|
87
|
+
state = custom_content.state if custom_content else None
|
|
88
|
+
for block in (state.claude_message_content if state else None) or []:
|
|
89
|
+
if block.type == "thinking":
|
|
90
|
+
return block.thinking, block.signature
|
|
91
|
+
return None, None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _is_reasoning_stage(name: str | None) -> bool:
|
|
95
|
+
return name is not None and bool(_REASONING_STAGE.search(name))
|