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
@@ -2,25 +2,39 @@
2
2
 
3
3
  from typing import Any
4
4
 
5
+ import structlog
6
+
7
+ from ccproxy.config.settings import Settings
5
8
  from ccproxy.core.async_utils import patched_typing
6
9
 
7
10
 
8
11
  with patched_typing():
9
12
  from claude_code_sdk import ClaudeCodeOptions
10
13
 
14
+ logger = structlog.get_logger(__name__)
15
+
11
16
 
12
17
  class OptionsHandler:
13
18
  """
14
19
  Handles creation and management of Claude SDK options.
15
20
  """
16
21
 
17
- @staticmethod
22
+ def __init__(self, settings: Settings | None = None) -> None:
23
+ """
24
+ Initialize options handler.
25
+
26
+ Args:
27
+ settings: Application settings containing default Claude options
28
+ """
29
+ self.settings = settings
30
+
18
31
  def create_options(
32
+ self,
19
33
  model: str,
20
34
  temperature: float | None = None,
21
35
  max_tokens: int | None = None,
22
36
  system_message: str | None = None,
23
- **kwargs: Any,
37
+ **additional_options: Any,
24
38
  ) -> ClaudeCodeOptions:
25
39
  """
26
40
  Create Claude SDK options from API parameters.
@@ -30,24 +44,79 @@ class OptionsHandler:
30
44
  temperature: Temperature for response generation
31
45
  max_tokens: Maximum tokens in response
32
46
  system_message: System message to include
33
- **kwargs: Additional options
47
+ **additional_options: Additional options to set on the ClaudeCodeOptions instance
34
48
 
35
49
  Returns:
36
50
  Configured ClaudeCodeOptions instance
37
51
  """
38
- options = ClaudeCodeOptions(model=model)
39
-
40
- if temperature is not None:
41
- options.temperature = temperature # type: ignore[attr-defined]
42
-
43
- if max_tokens is not None:
44
- options.max_tokens = max_tokens # type: ignore[attr-defined]
45
-
52
+ # Start with configured defaults if available, otherwise create fresh instance
53
+ if self.settings and self.settings.claude.code_options:
54
+ # Use the configured options as base - this preserves all default settings
55
+ # including complex objects like mcp_servers and permission_prompt_tool_name
56
+ configured_opts = self.settings.claude.code_options
57
+
58
+ # Create a new instance with the same configuration
59
+ # We need to extract the configuration values properly with type safety
60
+
61
+ # Extract configuration values with proper types
62
+ mcp_servers = (
63
+ configured_opts.mcp_servers.copy()
64
+ if configured_opts.mcp_servers
65
+ else {}
66
+ )
67
+ permission_prompt_tool_name = configured_opts.permission_prompt_tool_name
68
+ max_thinking_tokens = getattr(configured_opts, "max_thinking_tokens", None)
69
+ allowed_tools = getattr(configured_opts, "allowed_tools", None)
70
+ disallowed_tools = getattr(configured_opts, "disallowed_tools", None)
71
+ cwd = getattr(configured_opts, "cwd", None)
72
+ append_system_prompt = getattr(
73
+ configured_opts, "append_system_prompt", None
74
+ )
75
+ max_turns = getattr(configured_opts, "max_turns", None)
76
+ continue_conversation = getattr(
77
+ configured_opts, "continue_conversation", None
78
+ )
79
+ permission_mode = getattr(configured_opts, "permission_mode", None)
80
+
81
+ # Build ClaudeCodeOptions with proper type handling
82
+ # Start with a basic instance and set attributes individually for type safety
83
+ options = ClaudeCodeOptions(
84
+ mcp_servers=mcp_servers,
85
+ permission_prompt_tool_name=permission_prompt_tool_name,
86
+ )
87
+
88
+ # Set additional attributes if they exist and are not None
89
+ if max_thinking_tokens is not None:
90
+ options.max_thinking_tokens = int(max_thinking_tokens)
91
+ if allowed_tools is not None:
92
+ options.allowed_tools = list(allowed_tools)
93
+ if disallowed_tools is not None:
94
+ options.disallowed_tools = list(disallowed_tools)
95
+ if cwd is not None:
96
+ options.cwd = cwd
97
+ if append_system_prompt is not None:
98
+ options.append_system_prompt = append_system_prompt
99
+ if max_turns is not None:
100
+ options.max_turns = max_turns
101
+ if continue_conversation is not None:
102
+ options.continue_conversation = bool(continue_conversation)
103
+ if permission_mode is not None:
104
+ options.permission_mode = permission_mode
105
+ else:
106
+ options = ClaudeCodeOptions()
107
+
108
+ # Override the model (API parameter takes precedence)
109
+ options.model = model
110
+
111
+ # Apply system message if provided (this is supported by ClaudeCodeOptions)
46
112
  if system_message is not None:
