ccproxy-api 0.1.0__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 (148) hide show
  1. ccproxy/__init__.py +4 -0
  2. ccproxy/__main__.py +7 -0
  3. ccproxy/_version.py +21 -0
  4. ccproxy/adapters/__init__.py +11 -0
  5. ccproxy/adapters/base.py +80 -0
  6. ccproxy/adapters/openai/__init__.py +43 -0
  7. ccproxy/adapters/openai/adapter.py +915 -0
  8. ccproxy/adapters/openai/models.py +412 -0
  9. ccproxy/adapters/openai/streaming.py +449 -0
  10. ccproxy/api/__init__.py +28 -0
  11. ccproxy/api/app.py +225 -0
  12. ccproxy/api/dependencies.py +140 -0
  13. ccproxy/api/middleware/__init__.py +11 -0
  14. ccproxy/api/middleware/auth.py +0 -0
  15. ccproxy/api/middleware/cors.py +55 -0
  16. ccproxy/api/middleware/errors.py +703 -0
  17. ccproxy/api/middleware/headers.py +51 -0
  18. ccproxy/api/middleware/logging.py +175 -0
  19. ccproxy/api/middleware/request_id.py +69 -0
  20. ccproxy/api/middleware/server_header.py +62 -0
  21. ccproxy/api/responses.py +84 -0
  22. ccproxy/api/routes/__init__.py +16 -0
  23. ccproxy/api/routes/claude.py +181 -0
  24. ccproxy/api/routes/health.py +489 -0
  25. ccproxy/api/routes/metrics.py +1033 -0
  26. ccproxy/api/routes/proxy.py +238 -0
  27. ccproxy/auth/__init__.py +75 -0
  28. ccproxy/auth/bearer.py +68 -0
  29. ccproxy/auth/credentials_adapter.py +93 -0
  30. ccproxy/auth/dependencies.py +229 -0
  31. ccproxy/auth/exceptions.py +79 -0
  32. ccproxy/auth/manager.py +102 -0
  33. ccproxy/auth/models.py +118 -0
  34. ccproxy/auth/oauth/__init__.py +26 -0
  35. ccproxy/auth/oauth/models.py +49 -0
  36. ccproxy/auth/oauth/routes.py +396 -0
  37. ccproxy/auth/oauth/storage.py +0 -0
  38. ccproxy/auth/storage/__init__.py +12 -0
  39. ccproxy/auth/storage/base.py +57 -0
  40. ccproxy/auth/storage/json_file.py +159 -0
  41. ccproxy/auth/storage/keyring.py +192 -0
  42. ccproxy/claude_sdk/__init__.py +20 -0
  43. ccproxy/claude_sdk/client.py +169 -0
  44. ccproxy/claude_sdk/converter.py +331 -0
  45. ccproxy/claude_sdk/options.py +120 -0
  46. ccproxy/cli/__init__.py +14 -0
  47. ccproxy/cli/commands/__init__.py +8 -0
  48. ccproxy/cli/commands/auth.py +553 -0
  49. ccproxy/cli/commands/config/__init__.py +14 -0
  50. ccproxy/cli/commands/config/commands.py +766 -0
  51. ccproxy/cli/commands/config/schema_commands.py +119 -0
  52. ccproxy/cli/commands/serve.py +630 -0
  53. ccproxy/cli/docker/__init__.py +34 -0
  54. ccproxy/cli/docker/adapter_factory.py +157 -0
  55. ccproxy/cli/docker/params.py +278 -0
  56. ccproxy/cli/helpers.py +144 -0
  57. ccproxy/cli/main.py +193 -0
  58. ccproxy/cli/options/__init__.py +14 -0
  59. ccproxy/cli/options/claude_options.py +216 -0
  60. ccproxy/cli/options/core_options.py +40 -0
  61. ccproxy/cli/options/security_options.py +48 -0
  62. ccproxy/cli/options/server_options.py +117 -0
  63. ccproxy/config/__init__.py +40 -0
  64. ccproxy/config/auth.py +154 -0
  65. ccproxy/config/claude.py +124 -0
  66. ccproxy/config/cors.py +79 -0
  67. ccproxy/config/discovery.py +87 -0
  68. ccproxy/config/docker_settings.py +265 -0
  69. ccproxy/config/loader.py +108 -0
  70. ccproxy/config/observability.py +158 -0
  71. ccproxy/config/pricing.py +88 -0
  72. ccproxy/config/reverse_proxy.py +31 -0
  73. ccproxy/config/scheduler.py +89 -0
  74. ccproxy/config/security.py +14 -0
  75. ccproxy/config/server.py +81 -0
  76. ccproxy/config/settings.py +534 -0
  77. ccproxy/config/validators.py +231 -0
  78. ccproxy/core/__init__.py +274 -0
  79. ccproxy/core/async_utils.py +675 -0
  80. ccproxy/core/constants.py +97 -0
  81. ccproxy/core/errors.py +256 -0
  82. ccproxy/core/http.py +328 -0
  83. ccproxy/core/http_transformers.py +428 -0
  84. ccproxy/core/interfaces.py +247 -0
  85. ccproxy/core/logging.py +189 -0
  86. ccproxy/core/middleware.py +114 -0
  87. ccproxy/core/proxy.py +143 -0
  88. ccproxy/core/system.py +38 -0
  89. ccproxy/core/transformers.py +259 -0
  90. ccproxy/core/types.py +129 -0
  91. ccproxy/core/validators.py +288 -0
  92. ccproxy/docker/__init__.py +67 -0
  93. ccproxy/docker/adapter.py +588 -0
  94. ccproxy/docker/docker_path.py +207 -0
  95. ccproxy/docker/middleware.py +103 -0
  96. ccproxy/docker/models.py +228 -0
  97. ccproxy/docker/protocol.py +192 -0
  98. ccproxy/docker/stream_process.py +264 -0
  99. ccproxy/docker/validators.py +173 -0
  100. ccproxy/models/__init__.py +123 -0
  101. ccproxy/models/errors.py +42 -0
  102. ccproxy/models/messages.py +243 -0
  103. ccproxy/models/requests.py +85 -0
  104. ccproxy/models/responses.py +227 -0
  105. ccproxy/models/types.py +102 -0
  106. ccproxy/observability/__init__.py +51 -0
  107. ccproxy/observability/access_logger.py +400 -0
  108. ccproxy/observability/context.py +447 -0
  109. ccproxy/observability/metrics.py +539 -0
  110. ccproxy/observability/pushgateway.py +366 -0
  111. ccproxy/observability/sse_events.py +303 -0
  112. ccproxy/observability/stats_printer.py +755 -0
  113. ccproxy/observability/storage/__init__.py +1 -0
  114. ccproxy/observability/storage/duckdb_simple.py +665 -0
  115. ccproxy/observability/storage/models.py +55 -0
  116. ccproxy/pricing/__init__.py +19 -0
  117. ccproxy/pricing/cache.py +212 -0
  118. ccproxy/pricing/loader.py +267 -0
  119. ccproxy/pricing/models.py +106 -0
  120. ccproxy/pricing/updater.py +309 -0
  121. ccproxy/scheduler/__init__.py +39 -0
  122. ccproxy/scheduler/core.py +335 -0
  123. ccproxy/scheduler/exceptions.py +34 -0
  124. ccproxy/scheduler/manager.py +186 -0
  125. ccproxy/scheduler/registry.py +150 -0
  126. ccproxy/scheduler/tasks.py +484 -0
  127. ccproxy/services/__init__.py +10 -0
  128. ccproxy/services/claude_sdk_service.py +614 -0
  129. ccproxy/services/credentials/__init__.py +55 -0
  130. ccproxy/services/credentials/config.py +105 -0
  131. ccproxy/services/credentials/manager.py +562 -0
  132. ccproxy/services/credentials/oauth_client.py +482 -0
  133. ccproxy/services/proxy_service.py +1536 -0
  134. ccproxy/static/.keep +0 -0
  135. ccproxy/testing/__init__.py +34 -0
  136. ccproxy/testing/config.py +148 -0
  137. ccproxy/testing/content_generation.py +197 -0
  138. ccproxy/testing/mock_responses.py +262 -0
  139. ccproxy/testing/response_handlers.py +161 -0
  140. ccproxy/testing/scenarios.py +241 -0
  141. ccproxy/utils/__init__.py +6 -0
  142. ccproxy/utils/cost_calculator.py +210 -0
  143. ccproxy/utils/streaming_metrics.py +199 -0
  144. ccproxy_api-0.1.0.dist-info/METADATA +253 -0
  145. ccproxy_api-0.1.0.dist-info/RECORD +148 -0
  146. ccproxy_api-0.1.0.dist-info/WHEEL +4 -0
  147. ccproxy_api-0.1.0.dist-info/entry_points.txt +2 -0
  148. ccproxy_api-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,449 @@
