ccproxy-api 0.1.1__py3-none-any.whl → 0.1.3__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 (107) hide show
  1. ccproxy/_version.py +2 -2
  2. ccproxy/adapters/openai/__init__.py +1 -2
  3. ccproxy/adapters/openai/adapter.py +218 -180
  4. ccproxy/adapters/openai/streaming.py +247 -65
  5. ccproxy/api/__init__.py +0 -3
  6. ccproxy/api/app.py +173 -40
  7. ccproxy/api/dependencies.py +65 -3
  8. ccproxy/api/middleware/errors.py +3 -7
  9. ccproxy/api/middleware/headers.py +0 -2
  10. ccproxy/api/middleware/logging.py +4 -3
  11. ccproxy/api/middleware/request_content_logging.py +297 -0
  12. ccproxy/api/middleware/request_id.py +5 -0
  13. ccproxy/api/middleware/server_header.py +0 -4
  14. ccproxy/api/routes/__init__.py +9 -1
  15. ccproxy/api/routes/claude.py +23 -32
  16. ccproxy/api/routes/health.py +58 -4
  17. ccproxy/api/routes/mcp.py +171 -0
  18. ccproxy/api/routes/metrics.py +4 -8
  19. ccproxy/api/routes/permissions.py +217 -0
  20. ccproxy/api/routes/proxy.py +0 -53
  21. ccproxy/api/services/__init__.py +6 -0
  22. ccproxy/api/services/permission_service.py +368 -0
  23. ccproxy/api/ui/__init__.py +6 -0
  24. ccproxy/api/ui/permission_handler_protocol.py +33 -0
  25. ccproxy/api/ui/terminal_permission_handler.py +593 -0
  26. ccproxy/auth/conditional.py +2 -2
  27. ccproxy/auth/dependencies.py +1 -1
  28. ccproxy/auth/oauth/models.py +0 -1
  29. ccproxy/auth/oauth/routes.py +1 -3
  30. ccproxy/auth/storage/json_file.py +0 -1
  31. ccproxy/auth/storage/keyring.py +0 -3
  32. ccproxy/claude_sdk/__init__.py +2 -0
  33. ccproxy/claude_sdk/client.py +91 -8
  34. ccproxy/claude_sdk/converter.py +405 -210
  35. ccproxy/claude_sdk/options.py +88 -19
  36. ccproxy/claude_sdk/parser.py +200 -0
  37. ccproxy/claude_sdk/streaming.py +286 -0
  38. ccproxy/cli/commands/__init__.py +5 -1
  39. ccproxy/cli/commands/auth.py +2 -4
  40. ccproxy/cli/commands/permission_handler.py +553 -0
  41. ccproxy/cli/commands/serve.py +52 -12
  42. ccproxy/cli/docker/params.py +0 -4
  43. ccproxy/cli/helpers.py +0 -2
  44. ccproxy/cli/main.py +6 -17
  45. ccproxy/cli/options/claude_options.py +41 -1
  46. ccproxy/cli/options/core_options.py +0 -3
  47. ccproxy/cli/options/security_options.py +0 -2
  48. ccproxy/cli/options/server_options.py +3 -2
  49. ccproxy/config/auth.py +0 -1
  50. ccproxy/config/claude.py +78 -2
  51. ccproxy/config/discovery.py +0 -1
  52. ccproxy/config/docker_settings.py +0 -1
  53. ccproxy/config/loader.py +1 -4
  54. ccproxy/config/scheduler.py +20 -0
  55. ccproxy/config/security.py +7 -2
  56. ccproxy/config/server.py +5 -0
  57. ccproxy/config/settings.py +15 -7
  58. ccproxy/config/validators.py +1 -1
  59. ccproxy/core/async_utils.py +1 -4
  60. ccproxy/core/errors.py +45 -1
  61. ccproxy/core/http_transformers.py +4 -3
  62. ccproxy/core/interfaces.py +2 -2
  63. ccproxy/core/logging.py +97 -95
  64. ccproxy/core/middleware.py +1 -1
  65. ccproxy/core/proxy.py +1 -1
  66. ccproxy/core/transformers.py +1 -1
  67. ccproxy/core/types.py +1 -1
  68. ccproxy/docker/models.py +1 -1
  69. ccproxy/docker/protocol.py +0 -3
  70. ccproxy/models/__init__.py +41 -0
  71. ccproxy/models/claude_sdk.py +420 -0
  72. ccproxy/models/messages.py +45 -18
  73. ccproxy/models/permissions.py +115 -0
  74. ccproxy/models/requests.py +1 -1
  75. ccproxy/models/responses.py +64 -1
  76. ccproxy/observability/access_logger.py +1 -2
  77. ccproxy/observability/context.py +17 -1
  78. ccproxy/observability/metrics.py +1 -3
  79. ccproxy/observability/pushgateway.py +0 -2
  80. ccproxy/observability/stats_printer.py +2 -4
  81. ccproxy/observability/storage/duckdb_simple.py +1 -1
  82. ccproxy/observability/storage/models.py +0 -1
  83. ccproxy/pricing/cache.py +0 -1
  84. ccproxy/pricing/loader.py +5 -21
  85. ccproxy/pricing/updater.py +0 -1
  86. ccproxy/scheduler/__init__.py +1 -0
  87. ccproxy/scheduler/core.py +6 -6
  88. ccproxy/scheduler/manager.py +35 -7
  89. ccproxy/scheduler/registry.py +1 -1
  90. ccproxy/scheduler/tasks.py +127 -2
  91. ccproxy/services/claude_sdk_service.py +225 -329
  92. ccproxy/services/credentials/manager.py +0 -1
  93. ccproxy/services/credentials/oauth_client.py +1 -2
  94. ccproxy/services/proxy_service.py +93 -222
  95. ccproxy/testing/config.py +1 -1
  96. ccproxy/testing/mock_responses.py +0 -1
  97. ccproxy/utils/model_mapping.py +197 -0
  98. ccproxy/utils/models_provider.py +150 -0
  99. ccproxy/utils/simple_request_logger.py +284 -0
  100. ccproxy/utils/version_checker.py +184 -0
  101. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/METADATA +63 -2
  102. ccproxy_api-0.1.3.dist-info/RECORD +166 -0
  103. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/entry_points.txt +1 -0
  104. ccproxy_api-0.1.1.dist-info/RECORD +0 -149
  105. /ccproxy/scheduler/{exceptions.py → errors.py} +0 -0
  106. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/WHEEL +0 -0
  107. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/licenses/LICENSE +0 -0
