ccproxy-api 0.1.1__py3-none-any.whl → 0.1.3__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.
- ccproxy/_version.py +2 -2
- ccproxy/adapters/openai/__init__.py +1 -2
- ccproxy/adapters/openai/adapter.py +218 -180
- ccproxy/adapters/openai/streaming.py +247 -65
- ccproxy/api/__init__.py +0 -3
- ccproxy/api/app.py +173 -40
- ccproxy/api/dependencies.py +65 -3
- ccproxy/api/middleware/errors.py +3 -7
- ccproxy/api/middleware/headers.py +0 -2
- ccproxy/api/middleware/logging.py +4 -3
- ccproxy/api/middleware/request_content_logging.py +297 -0
- ccproxy/api/middleware/request_id.py +5 -0
- ccproxy/api/middleware/server_header.py +0 -4
- ccproxy/api/routes/__init__.py +9 -1
- ccproxy/api/routes/claude.py +23 -32
- ccproxy/api/routes/health.py +58 -4
- ccproxy/api/routes/mcp.py +171 -0
- ccproxy/api/routes/metrics.py +4 -8
- ccproxy/api/routes/permissions.py +217 -0
- ccproxy/api/routes/proxy.py +0 -53
- ccproxy/api/services/__init__.py +6 -0
- ccproxy/api/services/permission_service.py +368 -0
- ccproxy/api/ui/__init__.py +6 -0
- ccproxy/api/ui/permission_handler_protocol.py +33 -0
- ccproxy/api/ui/terminal_permission_handler.py +593 -0
- ccproxy/auth/conditional.py +2 -2
- ccproxy/auth/dependencies.py +1 -1
- ccproxy/auth/oauth/models.py +0 -1
- ccproxy/auth/oauth/routes.py +1 -3
- ccproxy/auth/storage/json_file.py +0 -1
- ccproxy/auth/storage/keyring.py +0 -3
- ccproxy/claude_sdk/__init__.py +2 -0
- ccproxy/claude_sdk/client.py +91 -8
- ccproxy/claude_sdk/converter.py +405 -210
- ccproxy/claude_sdk/options.py +88 -19
- ccproxy/claude_sdk/parser.py +200 -0
- ccproxy/claude_sdk/streaming.py +286 -0
- ccproxy/cli/commands/__init__.py +5 -1
- ccproxy/cli/commands/auth.py +2 -4
- ccproxy/cli/commands/permission_handler.py +553 -0
- ccproxy/cli/commands/serve.py +52 -12
- ccproxy/cli/docker/params.py +0 -4
- ccproxy/cli/helpers.py +0 -2
- ccproxy/cli/main.py +6 -17
- ccproxy/cli/options/claude_options.py +41 -1
- ccproxy/cli/options/core_options.py +0 -3
- ccproxy/cli/options/security_options.py +0 -2
- ccproxy/cli/options/server_options.py +3 -2
- ccproxy/config/auth.py +0 -1
- ccproxy/config/claude.py +78 -2
- ccproxy/config/discovery.py +0 -1
- ccproxy/config/docker_settings.py +0 -1
- ccproxy/config/loader.py +1 -4
- ccproxy/config/scheduler.py +20 -0
- ccproxy/config/security.py +7 -2
- ccproxy/config/server.py +5 -0
- ccproxy/config/settings.py +15 -7
- ccproxy/config/validators.py +1 -1
- ccproxy/core/async_utils.py +1 -4
- ccproxy/core/errors.py +45 -1
- ccproxy/core/http_transformers.py +4 -3
- ccproxy/core/interfaces.py +2 -2
- ccproxy/core/logging.py +97 -95
- ccproxy/core/middleware.py +1 -1
- ccproxy/core/proxy.py +1 -1
- ccproxy/core/transformers.py +1 -1
- ccproxy/core/types.py +1 -1
- ccproxy/docker/models.py +1 -1
- ccproxy/docker/protocol.py +0 -3
- ccproxy/models/__init__.py +41 -0
- ccproxy/models/claude_sdk.py +420 -0
- ccproxy/models/messages.py +45 -18
- ccproxy/models/permissions.py +115 -0
- ccproxy/models/requests.py +1 -1
- ccproxy/models/responses.py +64 -1
- ccproxy/observability/access_logger.py +1 -2
- ccproxy/observability/context.py +17 -1
- ccproxy/observability/metrics.py +1 -3
- ccproxy/observability/pushgateway.py +0 -2
- ccproxy/observability/stats_printer.py +2 -4
- ccproxy/observability/storage/duckdb_simple.py +1 -1
- ccproxy/observability/storage/models.py +0 -1
- ccproxy/pricing/cache.py +0 -1
- ccproxy/pricing/loader.py +5 -21
- ccproxy/pricing/updater.py +0 -1
- ccproxy/scheduler/__init__.py +1 -0
- ccproxy/scheduler/core.py +6 -6
- ccproxy/scheduler/manager.py +35 -7
- ccproxy/scheduler/registry.py +1 -1
- ccproxy/scheduler/tasks.py +127 -2
- ccproxy/services/claude_sdk_service.py +225 -329
- ccproxy/services/credentials/manager.py +0 -1
- ccproxy/services/credentials/oauth_client.py +1 -2
- ccproxy/services/proxy_service.py +93 -222
- ccproxy/testing/config.py +1 -1
- ccproxy/testing/mock_responses.py +0 -1
- ccproxy/utils/model_mapping.py +197 -0
- ccproxy/utils/models_provider.py +150 -0
- ccproxy/utils/simple_request_logger.py +284 -0
- ccproxy/utils/version_checker.py +184 -0
- {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/METADATA +63 -2
- ccproxy_api-0.1.3.dist-info/RECORD +166 -0
- {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/entry_points.txt +1 -0
- ccproxy_api-0.1.1.dist-info/RECORD +0 -149
- /ccproxy/scheduler/{exceptions.py → errors.py} +0 -0
- {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/WHEEL +0 -0
- {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/licenses/LICENSE +0 -0
ccproxy/claude_sdk/converter.py
CHANGED
|
@@ -1,24 +1,22 @@
|
|
|
1
1
|
"""Message format converter for Claude SDK interactions."""
|
|
2
2
|
|
|
3
|
+
import html
|
|
3
4
|
import json
|
|
4
|
-
from
|
|
5
|
-
from
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
6
7
|
|
|
7
8
|
import structlog
|
|
8
9
|
|
|
10
|
+
from ccproxy.config.claude import SDKMessageMode
|
|
9
11
|
from ccproxy.core.async_utils import patched_typing
|
|
12
|
+
from ccproxy.models import claude_sdk as sdk_models
|
|
13
|
+
from ccproxy.models.messages import MessageResponse
|
|
10
14
|
|
|
11
15
|
|
|
12
16
|
logger = structlog.get_logger(__name__)
|
|
13
17
|
|
|
14
18
|
with patched_typing():
|
|
15
|
-
|
|
16
|
-
AssistantMessage,
|
|
17
|
-
ResultMessage,
|
|
18
|
-
TextBlock,
|
|
19
|
-
ToolResultBlock,
|
|
20
|
-
ToolUseBlock,
|
|
21
|
-
)
|
|
19
|
+
pass
|
|
22
20
|
|
|
23
21
|
|
|
24
22
|
class MessageConverter:
|
|
@@ -26,6 +24,126 @@ class MessageConverter:
|
|
|
26
24
|
Handles conversion between Anthropic API format and Claude SDK format.
|
|
27
25
|
"""
|
|
28
26
|
|
|
27
|
+
@staticmethod
|
|
28
|
+
def _format_json_data(
|
|
29
|
+
data: dict[str, Any],
|
|
30
|
+
pretty_format: bool = True,
|
|
31
|
+
) -> str:
|
|
32
|
+
"""
|
|
33
|
+
Format JSON data with optional indentation and newlines.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
data: Dictionary to format as JSON
|
|
37
|
+
pretty_format: Whether to use pretty formatting (indented JSON with spacing)
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Formatted JSON string
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
if pretty_format:
|
|
44
|
+
# Pretty format with indentation and proper spacing
|
|
45
|
+
return json.dumps(data, indent=2, separators=(", ", ": "))
|
|
46
|
+
else:
|
|
47
|
+
# Compact format without indentation or spacing
|
|
48
|
+
return json.dumps(data, separators=(",", ":"))
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _create_xml_formatted_text(
|
|
52
|
+
data: dict[str, Any], tag_name: str, pretty_format: bool = True
|
|
53
|
+
) -> str:
|
|
54
|
+
"""
|
|
55
|
+
Create XML-formatted text from data with consistent formatting.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
data: Dictionary data to format as JSON and wrap in XML
|
|
59
|
+
tag_name: XML tag name to wrap the content
|
|
60
|
+
pretty_format: Whether to use pretty formatting
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Formatted XML string
|
|
64
|
+
"""
|
|
65
|
+
formatted_json = MessageConverter._format_json_data(data, pretty_format)
|
|
66
|
+
escaped_json = MessageConverter._escape_content_for_xml(
|
|
67
|
+
formatted_json, pretty_format
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if pretty_format:
|
|
71
|
+
return f"<{tag_name}>\n{escaped_json}\n</{tag_name}>\n"
|
|
72
|
+
else:
|
|
73
|
+
return f"<{tag_name}>{escaped_json}</{tag_name}>"
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _create_streaming_chunks_with_content(
|
|
77
|
+
content_block: dict[str, Any],
|
|
78
|
+
index: int,
|
|
79
|
+
text_content: str | None = None,
|
|
80
|
+
) -> list[tuple[str, dict[str, Any]]]:
|
|
81
|
+
"""
|
|
82
|
+
Create streaming chunks with optional text delta content.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
content_block: Content block for content_block_start
|
|
86
|
+
index: Content block index
|
|
87
|
+
text_content: Optional text content for content_block_delta
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
List of streaming chunks
|
|
91
|
+
"""
|
|
92
|
+
chunks = [
|
|
93
|
+
(
|
|
94
|
+
"content_block_start",
|
|
95
|
+
{
|
|
96
|
+
"type": "content_block_start",
|
|
97
|
+
"index": index,
|
|
98
|
+
"content_block": content_block,
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
if text_content is not None:
|
|
104
|
+
chunks.append(
|
|
105
|
+
(
|
|
106
|
+
"content_block_delta",
|
|
107
|
+
{
|
|
108
|
+
"type": "content_block_delta",
|
|
109
|
+
"index": index,
|
|
110
|
+
"delta": {"type": "text_delta", "text": text_content},
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
chunks.append(
|
|
116
|
+
(
|
|
117
|
+
"content_block_stop",
|
|
118
|
+
{
|
|
119
|
+
"type": "content_block_stop",
|
|
120
|
+
"index": index,
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return chunks
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _escape_content_for_xml(content: str, pretty_format: bool = True) -> str:
|
|
129
|
+
"""
|
|
130
|
+
Escape content for inclusion in XML tags.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
content: Content to escape
|
|
134
|
+
pretty_format: Whether to use pretty formatting (no escaping) or compact (escaped)
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Escaped or unescaped content based on formatting mode
|
|
138
|
+
"""
|
|
139
|
+
if pretty_format:
|
|
140
|
+
# Pretty format: no escaping, content as-is
|
|
141
|
+
return content
|
|
142
|
+
else:
|
|
143
|
+
# Compact format: escape special XML characters
|
|
144
|
+
|
|
145
|
+
return html.escape(content)
|
|
146
|
+
|
|
29
147
|
@staticmethod
|
|
30
148
|
def format_messages_to_prompt(messages: list[dict[str, Any]]) -> str:
|
|
31
149
|
"""
|
|
@@ -61,62 +179,14 @@ class MessageConverter:
|
|
|
61
179
|
|
|
62
180
|
return "\n\n".join(prompt_parts)
|
|
63
181
|
|
|
64
|
-
@staticmethod
|
|
65
|
-
def extract_text_from_content(
|
|
66
|
-
content: TextBlock | ToolUseBlock | ToolResultBlock,
|
|
67
|
-
) -> str:
|
|
68
|
-
"""
|
|
69
|
-
Extract text content from Claude SDK content blocks.
|
|
70
|
-
|
|
71
|
-
Args:
|
|
72
|
-
content: List of content blocks from Claude SDK
|
|
73
|
-
|
|
74
|
-
Returns:
|
|
75
|
-
Extracted text content
|
|
76
|
-
"""
|
|
77
|
-
if isinstance(content, TextBlock):
|
|
78
|
-
return content.text
|
|
79
|
-
elif isinstance(content, ToolUseBlock):
|
|
80
|
-
# Return full XML representation of ToolUseBlock
|
|
81
|
-
tool_id = escape(str(getattr(content, "id", f"tool_{id(content)}")))
|
|
82
|
-
tool_name = escape(content.name)
|
|
83
|
-
tool_input = getattr(content, "input", {}) or {}
|
|
84
|
-
# Convert input dict to JSON string and escape for XML
|
|
85
|
-
input_json = escape(json.dumps(tool_input, ensure_ascii=False))
|
|
86
|
-
return f'<tooluseblock id="{tool_id}" name="{tool_name}">{input_json}</tooluseblock>'
|
|
87
|
-
elif isinstance(content, ToolResultBlock):
|
|
88
|
-
# Return full XML representation of ToolResultBlock
|
|
89
|
-
tool_use_id = escape(str(getattr(content, "tool_use_id", "")))
|
|
90
|
-
result_content = content.content if isinstance(content.content, str) else ""
|
|
91
|
-
escaped_content = escape(result_content)
|
|
92
|
-
return f'<toolresultblock tool_use_id="{tool_use_id}">{escaped_content}</toolresultblock>'
|
|
93
|
-
|
|
94
|
-
@staticmethod
|
|
95
|
-
def extract_contents(
|
|
96
|
-
contents: list[TextBlock | ToolUseBlock | ToolResultBlock],
|
|
97
|
-
) -> str:
|
|
98
|
-
"""
|
|
99
|
-
Extract content from Claude SDK blocks, preserving custom blocks.
|
|
100
|
-
|
|
101
|
-
Args:
|
|
102
|
-
content: List of content blocks from Claude SDK
|
|
103
|
-
|
|
104
|
-
Returns:
|
|
105
|
-
Content with thinking blocks preserved
|
|
106
|
-
"""
|
|
107
|
-
text_parts = []
|
|
108
|
-
|
|
109
|
-
for block in contents:
|
|
110
|
-
text_parts.append(MessageConverter.extract_text_from_content(block))
|
|
111
|
-
|
|
112
|
-
return " ".join(text_parts)
|
|
113
|
-
|
|
114
182
|
@staticmethod
|
|
115
183
|
def convert_to_anthropic_response(
|
|
116
|
-
assistant_message: AssistantMessage,
|
|
117
|
-
result_message: ResultMessage,
|
|
184
|
+
assistant_message: sdk_models.AssistantMessage,
|
|
185
|
+
result_message: sdk_models.ResultMessage,
|
|
118
186
|
model: str,
|
|
119
|
-
|
|
187
|
+
mode: SDKMessageMode = SDKMessageMode.FORWARD,
|
|
188
|
+
pretty_format: bool = True,
|
|
189
|
+
) -> "MessageResponse":
|
|
120
190
|
"""
|
|
121
191
|
Convert Claude SDK messages to Anthropic API response format.
|
|
122
192
|
|
|
@@ -124,180 +194,180 @@ class MessageConverter:
|
|
|
124
194
|
assistant_message: The assistant message from Claude SDK
|
|
125
195
|
result_message: The result message from Claude SDK
|
|
126
196
|
model: The model name used
|
|
197
|
+
mode: System message handling mode (forward, ignore, formatted)
|
|
198
|
+
pretty_format: Whether to use pretty formatting (true: indented JSON with newlines, false: compact with escaped content)
|
|
127
199
|
|
|
128
200
|
Returns:
|
|
129
201
|
Response in Anthropic API format
|
|
130
202
|
"""
|
|
131
203
|
# Extract token usage from result message
|
|
132
|
-
|
|
133
|
-
usage = getattr(result_message, "usage", {})
|
|
134
|
-
if usage:
|
|
135
|
-
input_tokens = usage.get("input_tokens", 0)
|
|
136
|
-
output_tokens = usage.get("output_tokens", 0)
|
|
137
|
-
cache_read_tokens = usage.get("cache_read_input_tokens", 0)
|
|
138
|
-
cache_write_tokens = usage.get("cache_creation_input_tokens", 0)
|
|
139
|
-
else:
|
|
140
|
-
# Fallback to direct attributes
|
|
141
|
-
input_tokens = getattr(result_message, "input_tokens", 0)
|
|
142
|
-
output_tokens = getattr(result_message, "output_tokens", 0)
|
|
143
|
-
cache_read_tokens = getattr(result_message, "cache_read_tokens", 0)
|
|
144
|
-
cache_write_tokens = getattr(result_message, "cache_write_tokens", 0)
|
|
204
|
+
usage = result_message.usage_model
|
|
145
205
|
|
|
146
206
|
# Log token extraction for debugging
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
"assistant_message_content",
|
|
153
|
-
content_blocks=[
|
|
154
|
-
type(block).__name__ for block in assistant_message.content
|
|
155
|
-
],
|
|
156
|
-
content_count=len(assistant_message.content),
|
|
157
|
-
first_block_text=(
|
|
158
|
-
assistant_message.content[0].text[:100]
|
|
159
|
-
if assistant_message.content
|
|
160
|
-
and hasattr(assistant_message.content[0], "text")
|
|
161
|
-
else None
|
|
162
|
-
),
|
|
163
|
-
)
|
|
207
|
+
# logger.debug(
|
|
208
|
+
# "assistant_message_content",
|
|
209
|
+
# content_blocks=[block.type for block in assistant_message.content],
|
|
210
|
+
# content_count=len(assistant_message.content),
|
|
211
|
+
# )
|
|
164
212
|
|
|
165
213
|
logger.debug(
|
|
166
214
|
"token_usage_extracted",
|
|
167
|
-
input_tokens=input_tokens,
|
|
168
|
-
output_tokens=output_tokens,
|
|
169
|
-
cache_read_tokens=
|
|
170
|
-
cache_write_tokens=
|
|
215
|
+
input_tokens=usage.input_tokens,
|
|
216
|
+
output_tokens=usage.output_tokens,
|
|
217
|
+
cache_read_tokens=usage.cache_read_input_tokens,
|
|
218
|
+
cache_write_tokens=usage.cache_creation_input_tokens,
|
|
171
219
|
source="claude_sdk",
|
|
172
220
|
)
|
|
173
221
|
|
|
174
|
-
# Calculate total tokens
|
|
175
|
-
total_tokens = input_tokens + output_tokens
|
|
176
|
-
|
|
177
222
|
# Build usage information
|
|
178
|
-
usage_info =
|
|
179
|
-
"input_tokens": input_tokens,
|
|
180
|
-
"output_tokens": output_tokens,
|
|
181
|
-
"cache_read_tokens": cache_read_tokens,
|
|
182
|
-
"cache_write_tokens": cache_write_tokens,
|
|
183
|
-
"total_tokens": total_tokens,
|
|
184
|
-
}
|
|
223
|
+
usage_info = usage.model_dump(mode="json")
|
|
185
224
|
|
|
186
225
|
# Add cost information if available
|
|
187
|
-
total_cost_usd
|
|
188
|
-
|
|
189
|
-
usage_info["cost_usd"] = total_cost_usd
|
|
226
|
+
if result_message.total_cost_usd is not None:
|
|
227
|
+
usage_info["cost_usd"] = result_message.total_cost_usd
|
|
190
228
|
|
|
191
229
|
# Convert content blocks to Anthropic format, preserving thinking blocks
|
|
192
230
|
content_blocks = []
|
|
193
231
|
|
|
194
232
|
for block in assistant_message.content:
|
|
195
|
-
if isinstance(block, TextBlock):
|
|
196
|
-
#
|
|
233
|
+
if isinstance(block, sdk_models.TextBlock):
|
|
234
|
+
# Handle text content directly without thinking block parsing
|
|
197
235
|
text = block.text
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
thinking_pattern = r'<thinking signature="([^"]*)">(.*?)</thinking>'
|
|
203
|
-
|
|
204
|
-
# Split the text by thinking blocks
|
|
205
|
-
last_end = 0
|
|
206
|
-
for match in re.finditer(thinking_pattern, text, re.DOTALL):
|
|
207
|
-
# Add any text before the thinking block
|
|
208
|
-
before_text = text[last_end : match.start()].strip()
|
|
209
|
-
if before_text:
|
|
210
|
-
content_blocks.append({"type": "text", "text": before_text})
|
|
211
|
-
|
|
212
|
-
# Add the thinking block
|
|
213
|
-
signature, thinking_text = match.groups()
|
|
214
|
-
content_blocks.append(
|
|
215
|
-
{
|
|
216
|
-
"type": "thinking",
|
|
217
|
-
"text": thinking_text,
|
|
218
|
-
"signature": signature,
|
|
219
|
-
}
|
|
236
|
+
if mode == SDKMessageMode.FORMATTED:
|
|
237
|
+
escaped_text = MessageConverter._escape_content_for_xml(
|
|
238
|
+
text, pretty_format
|
|
220
239
|
)
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
# If no thinking blocks were found, add the entire text as a text block
|
|
230
|
-
if last_end == 0 and text:
|
|
240
|
+
formatted_text = (
|
|
241
|
+
f"<text>\n{escaped_text}\n</text>\n"
|
|
242
|
+
if pretty_format
|
|
243
|
+
else f"<text>{escaped_text}</text>"
|
|
244
|
+
)
|
|
245
|
+
content_blocks.append({"type": "text", "text": formatted_text})
|
|
246
|
+
else:
|
|
231
247
|
content_blocks.append({"type": "text", "text": text})
|
|
232
248
|
|
|
233
|
-
elif isinstance(block, ToolUseBlock):
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
"id": getattr(block, "id", f"tool_{id(block)}"),
|
|
241
|
-
"name": block.name,
|
|
242
|
-
"input": tool_input,
|
|
243
|
-
},
|
|
249
|
+
elif isinstance(block, sdk_models.ToolUseBlock):
|
|
250
|
+
if mode == SDKMessageMode.FORWARD:
|
|
251
|
+
content_blocks.append(block.to_sdk_block())
|
|
252
|
+
elif mode == SDKMessageMode.FORMATTED:
|
|
253
|
+
tool_data = block.model_dump(mode="json")
|
|
254
|
+
formatted_json = MessageConverter._format_json_data(
|
|
255
|
+
tool_data, pretty_format
|
|
244
256
|
)
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
"
|
|
250
|
-
|
|
251
|
-
"
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
257
|
+
escaped_json = MessageConverter._escape_content_for_xml(
|
|
258
|
+
formatted_json, pretty_format
|
|
259
|
+
)
|
|
260
|
+
formatted_text = (
|
|
261
|
+
f"<tool_use_sdk>\n{escaped_json}\n</tool_use_sdk>\n"
|
|
262
|
+
if pretty_format
|
|
263
|
+
else f"<tool_use_sdk>{escaped_json}</tool_use_sdk>"
|
|
264
|
+
)
|
|
265
|
+
content_blocks.append({"type": "text", "text": formatted_text})
|
|
266
|
+
|
|
267
|
+
elif isinstance(block, sdk_models.ToolResultBlock):
|
|
268
|
+
if mode == SDKMessageMode.FORWARD:
|
|
269
|
+
content_blocks.append(block.to_sdk_block())
|
|
270
|
+
elif mode == SDKMessageMode.FORMATTED:
|
|
271
|
+
tool_result_data = block.model_dump(mode="json")
|
|
272
|
+
formatted_json = MessageConverter._format_json_data(
|
|
273
|
+
tool_result_data, pretty_format
|
|
274
|
+
)
|
|
275
|
+
escaped_json = MessageConverter._escape_content_for_xml(
|
|
276
|
+
formatted_json, pretty_format
|
|
277
|
+
)
|
|
278
|
+
formatted_text = (
|
|
279
|
+
f"<tool_result_sdk>\n{escaped_json}\n</tool_result_sdk>\n"
|
|
280
|
+
if pretty_format
|
|
281
|
+
else f"<tool_result_sdk>{escaped_json}</tool_result_sdk>"
|
|
282
|
+
)
|
|
283
|
+
content_blocks.append({"type": "text", "text": formatted_text})
|
|
256
284
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
285
|
+
elif isinstance(block, sdk_models.ThinkingBlock):
|
|
286
|
+
if mode == SDKMessageMode.FORWARD:
|
|
287
|
+
thinking_block = {
|
|
288
|
+
"type": "thinking",
|
|
289
|
+
"thinking": block.thinking,
|
|
290
|
+
}
|
|
291
|
+
if block.signature is not None:
|
|
292
|
+
thinking_block["signature"] = block.signature
|
|
293
|
+
content_blocks.append(thinking_block)
|
|
294
|
+
elif mode == SDKMessageMode.FORMATTED:
|
|
295
|
+
# Format thinking block with signature in XML tag attribute
|
|
296
|
+
signature_attr = (
|
|
297
|
+
f' signature="{block.signature}"' if block.signature else ""
|
|
298
|
+
)
|
|
299
|
+
if pretty_format:
|
|
300
|
+
escaped_text = MessageConverter._escape_content_for_xml(
|
|
301
|
+
block.thinking, pretty_format
|
|
302
|
+
)
|
|
303
|
+
formatted_text = (
|
|
304
|
+
f"<thinking{signature_attr}>\n{escaped_text}\n</thinking>\n"
|
|
305
|
+
)
|
|
306
|
+
else:
|
|
307
|
+
escaped_text = MessageConverter._escape_content_for_xml(
|
|
308
|
+
block.thinking, pretty_format
|
|
309
|
+
)
|
|
310
|
+
formatted_text = (
|
|
311
|
+
f"<thinking{signature_attr}>{escaped_text}</thinking>"
|
|
312
|
+
)
|
|
313
|
+
content_blocks.append({"type": "text", "text": formatted_text})
|
|
314
|
+
|
|
315
|
+
return MessageResponse.model_validate(
|
|
316
|
+
{
|
|
317
|
+
"id": f"msg_{result_message.session_id}",
|
|
318
|
+
"type": "message",
|
|
319
|
+
"role": "assistant",
|
|
320
|
+
"content": content_blocks,
|
|
321
|
+
"model": model,
|
|
322
|
+
"stop_reason": result_message.stop_reason,
|
|
323
|
+
"stop_sequence": None,
|
|
324
|
+
"usage": usage_info,
|
|
325
|
+
}
|
|
326
|
+
)
|
|
267
327
|
|
|
268
328
|
@staticmethod
|
|
269
|
-
def
|
|
329
|
+
def create_streaming_start_chunks(
|
|
330
|
+
message_id: str, model: str, input_tokens: int = 0
|
|
331
|
+
) -> list[tuple[str, dict[str, Any]]]:
|
|
270
332
|
"""
|
|
271
|
-
Create the initial streaming
|
|
333
|
+
Create the initial streaming chunks for Anthropic API format.
|
|
272
334
|
|
|
273
335
|
Args:
|
|
274
336
|
message_id: The message ID
|
|
275
337
|
model: The model name
|
|
338
|
+
input_tokens: Number of input tokens for the request
|
|
276
339
|
|
|
277
340
|
Returns:
|
|
278
|
-
|
|
341
|
+
List of tuples (event_type, chunk) for initial streaming chunks
|
|
279
342
|
"""
|
|
280
|
-
return
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
343
|
+
return [
|
|
344
|
+
# First, send message_start with event type
|
|
345
|
+
(
|
|
346
|
+
"message_start",
|
|
347
|
+
{
|
|
348
|
+
"type": "message_start",
|
|
349
|
+
"message": {
|
|
350
|
+
"id": message_id,
|
|
351
|
+
"type": "message",
|
|
352
|
+
"role": "assistant",
|
|
353
|
+
"model": model,
|
|
354
|
+
"content": [],
|
|
355
|
+
"stop_reason": None,
|
|
356
|
+
"stop_sequence": None,
|
|
357
|
+
"usage": {
|
|
358
|
+
"input_tokens": input_tokens,
|
|
359
|
+
"cache_creation_input_tokens": 0,
|
|
360
|
+
"cache_read_input_tokens": 0,
|
|
361
|
+
"output_tokens": 1,
|
|
362
|
+
"service_tier": "standard",
|
|
363
|
+
},
|
|
364
|
+
},
|
|
295
365
|
},
|
|
296
|
-
|
|
297
|
-
|
|
366
|
+
),
|
|
367
|
+
]
|
|
298
368
|
|
|
299
369
|
@staticmethod
|
|
300
|
-
def create_streaming_delta_chunk(text: str) -> dict[str, Any]:
|
|
370
|
+
def create_streaming_delta_chunk(text: str) -> tuple[str, dict[str, Any]]:
|
|
301
371
|
"""
|
|
302
372
|
Create a streaming delta chunk for Anthropic API format.
|
|
303
373
|
|
|
@@ -305,27 +375,152 @@ class MessageConverter:
|
|
|
305
375
|
text: The text content to include
|
|
306
376
|
|
|
307
377
|
Returns:
|
|
308
|
-
|
|
378
|
+
Tuple of (event_type, chunk)
|
|
309
379
|
"""
|
|
310
|
-
return
|
|
311
|
-
"
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
380
|
+
return (
|
|
381
|
+
"content_block_delta",
|
|
382
|
+
{
|
|
383
|
+
"type": "content_block_delta",
|
|
384
|
+
"index": 0,
|
|
385
|
+
"delta": {"type": "text_delta", "text": text},
|
|
386
|
+
},
|
|
387
|
+
)
|
|
315
388
|
|
|
316
389
|
@staticmethod
|
|
317
|
-
def
|
|
390
|
+
def create_streaming_end_chunks(
|
|
391
|
+
stop_reason: str = "end_turn", stop_sequence: str | None = None
|
|
392
|
+
) -> list[tuple[str, dict[str, Any]]]:
|
|
318
393
|
"""
|
|
319
|
-
Create the final streaming
|
|
394
|
+
Create the final streaming chunks for Anthropic API format.
|
|
320
395
|
|
|
321
396
|
Args:
|
|
322
397
|
stop_reason: The reason for stopping
|
|
398
|
+
stop_sequence: The stop sequence used (if any)
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
List of tuples (event_type, chunk) for final streaming chunks
|
|
402
|
+
"""
|
|
403
|
+
return [
|
|
404
|
+
# Then, send message_delta with stop reason and usage
|
|
405
|
+
(
|
|
406
|
+
"message_delta",
|
|
407
|
+
{
|
|
408
|
+
"type": "message_delta",
|
|
409
|
+
"delta": {
|
|
410
|
+
"stop_reason": stop_reason,
|
|
411
|
+
"stop_sequence": stop_sequence,
|
|
412
|
+
},
|
|
413
|
+
"usage": {"output_tokens": 0},
|
|
414
|
+
},
|
|
415
|
+
),
|
|
416
|
+
# Finally, send message_stop
|
|
417
|
+
("message_stop", {"type": "message_stop"}),
|
|
418
|
+
]
|
|
419
|
+
|
|
420
|
+
@staticmethod
|
|
421
|
+
def create_ping_chunk() -> tuple[str, dict[str, Any]]:
|
|
422
|
+
"""
|
|
423
|
+
Create a ping chunk for keeping the connection alive.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
Tuple of (event_type, chunk)
|
|
427
|
+
"""
|
|
428
|
+
return ("ping", {"type": "ping"})
|
|
429
|
+
|
|
430
|
+
@staticmethod
|
|
431
|
+
def _create_sdk_content_block(
|
|
432
|
+
sdk_object: sdk_models.SystemMessage
|
|
433
|
+
| sdk_models.ToolUseBlock
|
|
434
|
+
| sdk_models.ToolResultBlock
|
|
435
|
+
| sdk_models.ResultMessage,
|
|
436
|
+
mode: SDKMessageMode = SDKMessageMode.FORWARD,
|
|
437
|
+
pretty_format: bool = True,
|
|
438
|
+
xml_tag: str = "sdk_block",
|
|
439
|
+
forward_converter: Callable[[Any], dict[str, Any]] | None = None,
|
|
440
|
+
) -> dict[str, Any] | None:
|
|
441
|
+
"""
|
|
442
|
+
Generic method to create content blocks for SDK objects in non-streaming responses.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
sdk_object: The SDK object to convert
|
|
446
|
+
mode: System message handling mode
|
|
447
|
+
pretty_format: Whether to use pretty formatting
|
|
448
|
+
xml_tag: XML tag name for FORMATTED mode
|
|
449
|
+
forward_converter: Optional converter function for FORWARD mode
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
Content block dict for the SDK object, or None if mode is IGNORE
|
|
453
|
+
"""
|
|
454
|
+
if mode == SDKMessageMode.IGNORE:
|
|
455
|
+
return None
|
|
456
|
+
elif mode == SDKMessageMode.FORWARD:
|
|
457
|
+
if forward_converter:
|
|
458
|
+
return forward_converter(sdk_object)
|
|
459
|
+
else:
|
|
460
|
+
return sdk_object.model_dump(mode="json")
|
|
461
|
+
elif mode == SDKMessageMode.FORMATTED:
|
|
462
|
+
object_data = sdk_object.model_dump(mode="json")
|
|
463
|
+
formatted_json = MessageConverter._format_json_data(
|
|
464
|
+
object_data, pretty_format
|
|
465
|
+
)
|
|
466
|
+
escaped_json = MessageConverter._escape_content_for_xml(
|
|
467
|
+
formatted_json, pretty_format
|
|
468
|
+
)
|
|
469
|
+
formatted_text = (
|
|
470
|
+
f"<{xml_tag}>\n{escaped_json}\n</{xml_tag}>\n"
|
|
471
|
+
if pretty_format
|
|
472
|
+
else f"<{xml_tag}>{escaped_json}</{xml_tag}>"
|
|
473
|
+
)
|
|
474
|
+
return {
|
|
475
|
+
"type": "text",
|
|
476
|
+
"text": formatted_text,
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
@staticmethod
|
|
480
|
+
def _create_sdk_content_block_chunks(
|
|
481
|
+
sdk_object: sdk_models.SystemMessage
|
|
482
|
+
| sdk_models.ToolUseBlock
|
|
483
|
+
| sdk_models.ToolResultBlock
|
|
484
|
+
| sdk_models.ResultMessage,
|
|
485
|
+
mode: SDKMessageMode = SDKMessageMode.FORWARD,
|
|
486
|
+
index: int = 0,
|
|
487
|
+
pretty_format: bool = True,
|
|
488
|
+
xml_tag: str = "sdk_block",
|
|
489
|
+
sdk_block_converter: Callable[[Any], dict[str, Any]] | None = None,
|
|
490
|
+
) -> list[tuple[str, dict[str, Any]]]:
|
|
491
|
+
"""
|
|
492
|
+
Generic method to create streaming chunks for SDK content blocks.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
sdk_object: The SDK object (SystemMessage, ToolUseBlock, or ToolResultBlock)
|
|
496
|
+
mode: System message handling mode
|
|
497
|
+
index: The content block index
|
|
498
|
+
pretty_format: Whether to use pretty formatting
|
|
499
|
+
xml_tag: XML tag name for FORMATTED mode
|
|
500
|
+
sdk_block_converter: Optional converter function for FORWARD mode
|
|
323
501
|
|
|
324
502
|
Returns:
|
|
325
|
-
|
|
503
|
+
List of tuples (event_type, chunk) for streaming chunks
|
|
326
504
|
"""
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
505
|
+
if mode == SDKMessageMode.IGNORE:
|
|
506
|
+
return []
|
|
507
|
+
elif mode == SDKMessageMode.FORWARD:
|
|
508
|
+
content_block = (
|
|
509
|
+
sdk_block_converter(sdk_object)
|
|
510
|
+
if sdk_block_converter
|
|
511
|
+
else sdk_object.model_dump(mode="json")
|
|
512
|
+
)
|
|
513
|
+
return MessageConverter._create_streaming_chunks_with_content(
|
|
514
|
+
content_block=content_block,
|
|
515
|
+
index=index,
|
|
516
|
+
)
|
|
517
|
+
elif mode == SDKMessageMode.FORMATTED:
|
|
518
|
+
object_data = sdk_object.model_dump(mode="json")
|
|
519
|
+
formatted_text = MessageConverter._create_xml_formatted_text(
|
|
520
|
+
object_data, xml_tag, pretty_format
|
|
521
|
+
)
|
|
522
|
+
return MessageConverter._create_streaming_chunks_with_content(
|
|
523
|
+
content_block={"type": "text", "text": ""},
|
|
524
|
+
index=index,
|
|
525
|
+
text_content=formatted_text,
|
|
526
|
+
)
|