ccproxy-api 0.1.2__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 (108) 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 +62 -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 +76 -29
  36. ccproxy/claude_sdk/parser.py +200 -0
  37. ccproxy/claude_sdk/streaming.py +286 -0
  38. ccproxy/cli/commands/__init__.py +5 -2
  39. ccproxy/cli/commands/auth.py +2 -4
  40. ccproxy/cli/commands/permission_handler.py +553 -0
  41. ccproxy/cli/commands/serve.py +30 -12
  42. ccproxy/cli/docker/params.py +0 -4
  43. ccproxy/cli/helpers.py +0 -2
  44. ccproxy/cli/main.py +5 -16
  45. ccproxy/cli/options/claude_options.py +19 -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 +13 -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 +29 -2
  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 +220 -328
  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.2.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/cli/commands/permission.py +0 -128
  104. ccproxy_api-0.1.2.dist-info/RECORD +0 -150
  105. /ccproxy/scheduler/{exceptions.py → errors.py} +0 -0
  106. {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.3.dist-info}/WHEEL +0 -0
  107. {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.3.dist-info}/entry_points.txt +0 -0
  108. {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.3.dist-info}/licenses/LICENSE +0 -0
ccproxy/_version.py CHANGED
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '0.1.2'
21
- __version_tuple__ = version_tuple = (0, 1, 2)
20
+ __version__ = version = '0.1.3'
21
+ __version_tuple__ = version_tuple = (0, 1, 3)
@@ -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, map_openai_model_to_claude
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
- pass
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 Exception as e:
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 = map_openai_model_to_claude(openai_req.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
- # Handle metadata - combine user field and metadata
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
- # Handle response format - add to system prompt for JSON mode
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
- # Handle reasoning_effort (o1 models) -> thinking configuration
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
- # Note: seed, logprobs, top_logprobs, and store don't have direct Anthropic equivalents
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
- # When there are tool calls but no content, use empty string instead of None
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
- # Ensure finish_reason is a valid literal type
396
- if finish_reason not in ["stop", "length", "tool_calls", "content_filter"]:
397
- finish_reason = "stop"
346
+ # Create choice with proper finish reason
347
+ choice = self._create_openai_choice(message, response)
398
348
 
399
- # Cast to proper literal type
400
- valid_finish_reason = cast(
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 choice
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=valid_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=usage_info.get("input_tokens", 0),
442
- output_tokens=usage_info.get("output_tokens", 0),
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 Exception as e:
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
- enable_text_chunking=False, # Keep text as-is for compatibility
515
+ output_format="dict", # Output dict objects instead of SSE strings
470
516
  )
471
517
 
472
518
  try:
473
- # Process the stream and parse SSE format back to dict objects
474
- async for sse_chunk in processor.process_stream(stream):
475
- if sse_chunk.startswith("data: "):
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
  ]