@@ -8,19 +8,19 @@ from __future__ import annotations
8
8
 
9
9
  import json
10
10
  import time
11
- from collections.abc import AsyncGenerator, AsyncIterator
12
- from typing import Any
11
+ from collections.abc import AsyncIterator
12
+ from typing import Any, Literal
13
+
14
+ import structlog
13
15
 
14
16
  from .models import (
15
- OpenAIStreamingChatCompletionResponse,
16
- OpenAIStreamingChoice,
17
- OpenAIStreamingDelta,
18
- OpenAIUsage,
19
17
  generate_openai_response_id,
20
- generate_openai_system_fingerprint,
21
18
  )
22
19
 
23
20
 
21
+ logger = structlog.get_logger(__name__)
22
+
23
+
24
24
  class OpenAISSEFormatter:
25
25
  """Formats streaming responses to match OpenAI's SSE format."""
26
26
 
@@ -246,8 +246,7 @@ class OpenAIStreamProcessor:
246
246
  created: int | None = None,
247
247
  enable_usage: bool = True,
248
248
  enable_tool_calls: bool = True,
249
- enable_text_chunking: bool = True,
250
- chunk_size_words: int = 3,
249
+ output_format: Literal["sse", "dict"] = "sse",
251
250
  ):
252
251
  """Initialize the stream processor.
253
252
 
@@ -257,16 +256,14 @@ class OpenAIStreamProcessor:
257
256
  created: Creation timestamp, current time if not provided
258
257
  enable_usage: Whether to include usage information
259
258
  enable_tool_calls: Whether to process tool calls
260
- enable_text_chunking: Whether to chunk text content
261
- chunk_size_words: Number of words per text chunk
259
+ output_format: Output format - "sse" for Server-Sent Events strings, "dict" for dict objects
262
260
  """