1
+ """OpenAI streaming response formatting.
2
+
3
+ This module provides Server-Sent Events (SSE) formatting for OpenAI-compatible
4
+ streaming responses.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import time
11
+ from collections.abc import AsyncGenerator, AsyncIterator
12
+ from typing import Any
13
+
14
+ from .models import (
15
+ OpenAIStreamingChatCompletionResponse,
16
+ OpenAIStreamingChoice,
17
+ OpenAIStreamingDelta,
18
+ OpenAIUsage,
19
+ generate_openai_response_id,
20
+ generate_openai_system_fingerprint,
21
+ )
22
+
23
+
24
+ class OpenAISSEFormatter:
25
+ """Formats streaming responses to match OpenAI's SSE format."""
26
+
27
+ @staticmethod
28
+ def format_data_event(data: dict[str, Any]) -> str:
29
+ """Format a data event for OpenAI-compatible Server-Sent Events.
30
+
31
+ Args:
32
+ data: Event data dictionary
33
+
34
+ Returns:
35
+ Formatted SSE string
36
+ """
37
+ json_data = json.dumps(data, separators=(",", ":"))
38
+ return f"data: {json_data}\n\n"
39
+
40
+ @staticmethod
41
+ def format_first_chunk(
42
+ message_id: str, model: str, created: int, role: str = "assistant"
43
+ ) -> str:
44
+ """Format the first chunk with role and basic metadata.
45
+
46
+ Args:
47
+ message_id: Unique identifier for the completion
48
+ model: Model name being used
49
+ created: Unix timestamp when the completion was created
50
+ role: Role of the assistant
51
+
52
+ Returns:
53
+ Formatted SSE string
54
+ """
55
+ data = {
56
+ "id": message_id,
57
+ "object": "chat.completion.chunk",
58
+ "created": created,
59
+ "model": model,
60
+ "choices": [
61
+ {
62
+ "index": 0,
63
+ "delta": {"role": role},
64
+ "logprobs": None,
65
+ "finish_reason": None,
66
+ }
67
+ ],
68
+ }
69
+ return OpenAISSEFormatter.format_data_event(data)
70
+
71
+ @staticmethod
72
+ def format_content_chunk(
73
+ message_id: str, model: str, created: int, content: str, choice_index: int = 0
74
+ ) -> str:
75
+ """Format a content chunk with text delta.
76
+
77
+ Args:
78
+ message_id: Unique identifier for the completion
79
+ model: Model name being used
80
+ created: Unix timestamp when the completion was created
81
+ content: Text content to include in the delta
82
+ choice_index: Index of the choice (usually 0)
83
+
84
+ Returns:
85
+ Formatted SSE string
86
+ """
87
+ data = {
88
+ "id": message_id,
89
+ "object": "chat.completion.chunk",
90
+ "created": created,
91
+ "model": model,
92
+ "choices": [
93
+ {
94
+ "index": choice_index,
95
+ "delta": {"content": content},
96
+ "logprobs": None,
97
+ "finish_reason": None,
98
+ }
99
+ ],
100
+ }
101
+ return OpenAISSEFormatter.format_data_event(data)
102
+
103
+ @staticmethod
104
+ def format_tool_call_chunk(
105
+ message_id: str,
106
+ model: str,
107
+ created: int,
108
+ tool_call_id: str,
109
+ function_name: str | None = None,
110
+ function_arguments: str | None = None,
111
+ tool_call_index: int = 0,
112
+ choice_index: int = 0,
113
+ ) -> str:
114
+ """Format a tool call chunk.
115
+
116
+ Args:
117
+ message_id: Unique identifier for the completion
118
+ model: Model name being used
119
+ created: Unix timestamp when the completion was created
120
+ tool_call_id: ID of the tool call
121
+ function_name: Name of the function being called
122
+ function_arguments: Arguments for the function
123
+ tool_call_index: Index of the tool call
124
+ choice_index: Index of the choice (usually 0)
125
+
126
+ Returns:
127
+ Formatted SSE string
128
+ """
129
+ tool_call: dict[str, Any] = {
130
+ "index": tool_call_index,
131
+ "id": tool_call_id,
132
+ "type": "function",
133
+ "function": {},
134
+ }
135
+
136
+ if function_name is not None:
137
+ tool_call["function"]["name"] = function_name
138
+
139
+ if function_arguments is not None:
140
+ tool_call["function"]["arguments"] = function_arguments
141
+
142
+ data = {
143
+ "id": message_id,
144
+ "object": "chat.completion.chunk",
145
+ "created": created,
146
+ "model": model,
147
+ "choices": [
148
+ {
149
+ "index": choice_index,
150
+ "delta": {"tool_calls": [tool_call]},
151
+ "logprobs": None,
152
+ "finish_reason": None,
153
+ }
154
+ ],
155
+ }
156
+ return OpenAISSEFormatter.format_data_event(data)
157
+
158
+ @staticmethod
159
+ def format_final_chunk(
160
+ message_id: str,
161
+ model: str,
162
+ created: int,
163
+ finish_reason: str = "stop",
164
+ choice_index: int = 0,
165
+ usage: dict[str, int] | None = None,
166
+ ) -> str:
167
+ """Format the final chunk with finish_reason.
168
+
169
+ Args:
170
+ message_id: Unique identifier for the completion
171
+ model: Model name being used
172
+ created: Unix timestamp when the completion was created
173
+ finish_reason: Reason for completion (stop, length, tool_calls, etc.)
174
+ choice_index: Index of the choice (usually 0)
175
+ usage: Optional usage information to include
176
+
177
+ Returns:
178
+ Formatted SSE string
179
+ """
180
+ data = {
181
+ "id": message_id,
182
+ "object": "chat.completion.chunk",
183
+ "created": created,
184
+ "model": model,
185
+ "choices": [
186
+ {
187
+ "index": choice_index,
188
+ "delta": {},
189
+ "logprobs": None,
190
+ "finish_reason": finish_reason,
191
+ }
192
+ ],
193
+ }
194
+
195
+ # Add usage if provided
196
+ if usage:
197
+ data["usage"] = usage
198
+
199
+ return OpenAISSEFormatter.format_data_event(data)
200
+
201
+ @staticmethod
202
+ def format_error_chunk(
203
+ message_id: str, model: str, created: int, error_type: str, error_message: str
204
+ ) -> str:
205
+ """Format an error chunk.
206
+
207
+ Args:
208
+ message_id: Unique identifier for the completion
209
+ model: Model name being used
210
+ created: Unix timestamp when the completion was created
211
+ error_type: Type of error
212
+ error_message: Error message
213
+
214
+ Returns:
215
+ Formatted SSE string
216
+ """
217
+ data = {
218
+ "id": message_id,
219
+ "object": "chat.completion.chunk",
220
+ "created": created,
221
+ "model": model,
222
+ "choices": [
223
+ {"index": 0, "delta": {}, "logprobs": None, "finish_reason": "error"}
224
+ ],
225
+ "error": {"type": error_type, "message": error_message},
226
+ }
227
+ return OpenAISSEFormatter.format_data_event(data)
228
+
229
+ @staticmethod
230
+ def format_done() -> str:
231
+ """Format the final DONE event.
232
+
233
+ Returns:
234
+ Formatted SSE termination string
235
+ """
236
+ return "data: [DONE]\n\n"
237
+
238
+
239
+ class OpenAIStreamProcessor:
240
+ """Processes Anthropic/Claude streaming responses into OpenAI format."""
241
+
242
+ def __init__(
243
+ self,
244
+ message_id: str | None = None,
245
+ model: str = "claude-3-5-sonnet-20241022",
246
+ created: int | None = None,
247
+ enable_usage: bool = True,
248
+ enable_tool_calls: bool = True,
249
+ enable_text_chunking: bool = True,
250
+ chunk_size_words: int = 3,
251
+ ):
252
+ """Initialize the stream processor.
253
+
254
+ Args:
255
+ message_id: Response ID, generated if not provided
256
+ model: Model name for responses
257
+ created: Creation timestamp, current time if not provided
258
+ enable_usage: Whether to include usage information
259
+ 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
262
+ """
263
+ self.message_id = message_id or generate_openai_response_id()
264
+ self.model = model
265
+ self.created = created or int(time.time())
266
+ self.enable_usage = enable_usage
267
+ self.enable_tool_calls = enable_tool_calls
268
+ self.enable_text_chunking = enable_text_chunking
269
+ self.chunk_size_words = chunk_size_words
270
+ self.formatter = OpenAISSEFormatter()
271
+
272
+ # State tracking
273
+ self.role_sent = False
274
+ self.accumulated_content = ""
275
+ self.tool_calls: dict[str, dict[str, Any]] = {}
276
+ self.usage_info: dict[str, int] | None = None
277
+ # Thinking block tracking
278
+ self.current_thinking_text = ""
279
+ self.current_thinking_signature: str | None = None
280
+ self.thinking_block_active = False
281
+
282
+ async def process_stream(
283
+ self, claude_stream: AsyncIterator[dict[str, Any]]
284
+ ) -> AsyncIterator[str]:
285
+ """Process a Claude/Anthropic stream into OpenAI format.
286
+
287
+ Args:
288
+ claude_stream: Async iterator of Claude response chunks
289
+
290
+ Yields:
291
+ OpenAI-formatted SSE strings
292
+ """
293
+ try:
294
+ async for chunk in claude_stream:
295
+ async for sse_chunk in self._process_chunk(chunk):
296
+ yield sse_chunk
297
+
298
+ # Send final chunk
299
+ 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,
304
+ finish_reason="stop",
305
+ usage=self.usage_info,
306
+ )
307
+ else:
308
+ yield self.formatter.format_final_chunk(
309
+ self.message_id, self.model, self.created, finish_reason="stop"
310
+ )
311
+
312
+ # Send DONE event
313
+ yield self.formatter.format_done()
314
+
315
+ except Exception as e:
316
+ # 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()
321
+
322
+ async def _process_chunk(self, chunk: dict[str, Any]) -> AsyncIterator[str]:
323
+ """Process a single chunk from the Claude stream.
324
+
325
+ Args:
326
+ chunk: Claude response chunk
327
+
328
+ Yields:
329
+ OpenAI-formatted SSE strings
330
+ """
331
+ chunk_type = chunk.get("type")
332
+
333
+ if chunk_type == "message_start":
334
+ # Send initial role chunk
335
+ if not self.role_sent:
336
+ yield self.formatter.format_first_chunk(
337
+ self.message_id, self.model, self.created
338
+ )
339
+ self.role_sent = True
340
+
341
+ elif chunk_type == "content_block_start":
342
+ block = chunk.get("content_block", {})
343
+ if block.get("type") == "thinking":
344
+ # Start of thinking block
345
+ self.thinking_block_active = True
346
+ self.current_thinking_text = ""
347
+ self.current_thinking_signature = None
348
+ elif block.get("type") == "tool_use" and self.enable_tool_calls:
349
+ # Start of tool call
350
+ tool_id = block.get("id", "")
351
+ tool_name = block.get("name", "")
352
+ self.tool_calls[tool_id] = {
353
+ "id": tool_id,
354
+ "name": tool_name,
355
+ "arguments": "",
356
+ }
357
+
358
+ elif chunk_type == "content_block_delta":
359
+ delta = chunk.get("delta", {})
360
+ delta_type = delta.get("type")
361
+
362
+ if delta_type == "text_delta":
363
+ # Text content
364
+ text = delta.get("text", "")
365
+ 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
+ )
380
+
381
+ elif delta_type == "thinking_delta" and self.thinking_block_active:
382
+ # Thinking content
383
+ thinking_text = delta.get("thinking", "")
384
+ if thinking_text:
385
+ self.current_thinking_text += thinking_text
386
+
387
+ elif delta_type == "signature_delta" and self.thinking_block_active:
388
+ # Thinking signature
389
+ signature = delta.get("signature", "")
390
+ if signature:
391
+ if self.current_thinking_signature is None:
392
+ self.current_thinking_signature = ""
393
+ self.current_thinking_signature += signature
394
+
395
+ elif delta_type == "input_json_delta" and self.enable_tool_calls:
396
+ # Tool call arguments
397
+ partial_json = delta.get("partial_json", "")
398
+ if partial_json and self.tool_calls:
399
+ # Find the tool call this belongs to (usually the last one)
400
+ latest_tool_id = list(self.tool_calls.keys())[-1]
401
+ self.tool_calls[latest_tool_id]["arguments"] += partial_json
402
+
403
+ elif chunk_type == "content_block_stop":
404
+ # End of content block
405
+ if self.thinking_block_active:
406
+ # Format and send the complete thinking block
407
+ self.thinking_block_active = False
408
+ if self.current_thinking_text:
409
+ # Format thinking block with signature
410
+ 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
+ )
414
+ # Reset thinking state
415
+ self.current_thinking_text = ""
416
+ self.current_thinking_signature = None
417
+
418
+ elif self.tool_calls and self.enable_tool_calls:
419
+ # Send completed tool calls
420
+ 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"],
428
+ )
429
+
430
+ elif chunk_type == "message_delta":
431
+ # Usage information
432
+ usage = chunk.get("usage", {})
433
+ if usage and self.enable_usage:
434
+ self.usage_info = {
435
+ "prompt_tokens": usage.get("input_tokens", 0),
436
+ "completion_tokens": usage.get("output_tokens", 0),
437
+ "total_tokens": usage.get("input_tokens", 0)
438
+ + usage.get("output_tokens", 0),
439
+ }
440
+
441
+ elif chunk_type == "message_stop":
442
+ # End of message - handled in main process_stream method
443
+ pass
444
+
445
+
446
+ __all__ = [
447
+ "OpenAISSEFormatter",
448
+ "OpenAIStreamProcessor",
449
+ ]
@@ -0,0 +1,28 @@
1
+ """API layer for CCProxy API Server."""
2
+
3
+ from ccproxy.api.app import create_app, get_app
4
+ from ccproxy.api.dependencies import (
5
+ ClaudeServiceDep,
6
+ ObservabilityMetricsDep,
7
+ ProxyServiceDep,
8
+ SettingsDep,
9
+ get_claude_service,
10
+ get_observability_metrics,
11
+ get_proxy_service,
12
+ )
13
+
14
+
15
+ app = create_app()
16
+
17
+ __all__ = [
18
+ "app",
19
+ "create_app",
20
+ "get_app",
21
+ "get_claude_service",
22
+ "get_proxy_service",
23
+ "get_observability_metrics",
24
+ "ClaudeServiceDep",
25
+ "ProxyServiceDep",
26
+ "ObservabilityMetricsDep",
27
+ "SettingsDep",
28
+ ]
ccproxy/api/app.py ADDED
@@ -0,0 +1,225 @@
1
+ """FastAPI application factory for CCProxy API Server."""
2
+
3
+ from collections.abc import AsyncGenerator
4
+ from contextlib import asynccontextmanager
5
+ from typing import Any
6
+
7
+ from fastapi import FastAPI, HTTPException
8
+ from fastapi.responses import JSONResponse
9
+ from fastapi.staticfiles import StaticFiles
10
+ from structlog import get_logger
11
+
12
+ from ccproxy import __version__
13
+ from ccproxy.api.middleware.cors import setup_cors_middleware
14
+ from ccproxy.api.middleware.errors import setup_error_handlers
15
+ from ccproxy.api.middleware.logging import AccessLogMiddleware
16
+ from ccproxy.api.middleware.request_id import RequestIDMiddleware
17
+ from ccproxy.api.middleware.server_header import ServerHeaderMiddleware
18
+ from ccproxy.api.routes.claude import router as claude_router
19
+ from ccproxy.api.routes.health import router as health_router
20
+ from ccproxy.api.routes.metrics import (
21
+ dashboard_router,
22
+ logs_router,
23
+ prometheus_router,
24
+ )
25
+ from ccproxy.api.routes.proxy import router as proxy_router
26
+ from ccproxy.auth.oauth.routes import router as oauth_router
27
+ from ccproxy.config.settings import Settings, get_settings
28
+ from ccproxy.core.logging import setup_logging
29
+ from ccproxy.observability.storage.duckdb_simple import SimpleDuckDBStorage
30
+ from ccproxy.scheduler.manager import start_scheduler, stop_scheduler
31
+
32
+
33
+ logger = get_logger(__name__)
34
+
35
+
36
+ @asynccontextmanager
37
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
38
+ """Application lifespan manager."""
39
+ settings = get_settings()
40
+
41
+ # Startup
42
+ logger.info(
43
+ "server_start",
44
+ host=settings.server.host,
45
+ port=settings.server.port,
46
+ url=f"http://{settings.server.host}:{settings.server.port}",
47
+ )
48
+ logger.debug(
49
+ "server_configured", host=settings.server.host, port=settings.server.port
50
+ )
51
+
52
+ # Log Claude CLI configuration
53
+ if settings.claude.cli_path:
54
+ logger.debug("claude_cli_configured", cli_path=settings.claude.cli_path)
55
+ else:
56
+ logger.debug("claude_cli_auto_detect")
57
+ logger.debug(
58
+ "claude_cli_search_paths", paths=settings.claude.get_searched_paths()
59
+ )
60
+
61
+ # Start scheduler system
62
+ try:
63
+ scheduler = await start_scheduler(settings)
64
+ app.state.scheduler = scheduler
65
+ logger.debug("scheduler_initialized")
66
+ except Exception as e:
67
+ logger.error("scheduler_initialization_failed", error=str(e))
68
+ # Continue startup even if scheduler fails (graceful degradation)
69
+
70
+ # Initialize log storage if needed and backend is duckdb
71
+ if (
72
+ settings.observability.needs_storage_backend
73
+ and settings.observability.log_storage_backend == "duckdb"
74
+ ):
75
+ try:
76
+ storage = SimpleDuckDBStorage(
77
+ database_path=settings.observability.duckdb_path
78
+ )
79
+ await storage.initialize()
80
+ app.state.log_storage = storage
81
+ logger.debug(
82
+ "log_storage_initialized",
83
+ backend="duckdb",
84
+ path=str(settings.observability.duckdb_path),
85
+ collection_enabled=settings.observability.logs_collection_enabled,
86
+ )
87
+ except Exception as e:
88
+ logger.error("log_storage_initialization_failed", error=str(e))
89
+ # Continue without log storage (graceful degradation)
90
+
91
+ yield
92
+
93
+ # Shutdown
94
+ logger.debug("server_stop")
95
+
96
+ # Stop scheduler system
97
+ try:
98
+ scheduler = getattr(app.state, "scheduler", None)
99
+ await stop_scheduler(scheduler)
100
+ logger.debug("scheduler_stopped")
101
+ except Exception as e:
102
+ logger.error("scheduler_stop_failed", error=str(e))
103
+
104
+ # Close log storage if initialized
105
+ if hasattr(app.state, "log_storage") and app.state.log_storage:
106
+ try:
107
+ await app.state.log_storage.close()
108
+ logger.debug("log_storage_closed")
109
+ except Exception as e:
110
+ logger.error("log_storage_close_failed", error=str(e))
111
+
112
+
113
+ def create_app(settings: Settings | None = None) -> FastAPI:
114
+ """Create and configure the FastAPI application.
115
+
116
+ Args:
117
+ settings: Optional settings override. If None, uses get_settings().
118
+
119
+ Returns:
120
+ Configured FastAPI application instance.
121
+ """
122
+ if settings is None:
123
+ settings = get_settings()
124
+
125
+ # Configure logging based on settings BEFORE any module uses logger
126
+ # This is needed for reload mode where the app is re-imported
127
+ import logging
128
+
129
+ import structlog
130
+
131
+ from ccproxy.config.settings import config_manager
132
+
133
+ # Only configure if not already configured or if no file handler exists
134
+ root_logger = logging.getLogger()
135
+ has_file_handler = any(
136
+ isinstance(h, logging.FileHandler) for h in root_logger.handlers
137
+ )
138
+
139
+ if not structlog.is_configured() or not has_file_handler:
140
+ # Only setup logging if not already configured with file handler
141
+ # Always use console output
142
+ json_logs = False
143
+ # Don't override file logging if it was already configured
144
+ if not has_file_handler:
145
+ setup_logging(json_logs=json_logs, log_level=settings.server.log_level)
146
+
147
+ app = FastAPI(
148
+ title="CCProxy API Server",
149
+ description="High-performance API server providing Anthropic and OpenAI-compatible interfaces for Claude AI models",
150
+ version=__version__,
151
+ lifespan=lifespan,
152
+ )
153
+
154
+ # Setup middleware
155
+ setup_cors_middleware(app, settings)
156
+ setup_error_handlers(app)
157
+
158
+ # Add custom access log middleware first (will run second due to middleware order)
159
+ app.add_middleware(AccessLogMiddleware)
160
+
161
+ # Add request ID middleware second (will run first to initialize context)
162
+ app.add_middleware(RequestIDMiddleware)
163
+
164
+ # Add server header middleware (for non-proxy routes)
165
+ # You can customize the server name here
166
+ app.add_middleware(ServerHeaderMiddleware, server_name="uvicorn")
167
+
168
+ # Include health router (always enabled)
169
+ app.include_router(health_router, tags=["health"])
170
+
171
+ # Include observability routers with granular controls
172
+ if settings.observability.metrics_endpoint_enabled:
173
+ app.include_router(prometheus_router, tags=["metrics"])
174
+
175
+ if settings.observability.logs_endpoints_enabled:
176
+ app.include_router(logs_router, tags=["logs"])
177
+
178
+ if settings.observability.dashboard_enabled:
179
+ app.include_router(dashboard_router, tags=["dashboard"])
180
+
181
+ app.include_router(oauth_router, prefix="/oauth", tags=["oauth"])
182
+
183
+ # New /sdk/ routes for Claude SDK endpoints
184
+ app.include_router(claude_router, prefix="/sdk", tags=["claude-sdk"])
185
+
186
+ # New /api/ routes for proxy endpoints (includes OpenAI-compatible /v1/chat/completions)
187
+ app.include_router(proxy_router, prefix="/api", tags=["proxy-api"])
188
+
189
+ # Mount static files for dashboard SPA
190
+ from pathlib import Path
191
+
192
+ # Get the path to the dashboard static files
193
+ current_file = Path(__file__)
194
+ project_root = (
195
+ current_file.parent.parent.parent
196
+ ) # ccproxy/api/app.py -> project root
197
+ dashboard_static_path = project_root / "ccproxy" / "static" / "dashboard"
198
+
199
+ # Mount dashboard static files if they exist
200
+ if dashboard_static_path.exists():
201
+ # Mount the _app directory for SvelteKit assets at the correct base path
202
+ app_path = dashboard_static_path / "_app"
203
+ if app_path.exists():
204
+ app.mount(
205
+ "/dashboard/_app",
206
+ StaticFiles(directory=str(app_path)),
207
+ name="dashboard-assets",
208
+ )
209
+
210
+ # Mount favicon.svg at root level
211
+ favicon_path = dashboard_static_path / "favicon.svg"
212
+ if favicon_path.exists():
213
+ # For single files, we'll handle this in the dashboard route or add a specific route
214
+ pass
215
+
216
+ return app
217
+
218
+
219
+ def get_app() -> FastAPI:
220
+ """Get the FastAPI application instance.
221
+
222
+ Returns:
223
+ FastAPI application instance.
224
+ """
225
+ return create_app()