aidial-sdk 0.47.0.dev5__tar.gz → 0.47.0.dev6__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 (77) hide show
  1. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/PKG-INFO +72 -1
  2. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/README.md +67 -0
  3. aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/__init__.py +28 -0
  4. aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/chat_completions/app.py +134 -0
  5. aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/chat_completions/cache_breakpoints.py +48 -0
  6. aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/chat_completions/dial_extensions.py +66 -0
  7. aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/chat_completions/from_chat_completions.py +237 -0
  8. aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/chat_completions/reasoning.py +48 -0
  9. aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/chat_completions/streaming.py +245 -0
  10. aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/chat_completions/system_turns.py +26 -0
  11. aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/chat_completions/to_chat_completions.py +695 -0
  12. aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/common.py +61 -0
  13. aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/core_client.py +64 -0
  14. aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/errors.py +153 -0
  15. aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/settings.py +21 -0
  16. aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/sse_stream.py +209 -0
  17. aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/translation_state.py +39 -0
  18. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/pyproject.toml +6 -1
  19. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/LICENSE +0 -0
  20. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/__init__.py +0 -0
  21. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/_errors.py +0 -0
  22. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/_pydantic/__init__.py +0 -0
  23. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/_pydantic/_compat.py +0 -0
  24. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/_pydantic/_model_config.py +0 -0
  25. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/application.py +0 -0
  26. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/__init__.py +0 -0
  27. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/_cache.py +0 -0
  28. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/_types.py +0 -0
  29. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/base.py +0 -0
  30. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/choice.py +0 -0
  31. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/choice_base.py +0 -0
  32. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/chunks.py +0 -0
  33. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/enums.py +0 -0
  34. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/form.py +0 -0
  35. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/function_call.py +0 -0
  36. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/function_tool_call.py +0 -0
  37. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/request.py +0 -0
  38. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/response.py +0 -0
  39. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/stage.py +0 -0
  40. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/deployment/__init__.py +0 -0
  41. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/deployment/_headers.py +0 -0
  42. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/deployment/configuration.py +0 -0
  43. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/deployment/from_request_mixin.py +0 -0
  44. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/deployment/rate.py +0 -0
  45. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/deployment/tokenize.py +0 -0
  46. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/deployment/truncate_prompt.py +0 -0
  47. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/embeddings/__init__.py +0 -0
  48. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/embeddings/base.py +0 -0
  49. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/embeddings/request.py +0 -0
  50. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/embeddings/response.py +0 -0
  51. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/exceptions.py +0 -0
  52. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/header_propagator.py +0 -0
  53. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/py.typed +0 -0
  54. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/pydantic/v2.py +0 -0
  55. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/pydantic_v1/__init__.py +0 -0
  56. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/response_headers.py +0 -0
  57. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/telemetry/__init__.py +0 -0
  58. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/telemetry/_context.py +0 -0
  59. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/telemetry/init.py +0 -0
  60. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/telemetry/types.py +0 -0
  61. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/__init__.py +0 -0
  62. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/_attachment.py +0 -0
  63. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/_cancel_scope.py +0 -0
  64. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/_content_stream.py +0 -0
  65. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/_disconnect_middleware.py +0 -0
  66. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/_indexed_list.py +0 -0
  67. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/_json_log_formatter.py +0 -0
  68. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/_logging.py +0 -0
  69. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/_reflection.py +0 -0
  70. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/env.py +0 -0
  71. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/errors.py +0 -0
  72. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/json.py +0 -0
  73. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/log_config.py +0 -0
  74. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/logging.py +0 -0
  75. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/merge_chunks.py +0 -0
  76. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/pydantic.py +0 -0
  77. {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/streaming.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aidial-sdk
3
- Version: 0.47.0.dev5
3
+ Version: 0.47.0.dev6
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
@@ -14,8 +14,12 @@ Classifier: Programming Language :: Python :: 3.13
14
14
  Classifier: Programming Language :: Python :: 3.14
15
15
  Provides-Extra: httpx
16
16
  Provides-Extra: telemetry
17
+ Provides-Extra: translators
18
+ Requires-Dist: anthropic (>=0.105.0,<1.0) ; extra == "translators"
17
19
  Requires-Dist: fastapi (>=0.51,<1.0)
18
20
  Requires-Dist: httpx (>=0.25.0,<1.0) ; extra == "httpx"
21
+ Requires-Dist: httpx (>=0.25.0,<1.0) ; extra == "translators"
22
+ Requires-Dist: openai (>=2.46.0,<3.0) ; extra == "translators"
19
23
  Requires-Dist: opentelemetry-api (>=1.22.0,<2.0) ; extra == "telemetry"
20
24
  Requires-Dist: opentelemetry-exporter-otlp-proto-grpc (>=1.22.0,<2.0) ; extra == "telemetry"
21
25
  Requires-Dist: opentelemetry-exporter-prometheus (>=0.64b0) ; extra == "telemetry"
@@ -61,6 +65,7 @@ Description-Content-Type: text/markdown
61
65
  - [Run](#run)
62
66
  - [Check](#check)
63
67
  - [Endpoints](#endpoints)
68
+ - [Anthropic Messages to Chat Completions translator](#anthropic-messages-to-chat-completions-translator)
64
69
  - [Development](#development)
65
70
  - [Development Environment](#development-environment)
66
71
  - [Setup](#setup)
@@ -205,6 +210,72 @@ An `/openai/v1` request with none of the headers set fails with `500 Internal Se
205
210
 
206
211
  ---
207
212
 
213
+ ### Anthropic Messages to Chat Completions translator
214
+
215
+ The translator lets an Anthropic-native client (e.g. Claude Code) talk to a model that only speaks the [Chat Completions API](https://platform.openai.com/docs/api-reference/chat). It converts an Anthropic Messages request into a Chat Completions request to DIAL Core at `DIAL_URL`, and converts the response, streaming or not, back into the Anthropic Messages shape.
216
+
217
+ The translator requires the `translators` extra and Pydantic V2 models (`PYDANTIC_V2=True`):
218
+
219
+ ```sh
220
+ pip install "aidial-sdk[translators]"
221
+ ```
222
+
223
+ Mount it on your application:
224
+
225
+ ```python
226
+ import httpx
227
+
228
+ from aidial_sdk import DIALApp
229
+ from aidial_sdk.translators.messages_to_chat_completions import mount_translator
230
+
231
+ app = DIALApp()
232
+ mount_translator(app, httpx.AsyncClient())
233
+ ```
234
+
235
+ The second argument is the HTTP client the translator calls DIAL Core with: either an `httpx.AsyncClient` or an async function that takes the incoming `Request` and returns one. The `path` (`/openaiChatCompletions` by default) and `name` keyword arguments configure the mount.
236
+
237
+ |Endpoint|Calls on DIAL Core|
238
+ |---|---|
239
+ |`POST /openaiChatCompletions/anthropic/v1/messages`|`POST {DIAL_URL}/openai/v1/chat/completions`|
240
+ |`POST /openaiChatCompletions/legacy/anthropic/v1/messages`|`POST {DIAL_URL}/openai/deployments/{deployment}/chat/completions`|
241
+
242
+ `DIAL_URL` must be set, otherwise the endpoints answer `500`.
243
+
244
+ To expose a Chat Completions model under `/anthropic/v1/messages`, add an `anthropicMessages` interface to its DIAL Core config, pointing `base_url` at the mount path of the application that runs the translator (or at `/openaiChatCompletions/legacy` to call DIAL Core's legacy per-deployment endpoint):
245
+
246
+ <details>
247
+ <summary>DIAL Core config example</summary>
248
+
249
+ ```json
250
+ {
251
+ "models": {
252
+ "gpt-5.5": {
253
+ "type": "chat",
254
+ "interfaces": {
255
+ "openaiChatCompletions": {
256
+ "base_url": "${OPENAI_ADAPTER_ORIGIN}"
257
+ },
258
+ "anthropicMessages": {
259
+ "base_url": "${TRANSLATOR_ORIGIN}/openaiChatCompletions"
260
+ }
261
+ },
262
+ "upstreams": [
263
+ {
264
+ "endpoint": "https://api.openai.com/v1/chat/completions",
265
+ "key": "..."
266
+ }
267
+ ]
268
+ }
269
+ }
270
+ }
271
+ ```
272
+
273
+ </details>
274
+
275
+ See the [translation guide](docs/messages-to-chat-translation.md) for how request parameters, content blocks, tools, caching, usage, and errors are translated, and for the translator's limitations.
276
+
277
+ ---
278
+
208
279
  ## Development
209
280
 
210
281
  ### Development Environment
@@ -23,6 +23,7 @@
23
23
  - [Run](#run)
24
24
  - [Check](#check)
25
25
  - [Endpoints](#endpoints)
26
+ - [Anthropic Messages to Chat Completions translator](#anthropic-messages-to-chat-completions-translator)
26
27
  - [Development](#development)
27
28
  - [Development Environment](#development-environment)
28
29
  - [Setup](#setup)
@@ -167,6 +168,72 @@ An `/openai/v1` request with none of the headers set fails with `500 Internal Se
167
168
 
168
169
  ---
169
170
 
171
+ ### Anthropic Messages to Chat Completions translator
172
+
173
+ The translator lets an Anthropic-native client (e.g. Claude Code) talk to a model that only speaks the [Chat Completions API](https://platform.openai.com/docs/api-reference/chat). It converts an Anthropic Messages request into a Chat Completions request to DIAL Core at `DIAL_URL`, and converts the response, streaming or not, back into the Anthropic Messages shape.
174
+
175
+ The translator requires the `translators` extra and Pydantic V2 models (`PYDANTIC_V2=True`):
176
+
177
+ ```sh
178
+ pip install "aidial-sdk[translators]"
179
+ ```
180
+
181
+ Mount it on your application:
182
+
183
+ ```python
184
+ import httpx
185
+
186
+ from aidial_sdk import DIALApp
187
+ from aidial_sdk.translators.messages_to_chat_completions import mount_translator
188
+
189
+ app = DIALApp()
190
+ mount_translator(app, httpx.AsyncClient())
191
+ ```
192
+
193
+ The second argument is the HTTP client the translator calls DIAL Core with: either an `httpx.AsyncClient` or an async function that takes the incoming `Request` and returns one. The `path` (`/openaiChatCompletions` by default) and `name` keyword arguments configure the mount.
194
+
195
+ |Endpoint|Calls on DIAL Core|
196
+ |---|---|
197
+ |`POST /openaiChatCompletions/anthropic/v1/messages`|`POST {DIAL_URL}/openai/v1/chat/completions`|
198
+ |`POST /openaiChatCompletions/legacy/anthropic/v1/messages`|`POST {DIAL_URL}/openai/deployments/{deployment}/chat/completions`|
199
+
200
+ `DIAL_URL` must be set, otherwise the endpoints answer `500`.
201
+
202
+ To expose a Chat Completions model under `/anthropic/v1/messages`, add an `anthropicMessages` interface to its DIAL Core config, pointing `base_url` at the mount path of the application that runs the translator (or at `/openaiChatCompletions/legacy` to call DIAL Core's legacy per-deployment endpoint):
203
+
204
+ <details>
205
+ <summary>DIAL Core config example</summary>
206
+
207
+ ```json
208
+ {
209
+ "models": {
210
+ "gpt-5.5": {
211
+ "type": "chat",
212
+ "interfaces": {
213
+ "openaiChatCompletions": {
214
+ "base_url": "${OPENAI_ADAPTER_ORIGIN}"
215
+ },
216
+ "anthropicMessages": {
217
+ "base_url": "${TRANSLATOR_ORIGIN}/openaiChatCompletions"
218
+ }
219
+ },
220
+ "upstreams": [
221
+ {
222
+ "endpoint": "https://api.openai.com/v1/chat/completions",
223
+ "key": "..."
224
+ }
225
+ ]
226
+ }
227
+ }
228
+ }
229
+ ```
230
+
231
+ </details>
232
+
233
+ See the [translation guide](docs/messages-to-chat-translation.md) for how request parameters, content blocks, tools, caching, usage, and errors are translated, and for the translator's limitations.
234
+
235
+ ---
236
+
170
237
  ## Development
171
238
 
172
239
  ### Development Environment
@@ -0,0 +1,28 @@
1
+ import httpx
2
+ from fastapi import FastAPI
3
+
4
+ from aidial_sdk._pydantic import PYDANTIC_V2
5
+
6
+ if not PYDANTIC_V2:
7
+ raise ImportError(
8
+ "The Anthropic Messages API translator requires Pydantic v2: "
9
+ "install pydantic>=2 and set PYDANTIC_V2=True"
10
+ )
11
+
12
+ from aidial_sdk.translators.messages_to_chat_completions.chat_completions.app import ( # noqa: E402
13
+ ClientFactory,
14
+ create_anthropic_api_app,
15
+ )
16
+
17
+ __all__ = ["ClientFactory", "create_anthropic_api_app", "mount_translator"]
18
+
19
+
20
+ def mount_translator(
21
+ app: FastAPI,
22
+ get_client: ClientFactory[httpx.AsyncClient],
23
+ *,
24
+ path: str = "/openaiChatCompletions",
25
+ name: str = "Anthropic Messages API to Chat Completions API translator",
26
+ ) -> None:
27
+ translator = create_anthropic_api_app(get_client)
28
+ app.mount(path=path, app=translator, name=name)
@@ -0,0 +1,134 @@
1
+ import json
2
+ import logging
3
+ from collections.abc import Awaitable, Callable
4
+ from typing import TypeVar
5
+
6
+ import httpx
7
+ from anthropic.types.beta.message_create_params import MessageCreateParams
8
+ from fastapi import FastAPI, Request
9
+ from fastapi.responses import JSONResponse, Response, StreamingResponse
10
+ from openai import AsyncStream
11
+ from openai.types.chat import ChatCompletion, ChatCompletionChunk
12
+
13
+ from aidial_sdk.translators.messages_to_chat_completions.chat_completions.from_chat_completions import (
14
+ to_anthropic_response,
15
+ )
16
+ from aidial_sdk.translators.messages_to_chat_completions.chat_completions.streaming import (
17
+ translate_stream,
18
+ )
19
+ from aidial_sdk.translators.messages_to_chat_completions.chat_completions.to_chat_completions import (
20
+ to_chat_completions_request,
21
+ )
22
+ from aidial_sdk.translators.messages_to_chat_completions.common import (
23
+ not_found,
24
+ with_debug_logging,
25
+ )
26
+ from aidial_sdk.translators.messages_to_chat_completions.core_client import (
27
+ core_headers,
28
+ core_legacy_client,
29
+ core_v1_client,
30
+ )
31
+ from aidial_sdk.translators.messages_to_chat_completions.errors import (
32
+ AnthropicErrorType,
33
+ AnthropicHTTPError,
34
+ translator_error_handler,
35
+ )
36
+ from aidial_sdk.translators.messages_to_chat_completions.settings import (
37
+ get_dial_url,
38
+ )
39
+ from aidial_sdk.translators.messages_to_chat_completions.translation_state import (
40
+ TranslationState,
41
+ )
42
+ from aidial_sdk.utils.logging import logger as log
43
+
44
+ ClientT = TypeVar("ClientT")
45
+ ClientFactory = Callable[[Request], Awaitable[ClientT]] | ClientT
46
+
47
+
48
+ async def _handle_messages(
49
+ request: Request,
50
+ get_client: ClientFactory[httpx.AsyncClient],
51
+ legacy: bool,
52
+ ) -> Response:
53
+ dial_url = get_dial_url()
54
+ translation_state = TranslationState()
55
+
56
+ try:
57
+ req: MessageCreateParams = await request.json()
58
+ except ValueError as e:
59
+ raise AnthropicHTTPError(
60
+ AnthropicErrorType.INVALID_REQUEST,
61
+ f"Request body is not valid JSON: {e}",
62
+ ) from e
63
+
64
+ deployment = request.headers.get("x-dial-deployment-id") or req["model"]
65
+ chat_request = to_chat_completions_request(
66
+ req, deployment, translation_state
67
+ )
68
+
69
+ body = chat_request.model_dump(mode="json", exclude_none=True)
70
+ if log.isEnabledFor(logging.DEBUG):
71
+ log.debug(f"Translated request to Core: {json.dumps(body)}")
72
+
73
+ http_client = (
74
+ get_client
75
+ if isinstance(get_client, httpx.AsyncClient)
76
+ else await get_client(request)
77
+ )
78
+ client = (
79
+ core_legacy_client(dial_url, deployment, http_client)
80
+ if legacy
81
+ else core_v1_client(dial_url, http_client)
82
+ )
83
+ headers = core_headers(request)
84
+
85
+ if chat_request.stream:
86
+ events = await client.post(
87
+ "/chat/completions",
88
+ cast_to=ChatCompletion,
89
+ body=body,
90
+ options={"headers": headers},
91
+ stream=True,
92
+ stream_cls=AsyncStream[ChatCompletionChunk],
93
+ )
94
+ return StreamingResponse(
95
+ translate_stream(events, deployment, translation_state),
96
+ media_type="text/event-stream",
97
+ )
98
+
99
+ response = await client.post(
100
+ "/chat/completions",
101
+ cast_to=ChatCompletion,
102
+ body=body,
103
+ options={"headers": headers},
104
+ )
105
+ message = to_anthropic_response(response, deployment, translation_state)
106
+ return JSONResponse(
107
+ content=message.model_dump(mode="json", exclude_unset=True)
108
+ )
109
+
110
+
111
+ def create_anthropic_api_app(
112
+ get_client: ClientFactory[httpx.AsyncClient],
113
+ ) -> FastAPI:
114
+ app = FastAPI()
115
+
116
+ @app.post("/anthropic/v1/messages")
117
+ @with_debug_logging
118
+ @translator_error_handler
119
+ async def _handle_v1_messages(request: Request) -> Response:
120
+ return await _handle_messages(request, get_client, legacy=False)
121
+
122
+ @app.post("/legacy/anthropic/v1/messages")
123
+ @with_debug_logging
124
+ @translator_error_handler
125
+ async def _handle_legacy_messages(request: Request) -> Response:
126
+ return await _handle_messages(request, get_client, legacy=True)
127
+
128
+ app.add_api_route(
129
+ "/{full_path:path}",
130
+ not_found,
131
+ methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
132
+ )
133
+
134
+ return app
@@ -0,0 +1,48 @@
1
+ import re
2
+ from datetime import UTC, datetime, timedelta
3
+
4
+ from anthropic.types.beta import BetaCacheControlEphemeralParam as CacheControl
5
+
6
+ from aidial_sdk.chat_completion.request import CacheBreakpoint
7
+ from aidial_sdk.utils.logging import logger as log
8
+
9
+ _TTL = re.compile(r"^([1-9]\d*)([mh])$")
10
+
11
+ _UNITS = {
12
+ "m": timedelta(minutes=1),
13
+ "h": timedelta(hours=1),
14
+ }
15
+
16
+
17
+ def to_dial_cache_breakpoint(
18
+ controls: list[CacheControl],
19
+ ) -> CacheBreakpoint | None:
20
+ if not controls:
21
+ return None
22
+ expires_at: str | None = None
23
+ for control in controls:
24
+ try:
25
+ if (ttl := _ttl(control)) is not None:
26
+ candidate = _expire_at(ttl)
27
+ expires_at = (
28
+ max(expires_at, candidate) if expires_at else candidate
29
+ )
30
+ except (OverflowError, ValueError):
31
+ log.warning(
32
+ f"Ignoring unreadable cache_control.ttl: {control.get('ttl')}"
33
+ )
34
+ return CacheBreakpoint(expire_at=expires_at)
35
+
36
+
37
+ def _ttl(control: CacheControl) -> timedelta | None:
38
+ raw = control.get("ttl")
39
+ if raw is None:
40
+ return None
41
+ if isinstance(raw, str) and (match := _TTL.match(raw)):
42
+ return int(match.group(1)) * _UNITS[match.group(2)]
43
+ raise ValueError("Invalid ttl")
44
+
45
+
46
+ def _expire_at(ttl: timedelta) -> str:
47
+ expires_at = datetime.now(UTC) + ttl
48
+ return expires_at.replace(microsecond=0).isoformat().replace("+00:00", "Z")
@@ -0,0 +1,66 @@
1
+ import re
2
+ from collections.abc import Mapping
3
+
4
+ from openai.types.chat.chat_completion_message import Annotation
5
+ from pydantic import ValidationError
6
+
7
+ from aidial_sdk.utils.logging import logger as log
8
+ from aidial_sdk.utils.pydantic import ExtraAllowModel
9
+
10
+ _REASONING_STAGE = re.compile(r"think|thought|reason", re.IGNORECASE)
11
+
12
+
13
+ class ClaudeMessageBlock(ExtraAllowModel):
14
+ type: str | None = None
15
+ thinking: str | None = None
16
+ signature: str | None = None
17
+
18
+
19
+ class MessageState(ExtraAllowModel):
20
+ claude_message_content: list[ClaudeMessageBlock] | None = None
21
+
22
+
23
+ class Stage(ExtraAllowModel):
24
+ index: int | None = None
25
+ name: str | None = None
26
+ content: str | None = None
27
+
28
+
29
+ class CustomContent(ExtraAllowModel):
30
+ stages: list[Stage] | None = None
31
+ state: MessageState | None = None
32
+
33
+
34
+ class DialExtras(ExtraAllowModel):
35
+ custom_content: CustomContent | None = None
36
+ annotations: list[Annotation] | None = None
37
+
38
+
39
+ def parse_extras(model_extra: Mapping[str, object] | None) -> DialExtras:
40
+ try:
41
+ return DialExtras.model_validate(model_extra or {})
42
+ except ValidationError as e:
43
+ log.error(f"Failed to parse DIAL extension fields: {e}")
44
+ return DialExtras()
45
+
46
+
47
+ def is_reasoning_stage(name: str | None) -> bool:
48
+ return name is not None and bool(_REASONING_STAGE.search(name))
49
+
50
+
51
+ def get_claude_thinking_block(
52
+ custom_content: CustomContent | None,
53
+ ) -> ClaudeMessageBlock | None:
54
+ state = custom_content.state if custom_content else None
55
+ for block in (state.claude_message_content if state else None) or []:
56
+ if block.type == "thinking" and block.thinking:
57
+ return block
58
+ return None
59
+
60
+
61
+ def get_dial_reasoning_content(custom_content: CustomContent | None) -> str:
62
+ return "".join(
63
+ stage.content or ""
64
+ for stage in (custom_content.stages if custom_content else None) or []
65
+ if is_reasoning_stage(stage.name)
66
+ )
@@ -0,0 +1,237 @@
1
+ import json
2
+ import uuid
3
+ from typing import assert_never
4
+
5
+ from anthropic.types import (
6
+ Message,
7
+ ServerToolUseBlock,
8
+ StopReason,
9
+ TextBlock,
10
+ ThinkingBlock,
11
+ ToolUseBlock,
12
+ Usage,
13
+ WebSearchToolResultBlock,
14
+ )
15
+ from anthropic.types.content_block import ContentBlock as AnthropicContentBlock
16
+ from anthropic.types.output_tokens_details import OutputTokensDetails
17
+ from anthropic.types.web_search_result_block import WebSearchResultBlock
18
+ from openai.types.chat import ChatCompletion
19
+ from openai.types.chat.chat_completion_message import (
20
+ Annotation,
21
+ ChatCompletionMessage,
22
+ )
23
+ from openai.types.chat.chat_completion_message_tool_call import (
24
+ ChatCompletionMessageToolCallUnion,
25
+ )
26
+ from openai.types.completion_usage import CompletionUsage, PromptTokensDetails
27
+
28
+ from aidial_sdk.translators.messages_to_chat_completions.chat_completions.dial_extensions import (
29
+ CustomContent,
30
+ get_claude_thinking_block,
31
+ get_dial_reasoning_content,
32
+ parse_extras,
33
+ )
34
+ from aidial_sdk.translators.messages_to_chat_completions.translation_state import (
35
+ TranslationState,
36
+ )
37
+ from aidial_sdk.utils.logging import logger as log
38
+
39
+
40
+ def to_anthropic_response(
41
+ response: ChatCompletion,
42
+ requested_model: str,
43
+ translation_state: TranslationState,
44
+ ) -> Message:
45
+ if len(response.choices) > 1:
46
+ log.warning(
47
+ f"Ignoring {len(response.choices) - 1} choices after the first one"
48
+ )
49
+ choice = response.choices[0] if response.choices else None
50
+ message = choice.message if choice else None
51
+ tool_blocks = _to_anthropic_tool_use_blocks(
52
+ message.tool_calls if message else None, translation_state
53
+ )
54
+
55
+ return Message(
56
+ id=response.id,
57
+ type="message",
58
+ role="assistant",
59
+ model=requested_model,
60
+ content=_to_anthropic_content_blocks(message, tool_blocks),
61
+ stop_reason=to_anthropic_stop_reason(
62
+ choice.finish_reason if choice else None,
63
+ bool(tool_blocks),
64
+ bool(message and message.refusal),
65
+ ),
66
+ usage=to_anthropic_usage(response.usage),
67
+ )
68
+
69
+
70
+ def _to_anthropic_content_blocks(
71
+ message: ChatCompletionMessage | None,
72
+ tool_blocks: list[ToolUseBlock],
73
+ ) -> list[AnthropicContentBlock]:
74
+ if message is None:
75
+ return [_text_block("")]
76
+
77
+ content: list[AnthropicContentBlock] = []
78
+
79
+ extras = parse_extras(message.model_extra)
80
+ if thinking := to_anthropic_thinking_block(extras.custom_content):
81
+ content.append(thinking)
82
+
83
+ content.extend(
84
+ to_anthropic_web_search_blocks(_citations(message.annotations))
85
+ )
86
+
87
+ if message.content:
88
+ content.append(_text_block(message.content))
89
+
90
+ if message.refusal:
91
+ content.append(_text_block(message.refusal))
92
+
93
+ content.extend(tool_blocks)
94
+
95
+ return content or [_text_block("")]
96
+
97
+
98
+ def _text_block(text: str) -> TextBlock:
99
+ return TextBlock(type="text", text=text)
100
+
101
+
102
+ def to_anthropic_thinking_block(
103
+ custom_content: CustomContent | None,
104
+ ) -> ThinkingBlock | None:
105
+ if block := get_claude_thinking_block(custom_content):
106
+ return ThinkingBlock(
107
+ type="thinking",
108
+ thinking=block.thinking or "",
109
+ signature=block.signature or "",
110
+ )
111
+ if text := get_dial_reasoning_content(custom_content):
112
+ return ThinkingBlock(type="thinking", thinking=text, signature="")
113
+ return None
114
+
115
+
116
+ def _citations(annotations: list[Annotation] | None) -> list[tuple[str, str]]:
117
+ return [
118
+ (annotation.url_citation.url, annotation.url_citation.title)
119
+ for annotation in annotations or []
120
+ ]
121
+
122
+
123
+ def to_anthropic_web_search_blocks(
124
+ citations: list[tuple[str, str]],
125
+ ) -> list[AnthropicContentBlock]:
126
+ if not citations:
127
+ return []
128
+ tool_use_id = f"srvtoolu_{uuid.uuid4().hex}"
129
+ return [
130
+ ServerToolUseBlock(
131
+ type="server_tool_use",
132
+ id=tool_use_id,
133
+ name="web_search",
134
+ input={"query": ""},
135
+ ),
136
+ WebSearchToolResultBlock(
137
+ type="web_search_tool_result",
138
+ tool_use_id=tool_use_id,
139
+ content=[
140
+ WebSearchResultBlock(
141
+ type="web_search_result",
142
+ url=url,
143
+ title=title,
144
+ encrypted_content="",
145
+ )
146
+ for url, title in citations
147
+ ],
148
+ ),
149
+ ]
150
+
151
+
152
+ def _to_anthropic_tool_use_blocks(
153
+ tool_calls: list[ChatCompletionMessageToolCallUnion] | None,
154
+ translation_state: TranslationState,
155
+ ) -> list[ToolUseBlock]:
156
+ blocks: list[ToolUseBlock] = []
157
+ for call in tool_calls or []:
158
+ match call.type:
159
+ case "custom":
160
+ log.warning(f"Skipping unsupported tool call type: {call.type}")
161
+ continue
162
+ case "function":
163
+ pass
164
+ case unexpected:
165
+ assert_never(unexpected)
166
+ blocks.append(
167
+ ToolUseBlock(
168
+ type="tool_use",
169
+ id=call.id,
170
+ name=translation_state.to_client_tool_name(call.function.name),
171
+ input=_parse_arguments(call.function.arguments),
172
+ )
173
+ )
174
+ return blocks
175
+
176
+
177
+ def _parse_arguments(arguments: str) -> dict[str, object]:
178
+ try:
179
+ parsed = json.loads(arguments) if arguments else {}
180
+ except json.JSONDecodeError:
181
+ parsed = None
182
+ if not isinstance(parsed, dict):
183
+ log.warning("Failed to parse function_call arguments as a JSON object")
184
+ return {}
185
+ return parsed
186
+
187
+
188
+ def to_anthropic_stop_reason(
189
+ finish_reason: str | None,
190
+ has_tool_call: bool,
191
+ is_refusal: bool,
192
+ ) -> StopReason:
193
+ if finish_reason == "length":
194
+ return "max_tokens"
195
+ if finish_reason == "content_filter":
196
+ return "refusal"
197
+ if has_tool_call:
198
+ return "tool_use"
199
+ if is_refusal:
200
+ return "refusal"
201
+ return "end_turn"
202
+
203
+
204
+ def to_anthropic_usage(usage: CompletionUsage | None) -> Usage:
205
+ if usage is None:
206
+ return Usage(input_tokens=0, output_tokens=0)
207
+
208
+ prompt_tokens = usage.prompt_tokens or 0
209
+ prompt_details = usage.prompt_tokens_details
210
+ # Anthropic's input_tokens excludes cache reads and writes, which
211
+ # prompt_tokens includes: cap the breakdown so the counters add up.
212
+ cache_read = min(
213
+ (prompt_details.cached_tokens if prompt_details else 0) or 0,
214
+ prompt_tokens,
215
+ )
216
+ cache_write = min(
217
+ _cache_write_tokens(prompt_details), prompt_tokens - cache_read
218
+ )
219
+
220
+ completion_details = usage.completion_tokens_details
221
+ thinking = (
222
+ completion_details.reasoning_tokens if completion_details else 0
223
+ ) or 0
224
+
225
+ return Usage(
226
+ input_tokens=prompt_tokens - cache_read - cache_write,
227
+ output_tokens=usage.completion_tokens or 0,
228
+ cache_read_input_tokens=cache_read,
229
+ cache_creation_input_tokens=cache_write,
230
+ output_tokens_details=OutputTokensDetails(thinking_tokens=thinking),
231
+ )
232
+
233
+
234
+ def _cache_write_tokens(details: PromptTokensDetails | None) -> int:
235
+ if details is None:
236
+ return 0
237
+ return details.cache_write_tokens or 0