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.
Files changed (54) hide show
  1. ccproxy/_version.py +2 -2
  2. ccproxy/adapters/openai/adapter.py +1 -1
  3. ccproxy/adapters/openai/streaming.py +1 -0
  4. ccproxy/api/app.py +134 -224
  5. ccproxy/api/dependencies.py +22 -2
  6. ccproxy/api/middleware/errors.py +27 -3
  7. ccproxy/api/middleware/logging.py +4 -0
  8. ccproxy/api/responses.py +6 -1
  9. ccproxy/api/routes/claude.py +222 -17
  10. ccproxy/api/routes/proxy.py +25 -6
  11. ccproxy/api/services/permission_service.py +2 -2
  12. ccproxy/claude_sdk/__init__.py +4 -8
  13. ccproxy/claude_sdk/client.py +661 -131
  14. ccproxy/claude_sdk/exceptions.py +16 -0
  15. ccproxy/claude_sdk/manager.py +219 -0
  16. ccproxy/claude_sdk/message_queue.py +342 -0
  17. ccproxy/claude_sdk/options.py +5 -0
  18. ccproxy/claude_sdk/session_client.py +546 -0
  19. ccproxy/claude_sdk/session_pool.py +550 -0
  20. ccproxy/claude_sdk/stream_handle.py +538 -0
  21. ccproxy/claude_sdk/stream_worker.py +392 -0
  22. ccproxy/claude_sdk/streaming.py +53 -11
  23. ccproxy/cli/commands/serve.py +96 -0
  24. ccproxy/cli/options/claude_options.py +47 -0
  25. ccproxy/config/__init__.py +0 -3
  26. ccproxy/config/claude.py +171 -23
  27. ccproxy/config/discovery.py +10 -1
  28. ccproxy/config/scheduler.py +4 -4
  29. ccproxy/config/settings.py +19 -1
  30. ccproxy/core/http_transformers.py +305 -73
  31. ccproxy/core/logging.py +108 -12
  32. ccproxy/core/transformers.py +5 -0
  33. ccproxy/models/claude_sdk.py +57 -0
  34. ccproxy/models/detection.py +126 -0
  35. ccproxy/observability/access_logger.py +72 -14
  36. ccproxy/observability/metrics.py +151 -0
  37. ccproxy/observability/storage/duckdb_simple.py +12 -0
  38. ccproxy/observability/storage/models.py +16 -0
  39. ccproxy/observability/streaming_response.py +107 -0
  40. ccproxy/scheduler/manager.py +31 -6
  41. ccproxy/scheduler/tasks.py +122 -0
  42. ccproxy/services/claude_detection_service.py +269 -0
  43. ccproxy/services/claude_sdk_service.py +334 -131
  44. ccproxy/services/proxy_service.py +91 -200
  45. ccproxy/utils/__init__.py +9 -1
  46. ccproxy/utils/disconnection_monitor.py +83 -0
  47. ccproxy/utils/id_generator.py +12 -0
  48. ccproxy/utils/startup_helpers.py +408 -0
  49. {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/METADATA +29 -2
  50. {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/RECORD +53 -41
  51. ccproxy/config/loader.py +0 -105
  52. {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/WHEEL +0 -0
  53. {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/entry_points.txt +0 -0
  54. {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/licenses/LICENSE +0 -0
@@ -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() -> ClaudeCodeOptions:
23
- """Create ClaudeCodeOptions with default values."""
24
- return ClaudeCodeOptions(
25
- mcp_servers={
26
- "confirmation": {"type": "sse", "url": "http://127.0.0.1:8000/mcp"}
27
- },
28
- permission_prompt_tool_name="mcp__confirmation__check_permission",
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
- code_options: ClaudeCodeOptions = Field(
54
- default_factory=_create_default_claude_code_options,
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 (same as default_factory)
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.copy(),
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 dict if possible
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
- return v
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.
@@ -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"
@@ -34,8 +34,8 @@ class SchedulerSettings(BaseSettings):
34
34
 
35
35
  # Pricing updater task settings
36
36
  pricing_update_enabled: bool = Field(
37
- default=True,
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=True,
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(
@@ -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 ["sdk_message_mode"]:
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 [