263
261
  self.message_id = message_id or generate_openai_response_id()
264
262
  self.model = model
265
263
  self.created = created or int(time.time())
266
264
  self.enable_usage = enable_usage
267
265
  self.enable_tool_calls = enable_tool_calls
268
- self.enable_text_chunking = enable_text_chunking
269
- self.chunk_size_words = chunk_size_words
266
+ self.output_format = output_format
270
267
  self.formatter = OpenAISSEFormatter()
271
268
 
272
269
  # State tracking
@@ -281,71 +278,164 @@ class OpenAIStreamProcessor:
281
278
 
282
279
  async def process_stream(
283
280
  self, claude_stream: AsyncIterator[dict[str, Any]]
284
- ) -> AsyncIterator[str]:
281
+ ) -> AsyncIterator[str | dict[str, Any]]:
285
282
  """Process a Claude/Anthropic stream into OpenAI format.
286
283
 
287
284
  Args:
288
285
  claude_stream: Async iterator of Claude response chunks
289
286
 
290
287
  Yields:
291
- OpenAI-formatted SSE strings
288
+ OpenAI-formatted SSE strings or dict objects based on output_format
292
289
  """
293
290
  try:
291
+ chunk_count = 0
292
+ processed_count = 0
294
293
  async for chunk in claude_stream:
294
+ chunk_count += 1
295
+ logger.debug(
296
+ "openai_stream_chunk_received",
297
+ chunk_count=chunk_count,
298
+ chunk_type=chunk.get("type"),
299
+ chunk=chunk,
300
+ )
295
301
  async for sse_chunk in self._process_chunk(chunk):
302
+ processed_count += 1
303
+ logger.debug(
304
+ "openai_stream_chunk_processed",
305
+ processed_count=processed_count,
306
+ sse_chunk=sse_chunk,
307
+ )
296
308
  yield sse_chunk
297
309
 
310
+ logger.debug(
311
+ "openai_stream_complete",
312
+ total_chunks=chunk_count,
313
+ processed_chunks=processed_count,
314
+ usage_info=self.usage_info,
315
+ )
316
+
298
317
  # Send final chunk
299
318
  if self.usage_info and self.enable_usage:
