mmsp 0.5.0__py3-none-any.whl
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.
- mmsp/__init__.py +45 -0
- mmsp/abort_signal.py +135 -0
- mmsp/ant_messages/__init__.py +18 -0
- mmsp/ant_messages/client.py +395 -0
- mmsp/anthropic_official/__init__.py +18 -0
- mmsp/anthropic_official/client.py +491 -0
- mmsp/auto_client.py +281 -0
- mmsp/base_client.py +378 -0
- mmsp/deepseek_official/__init__.py +18 -0
- mmsp/deepseek_official/client.py +384 -0
- mmsp/errors.py +123 -0
- mmsp/gemini_generate_content/__init__.py +18 -0
- mmsp/gemini_generate_content/client.py +668 -0
- mmsp/gemini_official/__init__.py +18 -0
- mmsp/gemini_official/client.py +674 -0
- mmsp/integration/__init__.py +14 -0
- mmsp/integration/playground.py +3306 -0
- mmsp/integration/tracer.py +1812 -0
- mmsp/legacy.py +78 -0
- mmsp/minimax_official/__init__.py +18 -0
- mmsp/minimax_official/client.py +323 -0
- mmsp/moonshot_official/__init__.py +18 -0
- mmsp/moonshot_official/client.py +412 -0
- mmsp/openai_chat/__init__.py +18 -0
- mmsp/openai_chat/client.py +379 -0
- mmsp/openai_chat_vllm_adapter/__init__.py +4 -0
- mmsp/openai_chat_vllm_adapter/client.py +122 -0
- mmsp/openai_embedding/__init__.py +18 -0
- mmsp/openai_embedding/client.py +102 -0
- mmsp/openai_official/__init__.py +18 -0
- mmsp/openai_official/client.py +426 -0
- mmsp/openai_responses/__init__.py +18 -0
- mmsp/openai_responses/client.py +411 -0
- mmsp/registry.py +846 -0
- mmsp/stream_items.py +199 -0
- mmsp/types.py +258 -0
- mmsp/utils.py +305 -0
- mmsp/zai_official/__init__.py +18 -0
- mmsp/zai_official/client.py +406 -0
- mmsp-0.5.0.dist-info/METADATA +356 -0
- mmsp-0.5.0.dist-info/RECORD +42 -0
- mmsp-0.5.0.dist-info/WHEEL +4 -0
mmsp/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Copyright 2025 Prism Shadow. and/or its affiliates
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from .auto_client import AutoLLMClient
|
|
16
|
+
from .errors import (
|
|
17
|
+
EmptyResponseError,
|
|
18
|
+
MMSPError,
|
|
19
|
+
StreamProtocolError,
|
|
20
|
+
ToolCallArgumentParseError,
|
|
21
|
+
UnsupportedOperationError,
|
|
22
|
+
UnsupportedParameterError,
|
|
23
|
+
)
|
|
24
|
+
from .legacy import normalize_legacy_messages
|
|
25
|
+
from .registry import Currency, Modality, ModelPricing, SupportedModel, list_supported_models
|
|
26
|
+
from .types import PromptCaching, ThinkingLevel
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AutoLLMClient",
|
|
31
|
+
"Currency",
|
|
32
|
+
"EmptyResponseError",
|
|
33
|
+
"MMSPError",
|
|
34
|
+
"Modality",
|
|
35
|
+
"ModelPricing",
|
|
36
|
+
"PromptCaching",
|
|
37
|
+
"StreamProtocolError",
|
|
38
|
+
"SupportedModel",
|
|
39
|
+
"ThinkingLevel",
|
|
40
|
+
"ToolCallArgumentParseError",
|
|
41
|
+
"UnsupportedOperationError",
|
|
42
|
+
"UnsupportedParameterError",
|
|
43
|
+
"list_supported_models",
|
|
44
|
+
"normalize_legacy_messages",
|
|
45
|
+
]
|
mmsp/abort_signal.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Copyright 2025 Prism Shadow. and/or its affiliates
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import threading
|
|
17
|
+
from contextlib import suppress
|
|
18
|
+
from typing import Any, Awaitable, TypeVar
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
T = TypeVar("T")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AbortSignal:
|
|
25
|
+
"""Abort signal that can also trigger its own aborted state."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._lock = threading.Lock()
|
|
29
|
+
self._aborted = False
|
|
30
|
+
self._reason: Any = None
|
|
31
|
+
self._waiters: set[asyncio.Future[None]] = set()
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def aborted(self) -> bool:
|
|
35
|
+
with self._lock:
|
|
36
|
+
return self._aborted
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def reason(self) -> Any:
|
|
40
|
+
with self._lock:
|
|
41
|
+
return self._reason
|
|
42
|
+
|
|
43
|
+
def abort(self, reason: Any = None) -> None:
|
|
44
|
+
with self._lock:
|
|
45
|
+
if self._aborted:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
self._aborted = True
|
|
49
|
+
self._reason = reason
|
|
50
|
+
waiters = tuple(self._waiters)
|
|
51
|
+
self._waiters.clear()
|
|
52
|
+
|
|
53
|
+
for waiter in waiters:
|
|
54
|
+
_notify_waiter(waiter)
|
|
55
|
+
|
|
56
|
+
async def wait(self) -> None:
|
|
57
|
+
loop = asyncio.get_running_loop()
|
|
58
|
+
waiter = loop.create_future()
|
|
59
|
+
with self._lock:
|
|
60
|
+
if self._aborted:
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
self._waiters.add(waiter)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
await waiter
|
|
67
|
+
finally:
|
|
68
|
+
with self._lock:
|
|
69
|
+
self._waiters.discard(waiter)
|
|
70
|
+
|
|
71
|
+
def throw_if_aborted(self) -> None:
|
|
72
|
+
with self._lock:
|
|
73
|
+
aborted = self._aborted
|
|
74
|
+
reason = self._reason
|
|
75
|
+
|
|
76
|
+
if aborted:
|
|
77
|
+
raise _cancelled_error(reason)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def run_with_abort(awaitable: Awaitable[T], signal: AbortSignal) -> T:
|
|
81
|
+
"""Run an awaitable and cancel it when the signal is aborted."""
|
|
82
|
+
|
|
83
|
+
task = asyncio.ensure_future(awaitable)
|
|
84
|
+
|
|
85
|
+
if signal.aborted:
|
|
86
|
+
task.cancel(signal.reason)
|
|
87
|
+
with suppress(asyncio.CancelledError):
|
|
88
|
+
await task
|
|
89
|
+
raise _cancelled_error(signal.reason)
|
|
90
|
+
|
|
91
|
+
abort_task = asyncio.create_task(signal.wait())
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
done, _ = await asyncio.wait((task, abort_task), return_when=asyncio.FIRST_COMPLETED)
|
|
95
|
+
if task in done:
|
|
96
|
+
return await task
|
|
97
|
+
|
|
98
|
+
task.cancel(signal.reason)
|
|
99
|
+
with suppress(asyncio.CancelledError):
|
|
100
|
+
await task
|
|
101
|
+
raise _cancelled_error(signal.reason)
|
|
102
|
+
except asyncio.CancelledError:
|
|
103
|
+
if not task.done():
|
|
104
|
+
task.cancel()
|
|
105
|
+
with suppress(asyncio.CancelledError):
|
|
106
|
+
await task
|
|
107
|
+
raise
|
|
108
|
+
finally:
|
|
109
|
+
if not abort_task.done():
|
|
110
|
+
abort_task.cancel()
|
|
111
|
+
with suppress(asyncio.CancelledError):
|
|
112
|
+
await abort_task
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _set_waiter_result(waiter: asyncio.Future[None]) -> None:
|
|
116
|
+
if not waiter.done():
|
|
117
|
+
waiter.set_result(None)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _notify_waiter(waiter: asyncio.Future[None]) -> None:
|
|
121
|
+
loop = waiter.get_loop()
|
|
122
|
+
if loop.is_closed():
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
if loop.is_running():
|
|
126
|
+
loop.call_soon_threadsafe(_set_waiter_result, waiter)
|
|
127
|
+
else:
|
|
128
|
+
_set_waiter_result(waiter)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _cancelled_error(reason: Any) -> asyncio.CancelledError:
|
|
132
|
+
if reason is None:
|
|
133
|
+
return asyncio.CancelledError()
|
|
134
|
+
|
|
135
|
+
return asyncio.CancelledError(reason)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Copyright 2025 Prism Shadow. and/or its affiliates
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from .client import AntMessagesClient
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = ["AntMessagesClient"]
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
# Copyright 2025 Prism Shadow. and/or its affiliates
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from typing import Any, AsyncIterator
|
|
17
|
+
|
|
18
|
+
from anthropic import AsyncAnthropic
|
|
19
|
+
from anthropic.types.beta import BetaMessageParam, BetaRawMessageStreamEvent
|
|
20
|
+
|
|
21
|
+
from ..base_client import LLMClient
|
|
22
|
+
from ..errors import UnsupportedParameterError
|
|
23
|
+
from ..types import (
|
|
24
|
+
EventContentItem,
|
|
25
|
+
EventType,
|
|
26
|
+
FinishReason,
|
|
27
|
+
PromptCaching,
|
|
28
|
+
ThinkingLevel,
|
|
29
|
+
ToolChoice,
|
|
30
|
+
UniConfig,
|
|
31
|
+
UniEvent,
|
|
32
|
+
UniMessage,
|
|
33
|
+
UsageMetadata,
|
|
34
|
+
)
|
|
35
|
+
from ..utils import fix_openrouter_usage_metadata, is_debug_enabled, resolve_credentials
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
REDACTED_THINKING = "_REDACTED_THINKING"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AntMessagesClient(LLMClient):
|
|
42
|
+
"""Anthropic Messages-compatible client implementation."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
model: str,
|
|
47
|
+
api_key: str | None = None,
|
|
48
|
+
base_url: str | None = None,
|
|
49
|
+
default_headers: dict[str, str] | None = None,
|
|
50
|
+
):
|
|
51
|
+
"""Initialize Anthropic Messages-compatible client with model, API key, and base URL."""
|
|
52
|
+
self._model = model
|
|
53
|
+
api_key, base_url = resolve_credentials(
|
|
54
|
+
self.__class__.__name__, api_key, base_url, "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"
|
|
55
|
+
)
|
|
56
|
+
# send the credential through both header conventions: Anthropic and DeepSeek read
|
|
57
|
+
# x-api-key while gateways such as OpenRouter and Z.AI read Authorization: Bearer
|
|
58
|
+
self._client = AsyncAnthropic(
|
|
59
|
+
api_key=api_key, auth_token=api_key, base_url=base_url, default_headers=default_headers
|
|
60
|
+
)
|
|
61
|
+
# With no credential the SDK reads the None auth_token as unset and may fill it from
|
|
62
|
+
# ANTHROPIC_AUTH_TOKEN, so pin the token to the key it was given.
|
|
63
|
+
self._client.auth_token = api_key
|
|
64
|
+
self._history: list[UniMessage] = []
|
|
65
|
+
|
|
66
|
+
def _convert_image_url_to_source(self, url: str) -> dict[str, Any]:
|
|
67
|
+
"""Convert image URL to an Anthropic image source block."""
|
|
68
|
+
if url.startswith("data:"):
|
|
69
|
+
match = re.match(r"data:([^;]+);base64,(.+)", url)
|
|
70
|
+
if not match:
|
|
71
|
+
raise ValueError(f"Invalid base64 image: {url}")
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
"type": "image",
|
|
75
|
+
"source": {"type": "base64", "media_type": match.group(1), "data": match.group(2)},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {"type": "image", "source": {"type": "url", "url": url}}
|
|
79
|
+
|
|
80
|
+
def _convert_thinking_level_to_thinking_config(self, thinking_level: ThinkingLevel) -> dict[str, Any]:
|
|
81
|
+
"""Convert ThinkingLevel enum to the Messages API thinking config."""
|
|
82
|
+
# NONE is explicit rather than omitted because some servers (e.g. Z.AI) think by default
|
|
83
|
+
mapping = {
|
|
84
|
+
ThinkingLevel.NONE: {"thinking": {"type": "disabled"}},
|
|
85
|
+
ThinkingLevel.LOW: {"thinking": {"type": "adaptive"}, "output_config": {"effort": "low"}},
|
|
86
|
+
ThinkingLevel.MEDIUM: {"thinking": {"type": "adaptive"}, "output_config": {"effort": "medium"}},
|
|
87
|
+
ThinkingLevel.HIGH: {"thinking": {"type": "adaptive"}, "output_config": {"effort": "high"}},
|
|
88
|
+
ThinkingLevel.XHIGH: {"thinking": {"type": "adaptive"}, "output_config": {"effort": "xhigh"}},
|
|
89
|
+
ThinkingLevel.MAX: {"thinking": {"type": "adaptive"}, "output_config": {"effort": "max"}},
|
|
90
|
+
}
|
|
91
|
+
return mapping.get(thinking_level)
|
|
92
|
+
|
|
93
|
+
def _convert_tool_choice(self, tool_choice: ToolChoice) -> dict[str, str]:
|
|
94
|
+
"""Convert ToolChoice to the Messages API tool_choice format."""
|
|
95
|
+
if isinstance(tool_choice, list):
|
|
96
|
+
if len(tool_choice) > 1:
|
|
97
|
+
raise UnsupportedParameterError(
|
|
98
|
+
self.__class__.__name__, "tool_choice", "The Messages API does not support multiple tool choices."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return {"type": "tool", "name": tool_choice[0]}
|
|
102
|
+
elif tool_choice == "none":
|
|
103
|
+
return {"type": "none"}
|
|
104
|
+
elif tool_choice == "auto":
|
|
105
|
+
return {"type": "auto"}
|
|
106
|
+
elif tool_choice == "required":
|
|
107
|
+
return {"type": "any"}
|
|
108
|
+
|
|
109
|
+
def transform_uni_config_to_model_config(self, config: UniConfig) -> dict[str, Any]:
|
|
110
|
+
"""
|
|
111
|
+
Transform universal configuration to Anthropic Messages-compatible configuration.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
config: Universal configuration dict
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Anthropic Messages API configuration dictionary
|
|
118
|
+
"""
|
|
119
|
+
ant_config = {"model": self._model, "stream": True}
|
|
120
|
+
|
|
121
|
+
if config.get("system_prompt") is not None:
|
|
122
|
+
ant_config["system"] = config["system_prompt"]
|
|
123
|
+
|
|
124
|
+
if config.get("max_tokens") is not None:
|
|
125
|
+
ant_config["max_tokens"] = config["max_tokens"]
|
|
126
|
+
else:
|
|
127
|
+
ant_config["max_tokens"] = 64000 # the Messages API requires max_tokens to be specified
|
|
128
|
+
|
|
129
|
+
if config.get("temperature") is not None:
|
|
130
|
+
ant_config["temperature"] = config["temperature"]
|
|
131
|
+
|
|
132
|
+
if config.get("thinking_level") is not None:
|
|
133
|
+
ant_config.update(self._convert_thinking_level_to_thinking_config(config["thinking_level"]))
|
|
134
|
+
|
|
135
|
+
if config.get("thinking_summary") is not None:
|
|
136
|
+
# display lives on the thinking block, so a summary asked for on its own selects
|
|
137
|
+
# adaptive thinking. A disabled block is the one place it cannot ride along --
|
|
138
|
+
# "thinking.disabled.display: Extra inputs are not permitted" (400, verified live
|
|
139
|
+
# 2026-09-03) -- and thinking_level NONE disables thinking, leaving nothing to show.
|
|
140
|
+
thinking = ant_config.setdefault("thinking", {"type": "adaptive"})
|
|
141
|
+
if thinking["type"] != "disabled":
|
|
142
|
+
thinking["display"] = "summarized" if config["thinking_summary"] else "omitted"
|
|
143
|
+
|
|
144
|
+
# Convert tools to the Messages API tool schema
|
|
145
|
+
if config.get("tools") is not None:
|
|
146
|
+
ant_tools = []
|
|
147
|
+
for tool in config["tools"]:
|
|
148
|
+
ant_tool = {}
|
|
149
|
+
for key, value in tool.items():
|
|
150
|
+
ant_tool[key.replace("parameters", "input_schema")] = value
|
|
151
|
+
|
|
152
|
+
ant_tools.append(ant_tool)
|
|
153
|
+
|
|
154
|
+
ant_config["tools"] = ant_tools
|
|
155
|
+
|
|
156
|
+
# Convert tool_choice
|
|
157
|
+
if config.get("tool_choice") is not None:
|
|
158
|
+
ant_config["tool_choice"] = self._convert_tool_choice(config["tool_choice"])
|
|
159
|
+
|
|
160
|
+
if config.get("fast_mode"):
|
|
161
|
+
ant_config["speed"] = "fast"
|
|
162
|
+
ant_config["betas"] = ["fast-mode-2026-02-01"]
|
|
163
|
+
|
|
164
|
+
if config.get("prompt_caching") is not None and config["prompt_caching"] != PromptCaching.ENABLE:
|
|
165
|
+
raise UnsupportedParameterError(
|
|
166
|
+
self.__class__.__name__, "prompt_caching", "prompt_caching must be ENABLE for the Messages API."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return ant_config
|
|
170
|
+
|
|
171
|
+
def transform_uni_message_to_model_input(self, messages: list[UniMessage]) -> list[BetaMessageParam]:
|
|
172
|
+
"""
|
|
173
|
+
Transform universal message format to the Messages API BetaMessageParam format.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
messages: List of universal message dictionaries
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
List of Messages API BetaMessageParam objects
|
|
180
|
+
"""
|
|
181
|
+
ant_messages: list[BetaMessageParam] = []
|
|
182
|
+
|
|
183
|
+
for msg in messages:
|
|
184
|
+
content_blocks = []
|
|
185
|
+
for item in msg["content_items"]:
|
|
186
|
+
if item["type"] == "text.done":
|
|
187
|
+
content_blocks.append({"type": "text", "text": item["text"]})
|
|
188
|
+
elif item["type"] == "image_url.done":
|
|
189
|
+
content_blocks.append(self._convert_image_url_to_source(item["image_url"]))
|
|
190
|
+
elif item["type"] == "thinking.done":
|
|
191
|
+
if item["thinking"] == REDACTED_THINKING:
|
|
192
|
+
content_blocks.append({"type": "redacted_thinking", "data": item["fidelity"]["signature"]})
|
|
193
|
+
else:
|
|
194
|
+
# third-party servers accept thinking without a signature, but the
|
|
195
|
+
# official API requires the one it emitted
|
|
196
|
+
thinking_block = {"type": "thinking", "thinking": item["thinking"]}
|
|
197
|
+
signature = (item.get("fidelity") or {}).get("signature")
|
|
198
|
+
if signature is not None:
|
|
199
|
+
thinking_block["signature"] = signature
|
|
200
|
+
|
|
201
|
+
content_blocks.append(thinking_block)
|
|
202
|
+
elif item["type"] == "tool_call.done":
|
|
203
|
+
content_blocks.append(
|
|
204
|
+
{
|
|
205
|
+
"type": "tool_use",
|
|
206
|
+
"id": item["tool_call_id"],
|
|
207
|
+
"name": item["name"],
|
|
208
|
+
"input": item["arguments"],
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
elif item["type"] == "tool_result.done":
|
|
212
|
+
if "tool_call_id" not in item:
|
|
213
|
+
raise ValueError("tool_call_id is required for tool result.")
|
|
214
|
+
|
|
215
|
+
tool_result = [{"type": "text", "text": item["text"]}]
|
|
216
|
+
if "images" in item:
|
|
217
|
+
for image_url in item["images"]:
|
|
218
|
+
tool_result.append(self._convert_image_url_to_source(image_url))
|
|
219
|
+
|
|
220
|
+
content_blocks.append(
|
|
221
|
+
{"type": "tool_result", "content": tool_result, "tool_use_id": item["tool_call_id"]}
|
|
222
|
+
)
|
|
223
|
+
else:
|
|
224
|
+
raise ValueError(f"Unknown item: {item}")
|
|
225
|
+
|
|
226
|
+
ant_messages.append({"role": msg["role"], "content": content_blocks})
|
|
227
|
+
|
|
228
|
+
return ant_messages
|
|
229
|
+
|
|
230
|
+
def transform_model_output_to_uni_event(self, model_output: BetaRawMessageStreamEvent) -> UniEvent:
|
|
231
|
+
"""
|
|
232
|
+
Transform one Messages API stream event into a universal event, identifying items by content block index.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
model_output: Messages API streaming event
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Universal event dictionary, an empty delta event when the wire event carries nothing universal
|
|
239
|
+
"""
|
|
240
|
+
event_type: EventType = "delta"
|
|
241
|
+
content_items: list[EventContentItem] = []
|
|
242
|
+
usage_metadata: UsageMetadata | None = None
|
|
243
|
+
finish_reason: FinishReason | None = None
|
|
244
|
+
|
|
245
|
+
ant_event_type = model_output.type
|
|
246
|
+
if ant_event_type == "content_block_start":
|
|
247
|
+
item_id = str(model_output.index)
|
|
248
|
+
block = model_output.content_block
|
|
249
|
+
if block.type == "tool_use":
|
|
250
|
+
content_items.append(
|
|
251
|
+
{
|
|
252
|
+
"type": "tool_call.delta",
|
|
253
|
+
"name": block.name,
|
|
254
|
+
"arguments": "",
|
|
255
|
+
"tool_call_id": block.id,
|
|
256
|
+
"fidelity": {"item_id": item_id},
|
|
257
|
+
}
|
|
258
|
+
)
|
|
259
|
+
elif block.type == "redacted_thinking":
|
|
260
|
+
content_items.append(
|
|
261
|
+
{
|
|
262
|
+
"type": "thinking.delta",
|
|
263
|
+
"thinking": REDACTED_THINKING,
|
|
264
|
+
"fidelity": {"item_id": item_id, "signature": block.data},
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
elif ant_event_type == "content_block_delta":
|
|
269
|
+
item_id = str(model_output.index)
|
|
270
|
+
delta = model_output.delta
|
|
271
|
+
if delta.type == "thinking_delta":
|
|
272
|
+
content_items.append(
|
|
273
|
+
{"type": "thinking.delta", "thinking": delta.thinking, "fidelity": {"item_id": item_id}}
|
|
274
|
+
)
|
|
275
|
+
elif delta.type == "text_delta":
|
|
276
|
+
content_items.append({"type": "text.delta", "text": delta.text, "fidelity": {"item_id": item_id}})
|
|
277
|
+
elif delta.type == "input_json_delta":
|
|
278
|
+
content_items.append(
|
|
279
|
+
{
|
|
280
|
+
"type": "tool_call.delta",
|
|
281
|
+
"name": "",
|
|
282
|
+
"arguments": delta.partial_json,
|
|
283
|
+
"tool_call_id": "",
|
|
284
|
+
"fidelity": {"item_id": item_id},
|
|
285
|
+
}
|
|
286
|
+
)
|
|
287
|
+
elif delta.type == "signature_delta":
|
|
288
|
+
# the last delta of a thinking block: its signature
|
|
289
|
+
content_items.append(
|
|
290
|
+
{
|
|
291
|
+
"type": "thinking.delta",
|
|
292
|
+
"thinking": "",
|
|
293
|
+
"fidelity": {"item_id": item_id, "signature": delta.signature},
|
|
294
|
+
}
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
elif ant_event_type == "message_start":
|
|
298
|
+
event_type = "stop"
|
|
299
|
+
usage = getattr(model_output.message, "usage", None)
|
|
300
|
+
if usage:
|
|
301
|
+
cache_creation_tokens = usage.cache_creation_input_tokens or 0
|
|
302
|
+
usage_metadata = {
|
|
303
|
+
"cached_tokens": usage.cache_read_input_tokens,
|
|
304
|
+
"prompt_tokens": usage.input_tokens + cache_creation_tokens,
|
|
305
|
+
"thoughts_tokens": None,
|
|
306
|
+
"response_tokens": None,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
elif ant_event_type == "message_delta":
|
|
310
|
+
event_type = "stop"
|
|
311
|
+
stop_reason_mapping = {
|
|
312
|
+
"end_turn": "stop",
|
|
313
|
+
"max_tokens": "length",
|
|
314
|
+
"stop_sequence": "stop",
|
|
315
|
+
"tool_use": "tool_call",
|
|
316
|
+
}
|
|
317
|
+
stop_reason = getattr(model_output.delta, "stop_reason", None)
|
|
318
|
+
if stop_reason:
|
|
319
|
+
finish_reason = stop_reason_mapping.get(stop_reason, "unknown")
|
|
320
|
+
|
|
321
|
+
usage = getattr(model_output, "usage", None)
|
|
322
|
+
if usage:
|
|
323
|
+
# gateways report zero usage in message_start and the full counts here, so the
|
|
324
|
+
# delta also carries the input-side fields (None on servers that omit them)
|
|
325
|
+
if usage.input_tokens is not None:
|
|
326
|
+
prompt_tokens = usage.input_tokens + (usage.cache_creation_input_tokens or 0)
|
|
327
|
+
else:
|
|
328
|
+
prompt_tokens = None
|
|
329
|
+
|
|
330
|
+
output_details = getattr(usage, "output_tokens_details", None)
|
|
331
|
+
thinking_tokens = getattr(output_details, "thinking_tokens", None) if output_details else None
|
|
332
|
+
usage_metadata = fix_openrouter_usage_metadata(
|
|
333
|
+
{
|
|
334
|
+
"cached_tokens": usage.cache_read_input_tokens,
|
|
335
|
+
"prompt_tokens": prompt_tokens,
|
|
336
|
+
"thoughts_tokens": thinking_tokens,
|
|
337
|
+
"response_tokens": usage.output_tokens - (thinking_tokens or 0),
|
|
338
|
+
},
|
|
339
|
+
str(self._client.base_url),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
elif ant_event_type in [
|
|
343
|
+
"content_block_stop",
|
|
344
|
+
"message_stop",
|
|
345
|
+
"text",
|
|
346
|
+
"thinking",
|
|
347
|
+
"signature",
|
|
348
|
+
"input_json",
|
|
349
|
+
"ping",
|
|
350
|
+
]:
|
|
351
|
+
# a block needs no stop: it is done when the next one begins or the stream ends. The SDK
|
|
352
|
+
# drops the "ping" heartbeat at the SSE layer; it reaches here only from gateways that
|
|
353
|
+
# relabel it onto another event
|
|
354
|
+
pass
|
|
355
|
+
|
|
356
|
+
elif is_debug_enabled():
|
|
357
|
+
raise ValueError(f"Unknown output: {model_output}")
|
|
358
|
+
|
|
359
|
+
else:
|
|
360
|
+
# a gateway injects its own events (heartbeats, cost tickers) into the stream, and
|
|
361
|
+
# killing a long generation over one costs more than dropping it
|
|
362
|
+
pass
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
"role": "assistant",
|
|
366
|
+
"event_type": event_type,
|
|
367
|
+
"content_items": content_items,
|
|
368
|
+
"usage_metadata": usage_metadata,
|
|
369
|
+
"finish_reason": finish_reason,
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async def _streaming_response_internal(
|
|
373
|
+
self,
|
|
374
|
+
messages: list[UniMessage],
|
|
375
|
+
config: UniConfig,
|
|
376
|
+
) -> AsyncIterator[UniEvent]:
|
|
377
|
+
"""Stream generate using an Anthropic Messages-compatible API with unified conversion methods."""
|
|
378
|
+
# Use unified config conversion
|
|
379
|
+
ant_config = self.transform_uni_config_to_model_config(config)
|
|
380
|
+
|
|
381
|
+
# Use unified message conversion
|
|
382
|
+
ant_messages = self.transform_uni_message_to_model_input(messages)
|
|
383
|
+
|
|
384
|
+
stream = await self._client.beta.messages.create(**ant_config, messages=ant_messages)
|
|
385
|
+
async for event in stream:
|
|
386
|
+
yield self.transform_model_output_to_uni_event(event)
|
|
387
|
+
|
|
388
|
+
async def list_models(self) -> list[str]:
|
|
389
|
+
"""
|
|
390
|
+
List the model ids the configured endpoint serves.
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
list[str]: The model ids, in the order the endpoint returned them.
|
|
394
|
+
"""
|
|
395
|
+
return [model.id async for model in self._client.models.list()]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Copyright 2025 Prism Shadow. and/or its affiliates
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from .client import AnthropicOfficialClient
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = ["AnthropicOfficialClient"]
|