47
113
  options.system_prompt = system_message
48
114
 
49
- # Handle other options as needed
50
- for key, value in kwargs.items():
115
+ # Note: temperature and max_tokens are API-level parameters, not ClaudeCodeOptions parameters
116
+ # These are handled at the API request level, not in the options object
117
+
118
+ # Handle additional options as needed
119
+ for key, value in additional_options.items():
51
120
  if hasattr(options, key):
52
121
  setattr(options, key, value)
53
122
 
@@ -86,11 +155,11 @@ class OptionsHandler:
86
155
  List of supported model names
87
156
  """
88
157
  # Import here to avoid circular imports
89
- from ccproxy.adapters.openai.adapter import OPENAI_TO_CLAUDE_MODEL_MAPPING
158
+ from ccproxy.utils.model_mapping import get_supported_claude_models
90
159
 
91
- # Extract unique Claude models from OpenAI mapping
92
- claude_models = list(set(OPENAI_TO_CLAUDE_MODEL_MAPPING.values()))
93
- return sorted(claude_models)
160
+ # Get supported Claude models
161
+ claude_models = get_supported_claude_models()
162
+ return claude_models
94
163
 
95
164
  @staticmethod
96
165
  def validate_model(model: str) -> bool:
@@ -108,10 +177,10 @@ class OptionsHandler:
108
177
  @staticmethod
109
178
  def get_default_options() -> dict[str, Any]:
110
179
  """
111
- Get default options for Claude SDK.
180
+ Get default options for API parameters.
112
181
 
113
182
  Returns:
