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
@@ -0,0 +1,389 @@
1
+ """Codex-specific transformers for request/response transformation."""
2
+
3
+ import json
4
+
5
+ import structlog
6
+ from typing_extensions import TypedDict
7
+
8
+ from ccproxy.core.transformers import RequestTransformer
9
+ from ccproxy.core.types import ProxyRequest, TransformContext
10
+ from ccproxy.models.detection import CodexCacheData
11
+
12
+
13
+ logger = structlog.get_logger(__name__)
14
+
15
+
16
+ class CodexRequestData(TypedDict):
17
+ """Typed structure for transformed Codex request data."""
18
+
19
+ method: str
20
+ url: str
21
+ headers: dict[str, str]
22
+ body: bytes | None
23
+
24
+
25
+ class CodexRequestTransformer(RequestTransformer):
26
+ """Codex request transformer for header and instructions field injection."""
27
+
28
+ def __init__(self) -> None:
29
+ """Initialize Codex request transformer."""
30
+ super().__init__()
31
+
32
+ async def _transform_request(
33
+ self, request: ProxyRequest, context: TransformContext | None = None
34
+ ) -> ProxyRequest:
35
+ """Transform a proxy request for Codex API.
36
+
37
+ Args:
38
+ request: The structured proxy request to transform
39
+ context: Optional transformation context
40
+
41
+ Returns:
42
+ The transformed proxy request
43
+ """
44
+ # Extract required data from context
45
+ access_token = ""
46
+ session_id = ""
47
+ account_id = ""
48
+ codex_detection_data = None
49
+
50
+ if context:
51
+ if hasattr(context, "access_token"):
52
+ access_token = context.access_token
53
+ elif isinstance(context, dict):
54
+ access_token = context.get("access_token", "")
55
+
56
+ if hasattr(context, "session_id"):
57
+ session_id = context.session_id
58
+ elif isinstance(context, dict):
59
+ session_id = context.get("session_id", "")
60
+
61
+ if hasattr(context, "account_id"):
62
+ account_id = context.account_id
63
+ elif isinstance(context, dict):
64
+ account_id = context.get("account_id", "")
65
+
66
+ if hasattr(context, "codex_detection_data"):
67
+ codex_detection_data = context.codex_detection_data
68
+ elif isinstance(context, dict):
69
+ codex_detection_data = context.get("codex_detection_data")
70
+
71
+ # Transform URL - remove codex prefix and forward to ChatGPT backend
72
+ transformed_url = self._transform_codex_url(request.url)
73
+
74
+ # Convert request body to bytes for header processing
75
+ body_bytes = None
76
+ if request.body:
77
+ if isinstance(request.body, bytes):
78
+ body_bytes = request.body
79
+ elif isinstance(request.body, str):
80
+ body_bytes = request.body.encode("utf-8")
81
+ elif isinstance(request.body, dict):
82
+ body_bytes = json.dumps(request.body).encode("utf-8")
83
+
84
+ # Transform headers with Codex CLI identity
85
+ transformed_headers = self.create_codex_headers(
86
+ request.headers,
87
+ access_token,
88
+ session_id,
89
+ account_id,
90
+ body_bytes,
91
+ codex_detection_data,
92
+ )
93
+
94
+ # Transform body to inject instructions
95
+ transformed_body = request.body
96
+ if request.body:
97
+ if isinstance(request.body, bytes):
98
+ transformed_body = self.transform_codex_body(
99
+ request.body, codex_detection_data
100
+ )
101
+ else:
102
+ # Convert to bytes if needed
103
+ body_bytes = (
104
+ json.dumps(request.body).encode("utf-8")
105
+ if isinstance(request.body, dict)
106
+ else str(request.body).encode("utf-8")
107
+ )
108
+ transformed_body = self.transform_codex_body(
109
+ body_bytes, codex_detection_data
110
+ )
111
+
112
+ # Create new transformed request
113
+ return ProxyRequest(
114
+ method=request.method,
115
+ url=transformed_url,
116
+ headers=transformed_headers,
117
+ params={}, # Query params handled in URL
118
+ body=transformed_body,
119
+ protocol=request.protocol,
120
+ timeout=request.timeout,
121
+ metadata=request.metadata,
122
+ )
123
+
124
+ async def transform_codex_request(
125
+ self,
126
+ method: str,
127
+ path: str,
128
+ headers: dict[str, str],
129
+ body: bytes | None,
130
+ access_token: str,
131
+ session_id: str,
132
+ account_id: str,
133
+ codex_detection_data: CodexCacheData | None = None,
134
+ target_base_url: str = "https://chatgpt.com/backend-api/codex",
135
+ ) -> CodexRequestData:
136
+ """Transform Codex request using direct parameters from ProxyService.
137
+
138
+ Args:
139
+ method: HTTP method
140
+ path: Request path
141
+ headers: Request headers
142
+ body: Request body
143
+ access_token: OAuth access token
144
+ session_id: Codex session ID
145
+ account_id: ChatGPT account ID
146
+ codex_detection_data: Optional Codex detection data
147
+ target_base_url: Base URL for the Codex API
148
+
149
+ Returns:
150
+ Dictionary with transformed request data (method, url, headers, body)
151
+ """
152
+ # Transform URL path
153
+ transformed_path = self._transform_codex_path(path)
154
+ target_url = f"{target_base_url.rstrip('/')}{transformed_path}"
155
+
156
+ # Transform body first (inject instructions)
157
+ codex_body = None
158
+ if body:
159
+ # body is guaranteed to be bytes due to parameter type
160
+ codex_body = self.transform_codex_body(body, codex_detection_data)
161
+
162
+ # Transform headers with Codex CLI identity and authentication
163
+ codex_headers = self.create_codex_headers(
164
+ headers, access_token, session_id, account_id, body, codex_detection_data
165
+ )
166
+
167
+ # Update Content-Length if body was transformed and size changed
168
+ if codex_body and body and len(codex_body) != len(body):
169
+ # Remove any existing content-length headers (case-insensitive)
170
+ codex_headers = {
171
+ k: v for k, v in codex_headers.items() if k.lower() != "content-length"
172
+ }
173
+ codex_headers["Content-Length"] = str(len(codex_body))
174
+ elif codex_body and not body:
175
+ # New body was created where none existed
176
+ codex_headers["Content-Length"] = str(len(codex_body))
177
+
178
+ return CodexRequestData(
179
+ method=method,
180
+ url=target_url,
181
+ headers=codex_headers,
182
+ body=codex_body,
183
+ )
184
+
185
+ def _transform_codex_url(self, url: str) -> str:
186
+ """Transform URL from proxy format to ChatGPT backend format."""
187
+ # Extract base URL and path
188
+ if "://" in url:
189
+ protocol, rest = url.split("://", 1)
190
+ if "/" in rest:
191
+ domain, path = rest.split("/", 1)
192
+ path = "/" + path
193
+ else:
194
+ path = "/"
195
+ else:
196
+ path = url if url.startswith("/") else "/" + url
197
+
198
+ # Transform path and build target URL
199
+ transformed_path = self._transform_codex_path(path)
200
+ return f"https://chatgpt.com/backend-api/codex{transformed_path}"
201
+
202
+ def _transform_codex_path(self, path: str) -> str:
203
+ """Transform request path for Codex API."""
204
+ # Remove /codex prefix if present
205
+ if path.startswith("/codex"):
206
+ path = path[6:] # Remove "/codex" prefix
207
+
208
+ # Ensure we have a valid path
209
+ if not path or path == "/":
210
+ path = "/responses"
211
+
212
+ # Handle session_id in path for /codex/{session_id}/responses pattern
213
+ if path.startswith("/") and "/" in path[1:]:
214
+ # This might be /{session_id}/responses - extract the responses part
215
+ parts = path.strip("/").split("/")
216
+ if len(parts) >= 2 and parts[-1] == "responses":
217
+ # Keep the /responses endpoint, session_id will be in headers
218
+ path = "/responses"
219
+
220
+ return path
221
+
222
+ def create_codex_headers(
223
+ self,
224
+ headers: dict[str, str],
225
+ access_token: str,
226
+ session_id: str,
227
+ account_id: str,
228
+ body: bytes | None = None,
229
+ codex_detection_data: CodexCacheData | None = None,
230
+ ) -> dict[str, str]:
231
+ """Create Codex headers with CLI identity and authentication."""
232
+ codex_headers = {}
233
+
234
+ # Strip potentially problematic headers
235
+ excluded_headers = {
236
+ "host",
237
+ "x-forwarded-for",
238
+ "x-forwarded-proto",
239
+ "x-forwarded-host",
240
+ "forwarded",
241
+ # Authentication headers to be replaced
242
+ "authorization",
243
+ "x-api-key",
244
+ # Compression headers to avoid decompression issues
245
+ "accept-encoding",
246
+ "content-encoding",
247
+ # CORS headers - should not be forwarded to upstream
248
+ "origin",
249
+ "access-control-request-method",
250
+ "access-control-request-headers",
251
+ "access-control-allow-origin",
252
+ "access-control-allow-methods",
253
+ "access-control-allow-headers",
254
+ "access-control-allow-credentials",
255
+ "access-control-max-age",
256
+ "access-control-expose-headers",
257
+ }
258
+
259
+ # Copy important headers (excluding problematic ones)
260
+ for key, value in headers.items():
261
+ lower_key = key.lower()
262
+ if lower_key not in excluded_headers:
263
+ codex_headers[key] = value
264
+
265
+ # Set authentication with OAuth token
266
+ if access_token:
267
+ codex_headers["Authorization"] = f"Bearer {access_token}"
268
+
269
+ # Set defaults for essential headers
270
+ if "content-type" not in [k.lower() for k in codex_headers]:
271
+ codex_headers["Content-Type"] = "application/json"
272
+ if "accept" not in [k.lower() for k in codex_headers]:
273
+ codex_headers["Accept"] = "application/json"
274
+
275
+ # Use detected Codex CLI headers when available
276
+ if codex_detection_data:
277
+ detected_headers = codex_detection_data.headers.to_headers_dict()
278
+ # Override with session-specific values
279
+ detected_headers["session_id"] = session_id
280
+ if account_id:
281
+ detected_headers["chatgpt-account-id"] = account_id
282
+ codex_headers.update(detected_headers)
283
+ logger.debug(
284
+ "using_detected_codex_headers",
285
+ version=codex_detection_data.codex_version,
286
+ )
287
+ else:
288
+ # Fallback to hardcoded Codex headers
289
+ codex_headers.update(
290
+ {
291
+ "session_id": session_id,
292
+ "originator": "codex_cli_rs",
293
+ "openai-beta": "responses=experimental",
294
+ "version": "0.21.0",
295
+ }
296
+ )
297
+ if account_id:
298
+ codex_headers["chatgpt-account-id"] = account_id
299
+ logger.debug("using_fallback_codex_headers")
300
+
301
+ # Don't set Accept header - let the backend handle it based on stream parameter
302
+ # Setting Accept: text/event-stream with stream:true in body causes 400 Bad Request
303
+ # The backend will determine the response format based on the stream parameter
304
+
305
+ return codex_headers
306
+
307
+ def _is_streaming_request(self, body: bytes | None) -> bool:
308
+ """Check if the request body indicates a streaming request (including injected default)."""
309
+ if not body:
310
+ return False
311
+
312
+ try:
313
+ data = json.loads(body.decode("utf-8"))
314
+ return data.get("stream", False) is True
315
+ except (json.JSONDecodeError, UnicodeDecodeError):
316
+ return False
317
+
318
+ def _is_user_streaming_request(self, body: bytes | None) -> bool:
319
+ """Check if the user explicitly requested streaming (has 'stream' field in original body)."""
320
+ if not body:
321
+ return False
322
+
323
+ try:
324
+ data = json.loads(body.decode("utf-8"))
325
+ # Only return True if user explicitly included "stream" field (regardless of its value)
326
+ return "stream" in data and data.get("stream") is True
327
+ except (json.JSONDecodeError, UnicodeDecodeError):
328
+ return False
329
+
330
+ def transform_codex_body(
331
+ self, body: bytes, codex_detection_data: CodexCacheData | None = None
332
+ ) -> bytes:
333
+ """Transform request body to inject Codex CLI instructions."""
334
+ if not body:
335
+ return body
336
+
337
+ try:
338
+ data = json.loads(body.decode("utf-8"))
339
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
340
+ # Return original if not valid JSON
341
+ logger.warning(
342
+ "codex_transform_json_decode_failed",
343
+ error=str(e),
344
+ body_preview=body[:200].decode("utf-8", errors="replace")
345
+ if body
346
+ else None,
347
+ body_length=len(body) if body else 0,
348
+ )
349
+ return body
350
+
351
+ # Check if this request already has the full Codex instructions
352
+ # If instructions field exists and is longer than 1000 chars, it's already set
353
+ if (
354
+ "instructions" in data
355
+ and data["instructions"]
356
+ and len(data["instructions"]) > 1000
357
+ ):
358
+ # This already has full Codex instructions, don't replace them
359
+ logger.debug("skipping_codex_transform_has_full_instructions")
360
+ return body
361
+
362
+ # Get the instructions to inject
363
+ detected_instructions = None
364
+ if codex_detection_data:
365
+ detected_instructions = codex_detection_data.instructions.instructions_field
366
+ else:
367
+ # Fallback instructions from req.json
368
+ detected_instructions = (
369
+ "You are a coding agent running in the Codex CLI, a terminal-based coding assistant. "
370
+ "Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful.\n\n"
371
+ "Your capabilities:\n"
372
+ "- Receive user prompts and other context provided by the harness, such as files in the workspace.\n"
373
+ "- Communicate with the user by streaming thinking & responses, and by making & updating plans.\n"
374
+ "- Emit function calls to run terminal commands and apply patches. Depending on how this specific run is configured, "
375
+ "you can request that these function calls be escalated to the user for approval before running. "
376
+ 'More on this in the "Sandbox and approvals" section.\n\n'
377
+ "Within this context, Codex refers to the open-source agentic coding interface "
378
+ "(not the old Codex language model built by OpenAI)."
379
+ )
380
+
381
+ # Always inject/override the instructions field
382
+ data["instructions"] = detected_instructions
383
+
384
+ # Only inject stream: true if user explicitly requested streaming or didn't specify
385
+ # For now, we'll inject stream: true by default since Codex seems to expect it
386
+ if "stream" not in data:
387
+ data["stream"] = True
388
+
389
+ return json.dumps(data, separators=(",", ":")).encode("utf-8")