ccproxy-api 0.1.4__py3-none-any.whl → 0.1.6__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 (72) hide show
  1. ccproxy/_version.py +2 -2
  2. ccproxy/adapters/codex/__init__.py +11 -0
  3. ccproxy/adapters/openai/adapter.py +1 -1
  4. ccproxy/adapters/openai/models.py +1 -1
  5. ccproxy/adapters/openai/response_adapter.py +355 -0
  6. ccproxy/adapters/openai/response_models.py +178 -0
  7. ccproxy/adapters/openai/streaming.py +1 -0
  8. ccproxy/api/app.py +150 -224
  9. ccproxy/api/dependencies.py +22 -2
  10. ccproxy/api/middleware/errors.py +27 -3
  11. ccproxy/api/middleware/logging.py +4 -0
  12. ccproxy/api/responses.py +6 -1
  13. ccproxy/api/routes/claude.py +222 -17
  14. ccproxy/api/routes/codex.py +1231 -0
  15. ccproxy/api/routes/health.py +228 -3
  16. ccproxy/api/routes/proxy.py +25 -6
  17. ccproxy/api/services/permission_service.py +2 -2
  18. ccproxy/auth/openai/__init__.py +13 -0
  19. ccproxy/auth/openai/credentials.py +166 -0
  20. ccproxy/auth/openai/oauth_client.py +334 -0
  21. ccproxy/auth/openai/storage.py +184 -0
  22. ccproxy/claude_sdk/__init__.py +4 -8
  23. ccproxy/claude_sdk/client.py +661 -131
  24. ccproxy/claude_sdk/exceptions.py +16 -0
  25. ccproxy/claude_sdk/manager.py +219 -0
  26. ccproxy/claude_sdk/message_queue.py +342 -0
  27. ccproxy/claude_sdk/options.py +6 -1
  28. ccproxy/claude_sdk/session_client.py +546 -0
  29. ccproxy/claude_sdk/session_pool.py +550 -0
  30. ccproxy/claude_sdk/stream_handle.py +538 -0
  31. ccproxy/claude_sdk/stream_worker.py +392 -0
  32. ccproxy/claude_sdk/streaming.py +53 -11
  33. ccproxy/cli/commands/auth.py +398 -1
  34. ccproxy/cli/commands/serve.py +99 -1
  35. ccproxy/cli/options/claude_options.py +47 -0
  36. ccproxy/config/__init__.py +0 -3
  37. ccproxy/config/claude.py +171 -23
  38. ccproxy/config/codex.py +100 -0
  39. ccproxy/config/discovery.py +10 -1
  40. ccproxy/config/scheduler.py +2 -2
  41. ccproxy/config/settings.py +38 -1
  42. ccproxy/core/codex_transformers.py +389 -0
  43. ccproxy/core/http_transformers.py +458 -75
  44. ccproxy/core/logging.py +108 -12
  45. ccproxy/core/transformers.py +5 -0
  46. ccproxy/models/claude_sdk.py +57 -0
  47. ccproxy/models/detection.py +208 -0
  48. ccproxy/models/requests.py +22 -0
  49. ccproxy/models/responses.py +16 -0
  50. ccproxy/observability/access_logger.py +72 -14
  51. ccproxy/observability/metrics.py +151 -0
  52. ccproxy/observability/storage/duckdb_simple.py +12 -0
  53. ccproxy/observability/storage/models.py +16 -0
  54. ccproxy/observability/streaming_response.py +107 -0
  55. ccproxy/scheduler/manager.py +31 -6
  56. ccproxy/scheduler/tasks.py +122 -0
  57. ccproxy/services/claude_detection_service.py +269 -0
  58. ccproxy/services/claude_sdk_service.py +333 -130
  59. ccproxy/services/codex_detection_service.py +263 -0
  60. ccproxy/services/proxy_service.py +618 -197
  61. ccproxy/utils/__init__.py +9 -1
  62. ccproxy/utils/disconnection_monitor.py +83 -0
  63. ccproxy/utils/id_generator.py +12 -0
  64. ccproxy/utils/model_mapping.py +7 -5
  65. ccproxy/utils/startup_helpers.py +470 -0
  66. ccproxy_api-0.1.6.dist-info/METADATA +615 -0
  67. {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/RECORD +70 -47
  68. ccproxy/config/loader.py +0 -105
  69. ccproxy_api-0.1.4.dist-info/METADATA +0 -369
  70. {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/WHEEL +0 -0
  71. {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/entry_points.txt +0 -0
  72. {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/licenses/LICENSE +0 -0
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 isinstance(defaults.mcp_servers, dict)
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.
@@ -0,0 +1,100 @@
1
+ """OpenAI Codex-specific configuration settings."""
2
+
3
+ from pydantic import BaseModel, Field, field_validator
4
+
5
+
6
+ class OAuthSettings(BaseModel):
7
+ """OAuth configuration for OpenAI authentication."""
8
+
9
+ base_url: str = Field(
10
+ default="https://auth.openai.com",
11
+ description="OpenAI OAuth base URL",
12
+ )
13
+
14
+ client_id: str = Field(
15
+ default="app_EMoamEEZ73f0CkXaXp7hrann",
16
+ description="OpenAI OAuth client ID",
17
+ )
18
+
19
+ scopes: list[str] = Field(
20
+ default_factory=lambda: ["openid", "profile", "email", "offline_access"],
21
+ description="OAuth scopes to request",
22
+ )
23
+
24
+ @field_validator("base_url")
25
+ @classmethod
26
+ def validate_base_url(cls, v: str) -> str:
27
+ """Validate OAuth base URL format."""
28
+ if not v.startswith(("http://", "https://")):
29
+ raise ValueError("OAuth base URL must start with http:// or https://")
30
+ return v.rstrip("/")
31
+
32
+
33
+ class CodexSettings(BaseModel):
34
+ """OpenAI Codex-specific configuration settings."""
35
+
36
+ enabled: bool = Field(
37
+ default=True,
38
+ description="Enable OpenAI Codex provider support",
39
+ )
40
+
41
+ base_url: str = Field(
42
+ default="https://chatgpt.com/backend-api/codex",
43
+ description="OpenAI Codex API base URL",
44
+ )
45
+
46
+ oauth: OAuthSettings = Field(
47
+ default_factory=OAuthSettings,
48
+ description="OAuth configuration settings",
49
+ )
50
+
51
+ callback_port: int = Field(
52
+ default=1455,
53
+ ge=1024,
54
+ le=65535,
55
+ description="Port for OAuth callback server (1024-65535)",
56
+ )
57
+
58
+ redirect_uri: str = Field(
59
+ default="http://localhost:1455/auth/callback",
60
+ description="OAuth redirect URI (auto-generated from callback_port if not set)",
61
+ )
62
+
63
+ verbose_logging: bool = Field(
64
+ default=False,
65
+ description="Enable verbose logging for Codex operations",
66
+ )
67
+
68
+ @field_validator("base_url")
69
+ @classmethod
70
+ def validate_base_url(cls, v: str) -> str:
71
+ """Validate Codex base URL format."""
72
+ if not v.startswith(("http://", "https://")):
73
+ raise ValueError("Codex base URL must start with http:// or https://")
74
+ return v.rstrip("/")
75
+
76
+ @field_validator("redirect_uri")
77
+ @classmethod
78
+ def validate_redirect_uri(cls, v: str) -> str:
79
+ """Validate redirect URI format."""
80
+ if not v.startswith(("http://", "https://")):
81
+ raise ValueError("Redirect URI must start with http:// or https://")
82
+ return v
83
+
84
+ @field_validator("callback_port")
85
+ @classmethod
86
+ def validate_callback_port(cls, v: int) -> int:
87
+ """Validate callback port range."""
88
+ if not (1024 <= v <= 65535):
89
+ raise ValueError("Callback port must be between 1024 and 65535")
90
+ return v
91
+
92
+ def get_redirect_uri(self) -> str:
93
+ """Get the redirect URI, auto-generating if needed."""
94
+ if (
95
+ self.redirect_uri
96
+ and self.redirect_uri
97
+ != f"http://localhost:{self.callback_port}/auth/callback"
98
+ ):
99
+ return self.redirect_uri
100
+ return f"http://localhost:{self.callback_port}/auth/callback"
@@ -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"
@@ -35,7 +35,7 @@ class SchedulerSettings(BaseSettings):
35
35
  # Pricing updater task settings
36
36
  pricing_update_enabled: bool = Field(
37
37
  default=True,
38
- description="Whether pricing cache update task is enabled",
38
+ description="Whether pricing cache update task is enabled. Enabled by default for privacy - downloads from GitHub when enabled",
39
39
  )
40
40
 
41
41
  pricing_update_interval_hours: int = Field(
@@ -85,7 +85,7 @@ class SchedulerSettings(BaseSettings):
85
85
  # Version checking task settings
86
86
  version_check_enabled: bool = Field(
87
87
  default=True,
88
- description="Whether version update checking is enabled",
88
+ description="Whether version update checking is enabled. Enabled by default for privacy - checks GitHub API when enabled",
89
89
  )
90
90
 
91
91
  version_check_interval_hours: int = Field(
@@ -15,6 +15,7 @@ from ccproxy.config.discovery import find_toml_config_file
15
15
 
16
16
  from .auth import AuthSettings
17
17
  from .claude import ClaudeSettings
18
+ from .codex import CodexSettings
18
19
  from .cors import CORSSettings
19
20
  from .docker_settings import DockerSettings
20
21
  from .observability import ObservabilitySettings
@@ -85,6 +86,12 @@ class Settings(BaseSettings):
85
86
  description="Claude-specific configuration settings",
86
87
  )
87
88
 
89
+ # Codex-specific settings
90
+ codex: CodexSettings = Field(
91
+ default_factory=CodexSettings,
92
+ description="OpenAI Codex-specific configuration settings",
93
+ )
94
+
88
95
  # Proxy and authentication
89
96
  reverse_proxy: ReverseProxySettings = Field(
90
97
  default_factory=ReverseProxySettings,
@@ -168,6 +175,18 @@ class Settings(BaseSettings):
168
175
  return ClaudeSettings(**v)
169
176
  return v
170
177
 
178
+ @field_validator("codex", mode="before")
179
+ @classmethod
180
+ def validate_codex(cls, v: Any) -> Any:
181
+ """Validate and convert Codex settings."""
182
+ if v is None:
183
+ return CodexSettings()
184
+ if isinstance(v, CodexSettings):
185
+ return v
186
+ if isinstance(v, dict):
187
+ return CodexSettings(**v)
188
+ return v
189
+
171
190
  @field_validator("reverse_proxy", mode="before")
172
191
  @classmethod
173
192
  def validate_reverse_proxy(cls, v: Any) -> Any:
@@ -462,10 +481,28 @@ class ConfigurationManager:
462
481
  claude_settings["cli_path"] = cli_args["claude_cli_path"]
463
482
 
464
483
  # Direct Claude settings (not nested in code_options)
465
- for key in ["sdk_message_mode"]:
484
+ for key in [
485
+ "sdk_message_mode",
486
+ "system_prompt_injection_mode",
487
+ "builtin_permissions",
488
+ ]:
466
489
  if cli_args.get(key) is not None:
467
490
  claude_settings[key] = cli_args[key]
468
491
 
492
+ # Handle pool configuration
493
+ if cli_args.get("sdk_pool") is not None:
494
+ claude_settings["sdk_pool"] = {"enabled": cli_args["sdk_pool"]}
495
+
496
+ if cli_args.get("sdk_pool_size") is not None:
497
+ if "sdk_pool" not in claude_settings:
498
+ claude_settings["sdk_pool"] = {}
499
+ claude_settings["sdk_pool"]["pool_size"] = cli_args["sdk_pool_size"]
500
+
501
+ if cli_args.get("sdk_session_pool") is not None:
502
+ claude_settings["sdk_session_pool"] = {
503
+ "enabled": cli_args["sdk_session_pool"]
504
+ }
505
+
469
506
  # Claude Code options
470
507
  claude_opts = {}
471
508
  for key in [