114
- Dictionary of default options
183
+ Dictionary of default API parameter values
115
184
  """
116
185
  return {
117
186
  "model": "claude-3-5-sonnet-20241022",
@@ -0,0 +1,200 @@
1
+ """Centralized XML parser for Claude SDK formatted content.
2
+
3
+ This module provides parsing functions for XML-formatted SDK content that appears
4
+ in Claude Code SDK responses. It consolidates the parsing logic that was previously
5
+ duplicated across OpenAI adapter and streaming components.
6
+
7
+ Currently not usedd but could be useful to rebuild message
8
+ for turn to turn conversation.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ from typing import Any
16
+
17
+ from ccproxy.adapters.openai.models import format_openai_tool_call
18
+
19
+
20
+ def parse_system_message_tags(text: str) -> str:
21
+ """Parse and format system_message XML tags.
22
+
23
+ Args:
24
+ text: Text content that may contain system_message XML tags
25
+
26
+ Returns:
27
+ Text with system_message tags converted to readable format
28
+ """
29
+ system_pattern = r"<system_message>(.*?)</system_message>"
30
+
31
+ def replace_system_message(match: re.Match[str]) -> str:
32
+ try:
33
+ system_data = json.loads(match.group(1))
34
+ source = system_data.get("source", "claude_code_sdk")
35
+ system_text = system_data.get("text", "")
36
+ return f"[{source}]: {system_text}"
37
+ except json.JSONDecodeError:
38
+ # Keep original if parsing fails
39
+ return match.group(0)
40
+
41
+ return re.sub(system_pattern, replace_system_message, text, flags=re.DOTALL)
42
+
43
+
44
+ def parse_tool_use_sdk_tags(
45
+ text: str, collect_tool_calls: bool = False
46
+ ) -> tuple[str, list[Any]]:
47
+ """Parse and format tool_use_sdk XML tags.
48
+
49
+ Args:
50
+ text: Text content that may contain tool_use_sdk XML tags
51
+ collect_tool_calls: Whether to collect tool calls for OpenAI format conversion
52
+
53
+ Returns:
54
+ Tuple of (processed_text, tool_calls_list)
55
+ """
56
+ tool_use_pattern = r"<tool_use_sdk>(.*?)</tool_use_sdk>"
57
+ tool_calls = []
58
+
59
+ def replace_tool_use(match: re.Match[str]) -> str:
60
+ try:
61
+ tool_data = json.loads(match.group(1))
62
+
63
+ if collect_tool_calls:
64
+ # For OpenAI adapter: collect tool calls and remove from text
65
+ tool_call_block = {
66
+ "type": "tool_use",
67
+ "id": tool_data.get("id", ""),
68
+ "name": tool_data.get("name", ""),
69
+ "input": tool_data.get("input", {}),
70
+ }
71
+ tool_calls.append(format_openai_tool_call(tool_call_block))
72
+ return "" # Remove the XML tag from text
73
+ else:
74
+ # For streaming: format as readable text
75
+ tool_id = tool_data.get("id", "")
76
+ tool_name = tool_data.get("name", "")
77
+ tool_input = tool_data.get("input", {})
78
+ return f"[claude_code_sdk tool_use {tool_id}]: {tool_name}({json.dumps(tool_input)})"
79
+ except json.JSONDecodeError:
80
+ # Keep original if parsing fails
81
+ return match.group(0)
82
+
83
+ processed_text = re.sub(tool_use_pattern, replace_tool_use, text, flags=re.DOTALL)
84
+ return processed_text, tool_calls
85
+
86
+
87
+ def parse_tool_result_sdk_tags(text: str) -> str:
88
+ """Parse and format tool_result_sdk XML tags.
89
+
90
+ Args:
91
+ text: Text content that may contain tool_result_sdk XML tags
92
+
93
+ Returns:
94
+ Text with tool_result_sdk tags converted to readable format
95
+ """
96
+ tool_result_pattern = r"<tool_result_sdk>(.*?)</tool_result_sdk>"
97
+
98
+ def replace_tool_result(match: re.Match[str]) -> str:
99
+ try:
100
+ result_data = json.loads(match.group(1))
101
+ tool_use_id = result_data.get("tool_use_id", "")
102
+ result_content = result_data.get("content", "")
103
+ is_error = result_data.get("is_error", False)
104
+ error_indicator = " (ERROR)" if is_error else ""
105
+ return f"[claude_code_sdk tool_result {tool_use_id}{error_indicator}]: {result_content}"
106
+ except json.JSONDecodeError:
107
+ # Keep original if parsing fails
108
+ return match.group(0)
109
+
110
+ return re.sub(tool_result_pattern, replace_tool_result, text, flags=re.DOTALL)
111
+
112
+
113
+ def parse_result_message_tags(text: str) -> str:
114
+ """Parse and format result_message XML tags.
115
+
116
+ Args:
117
+ text: Text content that may contain result_message XML tags
118
+
119
+ Returns:
120
+ Text with result_message tags converted to readable format
121
+ """
122
+ result_message_pattern = r"<result_message>(.*?)</result_message>"
123
+
124
+ def replace_result_message(match: re.Match[str]) -> str:
125
+ try:
126
+ result_data = json.loads(match.group(1))
127
+ source = result_data.get("source", "claude_code_sdk")
128
+ session_id = result_data.get("session_id", "")
129
+ stop_reason = result_data.get("stop_reason", "")
130
+ usage = result_data.get("usage", {})
131
+ cost_usd = result_data.get("total_cost_usd")
132
+
133
+ formatted_content = f"[{source} result {session_id}]: stop_reason={stop_reason}, usage={usage}"
134
+ if cost_usd is not None:
135
+ formatted_content += f", cost_usd={cost_usd}"
136
+ return formatted_content
137
+ except json.JSONDecodeError:
138
+ # Keep original if parsing fails
139
+ return match.group(0)
140
+
141
+ return re.sub(result_message_pattern, replace_result_message, text, flags=re.DOTALL)
142
+
143
+
144
+ def parse_text_tags(text: str) -> str:
145
+ """Parse and extract content from text XML tags.
146
+
147
+ Args:
148
+ text: Text content that may contain text XML tags
149
+
150
+ Returns:
151
+ Text with text tags unwrapped (inner content extracted)
152
+ """
153
+ text_pattern = r"<text>\n?(.*?)\n?</text>"
154
+
155
+ def replace_text(match: re.Match[str]) -> str:
156
+ return match.group(1).strip()
157
+
158
+ return re.sub(text_pattern, replace_text, text, flags=re.DOTALL)
159
+
160
+
161
+ def parse_formatted_sdk_content(
162
+ text: str, collect_tool_calls: bool = False
163
+ ) -> tuple[str, list[Any]]:
164
+ """Parse XML-formatted SDK content from text blocks.
165
+
166
+ This is the main parsing function that handles all types of XML-formatted
167
+ SDK content by applying the appropriate parsing functions in sequence.
168
+
169
+ Args:
170
+ text: Text content that may contain XML-formatted SDK data
171
+ collect_tool_calls: Whether to collect tool calls for OpenAI format conversion
172
+ (used by OpenAI adapter, not by streaming processor)
173
+
174
+ Returns:
175
+ Tuple of (cleaned_text, tool_calls_list)
176
+ - cleaned_text: Text with XML-formatted SDK content converted to readable format
177
+ - tool_calls_list: List of tool calls (empty if collect_tool_calls=False)
178
+ """
179
+ if not text:
180
+ return text, []
181
+
182
+ # Apply all parsing functions in sequence
183
+ cleaned_text = text
184
+
185
+ # Parse system messages
186
+ cleaned_text = parse_system_message_tags(cleaned_text)
187
+
188
+ # Parse tool use blocks (may collect tool calls)
189
+ cleaned_text, tool_calls = parse_tool_use_sdk_tags(cleaned_text, collect_tool_calls)
190
+
191
+ # Parse tool result blocks
192
+ cleaned_text = parse_tool_result_sdk_tags(cleaned_text)
193
+
194
+ # Parse result message blocks
195
+ cleaned_text = parse_result_message_tags(cleaned_text)
196
+
197
+ # Parse text tags (unwrap content) - do this last
198
+ cleaned_text = parse_text_tags(cleaned_text)
199
+
200
+ return cleaned_text.strip(), tool_calls
@@ -0,0 +1,286 @@
1
+ """Handles processing of Claude SDK streaming responses."""
2
+
3
+ from collections.abc import AsyncIterator
4
+ from typing import Any
5
+ from uuid import uuid4
6
+
7
+ import structlog
8
+
9
+ from ccproxy.claude_sdk.converter import MessageConverter
10
+ from ccproxy.config.claude import SDKMessageMode
11
+ from ccproxy.models import claude_sdk as sdk_models
12
+ from ccproxy.observability.context import RequestContext
13
+ from ccproxy.observability.metrics import PrometheusMetrics
14
+
15
+
16
+ logger = structlog.get_logger(__name__)
17
+
18
+
19
+ class ClaudeStreamProcessor:
20
+ """Processes streaming responses from the Claude SDK."""
21
+
22
+ def __init__(
23
+ self,
24
+ message_converter: MessageConverter,
25
+ metrics: PrometheusMetrics | None = None,
26
+ ) -> None:
27
+ """Initialize the stream processor.
28
+
29
+ Args:
30
+ message_converter: Converter for message formats.
31
+ metrics: Prometheus metrics instance.
32
+ """
33
+ self.message_converter = message_converter
34
+ self.metrics = metrics
35
+
36
+ async def process_stream(
37
+ self,
38
+ sdk_stream: AsyncIterator[
39
+ sdk_models.UserMessage
40
+ | sdk_models.AssistantMessage
41
+ | sdk_models.SystemMessage
42
+ | sdk_models.ResultMessage
43
+ ],
44
+ model: str,
45
+ request_id: str | None,
46
+ ctx: RequestContext | None,
47
+ sdk_message_mode: SDKMessageMode,
48
+ pretty_format: bool,
49
+ ) -> AsyncIterator[dict[str, Any]]:
50
+ """Process the SDK stream and yields Anthropic-compatible streaming chunks.
51
+
52
+ Args:
53
+ sdk_stream: The async iterator of Pydantic SDK messages.
54
+ model: The model name.
55
+ request_id: The request ID for correlation.
56
+ ctx: The request context for observability.
57
+ sdk_message_mode: The mode for handling system messages.
58
+ pretty_format: Whether to format content prettily.
59
+
60
+ Yields:
61
+ Anthropic-compatible streaming chunks.
62
+ """
63
+ message_id = f"msg_{uuid4()}"
64
+ content_block_index = 0
65
+ input_tokens = 0 # Will be updated by ResultMessage
66
+
67
+ # Yield start chunks
68
+ start_chunks = self.message_converter.create_streaming_start_chunks(
69
+ message_id, model, input_tokens
70
+ )
71
+ for _, chunk in start_chunks:
72
+ yield chunk
73
+
74
+ async for message in sdk_stream:
75
+ logger.debug(
76
+ "sdk_message_received",
77
+ message_type=type(message).__name__,
78
+ request_id=request_id,
79
+ message_content=message.model_dump()
80
+ if hasattr(message, "model_dump")
81
+ else str(message)[:200],
82
+ )
83
+
84
+ if isinstance(message, sdk_models.SystemMessage):
85
+ logger.debug(
86
+ "sdk_system_message_processing",
87
+ mode=sdk_message_mode.value,
88
+ subtype=message.subtype,
89
+ request_id=request_id,
90
+ )
91
+ if sdk_message_mode != SDKMessageMode.IGNORE:
92
+ chunks = self.message_converter._create_sdk_content_block_chunks(
93
+ sdk_object=message,
94
+ mode=sdk_message_mode,
95
+ index=content_block_index,
96
+ pretty_format=pretty_format,
97
+ xml_tag="system_message",
98
+ )
99
+ for _, chunk in chunks:
100
+ yield chunk
101
+ content_block_index += 1
102
+
103
+ elif isinstance(message, sdk_models.AssistantMessage):
104
+ logger.debug(
105
+ "sdk_assistant_message_processing",
106
+ content_blocks_count=len(message.content),
107
+ block_types=[type(block).__name__ for block in message.content],
108
+ request_id=request_id,
109
+ )
110
+ for block in message.content:
111
+ if isinstance(block, sdk_models.TextBlock):
112
+ logger.debug(
113
+ "sdk_text_block_processing",
114
+ text_length=len(block.text),
115
+ text_preview=block.text[:50],
116
+ block_index=content_block_index,
117
+ request_id=request_id,
118
+ )
119
+ yield {
120
+ "type": "content_block_start",
121
+ "index": content_block_index,
122
+ "content_block": {"type": "text", "text": ""},
123
+ }
124
+ yield self.message_converter.create_streaming_delta_chunk(
125
+ block.text
126
+ )[1]
127
+ yield {
128
+ "type": "content_block_stop",
129
+ "index": content_block_index,
130
+ }
131
+ content_block_index += 1
132
+ elif isinstance(block, sdk_models.ToolUseBlock):
133
+ logger.debug(
134
+ "sdk_tool_use_block_processing",
135
+ tool_id=block.id,
136
+ tool_name=block.name,
137
+ input_keys=list(block.input.keys()) if block.input else [],
138
+ block_index=content_block_index,
139
+ mode=sdk_message_mode.value,
140
+ request_id=request_id,
141
+ )
142
+ chunks = (
143
+ self.message_converter._create_sdk_content_block_chunks(
144
+ sdk_object=block,
145
+ mode=sdk_message_mode,
146
+ index=content_block_index,
147
+ pretty_format=pretty_format,
148
+ xml_tag="tool_use_sdk",
149
+ sdk_block_converter=lambda obj: obj.to_sdk_block(),
150
+ )
151
+ )
152
+ for _, chunk in chunks:
153
+ yield chunk
154
+ content_block_index += 1
155
+ elif isinstance(block, sdk_models.ToolResultBlock):
156
+ logger.debug(
157
+ "sdk_tool_result_block_processing",
158
+ tool_use_id=block.tool_use_id,
159
+ is_error=block.is_error,
160
+ content_type=type(block.content).__name__
161
+ if block.content
162
+ else "None",
163
+ content_preview=str(block.content)[:100]
164
+ if block.content
165
+ else None,
166
+ block_index=content_block_index,
167
+ mode=sdk_message_mode.value,
168
+ request_id=request_id,
169
+ )
170
+ chunks = (
171
+ self.message_converter._create_sdk_content_block_chunks(
172
+ sdk_object=block,
173
+ mode=sdk_message_mode,
174
+ index=content_block_index,
175
+ pretty_format=pretty_format,
176
+ xml_tag="tool_result_sdk",
177
+ sdk_block_converter=lambda obj: obj.to_sdk_block(),
178
+ )
179
+ )
180
+ for _, chunk in chunks:
181
+ yield chunk
182
+ content_block_index += 1
183
+
184
+ elif isinstance(message, sdk_models.UserMessage):
185
+ logger.debug(
186
+ "sdk_user_message_processing",
187
+ content_blocks_count=len(message.content),
188
+ block_types=[type(block).__name__ for block in message.content],
189
+ request_id=request_id,
190
+ )
191
+ for block in message.content:
192
+ if isinstance(block, sdk_models.ToolResultBlock):
193
+ logger.debug(
194
+ "sdk_tool_result_block_processing",
195
+ tool_use_id=block.tool_use_id,
196
+ is_error=block.is_error,
197
+ content_type=type(block.content).__name__
198
+ if block.content
199
+ else "None",
200
+ content_preview=str(block.content)[:100]
201
+ if block.content
202
+ else None,
203
+ block_index=content_block_index,
204
+ mode=sdk_message_mode.value,
205
+ request_id=request_id,
206
+ )
207
+ chunks = (
208
+ self.message_converter._create_sdk_content_block_chunks(
209
+ sdk_object=block,
210
+ mode=sdk_message_mode,
211
+ index=content_block_index,
212
+ pretty_format=pretty_format,
213
+ xml_tag="tool_result_sdk",
214
+ sdk_block_converter=lambda obj: obj.to_sdk_block(),
215
+ )
216
+ )
217
+ for _, chunk in chunks:
218
+ yield chunk
219
+ content_block_index += 1
220
+ # Handle other UserMessage content types if needed in the future
221
+ else:
222
+ logger.debug(
223
+ "sdk_user_message_unsupported_block",
224
+ block_type=type(block).__name__,
225
+ block_index=content_block_index,
226
+ request_id=request_id,
227
+ )
228
+
229
+ elif isinstance(message, sdk_models.ResultMessage):
230
+ logger.debug(
231
+ "sdk_result_message_processing",
232
+ session_id=message.session_id,
233
+ stop_reason=message.stop_reason,
234
+ is_error=message.is_error,
235
+ duration_ms=message.duration_ms,
236
+ num_turns=message.num_turns,
237
+ total_cost_usd=message.total_cost_usd,
238
+ usage_available=message.usage is not None,
239
+ mode=sdk_message_mode.value,
240
+ request_id=request_id,
241
+ )
242
+ if sdk_message_mode != SDKMessageMode.IGNORE:
243
+ chunks = self.message_converter._create_sdk_content_block_chunks(
244
+ sdk_object=message,
245
+ mode=sdk_message_mode,
246
+ index=content_block_index,
247
+ pretty_format=pretty_format,
248
+ xml_tag="system_message",
249
+ )
250
+ for _, chunk in chunks:
251
+ yield chunk
252
+ content_block_index += 1
253
+
254
+ # Final message, contains metrics
255
+ if ctx:
256
+ usage_model = message.usage_model
257
+ ctx.add_metadata(
258
+ status_code=200,
259
+ tokens_input=usage_model.input_tokens,
260
+ tokens_output=usage_model.output_tokens,
261
+ cache_read_tokens=usage_model.cache_read_input_tokens,
262
+ cache_write_tokens=usage_model.cache_creation_input_tokens,
263
+ cost_usd=message.total_cost_usd,
264
+ )
265
+
266
+ end_chunks = self.message_converter.create_streaming_end_chunks(
267
+ stop_reason=message.stop_reason
268
+ )
269
+ # Update usage in the delta chunk
270
+ delta_chunk = end_chunks[0][1]
271
+ delta_chunk["usage"] = {
272
+ "output_tokens": message.usage_model.output_tokens
273
+ }
274
+
275
+ yield delta_chunk
276
+ yield end_chunks[1][1] # message_stop
277
+ break # End of stream
278
+ else:
279
+ logger.warning( # type: ignore[unreachable]
280
+ "sdk_unknown_message_type",
281
+ message_type=type(message).__name__,
282
+ message_content=str(message)[:200],
283
+ request_id=request_id,
284
+ )
285
+
286
+ logger.debug("claude_sdk_stream_processing_completed", request_id=request_id)
@@ -5,4 +5,8 @@ from .config import app as config_app
5
5
  from .serve import api
6
6
 
7
7
 
8
- __all__ = ["api", "auth_app", "config_app"]
8
+ __all__ = [
9
+ "api",
10
+ "auth_app",
11
+ "config_app",
12
+ ]
@@ -1,10 +1,9 @@
1
1
  """Authentication and credential management commands."""
2
2
 
3
3
  import asyncio
4
- import json
5
- from datetime import UTC, datetime, timezone
4
+ from datetime import UTC, datetime
6
5
  from pathlib import Path
7
- from typing import Annotated, Optional
6
+ from typing import Annotated
8
7
 
9
8
  import typer
10
9
  from rich import box
@@ -12,7 +11,6 @@ from rich.console import Console
12
11
  from rich.table import Table
13
12
  from structlog import get_logger
14
13
 
15
- from ccproxy.auth.models import ValidationResult
16
14
  from ccproxy.cli.helpers import get_rich_toolkit
17
15
  from ccproxy.config.settings import get_settings
18
16
  from ccproxy.core.async_utils import get_claude_docker_home_dir