ccproxy-api 0.1.0__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 (148) hide show
  1. ccproxy/__init__.py +4 -0
  2. ccproxy/__main__.py +7 -0
  3. ccproxy/_version.py +21 -0
  4. ccproxy/adapters/__init__.py +11 -0
  5. ccproxy/adapters/base.py +80 -0
  6. ccproxy/adapters/openai/__init__.py +43 -0
  7. ccproxy/adapters/openai/adapter.py +915 -0
  8. ccproxy/adapters/openai/models.py +412 -0
  9. ccproxy/adapters/openai/streaming.py +449 -0
  10. ccproxy/api/__init__.py +28 -0
  11. ccproxy/api/app.py +225 -0
  12. ccproxy/api/dependencies.py +140 -0
  13. ccproxy/api/middleware/__init__.py +11 -0
  14. ccproxy/api/middleware/auth.py +0 -0
  15. ccproxy/api/middleware/cors.py +55 -0
  16. ccproxy/api/middleware/errors.py +703 -0
  17. ccproxy/api/middleware/headers.py +51 -0
  18. ccproxy/api/middleware/logging.py +175 -0
  19. ccproxy/api/middleware/request_id.py +69 -0
  20. ccproxy/api/middleware/server_header.py +62 -0
  21. ccproxy/api/responses.py +84 -0
  22. ccproxy/api/routes/__init__.py +16 -0
  23. ccproxy/api/routes/claude.py +181 -0
  24. ccproxy/api/routes/health.py +489 -0
  25. ccproxy/api/routes/metrics.py +1033 -0
  26. ccproxy/api/routes/proxy.py +238 -0
  27. ccproxy/auth/__init__.py +75 -0
  28. ccproxy/auth/bearer.py +68 -0
  29. ccproxy/auth/credentials_adapter.py +93 -0
  30. ccproxy/auth/dependencies.py +229 -0
  31. ccproxy/auth/exceptions.py +79 -0
  32. ccproxy/auth/manager.py +102 -0
  33. ccproxy/auth/models.py +118 -0
  34. ccproxy/auth/oauth/__init__.py +26 -0
  35. ccproxy/auth/oauth/models.py +49 -0
  36. ccproxy/auth/oauth/routes.py +396 -0
  37. ccproxy/auth/oauth/storage.py +0 -0
  38. ccproxy/auth/storage/__init__.py +12 -0
  39. ccproxy/auth/storage/base.py +57 -0
  40. ccproxy/auth/storage/json_file.py +159 -0
  41. ccproxy/auth/storage/keyring.py +192 -0
  42. ccproxy/claude_sdk/__init__.py +20 -0
  43. ccproxy/claude_sdk/client.py +169 -0
  44. ccproxy/claude_sdk/converter.py +331 -0
  45. ccproxy/claude_sdk/options.py +120 -0
  46. ccproxy/cli/__init__.py +14 -0
  47. ccproxy/cli/commands/__init__.py +8 -0
  48. ccproxy/cli/commands/auth.py +553 -0
  49. ccproxy/cli/commands/config/__init__.py +14 -0
  50. ccproxy/cli/commands/config/commands.py +766 -0
  51. ccproxy/cli/commands/config/schema_commands.py +119 -0
  52. ccproxy/cli/commands/serve.py +630 -0
  53. ccproxy/cli/docker/__init__.py +34 -0
  54. ccproxy/cli/docker/adapter_factory.py +157 -0
  55. ccproxy/cli/docker/params.py +278 -0
  56. ccproxy/cli/helpers.py +144 -0
  57. ccproxy/cli/main.py +193 -0
  58. ccproxy/cli/options/__init__.py +14 -0
  59. ccproxy/cli/options/claude_options.py +216 -0
  60. ccproxy/cli/options/core_options.py +40 -0
  61. ccproxy/cli/options/security_options.py +48 -0
  62. ccproxy/cli/options/server_options.py +117 -0
  63. ccproxy/config/__init__.py +40 -0
  64. ccproxy/config/auth.py +154 -0
  65. ccproxy/config/claude.py +124 -0
  66. ccproxy/config/cors.py +79 -0
  67. ccproxy/config/discovery.py +87 -0
  68. ccproxy/config/docker_settings.py +265 -0
  69. ccproxy/config/loader.py +108 -0
  70. ccproxy/config/observability.py +158 -0
  71. ccproxy/config/pricing.py +88 -0
  72. ccproxy/config/reverse_proxy.py +31 -0
  73. ccproxy/config/scheduler.py +89 -0
  74. ccproxy/config/security.py +14 -0
  75. ccproxy/config/server.py +81 -0
  76. ccproxy/config/settings.py +534 -0
  77. ccproxy/config/validators.py +231 -0
  78. ccproxy/core/__init__.py +274 -0
  79. ccproxy/core/async_utils.py +675 -0
  80. ccproxy/core/constants.py +97 -0
  81. ccproxy/core/errors.py +256 -0
  82. ccproxy/core/http.py +328 -0
  83. ccproxy/core/http_transformers.py +428 -0
  84. ccproxy/core/interfaces.py +247 -0
  85. ccproxy/core/logging.py +189 -0
  86. ccproxy/core/middleware.py +114 -0
  87. ccproxy/core/proxy.py +143 -0
  88. ccproxy/core/system.py +38 -0
  89. ccproxy/core/transformers.py +259 -0
  90. ccproxy/core/types.py +129 -0
  91. ccproxy/core/validators.py +288 -0
  92. ccproxy/docker/__init__.py +67 -0
  93. ccproxy/docker/adapter.py +588 -0
  94. ccproxy/docker/docker_path.py +207 -0
  95. ccproxy/docker/middleware.py +103 -0
  96. ccproxy/docker/models.py +228 -0
  97. ccproxy/docker/protocol.py +192 -0
  98. ccproxy/docker/stream_process.py +264 -0
  99. ccproxy/docker/validators.py +173 -0
  100. ccproxy/models/__init__.py +123 -0
  101. ccproxy/models/errors.py +42 -0
  102. ccproxy/models/messages.py +243 -0
  103. ccproxy/models/requests.py +85 -0
  104. ccproxy/models/responses.py +227 -0
  105. ccproxy/models/types.py +102 -0
  106. ccproxy/observability/__init__.py +51 -0
  107. ccproxy/observability/access_logger.py +400 -0
  108. ccproxy/observability/context.py +447 -0
  109. ccproxy/observability/metrics.py +539 -0
  110. ccproxy/observability/pushgateway.py +366 -0
  111. ccproxy/observability/sse_events.py +303 -0
  112. ccproxy/observability/stats_printer.py +755 -0
  113. ccproxy/observability/storage/__init__.py +1 -0
  114. ccproxy/observability/storage/duckdb_simple.py +665 -0
  115. ccproxy/observability/storage/models.py +55 -0
  116. ccproxy/pricing/__init__.py +19 -0
  117. ccproxy/pricing/cache.py +212 -0
  118. ccproxy/pricing/loader.py +267 -0
  119. ccproxy/pricing/models.py +106 -0
  120. ccproxy/pricing/updater.py +309 -0
  121. ccproxy/scheduler/__init__.py +39 -0
  122. ccproxy/scheduler/core.py +335 -0
  123. ccproxy/scheduler/exceptions.py +34 -0
  124. ccproxy/scheduler/manager.py +186 -0
  125. ccproxy/scheduler/registry.py +150 -0
  126. ccproxy/scheduler/tasks.py +484 -0
  127. ccproxy/services/__init__.py +10 -0
  128. ccproxy/services/claude_sdk_service.py +614 -0
  129. ccproxy/services/credentials/__init__.py +55 -0
  130. ccproxy/services/credentials/config.py +105 -0
  131. ccproxy/services/credentials/manager.py +562 -0
  132. ccproxy/services/credentials/oauth_client.py +482 -0
  133. ccproxy/services/proxy_service.py +1536 -0
  134. ccproxy/static/.keep +0 -0
  135. ccproxy/testing/__init__.py +34 -0
  136. ccproxy/testing/config.py +148 -0
  137. ccproxy/testing/content_generation.py +197 -0
  138. ccproxy/testing/mock_responses.py +262 -0
  139. ccproxy/testing/response_handlers.py +161 -0
  140. ccproxy/testing/scenarios.py +241 -0
  141. ccproxy/utils/__init__.py +6 -0
  142. ccproxy/utils/cost_calculator.py +210 -0
  143. ccproxy/utils/streaming_metrics.py +199 -0
  144. ccproxy_api-0.1.0.dist-info/METADATA +253 -0
  145. ccproxy_api-0.1.0.dist-info/RECORD +148 -0
  146. ccproxy_api-0.1.0.dist-info/WHEEL +4 -0
  147. ccproxy_api-0.1.0.dist-info/entry_points.txt +2 -0
  148. ccproxy_api-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,192 @@
