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.
- ccproxy/_version.py +2 -2
- ccproxy/adapters/openai/__init__.py +1 -2
- ccproxy/adapters/openai/adapter.py +218 -180
- ccproxy/adapters/openai/streaming.py +247 -65
- ccproxy/api/__init__.py +0 -3
- ccproxy/api/app.py +173 -40
- ccproxy/api/dependencies.py +65 -3
- ccproxy/api/middleware/errors.py +3 -7
- ccproxy/api/middleware/headers.py +0 -2
- ccproxy/api/middleware/logging.py +4 -3
- ccproxy/api/middleware/request_content_logging.py +297 -0
- ccproxy/api/middleware/request_id.py +5 -0
- ccproxy/api/middleware/server_header.py +0 -4
- ccproxy/api/routes/__init__.py +9 -1
- ccproxy/api/routes/claude.py +23 -32
- ccproxy/api/routes/health.py +58 -4
- ccproxy/api/routes/mcp.py +171 -0
- ccproxy/api/routes/metrics.py +4 -8
- ccproxy/api/routes/permissions.py +217 -0
- ccproxy/api/routes/proxy.py +0 -53
- ccproxy/api/services/__init__.py +6 -0
- ccproxy/api/services/permission_service.py +368 -0
- ccproxy/api/ui/__init__.py +6 -0
- ccproxy/api/ui/permission_handler_protocol.py +33 -0
- ccproxy/api/ui/terminal_permission_handler.py +593 -0
- ccproxy/auth/conditional.py +2 -2
- ccproxy/auth/dependencies.py +1 -1
- ccproxy/auth/oauth/models.py +0 -1
- ccproxy/auth/oauth/routes.py +1 -3
- ccproxy/auth/storage/json_file.py +0 -1
- ccproxy/auth/storage/keyring.py +0 -3
- ccproxy/claude_sdk/__init__.py +2 -0
- ccproxy/claude_sdk/client.py +91 -8
- ccproxy/claude_sdk/converter.py +405 -210
- ccproxy/claude_sdk/options.py +88 -19
- ccproxy/claude_sdk/parser.py +200 -0
- ccproxy/claude_sdk/streaming.py +286 -0
- ccproxy/cli/commands/__init__.py +5 -1
- ccproxy/cli/commands/auth.py +2 -4
- ccproxy/cli/commands/permission_handler.py +553 -0
- ccproxy/cli/commands/serve.py +52 -12
- ccproxy/cli/docker/params.py +0 -4
- ccproxy/cli/helpers.py +0 -2
- ccproxy/cli/main.py +6 -17
- ccproxy/cli/options/claude_options.py +41 -1
- ccproxy/cli/options/core_options.py +0 -3
- ccproxy/cli/options/security_options.py +0 -2
- ccproxy/cli/options/server_options.py +3 -2
- ccproxy/config/auth.py +0 -1
- ccproxy/config/claude.py +78 -2
- ccproxy/config/discovery.py +0 -1
- ccproxy/config/docker_settings.py +0 -1
- ccproxy/config/loader.py +1 -4
- ccproxy/config/scheduler.py +20 -0
- ccproxy/config/security.py +7 -2
- ccproxy/config/server.py +5 -0
- ccproxy/config/settings.py +15 -7
- ccproxy/config/validators.py +1 -1
- ccproxy/core/async_utils.py +1 -4
- ccproxy/core/errors.py +45 -1
- ccproxy/core/http_transformers.py +4 -3
- ccproxy/core/interfaces.py +2 -2
- ccproxy/core/logging.py +97 -95
- ccproxy/core/middleware.py +1 -1
- ccproxy/core/proxy.py +1 -1
- ccproxy/core/transformers.py +1 -1
- ccproxy/core/types.py +1 -1
- ccproxy/docker/models.py +1 -1
- ccproxy/docker/protocol.py +0 -3
- ccproxy/models/__init__.py +41 -0
- ccproxy/models/claude_sdk.py +420 -0
- ccproxy/models/messages.py +45 -18
- ccproxy/models/permissions.py +115 -0
- ccproxy/models/requests.py +1 -1
- ccproxy/models/responses.py +64 -1
- ccproxy/observability/access_logger.py +1 -2
- ccproxy/observability/context.py +17 -1
- ccproxy/observability/metrics.py +1 -3
- ccproxy/observability/pushgateway.py +0 -2
- ccproxy/observability/stats_printer.py +2 -4
- ccproxy/observability/storage/duckdb_simple.py +1 -1
- ccproxy/observability/storage/models.py +0 -1
- ccproxy/pricing/cache.py +0 -1
- ccproxy/pricing/loader.py +5 -21
- ccproxy/pricing/updater.py +0 -1
- ccproxy/scheduler/__init__.py +1 -0
- ccproxy/scheduler/core.py +6 -6
- ccproxy/scheduler/manager.py +35 -7
- ccproxy/scheduler/registry.py +1 -1
- ccproxy/scheduler/tasks.py +127 -2
- ccproxy/services/claude_sdk_service.py +225 -329
- ccproxy/services/credentials/manager.py +0 -1
- ccproxy/services/credentials/oauth_client.py +1 -2
- ccproxy/services/proxy_service.py +93 -222
- ccproxy/testing/config.py +1 -1
- ccproxy/testing/mock_responses.py +0 -1
- ccproxy/utils/model_mapping.py +197 -0
- ccproxy/utils/models_provider.py +150 -0
- ccproxy/utils/simple_request_logger.py +284 -0
- ccproxy/utils/version_checker.py +184 -0
- {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/METADATA +63 -2
- ccproxy_api-0.1.3.dist-info/RECORD +166 -0
- {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/entry_points.txt +1 -0
- ccproxy_api-0.1.1.dist-info/RECORD +0 -149
- /ccproxy/scheduler/{exceptions.py → errors.py} +0 -0
- {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/WHEEL +0 -0
- {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/licenses/LICENSE +0 -0
ccproxy/_version.py
CHANGED
|
@@ -4,7 +4,7 @@ This module provides the OpenAI adapter implementation for converting
|
|
|
4
4
|
between OpenAI and Anthropic API formats.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from .adapter import OpenAIAdapter
|
|
7
|
+
from .adapter import OpenAIAdapter
|
|
8
8
|
from .models import (
|
|
9
9
|
OpenAIChatCompletionResponse,
|
|
10
10
|
OpenAIChoice,
|
|
@@ -24,7 +24,6 @@ from .streaming import OpenAISSEFormatter, OpenAIStreamProcessor
|
|
|
24
24
|
__all__ = [
|
|
25
25
|
# Adapter
|
|
26
26
|
"OpenAIAdapter",
|
|
27
|
-
"map_openai_model_to_claude",
|
|
28
27
|
# Models
|
|
29
28
|
"OpenAIMessage",
|
|
30
29
|
"OpenAIMessageContent",
|
|
@@ -9,14 +9,14 @@ from __future__ import annotations
|
|
|
9
9
|
import json
|
|
10
10
|
import re
|
|
11
11
|
import time
|
|
12
|
-
import uuid
|
|
13
12
|
from collections.abc import AsyncIterator
|
|
14
|
-
from inspect import signature
|
|
15
13
|
from typing import Any, Literal, cast
|
|
16
14
|
|
|
17
15
|
import structlog
|
|
16
|
+
from pydantic import ValidationError
|
|
18
17
|
|
|
19
18
|
from ccproxy.core.interfaces import APIAdapter
|
|
19
|
+
from ccproxy.utils.model_mapping import map_model_to_claude
|
|
20
20
|
|
|
21
21
|
from .models import (
|
|
22
22
|
OpenAIChatCompletionRequest,
|
|
@@ -34,81 +34,12 @@ from .streaming import OpenAIStreamProcessor
|
|
|
34
34
|
logger = structlog.get_logger(__name__)
|
|
35
35
|
|
|
36
36
|
|
|
37
|
-
# Model mapping from OpenAI to Claude
|
|
38
|
-
OPENAI_TO_CLAUDE_MODEL_MAPPING: dict[str, str] = {
|
|
39
|
-
# GPT-4 models -> Claude 3.5 Sonnet (most comparable)
|
|
40
|
-
"gpt-4": "claude-3-5-sonnet-20241022",
|
|
41
|
-
"gpt-4-turbo": "claude-3-5-sonnet-20241022",
|
|
42
|
-
"gpt-4-turbo-preview": "claude-3-5-sonnet-20241022",
|
|
43
|
-
"gpt-4-1106-preview": "claude-3-5-sonnet-20241022",
|
|
44
|
-
"gpt-4-0125-preview": "claude-3-5-sonnet-20241022",
|
|
45
|
-
"gpt-4-turbo-2024-04-09": "claude-3-5-sonnet-20241022",
|
|
46
|
-
"gpt-4o": "claude-3-7-sonnet-20250219",
|
|
47
|
-
"gpt-4o-2024-05-13": "claude-3-7-sonnet-20250219",
|
|
48
|
-
"gpt-4o-2024-08-06": "claude-3-7-sonnet-20250219",
|
|
49
|
-
"gpt-4o-2024-11-20": "claude-3-7-sonnet-20250219",
|
|
50
|
-
"gpt-4o-mini": "claude-3-5-haiku-latest",
|
|
51
|
-
"gpt-4o-mini-2024-07-18": "claude-3-5-haiku-latest",
|
|
52
|
-
# o1 models -> Claude models that support thinking
|
|
53
|
-
"o1": "claude-opus-4-20250514",
|
|
54
|
-
"o1-preview": "claude-opus-4-20250514",
|
|
55
|
-
"o1-mini": "claude-sonnet-4-20250514",
|
|
56
|
-
# o3 models -> Claude Opus 4
|
|
57
|
-
"o3-mini": "claude-opus-4-20250514",
|
|
58
|
-
# GPT-3.5 models -> Claude 3.5 Haiku (faster, cheaper)
|
|
59
|
-
"gpt-3.5-turbo": "claude-3-5-haiku-20241022",
|
|
60
|
-
"gpt-3.5-turbo-16k": "claude-3-5-haiku-20241022",
|
|
61
|
-
"gpt-3.5-turbo-1106": "claude-3-5-haiku-20241022",
|
|
62
|
-
"gpt-3.5-turbo-0125": "claude-3-5-haiku-20241022",
|
|
63
|
-
# Generic fallback
|
|
64
|
-
"text-davinci-003": "claude-3-5-sonnet-20241022",
|
|
65
|
-
"text-davinci-002": "claude-3-5-sonnet-20241022",
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def map_openai_model_to_claude(openai_model: str) -> str:
|
|
70
|
-
"""Map OpenAI model name to Claude model name.
|
|
71
|
-
|
|
72
|
-
Args:
|
|
73
|
-
openai_model: OpenAI model identifier
|
|
74
|
-
|
|
75
|
-
Returns:
|
|
76
|
-
Claude model identifier
|
|
77
|
-
"""
|
|
78
|
-
# Direct mapping first
|
|
79
|
-
claude_model = OPENAI_TO_CLAUDE_MODEL_MAPPING.get(openai_model)
|
|
80
|
-
if claude_model:
|
|
81
|
-
return claude_model
|
|
82
|
-
|
|
83
|
-
# Pattern matching for versioned models
|
|
84
|
-
if openai_model.startswith("gpt-4o-mini"):
|
|
85
|
-
return "claude-3-5-haiku-latest"
|
|
86
|
-
elif openai_model.startswith("gpt-4o") or openai_model.startswith("gpt-4"):
|
|
87
|
-
return "claude-3-7-sonnet-20250219"
|
|
88
|
-
elif openai_model.startswith("gpt-3.5"):
|
|
89
|
-
return "claude-3-5-haiku-latest"
|
|
90
|
-
elif openai_model.startswith("o1"):
|
|
91
|
-
return "claude-sonnet-4-20250514"
|
|
92
|
-
elif openai_model.startswith("o3"):
|
|
93
|
-
return "claude-opus-4-20250514"
|
|
94
|
-
elif openai_model.startswith("gpt"):
|
|
95
|
-
return "claude-sonnet-4-20250514"
|
|
96
|
-
|
|
97
|
-
# If it's already a Claude model, pass through unchanged
|
|
98
|
-
if openai_model.startswith("claude-"):
|
|
99
|
-
return openai_model
|
|
100
|
-
|
|
101
|
-
# For unknown models, pass through unchanged we may change
|
|
102
|
-
# this to a default model in the future
|
|
103
|
-
return openai_model
|
|
104
|
-
|
|
105
|
-
|
|
106
37
|
class OpenAIAdapter(APIAdapter):
|
|
107
38
|
"""OpenAI API adapter for converting between OpenAI and Anthropic formats."""
|
|
108
39
|
|
|
109
|
-
def __init__(self) -> None:
|
|
40
|
+
def __init__(self, include_sdk_content_as_xml: bool = False) -> None:
|
|
110
41
|
"""Initialize the OpenAI adapter."""
|
|
111
|
-
|
|
42
|
+
self.include_sdk_content_as_xml = include_sdk_content_as_xml
|
|
112
43
|
|
|
113
44
|
def adapt_request(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
114
45
|
"""Convert OpenAI request format to Anthropic format.
|
|
@@ -125,18 +56,18 @@ class OpenAIAdapter(APIAdapter):
|
|
|
125
56
|
try:
|
|
126
57
|
# Parse OpenAI request
|
|
127
58
|
openai_req = OpenAIChatCompletionRequest(**request)
|
|
128
|
-
except
|
|
59
|
+
except ValidationError as e:
|
|
129
60
|
raise ValueError(f"Invalid OpenAI request format: {e}") from e
|
|
130
61
|
|
|
131
62
|
# Map OpenAI model to Claude model
|
|
132
|
-
model =
|
|
63
|
+
model = map_model_to_claude(openai_req.model)
|
|
133
64
|
|
|
134
65
|
# Convert messages
|
|
135
66
|
messages, system_prompt = self._convert_messages_to_anthropic(
|
|
136
67
|
openai_req.messages
|
|
137
68
|
)
|
|
138
69
|
|
|
139
|
-
# Build Anthropic request
|
|
70
|
+
# Build base Anthropic request
|
|
140
71
|
anthropic_request = {
|
|
141
72
|
"model": model,
|
|
142
73
|
"messages": messages,
|
|
@@ -148,6 +79,44 @@ class OpenAIAdapter(APIAdapter):
|
|
|
148
79
|
anthropic_request["system"] = system_prompt
|
|
149
80
|
|
|
150
81
|
# Add optional parameters
|
|
82
|
+
self._handle_optional_parameters(openai_req, anthropic_request)
|
|
83
|
+
|
|
84
|
+
# Handle metadata
|
|
85
|
+
self._handle_metadata(openai_req, anthropic_request)
|
|
86
|
+
|
|
87
|
+
# Handle response format
|
|
88
|
+
anthropic_request = self._handle_response_format(openai_req, anthropic_request)
|
|
89
|
+
|
|
90
|
+
# Handle thinking configuration
|
|
91
|
+
anthropic_request = self._handle_thinking_parameters(
|
|
92
|
+
openai_req, anthropic_request
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Log unsupported parameters
|
|
96
|
+
self._log_unsupported_parameters(openai_req)
|
|
97
|
+
|
|
98
|
+
# Handle tools and tool choice
|
|
99
|
+
self._handle_tools(openai_req, anthropic_request)
|
|
100
|
+
|
|
101
|
+
logger.debug(
|
|
102
|
+
"format_conversion_completed",
|
|
103
|
+
from_format="openai",
|
|
104
|
+
to_format="anthropic",
|
|
105
|
+
original_model=openai_req.model,
|
|
106
|
+
anthropic_model=anthropic_request.get("model"),
|
|
107
|
+
has_tools=bool(anthropic_request.get("tools")),
|
|
108
|
+
has_system=bool(anthropic_request.get("system")),
|
|
109
|
+
message_count=len(cast(list[Any], anthropic_request["messages"])),
|
|
110
|
+
operation="adapt_request",
|
|
111
|
+
)
|
|
112
|
+
return anthropic_request
|
|
113
|
+
|
|
114
|
+
def _handle_optional_parameters(
|
|
115
|
+
self,
|
|
116
|
+
openai_req: OpenAIChatCompletionRequest,
|
|
117
|
+
anthropic_request: dict[str, Any],
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Handle optional parameters like temperature, top_p, stream, and stop."""
|
|
151
120
|
if openai_req.temperature is not None:
|
|
152
121
|
anthropic_request["temperature"] = openai_req.temperature
|
|
153
122
|
|
|
@@ -163,7 +132,12 @@ class OpenAIAdapter(APIAdapter):
|
|
|
163
132
|
else:
|
|
164
133
|
anthropic_request["stop_sequences"] = openai_req.stop
|
|
165
134
|
|
|
166
|
-
|
|
135
|
+
def _handle_metadata(
|
|
136
|
+
self,
|
|
137
|
+
openai_req: OpenAIChatCompletionRequest,
|
|
138
|
+
anthropic_request: dict[str, Any],
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Handle metadata and user field combination."""
|
|
167
141
|
metadata = {}
|
|
168
142
|
if openai_req.user:
|
|
169
143
|
metadata["user_id"] = openai_req.user
|
|
@@ -172,11 +146,17 @@ class OpenAIAdapter(APIAdapter):
|
|
|
172
146
|
if metadata:
|
|
173
147
|
anthropic_request["metadata"] = metadata
|
|
174
148
|
|
|
175
|
-
|
|
149
|
+
def _handle_response_format(
|
|
150
|
+
self,
|
|
151
|
+
openai_req: OpenAIChatCompletionRequest,
|
|
152
|
+
anthropic_request: dict[str, Any],
|
|
153
|
+
) -> dict[str, Any]:
|
|
154
|
+
"""Handle response format by modifying system prompt for JSON mode."""
|
|
176
155
|
if openai_req.response_format:
|
|
177
156
|
format_type = (
|
|
178
157
|
openai_req.response_format.type if openai_req.response_format else None
|
|
179
158
|
)
|
|
159
|
+
system_prompt = anthropic_request.get("system")
|
|
180
160
|
|
|
181
161
|
if format_type == "json_object" and system_prompt is not None:
|
|
182
162
|
system_prompt += "\nYou must respond with valid JSON only."
|
|
@@ -189,7 +169,14 @@ class OpenAIAdapter(APIAdapter):
|
|
|
189
169
|
system_prompt += f"\nYou must respond with valid JSON that conforms to this schema: {openai_req.response_format.json_schema}"
|
|
190
170
|
anthropic_request["system"] = system_prompt
|
|
191
171
|
|
|
192
|
-
|
|
172
|
+
return anthropic_request
|
|
173
|
+
|
|
174
|
+
def _handle_thinking_parameters(
|
|
175
|
+
self,
|
|
176
|
+
openai_req: OpenAIChatCompletionRequest,
|
|
177
|
+
anthropic_request: dict[str, Any],
|
|
178
|
+
) -> dict[str, Any]:
|
|
179
|
+
"""Handle reasoning_effort and thinking configuration for o1/o3 models."""
|
|
193
180
|
# Automatically enable thinking for o1 models even without explicit reasoning_effort
|
|
194
181
|
if (
|
|
195
182
|
openai_req.reasoning_effort
|
|
@@ -263,7 +250,12 @@ class OpenAIAdapter(APIAdapter):
|
|
|
263
250
|
operation="adapt_request",
|
|
264
251
|
)
|
|
265
252
|
|
|
266
|
-
|
|
253
|
+
return anthropic_request
|
|
254
|
+
|
|
255
|
+
def _log_unsupported_parameters(
|
|
256
|
+
self, openai_req: OpenAIChatCompletionRequest
|
|
257
|
+
) -> None:
|
|
258
|
+
"""Log warnings for unsupported OpenAI parameters."""
|
|
267
259
|
if openai_req.seed is not None:
|
|
268
260
|
logger.debug(
|
|
269
261
|
"unsupported_parameter_ignored",
|
|
@@ -287,6 +279,12 @@ class OpenAIAdapter(APIAdapter):
|
|
|
287
279
|
operation="adapt_request",
|
|
288
280
|
)
|
|
289
281
|
|
|
282
|
+
def _handle_tools(
|
|
283
|
+
self,
|
|
284
|
+
openai_req: OpenAIChatCompletionRequest,
|
|
285
|
+
anthropic_request: dict[str, Any],
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Handle tools, functions, and tool choice conversion."""
|
|
290
288
|
# Handle tools/functions
|
|
291
289
|
if openai_req.tools:
|
|
292
290
|
anthropic_request["tools"] = self._convert_tools_to_anthropic(
|
|
@@ -298,6 +296,7 @@ class OpenAIAdapter(APIAdapter):
|
|
|
298
296
|
openai_req.functions
|
|
299
297
|
)
|
|
300
298
|
|
|
299
|
+
# Handle tool choice
|
|
301
300
|
if openai_req.tool_choice:
|
|
302
301
|
# Convert tool choice - can be string or OpenAIToolChoice object
|
|
303
302
|
if isinstance(openai_req.tool_choice, str):
|
|
@@ -319,19 +318,6 @@ class OpenAIAdapter(APIAdapter):
|
|
|
319
318
|
openai_req.function_call
|
|
320
319
|
)
|
|
321
320
|
|
|
322
|
-
logger.debug(
|
|
323
|
-
"format_conversion_completed",
|
|
324
|
-
from_format="openai",
|
|
325
|
-
to_format="anthropic",
|
|
326
|
-
original_model=openai_req.model,
|
|
327
|
-
anthropic_model=anthropic_request.get("model"),
|
|
328
|
-
has_tools=bool(anthropic_request.get("tools")),
|
|
329
|
-
has_system=bool(anthropic_request.get("system")),
|
|
330
|
-
message_count=len(cast(list[Any], anthropic_request["messages"])),
|
|
331
|
-
operation="adapt_request",
|
|
332
|
-
)
|
|
333
|
-
return anthropic_request
|
|
334
|
-
|
|
335
321
|
def adapt_response(self, response: dict[str, Any]) -> dict[str, Any]:
|
|
336
322
|
"""Convert Anthropic response format to OpenAI format.
|
|
337
323
|
|
|
@@ -351,74 +337,19 @@ class OpenAIAdapter(APIAdapter):
|
|
|
351
337
|
# Generate response ID
|
|
352
338
|
request_id = generate_openai_response_id()
|
|
353
339
|
|
|
354
|
-
# Convert content
|
|
355
|
-
content =
|
|
356
|
-
tool_calls = []
|
|
357
|
-
|
|
358
|
-
if "content" in response and response["content"]:
|
|
359
|
-
for block in response["content"]:
|
|
360
|
-
if block.get("type") == "text":
|
|
361
|
-
content += block.get("text", "")
|
|
362
|
-
elif block.get("type") == "thinking":
|
|
363
|
-
# Handle thinking blocks - we can include them with a marker
|
|
364
|
-
thinking_text = block.get("thinking", "")
|
|
365
|
-
signature = block.get("signature")
|
|
366
|
-
if thinking_text:
|
|
367
|
-
content += f'<thinking signature="{signature}">{thinking_text}</thinking>'
|
|
368
|
-
elif block.get("type") == "tool_use":
|
|
369
|
-
tool_calls.append(format_openai_tool_call(block))
|
|
370
|
-
else:
|
|
371
|
-
logger.warning(
|
|
372
|
-
"unsupported_content_block_type", type=block.get("type")
|
|
373
|
-
)
|
|
340
|
+
# Convert content and extract tool calls
|
|
341
|
+
content, tool_calls = self._convert_content_blocks(response)
|
|
374
342
|
|
|
375
343
|
# Create OpenAI message
|
|
376
|
-
|
|
377
|
-
# Otherwise, if content is empty string, convert to None
|
|
378
|
-
final_content: str | None = content
|
|
379
|
-
if tool_calls and not content:
|
|
380
|
-
final_content = ""
|
|
381
|
-
elif content == "":
|
|
382
|
-
final_content = None
|
|
383
|
-
|
|
384
|
-
message = OpenAIResponseMessage(
|
|
385
|
-
role="assistant",
|
|
386
|
-
content=final_content,
|
|
387
|
-
tool_calls=tool_calls if tool_calls else None,
|
|
388
|
-
)
|
|
389
|
-
|
|
390
|
-
# Map stop reason
|
|
391
|
-
finish_reason = self._convert_stop_reason_to_openai(
|
|
392
|
-
response.get("stop_reason")
|
|
393
|
-
)
|
|
344
|
+
message = self._create_openai_message(content, tool_calls)
|
|
394
345
|
|
|
395
|
-
#
|
|
396
|
-
|
|
397
|
-
finish_reason = "stop"
|
|
346
|
+
# Create choice with proper finish reason
|
|
347
|
+
choice = self._create_openai_choice(message, response)
|
|
398
348
|
|
|
399
|
-
#
|
|
400
|
-
|
|
401
|
-
Literal["stop", "length", "tool_calls", "content_filter"], finish_reason
|
|
402
|
-
)
|
|
349
|
+
# Create usage information
|
|
350
|
+
usage = self._create_openai_usage(response)
|
|
403
351
|
|
|
404
|
-
# Create
|
|
405
|
-
choice = OpenAIChoice(
|
|
406
|
-
index=0,
|
|
407
|
-
message=message,
|
|
408
|
-
finish_reason=valid_finish_reason,
|
|
409
|
-
logprobs=None, # Anthropic doesn't support logprobs
|
|
410
|
-
)
|
|
411
|
-
|
|
412
|
-
# Create usage
|
|
413
|
-
usage_info = response.get("usage", {})
|
|
414
|
-
usage = OpenAIUsage(
|
|
415
|
-
prompt_tokens=usage_info.get("input_tokens", 0),
|
|
416
|
-
completion_tokens=usage_info.get("output_tokens", 0),
|
|
417
|
-
total_tokens=usage_info.get("input_tokens", 0)
|
|
418
|
-
+ usage_info.get("output_tokens", 0),
|
|
419
|
-
)
|
|
420
|
-
|
|
421
|
-
# Create OpenAI response
|
|
352
|
+
# Create final OpenAI response
|
|
422
353
|
openai_response = OpenAIChatCompletionResponse(
|
|
423
354
|
id=request_id,
|
|
424
355
|
object="chat.completion",
|
|
@@ -435,19 +366,134 @@ class OpenAIAdapter(APIAdapter):
|
|
|
435
366
|
to_format="openai",
|
|
436
367
|
response_id=request_id,
|
|
437
368
|
original_model=original_model,
|
|
438
|
-
finish_reason=
|
|
369
|
+
finish_reason=choice.finish_reason,
|
|
439
370
|
content_length=len(content) if content else 0,
|
|
440
371
|
tool_calls_count=len(tool_calls),
|
|
441
|
-
input_tokens=
|
|
442
|
-
output_tokens=
|
|
372
|
+
input_tokens=usage.prompt_tokens,
|
|
373
|
+
output_tokens=usage.completion_tokens,
|
|
443
374
|
operation="adapt_response",
|
|
444
375
|
choice=choice,
|
|
445
376
|
)
|
|
446
377
|
return openai_response.model_dump()
|
|
447
378
|
|
|
448
|
-
except
|
|
379
|
+
except ValidationError as e:
|
|
449
380
|
raise ValueError(f"Invalid Anthropic response format: {e}") from e
|
|
450
381
|
|
|
382
|
+
def _convert_content_blocks(
|
|
383
|
+
self, response: dict[str, Any]
|
|
384
|
+
) -> tuple[str, list[Any]]:
|
|
385
|
+
"""Convert Anthropic content blocks to OpenAI format content and tool calls."""
|
|
386
|
+
content = ""
|
|
387
|
+
tool_calls: list[Any] = []
|
|
388
|
+
|
|
389
|
+
if "content" in response and response["content"]:
|
|
390
|
+
for block in response["content"]:
|
|
391
|
+
if block.get("type") == "text":
|
|
392
|
+
text_content = block.get("text", "")
|
|
393
|
+
# Forward text content as-is (already formatted if needed)
|
|
394
|
+
content += text_content
|
|
395
|
+
elif block.get("type") == "system_message":
|
|
396
|
+
# Handle custom system_message content blocks
|
|
397
|
+
system_text = block.get("text", "")
|
|
398
|
+
source = block.get("source", "claude_code_sdk")
|
|
399
|
+
# Format as text with clear source attribution
|
|
400
|
+
content += f"[{source}]: {system_text}"
|
|
401
|
+
elif block.get("type") == "tool_use_sdk":
|
|
402
|
+
# Handle custom tool_use_sdk content blocks - convert to standard tool_calls
|
|
403
|
+
tool_call_block = {
|
|
404
|
+
"type": "tool_use",
|
|
405
|
+
"id": block.get("id", ""),
|
|
406
|
+
"name": block.get("name", ""),
|
|
407
|
+
"input": block.get("input", {}),
|
|
408
|
+
}
|
|
409
|
+
tool_calls.append(format_openai_tool_call(tool_call_block))
|
|
410
|
+
elif block.get("type") == "tool_result_sdk":
|
|
411
|
+
# Handle custom tool_result_sdk content blocks - add as text with source attribution
|
|
412
|
+
source = block.get("source", "claude_code_sdk")
|
|
413
|
+
tool_use_id = block.get("tool_use_id", "")
|
|
414
|
+
result_content = block.get("content", "")
|
|
415
|
+
is_error = block.get("is_error", False)
|
|
416
|
+
error_indicator = " (ERROR)" if is_error else ""
|
|
417
|
+
content += f"[{source} tool_result {tool_use_id}{error_indicator}]: {result_content}"
|
|
418
|
+
elif block.get("type") == "result_message":
|
|
419
|
+
# Handle custom result_message content blocks - add as text with source attribution
|
|
420
|
+
source = block.get("source", "claude_code_sdk")
|
|
421
|
+
result_data = block.get("data", {})
|
|
422
|
+
session_id = result_data.get("session_id", "")
|
|
423
|
+
stop_reason = result_data.get("stop_reason", "")
|
|
424
|
+
usage = result_data.get("usage", {})
|
|
425
|
+
cost_usd = result_data.get("total_cost_usd")
|
|
426
|
+
formatted_text = f"[{source} result {session_id}]: stop_reason={stop_reason}, usage={usage}"
|
|
427
|
+
if cost_usd is not None:
|
|
428
|
+
formatted_text += f", cost_usd={cost_usd}"
|
|
429
|
+
content += formatted_text
|
|
430
|
+
elif block.get("type") == "thinking":
|
|
431
|
+
# Handle thinking blocks - we can include them with a marker
|
|
432
|
+
thinking_text = block.get("thinking", "")
|
|
433
|
+
signature = block.get("signature")
|
|
434
|
+
if thinking_text:
|
|
435
|
+
content += f'<thinking signature="{signature}">{thinking_text}</thinking>'
|
|
436
|
+
elif block.get("type") == "tool_use":
|
|
437
|
+
# Handle legacy tool_use content blocks
|
|
438
|
+
tool_calls.append(format_openai_tool_call(block))
|
|
439
|
+
else:
|
|
440
|
+
logger.warning(
|
|
441
|
+
"unsupported_content_block_type", type=block.get("type")
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
return content, tool_calls
|
|
445
|
+
|
|
446
|
+
def _create_openai_message(
|
|
447
|
+
self, content: str, tool_calls: list[Any]
|
|
448
|
+
) -> OpenAIResponseMessage:
|
|
449
|
+
"""Create OpenAI message with proper content handling."""
|
|
450
|
+
# When there are tool calls but no content, use empty string instead of None
|
|
451
|
+
# Otherwise, if content is empty string, convert to None
|
|
452
|
+
final_content: str | None = content
|
|
453
|
+
if tool_calls and not content:
|
|
454
|
+
final_content = ""
|
|
455
|
+
elif content == "":
|
|
456
|
+
final_content = None
|
|
457
|
+
|
|
458
|
+
return OpenAIResponseMessage(
|
|
459
|
+
role="assistant",
|
|
460
|
+
content=final_content,
|
|
461
|
+
tool_calls=tool_calls if tool_calls else None,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
def _create_openai_choice(
|
|
465
|
+
self, message: OpenAIResponseMessage, response: dict[str, Any]
|
|
466
|
+
) -> OpenAIChoice:
|
|
467
|
+
"""Create OpenAI choice with proper finish reason handling."""
|
|
468
|
+
# Map stop reason
|
|
469
|
+
finish_reason = self._convert_stop_reason_to_openai(response.get("stop_reason"))
|
|
470
|
+
|
|
471
|
+
# Ensure finish_reason is a valid literal type
|
|
472
|
+
if finish_reason not in ["stop", "length", "tool_calls", "content_filter"]:
|
|
473
|
+
finish_reason = "stop"
|
|
474
|
+
|
|
475
|
+
# Cast to proper literal type
|
|
476
|
+
valid_finish_reason = cast(
|
|
477
|
+
Literal["stop", "length", "tool_calls", "content_filter"], finish_reason
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
return OpenAIChoice(
|
|
481
|
+
index=0,
|
|
482
|
+
message=message,
|
|
483
|
+
finish_reason=valid_finish_reason,
|
|
484
|
+
logprobs=None, # Anthropic doesn't support logprobs
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
def _create_openai_usage(self, response: dict[str, Any]) -> OpenAIUsage:
|
|
488
|
+
"""Create OpenAI usage information from Anthropic response."""
|
|
489
|
+
usage_info = response.get("usage", {})
|
|
490
|
+
return OpenAIUsage(
|
|
491
|
+
prompt_tokens=usage_info.get("input_tokens", 0),
|
|
492
|
+
completion_tokens=usage_info.get("output_tokens", 0),
|
|
493
|
+
total_tokens=usage_info.get("input_tokens", 0)
|
|
494
|
+
+ usage_info.get("output_tokens", 0),
|
|
495
|
+
)
|
|
496
|
+
|
|
451
497
|
async def adapt_stream(
|
|
452
498
|
self, stream: AsyncIterator[dict[str, Any]]
|
|
453
499
|
) -> AsyncIterator[dict[str, Any]]:
|
|
@@ -462,31 +508,25 @@ class OpenAIAdapter(APIAdapter):
|
|
|
462
508
|
Raises:
|
|
463
509
|
ValueError: If the stream format is invalid or unsupported
|
|
464
510
|
"""
|
|
465
|
-
# Create stream processor
|
|
511
|
+
# Create stream processor with dict output format
|
|
466
512
|
processor = OpenAIStreamProcessor(
|
|
467
513
|
enable_usage=True,
|
|
468
514
|
enable_tool_calls=True,
|
|
469
|
-
|
|
515
|
+
output_format="dict", # Output dict objects instead of SSE strings
|
|
470
516
|
)
|
|
471
517
|
|
|
472
518
|
try:
|
|
473
|
-
# Process the stream
|
|
474
|
-
async for
|
|
475
|
-
|
|
476
|
-
data_str = sse_chunk[6:].strip()
|
|
477
|
-
if data_str and data_str != "[DONE]":
|
|
478
|
-
try:
|
|
479
|
-
yield json.loads(data_str)
|
|
480
|
-
except json.JSONDecodeError:
|
|
481
|
-
logger.warning(
|
|
482
|
-
"streaming_chunk_parse_failed",
|
|
483
|
-
chunk_data=data_str[:100] + "..."
|
|
484
|
-
if len(data_str) > 100
|
|
485
|
-
else data_str,
|
|
486
|
-
operation="adapt_stream",
|
|
487
|
-
)
|
|
488
|
-
continue
|
|
519
|
+
# Process the stream - now yields dict objects directly
|
|
520
|
+
async for chunk in processor.process_stream(stream):
|
|
521
|
+
yield chunk # type: ignore[misc] # chunk is guaranteed to be dict when output_format="dict"
|
|
489
522
|
except Exception as e:
|
|
523
|
+
logger.error(
|
|
524
|
+
"streaming_conversion_failed",
|
|
525
|
+
error=str(e),
|
|
526
|
+
error_type=type(e).__name__,
|
|
527
|
+
operation="adapt_stream",
|
|
528
|
+
exc_info=True,
|
|
529
|
+
)
|
|
490
530
|
raise ValueError(f"Error processing streaming response: {e}") from e
|
|
491
531
|
|
|
492
532
|
def _convert_messages_to_anthropic(
|
|
@@ -910,6 +950,4 @@ __all__ = [
|
|
|
910
950
|
"OpenAIAdapter",
|
|
911
951
|
"OpenAIChatCompletionRequest",
|
|
912
952
|
"OpenAIChatCompletionResponse",
|
|
913
|
-
"map_openai_model_to_claude",
|
|
914
|
-
"OPENAI_TO_CLAUDE_MODEL_MAPPING",
|
|
915
953
|
]
|