ccproxy-api 0.1.3__py3-none-any.whl → 0.1.5__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/adapter.py +1 -1
- ccproxy/adapters/openai/streaming.py +1 -0
- ccproxy/api/app.py +134 -224
- ccproxy/api/dependencies.py +22 -2
- ccproxy/api/middleware/errors.py +27 -3
- ccproxy/api/middleware/logging.py +4 -0
- ccproxy/api/responses.py +6 -1
- ccproxy/api/routes/claude.py +222 -17
- ccproxy/api/routes/proxy.py +25 -6
- ccproxy/api/services/permission_service.py +2 -2
- ccproxy/claude_sdk/__init__.py +4 -8
- ccproxy/claude_sdk/client.py +661 -131
- ccproxy/claude_sdk/exceptions.py +16 -0
- ccproxy/claude_sdk/manager.py +219 -0
- ccproxy/claude_sdk/message_queue.py +342 -0
- ccproxy/claude_sdk/options.py +5 -0
- ccproxy/claude_sdk/session_client.py +546 -0
- ccproxy/claude_sdk/session_pool.py +550 -0
- ccproxy/claude_sdk/stream_handle.py +538 -0
- ccproxy/claude_sdk/stream_worker.py +392 -0
- ccproxy/claude_sdk/streaming.py +53 -11
- ccproxy/cli/commands/serve.py +96 -0
- ccproxy/cli/options/claude_options.py +47 -0
- ccproxy/config/__init__.py +0 -3
- ccproxy/config/claude.py +171 -23
- ccproxy/config/discovery.py +10 -1
- ccproxy/config/scheduler.py +4 -4
- ccproxy/config/settings.py +19 -1
- ccproxy/core/http_transformers.py +305 -73
- ccproxy/core/logging.py +108 -12
- ccproxy/core/transformers.py +5 -0
- ccproxy/models/claude_sdk.py +57 -0
- ccproxy/models/detection.py +126 -0
- ccproxy/observability/access_logger.py +72 -14
- ccproxy/observability/metrics.py +151 -0
- ccproxy/observability/storage/duckdb_simple.py +12 -0
- ccproxy/observability/storage/models.py +16 -0
- ccproxy/observability/streaming_response.py +107 -0
- ccproxy/scheduler/manager.py +31 -6
- ccproxy/scheduler/tasks.py +122 -0
- ccproxy/services/claude_detection_service.py +269 -0
- ccproxy/services/claude_sdk_service.py +334 -131
- ccproxy/services/proxy_service.py +91 -200
- ccproxy/utils/__init__.py +9 -1
- ccproxy/utils/disconnection_monitor.py +83 -0
- ccproxy/utils/id_generator.py +12 -0
- ccproxy/utils/startup_helpers.py +408 -0
- {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/METADATA +29 -2
- {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/RECORD +53 -41
- ccproxy/config/loader.py +0 -105
- {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/WHEEL +0 -0
- {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/entry_points.txt +0 -0
- {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/licenses/LICENSE +0 -0
ccproxy/config/__init__.py
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from .auth import AuthSettings, CredentialStorageSettings, OAuthSettings
|
|
4
4
|
from .docker_settings import DockerSettings
|
|
5
|
-
from .loader import ConfigLoader, load_config
|
|
6
5
|
from .reverse_proxy import ReverseProxySettings
|
|
7
6
|
from .settings import Settings, get_settings
|
|
8
7
|
from .validators import (
|
|
@@ -26,8 +25,6 @@ __all__ = [
|
|
|
26
25
|
"CredentialStorageSettings",
|
|
27
26
|
"ReverseProxySettings",
|
|
28
27
|
"DockerSettings",
|
|
29
|
-
"ConfigLoader",
|
|
30
|
-
"load_config",
|
|
31
28
|
"ConfigValidationError",
|
|
32
29
|
"validate_config_dict",
|
|
33
30
|
"validate_cors_origins",
|
ccproxy/config/claude.py
CHANGED
|
@@ -7,7 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
9
|
import structlog
|
|
10
|
-
from pydantic import BaseModel, Field, field_validator
|
|
10
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
11
11
|
|
|
12
12
|
from ccproxy.core.async_utils import get_package_dir, patched_typing
|
|
13
13
|
|
|
@@ -19,14 +19,29 @@ with patched_typing():
|
|
|
19
19
|
logger = structlog.get_logger(__name__)
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
def _create_default_claude_code_options(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
22
|
+
def _create_default_claude_code_options(
|
|
23
|
+
builtin_permissions: bool = True,
|
|
24
|
+
continue_conversation: bool = False,
|
|
25
|
+
) -> ClaudeCodeOptions:
|
|
26
|
+
"""Create ClaudeCodeOptions with default values.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
builtin_permissions: Whether to include built-in permission handling defaults
|
|
30
|
+
"""
|
|
31
|
+
if builtin_permissions:
|
|
32
|
+
return ClaudeCodeOptions(
|
|
33
|
+
continue_conversation=continue_conversation,
|
|
34
|
+
mcp_servers={
|
|
35
|
+
"confirmation": {"type": "sse", "url": "http://127.0.0.1:8000/mcp"}
|
|
36
|
+
},
|
|
37
|
+
permission_prompt_tool_name="mcp__confirmation__check_permission",
|
|
38
|
+
)
|
|
39
|
+
else:
|
|
40
|
+
return ClaudeCodeOptions(
|
|
41
|
+
mcp_servers={},
|
|
42
|
+
permission_prompt_tool_name=None,
|
|
43
|
+
continue_conversation=continue_conversation,
|
|
44
|
+
)
|
|
30
45
|
|
|
31
46
|
|
|
32
47
|
class SDKMessageMode(str, Enum):
|
|
@@ -42,6 +57,94 @@ class SDKMessageMode(str, Enum):
|
|
|
42
57
|
FORMATTED = "formatted"
|
|
43
58
|
|
|
44
59
|
|
|
60
|
+
class SystemPromptInjectionMode(str, Enum):
|
|
61
|
+
"""Modes for system prompt injection.
|
|
62
|
+
|
|
63
|
+
- minimal: Only inject Claude Code identification prompt
|
|
64
|
+
- full: Inject all detected system messages from Claude CLI
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
MINIMAL = "minimal"
|
|
68
|
+
FULL = "full"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SessionPoolSettings(BaseModel):
|
|
72
|
+
"""Session pool configuration settings."""
|
|
73
|
+
|
|
74
|
+
enabled: bool = Field(
|
|
75
|
+
default=True, description="Enable session-aware persistent pooling"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
session_ttl: int = Field(
|
|
79
|
+
default=3600,
|
|
80
|
+
ge=60,
|
|
81
|
+
le=86400,
|
|
82
|
+
description="Session time-to-live in seconds (1 minute to 24 hours)",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
max_sessions: int = Field(
|
|
86
|
+
default=1000,
|
|
87
|
+
ge=1,
|
|
88
|
+
le=10000,
|
|
89
|
+
description="Maximum number of concurrent sessions",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
cleanup_interval: int = Field(
|
|
93
|
+
default=300,
|
|
94
|
+
ge=30,
|
|
95
|
+
le=3600,
|
|
96
|
+
description="Session cleanup interval in seconds (30 seconds to 1 hour)",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
idle_threshold: int = Field(
|
|
100
|
+
default=600,
|
|
101
|
+
ge=60,
|
|
102
|
+
le=7200,
|
|
103
|
+
description="Session idle threshold in seconds (1 minute to 2 hours)",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
connection_recovery: bool = Field(
|
|
107
|
+
default=True,
|
|
108
|
+
description="Enable automatic connection recovery for unhealthy sessions",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
stream_first_chunk_timeout: int = Field(
|
|
112
|
+
default=3,
|
|
113
|
+
ge=1,
|
|
114
|
+
le=30,
|
|
115
|
+
description="Stream first chunk timeout in seconds (1-30 seconds)",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
stream_ongoing_timeout: int = Field(
|
|
119
|
+
default=60,
|
|
120
|
+
ge=10,
|
|
121
|
+
le=600,
|
|
122
|
+
description="Stream ongoing timeout in seconds after first chunk (10 seconds to 10 minutes)",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
stream_interrupt_timeout: int = Field(
|
|
126
|
+
default=10,
|
|
127
|
+
ge=2,
|
|
128
|
+
le=60,
|
|
129
|
+
description="Stream interrupt timeout in seconds for SDK and worker operations (2-60 seconds)",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@model_validator(mode="after")
|
|
133
|
+
def validate_timeout_hierarchy(self) -> "SessionPoolSettings":
|
|
134
|
+
"""Ensure stream timeouts are less than session TTL."""
|
|
135
|
+
if self.stream_ongoing_timeout >= self.session_ttl:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"stream_ongoing_timeout ({self.stream_ongoing_timeout}s) must be less than session_ttl ({self.session_ttl}s)"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if self.stream_first_chunk_timeout >= self.stream_ongoing_timeout:
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"stream_first_chunk_timeout ({self.stream_first_chunk_timeout}s) must be less than stream_ongoing_timeout ({self.stream_ongoing_timeout}s)"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
|
|
45
148
|
class ClaudeSettings(BaseModel):
|
|
46
149
|
"""Claude-specific configuration settings."""
|
|
47
150
|
|
|
@@ -50,8 +153,13 @@ class ClaudeSettings(BaseModel):
|
|
|
50
153
|
description="Path to Claude CLI executable",
|
|
51
154
|
)
|
|
52
155
|
|
|
53
|
-
|
|
54
|
-
|
|
156
|
+
builtin_permissions: bool = Field(
|
|
157
|
+
default=True,
|
|
158
|
+
description="Whether to enable built-in permission handling infrastructure (MCP server and SSE endpoints). When disabled, users can still configure custom MCP servers and permission tools.",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
code_options: ClaudeCodeOptions | None = Field(
|
|
162
|
+
default=None,
|
|
55
163
|
description="Claude Code SDK options configuration",
|
|
56
164
|
)
|
|
57
165
|
|
|
@@ -60,11 +168,21 @@ class ClaudeSettings(BaseModel):
|
|
|
60
168
|
description="Mode for handling SDK messages from Claude SDK. Options: forward (direct SDK blocks), ignore (skip blocks), formatted (XML tags with JSON data)",
|
|
61
169
|
)
|
|
62
170
|
|
|
171
|
+
system_prompt_injection_mode: SystemPromptInjectionMode = Field(
|
|
172
|
+
default=SystemPromptInjectionMode.MINIMAL,
|
|
173
|
+
description="Mode for system prompt injection. Options: minimal (Claude Code ID only), full (all detected system messages)",
|
|
174
|
+
)
|
|
175
|
+
|
|
63
176
|
pretty_format: bool = Field(
|
|
64
177
|
default=True,
|
|
65
178
|
description="Whether to use pretty formatting (indented JSON, newlines after XML tags, unescaped content). When false: compact JSON, no newlines, escaped content between XML tags",
|
|
66
179
|
)
|
|
67
180
|
|
|
181
|
+
sdk_session_pool: SessionPoolSettings = Field(
|
|
182
|
+
default_factory=SessionPoolSettings,
|
|
183
|
+
description="Configuration settings for session-aware SDK client pooling",
|
|
184
|
+
)
|
|
185
|
+
|
|
68
186
|
@field_validator("cli_path")
|
|
69
187
|
@classmethod
|
|
70
188
|
def validate_claude_cli_path(cls, v: str | None) -> str | None:
|
|
@@ -81,11 +199,16 @@ class ClaudeSettings(BaseModel):
|
|
|
81
199
|
|
|
82
200
|
@field_validator("code_options", mode="before")
|
|
83
201
|
@classmethod
|
|
84
|
-
def validate_claude_code_options(cls, v: Any) -> Any:
|
|
202
|
+
def validate_claude_code_options(cls, v: Any, info: Any) -> Any:
|
|
85
203
|
"""Validate and convert Claude Code options."""
|
|
204
|
+
# Get builtin_permissions setting from the model data
|
|
205
|
+
builtin_permissions = True # default
|
|
206
|
+
if info.data and "builtin_permissions" in info.data:
|
|
207
|
+
builtin_permissions = info.data["builtin_permissions"]
|
|
208
|
+
|
|
86
209
|
if v is None:
|
|
87
|
-
# Create instance with default values
|
|
88
|
-
return _create_default_claude_code_options()
|
|
210
|
+
# Create instance with default values based on builtin_permissions
|
|
211
|
+
return _create_default_claude_code_options(builtin_permissions)
|
|
89
212
|
|
|
90
213
|
# If it's already a ClaudeCodeOptions instance, return as-is
|
|
91
214
|
if isinstance(v, ClaudeCodeOptions):
|
|
@@ -93,16 +216,18 @@ class ClaudeSettings(BaseModel):
|
|
|
93
216
|
|
|
94
217
|
# If it's an empty dict, treat it like None and use defaults
|
|
95
218
|
if isinstance(v, dict) and not v:
|
|
96
|
-
return _create_default_claude_code_options()
|
|
219
|
+
return _create_default_claude_code_options(builtin_permissions)
|
|
97
220
|
|
|
98
221
|
# For non-empty dicts, merge with defaults instead of replacing them
|
|
99
222
|
if isinstance(v, dict):
|
|
100
|
-
# Start with default values
|
|
101
|
-
defaults = _create_default_claude_code_options()
|
|
223
|
+
# Start with default values based on builtin_permissions
|
|
224
|
+
defaults = _create_default_claude_code_options(builtin_permissions)
|
|
102
225
|
|
|
103
226
|
# Extract default values as a dict for merging
|
|
104
227
|
default_values = {
|
|
105
|
-
"mcp_servers": defaults.mcp_servers
|
|
228
|
+
"mcp_servers": dict(defaults.mcp_servers)
|
|
229
|
+
if defaults.mcp_servers
|
|
230
|
+
else {},
|
|
106
231
|
"permission_prompt_tool_name": defaults.permission_prompt_tool_name,
|
|
107
232
|
}
|
|
108
233
|
|
|
@@ -124,18 +249,41 @@ class ClaudeSettings(BaseModel):
|
|
|
124
249
|
if default_value is not None:
|
|
125
250
|
default_values[attr] = default_value
|
|
126
251
|
|
|
252
|
+
# Handle MCP server merging when builtin_permissions is enabled
|
|
253
|
+
if builtin_permissions and "mcp_servers" in v:
|
|
254
|
+
user_mcp_servers = v["mcp_servers"]
|
|
255
|
+
if isinstance(user_mcp_servers, dict):
|
|
256
|
+
# Merge user MCP servers with built-in ones (user takes precedence)
|
|
257
|
+
default_mcp = default_values["mcp_servers"]
|
|
258
|
+
if isinstance(default_mcp, dict):
|
|
259
|
+
merged_mcp_servers = {
|
|
260
|
+
**default_mcp,
|
|
261
|
+
**user_mcp_servers,
|
|
262
|
+
}
|
|
263
|
+
v = {**v, "mcp_servers": merged_mcp_servers}
|
|
264
|
+
|
|
127
265
|
# Merge CLI overrides with defaults (CLI overrides take precedence)
|
|
128
266
|
merged_values = {**default_values, **v}
|
|
129
267
|
|
|
130
268
|
return ClaudeCodeOptions(**merged_values)
|
|
131
269
|
|
|
132
|
-
# Try to convert to
|
|
270
|
+
# Try to convert to ClaudeCodeOptions if possible
|
|
133
271
|
if hasattr(v, "model_dump"):
|
|
134
|
-
return v.model_dump()
|
|
272
|
+
return ClaudeCodeOptions(**v.model_dump())
|
|
135
273
|
elif hasattr(v, "__dict__"):
|
|
136
|
-
return v.__dict__
|
|
137
|
-
|
|
138
|
-
|
|
274
|
+
return ClaudeCodeOptions(**v.__dict__)
|
|
275
|
+
|
|
276
|
+
# Fallback: use default values
|
|
277
|
+
return _create_default_claude_code_options(builtin_permissions)
|
|
278
|
+
|
|
279
|
+
@model_validator(mode="after")
|
|
280
|
+
def validate_code_options_after(self) -> "ClaudeSettings":
|
|
281
|
+
"""Ensure code_options is properly initialized after field validation."""
|
|
282
|
+
if self.code_options is None:
|
|
283
|
+
self.code_options = _create_default_claude_code_options(
|
|
284
|
+
self.builtin_permissions
|
|
285
|
+
)
|
|
286
|
+
return self
|
|
139
287
|
|
|
140
288
|
def find_claude_cli(self) -> tuple[str | None, bool]:
|
|
141
289
|
"""Find Claude CLI executable in PATH or specified location.
|
ccproxy/config/discovery.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
|
|
3
|
-
from ccproxy.core.system import get_xdg_config_home
|
|
3
|
+
from ccproxy.core.system import get_xdg_cache_home, get_xdg_config_home
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
def find_toml_config_file() -> Path | None:
|
|
@@ -84,3 +84,12 @@ def get_claude_docker_home_dir() -> Path:
|
|
|
84
84
|
Path to the Claude Docker home directory within XDG_DATA_HOME.
|
|
85
85
|
"""
|
|
86
86
|
return get_ccproxy_config_dir() / "home"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_ccproxy_cache_dir() -> Path:
|
|
90
|
+
"""Get the ccproxy cache directory.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Path to the ccproxy cache directory within XDG_CACHE_HOME.
|
|
94
|
+
"""
|
|
95
|
+
return get_xdg_cache_home() / "ccproxy"
|
ccproxy/config/scheduler.py
CHANGED
|
@@ -34,8 +34,8 @@ class SchedulerSettings(BaseSettings):
|
|
|
34
34
|
|
|
35
35
|
# Pricing updater task settings
|
|
36
36
|
pricing_update_enabled: bool = Field(
|
|
37
|
-
default=
|
|
38
|
-
description="Whether pricing cache update task is enabled",
|
|
37
|
+
default=False,
|
|
38
|
+
description="Whether pricing cache update task is enabled. Disabled by default for privacy - downloads from GitHub when enabled",
|
|
39
39
|
)
|
|
40
40
|
|
|
41
41
|
pricing_update_interval_hours: int = Field(
|
|
@@ -84,8 +84,8 @@ class SchedulerSettings(BaseSettings):
|
|
|
84
84
|
|
|
85
85
|
# Version checking task settings
|
|
86
86
|
version_check_enabled: bool = Field(
|
|
87
|
-
default=
|
|
88
|
-
description="Whether version update checking is enabled",
|
|
87
|
+
default=False,
|
|
88
|
+
description="Whether version update checking is enabled. Disabled by default for privacy - checks GitHub API when enabled",
|
|
89
89
|
)
|
|
90
90
|
|
|
91
91
|
version_check_interval_hours: int = Field(
|
ccproxy/config/settings.py
CHANGED
|
@@ -462,10 +462,28 @@ class ConfigurationManager:
|
|
|
462
462
|
claude_settings["cli_path"] = cli_args["claude_cli_path"]
|
|
463
463
|
|
|
464
464
|
# Direct Claude settings (not nested in code_options)
|
|
465
|
-
for key in [
|
|
465
|
+
for key in [
|
|
466
|
+
"sdk_message_mode",
|
|
467
|
+
"system_prompt_injection_mode",
|
|
468
|
+
"builtin_permissions",
|
|
469
|
+
]:
|
|
466
470
|
if cli_args.get(key) is not None:
|
|
467
471
|
claude_settings[key] = cli_args[key]
|
|
468
472
|
|
|
473
|
+
# Handle pool configuration
|
|
474
|
+
if cli_args.get("sdk_pool") is not None:
|
|
475
|
+
claude_settings["sdk_pool"] = {"enabled": cli_args["sdk_pool"]}
|
|
476
|
+
|
|
477
|
+
if cli_args.get("sdk_pool_size") is not None:
|
|
478
|
+
if "sdk_pool" not in claude_settings:
|
|
479
|
+
claude_settings["sdk_pool"] = {}
|
|
480
|
+
claude_settings["sdk_pool"]["pool_size"] = cli_args["sdk_pool_size"]
|
|
481
|
+
|
|
482
|
+
if cli_args.get("sdk_session_pool") is not None:
|
|
483
|
+
claude_settings["sdk_session_pool"] = {
|
|
484
|
+
"enabled": cli_args["sdk_session_pool"]
|
|
485
|
+
}
|
|
486
|
+
|
|
469
487
|
# Claude Code options
|
|
470
488
|
claude_opts = {}
|
|
471
489
|
for key in [
|