1
+ """OS keyring storage implementation for token storage."""
2
+
3
+ import json
4
+ from typing import Optional
5
+
6
+ import keyring
7
+ from structlog import get_logger
8
+
9
+ from ccproxy.auth.exceptions import (
10
+ CredentialsInvalidError,
11
+ CredentialsStorageError,
12
+ )
13
+ from ccproxy.auth.models import ClaudeCredentials
14
+ from ccproxy.auth.storage.base import TokenStorage
15
+
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ class KeyringTokenStorage(TokenStorage):
21
+ """OS keyring storage implementation for Claude credentials."""
22
+
23
+ def __init__(
24
+ self, service_name: str = "claude-code-proxy", username: str = "default"
25
+ ):
26
+ """Initialize keyring storage.
27
+
28
+ Args:
29
+ service_name: Name of the service in the keyring
30
+ username: Username to associate with the stored credentials
31
+ """
32
+ self.service_name = service_name
33
+ self.username = username
34
+
35
+ async def load(self) -> ClaudeCredentials | None:
36
+ """Load credentials from the OS keyring.
37
+
38
+ Returns:
39
+ Parsed credentials if found and valid, None otherwise
40
+
41
+ Raises:
42
+ CredentialsStorageError: If the stored data is invalid
43
+ CredentialsStorageError: If there's an error reading from keyring
44
+ """
45
+ try:
46
+ import keyring
47
+ except ImportError as e:
48
+ raise CredentialsStorageError(
49
+ "keyring package is required for keyring storage. "
50
+ "Install it with: pip install keyring"
51
+ ) from e
52
+
53
+ try:
54
+ logger.debug(
55
+ "credentials_load_start",
56
+ source="keyring",
57
+ service_name=self.service_name,
58
+ )
59
+ password = keyring.get_password(self.service_name, self.username)
60
+
61
+ if password is None:
62
+ logger.debug(
63
+ "credentials_not_found",
64
+ source="keyring",
65
+ service_name=self.service_name,
66
+ )
67
+ return None
68
+
69
+ # Parse the stored JSON
70
+ data = json.loads(password)
71
+ credentials = ClaudeCredentials.model_validate(data)
72
+
73
+ self._log_credential_details(credentials)
74
+ return credentials
75
+
76
+ except json.JSONDecodeError as e:
77
+ raise CredentialsStorageError(
78
+ f"Failed to parse credentials from keyring: {e}"
79
+ ) from e
80
+ except Exception as e:
81
+ raise CredentialsStorageError(
82
+ f"Error loading credentials from keyring: {e}"
83
+ ) from e
84
+
85
+ def _log_credential_details(self, credentials: ClaudeCredentials) -> None:
86
+ """Log credential details safely."""
87
+ oauth_token = credentials.claude_ai_oauth
88
+ logger.debug(
89
+ "credentials_load_completed",
90
+ source="keyring",
91
+ subscription_type=oauth_token.subscription_type,
92
+ expires_at=str(oauth_token.expires_at_datetime),
93
+ is_expired=oauth_token.is_expired,
94
+ scopes=oauth_token.scopes,
95
+ )
96
+
97
+ async def save(self, credentials: ClaudeCredentials) -> bool:
98
+ """Save credentials to the OS keyring.
99
+
100
+ Args:
101
+ credentials: Credentials to save
102
+
103
+ Returns:
104
+ True if saved successfully, False otherwise
105
+
106
+ Raises:
107
+ CredentialsStorageError: If there's an error writing to keyring
108
+ """
109
+ try:
110
+ import keyring
111
+ except ImportError as e:
112
+ raise CredentialsStorageError(
113
+ "keyring package is required for keyring storage. "
114
+ "Install it with: pip install keyring"
115
+ ) from e
116
+
117
+ try:
118
+ # Convert to JSON string
119
+ data = credentials.model_dump(by_alias=True)
120
+ json_data = json.dumps(data)
121
+
122
+ # Store in keyring
123
+ keyring.set_password(self.service_name, self.username, json_data)
124
+
125
+ logger.debug(
126
+ "credentials_save_completed",
127
+ source="keyring",
128
+ service_name=self.service_name,
129
+ )
130
+ return True
131
+
132
+ except Exception as e:
133
+ raise CredentialsStorageError(
134
+ f"Error saving credentials to keyring: {e}"
135
+ ) from e
136
+
137
+ async def exists(self) -> bool:
138
+ """Check if credentials exist in the keyring.
139
+
140
+ Returns:
141
+ True if credentials exist, False otherwise
142
+ """
143
+ try:
144
+ import keyring
145
+ except ImportError:
146
+ return False
147
+
148
+ try:
149
+ password = keyring.get_password(self.service_name, self.username)
150
+ return password is not None
151
+ except Exception:
152
+ return False
153
+
154
+ async def delete(self) -> bool:
155
+ """Delete credentials from the keyring.
156
+
157
+ Returns:
158
+ True if deleted successfully, False otherwise
159
+
160
+ Raises:
161
+ CredentialsStorageError: If there's an error deleting from keyring
162
+ """
163
+ try:
164
+ import keyring
165
+ except ImportError as e:
166
+ raise CredentialsStorageError(
167
+ "keyring package is required for keyring storage. "
168
+ "Install it with: pip install keyring"
169
+ ) from e
170
+
171
+ try:
172
+ if await self.exists():
173
+ keyring.delete_password(self.service_name, self.username)
174
+ logger.debug(
175
+ "credentials_delete_completed",
176
+ source="keyring",
177
+ service_name=self.service_name,
178
+ )
179
+ return True
180
+ return False
181
+ except Exception as e:
182
+ raise CredentialsStorageError(
183
+ f"Error deleting credentials from keyring: {e}"
184
+ ) from e
185
+
186
+ def get_location(self) -> str:
187
+ """Get the storage location description.
188
+
189
+ Returns:
190
+ Description of the keyring storage location
191
+ """
192
+ return f"OS keyring (service: {self.service_name}, user: {self.username})"
@@ -0,0 +1,20 @@
1
+ """Claude SDK integration module."""
2
+
3
+ from .client import (
4
+ ClaudeSDKClient,
5
+ ClaudeSDKConnectionError,
6
+ ClaudeSDKError,
7
+ ClaudeSDKProcessError,
8
+ )
9
+ from .converter import MessageConverter
10
+ from .options import OptionsHandler
11
+
12
+
13
+ __all__ = [
14
+ "ClaudeSDKClient",
15
+ "ClaudeSDKError",
16
+ "ClaudeSDKConnectionError",
17
+ "ClaudeSDKProcessError",
18
+ "MessageConverter",
19
+ "OptionsHandler",
20
+ ]
@@ -0,0 +1,169 @@
1
+ """Claude SDK client wrapper for handling core Claude Code SDK interactions."""
2
+
3
+ from collections.abc import AsyncIterator
4
+ from typing import Any
5
+
6
+ import structlog
7
+
8
+ from ccproxy.core.async_utils import patched_typing
9
+ from ccproxy.core.errors import ClaudeProxyError, ServiceUnavailableError
10
+ from ccproxy.observability import timed_operation
11
+
12
+
13
+ with patched_typing():
14
+ from claude_code_sdk import (
15
+ AssistantMessage,
16
+ ClaudeCodeOptions,
17
+ CLIConnectionError,
18
+ CLIJSONDecodeError,
19
+ CLINotFoundError,
20
+ ProcessError,
21
+ ResultMessage,
22
+ SystemMessage,
23
+ UserMessage,
24
+ query,
25
+ )
26
+
27
+ logger = structlog.get_logger(__name__)
28
+
29
+
30
+ class ClaudeSDKError(Exception):
31
+ """Base exception for Claude SDK errors."""
32
+
33
+
34
+ class ClaudeSDKConnectionError(ClaudeSDKError):
35
+ """Raised when unable to connect to Claude Code."""
36
+
37
+
38
+ class ClaudeSDKProcessError(ClaudeSDKError):
39
+ """Raised when Claude Code process fails."""
40
+
41
+
42
+ class ClaudeSDKClient:
43
+ """
44
+ Minimal Claude SDK client wrapper that handles core SDK interactions.
45
+
46
+ This class provides a clean interface to the Claude Code SDK while handling
47
+ error translation and basic query execution.
48
+ """
49
+
50
+ def __init__(self) -> None:
51
+ """Initialize the Claude SDK client."""
52
+ self._last_api_call_time_ms: float = 0.0
53
+
54
+ async def query_completion(
55
+ self, prompt: str, options: ClaudeCodeOptions, request_id: str | None = None
56
+ ) -> AsyncIterator[UserMessage | AssistantMessage | SystemMessage | ResultMessage]:
57
+ """
58
+ Execute a query using the Claude Code SDK.
59
+
60
+ Args:
61
+ prompt: The prompt string to send to Claude
62
+ options: Claude Code options configuration
63
+ request_id: Optional request ID for correlation
64
+
65
+ Yields:
66
+ Messages from the Claude Code SDK
67
+
68
+ Raises:
69
+ ClaudeSDKError: If the query fails
70
+ """
71
+ async with timed_operation("claude_sdk_query", request_id) as op:
72
+ try:
73
+ logger.debug("claude_sdk_query_start", prompt_length=len(prompt))
74
+
75
+ message_count = 0
76
+ async for message in query(prompt=prompt, options=options):
77
+ message_count += 1
78
+ yield message
79
+
80
+ # Store final metrics
81
+ op["message_count"] = message_count
82
+ self._last_api_call_time_ms = op.get("duration_ms", 0.0)
83
+
84
+ logger.debug(
85
+ "claude_sdk_query_completed",
86
+ message_count=message_count,
87
+ duration_ms=op.get("duration_ms"),
88
+ )
89
+
90
+ except (CLINotFoundError, CLIConnectionError) as e:
91
+ logger.error(
92
+ "claude_sdk_connection_failed",
93
+ error=str(e),
94
+ error_type=type(e).__name__,
95
+ )
96
+ raise ServiceUnavailableError(
97
+ f"Claude CLI not available: {str(e)}"
98
+ ) from e
99
+ except (ProcessError, CLIJSONDecodeError) as e:
100
+ logger.error(
101
+ "claude_sdk_process_failed",
102
+ error=str(e),
103
+ error_type=type(e).__name__,
104
+ )
105
+ raise ClaudeProxyError(
106
+ message=f"Claude process error: {str(e)}",
107
+ error_type="service_unavailable_error",
108
+ status_code=503,
109
+ ) from e
110
+ except Exception as e:
111
+ logger.error(
112
+ "claude_sdk_unexpected_error_occurred",
113
+ error=str(e),
114
+ error_type=type(e).__name__,
115
+ )
116
+ raise ClaudeProxyError(
117
+ message=f"Unexpected error: {str(e)}",
118
+ error_type="internal_server_error",
119
+ status_code=500,
120
+ ) from e
121
+
122
+ def get_last_api_call_time_ms(self) -> float:
123
+ """
124
+ Get the duration of the last Claude API call in milliseconds.
125
+
126
+ Returns:
127
+ Duration in milliseconds, or 0.0 if no call has been made yet
128
+ """
129
+ return self._last_api_call_time_ms
130
+
131
+ async def validate_health(self) -> bool:
132
+ """
133
+ Validate that the Claude SDK is healthy.
134
+
135
+ Returns:
136
+ True if healthy, False otherwise
137
+ """
138
+ try:
139
+ logger.debug("health_check_start", component="claude_sdk")
140
+
141
+ # Simple health check - the SDK is available if we can import it
142
+ # More sophisticated checks could be added here
143
+ is_healthy = True
144
+
145
+ logger.debug(
146
+ "health_check_completed", component="claude_sdk", healthy=is_healthy
147
+ )
148
+ return is_healthy
149
+ except Exception as e:
150
+ logger.error(
151
+ "health_check_failed",
152
+ component="claude_sdk",
153
+ error=str(e),
154
+ error_type=type(e).__name__,
155
+ )
156
+ return False
157
+
158
+ async def close(self) -> None:
159
+ """Close the client and cleanup resources."""
160
+ # Claude Code SDK doesn't require explicit cleanup
161
+ pass
162
+
163
+ async def __aenter__(self) -> "ClaudeSDKClient":
164
+ """Async context manager entry."""
165
+ return self
166
+
167
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
168
+ """Async context manager exit."""
169
+ await self.close()