aidial-sdk 0.48.0.dev2__tar.gz → 0.48.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 (82) hide show
  1. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/PKG-INFO +1 -1
  2. aidial_sdk-0.48.0.dev4/aidial_sdk/_errors.py +81 -0
  3. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/application.py +24 -26
  4. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/chat_completion/response.py +3 -1
  5. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/deployment/from_request_mixin.py +12 -2
  6. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/exceptions.py +58 -16
  7. aidial_sdk-0.48.0.dev4/aidial_sdk/translators/messages_to_chat_completions/backend/http.py +161 -0
  8. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/translators/messages_to_chat_completions/chat_completions/app.py +16 -21
  9. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/translators/messages_to_chat_completions/chat_completions/reasoning.py +3 -7
  10. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/translators/messages_to_chat_completions/chat_completions/streaming.py +2 -4
  11. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/translators/messages_to_chat_completions/chat_completions/to_chat_completions.py +7 -16
  12. aidial_sdk-0.48.0.dev4/aidial_sdk/translators/messages_to_chat_completions/settings.py +18 -0
  13. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/translators/messages_to_chat_completions/sse_stream.py +17 -49
  14. aidial_sdk-0.48.0.dev4/aidial_sdk/utils/_logging_middleware.py +73 -0
  15. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/utils/streaming.py +0 -2
  16. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/pyproject.toml +1 -1
  17. aidial_sdk-0.48.0.dev2/aidial_sdk/_errors.py +0 -32
  18. aidial_sdk-0.48.0.dev2/aidial_sdk/translators/messages_to_chat_completions/backend/http.py +0 -92
  19. aidial_sdk-0.48.0.dev2/aidial_sdk/translators/messages_to_chat_completions/common.py +0 -61
  20. aidial_sdk-0.48.0.dev2/aidial_sdk/translators/messages_to_chat_completions/errors.py +0 -153
  21. aidial_sdk-0.48.0.dev2/aidial_sdk/translators/messages_to_chat_completions/settings.py +0 -21
  22. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/LICENSE +0 -0
  23. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/README.md +0 -0
  24. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/__init__.py +0 -0
  25. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/_pydantic/__init__.py +0 -0
  26. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/_pydantic/_compat.py +0 -0
  27. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/_pydantic/_model_config.py +0 -0
  28. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/chat_completion/__init__.py +0 -0
  29. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/chat_completion/_cache.py +0 -0
  30. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/chat_completion/_types.py +0 -0
  31. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/chat_completion/base.py +0 -0
  32. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/chat_completion/choice.py +0 -0
  33. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/chat_completion/choice_base.py +0 -0
  34. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/chat_completion/chunks.py +0 -0
  35. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/chat_completion/enums.py +0 -0
  36. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/chat_completion/form.py +0 -0
  37. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/chat_completion/function_call.py +0 -0
  38. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/chat_completion/function_tool_call.py +0 -0
  39. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/chat_completion/request.py +0 -0
  40. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/chat_completion/stage.py +0 -0
  41. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/deployment/__init__.py +0 -0
  42. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/deployment/_headers.py +0 -0
  43. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/deployment/configuration.py +0 -0
  44. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/deployment/rate.py +0 -0
  45. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/deployment/tokenize.py +0 -0
  46. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/deployment/truncate_prompt.py +0 -0
  47. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/embeddings/__init__.py +0 -0
  48. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/embeddings/base.py +0 -0
  49. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/embeddings/request.py +0 -0
  50. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/embeddings/response.py +0 -0
  51. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/header_propagator.py +0 -0
  52. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/py.typed +0 -0
  53. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/pydantic/v2.py +0 -0
  54. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/pydantic_v1/__init__.py +0 -0
  55. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/response_headers.py +0 -0
  56. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/telemetry/__init__.py +0 -0
  57. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/telemetry/_context.py +0 -0
  58. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/telemetry/init.py +0 -0
  59. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/telemetry/types.py +0 -0
  60. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/translators/messages_to_chat_completions/__init__.py +0 -0
  61. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/translators/messages_to_chat_completions/chat_completions/cache_breakpoints.py +0 -0
  62. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/translators/messages_to_chat_completions/chat_completions/dial_extensions.py +0 -0
  63. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/translators/messages_to_chat_completions/chat_completions/from_chat_completions.py +0 -0
  64. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/translators/messages_to_chat_completions/chat_completions/system_turns.py +0 -0
  65. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/translators/messages_to_chat_completions/translation_state.py +0 -0
  66. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/utils/__init__.py +0 -0
  67. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/utils/_attachment.py +0 -0
  68. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/utils/_cancel_scope.py +0 -0
  69. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/utils/_content_stream.py +0 -0
  70. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/utils/_disconnect_middleware.py +0 -0
  71. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/utils/_indexed_list.py +0 -0
  72. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/utils/_interpolation.py +0 -0
  73. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/utils/_json_log_formatter.py +0 -0
  74. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/utils/_logging.py +0 -0
  75. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/utils/_reflection.py +0 -0
  76. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/utils/env.py +0 -0
  77. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/utils/errors.py +0 -0
  78. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/utils/json.py +0 -0
  79. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/utils/log_config.py +0 -0
  80. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/utils/logging.py +0 -0
  81. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/utils/merge_chunks.py +0 -0
  82. {aidial_sdk-0.48.0.dev2 → aidial_sdk-0.48.0.dev4}/aidial_sdk/utils/pydantic.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aidial-sdk
