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
@@ -14,6 +14,7 @@ from ccproxy.adapters.openai.adapter import (
14
14
  )
15
15
  from ccproxy.api.dependencies import ClaudeServiceDep
16
16
  from ccproxy.models.messages import MessageCreateParams, MessageResponse
17
+ from ccproxy.observability.streaming_response import StreamingResponseWithLogging
17
18
 
18
19
 
19
20
  # Create the router for Claude SDK endpoints
@@ -24,9 +25,9 @@ logger = structlog.get_logger(__name__)
24
25
 
25
26
  @router.post("/v1/chat/completions", response_model=None)
26
27
  async def create_openai_chat_completion(
27
- request: Request,
28
28
  openai_request: OpenAIChatCompletionRequest,
29
29
  claude_service: ClaudeServiceDep,
30
+ request: Request,
30
31
  ) -> StreamingResponse | OpenAIChatCompletionResponse:
31
32
  """Create a chat completion using Claude SDK with OpenAI-compatible format.
32
33
 
@@ -43,13 +44,102 @@ async def create_openai_chat_completion(
43
44
  # Extract stream parameter
44
45
  stream = openai_request.stream or False
45
46
 
47
+ # Get request context from middleware
48
+ request_context = getattr(request.state, "context", None)
49
+
50
+ if request_context is None:
51
+ raise HTTPException(
52
+ status_code=500, detail="Internal server error: no request context"
53
+ )
54
+
46
55
  # Call Claude SDK service with adapted request
47
- if request and hasattr(request, "state") and hasattr(request.state, "context"):
48
- # Use existing context from middleware
49
- ctx = request.state.context
50
- # Add service-specific metadata
51
- ctx.add_metadata(streaming=stream)
56
+ response = await claude_service.create_completion(
57
+ messages=anthropic_request["messages"],
58
+ model=anthropic_request["model"],
59
+ temperature=anthropic_request.get("temperature"),
60
+ max_tokens=anthropic_request.get("max_tokens"),
61
+ stream=stream,
62
+ user_id=getattr(openai_request, "user", None),
63
+ request_context=request_context,
64
+ )
65
+
66
+ if stream:
67
+ # Handle streaming response
68
+ async def openai_stream_generator() -> AsyncIterator[bytes]:
69
+ # Use adapt_stream for streaming responses
70
+ async for openai_chunk in adapter.adapt_stream(response): # type: ignore[arg-type]
71
+ yield f"data: {json.dumps(openai_chunk)}\n\n".encode()
72
+ # Send final chunk
73
+ yield b"data: [DONE]\n\n"
52
74
 
75
+ # Use unified streaming wrapper with logging
76
+ return StreamingResponseWithLogging(
77
+ content=openai_stream_generator(),
78
+ request_context=request_context,
79
+ metrics=getattr(claude_service, "metrics", None),
80
+ status_code=200,
81
+ media_type="text/event-stream",
82
+ headers={
83
+ "Cache-Control": "no-cache",
84
+ "Connection": "keep-alive",
85
+ },
86
+ )
87
+ else:
88
+ # Convert non-streaming response to OpenAI format using adapter
89
+ # Convert MessageResponse model to dict for adapter
90
+ # In non-streaming mode, response should always be MessageResponse
91
+ assert isinstance(response, MessageResponse), (
92
+ "Non-streaming response must be MessageResponse"
93
+ )
94
+ response_dict = response.model_dump()
95
+ openai_response = adapter.adapt_response(response_dict)
96
+ return OpenAIChatCompletionResponse.model_validate(openai_response)
97
+
98
+ except Exception as e:
99
+ # Re-raise specific proxy errors to be handled by the error handler
100
+ from ccproxy.core.errors import ClaudeProxyError
101
+
102
+ if isinstance(e, ClaudeProxyError):
103
+ raise
104
+ raise HTTPException(
105
+ status_code=500, detail=f"Internal server error: {str(e)}"
106
+ ) from e
107
+
108
+
109
+ @router.post(
110
+ "/{session_id}/v1/chat/completions",
111
+ response_model=None,
112
+ )
113
+ async def create_openai_chat_completion_with_session(
114
+ session_id: str,
115
+ openai_request: OpenAIChatCompletionRequest,
116
+ claude_service: ClaudeServiceDep,
117
+ request: Request,
118
+ ) -> StreamingResponse | OpenAIChatCompletionResponse:
119
+ """Create a chat completion using Claude SDK with OpenAI-compatible format and session ID.
120
+
121
+ This endpoint handles OpenAI API format requests with session ID and converts them
122
+ to Anthropic format before using the Claude SDK directly.
123
+ """
124
+ try:
125
+ # Create adapter instance
126
+ adapter = OpenAIAdapter()
127
+
128
+ # Convert entire OpenAI request to Anthropic format using adapter
129
+ anthropic_request = adapter.adapt_request(openai_request.model_dump())
130
+
131
+ # Extract stream parameter
132
+ stream = openai_request.stream or False
133
+
134
+ # Get request context from middleware
135
+ request_context = getattr(request.state, "context", None)
136
+
137
+ if request_context is None:
138
+ raise HTTPException(
139
+ status_code=500, detail="Internal server error: no request context"
140
+ )
141
+
142
+ # Call Claude SDK service with adapted request and session_id
53
143
  response = await claude_service.create_completion(
54
144
  messages=anthropic_request["messages"],
55
145
  model=anthropic_request["model"],
@@ -57,6 +147,8 @@ async def create_openai_chat_completion(
57
147
  max_tokens=anthropic_request.get("max_tokens"),
58
148
  stream=stream,
59
149
  user_id=getattr(openai_request, "user", None),
150
+ session_id=session_id,
151
+ request_context=request_context,
60
152
  )
61
153
 
62
154
  if stream:
@@ -68,8 +160,13 @@ async def create_openai_chat_completion(
68
160
  # Send final chunk
69
161
  yield b"data: [DONE]\n\n"
70
162
 
71
- return StreamingResponse(
72
- openai_stream_generator(),
163
+ # Use unified streaming wrapper with logging
164
+ # Session interrupts are now handled directly by the StreamHandle
165
+ return StreamingResponseWithLogging(
166
+ content=openai_stream_generator(),
167
+ request_context=request_context,
168
+ metrics=getattr(claude_service, "metrics", None),
169
+ status_code=200,
73
170
  media_type="text/event-stream",
74
171
  headers={
75
172
  "Cache-Control": "no-cache",
@@ -98,10 +195,98 @@ async def create_openai_chat_completion(
98
195
  ) from e
99
196
 
100
197
 
198
+ @router.post(
199
+ "/{session_id}/v1/messages",
200
+ response_model=None,
201
+ )
202
+ async def create_anthropic_message_with_session(
203
+ session_id: str,
204
+ message_request: MessageCreateParams,
205
+ claude_service: ClaudeServiceDep,
206
+ request: Request,
207
+ ) -> StreamingResponse | MessageResponse:
208
+ """Create a message using Claude SDK with Anthropic format and session ID.
209
+
210
+ This endpoint handles Anthropic API format requests with session ID directly
211
+ using the Claude SDK without any format conversion.
212
+ """
213
+ try:
214
+ # Extract parameters from Anthropic request
215
+ messages = [msg.model_dump() for msg in message_request.messages]
216
+ model = message_request.model
217
+ temperature = message_request.temperature
218
+ max_tokens = message_request.max_tokens
219
+ stream = message_request.stream or False
220
+
221
+ # Get request context from middleware
222
+ request_context = getattr(request.state, "context", None)
223
+ if request_context is None:
224
+ raise HTTPException(
225
+ status_code=500, detail="Internal server error: no request context"
226
+ )
227
+
228
+ # Call Claude SDK service directly with Anthropic format and session_id
229
+ response = await claude_service.create_completion(
230
+ messages=messages,
231
+ model=model,
232
+ temperature=temperature,
233
+ max_tokens=max_tokens,
234
+ stream=stream,
235
+ user_id=getattr(message_request, "user_id", None),
236
+ session_id=session_id,
237
+ request_context=request_context,
238
+ )
239
+
240
+ if stream:
241
+ # Handle streaming response
242
+ async def anthropic_stream_generator() -> AsyncIterator[bytes]:
243
+ async for chunk in response: # type: ignore[union-attr]
244
+ if chunk:
245
+ # All chunks from Claude SDK streaming should be dict format
246
+ # and need proper SSE event formatting
247
+ if isinstance(chunk, dict):
248
+ # Determine event type from chunk type
249
+ event_type = chunk.get("type", "message_delta")
250
+ yield f"event: {event_type}\n".encode()
251
+ yield f"data: {json.dumps(chunk)}\n\n".encode()
252
+ else:
253
+ # Fallback for unexpected format
254
+ yield f"data: {json.dumps(chunk)}\n\n".encode()
255
+ # No final [DONE] chunk for Anthropic format
256
+
257
+ # Use unified streaming wrapper with logging
258
+ # Session interrupts are now handled directly by the StreamHandle
259
+ return StreamingResponseWithLogging(
260
+ content=anthropic_stream_generator(),
261
+ request_context=request_context,
262
+ metrics=getattr(claude_service, "metrics", None),
263
+ status_code=200,
264
+ media_type="text/event-stream",
265
+ headers={
266
+ "Cache-Control": "no-cache",
267
+ "Connection": "keep-alive",
268
+ },
269
+ )
270
+ else:
271
+ # Return Anthropic format response directly
272
+ return MessageResponse.model_validate(response)
273
+
274
+ except Exception as e:
275
+ # Re-raise specific proxy errors to be handled by the error handler
276
+ from ccproxy.core.errors import ClaudeProxyError
277
+
278
+ if isinstance(e, ClaudeProxyError):
279
+ raise e
280
+ raise HTTPException(
281
+ status_code=500, detail=f"Internal server error: {str(e)}"
282
+ ) from e
283
+
284
+
101
285
  @router.post("/v1/messages", response_model=None)
102
286
  async def create_anthropic_message(
103
- request: MessageCreateParams,
287
+ message_request: MessageCreateParams,
104
288
  claude_service: ClaudeServiceDep,
289
+ request: Request,
105
290
  ) -> StreamingResponse | MessageResponse:
106
291
  """Create a message using Claude SDK with Anthropic format.