300
- yield self.formatter.format_final_chunk(
301
- self.message_id,
302
- self.model,
303
- self.created,
319
+ yield self._format_chunk_output(
304
320
  finish_reason="stop",
305
321
  usage=self.usage_info,
306
322
  )
307
323
  else:
308
- yield self.formatter.format_final_chunk(
309
- self.message_id, self.model, self.created, finish_reason="stop"
310
- )
324
+ yield self._format_chunk_output(finish_reason="stop")
311
325
 
312
- # Send DONE event
313
- yield self.formatter.format_done()
326
+ # Send DONE event (only for SSE format)
327
+ if self.output_format == "sse":
328
+ yield self.formatter.format_done()
314
329
 
315
330
  except Exception as e:
316
331
  # Send error chunk
317
- yield self.formatter.format_error_chunk(
318
- self.message_id, self.model, self.created, "error", str(e)
319
- )
320
- yield self.formatter.format_done()
332
+ if self.output_format == "sse":
333
+ yield self.formatter.format_error_chunk(
334
+ self.message_id, self.model, self.created, "error", str(e)
335
+ )
336
+ yield self.formatter.format_done()
337
+ else:
338
+ # Dict format error
339
+ yield self._create_chunk_dict(finish_reason="error")
321
340
 
322
- async def _process_chunk(self, chunk: dict[str, Any]) -> AsyncIterator[str]:
341
+ async def _process_chunk(
342
+ self, chunk: dict[str, Any]
343
+ ) -> AsyncIterator[str | dict[str, Any]]:
323
344
  """Process a single chunk from the Claude stream.
324
345
 
325
346
  Args:
326
347
  chunk: Claude response chunk
327
348
 
328
349
  Yields:
329
- OpenAI-formatted SSE strings
350
+ OpenAI-formatted SSE strings or dict objects based on output_format
330
351
  """
331
- chunk_type = chunk.get("type")
352
+ # Handle both Claude SDK and standard Anthropic API formats:
353
+ # Claude SDK format: {"event": "...", "data": {"type": "..."}}
354
+ # Anthropic API format: {"type": "...", ...}
355
+ event_type = chunk.get("event")
356
+ if event_type:
357
+ # Claude SDK format
358
+ chunk_data = chunk.get("data", {})
359
+ chunk_type = chunk_data.get("type")
360
+ else:
361
+ # Standard Anthropic API format
362
+ chunk_data = chunk
363
+ chunk_type = chunk.get("type")
332
364
 
333
365
  if chunk_type == "message_start":
334
366
  # Send initial role chunk
335
367
  if not self.role_sent:
336
- yield self.formatter.format_first_chunk(
337
- self.message_id, self.model, self.created
338
- )
368
+ yield self._format_chunk_output(delta={"role": "assistant"})
339
369
  self.role_sent = True
340
370
 
341
371
  elif chunk_type == "content_block_start":
342
- block = chunk.get("content_block", {})
372
+ block = chunk_data.get("content_block", {})
343
373
  if block.get("type") == "thinking":
344
374
  # Start of thinking block
345
375
  self.thinking_block_active = True
346
376
  self.current_thinking_text = ""
347
377
  self.current_thinking_signature = None
348
- elif block.get("type") == "tool_use" and self.enable_tool_calls:
378
+ elif block.get("type") == "system_message":
379
+ # Handle system message content block
380
+ system_text = block.get("text", "")
381
+ source = block.get("source", "claude_code_sdk")
382
+ # Format as text with clear source attribution
383
+ formatted_text = f"[{source}]: {system_text}"
384
+ yield self._format_chunk_output(delta={"content": formatted_text})
385
+ elif block.get("type") == "tool_use_sdk" and self.enable_tool_calls:
386
+ # Handle custom tool_use_sdk content block
387
+ tool_id = block.get("id", "")
388
+ tool_name = block.get("name", "")
389
+ tool_input = block.get("input", {})
390
+ source = block.get("source", "claude_code_sdk")
391
+
392
+ # For dict format, immediately yield the tool call
393
+ if self.output_format == "dict":
394
+ yield self._format_chunk_output(
395
+ delta={
396
+ "tool_calls": [
397
+ {
398
+ "index": 0,
399
+ "id": tool_id,
400
+ "type": "function",
401
+ "function": {
402
+ "name": tool_name,
403
+ "arguments": json.dumps(tool_input),
404
+ },
405
+ }
406
+ ]
407
+ }
408
+ )
409
+ else:
410
+ # For SSE format, store for later processing
411
+ self.tool_calls[tool_id] = {
412
+ "id": tool_id,
413
+ "name": tool_name,
414
+ "arguments": tool_input,
415
+ "source": source,
416
+ }
417
+ elif block.get("type") == "tool_result_sdk":
418
+ # Handle custom tool_result_sdk content block
419
+ source = block.get("source", "claude_code_sdk")
420
+ tool_use_id = block.get("tool_use_id", "")
421
+ result_content = block.get("content", "")
422
+ is_error = block.get("is_error", False)
423
+ error_indicator = " (ERROR)" if is_error else ""
424
+ formatted_text = f"[{source} tool_result {tool_use_id}{error_indicator}]: {result_content}"
425
+ yield self._format_chunk_output(delta={"content": formatted_text})
426
+ elif block.get("type") == "result_message":
427
+ # Handle custom result_message content block
428
+ source = block.get("source", "claude_code_sdk")
429
+ result_data = block.get("data", {})
430
+ session_id = result_data.get("session_id", "")
431
+ stop_reason = result_data.get("stop_reason", "")
432
+ usage = result_data.get("usage", {})
433
+ cost_usd = result_data.get("total_cost_usd")
434
+ formatted_text = f"[{source} result {session_id}]: stop_reason={stop_reason}, usage={usage}"
435
+ if cost_usd is not None:
436
+ formatted_text += f", cost_usd={cost_usd}"
437
+ yield self._format_chunk_output(delta={"content": formatted_text})
438
+ elif block.get("type") == "tool_use":
349
439
  # Start of tool call
350
440
  tool_id = block.get("id", "")
351
441
  tool_name = block.get("name", "")
@@ -356,27 +446,14 @@ class OpenAIStreamProcessor:
356
446
  }