3
- Version: 0.48.0.dev2
3
+ Version: 0.48.0.dev4
4
4
  Summary: Framework to create applications and model adapters for AI DIAL
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -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
- fastapi_exception_handler,
15
- pydantic_validation_exception_handler,
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
- self.add_exception_handler(
150
- ValidationError, pydantic_validation_exception_handler
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
- ret = await request.from_request(
383
- original_request, deployment_id, self._dial_url
384
- )
385
- if not self._allow_extra_request_fields:
386
- model_validate_extra_fields(ret)
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:
@@ -70,7 +70,9 @@ class Response:
70
70
  if self.request.stream
71
71
  else "chat.completion"
72
72
  ),
73
- model=get_client_deployment_id(request.headers),
73
+ model=get_client_deployment_id(
74
+ request.headers, request.deployment_id
75
+ ),
74
76
  )
75
77
 
76
78
  self._headers = []
@@ -44,14 +44,24 @@ def get_upstream_deployment_id(
44
44
  return (
45
45
  headers.get(DIAL_OVERRIDE_NAME)
46
46
  or deployment_id
47
- or get_client_deployment_id(headers)
47
+ or _get_dial_deployment_id(headers)
48
48
  )
49
49
 
50
50
 
51
- def get_client_deployment_id(headers: Mapping[str, str]) -> str:
51
+ def get_client_deployment_id(
52
+ headers: Mapping[str, str], deployment_id: str | None
53
+ ) -> str:
52
54
  """The client-facing deployment id that a DIAL application is expected
53
55
  to return as a model parameter in the response."""
54
56
 
57
+ # NOTE: Once https://github.com/epam/ai-dial-core/issues/2243 is fixed,
58
+ # this could be replaced with just _get_dial_deployment_id.
59
+ return headers.get(DIAL_DEPLOYMENT_ID) or get_upstream_deployment_id(
60
+ headers, deployment_id
61
+ )
62
+
63
+
64
+ def _get_dial_deployment_id(headers: Mapping[str, str]) -> str:
55
65
  if (deployment_id := headers.get(DIAL_DEPLOYMENT_ID)) is None:
56
66
  raise InternalServerError(
57
67
  f"The request headers are missing {DIAL_DEPLOYMENT_ID} header."
@@ -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
- return {
52
- "error": remove_nones(
53
- {
54
- "message": self.message,
55
- "type": self.type,
56
- "param": self.param,
57
- "code": self.code,
58
- "display_message": self.display_message,
59
- **self.extra_fields,
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(self) -> JSONResponse:
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(self) -> FastAPIException:
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
@@ -8,7 +8,9 @@ from anthropic.types.beta.message_create_params import MessageCreateParams
8
8
  from fastapi import FastAPI, Request
9
9
  from fastapi.responses import JSONResponse, Response, StreamingResponse
10
10
 
11
+ from aidial_sdk._errors import error_handler
11
12
  from aidial_sdk.deployment.from_request_mixin import get_client_deployment_id
13
+ from aidial_sdk.exceptions import InvalidRequestError, ResourceNotFoundError
12
14
  from aidial_sdk.translators.messages_to_chat_completions.backend.http import (
13
15
  ChatClientHttp,
14
16
  core_headers,
@@ -24,39 +26,33 @@ from aidial_sdk.translators.messages_to_chat_completions.chat_completions.stream
24
26
  from aidial_sdk.translators.messages_to_chat_completions.chat_completions.to_chat_completions import (
25
27
  to_chat_completions_request,
26
28
  )
27
- from aidial_sdk.translators.messages_to_chat_completions.common import (
28
- not_found,
29
- with_debug_logging,
30
- )
31
- from aidial_sdk.translators.messages_to_chat_completions.errors import (
32
- AnthropicErrorType,
33
- AnthropicHTTPError,
34
- translator_error_handler,
35
- )
36
29
  from aidial_sdk.translators.messages_to_chat_completions.settings import (
37
30
  get_dial_url,
38
31
  )
39
32
  from aidial_sdk.translators.messages_to_chat_completions.translation_state import (
40
33
  TranslationState,
41
34
  )
35
+ from aidial_sdk.utils._logging_middleware import LoggingMiddleware
42
36
  from aidial_sdk.utils.logging import logger as log
43
37
 
44
38
  ClientT = TypeVar("ClientT")
45
39
  ClientFactory = Callable[[Request], Awaitable[ClientT]] | ClientT
46
40
 
47
41
 
42
+ async def _not_found(request: Request) -> Response:
43
+ error = ResourceNotFoundError(f"Unknown endpoint: {request.url.path}")
44
+ return error.to_fastapi_response("anthropic")
45
+
46
+
48
47
  async def handle_anthropic_messages(
49
48
  request: Request, client: ChatClientHttp
50
49
  ) -> Response:
51
- deployment = get_client_deployment_id(request.headers)
50
+ deployment = get_client_deployment_id(request.headers, None)
52
51
 
53
52
  try:
54
53
  req: MessageCreateParams = await request.json()
55
54
  except ValueError as e:
56
- raise AnthropicHTTPError(
57
- AnthropicErrorType.INVALID_REQUEST,
58
- f"Request body is not valid JSON: {e}",
59
- ) from e
55
+ raise InvalidRequestError(f"Request body is not valid JSON: {e}") from e
60
56
 
61
57
  translation_state = TranslationState()
62
58
  chat_request = to_chat_completions_request(
@@ -65,7 +61,7 @@ async def handle_anthropic_messages(
65
61
 
66
62
  body = chat_request.model_dump(mode="json", exclude_none=True)
67
63
  if log.isEnabledFor(logging.DEBUG):
68
- log.debug(f"Translated request to Core: {json.dumps(body)}")
64
+ log.debug(f"chat request: {json.dumps(body)}")
69
65
 
70
66
  if chat_request.stream:
71
67
  events = await client.create_stream(body)
@@ -85,6 +81,7 @@ def create_anthropic_api_app(
85
81
  get_client: ClientFactory[httpx.AsyncClient],
86
82
  ) -> FastAPI:
87
83
  app = FastAPI()
84
+ app.add_middleware(LoggingMiddleware)
88
85
 
89
86
  async def _chat_client(request: Request, *, legacy: bool) -> ChatClientHttp:
90
87
  http_client = (
@@ -92,7 +89,7 @@ def create_anthropic_api_app(
92
89
  )
93
90
  dial_url = get_dial_url()
94
91
  if legacy:
95
- deployment_id = get_client_deployment_id(request.headers)
92
+ deployment_id = get_client_deployment_id(request.headers, None)
96
93
  client = core_legacy_client(dial_url, deployment_id, http_client)
97
94
  else:
98
95
  client = core_v1_client(dial_url, http_client)
@@ -100,16 +97,14 @@ def create_anthropic_api_app(
100
97
  return ChatClientHttp(client, core_headers(request))
101
98
 
102
99
  @app.post("/anthropic/v1/messages")
103
- @with_debug_logging
104
- @translator_error_handler
100
+ @error_handler("anthropic")
105
101
  async def _handle_v1_messages(request: Request) -> Response:
106
102
  return await handle_anthropic_messages(
107
103
  request, await _chat_client(request, legacy=False)
108
104
  )
109
105
 
110
106
  @app.post("/legacy/anthropic/v1/messages")
111
- @with_debug_logging
112
- @translator_error_handler
107
+ @error_handler("anthropic")
113
108
  async def _handle_legacy_messages(request: Request) -> Response:
114
109
  return await handle_anthropic_messages(
115
110
  request, await _chat_client(request, legacy=True)
@@ -117,7 +112,7 @@ def create_anthropic_api_app(
117
112
 
118
113
  app.add_api_route(
119
114
  "/{full_path:path}",
120
- not_found,
115
+ _not_found,
121
116
  methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
122
117
  )
123
118