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.
Files changed (107) hide show
  1. ccproxy/_version.py +2 -2
  2. ccproxy/adapters/openai/__init__.py +1 -2
  3. ccproxy/adapters/openai/adapter.py +218 -180
  4. ccproxy/adapters/openai/streaming.py +247 -65
  5. ccproxy/api/__init__.py +0 -3
  6. ccproxy/api/app.py +173 -40
  7. ccproxy/api/dependencies.py +65 -3
  8. ccproxy/api/middleware/errors.py +3 -7
  9. ccproxy/api/middleware/headers.py +0 -2
  10. ccproxy/api/middleware/logging.py +4 -3
  11. ccproxy/api/middleware/request_content_logging.py +297 -0
  12. ccproxy/api/middleware/request_id.py +5 -0
  13. ccproxy/api/middleware/server_header.py +0 -4
  14. ccproxy/api/routes/__init__.py +9 -1
  15. ccproxy/api/routes/claude.py +23 -32
  16. ccproxy/api/routes/health.py +58 -4
  17. ccproxy/api/routes/mcp.py +171 -0
  18. ccproxy/api/routes/metrics.py +4 -8
  19. ccproxy/api/routes/permissions.py +217 -0
  20. ccproxy/api/routes/proxy.py +0 -53
  21. ccproxy/api/services/__init__.py +6 -0
  22. ccproxy/api/services/permission_service.py +368 -0
  23. ccproxy/api/ui/__init__.py +6 -0
  24. ccproxy/api/ui/permission_handler_protocol.py +33 -0
  25. ccproxy/api/ui/terminal_permission_handler.py +593 -0
  26. ccproxy/auth/conditional.py +2 -2
  27. ccproxy/auth/dependencies.py +1 -1
  28. ccproxy/auth/oauth/models.py +0 -1
  29. ccproxy/auth/oauth/routes.py +1 -3
  30. ccproxy/auth/storage/json_file.py +0 -1
  31. ccproxy/auth/storage/keyring.py +0 -3
  32. ccproxy/claude_sdk/__init__.py +2 -0
  33. ccproxy/claude_sdk/client.py +91 -8
  34. ccproxy/claude_sdk/converter.py +405 -210
  35. ccproxy/claude_sdk/options.py +88 -19
  36. ccproxy/claude_sdk/parser.py +200 -0
  37. ccproxy/claude_sdk/streaming.py +286 -0
  38. ccproxy/cli/commands/__init__.py +5 -1
  39. ccproxy/cli/commands/auth.py +2 -4
  40. ccproxy/cli/commands/permission_handler.py +553 -0
  41. ccproxy/cli/commands/serve.py +52 -12
  42. ccproxy/cli/docker/params.py +0 -4
  43. ccproxy/cli/helpers.py +0 -2
  44. ccproxy/cli/main.py +6 -17
  45. ccproxy/cli/options/claude_options.py +41 -1
  46. ccproxy/cli/options/core_options.py +0 -3
  47. ccproxy/cli/options/security_options.py +0 -2
  48. ccproxy/cli/options/server_options.py +3 -2
  49. ccproxy/config/auth.py +0 -1
  50. ccproxy/config/claude.py +78 -2
  51. ccproxy/config/discovery.py +0 -1
  52. ccproxy/config/docker_settings.py +0 -1
  53. ccproxy/config/loader.py +1 -4
  54. ccproxy/config/scheduler.py +20 -0
  55. ccproxy/config/security.py +7 -2
  56. ccproxy/config/server.py +5 -0
  57. ccproxy/config/settings.py +15 -7
  58. ccproxy/config/validators.py +1 -1
  59. ccproxy/core/async_utils.py +1 -4
  60. ccproxy/core/errors.py +45 -1
  61. ccproxy/core/http_transformers.py +4 -3
  62. ccproxy/core/interfaces.py +2 -2
  63. ccproxy/core/logging.py +97 -95
  64. ccproxy/core/middleware.py +1 -1
  65. ccproxy/core/proxy.py +1 -1
  66. ccproxy/core/transformers.py +1 -1
  67. ccproxy/core/types.py +1 -1
  68. ccproxy/docker/models.py +1 -1
  69. ccproxy/docker/protocol.py +0 -3
  70. ccproxy/models/__init__.py +41 -0
  71. ccproxy/models/claude_sdk.py +420 -0
  72. ccproxy/models/messages.py +45 -18
  73. ccproxy/models/permissions.py +115 -0
  74. ccproxy/models/requests.py +1 -1
  75. ccproxy/models/responses.py +64 -1
  76. ccproxy/observability/access_logger.py +1 -2
  77. ccproxy/observability/context.py +17 -1
  78. ccproxy/observability/metrics.py +1 -3
  79. ccproxy/observability/pushgateway.py +0 -2
  80. ccproxy/observability/stats_printer.py +2 -4
  81. ccproxy/observability/storage/duckdb_simple.py +1 -1
  82. ccproxy/observability/storage/models.py +0 -1
  83. ccproxy/pricing/cache.py +0 -1
  84. ccproxy/pricing/loader.py +5 -21
  85. ccproxy/pricing/updater.py +0 -1
  86. ccproxy/scheduler/__init__.py +1 -0
  87. ccproxy/scheduler/core.py +6 -6
  88. ccproxy/scheduler/manager.py +35 -7
  89. ccproxy/scheduler/registry.py +1 -1
  90. ccproxy/scheduler/tasks.py +127 -2
  91. ccproxy/services/claude_sdk_service.py +225 -329
  92. ccproxy/services/credentials/manager.py +0 -1
  93. ccproxy/services/credentials/oauth_client.py +1 -2
  94. ccproxy/services/proxy_service.py +93 -222
  95. ccproxy/testing/config.py +1 -1
  96. ccproxy/testing/mock_responses.py +0 -1
  97. ccproxy/utils/model_mapping.py +197 -0
  98. ccproxy/utils/models_provider.py +150 -0
  99. ccproxy/utils/simple_request_logger.py +284 -0
  100. ccproxy/utils/version_checker.py +184 -0
  101. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/METADATA +63 -2
  102. ccproxy_api-0.1.3.dist-info/RECORD +166 -0
  103. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/entry_points.txt +1 -0
  104. ccproxy_api-0.1.1.dist-info/RECORD +0 -149
  105. /ccproxy/scheduler/{exceptions.py → errors.py} +0 -0
  106. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/WHEEL +0 -0
  107. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/licenses/LICENSE +0 -0
