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.
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/PKG-INFO +72 -1
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/README.md +67 -0
- aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/__init__.py +28 -0
- aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/chat_completions/app.py +134 -0
- aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/chat_completions/cache_breakpoints.py +48 -0
- aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/chat_completions/dial_extensions.py +66 -0
- aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/chat_completions/from_chat_completions.py +237 -0
- aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/chat_completions/reasoning.py +48 -0
- aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/chat_completions/streaming.py +245 -0
- aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/chat_completions/system_turns.py +26 -0
- aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/chat_completions/to_chat_completions.py +695 -0
- aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/common.py +61 -0
- aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/core_client.py +64 -0
- aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/errors.py +153 -0
- aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/settings.py +21 -0
- aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/sse_stream.py +209 -0
- aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/translation_state.py +39 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/pyproject.toml +6 -1
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/LICENSE +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/__init__.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/_errors.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/_pydantic/__init__.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/_pydantic/_compat.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/_pydantic/_model_config.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/application.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/__init__.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/_cache.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/_types.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/base.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/choice.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/choice_base.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/chunks.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/enums.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/form.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/function_call.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/function_tool_call.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/request.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/response.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/chat_completion/stage.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/deployment/__init__.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/deployment/_headers.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/deployment/configuration.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/deployment/from_request_mixin.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/deployment/rate.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/deployment/tokenize.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/deployment/truncate_prompt.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/embeddings/__init__.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/embeddings/base.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/embeddings/request.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/embeddings/response.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/exceptions.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/header_propagator.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/py.typed +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/pydantic/v2.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/pydantic_v1/__init__.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/response_headers.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/telemetry/__init__.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/telemetry/_context.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/telemetry/init.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/telemetry/types.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/__init__.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/_attachment.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/_cancel_scope.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/_content_stream.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/_disconnect_middleware.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/_indexed_list.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/_json_log_formatter.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/_logging.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/_reflection.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/env.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/errors.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/json.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/log_config.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/logging.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/merge_chunks.py +0 -0
- {aidial_sdk-0.47.0.dev5 → aidial_sdk-0.47.0.dev6}/aidial_sdk/utils/pydantic.py +0 -0
- {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.
|
|
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)
|
aidial_sdk-0.47.0.dev6/aidial_sdk/translators/messages_to_chat_completions/chat_completions/app.py
ADDED
|
@@ -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
|