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,428 @@
1
+ """HTTP-level transformers for proxy service."""
2
+
3
+ import json
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ import structlog
7
+
8
+ from ccproxy.core.transformers import RequestTransformer, ResponseTransformer
9
+ from ccproxy.core.types import ProxyRequest, ProxyResponse, TransformContext
10
+
11
+
12
+ if TYPE_CHECKING:
13
+ pass
14
+
15
+
16
+ logger = structlog.get_logger(__name__)
17
+
18
+ # Claude Code system prompt constants
19
+ claude_code_prompt = "You are Claude Code, Anthropic's official CLI for Claude."
20
+
21
+
22
+ def get_claude_code_prompt() -> dict[str, Any]:
23
+ """Get the Claude Code system prompt with cache control."""
24
+ return {
25
+ "type": "text",
26
+ "text": claude_code_prompt,
27
+ "cache_control": {"type": "ephemeral"},
28
+ }
29
+
30
+
31
+ class HTTPRequestTransformer(RequestTransformer):
32
+ """HTTP request transformer that implements the abstract RequestTransformer interface."""
33
+
34
+ def __init__(self) -> None:
35
+ """Initialize HTTP request transformer."""
36
+ super().__init__()
37
+
38
+ async def _transform_request(
39
+ self, request: ProxyRequest, context: TransformContext | None = None
40
+ ) -> ProxyRequest:
41
+ """Transform a proxy request according to the abstract interface.
42
+
43
+ Args:
44
+ request: The structured proxy request to transform
45
+ context: Optional transformation context
46
+
47
+ Returns:
48
+ The transformed proxy request
49
+ """
50
+ # Transform path
51
+ transformed_path = self.transform_path(
52
+ request.url.split("?")[0].split("/", 3)[-1]
53
+ if "/" in request.url
54
+ else request.url
55
+ )
56
+
57
+ # Build new URL with transformed path
58
+ base_url = "https://api.anthropic.com"
59
+ new_url = f"{base_url}{transformed_path}"
60
+
61
+ # Add query parameters
62
+ if request.params:
63
+ import urllib.parse
64
+
65
+ query_string = urllib.parse.urlencode(request.params)
66
+ new_url = f"{new_url}?{query_string}"
67
+
68
+ # Transform headers (requires access token from context)
69
+ access_token = ""
70
+ if context and hasattr(context, "access_token"):
71
+ access_token = context.access_token
72
+ elif context and isinstance(context, dict):
73
+ access_token = context.get("access_token", "")
74
+
75
+ transformed_headers = self.create_proxy_headers(request.headers, access_token)
76
+
77
+ # Transform body
78
+ transformed_body = request.body
79
+ if request.body:
80
+ if isinstance(request.body, bytes):
81
+ transformed_body = self.transform_request_body(
82
+ request.body, transformed_path
83
+ )
84
+ elif isinstance(request.body, str):
85
+ transformed_body = self.transform_request_body(
86
+ request.body.encode("utf-8"), transformed_path
87
+ )
88
+ elif isinstance(request.body, dict):
89
+ import json
90
+
91
+ transformed_body = self.transform_request_body(
92
+ json.dumps(request.body).encode("utf-8"), transformed_path
93
+ )
94
+
95
+ # Create new transformed request
96
+ return ProxyRequest(
97
+ method=request.method,
98
+ url=new_url,
99
+ headers=transformed_headers,
100
+ params={}, # Already included in URL
101
+ body=transformed_body,
102
+ protocol=request.protocol,
103
+ timeout=request.timeout,
104
+ metadata=request.metadata,
105
+ )
106
+
107
+ def transform_path(self, path: str, proxy_mode: str = "full") -> str:
108
+ """Transform request path."""
109
+ # Remove /api prefix if present (for new proxy endpoints)
110
+ if path.startswith("/api"):
111
+ path = path[4:] # Remove "/api" prefix
112
+
113
+ # Remove /openai prefix if present
114
+ if path.startswith("/openai"):
115
+ path = path[7:] # Remove "/openai" prefix
116
+
117
+ # Convert OpenAI chat completions to Anthropic messages
118
+ if path == "/v1/chat/completions":
119
+ return "/v1/messages"
120
+
121
+ return path
122
+
123
+ def create_proxy_headers(
124
+ self, headers: dict[str, str], access_token: str, proxy_mode: str = "full"
125
+ ) -> dict[str, str]:
126
+ """Create proxy headers from original headers with Claude CLI identity."""
127
+ proxy_headers = {}
128
+
129
+ # Strip potentially problematic headers
130
+ excluded_headers = {
131
+ "host",
132
+ "x-forwarded-for",
133
+ "x-forwarded-proto",
134
+ "x-forwarded-host",
135
+ "forwarded",
136
+ # Authentication headers to be replaced
137
+ "authorization",
138
+ "x-api-key",
139
+ # Compression headers to avoid decompression issues
140
+ "accept-encoding",
141
+ "content-encoding",
142
+ # CORS headers - should not be forwarded to upstream
143
+ "origin",
144
+ "access-control-request-method",
145
+ "access-control-request-headers",
146
+ "access-control-allow-origin",
147
+ "access-control-allow-methods",
148
+ "access-control-allow-headers",
149
+ "access-control-allow-credentials",
150
+ "access-control-max-age",
151
+ "access-control-expose-headers",
152
+ }
153
+
154
+ # Copy important headers (excluding problematic ones)
155
+ for key, value in headers.items():
156
+ lower_key = key.lower()
157
+ if lower_key not in excluded_headers:
158
+ proxy_headers[key] = value
159
+
160
+ # Set authentication with OAuth token
161
+ if access_token:
162
+ proxy_headers["Authorization"] = f"Bearer {access_token}"
163
+
164
+ # Set defaults for essential headers
165
+ if "content-type" not in [k.lower() for k in proxy_headers]:
166
+ proxy_headers["Content-Type"] = "application/json"
167
+ if "accept" not in [k.lower() for k in proxy_headers]:
168
+ proxy_headers["Accept"] = "application/json"
169
+ if "connection" not in [k.lower() for k in proxy_headers]:
170
+ proxy_headers["Connection"] = "keep-alive"
171
+
172
+ # Critical Claude/Anthropic headers for tools and beta features
173
+ proxy_headers["anthropic-beta"] = (
174
+ "claude-code-20250219,oauth-2025-04-20,"
175
+ "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
176
+ )
177
+ proxy_headers["anthropic-version"] = "2023-06-01"
178
+ proxy_headers["anthropic-dangerous-direct-browser-access"] = "true"
179
+
180
+ # Claude CLI identity headers
181
+ proxy_headers["x-app"] = "cli"
182
+ proxy_headers["User-Agent"] = "claude-cli/1.0.43 (external, cli)"
183
+
184
+ # Stainless SDK compatibility headers
185
+ proxy_headers["X-Stainless-Lang"] = "js"
186
+ proxy_headers["X-Stainless-Retry-Count"] = "0"
187
+ proxy_headers["X-Stainless-Timeout"] = "60"
188
+ proxy_headers["X-Stainless-Package-Version"] = "0.55.1"
189
+ proxy_headers["X-Stainless-OS"] = "Linux"
190
+ proxy_headers["X-Stainless-Arch"] = "x64"
191
+ proxy_headers["X-Stainless-Runtime"] = "node"
192
+ proxy_headers["X-Stainless-Runtime-Version"] = "v22.14.0"
193
+
194
+ # Standard HTTP headers for proper API interaction
195
+ proxy_headers["accept-language"] = "*"
196
+ proxy_headers["sec-fetch-mode"] = "cors"
197
+ # Note: accept-encoding removed to avoid compression issues
198
+ # HTTPX handles compression automatically
199
+
200
+ return proxy_headers
201
+
202
+ def transform_request_body(
203
+ self, body: bytes, path: str, proxy_mode: str = "full"
204
+ ) -> bytes:
205
+ """Transform request body."""
206
+ if not body:
207
+ return body
208
+
209
+ # Check if this is an OpenAI request and transform it
210
+ if self._is_openai_request(path, body):
211
+ # Transform OpenAI format to Anthropic format
212
+ body = self._transform_openai_to_anthropic(body)
213
+
214
+ # Apply system prompt transformation for Claude Code identity
215
+ return self.transform_system_prompt(body)
216
+
217
+ def transform_system_prompt(self, body: bytes) -> bytes:
218
+ """Transform system prompt to ensure Claude Code identification comes first.
219
+
220
+ Args:
221
+ body: Original request body as bytes
222
+
223
+ Returns:
224
+ Transformed request body as bytes with Claude Code system prompt
225
+ """
226
+ try:
227
+ import json
228
+
229
+ data = json.loads(body.decode("utf-8"))
230
+ except (json.JSONDecodeError, UnicodeDecodeError):
231
+ # Return original if not valid JSON
232
+ return body
233
+
234
+ # Check if request has a system prompt
235
+ if "system" not in data or (
236
+ isinstance(data["system"], str) and data["system"] == claude_code_prompt
237
+ ):
238
+ # No system prompt, inject Claude Code identification
239
+ data["system"] = [get_claude_code_prompt()]
240
+ return json.dumps(data).encode("utf-8")
241
+
242
+ system = data["system"]
243
+
244
+ if isinstance(system, str):
245
+ # Handle string system prompt
246
+ if system == claude_code_prompt:
247
+ # Already correct, convert to proper array format
248
+ data["system"] = [get_claude_code_prompt()]
249
+ return json.dumps(data).encode("utf-8")
250
+
251
+ # Prepend Claude Code prompt to existing string
252
+ data["system"] = [
253
+ get_claude_code_prompt(),
254
+ {"type": "text", "text": system},
255
+ ]
256
+
257
+ elif isinstance(system, list):
258
+ # Handle array system prompt
259
+ if len(system) > 0:
260
+ # Check if first element has correct text
261
+ first = system[0]
262
+ if isinstance(first, dict) and first.get("text") == claude_code_prompt:
263
+ # Already has Claude Code first, ensure it has cache_control
264
+ data["system"][0] = get_claude_code_prompt()
265
+ return json.dumps(data).encode("utf-8")
266
+
267
+ # Prepend Claude Code prompt
268
+ data["system"] = [get_claude_code_prompt()] + system
269
+
270
+ return json.dumps(data).encode("utf-8")
271
+
272
+ def _is_openai_request(self, path: str, body: bytes) -> bool:
273
+ """Check if this is an OpenAI API request."""
274
+ # Check path-based indicators
275
+ if "/openai/" in path or "/chat/completions" in path:
276
+ return True
277
+
278
+ # Check body-based indicators
279
+ if body:
280
+ try:
281
+ import json
282
+
283
+ data = json.loads(body.decode("utf-8"))
284
+ # Look for OpenAI-specific patterns
285
+ model = data.get("model", "")
286
+ if model.startswith(("gpt-", "o1-", "text-davinci")):
287
+ return True
288
+ # Check for OpenAI message format with system in messages
289
+ messages = data.get("messages", [])
290
+ if messages and any(msg.get("role") == "system" for msg in messages):
291
+ return True
292
+ except (json.JSONDecodeError, UnicodeDecodeError):
293
+ pass
294
+
295
+ return False
296
+
297
+ def _transform_openai_to_anthropic(self, body: bytes) -> bytes:
298
+ """Transform OpenAI request format to Anthropic format."""
299
+ try:
300
+ # Use the OpenAI adapter for transformation
301
+ import json
302
+
303
+ from ccproxy.adapters.openai.adapter import OpenAIAdapter
304
+
305
+ adapter = OpenAIAdapter()
306
+ openai_data = json.loads(body.decode("utf-8"))
307
+ anthropic_data = adapter.adapt_request(openai_data)
308
+ return json.dumps(anthropic_data).encode("utf-8")
309
+
310
+ except Exception as e:
311
+ logger.warning(
312
+ "openai_transformation_failed",
313
+ error=str(e),
314
+ operation="transform_openai_to_anthropic",
315
+ )
316
+ # Return original body if transformation fails
317
+ return body
318
+
319
+
320
+ class HTTPResponseTransformer(ResponseTransformer):
321
+ """HTTP response transformer that implements the abstract ResponseTransformer interface."""
322
+
323
+ def __init__(self) -> None:
324
+ """Initialize HTTP response transformer."""
325
+ super().__init__()
326
+
327
+ async def _transform_response(
328
+ self, response: ProxyResponse, context: TransformContext | None = None
329
+ ) -> ProxyResponse:
330
+ """Transform a proxy response according to the abstract interface.
331
+
332
+ Args:
333
+ response: The structured proxy response to transform
334
+ context: Optional transformation context
335
+
336
+ Returns:
337
+ The transformed proxy response
338
+ """
339
+ # Extract original path from context for transformation decisions
340
+ original_path = ""
341
+ if context and hasattr(context, "original_path"):
342
+ original_path = context.original_path
343
+ elif context and isinstance(context, dict):
344
+ original_path = context.get("original_path", "")
345
+
346
+ # Transform response body
347
+ transformed_body = response.body
348
+ if response.body:
349
+ if isinstance(response.body, bytes):
350
+ transformed_body = self.transform_response_body(
351
+ response.body, original_path
352
+ )
353
+ elif isinstance(response.body, str):
354
+ body_bytes = response.body.encode("utf-8")
355
+ transformed_body = self.transform_response_body(
356
+ body_bytes, original_path
357
+ )
358
+ elif isinstance(response.body, dict):
359
+ import json
360
+
361
+ body_bytes = json.dumps(response.body).encode("utf-8")
362
+ transformed_body = self.transform_response_body(
363
+ body_bytes, original_path
364
+ )
365
+
366
+ # Calculate content length for transformed body
367
+ content_length = 0
368
+ if transformed_body:
369
+ if isinstance(transformed_body, bytes):
370
+ content_length = len(transformed_body)
371
+ elif isinstance(transformed_body, str):
372
+ content_length = len(transformed_body.encode("utf-8"))
373
+ else:
374
+ content_length = len(str(transformed_body))
375
+
376
+ # Transform response headers
377
+ transformed_headers = self.transform_response_headers(
378
+ response.headers, original_path, content_length
379
+ )
380
+
381
+ # Create new transformed response
382
+ return ProxyResponse(
383
+ status_code=response.status_code,
384
+ headers=transformed_headers,
385
+ body=transformed_body,
386
+ metadata=response.metadata,
387
+ )
388
+
389
+ def transform_response_body(
390
+ self, body: bytes, path: str, proxy_mode: str = "full"
391
+ ) -> bytes:
392
+ """Transform response body."""
393
+ # Basic body transformation - pass through for now
394
+ return body
395
+
396
+ def transform_response_headers(
397
+ self,
398
+ headers: dict[str, str],
399
+ path: str,
400
+ content_length: int,
401
+ proxy_mode: str = "full",
402
+ ) -> dict[str, str]:
403
+ """Transform response headers."""
404
+ transformed_headers = {}
405
+
406
+ # Copy important headers
407
+ for key, value in headers.items():
408
+ lower_key = key.lower()
409
+ if lower_key not in [
410
+ "content-length",
411
+ "transfer-encoding",
412
+ "content-encoding",
413
+ ]:
414
+ transformed_headers[key] = value
415
+
416
+ # Set content length
417
+ transformed_headers["Content-Length"] = str(content_length)
418
+
419
+ # Add CORS headers
420
+ transformed_headers["Access-Control-Allow-Origin"] = "*"
421
+ transformed_headers["Access-Control-Allow-Headers"] = "*"
422
+ transformed_headers["Access-Control-Allow-Methods"] = "*"
423
+
424
+ return transformed_headers
425
+
426
+ def _is_openai_request(self, path: str) -> bool:
427
+ """Check if this is an OpenAI API request."""
428
+ return "/openai/" in path or "/chat/completions" in path
@@ -0,0 +1,247 @@
1
+ """Core interfaces and abstract base classes for the CCProxy API.
2
+
3
+ This module consolidates all abstract interfaces used throughout the application,
4
+ providing a single location for defining contracts and protocols.
5
+ """
6
+
7
+ from abc import ABC, abstractmethod
8
+ from collections.abc import AsyncIterator
9
+ from typing import Any, Optional, Protocol, TypeVar, runtime_checkable
10
+
11
+ from ccproxy.auth.models import ClaudeCredentials
12
+ from ccproxy.core.types import ProxyRequest, ProxyResponse, TransformContext
13
+
14
+
15
+ __all__ = [
16
+ # Transformation interfaces
17
+ "RequestTransformer",
18
+ "ResponseTransformer",
19
+ "StreamTransformer",
20
+ "APIAdapter",
21
+ "TransformerProtocol",
22
+ # Storage interfaces
23
+ "TokenStorage",
24
+ # Metrics interfaces
25
+ "MetricExporter",
26
+ ]
27
+
28
+
29
+ T = TypeVar("T", contravariant=True)
30
+ R = TypeVar("R", covariant=True)
31
+
32
+
33
+ # === Transformation Interfaces ===
34
+
35
+
36
+ class RequestTransformer(ABC):
37
+ """Abstract interface for request transformers."""
38
+
39
+ @abstractmethod
40
+ async def transform_request(self, request: dict[str, Any]) -> dict[str, Any]:
41
+ """Transform a request from one format to another.
42
+
43
+ Args:
44
+ request: The request data to transform
45
+
46
+ Returns:
47
+ The transformed request data
48
+
49
+ Raises:
50
+ ValueError: If the request format is invalid or unsupported
51
+ """
52
+ pass
53
+
54
+
55
+ class ResponseTransformer(ABC):
56
+ """Abstract interface for response transformers."""
57
+
58
+ @abstractmethod
59
+ async def transform_response(self, response: dict[str, Any]) -> dict[str, Any]:
60
+ """Transform a response from one format to another.
61
+
62
+ Args:
63
+ response: The response data to transform
64
+
65
+ Returns:
66
+ The transformed response data
67
+
68
+ Raises:
69
+ ValueError: If the response format is invalid or unsupported
70
+ """
71
+ pass
72
+
73
+
74
+ class StreamTransformer(ABC):
75
+ """Abstract interface for stream transformers."""
76
+
77
+ @abstractmethod
78
+ async def transform_stream(
79
+ self, stream: AsyncIterator[dict[str, Any]]
80
+ ) -> AsyncIterator[dict[str, Any]]:
81
+ """Transform a streaming response from one format to another.
82
+
83
+ Args:
84
+ stream: The streaming response data to transform
85
+
86
+ Yields:
87
+ The transformed streaming response chunks
88
+
89
+ Raises:
90
+ ValueError: If the stream format is invalid or unsupported
91
+ """
92
+ pass
93
+
94
+
95
+ class APIAdapter(ABC):
96
+ """Abstract base class for API format adapters.
97
+
98
+ Combines all transformation interfaces to provide a complete adapter
99
+ for converting between different API formats.
100
+ """
101
+
102
+ @abstractmethod
103
+ def adapt_request(self, request: dict[str, Any]) -> dict[str, Any]:
104
+ """Convert a request from one API format to another.
105
+
106
+ Args:
107
+ request: The request data to convert
108
+
109
+ Returns:
110
+ The converted request data
111
+
112
+ Raises:
113
+ ValueError: If the request format is invalid or unsupported
114
+ """
115
+ pass
116
+
117
+ @abstractmethod
118
+ def adapt_response(self, response: dict[str, Any]) -> dict[str, Any]:
119
+ """Convert a response from one API format to another.
120
+
121
+ Args:
122
+ response: The response data to convert
123
+
124
+ Returns:
125
+ The converted response data
126
+
127
+ Raises:
128
+ ValueError: If the response format is invalid or unsupported
129
+ """
130
+ pass
131
+
132
+ @abstractmethod
133
+ def adapt_stream(
134
+ self, stream: AsyncIterator[dict[str, Any]]
135
+ ) -> AsyncIterator[dict[str, Any]]:
136
+ """Convert a streaming response from one API format to another.
137
+
138
+ Args:
139
+ stream: The streaming response data to convert
140
+
141
+ Yields:
142
+ The converted streaming response chunks
143
+
144
+ Raises:
145
+ ValueError: If the stream format is invalid or unsupported
146
+ """
147
+ # This should be implemented as an async generator
148
+ # async def adapt_stream(self, stream): ...
149
+ # async for item in stream:
150
+ # yield transformed_item
151
+ raise NotImplementedError
152
+
153
+
154
+ @runtime_checkable
155
+ class TransformerProtocol(Protocol[T, R]):
156
+ """Protocol defining the transformer interface."""
157
+
158
+ async def transform(self, data: T, context: TransformContext | None = None) -> R:
159
+ """Transform the input data."""
160
+ ...
161
+
162
+
163
+ # === Storage Interfaces ===
164
+
165
+
166
+ class TokenStorage(ABC):
167
+ """Abstract interface for token storage backends."""
168
+
169
+ @abstractmethod
170
+ async def load(self) -> ClaudeCredentials | None:
171
+ """Load credentials from storage.
172
+
173
+ Returns:
174
+ Parsed credentials if found and valid, None otherwise
175
+ """
176
+ pass
177
+
178
+ @abstractmethod
179
+ async def save(self, credentials: ClaudeCredentials) -> bool:
180
+ """Save credentials to storage.
181
+
182
+ Args:
183
+ credentials: Credentials to save
184
+
185
+ Returns:
186
+ True if saved successfully, False otherwise
187
+ """
188
+ pass
189
+
190
+ @abstractmethod
191
+ async def exists(self) -> bool:
192
+ """Check if credentials exist in storage.
193
+
194
+ Returns:
195
+ True if credentials exist, False otherwise
196
+ """
197
+ pass
198
+
199
+ @abstractmethod
200
+ async def delete(self) -> bool:
201
+ """Delete credentials from storage.
202
+
203
+ Returns:
204
+ True if deleted successfully, False otherwise
205
+ """
206
+ pass
207
+
208
+ @abstractmethod
209
+ def get_location(self) -> str:
210
+ """Get the storage location description.
211
+
212
+ Returns:
213
+ Human-readable description of where credentials are stored
214
+ """
215
+ pass
216
+
217
+
218
+ # === Metrics Interfaces ===
219
+
220
+
221
+ class MetricExporter(ABC):
222
+ """Abstract interface for exporting metrics to external systems."""
223
+
224
+ @abstractmethod
225
+ async def export_metrics(self, metrics: dict[str, Any]) -> bool:
226
+ """Export metrics to the target system.
227
+
228
+ Args:
229
+ metrics: Dictionary of metrics to export
230
+
231
+ Returns:
232
+ True if export was successful, False otherwise
233
+
234
+ Raises:
235
+ ConnectionError: If unable to connect to the metrics backend
236
+ ValueError: If metrics format is invalid
237
+ """
238
+ pass
239
+
240
+ @abstractmethod
241
+ async def health_check(self) -> bool:
242
+ """Check if the metrics export system is healthy.
243
+
244
+ Returns:
245
+ True if the system is healthy, False otherwise
246
+ """
247
+ pass