357
447
 
358
448
  elif chunk_type == "content_block_delta":
359
- delta = chunk.get("delta", {})
449
+ delta = chunk_data.get("delta", {})
360
450
  delta_type = delta.get("type")
361
451
 
362
452
  if delta_type == "text_delta":
363
453
  # Text content
364
454
  text = delta.get("text", "")
365
455
  if text:
366
- if self.enable_text_chunking:
367
- # Chunk the text
368
- words = text.split()
369
- for i in range(0, len(words), self.chunk_size_words):
370
- chunk_words = words[i : i + self.chunk_size_words]
371
- chunk_text = " ".join(chunk_words)
372
- yield self.formatter.format_content_chunk(
373
- self.message_id, self.model, self.created, chunk_text
374
- )
375
- else:
376
- # Send text as-is
377
- yield self.formatter.format_content_chunk(
378
- self.message_id, self.model, self.created, text
379
- )
456
+ yield self._format_chunk_output(delta={"content": text})
380
457
 
381
458
  elif delta_type == "thinking_delta" and self.thinking_block_active:
382
459
  # Thinking content
@@ -392,7 +469,7 @@ class OpenAIStreamProcessor:
392
469
  self.current_thinking_signature = ""
393
470
  self.current_thinking_signature += signature
394
471
 
395
- elif delta_type == "input_json_delta" and self.enable_tool_calls:
472
+ elif delta_type == "input_json_delta":
396
473
  # Tool call arguments
397
474
  partial_json = delta.get("partial_json", "")
398
475
  if partial_json and self.tool_calls:
@@ -408,28 +485,39 @@ class OpenAIStreamProcessor:
408
485
  if self.current_thinking_text:
409
486
  # Format thinking block with signature
410
487
  thinking_content = f'<thinking signature="{self.current_thinking_signature}">{self.current_thinking_text}</thinking>'
411
- yield self.formatter.format_content_chunk(
412
- self.message_id, self.model, self.created, thinking_content
413
- )
488
+ yield self._format_chunk_output(delta={"content": thinking_content})
414
489
  # Reset thinking state
415
490
  self.current_thinking_text = ""
416
491
  self.current_thinking_signature = None
417
492
 
418
- elif self.tool_calls and self.enable_tool_calls:
419
- # Send completed tool calls
493
+ elif (
494
+ self.tool_calls
495
+ and self.enable_tool_calls
496
+ and self.output_format == "sse"
497
+ ):
498
+ # Send completed tool calls (only for SSE format, dict format sends immediately)
420
499
  for tool_call in self.tool_calls.values():
