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
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
- # Use human-readable timestamp for structlog logs in debug mode, normal otherwise
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, # Cache for performance
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, log_level_name: str = "DEBUG", log_file: str | None = None
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(fmt="%Y-%m-%d %H:%M:%S")
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
 
@@ -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:
@@ -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")
@@ -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
@@ -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 comprehensive log data
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
- if not log_data.get("streaming", False):
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 log_data.get("event_type", "") == "streaming_complete":
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.debug("access_log")
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)