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/options.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
**
|
|
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
|
-
**
|
|
47
|
+
**additional_options: Additional options to set on the ClaudeCodeOptions instance
|
|
34
48
|
|
|
35
49
|
Returns:
|
|
36
50
|
Configured ClaudeCodeOptions instance
|
|
37
51
|
"""
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
#
|
|
50
|
-
|
|
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.
|
|
158
|
+
from ccproxy.utils.model_mapping import get_supported_claude_models
|
|
90
159
|
|
|
91
|
-
#
|
|
92
|
-
claude_models =
|
|
93
|
-
return
|
|
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
|
|
180
|
+
Get default options for API parameters.
|
|
112
181
|
|
|
113
182
|
Returns:
|
|
114
|
-
Dictionary of default
|
|
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)
|
ccproxy/cli/commands/__init__.py
CHANGED
ccproxy/cli/commands/auth.py
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
"""Authentication and credential management commands."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
import
|
|
5
|
-
from datetime import UTC, datetime, timezone
|
|
4
|
+
from datetime import UTC, datetime
|
|
6
5
|
from pathlib import Path
|
|
7
|
-
from typing import Annotated
|
|
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
|