@@ -1,24 +1,22 @@
1
1
  """Message format converter for Claude SDK interactions."""
2
2
 
3
+ import html
3
4
  import json
4
- from typing import Any, cast
5
- from xml.sax.saxutils import escape
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
- from claude_code_sdk import (
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
- ) -> dict[str, Any]:
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
- # First try to get usage from the usage field (preferred method)
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
- from structlog import get_logger
148
-
149
- logger = get_logger(__name__)
150
-
151
- logger.debug(
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=cache_read_tokens,
170
- cache_write_tokens=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 = getattr(result_message, "total_cost_usd", None)
188
- if total_cost_usd is not None:
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
- # Parse text content for thinking blocks
233
+ if isinstance(block, sdk_models.TextBlock):
234
+ # Handle text content directly without thinking block parsing
197
235
  text = block.text
198
-
199
- # Check if the text contains thinking blocks
200
- import re
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
- last_end = match.end()
223
-
224
- # Add any remaining text after the last thinking block
225
- remaining_text = text[last_end:].strip()
226
- if remaining_text:
227
- content_blocks.append({"type": "text", "text": remaining_text})
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
- tool_input = getattr(block, "input", {}) or {}
235
- content_blocks.append(
236
- cast(
237
- dict[str, Any],
238
- {
239
- "type": "tool_use",
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
- elif isinstance(block, ToolResultBlock):
247
- content_blocks.append(
248
- {
249
- "type": "tool_result",
250
- "tool_use_id": getattr(block, "tool_use_id", ""),
251
- "content": block.content
252
- if isinstance(block.content, str)
253
- else "",
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
- return {
258
- "id": f"msg_{result_message.session_id}",
259
- "type": "message",
260
- "role": "assistant",
261
- "content": content_blocks,
262
- "model": model,
263
- "stop_reason": getattr(result_message, "stop_reason", "end_turn"),
264
- "stop_sequence": None,
265
- "usage": usage_info,
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 create_streaming_start_chunk(message_id: str, model: str) -> dict[str, Any]:
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 chunk for Anthropic API format.
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
- Initial streaming chunk
341
+ List of tuples (event_type, chunk) for initial streaming chunks
279
342
  """
280
- return {
281
- "id": message_id,
282
- "type": "message_start",
283
- "message": {
284
- "id": message_id,
285
- "type": "message",
286
- "role": "assistant",
287
- "content": [],
288
- "model": model,
289
- "stop_reason": None,
290
- "stop_sequence": None,
291
- "usage": {
292
- "input_tokens": 0,
293
- "output_tokens": 0,
294
- "total_tokens": 0,
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
- Delta chunk
378
+ Tuple of (event_type, chunk)
309
379
  """
310
- return {
311
- "type": "content_block_delta",
312
- "index": 0,
313
- "delta": {"type": "text_delta", "text": text},
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 create_streaming_end_chunk(stop_reason: str = "end_turn") -> dict[str, Any]:
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 chunk for Anthropic API format.
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
- Final streaming chunk
503
+ List of tuples (event_type, chunk) for streaming chunks
326
504
  """
327
- return {
328
- "type": "message_delta",
329
- "delta": {"stop_reason": stop_reason},
330
- "usage": {"output_tokens": 0},
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
+ )