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.
Files changed (83) hide show
  1. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/PKG-INFO +1 -1
  2. aidial_sdk-0.48.0.dev5/aidial_sdk/_errors.py +81 -0
  3. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/application.py +24 -26
  4. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/exceptions.py +58 -16
  5. aidial_sdk-0.48.0.dev5/aidial_sdk/translators/messages_to_chat_completions/backend/http.py +161 -0
  6. aidial_sdk-0.48.0.dev5/aidial_sdk/translators/messages_to_chat_completions/chat_completions/_reasoning_content.py +95 -0
  7. {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
  8. {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
  9. {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
  10. {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
  11. {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
  12. {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
  13. aidial_sdk-0.48.0.dev5/aidial_sdk/translators/messages_to_chat_completions/settings.py +18 -0
  14. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/translators/messages_to_chat_completions/sse_stream.py +17 -49
  15. aidial_sdk-0.48.0.dev5/aidial_sdk/utils/_logging_middleware.py +73 -0
  16. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/streaming.py +0 -2
  17. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/pyproject.toml +1 -1
  18. aidial_sdk-0.48.0.dev3/aidial_sdk/_errors.py +0 -32
  19. aidial_sdk-0.48.0.dev3/aidial_sdk/translators/messages_to_chat_completions/backend/http.py +0 -92
  20. aidial_sdk-0.48.0.dev3/aidial_sdk/translators/messages_to_chat_completions/common.py +0 -61
  21. aidial_sdk-0.48.0.dev3/aidial_sdk/translators/messages_to_chat_completions/errors.py +0 -153
  22. aidial_sdk-0.48.0.dev3/aidial_sdk/translators/messages_to_chat_completions/settings.py +0 -21
  23. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/LICENSE +0 -0
  24. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/README.md +0 -0
  25. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/__init__.py +0 -0
  26. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/_pydantic/__init__.py +0 -0
  27. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/_pydantic/_compat.py +0 -0
  28. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/_pydantic/_model_config.py +0 -0
  29. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/__init__.py +0 -0
  30. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/_cache.py +0 -0
  31. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/_types.py +0 -0
  32. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/base.py +0 -0
  33. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/choice.py +0 -0
  34. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/choice_base.py +0 -0
  35. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/chunks.py +0 -0
  36. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/enums.py +0 -0
  37. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/form.py +0 -0
  38. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/function_call.py +0 -0
  39. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/function_tool_call.py +0 -0
  40. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/request.py +0 -0
  41. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/response.py +0 -0
  42. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/chat_completion/stage.py +0 -0
  43. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/deployment/__init__.py +0 -0
  44. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/deployment/_headers.py +0 -0
  45. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/deployment/configuration.py +0 -0
  46. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/deployment/from_request_mixin.py +0 -0
  47. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/deployment/rate.py +0 -0
  48. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/deployment/tokenize.py +0 -0
  49. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/deployment/truncate_prompt.py +0 -0
  50. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/embeddings/__init__.py +0 -0
  51. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/embeddings/base.py +0 -0
  52. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/embeddings/request.py +0 -0
  53. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/embeddings/response.py +0 -0
  54. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/header_propagator.py +0 -0
  55. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/py.typed +0 -0
  56. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/pydantic/v2.py +0 -0
  57. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/pydantic_v1/__init__.py +0 -0
  58. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/response_headers.py +0 -0
  59. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/telemetry/__init__.py +0 -0
  60. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/telemetry/_context.py +0 -0
  61. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/telemetry/init.py +0 -0
  62. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/telemetry/types.py +0 -0
  63. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/translators/messages_to_chat_completions/__init__.py +0 -0
  64. {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
  65. {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
  66. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/translators/messages_to_chat_completions/translation_state.py +0 -0
  67. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/__init__.py +0 -0
  68. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/_attachment.py +0 -0
  69. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/_cancel_scope.py +0 -0
  70. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/_content_stream.py +0 -0
  71. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/_disconnect_middleware.py +0 -0
  72. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/_indexed_list.py +0 -0
  73. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/_interpolation.py +0 -0
  74. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/_json_log_formatter.py +0 -0
  75. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/_logging.py +0 -0
  76. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/_reflection.py +0 -0
  77. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/env.py +0 -0
  78. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/errors.py +0 -0
  79. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/json.py +0 -0
  80. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/log_config.py +0 -0
  81. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/logging.py +0 -0
  82. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/aidial_sdk/utils/merge_chunks.py +0 -0
  83. {aidial_sdk-0.48.0.dev3 → aidial_sdk-0.48.0.dev5}/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.dev3
3
+ Version: 0.48.0.dev5
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:
@@ -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
@@ -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))