421
- yield self.formatter.format_tool_call_chunk(
422
- self.message_id,
423
- self.model,
424
- self.created,
425
- tool_call["id"],
426
- function_name=tool_call["name"],
427
- function_arguments=tool_call["arguments"],
500
+ yield self._format_chunk_output(
501
+ delta={
502
+ "tool_calls": [
503
+ {
504
+ "index": 0,
505
+ "id": tool_call["id"],
506
+ "type": "function",
507
+ "function": {
508
+ "name": tool_call["name"],
509
+ "arguments": json.dumps(tool_call["arguments"])
510
+ if isinstance(tool_call["arguments"], dict)
511
+ else tool_call["arguments"],
512
+ },
513
+ }
514
+ ]
515
+ }
428
516
  )
429
517
 
430
518
  elif chunk_type == "message_delta":
431
519
  # Usage information
432
- usage = chunk.get("usage", {})
520
+ usage = chunk_data.get("usage", {})
433
521
  if usage and self.enable_usage:
434
522
  self.usage_info = {
435
523
  "prompt_tokens": usage.get("input_tokens", 0),
@@ -442,6 +530,100 @@ class OpenAIStreamProcessor:
442
530
  # End of message - handled in main process_stream method
443
531
  pass
444
532
 
533
+ def _create_chunk_dict(
534
+ self,
535
+ delta: dict[str, Any] | None = None,
536
+ finish_reason: str | None = None,
537
+ usage: dict[str, int] | None = None,
538
+ ) -> dict[str, Any]:
539
+ """Create an OpenAI completion chunk dict.
540
+
541
+ Args:
542
+ delta: The delta content for the chunk
543
+ finish_reason: Optional finish reason
544
+ usage: Optional usage information
545
+
546
+ Returns:
547
+ OpenAI completion chunk dict
548
+ """
549
+ chunk = {
550
+ "id": self.message_id,
551
+ "object": "chat.completion.chunk",
552
+ "created": self.created,
553
+ "model": self.model,
554
+ "choices": [
555
+ {
556
+ "index": 0,
557
+ "delta": delta or {},
558
+ "logprobs": None,
559
+ "finish_reason": finish_reason,
560
+ }
561
+ ],
562
+ }
563
+
564
+ if usage:
565
+ chunk["usage"] = usage
566
+
567
+ return chunk
568
+
569
+ def _format_chunk_output(
570
+ self,
571
+ delta: dict[str, Any] | None = None,
572
+ finish_reason: str | None = None,
573
+ usage: dict[str, int] | None = None,
574
+ ) -> str | dict[str, Any]:
575
+ """Format chunk output based on output_format flag.
576
+
577
+ Args:
578
+ delta: The delta content for the chunk
579
+ finish_reason: Optional finish reason
580
+ usage: Optional usage information
581
+
582
+ Returns:
583
+ Either SSE string or dict based on output_format
584
+ """
585
+ if self.output_format == "dict":
586
+ return self._create_chunk_dict(delta, finish_reason, usage)
587
+ else:
588
+ # SSE format
589
+ if finish_reason:
590
+ if usage:
591
+ return self.formatter.format_final_chunk(
592
+ self.message_id,
593
+ self.model,
594
+ self.created,
595
+ finish_reason,
596
+ usage=usage,
597
+ )
598
+ else:
599
+ return self.formatter.format_final_chunk(
600
+ self.message_id, self.model, self.created, finish_reason
601
+ )
602
+ elif delta and delta.get("role"):
603
+ return self.formatter.format_first_chunk(
604
+ self.message_id, self.model, self.created, delta["role"]
605
+ )
606
+ elif delta and delta.get("content"):
607
+ return self.formatter.format_content_chunk(
608
+ self.message_id, self.model, self.created, delta["content"]
609
+ )
610
+ elif delta and delta.get("tool_calls"):
611
+ # Handle tool calls
612
+ tool_call = delta["tool_calls"][0] # Assume single tool call for now
613
+ return self.formatter.format_tool_call_chunk(
614
+ self.message_id,
615
+ self.model,
616
+ self.created,
617
+ tool_call["id"],
618
+ tool_call.get("function", {}).get("name"),
619
+ tool_call.get("function", {}).get("arguments"),
620
+ )
621
+ else:
622
+ # Empty delta
623
+ return self.formatter.format_final_chunk(
624
+ self.message_id, self.model, self.created, "stop"
625
+ )
626
+
445
627
 
446
628
  __all__ = [
447
629
  "OpenAISSEFormatter",
ccproxy/api/__init__.py CHANGED
@@ -12,10 +12,7 @@ from ccproxy.api.dependencies import (
12
12
  )
13
13
 
14
14
 
15
- app = create_app()
16
-
17
15
  __all__ = [
18
- "app",
19
16
  "create_app",
20
17
  "get_app",
21
18
  "get_claude_service",