ccproxy-api 0.1.5__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.
- ccproxy/_version.py +2 -2
- ccproxy/adapters/codex/__init__.py +11 -0
- ccproxy/adapters/openai/models.py +1 -1
- ccproxy/adapters/openai/response_adapter.py +355 -0
- ccproxy/adapters/openai/response_models.py +178 -0
- ccproxy/api/app.py +16 -0
- ccproxy/api/routes/codex.py +1231 -0
- ccproxy/api/routes/health.py +228 -3
- ccproxy/auth/openai/__init__.py +13 -0
- ccproxy/auth/openai/credentials.py +166 -0
- ccproxy/auth/openai/oauth_client.py +334 -0
- ccproxy/auth/openai/storage.py +184 -0
- ccproxy/claude_sdk/options.py +1 -1
- ccproxy/cli/commands/auth.py +398 -1
- ccproxy/cli/commands/serve.py +3 -1
- ccproxy/config/claude.py +1 -1
- ccproxy/config/codex.py +100 -0
- ccproxy/config/scheduler.py +4 -4
- ccproxy/config/settings.py +19 -0
- ccproxy/core/codex_transformers.py +389 -0
- ccproxy/core/http_transformers.py +153 -2
- ccproxy/models/detection.py +82 -0
- ccproxy/models/requests.py +22 -0
- ccproxy/models/responses.py +16 -0
- ccproxy/services/codex_detection_service.py +263 -0
- ccproxy/services/proxy_service.py +530 -0
- ccproxy/utils/model_mapping.py +7 -5
- ccproxy/utils/startup_helpers.py +62 -0
- ccproxy_api-0.1.6.dist-info/METADATA +615 -0
- {ccproxy_api-0.1.5.dist-info → ccproxy_api-0.1.6.dist-info}/RECORD +33 -22
- ccproxy_api-0.1.5.dist-info/METADATA +0 -396
- {ccproxy_api-0.1.5.dist-info → ccproxy_api-0.1.6.dist-info}/WHEEL +0 -0
- {ccproxy_api-0.1.5.dist-info → ccproxy_api-0.1.6.dist-info}/entry_points.txt +0 -0
- {ccproxy_api-0.1.5.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")
|
|
@@ -361,6 +361,139 @@ class HTTPRequestTransformer(RequestTransformer):
|
|
|
361
361
|
|
|
362
362
|
return proxy_headers
|
|
363
363
|
|
|
364
|
+
def _count_cache_control_blocks(self, data: dict[str, Any]) -> dict[str, int]:
|
|
365
|
+
"""Count cache_control blocks in different parts of the request.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
Dictionary with counts for 'injected_system', 'user_system', and 'messages'
|
|
369
|
+
"""
|
|
370
|
+
counts = {"injected_system": 0, "user_system": 0, "messages": 0}
|
|
371
|
+
|
|
372
|
+
# Count in system field
|
|
373
|
+
system = data.get("system")
|
|
374
|
+
if system:
|
|
375
|
+
if isinstance(system, str):
|
|
376
|
+
# String system prompts don't have cache_control
|
|
377
|
+
pass
|
|
378
|
+
elif isinstance(system, list):
|
|
379
|
+
# Count cache_control in system prompt blocks
|
|
380
|
+
# The first block(s) are injected, rest are user's
|
|
381
|
+
injected_count = 0
|
|
382
|
+
for i, block in enumerate(system):
|
|
383
|
+
if isinstance(block, dict) and "cache_control" in block:
|
|
384
|
+
# Check if this is the injected prompt (contains Claude Code identity)
|
|
385
|
+
text = block.get("text", "")
|
|
386
|
+
if "Claude Code" in text or "Anthropic's official CLI" in text:
|
|
387
|
+
counts["injected_system"] += 1
|
|
388
|
+
injected_count = max(injected_count, i + 1)
|
|
389
|
+
elif i < injected_count:
|
|
390
|
+
# Part of injected system (multiple blocks)
|
|
391
|
+
counts["injected_system"] += 1
|
|
392
|
+
else:
|
|
393
|
+
counts["user_system"] += 1
|
|
394
|
+
|
|
395
|
+
# Count in messages
|
|
396
|
+
messages = data.get("messages", [])
|
|
397
|
+
for msg in messages:
|
|
398
|
+
content = msg.get("content")
|
|
399
|
+
if isinstance(content, list):
|
|
400
|
+
for block in content:
|
|
401
|
+
if isinstance(block, dict) and "cache_control" in block:
|
|
402
|
+
counts["messages"] += 1
|
|
403
|
+
|
|
404
|
+
return counts
|
|
405
|
+
|
|
406
|
+
def _limit_cache_control_blocks(
|
|
407
|
+
self, data: dict[str, Any], max_blocks: int = 4
|
|
408
|
+
) -> dict[str, Any]:
|
|
409
|
+
"""Limit the number of cache_control blocks to comply with Anthropic's limit.
|
|
410
|
+
|
|
411
|
+
Priority order:
|
|
412
|
+
1. Injected system prompt cache_control (highest priority - Claude Code identity)
|
|
413
|
+
2. User's system prompt cache_control
|
|
414
|
+
3. User's message cache_control (lowest priority)
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
data: Request data dictionary
|
|
418
|
+
max_blocks: Maximum number of cache_control blocks allowed (default: 4)
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
Modified data dictionary with cache_control blocks limited
|
|
422
|
+
"""
|
|
423
|
+
import copy
|
|
424
|
+
|
|
425
|
+
# Deep copy to avoid modifying original
|
|
426
|
+
data = copy.deepcopy(data)
|
|
427
|
+
|
|
428
|
+
# Count existing blocks
|
|
429
|
+
counts = self._count_cache_control_blocks(data)
|
|
430
|
+
total = counts["injected_system"] + counts["user_system"] + counts["messages"]
|
|
431
|
+
|
|
432
|
+
if total <= max_blocks:
|
|
433
|
+
# No need to remove anything
|
|
434
|
+
return data
|
|
435
|
+
|
|
436
|
+
logger.warning(
|
|
437
|
+
"cache_control_limit_exceeded",
|
|
438
|
+
total_blocks=total,
|
|
439
|
+
max_blocks=max_blocks,
|
|
440
|
+
injected=counts["injected_system"],
|
|
441
|
+
user_system=counts["user_system"],
|
|
442
|
+
messages=counts["messages"],
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
# Calculate how many to remove
|
|
446
|
+
to_remove = total - max_blocks
|
|
447
|
+
removed = 0
|
|
448
|
+
|
|
449
|
+
# Remove from messages first (lowest priority)
|
|
450
|
+
if to_remove > 0 and counts["messages"] > 0:
|
|
451
|
+
messages = data.get("messages", [])
|
|
452
|
+
for msg in reversed(messages): # Remove from end first
|
|
453
|
+
if removed >= to_remove:
|
|
454
|
+
break
|
|
455
|
+
content = msg.get("content")
|
|
456
|
+
if isinstance(content, list):
|
|
457
|
+
for block in reversed(content):
|
|
458
|
+
if removed >= to_remove:
|
|
459
|
+
break
|
|
460
|
+
if isinstance(block, dict) and "cache_control" in block:
|
|
461
|
+
del block["cache_control"]
|
|
462
|
+
removed += 1
|
|
463
|
+
logger.debug("removed_cache_control", location="message")
|
|
464
|
+
|
|
465
|
+
# Remove from user system prompts next
|
|
466
|
+
if removed < to_remove and counts["user_system"] > 0:
|
|
467
|
+
system = data.get("system")
|
|
468
|
+
if isinstance(system, list):
|
|
469
|
+
# Find and remove cache_control from user system blocks (non-injected)
|
|
470
|
+
for block in reversed(system):
|
|
471
|
+
if removed >= to_remove:
|
|
472
|
+
break
|
|
473
|
+
if isinstance(block, dict) and "cache_control" in block:
|
|
474
|
+
text = block.get("text", "")
|
|
475
|
+
# Skip injected prompts (highest priority)
|
|
476
|
+
if (
|
|
477
|
+
"Claude Code" not in text
|
|
478
|
+
and "Anthropic's official CLI" not in text
|
|
479
|
+
):
|
|
480
|
+
del block["cache_control"]
|
|
481
|
+
removed += 1
|
|
482
|
+
logger.debug(
|
|
483
|
+
"removed_cache_control", location="user_system"
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
# In theory, we should never need to remove injected system cache_control
|
|
487
|
+
# but include this for completeness
|
|
488
|
+
if removed < to_remove:
|
|
489
|
+
logger.error(
|
|
490
|
+
"cannot_preserve_injected_cache_control",
|
|
491
|
+
needed_to_remove=to_remove,
|
|
492
|
+
actually_removed=removed,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
return data
|
|
496
|
+
|
|
364
497
|
def transform_request_body(
|
|
365
498
|
self,
|
|
366
499
|
body: bytes,
|
|
@@ -398,8 +531,16 @@ class HTTPRequestTransformer(RequestTransformer):
|
|
|
398
531
|
import json
|
|
399
532
|
|
|
400
533
|
data = json.loads(body.decode("utf-8"))
|
|
401
|
-
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
534
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
402
535
|
# Return original if not valid JSON
|
|
536
|
+
logger.warning(
|
|
537
|
+
"http_transform_json_decode_failed",
|
|
538
|
+
error=str(e),
|
|
539
|
+
body_preview=body[:200].decode("utf-8", errors="replace")
|
|
540
|
+
if body
|
|
541
|
+
else None,
|
|
542
|
+
body_length=len(body) if body else 0,
|
|
543
|
+
)
|
|
403
544
|
return body
|
|
404
545
|
|
|
405
546
|
# Get the system field to inject
|
|
@@ -440,6 +581,9 @@ class HTTPRequestTransformer(RequestTransformer):
|
|
|
440
581
|
# Both are lists, concatenate
|
|
441
582
|
data["system"] = detected_system + existing_system
|
|
442
583
|
|
|
584
|
+
# Limit cache_control blocks to comply with Anthropic's limit
|
|
585
|
+
data = self._limit_cache_control_blocks(data)
|
|
586
|
+
|
|
443
587
|
return json.dumps(data).encode("utf-8")
|
|
444
588
|
|
|
445
589
|
def _is_openai_request(self, path: str, body: bytes) -> bool:
|
|
@@ -462,7 +606,14 @@ class HTTPRequestTransformer(RequestTransformer):
|
|
|
462
606
|
messages = data.get("messages", [])
|
|
463
607
|
if messages and any(msg.get("role") == "system" for msg in messages):
|
|
464
608
|
return True
|
|
465
|
-
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
609
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
610
|
+
logger.warning(
|
|
611
|
+
"openai_request_detection_json_decode_failed",
|
|
612
|
+
error=str(e),
|
|
613
|
+
body_preview=body[:100].decode("utf-8", errors="replace")
|
|
614
|
+
if body
|
|
615
|
+
else None,
|
|
616
|
+
)
|
|
466
617
|
pass
|
|
467
618
|
|
|
468
619
|
return False
|
ccproxy/models/detection.py
CHANGED
|
@@ -124,3 +124,85 @@ class ClaudeCacheData(BaseModel):
|
|
|
124
124
|
] = None # type: ignore # Pydantic handles this via default_factory
|
|
125
125
|
|
|
126
126
|
model_config = ConfigDict(extra="forbid")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class CodexHeaders(BaseModel):
|
|
130
|
+
"""Pydantic model for Codex CLI headers extraction with field aliases."""
|
|
131
|
+
|
|
132
|
+
session_id: str = Field(
|
|
133
|
+
alias="session_id",
|
|
134
|
+
description="Codex session identifier",
|
|
135
|
+
default="",
|
|
136
|
+
)
|
|
137
|
+
originator: str = Field(
|
|
138
|
+
description="Codex originator identifier",
|
|
139
|
+
default="codex_cli_rs",
|
|
140
|
+
)
|
|
141
|
+
openai_beta: str = Field(
|
|
142
|
+
alias="openai-beta",
|
|
143
|
+
description="OpenAI beta features",
|
|
144
|
+
default="responses=experimental",
|
|
145
|
+
)
|
|
146
|
+
version: str = Field(
|
|
147
|
+
description="Codex CLI version",
|
|
148
|
+
default="0.21.0",
|
|
149
|
+
)
|
|
150
|
+
chatgpt_account_id: str = Field(
|
|
151
|
+
alias="chatgpt-account-id",
|
|
152
|
+
description="ChatGPT account identifier",
|
|
153
|
+
default="",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
|
157
|
+
|
|
158
|
+
def to_headers_dict(self) -> dict[str, str]:
|
|
159
|
+
"""Convert to headers dictionary for HTTP forwarding with proper case."""
|
|
160
|
+
headers = {}
|
|
161
|
+
|
|
162
|
+
# Map field names to proper HTTP header names
|
|
163
|
+
header_mapping = {
|
|
164
|
+
"session_id": "session_id",
|
|
165
|
+
"originator": "originator",
|
|
166
|
+
"openai_beta": "openai-beta",
|
|
167
|
+
"version": "version",
|
|
168
|
+
"chatgpt_account_id": "chatgpt-account-id",
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for field_name, header_name in header_mapping.items():
|
|
172
|
+
value = getattr(self, field_name, None)
|
|
173
|
+
if value is not None and value != "":
|
|
174
|
+
headers[header_name] = value
|
|
175
|
+
|
|
176
|
+
return headers
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class CodexInstructionsData(BaseModel):
|
|
180
|
+
"""Extracted Codex instructions information."""
|
|
181
|
+
|
|
182
|
+
instructions_field: Annotated[
|
|
183
|
+
str,
|
|
184
|
+
Field(
|
|
185
|
+
description="Complete instructions field as detected from Codex CLI, preserving exact text content"
|
|
186
|
+
),
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
model_config = ConfigDict(extra="forbid")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class CodexCacheData(BaseModel):
|
|
193
|
+
"""Cached Codex CLI detection data with version tracking."""
|
|
194
|
+
|
|
195
|
+
codex_version: Annotated[str, Field(description="Codex CLI version")]
|
|
196
|
+
headers: Annotated[CodexHeaders, Field(description="Extracted headers")]
|
|
197
|
+
instructions: Annotated[
|
|
198
|
+
CodexInstructionsData, Field(description="Extracted instructions")
|
|
199
|
+
]
|
|
200
|
+
cached_at: Annotated[
|
|
201
|
+
datetime,
|
|
202
|
+
Field(
|
|
203
|
+
description="Cache timestamp",
|
|
204
|
+
default_factory=lambda: datetime.now(UTC),
|
|
205
|
+
),
|
|
206
|
+
] = None # type: ignore # Pydantic handles this via default_factory
|
|
207
|
+
|
|
208
|
+
model_config = ConfigDict(extra="forbid")
|
ccproxy/models/requests.py
CHANGED
|
@@ -83,3 +83,25 @@ class Usage(BaseModel):
|
|
|
83
83
|
cache_read_input_tokens: Annotated[
|
|
84
84
|
int | None, Field(description="Number of tokens read from cache")
|
|
85
85
|
] = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CodexMessage(BaseModel):
|
|
89
|
+
"""Message format for Codex requests."""
|
|
90
|
+
|
|
91
|
+
role: Annotated[Literal["user", "assistant"], Field(description="Message role")]
|
|
92
|
+
content: Annotated[str, Field(description="Message content")]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class CodexRequest(BaseModel):
|
|
96
|
+
"""OpenAI Codex completion request model."""
|
|
97
|
+
|
|
98
|
+
model: Annotated[str, Field(description="Model name (e.g., gpt-5)")] = "gpt-5"
|
|
99
|
+
instructions: Annotated[
|
|
100
|
+
str | None, Field(description="System instructions for the model")
|
|
101
|
+
] = None
|
|
102
|
+
messages: Annotated[list[CodexMessage], Field(description="Conversation messages")]
|
|
103
|
+
stream: Annotated[bool, Field(description="Whether to stream the response")] = True
|
|
104
|
+
|
|
105
|
+
model_config = ConfigDict(
|
|
106
|
+
extra="allow"
|
|
107
|
+
) # Allow additional fields for compatibility
|