107
292
 
@@ -110,11 +295,24 @@ async def create_anthropic_message(
110
295
  """
111
296
  try:
112
297
  # Extract parameters from Anthropic request
113
- messages = [msg.model_dump() for msg in request.messages]
114
- model = request.model
115
- temperature = request.temperature
116
- max_tokens = request.max_tokens
117
- stream = request.stream or False
298
+ messages = [msg.model_dump() for msg in message_request.messages]
299
+ model = message_request.model
300
+ temperature = message_request.temperature
301
+ max_tokens = message_request.max_tokens
302
+ stream = message_request.stream or False
303
+
304
+ # Get request context from middleware
305
+ request_context = getattr(request.state, "context", None)
306
+ if request_context is None:
307
+ raise HTTPException(
308
+ status_code=500, detail="Internal server error: no request context"
309
+ )
310
+
311
+ # Extract session_id from metadata if present
312
+ session_id = None
313
+ if message_request.metadata:
314
+ metadata_dict = message_request.metadata.model_dump()
315
+ session_id = metadata_dict.get("session_id")
118
316
 
119
317
  # Call Claude SDK service directly with Anthropic format
120
318
  response = await claude_service.create_completion(
@@ -123,7 +321,9 @@ async def create_anthropic_message(
123
321
  temperature=temperature,
124
322
  max_tokens=max_tokens,
125
323
  stream=stream,
126
- user_id=getattr(request, "user_id", None),
324
+ user_id=getattr(message_request, "user_id", None),
325
+ session_id=session_id,
326
+ request_context=request_context,
127
327
  )
128
328
 
129
329
  if stream:
@@ -143,8 +343,13 @@ async def create_anthropic_message(
143
343
  yield f"data: {json.dumps(chunk)}\n\n".encode()
144
344
  # No final [DONE] chunk for Anthropic format
145
345
 
146
- return StreamingResponse(
147
- anthropic_stream_generator(),
346
+ # Use unified streaming wrapper with logging for all requests
347
+ # Session interrupts are now handled directly by the StreamHandle
348
+ return StreamingResponseWithLogging(
349
+ content=anthropic_stream_generator(),
350
+ request_context=request_context,
351
+ metrics=getattr(claude_service, "metrics", None),
352
+ status_code=200,
148
353
  media_type="text/event-stream",
149
354
  headers={
150
355
  "Cache-Control": "no-cache",