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.
- ccproxy/__init__.py +4 -0
- ccproxy/__main__.py +7 -0
- ccproxy/_version.py +21 -0
- ccproxy/adapters/__init__.py +11 -0
- ccproxy/adapters/base.py +80 -0
- ccproxy/adapters/openai/__init__.py +43 -0
- ccproxy/adapters/openai/adapter.py +915 -0
- ccproxy/adapters/openai/models.py +412 -0
- ccproxy/adapters/openai/streaming.py +449 -0
- ccproxy/api/__init__.py +28 -0
- ccproxy/api/app.py +225 -0
- ccproxy/api/dependencies.py +140 -0
- ccproxy/api/middleware/__init__.py +11 -0
- ccproxy/api/middleware/auth.py +0 -0
- ccproxy/api/middleware/cors.py +55 -0
- ccproxy/api/middleware/errors.py +703 -0
- ccproxy/api/middleware/headers.py +51 -0
- ccproxy/api/middleware/logging.py +175 -0
- ccproxy/api/middleware/request_id.py +69 -0
- ccproxy/api/middleware/server_header.py +62 -0
- ccproxy/api/responses.py +84 -0
- ccproxy/api/routes/__init__.py +16 -0
- ccproxy/api/routes/claude.py +181 -0
- ccproxy/api/routes/health.py +489 -0
- ccproxy/api/routes/metrics.py +1033 -0
- ccproxy/api/routes/proxy.py +238 -0
- ccproxy/auth/__init__.py +75 -0
- ccproxy/auth/bearer.py +68 -0
- ccproxy/auth/credentials_adapter.py +93 -0
- ccproxy/auth/dependencies.py +229 -0
- ccproxy/auth/exceptions.py +79 -0
- ccproxy/auth/manager.py +102 -0
- ccproxy/auth/models.py +118 -0
- ccproxy/auth/oauth/__init__.py +26 -0
- ccproxy/auth/oauth/models.py +49 -0
- ccproxy/auth/oauth/routes.py +396 -0
- ccproxy/auth/oauth/storage.py +0 -0
- ccproxy/auth/storage/__init__.py +12 -0
- ccproxy/auth/storage/base.py +57 -0
- ccproxy/auth/storage/json_file.py +159 -0
- ccproxy/auth/storage/keyring.py +192 -0
- ccproxy/claude_sdk/__init__.py +20 -0
- ccproxy/claude_sdk/client.py +169 -0
- ccproxy/claude_sdk/converter.py +331 -0
- ccproxy/claude_sdk/options.py +120 -0
- ccproxy/cli/__init__.py +14 -0
- ccproxy/cli/commands/__init__.py +8 -0
- ccproxy/cli/commands/auth.py +553 -0
- ccproxy/cli/commands/config/__init__.py +14 -0
- ccproxy/cli/commands/config/commands.py +766 -0
- ccproxy/cli/commands/config/schema_commands.py +119 -0
- ccproxy/cli/commands/serve.py +630 -0
- ccproxy/cli/docker/__init__.py +34 -0
- ccproxy/cli/docker/adapter_factory.py +157 -0
- ccproxy/cli/docker/params.py +278 -0
- ccproxy/cli/helpers.py +144 -0
- ccproxy/cli/main.py +193 -0
- ccproxy/cli/options/__init__.py +14 -0
- ccproxy/cli/options/claude_options.py +216 -0
- ccproxy/cli/options/core_options.py +40 -0
- ccproxy/cli/options/security_options.py +48 -0
- ccproxy/cli/options/server_options.py +117 -0
- ccproxy/config/__init__.py +40 -0
- ccproxy/config/auth.py +154 -0
- ccproxy/config/claude.py +124 -0
- ccproxy/config/cors.py +79 -0
- ccproxy/config/discovery.py +87 -0
- ccproxy/config/docker_settings.py +265 -0
- ccproxy/config/loader.py +108 -0
- ccproxy/config/observability.py +158 -0
- ccproxy/config/pricing.py +88 -0
- ccproxy/config/reverse_proxy.py +31 -0
- ccproxy/config/scheduler.py +89 -0
- ccproxy/config/security.py +14 -0
- ccproxy/config/server.py +81 -0
- ccproxy/config/settings.py +534 -0
- ccproxy/config/validators.py +231 -0
- ccproxy/core/__init__.py +274 -0
- ccproxy/core/async_utils.py +675 -0
- ccproxy/core/constants.py +97 -0
- ccproxy/core/errors.py +256 -0
- ccproxy/core/http.py +328 -0
- ccproxy/core/http_transformers.py +428 -0
- ccproxy/core/interfaces.py +247 -0
- ccproxy/core/logging.py +189 -0
- ccproxy/core/middleware.py +114 -0
- ccproxy/core/proxy.py +143 -0
- ccproxy/core/system.py +38 -0
- ccproxy/core/transformers.py +259 -0
- ccproxy/core/types.py +129 -0
- ccproxy/core/validators.py +288 -0
- ccproxy/docker/__init__.py +67 -0
- ccproxy/docker/adapter.py +588 -0
- ccproxy/docker/docker_path.py +207 -0
- ccproxy/docker/middleware.py +103 -0
- ccproxy/docker/models.py +228 -0
- ccproxy/docker/protocol.py +192 -0
- ccproxy/docker/stream_process.py +264 -0
- ccproxy/docker/validators.py +173 -0
- ccproxy/models/__init__.py +123 -0
- ccproxy/models/errors.py +42 -0
- ccproxy/models/messages.py +243 -0
- ccproxy/models/requests.py +85 -0
- ccproxy/models/responses.py +227 -0
- ccproxy/models/types.py +102 -0
- ccproxy/observability/__init__.py +51 -0
- ccproxy/observability/access_logger.py +400 -0
- ccproxy/observability/context.py +447 -0
- ccproxy/observability/metrics.py +539 -0
- ccproxy/observability/pushgateway.py +366 -0
- ccproxy/observability/sse_events.py +303 -0
- ccproxy/observability/stats_printer.py +755 -0
- ccproxy/observability/storage/__init__.py +1 -0
- ccproxy/observability/storage/duckdb_simple.py +665 -0
- ccproxy/observability/storage/models.py +55 -0
- ccproxy/pricing/__init__.py +19 -0
- ccproxy/pricing/cache.py +212 -0
- ccproxy/pricing/loader.py +267 -0
- ccproxy/pricing/models.py +106 -0
- ccproxy/pricing/updater.py +309 -0
- ccproxy/scheduler/__init__.py +39 -0
- ccproxy/scheduler/core.py +335 -0
- ccproxy/scheduler/exceptions.py +34 -0
- ccproxy/scheduler/manager.py +186 -0
- ccproxy/scheduler/registry.py +150 -0
- ccproxy/scheduler/tasks.py +484 -0
- ccproxy/services/__init__.py +10 -0
- ccproxy/services/claude_sdk_service.py +614 -0
- ccproxy/services/credentials/__init__.py +55 -0
- ccproxy/services/credentials/config.py +105 -0
- ccproxy/services/credentials/manager.py +562 -0
- ccproxy/services/credentials/oauth_client.py +482 -0
- ccproxy/services/proxy_service.py +1536 -0
- ccproxy/static/.keep +0 -0
- ccproxy/testing/__init__.py +34 -0
- ccproxy/testing/config.py +148 -0
- ccproxy/testing/content_generation.py +197 -0
- ccproxy/testing/mock_responses.py +262 -0
- ccproxy/testing/response_handlers.py +161 -0
- ccproxy/testing/scenarios.py +241 -0
- ccproxy/utils/__init__.py +6 -0
- ccproxy/utils/cost_calculator.py +210 -0
- ccproxy/utils/streaming_metrics.py +199 -0
- ccproxy_api-0.1.0.dist-info/METADATA +253 -0
- ccproxy_api-0.1.0.dist-info/RECORD +148 -0
- ccproxy_api-0.1.0.dist-info/WHEEL +4 -0
- ccproxy_api-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|
ccproxy/api/__init__.py
ADDED
|
@@ -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()
|