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.
- ccproxy/__init__.py +4 -0
- ccproxy/__main__.py +7 -0
- ccproxy/_version.py +21 -0
- ccproxy/adapters/__init__.py +11 -0
- ccproxy/adapters/base.py +80 -0
- ccproxy/adapters/openai/__init__.py +43 -0
- ccproxy/adapters/openai/adapter.py +915 -0
- ccproxy/adapters/openai/models.py +412 -0
- ccproxy/adapters/openai/streaming.py +449 -0
- ccproxy/api/__init__.py +28 -0
- ccproxy/api/app.py +225 -0
- ccproxy/api/dependencies.py +140 -0
- ccproxy/api/middleware/__init__.py +11 -0
- ccproxy/api/middleware/auth.py +0 -0
- ccproxy/api/middleware/cors.py +55 -0
- ccproxy/api/middleware/errors.py +703 -0
- ccproxy/api/middleware/headers.py +51 -0
- ccproxy/api/middleware/logging.py +175 -0
- ccproxy/api/middleware/request_id.py +69 -0
- ccproxy/api/middleware/server_header.py +62 -0
- ccproxy/api/responses.py +84 -0
- ccproxy/api/routes/__init__.py +16 -0
- ccproxy/api/routes/claude.py +181 -0
- ccproxy/api/routes/health.py +489 -0
- ccproxy/api/routes/metrics.py +1033 -0
- ccproxy/api/routes/proxy.py +238 -0
- ccproxy/auth/__init__.py +75 -0
- ccproxy/auth/bearer.py +68 -0
- ccproxy/auth/credentials_adapter.py +93 -0
- ccproxy/auth/dependencies.py +229 -0
- ccproxy/auth/exceptions.py +79 -0
- ccproxy/auth/manager.py +102 -0
- ccproxy/auth/models.py +118 -0
- ccproxy/auth/oauth/__init__.py +26 -0
- ccproxy/auth/oauth/models.py +49 -0
- ccproxy/auth/oauth/routes.py +396 -0
- ccproxy/auth/oauth/storage.py +0 -0
- ccproxy/auth/storage/__init__.py +12 -0
- ccproxy/auth/storage/base.py +57 -0
- ccproxy/auth/storage/json_file.py +159 -0
- ccproxy/auth/storage/keyring.py +192 -0
- ccproxy/claude_sdk/__init__.py +20 -0
- ccproxy/claude_sdk/client.py +169 -0
- ccproxy/claude_sdk/converter.py +331 -0
- ccproxy/claude_sdk/options.py +120 -0
- ccproxy/cli/__init__.py +14 -0
- ccproxy/cli/commands/__init__.py +8 -0
- ccproxy/cli/commands/auth.py +553 -0
- ccproxy/cli/commands/config/__init__.py +14 -0
- ccproxy/cli/commands/config/commands.py +766 -0
- ccproxy/cli/commands/config/schema_commands.py +119 -0
- ccproxy/cli/commands/serve.py +630 -0
- ccproxy/cli/docker/__init__.py +34 -0
- ccproxy/cli/docker/adapter_factory.py +157 -0
- ccproxy/cli/docker/params.py +278 -0
- ccproxy/cli/helpers.py +144 -0
- ccproxy/cli/main.py +193 -0
- ccproxy/cli/options/__init__.py +14 -0
- ccproxy/cli/options/claude_options.py +216 -0
- ccproxy/cli/options/core_options.py +40 -0
- ccproxy/cli/options/security_options.py +48 -0
- ccproxy/cli/options/server_options.py +117 -0
- ccproxy/config/__init__.py +40 -0
- ccproxy/config/auth.py +154 -0
- ccproxy/config/claude.py +124 -0
- ccproxy/config/cors.py +79 -0
- ccproxy/config/discovery.py +87 -0
- ccproxy/config/docker_settings.py +265 -0
- ccproxy/config/loader.py +108 -0
- ccproxy/config/observability.py +158 -0
- ccproxy/config/pricing.py +88 -0
- ccproxy/config/reverse_proxy.py +31 -0
- ccproxy/config/scheduler.py +89 -0
- ccproxy/config/security.py +14 -0
- ccproxy/config/server.py +81 -0
- ccproxy/config/settings.py +534 -0
- ccproxy/config/validators.py +231 -0
- ccproxy/core/__init__.py +274 -0
- ccproxy/core/async_utils.py +675 -0
- ccproxy/core/constants.py +97 -0
- ccproxy/core/errors.py +256 -0
- ccproxy/core/http.py +328 -0
- ccproxy/core/http_transformers.py +428 -0
- ccproxy/core/interfaces.py +247 -0
- ccproxy/core/logging.py +189 -0
- ccproxy/core/middleware.py +114 -0
- ccproxy/core/proxy.py +143 -0
- ccproxy/core/system.py +38 -0
- ccproxy/core/transformers.py +259 -0
- ccproxy/core/types.py +129 -0
- ccproxy/core/validators.py +288 -0
- ccproxy/docker/__init__.py +67 -0
- ccproxy/docker/adapter.py +588 -0
- ccproxy/docker/docker_path.py +207 -0
- ccproxy/docker/middleware.py +103 -0
- ccproxy/docker/models.py +228 -0
- ccproxy/docker/protocol.py +192 -0
- ccproxy/docker/stream_process.py +264 -0
- ccproxy/docker/validators.py +173 -0
- ccproxy/models/__init__.py +123 -0
- ccproxy/models/errors.py +42 -0
- ccproxy/models/messages.py +243 -0
- ccproxy/models/requests.py +85 -0
- ccproxy/models/responses.py +227 -0
- ccproxy/models/types.py +102 -0
- ccproxy/observability/__init__.py +51 -0
- ccproxy/observability/access_logger.py +400 -0
- ccproxy/observability/context.py +447 -0
- ccproxy/observability/metrics.py +539 -0
- ccproxy/observability/pushgateway.py +366 -0
- ccproxy/observability/sse_events.py +303 -0
- ccproxy/observability/stats_printer.py +755 -0
- ccproxy/observability/storage/__init__.py +1 -0
- ccproxy/observability/storage/duckdb_simple.py +665 -0
- ccproxy/observability/storage/models.py +55 -0
- ccproxy/pricing/__init__.py +19 -0
- ccproxy/pricing/cache.py +212 -0
- ccproxy/pricing/loader.py +267 -0
- ccproxy/pricing/models.py +106 -0
- ccproxy/pricing/updater.py +309 -0
- ccproxy/scheduler/__init__.py +39 -0
- ccproxy/scheduler/core.py +335 -0
- ccproxy/scheduler/exceptions.py +34 -0
- ccproxy/scheduler/manager.py +186 -0
- ccproxy/scheduler/registry.py +150 -0
- ccproxy/scheduler/tasks.py +484 -0
- ccproxy/services/__init__.py +10 -0
- ccproxy/services/claude_sdk_service.py +614 -0
- ccproxy/services/credentials/__init__.py +55 -0
- ccproxy/services/credentials/config.py +105 -0
- ccproxy/services/credentials/manager.py +562 -0
- ccproxy/services/credentials/oauth_client.py +482 -0
- ccproxy/services/proxy_service.py +1536 -0
- ccproxy/static/.keep +0 -0
- ccproxy/testing/__init__.py +34 -0
- ccproxy/testing/config.py +148 -0
- ccproxy/testing/content_generation.py +197 -0
- ccproxy/testing/mock_responses.py +262 -0
- ccproxy/testing/response_handlers.py +161 -0
- ccproxy/testing/scenarios.py +241 -0
- ccproxy/utils/__init__.py +6 -0
- ccproxy/utils/cost_calculator.py +210 -0
- ccproxy/utils/streaming_metrics.py +199 -0
- ccproxy_api-0.1.0.dist-info/METADATA +253 -0
- ccproxy_api-0.1.0.dist-info/RECORD +148 -0
- ccproxy_api-0.1.0.dist-info/WHEEL +4 -0
- ccproxy_api-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|