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.
- ccproxy/_version.py +2 -2
- ccproxy/adapters/codex/__init__.py +11 -0
- ccproxy/adapters/openai/adapter.py +1 -1
- ccproxy/adapters/openai/models.py +1 -1
- ccproxy/adapters/openai/response_adapter.py +355 -0
- ccproxy/adapters/openai/response_models.py +178 -0
- ccproxy/adapters/openai/streaming.py +1 -0
- ccproxy/api/app.py +150 -224
- ccproxy/api/dependencies.py +22 -2
- ccproxy/api/middleware/errors.py +27 -3
- ccproxy/api/middleware/logging.py +4 -0
- ccproxy/api/responses.py +6 -1
- ccproxy/api/routes/claude.py +222 -17
- ccproxy/api/routes/codex.py +1231 -0
- ccproxy/api/routes/health.py +228 -3
- ccproxy/api/routes/proxy.py +25 -6
- ccproxy/api/services/permission_service.py +2 -2
- 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/__init__.py +4 -8
- ccproxy/claude_sdk/client.py +661 -131
- ccproxy/claude_sdk/exceptions.py +16 -0
- ccproxy/claude_sdk/manager.py +219 -0
- ccproxy/claude_sdk/message_queue.py +342 -0
- ccproxy/claude_sdk/options.py +6 -1
- ccproxy/claude_sdk/session_client.py +546 -0
- ccproxy/claude_sdk/session_pool.py +550 -0
- ccproxy/claude_sdk/stream_handle.py +538 -0
- ccproxy/claude_sdk/stream_worker.py +392 -0
- ccproxy/claude_sdk/streaming.py +53 -11
- ccproxy/cli/commands/auth.py +398 -1
- ccproxy/cli/commands/serve.py +99 -1
- ccproxy/cli/options/claude_options.py +47 -0
- ccproxy/config/__init__.py +0 -3
- ccproxy/config/claude.py +171 -23
- ccproxy/config/codex.py +100 -0
- ccproxy/config/discovery.py +10 -1
- ccproxy/config/scheduler.py +2 -2
- ccproxy/config/settings.py +38 -1
- ccproxy/core/codex_transformers.py +389 -0
- ccproxy/core/http_transformers.py +458 -75
- ccproxy/core/logging.py +108 -12
- ccproxy/core/transformers.py +5 -0
- ccproxy/models/claude_sdk.py +57 -0
- ccproxy/models/detection.py +208 -0
- ccproxy/models/requests.py +22 -0
- ccproxy/models/responses.py +16 -0
- ccproxy/observability/access_logger.py +72 -14
- ccproxy/observability/metrics.py +151 -0
- ccproxy/observability/storage/duckdb_simple.py +12 -0
- ccproxy/observability/storage/models.py +16 -0
- ccproxy/observability/streaming_response.py +107 -0
- ccproxy/scheduler/manager.py +31 -6
- ccproxy/scheduler/tasks.py +122 -0
- ccproxy/services/claude_detection_service.py +269 -0
- ccproxy/services/claude_sdk_service.py +333 -130
- ccproxy/services/codex_detection_service.py +263 -0
- ccproxy/services/proxy_service.py +618 -197
- ccproxy/utils/__init__.py +9 -1
- ccproxy/utils/disconnection_monitor.py +83 -0
- ccproxy/utils/id_generator.py +12 -0
- ccproxy/utils/model_mapping.py +7 -5
- ccproxy/utils/startup_helpers.py +470 -0
- ccproxy_api-0.1.6.dist-info/METADATA +615 -0
- {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/RECORD +70 -47
- ccproxy/config/loader.py +0 -105
- ccproxy_api-0.1.4.dist-info/METADATA +0 -369
- {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/WHEEL +0 -0
- {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/entry_points.txt +0 -0
- {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/licenses/LICENSE +0 -0
ccproxy/core/logging.py
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
import shutil
|
|
2
3
|
import sys
|
|
4
|
+
from collections.abc import MutableMapping
|
|
3
5
|
from pathlib import Path
|
|
6
|
+
from typing import Any, TextIO
|
|
4
7
|
|
|
5
8
|
import structlog
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.traceback import Traceback
|
|
6
11
|
from structlog.stdlib import BoundLogger
|
|
7
|
-
from structlog.typing import Processor
|
|
12
|
+
from structlog.typing import ExcInfo, Processor
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
suppress_debug = [
|
|
16
|
+
"ccproxy.scheduler",
|
|
17
|
+
"ccproxy.observability.context",
|
|
18
|
+
"ccproxy.utils.simple_request_logger",
|
|
19
|
+
]
|
|
8
20
|
|
|
9
21
|
|
|
10
22
|
def configure_structlog(log_level: int = logging.INFO) -> None:
|
|
@@ -30,12 +42,28 @@ def configure_structlog(log_level: int = logging.INFO) -> None:
|
|
|
30
42
|
)
|
|
31
43
|
|
|
32
44
|
# Common processors for all log levels
|
|
45
|
+
# First add timestamp with microseconds
|
|
46
|
+
processors.append(
|
|
47
|
+
structlog.processors.TimeStamper(
|
|
48
|
+
fmt="%H:%M:%S.%f" if log_level < logging.INFO else "%Y-%m-%d %H:%M:%S.%f",
|
|
49
|
+
key="timestamp_raw",
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Then add processor to convert microseconds to milliseconds
|
|
54
|
+
def format_timestamp_ms(
|
|
55
|
+
logger: Any, log_method: str, event_dict: MutableMapping[str, Any]
|
|
56
|
+
) -> MutableMapping[str, Any]:
|
|
57
|
+
"""Format timestamp with milliseconds instead of microseconds."""
|
|
58
|
+
if "timestamp_raw" in event_dict:
|
|
59
|
+
# Truncate microseconds to milliseconds (6 digits to 3)
|
|
60
|
+
timestamp_raw = event_dict.pop("timestamp_raw")
|
|
61
|
+
event_dict["timestamp"] = timestamp_raw[:-3]
|
|
62
|
+
return event_dict
|
|
63
|
+
|
|
33
64
|
processors.extend(
|
|
34
65
|
[
|
|
35
|
-
|
|
36
|
-
structlog.processors.TimeStamper(
|
|
37
|
-
fmt="%H:%M:%S" if log_level < logging.INFO else "%Y-%m-%d %H:%M:%S"
|
|
38
|
-
),
|
|
66
|
+
format_timestamp_ms,
|
|
39
67
|
structlog.processors.StackInfoRenderer(),
|
|
40
68
|
structlog.dev.set_exc_info, # Handle exceptions properly
|
|
41
69
|
# This MUST be the last processor - allows different renderers per handler
|
|
@@ -48,12 +76,42 @@ def configure_structlog(log_level: int = logging.INFO) -> None:
|
|
|
48
76
|
context_class=dict,
|
|
49
77
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
50
78
|
wrapper_class=structlog.stdlib.BoundLogger,
|
|
51
|
-
cache_logger_on_first_use=True,
|
|
79
|
+
cache_logger_on_first_use=True,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def rich_traceback(sio: TextIO, exc_info: ExcInfo) -> None:
|
|
84
|
+
"""Pretty-print *exc_info* to *sio* using the *Rich* package.
|
|
85
|
+
|
|
86
|
+
Based on:
|
|
87
|
+
https://github.com/hynek/structlog/blob/74cdff93af217519d4ebea05184f5e0db2972556/src/structlog/dev.py#L179-L192
|
|
88
|
+
|
|
89
|
+
"""
|
|
90
|
+
term_width, _height = shutil.get_terminal_size((80, 123))
|
|
91
|
+
sio.write("\n")
|
|
92
|
+
# Rich docs: https://rich.readthedocs.io/en/stable/reference/traceback.html
|
|
93
|
+
Console(file=sio, color_system="truecolor").print(
|
|
94
|
+
Traceback.from_exception(
|
|
95
|
+
*exc_info,
|
|
96
|
+
# show_locals=True, # Takes up too much vertical space
|
|
97
|
+
extra_lines=1, # Reduce amount of source code displayed
|
|
98
|
+
width=term_width, # Maximize width
|
|
99
|
+
max_frames=5, # Default is 10
|
|
100
|
+
suppress=[
|
|
101
|
+
"click",
|
|
102
|
+
"typer",
|
|
103
|
+
"uvicorn",
|
|
104
|
+
"fastapi",
|
|
105
|
+
"starlette",
|
|
106
|
+
], # Suppress noise from these libraries
|
|
107
|
+
),
|
|
52
108
|
)
|
|
53
109
|
|
|
54
110
|
|
|
55
111
|
def setup_logging(
|
|
56
|
-
json_logs: bool = False,
|
|
112
|
+
json_logs: bool = False,
|
|
113
|
+
log_level_name: str = "DEBUG",
|
|
114
|
+
log_file: str | None = None,
|
|
57
115
|
) -> BoundLogger:
|
|
58
116
|
"""
|
|
59
117
|
Setup logging for the entire application using canonical structlog pattern.
|
|
@@ -61,6 +119,21 @@ def setup_logging(
|
|
|
61
119
|
"""
|
|
62
120
|
log_level = getattr(logging, log_level_name.upper(), logging.INFO)
|
|
63
121
|
|
|
122
|
+
# Install rich traceback handler globally with frame limit
|
|
123
|
+
# install_rich_traceback(
|
|
124
|
+
# show_locals=log_level <= logging.DEBUG, # Only show locals in debug mode
|
|
125
|
+
# max_frames=max_traceback_frames,
|
|
126
|
+
# width=120,
|
|
127
|
+
# word_wrap=True,
|
|
128
|
+
# suppress=[
|
|
129
|
+
# "click",
|
|
130
|
+
# "typer",
|
|
131
|
+
# "uvicorn",
|
|
132
|
+
# "fastapi",
|
|
133
|
+
# "starlette",
|
|
134
|
+
# ], # Suppress noise from these libraries
|
|
135
|
+
# )
|
|
136
|
+
|
|
64
137
|
# Get root logger and set level BEFORE configuring structlog
|
|
65
138
|
root_logger = logging.getLogger()
|
|
66
139
|
root_logger.setLevel(log_level)
|
|
@@ -91,12 +164,26 @@ def setup_logging(
|
|
|
91
164
|
)
|
|
92
165
|
|
|
93
166
|
# Add appropriate timestamper for console vs file
|
|
167
|
+
# Using custom lambda to truncate microseconds to milliseconds
|
|
94
168
|
console_timestamper = (
|
|
95
|
-
structlog.processors.TimeStamper(fmt="%H:%M:%S")
|
|
169
|
+
structlog.processors.TimeStamper(fmt="%H:%M:%S.%f", key="timestamp_raw")
|
|
96
170
|
if log_level < logging.INFO
|
|
97
|
-
else structlog.processors.TimeStamper(
|
|
171
|
+
else structlog.processors.TimeStamper(
|
|
172
|
+
fmt="%Y-%m-%d %H:%M:%S.%f", key="timestamp_raw"
|
|
173
|
+
)
|
|
98
174
|
)
|
|
99
175
|
|
|
176
|
+
# Processor to convert microseconds to milliseconds
|
|
177
|
+
def format_timestamp_ms(
|
|
178
|
+
logger: Any, log_method: str, event_dict: MutableMapping[str, Any]
|
|
179
|
+
) -> MutableMapping[str, Any]:
|
|
180
|
+
"""Format timestamp with milliseconds instead of microseconds."""
|
|
181
|
+
if "timestamp_raw" in event_dict:
|
|
182
|
+
# Truncate microseconds to milliseconds (6 digits to 3)
|
|
183
|
+
timestamp_raw = event_dict.pop("timestamp_raw")
|
|
184
|
+
event_dict["timestamp"] = timestamp_raw[:-3]
|
|
185
|
+
return event_dict
|
|
186
|
+
|
|
100
187
|
file_timestamper = structlog.processors.TimeStamper(fmt="iso")
|
|
101
188
|
|
|
102
189
|
# 4. Setup console handler with ConsoleRenderer
|
|
@@ -105,14 +192,16 @@ def setup_logging(
|
|
|
105
192
|
console_renderer = (
|
|
106
193
|
structlog.processors.JSONRenderer()
|
|
107
194
|
if json_logs
|
|
108
|
-
else structlog.dev.ConsoleRenderer(
|
|
195
|
+
else structlog.dev.ConsoleRenderer(
|
|
196
|
+
exception_formatter=rich_traceback # structlog.dev.rich_traceback, # Use rich for better formatting
|
|
197
|
+
)
|
|
109
198
|
)
|
|
110
199
|
|
|
111
200
|
# Console gets human-readable timestamps for both structlog and stdlib logs
|
|
112
|
-
console_processors = shared_processors + [console_timestamper]
|
|
201
|
+
console_processors = shared_processors + [console_timestamper, format_timestamp_ms]
|
|
113
202
|
console_handler.setFormatter(
|
|
114
203
|
structlog.stdlib.ProcessorFormatter(
|
|
115
|
-
foreign_pre_chain=console_processors,
|
|
204
|
+
foreign_pre_chain=console_processors, # type: ignore[arg-type]
|
|
116
205
|
processor=console_renderer,
|
|
117
206
|
)
|
|
118
207
|
)
|
|
@@ -182,6 +271,13 @@ def setup_logging(
|
|
|
182
271
|
noisy_logger.propagate = True
|
|
183
272
|
noisy_logger.setLevel(noisy_log_level)
|
|
184
273
|
|
|
274
|
+
[
|
|
275
|
+
logging.getLogger(logger_name).setLevel(
|
|
276
|
+
logging.INFO if log_level <= logging.DEBUG else log_level
|
|
277
|
+
) # type: ignore[func-returns-value]
|
|
278
|
+
for logger_name in suppress_debug
|
|
279
|
+
]
|
|
280
|
+
|
|
185
281
|
return structlog.get_logger() # type: ignore[no-any-return]
|
|
186
282
|
|
|
187
283
|
|
ccproxy/core/transformers.py
CHANGED
|
@@ -114,6 +114,11 @@ class BaseTransformer(ABC):
|
|
|
114
114
|
class RequestTransformer(BaseTransformer):
|
|
115
115
|
"""Base class for request transformers."""
|
|
116
116
|
|
|
117
|
+
def __init__(self, proxy_mode: str = "full") -> None:
|
|
118
|
+
"""Initialize request transformer with proxy mode."""
|
|
119
|
+
super().__init__()
|
|
120
|
+
self.proxy_mode = proxy_mode
|
|
121
|
+
|
|
117
122
|
async def transform(
|
|
118
123
|
self, request: ProxyRequest, context: TransformContext | None = None
|
|
119
124
|
) -> ProxyRequest:
|
ccproxy/models/claude_sdk.py
CHANGED
|
@@ -338,6 +338,59 @@ SDKContentBlock = Annotated[
|
|
|
338
338
|
ExtendedContentBlock = SDKContentBlock
|
|
339
339
|
|
|
340
340
|
|
|
341
|
+
# SDK Query Message Types
|
|
342
|
+
class SDKMessageContent(BaseModel):
|
|
343
|
+
"""Content structure for SDK query messages."""
|
|
344
|
+
|
|
345
|
+
role: Literal["user"] = "user"
|
|
346
|
+
content: str = Field(..., description="Message text content")
|
|
347
|
+
|
|
348
|
+
model_config = ConfigDict(extra="forbid")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class SDKMessage(BaseModel):
|
|
352
|
+
"""Message format used to send queries over the Claude SDK.
|
|
353
|
+
|
|
354
|
+
This represents the internal message structure expected by the
|
|
355
|
+
Claude Code SDK client for query operations.
|
|
356
|
+
"""
|
|
357
|
+
|
|
358
|
+
type: Literal["user"] = "user"
|
|
359
|
+
message: SDKMessageContent = Field(
|
|
360
|
+
..., description="Message content with role and text"
|
|
361
|
+
)
|
|
362
|
+
parent_tool_use_id: str | None = Field(
|
|
363
|
+
None, description="Optional parent tool use ID"
|
|
364
|
+
)
|
|
365
|
+
session_id: str | None = Field(
|
|
366
|
+
None, description="Optional session ID for conversation continuity"
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
model_config = ConfigDict(extra="forbid")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def create_sdk_message(
|
|
373
|
+
content: str,
|
|
374
|
+
session_id: str | None = None,
|
|
375
|
+
parent_tool_use_id: str | None = None,
|
|
376
|
+
) -> SDKMessage:
|
|
377
|
+
"""Create an SDKMessage instance for sending queries to Claude SDK.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
content: The text content to send to Claude
|
|
381
|
+
session_id: Optional session ID for conversation continuity
|
|
382
|
+
parent_tool_use_id: Optional parent tool use ID
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
SDKMessage instance ready to send to Claude SDK
|
|
386
|
+
"""
|
|
387
|
+
return SDKMessage(
|
|
388
|
+
message=SDKMessageContent(content=content),
|
|
389
|
+
session_id=session_id,
|
|
390
|
+
parent_tool_use_id=parent_tool_use_id,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
341
394
|
# Conversion Functions
|
|
342
395
|
def convert_sdk_text_block(text_content: str) -> TextBlock:
|
|
343
396
|
"""Convert raw text content to TextBlock model."""
|
|
@@ -404,6 +457,10 @@ __all__ = [
|
|
|
404
457
|
"AssistantMessage",
|
|
405
458
|
"SystemMessage",
|
|
406
459
|
"ResultMessage",
|
|
460
|
+
# SDK Query Messages
|
|
461
|
+
"SDKMessageContent",
|
|
462
|
+
"SDKMessage",
|
|
463
|
+
"create_sdk_message",
|
|
407
464
|
# Custom content blocks
|
|
408
465
|
"SDKMessageMode",
|
|
409
466
|
"ToolUseSDKBlock",
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Detection models for Claude Code CLI headers and system prompt extraction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Annotated, Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ClaudeCodeHeaders(BaseModel):
|
|
12
|
+
"""Pydantic model for Claude CLI headers extraction with field aliases."""
|
|
13
|
+
|
|
14
|
+
anthropic_beta: str = Field(
|
|
15
|
+
alias="anthropic-beta",
|
|
16
|
+
description="Anthropic beta features",
|
|
17
|
+
default="claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
|
|
18
|
+
)
|
|
19
|
+
anthropic_version: str = Field(
|
|
20
|
+
alias="anthropic-version",
|
|
21
|
+
description="Anthropic API version",
|
|
22
|
+
default="2023-06-01",
|
|
23
|
+
)
|
|
24
|
+
anthropic_dangerous_direct_browser_access: str = Field(
|
|
25
|
+
alias="anthropic-dangerous-direct-browser-access",
|
|
26
|
+
description="Browser access flag",
|
|
27
|
+
default="true",
|
|
28
|
+
)
|
|
29
|
+
x_app: str = Field(
|
|
30
|
+
alias="x-app", description="Application identifier", default="cli"
|
|
31
|
+
)
|
|
32
|
+
user_agent: str = Field(
|
|
33
|
+
alias="user-agent",
|
|
34
|
+
description="User agent string",
|
|
35
|
+
default="claude-cli/1.0.60 (external, cli)",
|
|
36
|
+
)
|
|
37
|
+
x_stainless_lang: str = Field(
|
|
38
|
+
alias="x-stainless-lang", description="SDK language", default="js"
|
|
39
|
+
)
|
|
40
|
+
x_stainless_retry_count: str = Field(
|
|
41
|
+
alias="x-stainless-retry-count", description="Retry count", default="0"
|
|
42
|
+
)
|
|
43
|
+
x_stainless_timeout: str = Field(
|
|
44
|
+
alias="x-stainless-timeout", description="Request timeout", default="60"
|
|
45
|
+
)
|
|
46
|
+
x_stainless_package_version: str = Field(
|
|
47
|
+
alias="x-stainless-package-version",
|
|
48
|
+
description="Package version",
|
|
49
|
+
default="0.55.1",
|
|
50
|
+
)
|
|
51
|
+
x_stainless_os: str = Field(
|
|
52
|
+
alias="x-stainless-os", description="Operating system", default="Linux"
|
|
53
|
+
)
|
|
54
|
+
x_stainless_arch: str = Field(
|
|
55
|
+
alias="x-stainless-arch", description="Architecture", default="x64"
|
|
56
|
+
)
|
|
57
|
+
x_stainless_runtime: str = Field(
|
|
58
|
+
alias="x-stainless-runtime", description="Runtime", default="node"
|
|
59
|
+
)
|
|
60
|
+
x_stainless_runtime_version: str = Field(
|
|
61
|
+
alias="x-stainless-runtime-version",
|
|
62
|
+
description="Runtime version",
|
|
63
|
+
default="v24.3.0",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
|
67
|
+
|
|
68
|
+
def to_headers_dict(self) -> dict[str, str]:
|
|
69
|
+
"""Convert to headers dictionary for HTTP forwarding with proper case."""
|
|
70
|
+
headers = {}
|
|
71
|
+
|
|
72
|
+
# Map field names to proper HTTP header names
|
|
73
|
+
header_mapping = {
|
|
74
|
+
"anthropic_beta": "anthropic-beta",
|
|
75
|
+
"anthropic_version": "anthropic-version",
|
|
76
|
+
"anthropic_dangerous_direct_browser_access": "anthropic-dangerous-direct-browser-access",
|
|
77
|
+
"x_app": "x-app",
|
|
78
|
+
"user_agent": "User-Agent",
|
|
79
|
+
"x_stainless_lang": "X-Stainless-Lang",
|
|
80
|
+
"x_stainless_retry_count": "X-Stainless-Retry-Count",
|
|
81
|
+
"x_stainless_timeout": "X-Stainless-Timeout",
|
|
82
|
+
"x_stainless_package_version": "X-Stainless-Package-Version",
|
|
83
|
+
"x_stainless_os": "X-Stainless-OS",
|
|
84
|
+
"x_stainless_arch": "X-Stainless-Arch",
|
|
85
|
+
"x_stainless_runtime": "X-Stainless-Runtime",
|
|
86
|
+
"x_stainless_runtime_version": "X-Stainless-Runtime-Version",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for field_name, header_name in header_mapping.items():
|
|
90
|
+
value = getattr(self, field_name, None)
|
|
91
|
+
if value is not None:
|
|
92
|
+
headers[header_name] = value
|
|
93
|
+
|
|
94
|
+
return headers
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class SystemPromptData(BaseModel):
|
|
98
|
+
"""Extracted system prompt information."""
|
|
99
|
+
|
|
100
|
+
system_field: Annotated[
|
|
101
|
+
str | list[dict[str, Any]],
|
|
102
|
+
Field(
|
|
103
|
+
description="Complete system field as detected from Claude CLI, preserving exact structure including type, text, and cache_control"
|
|
104
|
+
),
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
model_config = ConfigDict(extra="forbid")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ClaudeCacheData(BaseModel):
|
|
111
|
+
"""Cached Claude CLI detection data with version tracking."""
|
|
112
|
+
|
|
113
|
+
claude_version: Annotated[str, Field(description="Claude CLI version")]
|
|
114
|
+
headers: Annotated[ClaudeCodeHeaders, Field(description="Extracted headers")]
|
|
115
|
+
system_prompt: Annotated[
|
|
116
|
+
SystemPromptData, Field(description="Extracted system prompt")
|
|
117
|
+
]
|
|
118
|
+
cached_at: Annotated[
|
|
119
|
+
datetime,
|
|
120
|
+
Field(
|
|
121
|
+
description="Cache timestamp",
|
|
122
|
+
default_factory=lambda: datetime.now(UTC),
|
|
123
|
+
),
|
|
124
|
+
] = None # type: ignore # Pydantic handles this via default_factory
|
|
125
|
+
|
|
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
|
ccproxy/models/responses.py
CHANGED
|
@@ -252,3 +252,19 @@ class InternalServerError(APIError):
|
|
|
252
252
|
type: Annotated[
|
|
253
253
|
Literal["internal_server_error"], Field(description="Error type")
|
|
254
254
|
] = "internal_server_error"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class CodexResponse(BaseModel):
|
|
258
|
+
"""OpenAI Codex completion response model."""
|
|
259
|
+
|
|
260
|
+
id: Annotated[str, Field(description="Response ID")]
|
|
261
|
+
model: Annotated[str, Field(description="Model used for completion")]
|
|
262
|
+
content: Annotated[str, Field(description="Generated content")]
|
|
263
|
+
finish_reason: Annotated[
|
|
264
|
+
str | None, Field(description="Reason the response finished")
|
|
265
|
+
] = None
|
|
266
|
+
usage: Annotated[Usage | None, Field(description="Token usage information")] = None
|
|
267
|
+
|
|
268
|
+
model_config = ConfigDict(
|
|
269
|
+
extra="allow"
|
|
270
|
+
) # Allow additional fields for compatibility
|
|
@@ -63,20 +63,31 @@ async def log_request_access(
|
|
|
63
63
|
path = path or ctx_metadata.get("path")
|
|
64
64
|
status_code = status_code or ctx_metadata.get("status_code")
|
|
65
65
|
|
|
66
|
-
# Prepare
|
|
66
|
+
# Prepare basic log data (always included)
|
|
67
67
|
log_data = {
|
|
68
68
|
"request_id": context.request_id,
|
|
69
69
|
"method": method,
|
|
70
70
|
"path": path,
|
|
71
71
|
"query": query,
|
|
72
|
-
"status_code": status_code,
|
|
73
72
|
"client_ip": client_ip,
|
|
74
73
|
"user_agent": user_agent,
|
|
75
|
-
"duration_ms": context.duration_ms,
|
|
76
|
-
"duration_seconds": context.duration_seconds,
|
|
77
|
-
"error_message": error_message,
|
|
78
74
|
}
|
|
79
75
|
|
|
76
|
+
# Add response-specific fields (only for completed requests)
|
|
77
|
+
is_streaming = ctx_metadata.get("streaming", False)
|
|
78
|
+
is_streaming_complete = ctx_metadata.get("event_type", "") == "streaming_complete"
|
|
79
|
+
|
|
80
|
+
# Include response fields only if this is not a streaming start
|
|
81
|
+
if not is_streaming or is_streaming_complete or ctx_metadata.get("error"):
|
|
82
|
+
log_data.update(
|
|
83
|
+
{
|
|
84
|
+
"status_code": status_code,
|
|
85
|
+
"duration_ms": context.duration_ms,
|
|
86
|
+
"duration_seconds": context.duration_seconds,
|
|
87
|
+
"error_message": error_message,
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
|
|
80
91
|
# Add token and cost metrics if available
|
|
81
92
|
token_fields = [
|
|
82
93
|
"tokens_input",
|
|
@@ -85,6 +96,7 @@ async def log_request_access(
|
|
|
85
96
|
"cache_write_tokens",
|
|
86
97
|
"cost_usd",
|
|
87
98
|
"cost_sdk_usd",
|
|
99
|
+
"num_turns",
|
|
88
100
|
]
|
|
89
101
|
|
|
90
102
|
for field in token_fields:
|
|
@@ -93,18 +105,50 @@ async def log_request_access(
|
|
|
93
105
|
log_data[field] = value
|
|
94
106
|
|
|
95
107
|
# Add service and endpoint info
|
|
96
|
-
service_fields = [
|
|
97
|
-
"endpoint",
|
|
98
|
-
"model",
|
|
99
|
-
"streaming",
|
|
100
|
-
"service_type",
|
|
101
|
-
]
|
|
108
|
+
service_fields = ["endpoint", "model", "streaming", "service_type", "headers"]
|
|
102
109
|
|
|
103
110
|
for field in service_fields:
|
|
104
111
|
value = ctx_metadata.get(field)
|
|
105
112
|
if value is not None:
|
|
106
113
|
log_data[field] = value
|
|
107
114
|
|
|
115
|
+
# Add session context metadata if available
|
|
116
|
+
session_fields = [
|
|
117
|
+
"session_id",
|
|
118
|
+
"session_type", # "session_pool" or "direct"
|
|
119
|
+
"session_status", # active, idle, connecting, etc.
|
|
120
|
+
"session_age_seconds", # how long session has been alive
|
|
121
|
+
"session_message_count", # number of messages in session
|
|
122
|
+
"session_pool_enabled", # whether session pooling is enabled
|
|
123
|
+
"session_idle_seconds", # how long since last activity
|
|
124
|
+
"session_error_count", # number of errors in this session
|
|
125
|
+
"session_is_new", # whether this is a newly created session
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
for field in session_fields:
|
|
129
|
+
value = ctx_metadata.get(field)
|
|
130
|
+
if value is not None:
|
|
131
|
+
log_data[field] = value
|
|
132
|
+
|
|
133
|
+
# Add rate limit headers if available
|
|
134
|
+
rate_limit_fields = [
|
|
135
|
+
"x-ratelimit-limit",
|
|
136
|
+
"x-ratelimit-remaining",
|
|
137
|
+
"x-ratelimit-reset",
|
|
138
|
+
"anthropic-ratelimit-requests-limit",
|
|
139
|
+
"anthropic-ratelimit-requests-remaining",
|
|
140
|
+
"anthropic-ratelimit-requests-reset",
|
|
141
|
+
"anthropic-ratelimit-tokens-limit",
|
|
142
|
+
"anthropic-ratelimit-tokens-remaining",
|
|
143
|
+
"anthropic-ratelimit-tokens-reset",
|
|
144
|
+
"anthropic_request_id",
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
for field in rate_limit_fields:
|
|
148
|
+
value = ctx_metadata.get(field)
|
|
149
|
+
if value is not None:
|
|
150
|
+
log_data[field] = value
|
|
151
|
+
|
|
108
152
|
# Add any additional metadata provided
|
|
109
153
|
log_data.update(additional_metadata)
|
|
110
154
|
|
|
@@ -112,15 +156,18 @@ async def log_request_access(
|
|
|
112
156
|
log_data = {k: v for k, v in log_data.items() if v is not None}
|
|
113
157
|
|
|
114
158
|
logger = context.logger.bind(**log_data)
|
|
115
|
-
|
|
159
|
+
|
|
160
|
+
if context.metadata.get("error"):
|
|
161
|
+
logger.warn("access_log", exc_info=context.metadata.get("error"))
|
|
162
|
+
elif not is_streaming:
|
|
116
163
|
# Log as access_log event (structured logging)
|
|
117
164
|
logger.info("access_log")
|
|
118
|
-
elif
|
|
165
|
+
elif is_streaming_complete:
|
|
119
166
|
logger.info("access_log")
|
|
120
167
|
else:
|
|
121
168
|
# if streaming is true, and not streaming_complete log as debug
|
|
122
169
|
# real access_log will come later
|
|
123
|
-
logger.
|
|
170
|
+
logger.info("access_log_streaming_start")
|
|
124
171
|
|
|
125
172
|
# Store in DuckDB if available
|
|
126
173
|
await _store_access_log(log_data, storage)
|
|
@@ -258,6 +305,17 @@ async def _store_access_log(
|
|
|
258
305
|
"cache_write_tokens": log_data.get("cache_write_tokens", 0),
|
|
259
306
|
"cost_usd": log_data.get("cost_usd", 0.0),
|
|
260
307
|
"cost_sdk_usd": log_data.get("cost_sdk_usd", 0.0),
|
|
308
|
+
"num_turns": log_data.get("num_turns", 0),
|
|
309
|
+
# Session context metadata
|
|
310
|
+
"session_type": log_data.get("session_type", ""),
|
|
311
|
+
"session_status": log_data.get("session_status", ""),
|
|
312
|
+
"session_age_seconds": log_data.get("session_age_seconds", 0.0),
|
|
313
|
+
"session_message_count": log_data.get("session_message_count", 0),
|
|
314
|
+
"session_client_id": log_data.get("session_client_id", ""),
|
|
315
|
+
"session_pool_enabled": log_data.get("session_pool_enabled", False),
|
|
316
|
+
"session_idle_seconds": log_data.get("session_idle_seconds", 0.0),
|
|
317
|
+
"session_error_count": log_data.get("session_error_count", 0),
|
|
318
|
+
"session_is_new": log_data.get("session_is_new", True),
|
|
261
319
|
}
|
|
262
320
|
|
|
263
321
|
# Store asynchronously using queue-based DuckDB (prevents deadlocks)
|