massgen 0.0.3__py3-none-any.whl → 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.
Potentially problematic release.
This version of massgen might be problematic. Click here for more details.
- massgen/__init__.py +142 -8
- massgen/adapters/__init__.py +29 -0
- massgen/adapters/ag2_adapter.py +483 -0
- massgen/adapters/base.py +183 -0
- massgen/adapters/tests/__init__.py +0 -0
- massgen/adapters/tests/test_ag2_adapter.py +439 -0
- massgen/adapters/tests/test_agent_adapter.py +128 -0
- massgen/adapters/utils/__init__.py +2 -0
- massgen/adapters/utils/ag2_utils.py +236 -0
- massgen/adapters/utils/tests/__init__.py +0 -0
- massgen/adapters/utils/tests/test_ag2_utils.py +138 -0
- massgen/agent_config.py +329 -55
- massgen/api_params_handler/__init__.py +10 -0
- massgen/api_params_handler/_api_params_handler_base.py +99 -0
- massgen/api_params_handler/_chat_completions_api_params_handler.py +176 -0
- massgen/api_params_handler/_claude_api_params_handler.py +113 -0
- massgen/api_params_handler/_response_api_params_handler.py +130 -0
- massgen/backend/__init__.py +39 -4
- massgen/backend/azure_openai.py +385 -0
- massgen/backend/base.py +341 -69
- massgen/backend/base_with_mcp.py +1102 -0
- massgen/backend/capabilities.py +386 -0
- massgen/backend/chat_completions.py +577 -130
- massgen/backend/claude.py +1033 -537
- massgen/backend/claude_code.py +1203 -0
- massgen/backend/cli_base.py +209 -0
- massgen/backend/docs/BACKEND_ARCHITECTURE.md +126 -0
- massgen/backend/{CLAUDE_API_RESEARCH.md → docs/CLAUDE_API_RESEARCH.md} +18 -18
- massgen/backend/{GEMINI_API_DOCUMENTATION.md → docs/GEMINI_API_DOCUMENTATION.md} +9 -9
- massgen/backend/docs/Gemini MCP Integration Analysis.md +1050 -0
- massgen/backend/docs/MCP_IMPLEMENTATION_CLAUDE_BACKEND.md +177 -0
- massgen/backend/docs/MCP_INTEGRATION_RESPONSE_BACKEND.md +352 -0
- massgen/backend/docs/OPENAI_GPT5_MODELS.md +211 -0
- massgen/backend/{OPENAI_RESPONSES_API_FORMAT.md → docs/OPENAI_RESPONSE_API_TOOL_CALLS.md} +3 -3
- massgen/backend/docs/OPENAI_response_streaming.md +20654 -0
- massgen/backend/docs/inference_backend.md +257 -0
- massgen/backend/docs/permissions_and_context_files.md +1085 -0
- massgen/backend/external.py +126 -0
- massgen/backend/gemini.py +1850 -241
- massgen/backend/grok.py +40 -156
- massgen/backend/inference.py +156 -0
- massgen/backend/lmstudio.py +171 -0
- massgen/backend/response.py +1095 -322
- massgen/chat_agent.py +131 -113
- massgen/cli.py +1560 -275
- massgen/config_builder.py +2396 -0
- massgen/configs/BACKEND_CONFIGURATION.md +458 -0
- massgen/configs/README.md +559 -216
- massgen/configs/ag2/ag2_case_study.yaml +27 -0
- massgen/configs/ag2/ag2_coder.yaml +34 -0
- massgen/configs/ag2/ag2_coder_case_study.yaml +36 -0
- massgen/configs/ag2/ag2_gemini.yaml +27 -0
- massgen/configs/ag2/ag2_groupchat.yaml +108 -0
- massgen/configs/ag2/ag2_groupchat_gpt.yaml +118 -0
- massgen/configs/ag2/ag2_single_agent.yaml +21 -0
- massgen/configs/basic/multi/fast_timeout_example.yaml +37 -0
- massgen/configs/basic/multi/gemini_4o_claude.yaml +31 -0
- massgen/configs/basic/multi/gemini_gpt5nano_claude.yaml +36 -0
- massgen/configs/{gemini_4o_claude.yaml → basic/multi/geminicode_4o_claude.yaml} +3 -3
- massgen/configs/basic/multi/geminicode_gpt5nano_claude.yaml +36 -0
- massgen/configs/basic/multi/glm_gemini_claude.yaml +25 -0
- massgen/configs/basic/multi/gpt4o_audio_generation.yaml +30 -0
- massgen/configs/basic/multi/gpt4o_image_generation.yaml +31 -0
- massgen/configs/basic/multi/gpt5nano_glm_qwen.yaml +26 -0
- massgen/configs/basic/multi/gpt5nano_image_understanding.yaml +26 -0
- massgen/configs/{three_agents_default.yaml → basic/multi/three_agents_default.yaml} +8 -4
- massgen/configs/basic/multi/three_agents_opensource.yaml +27 -0
- massgen/configs/basic/multi/three_agents_vllm.yaml +20 -0
- massgen/configs/basic/multi/two_agents_gemini.yaml +19 -0
- massgen/configs/{two_agents.yaml → basic/multi/two_agents_gpt5.yaml} +14 -6
- massgen/configs/basic/multi/two_agents_opensource_lmstudio.yaml +31 -0
- massgen/configs/basic/multi/two_qwen_vllm_sglang.yaml +28 -0
- massgen/configs/{single_agent.yaml → basic/single/single_agent.yaml} +1 -1
- massgen/configs/{single_flash2.5.yaml → basic/single/single_flash2.5.yaml} +1 -2
- massgen/configs/basic/single/single_gemini2.5pro.yaml +16 -0
- massgen/configs/basic/single/single_gpt4o_audio_generation.yaml +22 -0
- massgen/configs/basic/single/single_gpt4o_image_generation.yaml +22 -0
- massgen/configs/basic/single/single_gpt4o_video_generation.yaml +24 -0
- massgen/configs/basic/single/single_gpt5nano.yaml +20 -0
- massgen/configs/basic/single/single_gpt5nano_file_search.yaml +18 -0
- massgen/configs/basic/single/single_gpt5nano_image_understanding.yaml +17 -0
- massgen/configs/basic/single/single_gptoss120b.yaml +15 -0
- massgen/configs/basic/single/single_openrouter_audio_understanding.yaml +15 -0
- massgen/configs/basic/single/single_qwen_video_understanding.yaml +15 -0
- massgen/configs/debug/code_execution/command_filtering_blacklist.yaml +29 -0
- massgen/configs/debug/code_execution/command_filtering_whitelist.yaml +28 -0
- massgen/configs/debug/code_execution/docker_verification.yaml +29 -0
- massgen/configs/debug/skip_coordination_test.yaml +27 -0
- massgen/configs/debug/test_sdk_migration.yaml +17 -0
- massgen/configs/docs/DISCORD_MCP_SETUP.md +208 -0
- massgen/configs/docs/TWITTER_MCP_ENESCINAR_SETUP.md +82 -0
- massgen/configs/providers/azure/azure_openai_multi.yaml +21 -0
- massgen/configs/providers/azure/azure_openai_single.yaml +19 -0
- massgen/configs/providers/claude/claude.yaml +14 -0
- massgen/configs/providers/gemini/gemini_gpt5nano.yaml +28 -0
- massgen/configs/providers/local/lmstudio.yaml +11 -0
- massgen/configs/providers/openai/gpt5.yaml +46 -0
- massgen/configs/providers/openai/gpt5_nano.yaml +46 -0
- massgen/configs/providers/others/grok_single_agent.yaml +19 -0
- massgen/configs/providers/others/zai_coding_team.yaml +108 -0
- massgen/configs/providers/others/zai_glm45.yaml +12 -0
- massgen/configs/{creative_team.yaml → teams/creative/creative_team.yaml} +16 -6
- massgen/configs/{travel_planning.yaml → teams/creative/travel_planning.yaml} +16 -6
- massgen/configs/{news_analysis.yaml → teams/research/news_analysis.yaml} +16 -6
- massgen/configs/{research_team.yaml → teams/research/research_team.yaml} +15 -7
- massgen/configs/{technical_analysis.yaml → teams/research/technical_analysis.yaml} +16 -6
- massgen/configs/tools/code-execution/basic_command_execution.yaml +25 -0
- massgen/configs/tools/code-execution/code_execution_use_case_simple.yaml +41 -0
- massgen/configs/tools/code-execution/docker_claude_code.yaml +32 -0
- massgen/configs/tools/code-execution/docker_multi_agent.yaml +32 -0
- massgen/configs/tools/code-execution/docker_simple.yaml +29 -0
- massgen/configs/tools/code-execution/docker_with_resource_limits.yaml +32 -0
- massgen/configs/tools/code-execution/multi_agent_playwright_automation.yaml +57 -0
- massgen/configs/tools/filesystem/cc_gpt5_gemini_filesystem.yaml +34 -0
- massgen/configs/tools/filesystem/claude_code_context_sharing.yaml +68 -0
- massgen/configs/tools/filesystem/claude_code_flash2.5.yaml +43 -0
- massgen/configs/tools/filesystem/claude_code_flash2.5_gptoss.yaml +49 -0
- massgen/configs/tools/filesystem/claude_code_gpt5nano.yaml +31 -0
- massgen/configs/tools/filesystem/claude_code_single.yaml +40 -0
- massgen/configs/tools/filesystem/fs_permissions_test.yaml +87 -0
- massgen/configs/tools/filesystem/gemini_gemini_workspace_cleanup.yaml +54 -0
- massgen/configs/tools/filesystem/gemini_gpt5_filesystem_casestudy.yaml +30 -0
- massgen/configs/tools/filesystem/gemini_gpt5nano_file_context_path.yaml +43 -0
- massgen/configs/tools/filesystem/gemini_gpt5nano_protected_paths.yaml +45 -0
- massgen/configs/tools/filesystem/gpt5mini_cc_fs_context_path.yaml +31 -0
- massgen/configs/tools/filesystem/grok4_gpt5_gemini_filesystem.yaml +32 -0
- massgen/configs/tools/filesystem/multiturn/grok4_gpt5_claude_code_filesystem_multiturn.yaml +58 -0
- massgen/configs/tools/filesystem/multiturn/grok4_gpt5_gemini_filesystem_multiturn.yaml +58 -0
- massgen/configs/tools/filesystem/multiturn/two_claude_code_filesystem_multiturn.yaml +47 -0
- massgen/configs/tools/filesystem/multiturn/two_gemini_flash_filesystem_multiturn.yaml +48 -0
- massgen/configs/tools/mcp/claude_code_discord_mcp_example.yaml +27 -0
- massgen/configs/tools/mcp/claude_code_simple_mcp.yaml +35 -0
- massgen/configs/tools/mcp/claude_code_twitter_mcp_example.yaml +32 -0
- massgen/configs/tools/mcp/claude_mcp_example.yaml +24 -0
- massgen/configs/tools/mcp/claude_mcp_test.yaml +27 -0
- massgen/configs/tools/mcp/five_agents_travel_mcp_test.yaml +157 -0
- massgen/configs/tools/mcp/five_agents_weather_mcp_test.yaml +103 -0
- massgen/configs/tools/mcp/gemini_mcp_example.yaml +24 -0
- massgen/configs/tools/mcp/gemini_mcp_filesystem_test.yaml +23 -0
- massgen/configs/tools/mcp/gemini_mcp_filesystem_test_sharing.yaml +23 -0
- massgen/configs/tools/mcp/gemini_mcp_filesystem_test_single_agent.yaml +17 -0
- massgen/configs/tools/mcp/gemini_mcp_filesystem_test_with_claude_code.yaml +24 -0
- massgen/configs/tools/mcp/gemini_mcp_test.yaml +27 -0
- massgen/configs/tools/mcp/gemini_notion_mcp.yaml +52 -0
- massgen/configs/tools/mcp/gpt5_nano_mcp_example.yaml +24 -0
- massgen/configs/tools/mcp/gpt5_nano_mcp_test.yaml +27 -0
- massgen/configs/tools/mcp/gpt5mini_claude_code_discord_mcp_example.yaml +38 -0
- massgen/configs/tools/mcp/gpt_oss_mcp_example.yaml +25 -0
- massgen/configs/tools/mcp/gpt_oss_mcp_test.yaml +28 -0
- massgen/configs/tools/mcp/grok3_mini_mcp_example.yaml +24 -0
- massgen/configs/tools/mcp/grok3_mini_mcp_test.yaml +27 -0
- massgen/configs/tools/mcp/multimcp_gemini.yaml +111 -0
- massgen/configs/tools/mcp/qwen_api_mcp_example.yaml +25 -0
- massgen/configs/tools/mcp/qwen_api_mcp_test.yaml +28 -0
- massgen/configs/tools/mcp/qwen_local_mcp_example.yaml +24 -0
- massgen/configs/tools/mcp/qwen_local_mcp_test.yaml +27 -0
- massgen/configs/tools/planning/five_agents_discord_mcp_planning_mode.yaml +140 -0
- massgen/configs/tools/planning/five_agents_filesystem_mcp_planning_mode.yaml +151 -0
- massgen/configs/tools/planning/five_agents_notion_mcp_planning_mode.yaml +151 -0
- massgen/configs/tools/planning/five_agents_twitter_mcp_planning_mode.yaml +155 -0
- massgen/configs/tools/planning/gpt5_mini_case_study_mcp_planning_mode.yaml +73 -0
- massgen/configs/tools/web-search/claude_streamable_http_test.yaml +43 -0
- massgen/configs/tools/web-search/gemini_streamable_http_test.yaml +43 -0
- massgen/configs/tools/web-search/gpt5_mini_streamable_http_test.yaml +43 -0
- massgen/configs/tools/web-search/gpt_oss_streamable_http_test.yaml +44 -0
- massgen/configs/tools/web-search/grok3_mini_streamable_http_test.yaml +43 -0
- massgen/configs/tools/web-search/qwen_api_streamable_http_test.yaml +44 -0
- massgen/configs/tools/web-search/qwen_local_streamable_http_test.yaml +43 -0
- massgen/coordination_tracker.py +708 -0
- massgen/docker/README.md +462 -0
- massgen/filesystem_manager/__init__.py +21 -0
- massgen/filesystem_manager/_base.py +9 -0
- massgen/filesystem_manager/_code_execution_server.py +545 -0
- massgen/filesystem_manager/_docker_manager.py +477 -0
- massgen/filesystem_manager/_file_operation_tracker.py +248 -0
- massgen/filesystem_manager/_filesystem_manager.py +813 -0
- massgen/filesystem_manager/_path_permission_manager.py +1261 -0
- massgen/filesystem_manager/_workspace_tools_server.py +1815 -0
- massgen/formatter/__init__.py +10 -0
- massgen/formatter/_chat_completions_formatter.py +284 -0
- massgen/formatter/_claude_formatter.py +235 -0
- massgen/formatter/_formatter_base.py +156 -0
- massgen/formatter/_response_formatter.py +263 -0
- massgen/frontend/__init__.py +1 -2
- massgen/frontend/coordination_ui.py +471 -286
- massgen/frontend/displays/base_display.py +56 -11
- massgen/frontend/displays/create_coordination_table.py +1956 -0
- massgen/frontend/displays/rich_terminal_display.py +1259 -619
- massgen/frontend/displays/simple_display.py +9 -4
- massgen/frontend/displays/terminal_display.py +27 -68
- massgen/logger_config.py +681 -0
- massgen/mcp_tools/README.md +232 -0
- massgen/mcp_tools/__init__.py +105 -0
- massgen/mcp_tools/backend_utils.py +1035 -0
- massgen/mcp_tools/circuit_breaker.py +195 -0
- massgen/mcp_tools/client.py +894 -0
- massgen/mcp_tools/config_validator.py +138 -0
- massgen/mcp_tools/docs/circuit_breaker.md +646 -0
- massgen/mcp_tools/docs/client.md +950 -0
- massgen/mcp_tools/docs/config_validator.md +478 -0
- massgen/mcp_tools/docs/exceptions.md +1165 -0
- massgen/mcp_tools/docs/security.md +854 -0
- massgen/mcp_tools/exceptions.py +338 -0
- massgen/mcp_tools/hooks.py +212 -0
- massgen/mcp_tools/security.py +780 -0
- massgen/message_templates.py +342 -64
- massgen/orchestrator.py +1515 -241
- massgen/stream_chunk/__init__.py +35 -0
- massgen/stream_chunk/base.py +92 -0
- massgen/stream_chunk/multimodal.py +237 -0
- massgen/stream_chunk/text.py +162 -0
- massgen/tests/mcp_test_server.py +150 -0
- massgen/tests/multi_turn_conversation_design.md +0 -8
- massgen/tests/test_azure_openai_backend.py +156 -0
- massgen/tests/test_backend_capabilities.py +262 -0
- massgen/tests/test_backend_event_loop_all.py +179 -0
- massgen/tests/test_chat_completions_refactor.py +142 -0
- massgen/tests/test_claude_backend.py +15 -28
- massgen/tests/test_claude_code.py +268 -0
- massgen/tests/test_claude_code_context_sharing.py +233 -0
- massgen/tests/test_claude_code_orchestrator.py +175 -0
- massgen/tests/test_cli_backends.py +180 -0
- massgen/tests/test_code_execution.py +679 -0
- massgen/tests/test_external_agent_backend.py +134 -0
- massgen/tests/test_final_presentation_fallback.py +237 -0
- massgen/tests/test_gemini_planning_mode.py +351 -0
- massgen/tests/test_grok_backend.py +7 -10
- massgen/tests/test_http_mcp_server.py +42 -0
- massgen/tests/test_integration_simple.py +198 -0
- massgen/tests/test_mcp_blocking.py +125 -0
- massgen/tests/test_message_context_building.py +29 -47
- massgen/tests/test_orchestrator_final_presentation.py +48 -0
- massgen/tests/test_path_permission_manager.py +2087 -0
- massgen/tests/test_rich_terminal_display.py +14 -13
- massgen/tests/test_timeout.py +133 -0
- massgen/tests/test_v3_3agents.py +11 -12
- massgen/tests/test_v3_simple.py +8 -13
- massgen/tests/test_v3_three_agents.py +11 -18
- massgen/tests/test_v3_two_agents.py +8 -13
- massgen/token_manager/__init__.py +7 -0
- massgen/token_manager/token_manager.py +400 -0
- massgen/utils.py +52 -16
- massgen/v1/agent.py +45 -91
- massgen/v1/agents.py +18 -53
- massgen/v1/backends/gemini.py +50 -153
- massgen/v1/backends/grok.py +21 -54
- massgen/v1/backends/oai.py +39 -111
- massgen/v1/cli.py +36 -93
- massgen/v1/config.py +8 -12
- massgen/v1/logging.py +43 -127
- massgen/v1/main.py +18 -32
- massgen/v1/orchestrator.py +68 -209
- massgen/v1/streaming_display.py +62 -163
- massgen/v1/tools.py +8 -12
- massgen/v1/types.py +9 -23
- massgen/v1/utils.py +5 -23
- massgen-0.1.0.dist-info/METADATA +1245 -0
- massgen-0.1.0.dist-info/RECORD +273 -0
- massgen-0.1.0.dist-info/entry_points.txt +2 -0
- massgen/frontend/logging/__init__.py +0 -9
- massgen/frontend/logging/realtime_logger.py +0 -197
- massgen-0.0.3.dist-info/METADATA +0 -568
- massgen-0.0.3.dist-info/RECORD +0 -76
- massgen-0.0.3.dist-info/entry_points.txt +0 -2
- /massgen/backend/{Function calling openai responses.md → docs/Function calling openai responses.md} +0 -0
- {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/WHEEL +0 -0
- {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/licenses/LICENSE +0 -0
- {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/top_level.txt +0 -0
massgen/backend/claude.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
3
2
|
"""
|
|
4
3
|
Claude backend implementation using Anthropic's Messages API.
|
|
5
4
|
Production-ready implementation with full multi-tool support.
|
|
@@ -20,14 +19,28 @@ Multi-Tool Capabilities:
|
|
|
20
19
|
- Parallel and sequential tool execution supported
|
|
21
20
|
- Perfect integration with MassGen StreamChunk pattern
|
|
22
21
|
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
23
|
|
|
24
|
-
import
|
|
24
|
+
import base64
|
|
25
|
+
import binascii
|
|
25
26
|
import json
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
import mimetypes
|
|
28
|
+
import os
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, AsyncGenerator, Callable, Dict, List, Optional, Tuple
|
|
31
|
+
|
|
32
|
+
import anthropic
|
|
33
|
+
import httpx
|
|
28
34
|
|
|
35
|
+
from ..api_params_handler import ClaudeAPIParamsHandler
|
|
36
|
+
from ..formatter import ClaudeFormatter
|
|
37
|
+
from ..logger_config import log_backend_agent_message, log_stream_chunk, logger
|
|
38
|
+
from ..mcp_tools.backend_utils import MCPErrorHandler
|
|
39
|
+
from .base import FilesystemSupport, StreamChunk
|
|
40
|
+
from .base_with_mcp import MCPBackend, UploadFileError
|
|
29
41
|
|
|
30
|
-
|
|
42
|
+
|
|
43
|
+
class ClaudeBackend(MCPBackend):
|
|
31
44
|
"""Claude backend using Anthropic's Messages API with full multi-tool support."""
|
|
32
45
|
|
|
33
46
|
def __init__(self, api_key: Optional[str] = None, **kwargs):
|
|
@@ -35,556 +48,1071 @@ class ClaudeBackend(LLMBackend):
|
|
|
35
48
|
self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY")
|
|
36
49
|
self.search_count = 0 # Track web search usage for pricing
|
|
37
50
|
self.code_session_hours = 0.0 # Track code execution usage
|
|
51
|
+
self.formatter = ClaudeFormatter()
|
|
52
|
+
self.api_params_handler = ClaudeAPIParamsHandler(self)
|
|
53
|
+
self._uploaded_file_ids: List[str] = []
|
|
54
|
+
|
|
55
|
+
def supports_upload_files(self) -> bool:
|
|
56
|
+
"""Claude Vision supports inline images; Files API handles PDFs and text docs."""
|
|
38
57
|
|
|
39
|
-
|
|
40
|
-
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
async def stream_with_tools(
|
|
61
|
+
self,
|
|
62
|
+
messages: List[Dict[str, Any]],
|
|
63
|
+
tools: List[Dict[str, Any]],
|
|
64
|
+
**kwargs,
|
|
65
|
+
) -> AsyncGenerator[StreamChunk, None]:
|
|
66
|
+
"""Override to ensure Files API cleanup happens after streaming completes."""
|
|
67
|
+
try:
|
|
68
|
+
async for chunk in super().stream_with_tools(messages, tools, **kwargs):
|
|
69
|
+
yield chunk
|
|
70
|
+
finally:
|
|
71
|
+
await self._cleanup_files_api_resources(**kwargs)
|
|
72
|
+
|
|
73
|
+
async def _process_upload_files(
|
|
74
|
+
self,
|
|
75
|
+
messages: List[Dict[str, Any]],
|
|
76
|
+
all_params: Dict[str, Any],
|
|
41
77
|
) -> List[Dict[str, Any]]:
|
|
42
|
-
"""Convert
|
|
78
|
+
"""Convert upload_files entries into Claude-compatible multimodal content."""
|
|
43
79
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
80
|
+
processed_messages = await super()._process_upload_files(messages, all_params)
|
|
81
|
+
if not processed_messages:
|
|
82
|
+
return processed_messages
|
|
47
83
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
84
|
+
allowed_mime_types = {
|
|
85
|
+
"image/jpeg",
|
|
86
|
+
"image/png",
|
|
87
|
+
"image/gif",
|
|
88
|
+
"image/webp",
|
|
89
|
+
}
|
|
90
|
+
max_image_size_bytes = 5 * 1024 * 1024
|
|
91
|
+
|
|
92
|
+
for message in processed_messages:
|
|
93
|
+
content = message.get("content")
|
|
94
|
+
if not isinstance(content, list):
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
converted_items: List[Dict[str, Any]] = []
|
|
98
|
+
for item in content:
|
|
99
|
+
if not isinstance(item, dict):
|
|
100
|
+
converted_items.append(item)
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
item_type = item.get("type")
|
|
104
|
+
if item_type == "file_pending_upload":
|
|
105
|
+
converted_items.append(item)
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
if item_type != "image":
|
|
109
|
+
converted_items.append(item)
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
if "source" in item and isinstance(item["source"], dict):
|
|
113
|
+
converted_items.append(item)
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
# Handle base64-encoded images
|
|
117
|
+
if "base64" in item:
|
|
118
|
+
mime_type = (item.get("mime_type") or "").lower()
|
|
119
|
+
if mime_type not in allowed_mime_types:
|
|
120
|
+
raise UploadFileError(
|
|
121
|
+
f"Unsupported Claude image MIME type: {mime_type or 'unknown'}",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
decoded = base64.b64decode(item["base64"], validate=True)
|
|
126
|
+
except binascii.Error as exc:
|
|
127
|
+
raise UploadFileError("Invalid base64 image data") from exc
|
|
128
|
+
|
|
129
|
+
if len(decoded) > max_image_size_bytes:
|
|
130
|
+
raise UploadFileError(
|
|
131
|
+
"Claude Vision image exceeds 5MB size limit",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
converted_item = {key: value for key, value in item.items() if key not in {"base64", "mime_type"}}
|
|
135
|
+
converted_item["type"] = "image"
|
|
136
|
+
converted_item["source"] = {
|
|
137
|
+
"type": "base64",
|
|
138
|
+
"media_type": mime_type,
|
|
139
|
+
"data": item["base64"],
|
|
140
|
+
}
|
|
141
|
+
logger.debug(
|
|
142
|
+
"Converted base64 image for Claude Vision: %s",
|
|
143
|
+
converted_item.get("source_path", "inline"),
|
|
66
144
|
)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
145
|
+
converted_items.append(converted_item)
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
# Handle URL-referenced images
|
|
149
|
+
if "url" in item:
|
|
150
|
+
converted_item = {key: value for key, value in item.items() if key != "url"}
|
|
151
|
+
converted_item["type"] = "image"
|
|
152
|
+
converted_item["source"] = {
|
|
153
|
+
"type": "url",
|
|
154
|
+
"url": item["url"],
|
|
155
|
+
}
|
|
156
|
+
logger.debug(
|
|
157
|
+
"Converted URL image for Claude Vision: %s",
|
|
158
|
+
item["url"],
|
|
76
159
|
)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
converted_tools.append(tool)
|
|
80
|
-
else:
|
|
81
|
-
# Non-function tool (builtin tools) - keep as-is
|
|
82
|
-
converted_tools.append(tool)
|
|
160
|
+
converted_items.append(converted_item)
|
|
161
|
+
continue
|
|
83
162
|
|
|
84
|
-
|
|
163
|
+
# Handle Files API references
|
|
164
|
+
if "file_id" in item:
|
|
165
|
+
converted_item = {key: value for key, value in item.items() if key != "file_id"}
|
|
166
|
+
converted_item["type"] = "image"
|
|
167
|
+
converted_item["source"] = {
|
|
168
|
+
"type": "file",
|
|
169
|
+
"file_id": item["file_id"],
|
|
170
|
+
}
|
|
171
|
+
logger.debug(
|
|
172
|
+
"Attached Claude file_id reference for image: %s",
|
|
173
|
+
item["file_id"],
|
|
174
|
+
)
|
|
175
|
+
converted_items.append(converted_item)
|
|
176
|
+
continue
|
|
85
177
|
|
|
86
|
-
|
|
87
|
-
self, messages: List[Dict[str, Any]]
|
|
88
|
-
) -> tuple:
|
|
89
|
-
"""Convert messages to Claude's expected format.
|
|
178
|
+
converted_items.append(item)
|
|
90
179
|
|
|
91
|
-
|
|
92
|
-
- Chat Completions tool message: {"role": "tool", "tool_call_id": "...", "content": "..."}
|
|
93
|
-
- Response API tool message: {"type": "function_call_output", "call_id": "...", "output": "..."}
|
|
94
|
-
- System messages: Extract and return separately for top-level system parameter
|
|
180
|
+
message["content"] = converted_items
|
|
95
181
|
|
|
96
|
-
|
|
97
|
-
|
|
182
|
+
return processed_messages
|
|
183
|
+
|
|
184
|
+
async def _upload_files_via_files_api(
|
|
185
|
+
self,
|
|
186
|
+
messages: List[Dict[str, Any]],
|
|
187
|
+
client,
|
|
188
|
+
agent_id: Optional[str] = None,
|
|
189
|
+
) -> List[Dict[str, Any]]:
|
|
190
|
+
"""Upload files via Claude Files API and replace pending markers with document blocks.
|
|
191
|
+
|
|
192
|
+
Claude Files API only supports PDF and TXT files. Unsupported files are gracefully
|
|
193
|
+
skipped and replaced with informative text notes to maintain workflow continuity.
|
|
98
194
|
"""
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
elif message.get("role") == "tool":
|
|
107
|
-
# Chat Completions tool message -> Claude tool result
|
|
108
|
-
converted_messages.append(
|
|
109
|
-
{
|
|
110
|
-
"role": "user",
|
|
111
|
-
"content": [
|
|
112
|
-
{
|
|
113
|
-
"type": "tool_result",
|
|
114
|
-
"tool_use_id": message.get("tool_call_id"),
|
|
115
|
-
"content": message.get("content", ""),
|
|
116
|
-
}
|
|
117
|
-
],
|
|
118
|
-
}
|
|
119
|
-
)
|
|
120
|
-
elif message.get("type") == "function_call_output":
|
|
121
|
-
# Response API tool message -> Claude tool result
|
|
122
|
-
converted_messages.append(
|
|
123
|
-
{
|
|
124
|
-
"role": "user",
|
|
125
|
-
"content": [
|
|
126
|
-
{
|
|
127
|
-
"type": "tool_result",
|
|
128
|
-
"tool_use_id": message.get("call_id"),
|
|
129
|
-
"content": message.get("output", ""),
|
|
130
|
-
}
|
|
131
|
-
],
|
|
132
|
-
}
|
|
133
|
-
)
|
|
134
|
-
elif message.get("role") == "assistant" and "tool_calls" in message:
|
|
135
|
-
# Assistant message with tool calls - convert to Claude format
|
|
136
|
-
content = []
|
|
195
|
+
# Claude Files API only supports PDF and TXT files
|
|
196
|
+
CLAUDE_FILES_API_SUPPORTED_EXTENSIONS = {".pdf", ".txt"}
|
|
197
|
+
CLAUDE_FILES_API_SUPPORTED_MIME_TYPES = {
|
|
198
|
+
"application/pdf",
|
|
199
|
+
"text/plain",
|
|
200
|
+
"text/txt",
|
|
201
|
+
}
|
|
137
202
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
203
|
+
# Find all file_pending_upload markers
|
|
204
|
+
file_locations: List[Tuple[int, int]] = []
|
|
205
|
+
for msg_idx, message in enumerate(messages):
|
|
206
|
+
content = message.get("content")
|
|
207
|
+
if not isinstance(content, list):
|
|
208
|
+
continue
|
|
209
|
+
for item_idx, item in enumerate(content):
|
|
210
|
+
if isinstance(item, dict) and item.get("type") == "file_pending_upload":
|
|
211
|
+
file_locations.append((msg_idx, item_idx))
|
|
141
212
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
tool_name = self.extract_tool_name(tool_call)
|
|
145
|
-
tool_args = self.extract_tool_arguments(tool_call)
|
|
146
|
-
tool_id = self.extract_tool_call_id(tool_call)
|
|
213
|
+
if not file_locations:
|
|
214
|
+
return messages
|
|
147
215
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
216
|
+
httpx_client = None
|
|
217
|
+
try:
|
|
218
|
+
httpx_client = httpx.AsyncClient()
|
|
219
|
+
|
|
220
|
+
# Track uploaded file IDs, skipped files, failed uploads, and their corresponding locations
|
|
221
|
+
uploaded_files: List[Tuple[int, int, str]] = [] # (msg_idx, item_idx, file_id)
|
|
222
|
+
skipped_files: List[Tuple[int, int, str, str]] = [] # (msg_idx, item_idx, filename, reason)
|
|
223
|
+
failed_uploads: List[Tuple[int, int, str, str]] = [] # (msg_idx, item_idx, filename, reason)
|
|
224
|
+
|
|
225
|
+
for msg_idx, item_idx in file_locations:
|
|
226
|
+
marker = messages[msg_idx]["content"][item_idx]
|
|
227
|
+
source = marker.get("source")
|
|
228
|
+
file_path = marker.get("path")
|
|
229
|
+
url = marker.get("url")
|
|
230
|
+
mime_type = marker.get("mime_type", "application/octet-stream")
|
|
231
|
+
filename_hint = marker.get("filename") or marker.get("name")
|
|
232
|
+
|
|
233
|
+
# Validate file extension and MIME type for Claude Files API
|
|
234
|
+
file_ext = None
|
|
235
|
+
filename = None
|
|
236
|
+
|
|
237
|
+
if source == "local" and file_path:
|
|
238
|
+
file_ext = Path(file_path).suffix.lower()
|
|
239
|
+
filename = Path(file_path).name
|
|
240
|
+
# Re-validate MIME type using mimetypes module for accuracy
|
|
241
|
+
guessed_mime, _ = mimetypes.guess_type(file_path)
|
|
242
|
+
if guessed_mime:
|
|
243
|
+
mime_type = guessed_mime
|
|
244
|
+
elif source == "url" and url:
|
|
245
|
+
# Extract extension from URL (strip query parameters and fragments)
|
|
246
|
+
url_path = url.split("?")[0].split("#")[0]
|
|
247
|
+
file_ext = Path(url_path).suffix.lower()
|
|
248
|
+
filename = Path(url_path).name or url
|
|
249
|
+
if not filename_hint:
|
|
250
|
+
filename_hint = filename
|
|
251
|
+
# Re-validate MIME type using mimetypes module
|
|
252
|
+
guessed_mime, _ = mimetypes.guess_type(url_path)
|
|
253
|
+
if guessed_mime:
|
|
254
|
+
mime_type = guessed_mime
|
|
255
|
+
|
|
256
|
+
# Check if file type is supported (both extension and MIME type)
|
|
257
|
+
is_supported = False
|
|
258
|
+
skip_reason = None
|
|
259
|
+
|
|
260
|
+
if file_ext and file_ext.lower() in CLAUDE_FILES_API_SUPPORTED_EXTENSIONS:
|
|
261
|
+
# Extension is supported, now check MIME type
|
|
262
|
+
if mime_type and mime_type.lower() in CLAUDE_FILES_API_SUPPORTED_MIME_TYPES:
|
|
263
|
+
is_supported = True
|
|
264
|
+
else:
|
|
265
|
+
skip_reason = f"MIME type '{mime_type}' not supported (extension {file_ext} is valid)"
|
|
266
|
+
else:
|
|
267
|
+
skip_reason = f"File extension '{file_ext or 'unknown'}' not supported"
|
|
268
|
+
|
|
269
|
+
# If file is not supported, skip it gracefully and log warning
|
|
270
|
+
if not is_supported:
|
|
271
|
+
logger.warning(
|
|
272
|
+
f"[Agent {agent_id or 'default'}] Skipping unsupported file for Claude Files API: "
|
|
273
|
+
f"{filename or file_path or url} - {skip_reason}. "
|
|
274
|
+
f"Only PDF and TXT files are supported.",
|
|
155
275
|
)
|
|
276
|
+
skipped_files.append((msg_idx, item_idx, filename or file_path or url or "unknown", skip_reason))
|
|
277
|
+
continue
|
|
156
278
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
279
|
+
try:
|
|
280
|
+
if source == "local" and file_path:
|
|
281
|
+
# Upload local file
|
|
282
|
+
path_obj = Path(file_path)
|
|
283
|
+
filename = path_obj.name
|
|
284
|
+
with open(file_path, "rb") as f:
|
|
285
|
+
file_bytes = f.read()
|
|
286
|
+
|
|
287
|
+
uploaded_file = await client.beta.files.upload(
|
|
288
|
+
file=(filename, file_bytes, mime_type),
|
|
289
|
+
)
|
|
290
|
+
file_id = getattr(uploaded_file, "id", None)
|
|
291
|
+
if file_id:
|
|
292
|
+
self._uploaded_file_ids.append(file_id)
|
|
293
|
+
uploaded_files.append((msg_idx, item_idx, file_id))
|
|
294
|
+
logger.info(
|
|
295
|
+
f"[Agent {agent_id or 'default'}] Uploaded local file via Files API: {filename} -> {file_id}",
|
|
296
|
+
)
|
|
297
|
+
else:
|
|
298
|
+
failure_reason = "Claude Files API response missing file_id"
|
|
299
|
+
failed_uploads.append(
|
|
300
|
+
(
|
|
301
|
+
msg_idx,
|
|
302
|
+
item_idx,
|
|
303
|
+
filename or filename_hint or file_path or "unknown",
|
|
304
|
+
failure_reason,
|
|
305
|
+
),
|
|
306
|
+
)
|
|
307
|
+
logger.warning(
|
|
308
|
+
f"[Agent {agent_id or 'default'}] Failed to upload file via Files API: {failure_reason}",
|
|
309
|
+
)
|
|
165
310
|
|
|
166
|
-
|
|
311
|
+
elif source == "url" and url:
|
|
312
|
+
# Download and upload URL file
|
|
313
|
+
response = await httpx_client.get(url, timeout=30.0)
|
|
314
|
+
response.raise_for_status()
|
|
315
|
+
|
|
316
|
+
# Enforce Claude Files API 500 MB size limit
|
|
317
|
+
max_size_bytes = 500 * 1024 * 1024 # 500 MB
|
|
318
|
+
content_length = response.headers.get("Content-Length")
|
|
319
|
+
if content_length:
|
|
320
|
+
file_size = int(content_length)
|
|
321
|
+
if file_size > max_size_bytes:
|
|
322
|
+
raise UploadFileError(
|
|
323
|
+
f"File size {file_size / (1024 * 1024):.2f} MB exceeds Claude Files API limit of 500 MB",
|
|
324
|
+
)
|
|
167
325
|
|
|
168
|
-
|
|
169
|
-
self, messages: List[Dict[str, Any]], tools: List[Dict[str, Any]], **kwargs
|
|
170
|
-
) -> AsyncGenerator[StreamChunk, None]:
|
|
171
|
-
"""Stream response using Claude's Messages API with full multi-tool support."""
|
|
172
|
-
try:
|
|
173
|
-
import anthropic
|
|
174
|
-
|
|
175
|
-
# Initialize client
|
|
176
|
-
client = anthropic.AsyncAnthropic(api_key=self.api_key)
|
|
177
|
-
|
|
178
|
-
# Extract parameters
|
|
179
|
-
model = kwargs.get(
|
|
180
|
-
"model", "claude-3-5-haiku-latest"
|
|
181
|
-
) # Use model that supports code execution
|
|
182
|
-
max_tokens = kwargs.get("max_tokens", 8192)
|
|
183
|
-
temperature = kwargs.get("temperature", None)
|
|
184
|
-
enable_web_search = kwargs.get("enable_web_search", False)
|
|
185
|
-
enable_code_execution = kwargs.get("enable_code_execution", False)
|
|
186
|
-
|
|
187
|
-
# Convert messages to Claude format and extract system message
|
|
188
|
-
converted_messages, system_message = self.convert_messages_to_claude_format(
|
|
189
|
-
messages
|
|
190
|
-
)
|
|
326
|
+
file_bytes = response.content
|
|
191
327
|
|
|
192
|
-
|
|
193
|
-
|
|
328
|
+
# Cap bytes read if Content-Length was missing
|
|
329
|
+
if len(file_bytes) > max_size_bytes:
|
|
330
|
+
raise UploadFileError(
|
|
331
|
+
f"Downloaded file size {len(file_bytes) / (1024 * 1024):.2f} MB exceeds Claude Files API limit of 500 MB",
|
|
332
|
+
)
|
|
194
333
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
334
|
+
filename = url.split("/")[-1] or "document"
|
|
335
|
+
|
|
336
|
+
uploaded_file = await client.beta.files.upload(
|
|
337
|
+
file=(filename, file_bytes, mime_type),
|
|
338
|
+
)
|
|
339
|
+
file_id = getattr(uploaded_file, "id", None)
|
|
340
|
+
if file_id:
|
|
341
|
+
self._uploaded_file_ids.append(file_id)
|
|
342
|
+
uploaded_files.append((msg_idx, item_idx, file_id))
|
|
343
|
+
logger.info(
|
|
344
|
+
f"[Agent {agent_id or 'default'}] Uploaded URL file via Files API: {url} -> {file_id}",
|
|
345
|
+
)
|
|
346
|
+
else:
|
|
347
|
+
failure_reason = "Claude Files API response missing file_id"
|
|
348
|
+
failed_uploads.append(
|
|
349
|
+
(
|
|
350
|
+
msg_idx,
|
|
351
|
+
item_idx,
|
|
352
|
+
filename or filename_hint or url or "unknown",
|
|
353
|
+
failure_reason,
|
|
354
|
+
),
|
|
355
|
+
)
|
|
356
|
+
logger.warning(
|
|
357
|
+
f"[Agent {agent_id or 'default'}] Failed to upload file via Files API: {failure_reason}",
|
|
358
|
+
)
|
|
200
359
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
360
|
+
except Exception as upload_error:
|
|
361
|
+
logger.warning(
|
|
362
|
+
f"[Agent {agent_id or 'default'}] Failed to upload file via Files API: {upload_error}",
|
|
363
|
+
)
|
|
364
|
+
failure_context = filename or filename_hint or file_path or url or "unknown"
|
|
365
|
+
failed_uploads.append((msg_idx, item_idx, failure_context, str(upload_error)))
|
|
366
|
+
continue
|
|
205
367
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
368
|
+
except Exception as e:
|
|
369
|
+
logger.warning(f"[Agent {agent_id or 'default'}] Files API upload error: {e}")
|
|
370
|
+
raise UploadFileError(f"Files API upload failed: {e}") from e
|
|
371
|
+
finally:
|
|
372
|
+
if httpx_client:
|
|
373
|
+
await httpx_client.aclose()
|
|
374
|
+
|
|
375
|
+
# Clone messages and replace markers with document blocks or text notes
|
|
376
|
+
updated_messages = [msg.copy() for msg in messages]
|
|
377
|
+
|
|
378
|
+
# Replace successfully uploaded files with document blocks
|
|
379
|
+
for msg_idx, item_idx, file_id in reversed(uploaded_files):
|
|
380
|
+
content = updated_messages[msg_idx]["content"]
|
|
381
|
+
if isinstance(content, list):
|
|
382
|
+
# Create document block
|
|
383
|
+
document_block = {
|
|
384
|
+
"type": "document",
|
|
385
|
+
"source": {
|
|
386
|
+
"type": "file",
|
|
387
|
+
"file_id": file_id,
|
|
388
|
+
},
|
|
389
|
+
}
|
|
390
|
+
# Replace marker with document block
|
|
391
|
+
new_content = content[:item_idx] + [document_block] + content[item_idx + 1 :]
|
|
392
|
+
updated_messages[msg_idx]["content"] = new_content
|
|
393
|
+
|
|
394
|
+
# Replace skipped files with informative text notes
|
|
395
|
+
for msg_idx, item_idx, filename, reason in reversed(skipped_files):
|
|
396
|
+
content = updated_messages[msg_idx]["content"]
|
|
397
|
+
if isinstance(content, list):
|
|
398
|
+
# Create text note explaining the limitation
|
|
399
|
+
text_note = {
|
|
400
|
+
"type": "text",
|
|
401
|
+
"text": (f"\n[Note: File '{filename}' was not uploaded to Claude Files API. " f"Reason: {reason}. " f"Claude Files API only supports PDF and TXT files.]\n"),
|
|
402
|
+
}
|
|
403
|
+
# Replace marker with text note
|
|
404
|
+
new_content = content[:item_idx] + [text_note] + content[item_idx + 1 :]
|
|
405
|
+
updated_messages[msg_idx]["content"] = new_content
|
|
406
|
+
|
|
407
|
+
# Replace failed uploads with informative text notes
|
|
408
|
+
for msg_idx, item_idx, filename, reason in reversed(failed_uploads):
|
|
409
|
+
content = updated_messages[msg_idx]["content"]
|
|
410
|
+
if isinstance(content, list):
|
|
411
|
+
text_note = {
|
|
412
|
+
"type": "text",
|
|
413
|
+
"text": (f"\n[Note: File '{filename}' failed to upload to Claude Files API. " f"Reason: {reason}.]\n"),
|
|
414
|
+
}
|
|
415
|
+
new_content = content[:item_idx] + [text_note] + content[item_idx + 1 :]
|
|
416
|
+
updated_messages[msg_idx]["content"] = new_content
|
|
218
417
|
|
|
219
|
-
|
|
220
|
-
|
|
418
|
+
# Final sweep to ensure all file_pending_upload markers were replaced
|
|
419
|
+
self._ensure_no_pending_upload_markers(updated_messages)
|
|
221
420
|
|
|
222
|
-
|
|
223
|
-
api_params["temperature"] = temperature
|
|
421
|
+
return updated_messages
|
|
224
422
|
|
|
225
|
-
|
|
226
|
-
|
|
423
|
+
async def _cleanup_files_api_resources(self, **kwargs) -> None:
|
|
424
|
+
"""Clean up uploaded files via Files API."""
|
|
425
|
+
if not self._uploaded_file_ids:
|
|
426
|
+
return
|
|
227
427
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
stream = await client.beta.messages.create(**api_params)
|
|
233
|
-
else:
|
|
234
|
-
# Regular client for non-code-execution requests
|
|
235
|
-
stream = await client.messages.create(**api_params)
|
|
428
|
+
agent_id = kwargs.get("agent_id")
|
|
429
|
+
logger.info(
|
|
430
|
+
f"[Agent {agent_id or 'default'}] Cleaning up {len(self._uploaded_file_ids)} Files API resources...",
|
|
431
|
+
)
|
|
236
432
|
|
|
237
|
-
|
|
238
|
-
|
|
433
|
+
client = None
|
|
434
|
+
try:
|
|
435
|
+
client = self._create_client(**kwargs)
|
|
239
436
|
|
|
240
|
-
|
|
437
|
+
for file_id in self._uploaded_file_ids:
|
|
241
438
|
try:
|
|
242
|
-
|
|
243
|
-
|
|
439
|
+
await client.beta.files.delete(file_id)
|
|
440
|
+
logger.debug(f"[Agent {agent_id or 'default'}] Deleted Files API file: {file_id}")
|
|
441
|
+
except Exception as delete_error:
|
|
442
|
+
logger.warning(
|
|
443
|
+
f"[Agent {agent_id or 'default'}] Failed to delete Files API file {file_id}: {delete_error}",
|
|
444
|
+
)
|
|
445
|
+
continue
|
|
446
|
+
|
|
447
|
+
self._uploaded_file_ids.clear()
|
|
448
|
+
logger.info(f"[Agent {agent_id or 'default'}] Files API cleanup completed")
|
|
449
|
+
|
|
450
|
+
except Exception as e:
|
|
451
|
+
logger.warning(f"[Agent {agent_id or 'default'}] Files API cleanup error: {e}")
|
|
452
|
+
finally:
|
|
453
|
+
if client and hasattr(client, "aclose"):
|
|
454
|
+
await client.aclose()
|
|
455
|
+
|
|
456
|
+
def _ensure_no_pending_upload_markers(self, messages: List[Dict[str, Any]]) -> None:
|
|
457
|
+
"""Raise UploadFileError if any file_pending_upload markers remain."""
|
|
458
|
+
if not messages:
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
for msg_idx, message in enumerate(messages):
|
|
462
|
+
content = message.get("content")
|
|
463
|
+
if not isinstance(content, list):
|
|
464
|
+
continue
|
|
465
|
+
for item_idx, item in enumerate(content):
|
|
466
|
+
if isinstance(item, dict) and item.get("type") == "file_pending_upload":
|
|
467
|
+
identifier = item.get("filename") or item.get("name") or item.get("path") or item.get("url") or "unknown"
|
|
468
|
+
raise UploadFileError(
|
|
469
|
+
"Claude Files API upload left unresolved file_pending_upload marker " f"(message {msg_idx}, item {item_idx}, source {identifier}).",
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
async def _stream_without_mcp_tools(
|
|
473
|
+
self,
|
|
474
|
+
messages: List[Dict[str, Any]],
|
|
475
|
+
tools: List[Dict[str, Any]],
|
|
476
|
+
client,
|
|
477
|
+
**kwargs,
|
|
478
|
+
) -> AsyncGenerator[StreamChunk, None]:
|
|
479
|
+
"""Override to integrate Files API uploads into non-MCP streaming."""
|
|
480
|
+
agent_id = kwargs.get("agent_id", None)
|
|
481
|
+
all_params = {**self.config, **kwargs}
|
|
482
|
+
processed_messages = await self._process_upload_files(messages, all_params)
|
|
483
|
+
|
|
484
|
+
# Check if we need to upload files via Files API
|
|
485
|
+
if all_params.get("_has_file_search_files"):
|
|
486
|
+
logger.info("Processing Files API uploads...")
|
|
487
|
+
processed_messages = await self._upload_files_via_files_api(processed_messages, client, agent_id)
|
|
488
|
+
all_params["_has_files_api_files"] = True
|
|
489
|
+
all_params.pop("_has_file_search_files", None)
|
|
490
|
+
|
|
491
|
+
self._ensure_no_pending_upload_markers(processed_messages)
|
|
492
|
+
|
|
493
|
+
api_params = await self.api_params_handler.build_api_params(processed_messages, tools, all_params)
|
|
494
|
+
|
|
495
|
+
# Remove any MCP tools from the tools list
|
|
496
|
+
if "tools" in api_params:
|
|
497
|
+
non_mcp_tools = []
|
|
498
|
+
for tool in api_params.get("tools", []):
|
|
499
|
+
# Check different formats for MCP tools
|
|
500
|
+
if tool.get("type") == "function":
|
|
501
|
+
name = tool.get("function", {}).get("name") if "function" in tool else tool.get("name")
|
|
502
|
+
if name and name in self._mcp_function_names:
|
|
244
503
|
continue
|
|
504
|
+
elif tool.get("type") == "mcp":
|
|
505
|
+
continue
|
|
506
|
+
non_mcp_tools.append(tool)
|
|
507
|
+
if non_mcp_tools:
|
|
508
|
+
api_params["tools"] = non_mcp_tools
|
|
509
|
+
else:
|
|
510
|
+
api_params.pop("tools", None)
|
|
511
|
+
|
|
512
|
+
# Create stream (handle betas)
|
|
513
|
+
if "betas" in api_params:
|
|
514
|
+
stream = await client.beta.messages.create(**api_params)
|
|
515
|
+
else:
|
|
516
|
+
stream = await client.messages.create(**api_params)
|
|
517
|
+
|
|
518
|
+
# Process stream chunks
|
|
519
|
+
async for chunk in self._process_stream(stream, all_params, agent_id):
|
|
520
|
+
yield chunk
|
|
521
|
+
|
|
522
|
+
async def _stream_with_mcp_tools(
|
|
523
|
+
self,
|
|
524
|
+
current_messages: List[Dict[str, Any]],
|
|
525
|
+
tools: List[Dict[str, Any]],
|
|
526
|
+
client,
|
|
527
|
+
**kwargs,
|
|
528
|
+
) -> AsyncGenerator[StreamChunk, None]:
|
|
529
|
+
"""Recursively stream responses, executing MCP function calls when detected."""
|
|
530
|
+
|
|
531
|
+
# Build API params for this iteration
|
|
532
|
+
all_params = {**self.config, **kwargs}
|
|
533
|
+
|
|
534
|
+
# Check if we need to upload files via Files API
|
|
535
|
+
if all_params.get("_has_file_search_files"):
|
|
536
|
+
logger.info("Processing Files API uploads in MCP mode...")
|
|
537
|
+
agent_id = kwargs.get("agent_id")
|
|
538
|
+
current_messages = await self._upload_files_via_files_api(current_messages, client, agent_id)
|
|
539
|
+
all_params["_has_files_api_files"] = True
|
|
540
|
+
all_params.pop("_has_file_search_files", None)
|
|
541
|
+
|
|
542
|
+
self._ensure_no_pending_upload_markers(current_messages)
|
|
245
543
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
544
|
+
api_params = await self.api_params_handler.build_api_params(current_messages, tools, all_params)
|
|
545
|
+
|
|
546
|
+
agent_id = kwargs.get("agent_id", None)
|
|
547
|
+
|
|
548
|
+
# Create stream (handle code execution beta)
|
|
549
|
+
if "betas" in api_params:
|
|
550
|
+
stream = await client.beta.messages.create(**api_params)
|
|
551
|
+
else:
|
|
552
|
+
stream = await client.messages.create(**api_params)
|
|
553
|
+
|
|
554
|
+
content = ""
|
|
555
|
+
current_tool_uses: Dict[str, Dict[str, Any]] = {}
|
|
556
|
+
mcp_tool_calls: List[Dict[str, Any]] = []
|
|
557
|
+
response_completed = False
|
|
558
|
+
|
|
559
|
+
async for event in stream:
|
|
560
|
+
try:
|
|
561
|
+
if event.type == "message_start":
|
|
562
|
+
continue
|
|
563
|
+
elif event.type == "content_block_start":
|
|
564
|
+
if hasattr(event, "content_block"):
|
|
565
|
+
if event.content_block.type == "tool_use":
|
|
566
|
+
tool_id = event.content_block.id
|
|
567
|
+
tool_name = event.content_block.name
|
|
568
|
+
current_tool_uses[tool_id] = {
|
|
569
|
+
"id": tool_id,
|
|
570
|
+
"name": tool_name,
|
|
571
|
+
"input": "",
|
|
572
|
+
"index": getattr(event, "index", None),
|
|
573
|
+
}
|
|
574
|
+
elif event.content_block.type == "server_tool_use":
|
|
575
|
+
tool_id = event.content_block.id
|
|
576
|
+
tool_name = event.content_block.name
|
|
577
|
+
current_tool_uses[tool_id] = {
|
|
578
|
+
"id": tool_id,
|
|
579
|
+
"name": tool_name,
|
|
580
|
+
"input": "",
|
|
581
|
+
"index": getattr(event, "index", None),
|
|
582
|
+
"server_side": True,
|
|
583
|
+
}
|
|
584
|
+
if tool_name == "code_execution":
|
|
585
|
+
yield StreamChunk(
|
|
586
|
+
type="content",
|
|
587
|
+
content="\n💻 [Code Execution] Starting...\n",
|
|
588
|
+
)
|
|
589
|
+
elif tool_name == "web_search":
|
|
590
|
+
yield StreamChunk(
|
|
591
|
+
type="content",
|
|
592
|
+
content="\n🔍 [Web Search] Starting search...\n",
|
|
593
|
+
)
|
|
594
|
+
elif event.content_block.type == "code_execution_tool_result":
|
|
595
|
+
result_block = event.content_block
|
|
596
|
+
result_parts = []
|
|
597
|
+
if hasattr(result_block, "stdout") and result_block.stdout:
|
|
598
|
+
result_parts.append(f"Output: {result_block.stdout.strip()}")
|
|
599
|
+
if hasattr(result_block, "stderr") and result_block.stderr:
|
|
600
|
+
result_parts.append(f"Error: {result_block.stderr.strip()}")
|
|
601
|
+
if hasattr(result_block, "return_code") and result_block.return_code != 0:
|
|
602
|
+
result_parts.append(f"Exit code: {result_block.return_code}")
|
|
603
|
+
if result_parts:
|
|
604
|
+
result_text = f"\n💻 [Code Execution Result]\n{chr(10).join(result_parts)}\n"
|
|
605
|
+
yield StreamChunk(type="content", content=result_text)
|
|
606
|
+
elif event.type == "content_block_delta":
|
|
607
|
+
if hasattr(event, "delta"):
|
|
608
|
+
if event.delta.type == "text_delta":
|
|
609
|
+
text_chunk = event.delta.text
|
|
610
|
+
content += text_chunk
|
|
611
|
+
log_backend_agent_message(
|
|
612
|
+
agent_id or "default",
|
|
613
|
+
"RECV",
|
|
614
|
+
{"content": text_chunk},
|
|
615
|
+
backend_name="claude",
|
|
616
|
+
)
|
|
617
|
+
log_stream_chunk("backend.claude", "content", text_chunk, agent_id)
|
|
618
|
+
yield StreamChunk(type="content", content=text_chunk)
|
|
619
|
+
elif event.delta.type == "input_json_delta":
|
|
620
|
+
if hasattr(event, "index"):
|
|
621
|
+
for tool_id, tool_data in current_tool_uses.items():
|
|
622
|
+
if tool_data.get("index") == event.index:
|
|
623
|
+
partial_json = getattr(event.delta, "partial_json", "")
|
|
624
|
+
tool_data["input"] += partial_json
|
|
625
|
+
break
|
|
626
|
+
elif event.type == "content_block_stop":
|
|
627
|
+
if hasattr(event, "index"):
|
|
628
|
+
for tool_id, tool_data in current_tool_uses.items():
|
|
629
|
+
if tool_data.get("index") == event.index and tool_data.get("server_side"):
|
|
630
|
+
tool_name = tool_data.get("name", "")
|
|
631
|
+
tool_input = tool_data.get("input", "")
|
|
632
|
+
try:
|
|
633
|
+
parsed_input = json.loads(tool_input) if tool_input else {}
|
|
634
|
+
except json.JSONDecodeError:
|
|
635
|
+
parsed_input = {"raw_input": tool_input}
|
|
272
636
|
if tool_name == "code_execution":
|
|
637
|
+
code = parsed_input.get("code", "")
|
|
638
|
+
if code:
|
|
639
|
+
yield StreamChunk(type="content", content=f"💻 [Code] {code}\n")
|
|
273
640
|
yield StreamChunk(
|
|
274
641
|
type="content",
|
|
275
|
-
content=
|
|
642
|
+
content="✅ [Code Execution] Completed\n",
|
|
276
643
|
)
|
|
277
644
|
elif tool_name == "web_search":
|
|
645
|
+
query = parsed_input.get("query", "")
|
|
646
|
+
if query:
|
|
647
|
+
yield StreamChunk(
|
|
648
|
+
type="content",
|
|
649
|
+
content=f"🔍 [Query] '{query}'\n",
|
|
650
|
+
)
|
|
278
651
|
yield StreamChunk(
|
|
279
652
|
type="content",
|
|
280
|
-
content=
|
|
281
|
-
)
|
|
282
|
-
elif (
|
|
283
|
-
event.content_block.type == "code_execution_tool_result"
|
|
284
|
-
):
|
|
285
|
-
# Code execution result - format properly
|
|
286
|
-
result_block = event.content_block
|
|
287
|
-
|
|
288
|
-
# Format execution result nicely
|
|
289
|
-
result_parts = []
|
|
290
|
-
if (
|
|
291
|
-
hasattr(result_block, "stdout")
|
|
292
|
-
and result_block.stdout
|
|
293
|
-
):
|
|
294
|
-
result_parts.append(
|
|
295
|
-
f"Output: {result_block.stdout.strip()}"
|
|
296
|
-
)
|
|
297
|
-
if (
|
|
298
|
-
hasattr(result_block, "stderr")
|
|
299
|
-
and result_block.stderr
|
|
300
|
-
):
|
|
301
|
-
result_parts.append(
|
|
302
|
-
f"Error: {result_block.stderr.strip()}"
|
|
303
|
-
)
|
|
304
|
-
if (
|
|
305
|
-
hasattr(result_block, "return_code")
|
|
306
|
-
and result_block.return_code != 0
|
|
307
|
-
):
|
|
308
|
-
result_parts.append(
|
|
309
|
-
f"Exit code: {result_block.return_code}"
|
|
653
|
+
content="✅ [Web Search] Completed\n",
|
|
310
654
|
)
|
|
655
|
+
tool_data["processed"] = True
|
|
656
|
+
break
|
|
657
|
+
elif event.type == "message_delta":
|
|
658
|
+
pass
|
|
659
|
+
elif event.type == "message_stop":
|
|
660
|
+
# Identify MCP and non-MCP tool calls among current_tool_uses
|
|
661
|
+
non_mcp_tool_calls = []
|
|
662
|
+
if current_tool_uses:
|
|
663
|
+
for tool_use in current_tool_uses.values():
|
|
664
|
+
tool_name = tool_use.get("name", "")
|
|
665
|
+
is_server_side = tool_use.get("server_side", False)
|
|
666
|
+
if is_server_side:
|
|
667
|
+
continue
|
|
668
|
+
# Parse accumulated JSON input for tool
|
|
669
|
+
tool_input = tool_use.get("input", "")
|
|
670
|
+
try:
|
|
671
|
+
parsed_input = json.loads(tool_input) if tool_input else {}
|
|
672
|
+
except json.JSONDecodeError:
|
|
673
|
+
parsed_input = {"raw_input": tool_input}
|
|
674
|
+
|
|
675
|
+
if self.is_mcp_tool_call(tool_name):
|
|
676
|
+
mcp_tool_calls.append(
|
|
677
|
+
{
|
|
678
|
+
"id": tool_use["id"],
|
|
679
|
+
"type": "function",
|
|
680
|
+
"function": {
|
|
681
|
+
"name": tool_name,
|
|
682
|
+
"arguments": parsed_input,
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
)
|
|
686
|
+
else:
|
|
687
|
+
non_mcp_tool_calls.append(
|
|
688
|
+
{
|
|
689
|
+
"id": tool_use["id"],
|
|
690
|
+
"type": "function",
|
|
691
|
+
"function": {
|
|
692
|
+
"name": tool_name,
|
|
693
|
+
"arguments": parsed_input,
|
|
694
|
+
},
|
|
695
|
+
},
|
|
696
|
+
)
|
|
697
|
+
# Emit non-MCP tool calls for the caller to execute
|
|
698
|
+
if non_mcp_tool_calls:
|
|
699
|
+
log_stream_chunk("backend.claude", "tool_calls", non_mcp_tool_calls, agent_id)
|
|
700
|
+
yield StreamChunk(type="tool_calls", tool_calls=non_mcp_tool_calls)
|
|
701
|
+
response_completed = True
|
|
702
|
+
break
|
|
703
|
+
except Exception as event_error:
|
|
704
|
+
error_msg = f"Event processing error: {event_error}"
|
|
705
|
+
log_stream_chunk("backend.claude", "error", error_msg, agent_id)
|
|
706
|
+
yield StreamChunk(type="error", error=error_msg)
|
|
707
|
+
continue
|
|
708
|
+
|
|
709
|
+
# If we captured MCP tool calls, execute them and recurse
|
|
710
|
+
if response_completed and mcp_tool_calls:
|
|
711
|
+
# Circuit breaker pre-execution check using base class method
|
|
712
|
+
if not await self._check_circuit_breaker_before_execution():
|
|
713
|
+
yield StreamChunk(
|
|
714
|
+
type="mcp_status",
|
|
715
|
+
status="mcp_blocked",
|
|
716
|
+
content="⚠️ [MCP] All servers blocked by circuit breaker",
|
|
717
|
+
source="circuit_breaker",
|
|
718
|
+
)
|
|
719
|
+
yield StreamChunk(type="done")
|
|
720
|
+
return
|
|
311
721
|
|
|
312
|
-
|
|
313
|
-
result_text = f"\n💻 [Code Execution Result]\n{chr(10).join(result_parts)}\n"
|
|
314
|
-
yield StreamChunk(
|
|
315
|
-
type="content", content=result_text
|
|
316
|
-
)
|
|
722
|
+
updated_messages = current_messages.copy()
|
|
317
723
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
# Text content
|
|
323
|
-
text_chunk = event.delta.text
|
|
324
|
-
content += text_chunk
|
|
325
|
-
yield StreamChunk(type="content", content=text_chunk)
|
|
326
|
-
|
|
327
|
-
elif event.delta.type == "input_json_delta":
|
|
328
|
-
# Tool input streaming - accumulate JSON fragments
|
|
329
|
-
if hasattr(event, "index"):
|
|
330
|
-
# Find tool by index
|
|
331
|
-
for tool_id, tool_data in current_tool_uses.items():
|
|
332
|
-
if tool_data.get("index") == event.index:
|
|
333
|
-
# Accumulate partial JSON
|
|
334
|
-
partial_json = getattr(
|
|
335
|
-
event.delta, "partial_json", ""
|
|
336
|
-
)
|
|
337
|
-
tool_data["input"] += partial_json
|
|
338
|
-
break
|
|
339
|
-
|
|
340
|
-
elif event.type == "content_block_stop":
|
|
341
|
-
# Content block completed - check if it was a server-side tool
|
|
342
|
-
if hasattr(event, "index"):
|
|
343
|
-
# Find the tool that just completed
|
|
344
|
-
for tool_id, tool_data in current_tool_uses.items():
|
|
345
|
-
if tool_data.get(
|
|
346
|
-
"index"
|
|
347
|
-
) == event.index and tool_data.get("server_side"):
|
|
348
|
-
tool_name = tool_data.get("name", "")
|
|
349
|
-
|
|
350
|
-
# Parse the accumulated input to show what was executed
|
|
351
|
-
tool_input = tool_data.get("input", "")
|
|
352
|
-
try:
|
|
353
|
-
if tool_input:
|
|
354
|
-
parsed_input = json.loads(tool_input)
|
|
355
|
-
else:
|
|
356
|
-
parsed_input = {}
|
|
357
|
-
except json.JSONDecodeError:
|
|
358
|
-
parsed_input = {"raw_input": tool_input}
|
|
359
|
-
|
|
360
|
-
if tool_name == "code_execution":
|
|
361
|
-
code = parsed_input.get("code", "")
|
|
362
|
-
if code:
|
|
363
|
-
yield StreamChunk(
|
|
364
|
-
type="content",
|
|
365
|
-
content=f"💻 [Code] {code}\n",
|
|
366
|
-
)
|
|
367
|
-
yield StreamChunk(
|
|
368
|
-
type="content",
|
|
369
|
-
content=f"✅ [Code Execution] Completed\n",
|
|
370
|
-
)
|
|
724
|
+
# Build assistant message with tool_use blocks for all MCP tool calls
|
|
725
|
+
assistant_content = []
|
|
726
|
+
if content: # Add text content if any
|
|
727
|
+
assistant_content.append({"type": "text", "text": content})
|
|
371
728
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
"status": "completed",
|
|
377
|
-
"code": code,
|
|
378
|
-
"input": parsed_input,
|
|
379
|
-
}
|
|
380
|
-
yield StreamChunk(
|
|
381
|
-
type="builtin_tool_results",
|
|
382
|
-
builtin_tool_results=[builtin_result],
|
|
383
|
-
)
|
|
729
|
+
for tool_call in mcp_tool_calls:
|
|
730
|
+
tool_name = tool_call["function"]["name"]
|
|
731
|
+
tool_args = tool_call["function"]["arguments"]
|
|
732
|
+
tool_id = tool_call["id"]
|
|
384
733
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
type="content",
|
|
394
|
-
content=f"✅ [Web Search] Completed\n",
|
|
395
|
-
)
|
|
734
|
+
assistant_content.append(
|
|
735
|
+
{
|
|
736
|
+
"type": "tool_use",
|
|
737
|
+
"id": tool_id,
|
|
738
|
+
"name": tool_name,
|
|
739
|
+
"input": tool_args,
|
|
740
|
+
},
|
|
741
|
+
)
|
|
396
742
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
"id": tool_id,
|
|
400
|
-
"tool_type": "web_search",
|
|
401
|
-
"status": "completed",
|
|
402
|
-
"query": query,
|
|
403
|
-
"input": parsed_input,
|
|
404
|
-
}
|
|
405
|
-
yield StreamChunk(
|
|
406
|
-
type="builtin_tool_results",
|
|
407
|
-
builtin_tool_results=[builtin_result],
|
|
408
|
-
)
|
|
743
|
+
# Append the assistant message with tool uses
|
|
744
|
+
updated_messages.append({"role": "assistant", "content": assistant_content})
|
|
409
745
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
746
|
+
# Now execute the MCP tool calls and append results
|
|
747
|
+
for tool_call in mcp_tool_calls:
|
|
748
|
+
function_name = tool_call["function"]["name"]
|
|
413
749
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
750
|
+
# Yield MCP tool call status
|
|
751
|
+
yield StreamChunk(
|
|
752
|
+
type="mcp_status",
|
|
753
|
+
status="mcp_tool_called",
|
|
754
|
+
content=f"🔧 [MCP Tool] Calling {function_name}...",
|
|
755
|
+
source=f"mcp_{function_name}",
|
|
756
|
+
)
|
|
419
757
|
|
|
420
|
-
|
|
421
|
-
|
|
758
|
+
try:
|
|
759
|
+
# Execute MCP function
|
|
760
|
+
args_json = json.dumps(tool_call["function"]["arguments"]) if isinstance(tool_call["function"].get("arguments"), (dict, list)) else tool_call["function"].get("arguments", "{}")
|
|
761
|
+
result_list = await self._execute_mcp_function_with_retry(function_name, args_json)
|
|
762
|
+
if not result_list or (isinstance(result_list[0], str) and result_list[0].startswith("Error:")):
|
|
763
|
+
logger.warning(f"MCP function {function_name} failed after retries: {result_list[0] if result_list else 'unknown error'}")
|
|
764
|
+
continue
|
|
765
|
+
result_str = result_list[0]
|
|
766
|
+
result_obj = result_list[1] if len(result_list) > 1 else None
|
|
767
|
+
except Exception as e:
|
|
768
|
+
logger.error(f"Unexpected error in MCP function execution: {e}")
|
|
769
|
+
continue
|
|
422
770
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
771
|
+
# Build tool result message: { "role":"user", "content":[{ "type":"tool_result", "tool_use_id": tool_call["id"], "content": result_str }] }
|
|
772
|
+
tool_result_msg = {
|
|
773
|
+
"role": "user",
|
|
774
|
+
"content": [
|
|
775
|
+
{
|
|
776
|
+
"type": "tool_result",
|
|
777
|
+
"tool_use_id": tool_call["id"],
|
|
778
|
+
"content": result_str,
|
|
779
|
+
},
|
|
780
|
+
],
|
|
781
|
+
}
|
|
428
782
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
is_server_side = tool_use.get("server_side", False)
|
|
783
|
+
# Append to updated_messages
|
|
784
|
+
updated_messages.append(tool_result_msg)
|
|
432
785
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
parsed_input = {}
|
|
440
|
-
except json.JSONDecodeError:
|
|
441
|
-
parsed_input = {"raw_input": tool_input}
|
|
786
|
+
yield StreamChunk(
|
|
787
|
+
type="mcp_status",
|
|
788
|
+
status="function_call",
|
|
789
|
+
content=f"Arguments for Calling {function_name}: {json.dumps(tool_call['function'].get('arguments', {}))}",
|
|
790
|
+
source=f"mcp_{function_name}",
|
|
791
|
+
)
|
|
442
792
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
builtin_tool_results.append(builtin_result)
|
|
468
|
-
else:
|
|
469
|
-
# User-defined tools that need external execution
|
|
470
|
-
user_tool_calls.append(
|
|
471
|
-
{
|
|
472
|
-
"id": tool_use["id"],
|
|
473
|
-
"type": "function",
|
|
474
|
-
"function": {
|
|
475
|
-
"name": tool_name,
|
|
476
|
-
"arguments": parsed_input,
|
|
477
|
-
},
|
|
478
|
-
}
|
|
479
|
-
)
|
|
793
|
+
# If result_obj might be structured, try to display summary
|
|
794
|
+
result_display = None
|
|
795
|
+
try:
|
|
796
|
+
if hasattr(result_obj, "content") and result_obj.content:
|
|
797
|
+
part = result_obj.content[0]
|
|
798
|
+
if hasattr(part, "text"):
|
|
799
|
+
result_display = str(part.text)
|
|
800
|
+
except Exception:
|
|
801
|
+
result_display = None
|
|
802
|
+
if result_display:
|
|
803
|
+
yield StreamChunk(
|
|
804
|
+
type="mcp_status",
|
|
805
|
+
status="function_call_output",
|
|
806
|
+
content=f"Results for Calling {function_name}: {result_display}",
|
|
807
|
+
source=f"mcp_{function_name}",
|
|
808
|
+
)
|
|
809
|
+
else:
|
|
810
|
+
yield StreamChunk(
|
|
811
|
+
type="mcp_status",
|
|
812
|
+
status="function_call_output",
|
|
813
|
+
content=f"Results for Calling {function_name}: {result_str}",
|
|
814
|
+
source=f"mcp_{function_name}",
|
|
815
|
+
)
|
|
480
816
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
817
|
+
logger.info(f"Executed MCP function {function_name} (stdio/streamable-http)")
|
|
818
|
+
yield StreamChunk(
|
|
819
|
+
type="mcp_status",
|
|
820
|
+
status="mcp_tool_response",
|
|
821
|
+
content=f"✅ [MCP Tool] {function_name} completed",
|
|
822
|
+
source=f"mcp_{function_name}",
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
# Trim updated_messages using base class method
|
|
826
|
+
updated_messages = self._trim_message_history(updated_messages)
|
|
489
827
|
|
|
490
|
-
|
|
828
|
+
# After processing all MCP calls, recurse: async for chunk in self._stream_mcp_recursive(updated_messages, tools, client, **kwargs): yield chunk
|
|
829
|
+
async for chunk in self._stream_with_mcp_tools(updated_messages, tools, client, **kwargs):
|
|
830
|
+
yield chunk
|
|
831
|
+
return
|
|
832
|
+
else:
|
|
833
|
+
# No MCP function calls; finalize this turn
|
|
834
|
+
# Ensure termination with a done chunk when no further tool calls
|
|
835
|
+
complete_message = {
|
|
836
|
+
"role": "assistant",
|
|
837
|
+
"content": content.strip(),
|
|
838
|
+
}
|
|
839
|
+
log_stream_chunk("backend.claude", "complete_message", complete_message, agent_id)
|
|
840
|
+
yield StreamChunk(type="complete_message", complete_message=complete_message)
|
|
841
|
+
yield StreamChunk(
|
|
842
|
+
type="mcp_status",
|
|
843
|
+
status="mcp_session_complete",
|
|
844
|
+
content="✅ [MCP] Session completed",
|
|
845
|
+
source="mcp_session",
|
|
846
|
+
)
|
|
847
|
+
yield StreamChunk(type="done")
|
|
848
|
+
return
|
|
849
|
+
|
|
850
|
+
async def _process_stream(
|
|
851
|
+
self,
|
|
852
|
+
stream,
|
|
853
|
+
all_params: Dict[str, Any],
|
|
854
|
+
agent_id: Optional[str],
|
|
855
|
+
) -> AsyncGenerator[StreamChunk, None]:
|
|
856
|
+
"""Process stream events and yield StreamChunks."""
|
|
857
|
+
content_local = ""
|
|
858
|
+
current_tool_uses_local: Dict[str, Dict[str, Any]] = {}
|
|
859
|
+
|
|
860
|
+
async for chunk in stream:
|
|
861
|
+
try:
|
|
862
|
+
if chunk.type == "message_start":
|
|
863
|
+
continue
|
|
864
|
+
elif chunk.type == "content_block_start":
|
|
865
|
+
if hasattr(chunk, "content_block"):
|
|
866
|
+
if chunk.content_block.type == "tool_use":
|
|
867
|
+
tool_id = chunk.content_block.id
|
|
868
|
+
tool_name = chunk.content_block.name
|
|
869
|
+
current_tool_uses_local[tool_id] = {
|
|
870
|
+
"id": tool_id,
|
|
871
|
+
"name": tool_name,
|
|
872
|
+
"input": "",
|
|
873
|
+
"index": getattr(chunk, "index", None),
|
|
874
|
+
}
|
|
875
|
+
elif chunk.content_block.type == "server_tool_use":
|
|
876
|
+
tool_id = chunk.content_block.id
|
|
877
|
+
tool_name = chunk.content_block.name
|
|
878
|
+
current_tool_uses_local[tool_id] = {
|
|
879
|
+
"id": tool_id,
|
|
880
|
+
"name": tool_name,
|
|
881
|
+
"input": "",
|
|
882
|
+
"index": getattr(chunk, "index", None),
|
|
883
|
+
"server_side": True,
|
|
884
|
+
}
|
|
885
|
+
if tool_name == "code_execution":
|
|
491
886
|
yield StreamChunk(
|
|
492
|
-
type="
|
|
493
|
-
|
|
887
|
+
type="content",
|
|
888
|
+
content="\n💻 [Code Execution] Starting...\n",
|
|
494
889
|
)
|
|
495
|
-
|
|
496
|
-
# Yield user tool calls if any
|
|
497
|
-
if user_tool_calls:
|
|
890
|
+
elif tool_name == "web_search":
|
|
498
891
|
yield StreamChunk(
|
|
499
|
-
type="
|
|
892
|
+
type="content",
|
|
893
|
+
content="\n🔍 [Web Search] Starting search...\n",
|
|
500
894
|
)
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
"
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
895
|
+
elif chunk.content_block.type == "code_execution_tool_result":
|
|
896
|
+
result_block = chunk.content_block
|
|
897
|
+
result_parts = []
|
|
898
|
+
if hasattr(result_block, "stdout") and result_block.stdout:
|
|
899
|
+
result_parts.append(f"Output: {result_block.stdout.strip()}")
|
|
900
|
+
if hasattr(result_block, "stderr") and result_block.stderr:
|
|
901
|
+
result_parts.append(f"Error: {result_block.stderr.strip()}")
|
|
902
|
+
if hasattr(result_block, "return_code") and result_block.return_code != 0:
|
|
903
|
+
result_parts.append(f"Exit code: {result_block.return_code}")
|
|
904
|
+
if result_parts:
|
|
905
|
+
result_text = f"\n💻 [Code Execution Result]\n{chr(10).join(result_parts)}\n"
|
|
906
|
+
yield StreamChunk(
|
|
907
|
+
type="content",
|
|
908
|
+
content=result_text,
|
|
909
|
+
)
|
|
910
|
+
elif chunk.type == "content_block_delta":
|
|
911
|
+
if hasattr(chunk, "delta"):
|
|
912
|
+
if chunk.delta.type == "text_delta":
|
|
913
|
+
text_chunk = chunk.delta.text
|
|
914
|
+
content_local += text_chunk
|
|
915
|
+
log_backend_agent_message(
|
|
916
|
+
agent_id or "default",
|
|
917
|
+
"RECV",
|
|
918
|
+
{"content": text_chunk},
|
|
919
|
+
backend_name="claude",
|
|
512
920
|
)
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
yield StreamChunk(
|
|
520
|
-
|
|
521
|
-
|
|
921
|
+
log_stream_chunk(
|
|
922
|
+
"backend.claude",
|
|
923
|
+
"content",
|
|
924
|
+
text_chunk,
|
|
925
|
+
agent_id,
|
|
926
|
+
)
|
|
927
|
+
yield StreamChunk(type="content", content=text_chunk)
|
|
928
|
+
elif chunk.delta.type == "input_json_delta":
|
|
929
|
+
if hasattr(chunk, "index"):
|
|
930
|
+
for (
|
|
931
|
+
tool_id,
|
|
932
|
+
tool_data,
|
|
933
|
+
) in current_tool_uses_local.items():
|
|
934
|
+
if tool_data.get("index") == chunk.index:
|
|
935
|
+
partial_json = getattr(
|
|
936
|
+
chunk.delta,
|
|
937
|
+
"partial_json",
|
|
938
|
+
"",
|
|
939
|
+
)
|
|
940
|
+
tool_data["input"] += partial_json
|
|
941
|
+
break
|
|
942
|
+
elif chunk.type == "content_block_stop":
|
|
943
|
+
if hasattr(chunk, "index"):
|
|
944
|
+
for (
|
|
945
|
+
tool_id,
|
|
946
|
+
tool_data,
|
|
947
|
+
) in current_tool_uses_local.items():
|
|
948
|
+
if tool_data.get("index") == chunk.index and tool_data.get("server_side"):
|
|
949
|
+
tool_name = tool_data.get("name", "")
|
|
950
|
+
tool_input = tool_data.get("input", "")
|
|
951
|
+
try:
|
|
952
|
+
parsed_input = json.loads(tool_input) if tool_input else {}
|
|
953
|
+
except json.JSONDecodeError:
|
|
954
|
+
parsed_input = {"raw_input": tool_input}
|
|
955
|
+
if tool_name == "code_execution":
|
|
956
|
+
code = parsed_input.get("code", "")
|
|
957
|
+
if code:
|
|
958
|
+
yield StreamChunk(
|
|
959
|
+
type="content",
|
|
960
|
+
content=f"💻 [Code] {code}\n",
|
|
961
|
+
)
|
|
962
|
+
yield StreamChunk(
|
|
963
|
+
type="content",
|
|
964
|
+
content="✅ [Code Execution] Completed\n",
|
|
965
|
+
)
|
|
966
|
+
elif tool_name == "web_search":
|
|
967
|
+
query = parsed_input.get("query", "")
|
|
968
|
+
if query:
|
|
969
|
+
yield StreamChunk(
|
|
970
|
+
type="content",
|
|
971
|
+
content=f"🔍 [Query] '{query}'\n",
|
|
972
|
+
)
|
|
973
|
+
yield StreamChunk(
|
|
974
|
+
type="content",
|
|
975
|
+
content="✅ [Web Search] Completed\n",
|
|
976
|
+
)
|
|
977
|
+
tool_data["processed"] = True
|
|
978
|
+
break
|
|
979
|
+
elif chunk.type == "message_delta":
|
|
980
|
+
pass
|
|
981
|
+
elif chunk.type == "message_stop":
|
|
982
|
+
# Build final response and yield tool_calls for user-defined non-MCP tools
|
|
983
|
+
user_tool_calls = []
|
|
984
|
+
for tool_use in current_tool_uses_local.values():
|
|
985
|
+
tool_name = tool_use.get("name", "")
|
|
986
|
+
is_server_side = tool_use.get("server_side", False)
|
|
987
|
+
if not is_server_side and tool_name not in ["web_search", "code_execution"]:
|
|
988
|
+
tool_input = tool_use.get("input", "")
|
|
989
|
+
try:
|
|
990
|
+
parsed_input = json.loads(tool_input) if tool_input else {}
|
|
991
|
+
except json.JSONDecodeError:
|
|
992
|
+
parsed_input = {"raw_input": tool_input}
|
|
993
|
+
user_tool_calls.append(
|
|
994
|
+
{
|
|
995
|
+
"id": tool_use["id"],
|
|
996
|
+
"type": "function",
|
|
997
|
+
"function": {
|
|
998
|
+
"name": tool_name,
|
|
999
|
+
"arguments": parsed_input,
|
|
1000
|
+
},
|
|
1001
|
+
},
|
|
522
1002
|
)
|
|
523
1003
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
yield StreamChunk(
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
1004
|
+
if user_tool_calls:
|
|
1005
|
+
log_stream_chunk(
|
|
1006
|
+
"backend.claude",
|
|
1007
|
+
"tool_calls",
|
|
1008
|
+
user_tool_calls,
|
|
1009
|
+
agent_id,
|
|
1010
|
+
)
|
|
1011
|
+
yield StreamChunk(
|
|
1012
|
+
type="tool_calls",
|
|
1013
|
+
tool_calls=user_tool_calls,
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
complete_message = {
|
|
1017
|
+
"role": "assistant",
|
|
1018
|
+
"content": content_local.strip(),
|
|
1019
|
+
}
|
|
1020
|
+
if user_tool_calls:
|
|
1021
|
+
complete_message["tool_calls"] = user_tool_calls
|
|
1022
|
+
log_stream_chunk(
|
|
1023
|
+
"backend.claude",
|
|
1024
|
+
"complete_message",
|
|
1025
|
+
complete_message,
|
|
1026
|
+
agent_id,
|
|
1027
|
+
)
|
|
535
1028
|
yield StreamChunk(
|
|
536
|
-
type="
|
|
1029
|
+
type="complete_message",
|
|
1030
|
+
complete_message=complete_message,
|
|
537
1031
|
)
|
|
538
|
-
continue
|
|
539
1032
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
1033
|
+
# Track usage for pricing
|
|
1034
|
+
if all_params.get("enable_web_search", False):
|
|
1035
|
+
self.search_count += 1
|
|
1036
|
+
if all_params.get("enable_code_execution", False):
|
|
1037
|
+
self.code_session_hours += 0.083
|
|
1038
|
+
|
|
1039
|
+
log_stream_chunk("backend.claude", "done", None, agent_id)
|
|
1040
|
+
yield StreamChunk(type="done")
|
|
1041
|
+
return
|
|
1042
|
+
except Exception as event_error:
|
|
1043
|
+
error_msg = f"Event processing error: {event_error}"
|
|
1044
|
+
log_stream_chunk("backend.claude", "error", error_msg, agent_id)
|
|
1045
|
+
yield StreamChunk(type="error", error=error_msg)
|
|
1046
|
+
continue
|
|
1047
|
+
|
|
1048
|
+
async def _handle_mcp_error_and_fallback(
|
|
1049
|
+
self,
|
|
1050
|
+
error: Exception,
|
|
1051
|
+
api_params: Dict[str, Any],
|
|
1052
|
+
provider_tools: List[Dict[str, Any]],
|
|
1053
|
+
stream_func: Callable[[Dict[str, Any]], AsyncGenerator[StreamChunk, None]],
|
|
1054
|
+
) -> AsyncGenerator[StreamChunk, None]:
|
|
1055
|
+
"""Handle MCP errors with user-friendly messaging and fallback to non-MCP tools."""
|
|
546
1056
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
1057
|
+
async with self._stats_lock:
|
|
1058
|
+
self._mcp_tool_failures += 1
|
|
1059
|
+
call_index_snapshot = self._mcp_tool_calls_count
|
|
550
1060
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
# Chat Completions format
|
|
554
|
-
if "function" in tool_call:
|
|
555
|
-
return tool_call.get("function", {}).get("name", "unknown")
|
|
556
|
-
# Claude native format
|
|
557
|
-
elif "name" in tool_call:
|
|
558
|
-
return tool_call.get("name", "unknown")
|
|
559
|
-
# Fallback
|
|
560
|
-
return "unknown"
|
|
561
|
-
|
|
562
|
-
def extract_tool_arguments(self, tool_call: Dict[str, Any]) -> Dict[str, Any]:
|
|
563
|
-
"""Extract tool arguments from tool call (handles multiple formats)."""
|
|
564
|
-
# Chat Completions format
|
|
565
|
-
if "function" in tool_call:
|
|
566
|
-
args = tool_call.get("function", {}).get("arguments", {})
|
|
567
|
-
# Claude native format
|
|
568
|
-
elif "input" in tool_call:
|
|
569
|
-
args = tool_call.get("input", {})
|
|
1061
|
+
if MCPErrorHandler:
|
|
1062
|
+
log_type, user_message, _ = MCPErrorHandler.get_error_details(error) # type: ignore[assignment]
|
|
570
1063
|
else:
|
|
571
|
-
|
|
1064
|
+
log_type, user_message = "mcp_error", "[MCP] Error occurred"
|
|
572
1065
|
|
|
573
|
-
#
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
1066
|
+
logger.warning(f"MCP tool call #{call_index_snapshot} failed - {log_type}: {error}")
|
|
1067
|
+
|
|
1068
|
+
yield StreamChunk(
|
|
1069
|
+
type="content",
|
|
1070
|
+
content=f"\n⚠️ {user_message} ({error}); continuing without MCP tools\n",
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
# Build non-MCP configuration and stream fallback
|
|
1074
|
+
fallback_params = dict(api_params)
|
|
1075
|
+
|
|
1076
|
+
# Remove any MCP tools from the tools list
|
|
1077
|
+
if "tools" in fallback_params and self._mcp_functions:
|
|
1078
|
+
mcp_names = set(self._mcp_functions.keys())
|
|
1079
|
+
non_mcp_tools = []
|
|
1080
|
+
for tool in fallback_params["tools"]:
|
|
1081
|
+
name = tool.get("name")
|
|
1082
|
+
if name in mcp_names:
|
|
1083
|
+
continue
|
|
1084
|
+
non_mcp_tools.append(tool)
|
|
1085
|
+
fallback_params["tools"] = non_mcp_tools
|
|
1086
|
+
|
|
1087
|
+
# Add back provider tools if they were present
|
|
1088
|
+
if provider_tools:
|
|
1089
|
+
if "tools" not in fallback_params:
|
|
1090
|
+
fallback_params["tools"] = []
|
|
1091
|
+
fallback_params["tools"].extend(provider_tools)
|
|
1092
|
+
|
|
1093
|
+
async for chunk in stream_func(fallback_params):
|
|
1094
|
+
yield chunk
|
|
1095
|
+
|
|
1096
|
+
async def _execute_mcp_function_with_retry(
|
|
1097
|
+
self,
|
|
1098
|
+
function_name: str,
|
|
1099
|
+
arguments_json: str,
|
|
1100
|
+
max_retries: int = 3,
|
|
1101
|
+
) -> List[str | Any]:
|
|
1102
|
+
"""Execute MCP function with Claude-specific formatting."""
|
|
1103
|
+
# Use parent class method which returns tuple
|
|
1104
|
+
result_str, result_obj = await super()._execute_mcp_function_with_retry(
|
|
1105
|
+
function_name,
|
|
1106
|
+
arguments_json,
|
|
1107
|
+
max_retries,
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
# Convert to list format expected by Claude streaming
|
|
1111
|
+
if result_str.startswith("Error:"):
|
|
1112
|
+
return [result_str]
|
|
1113
|
+
return [result_str, result_obj]
|
|
1114
|
+
|
|
1115
|
+
def create_tool_result_message(self, tool_call: Dict[str, Any], result_content: str) -> Dict[str, Any]:
|
|
588
1116
|
"""Create tool result message in Claude's expected format."""
|
|
589
1117
|
tool_call_id = self.extract_tool_call_id(tool_call)
|
|
590
1118
|
return {
|
|
@@ -594,7 +1122,7 @@ class ClaudeBackend(LLMBackend):
|
|
|
594
1122
|
"type": "tool_result",
|
|
595
1123
|
"tool_use_id": tool_call_id,
|
|
596
1124
|
"content": result_content,
|
|
597
|
-
}
|
|
1125
|
+
},
|
|
598
1126
|
],
|
|
599
1127
|
}
|
|
600
1128
|
|
|
@@ -607,55 +1135,23 @@ class ClaudeBackend(LLMBackend):
|
|
|
607
1135
|
return item.get("content", "")
|
|
608
1136
|
return ""
|
|
609
1137
|
|
|
610
|
-
def estimate_tokens(self, text: str) -> int:
|
|
611
|
-
"""Estimate token count for text (Claude uses ~4 chars per token)."""
|
|
612
|
-
return len(text) // 4
|
|
613
|
-
|
|
614
|
-
def calculate_cost(
|
|
615
|
-
self, input_tokens: int, output_tokens: int, model: str
|
|
616
|
-
) -> float:
|
|
617
|
-
"""Calculate cost for Claude token usage (2025 pricing)."""
|
|
618
|
-
model_lower = model.lower()
|
|
619
|
-
|
|
620
|
-
if "claude-4" in model_lower:
|
|
621
|
-
if "opus" in model_lower:
|
|
622
|
-
# Claude 4 Opus
|
|
623
|
-
input_cost = (input_tokens / 1_000_000) * 15.0
|
|
624
|
-
output_cost = (output_tokens / 1_000_000) * 75.0
|
|
625
|
-
else:
|
|
626
|
-
# Claude 4 Sonnet
|
|
627
|
-
input_cost = (input_tokens / 1_000_000) * 3.0
|
|
628
|
-
output_cost = (output_tokens / 1_000_000) * 15.0
|
|
629
|
-
elif "claude-3.7" in model_lower or "claude-3-7" in model_lower:
|
|
630
|
-
# Claude 3.7 Sonnet
|
|
631
|
-
input_cost = (input_tokens / 1_000_000) * 3.0
|
|
632
|
-
output_cost = (output_tokens / 1_000_000) * 15.0
|
|
633
|
-
elif "claude-3.5" in model_lower or "claude-3-5" in model_lower:
|
|
634
|
-
if "haiku" in model_lower:
|
|
635
|
-
# Claude 3.5 Haiku
|
|
636
|
-
input_cost = (input_tokens / 1_000_000) * 1.0
|
|
637
|
-
output_cost = (output_tokens / 1_000_000) * 5.0
|
|
638
|
-
else:
|
|
639
|
-
# Claude 3.5 Sonnet (legacy)
|
|
640
|
-
input_cost = (input_tokens / 1_000_000) * 3.0
|
|
641
|
-
output_cost = (output_tokens / 1_000_000) * 15.0
|
|
642
|
-
else:
|
|
643
|
-
# Default fallback (assume Claude 4 Sonnet pricing)
|
|
644
|
-
input_cost = (input_tokens / 1_000_000) * 3.0
|
|
645
|
-
output_cost = (output_tokens / 1_000_000) * 15.0
|
|
646
|
-
|
|
647
|
-
# Add tool usage costs
|
|
648
|
-
tool_costs = 0.0
|
|
649
|
-
if self.search_count > 0:
|
|
650
|
-
tool_costs += (self.search_count / 1000) * 10.0 # $10 per 1,000 searches
|
|
651
|
-
|
|
652
|
-
if self.code_session_hours > 0:
|
|
653
|
-
tool_costs += self.code_session_hours * 0.05 # $0.05 per session-hour
|
|
654
|
-
|
|
655
|
-
return input_cost + output_cost + tool_costs
|
|
656
|
-
|
|
657
1138
|
def reset_tool_usage(self):
|
|
658
1139
|
"""Reset tool usage tracking."""
|
|
659
1140
|
self.search_count = 0
|
|
660
1141
|
self.code_session_hours = 0.0
|
|
661
1142
|
super().reset_token_usage()
|
|
1143
|
+
|
|
1144
|
+
def _create_client(self, **kwargs):
|
|
1145
|
+
return anthropic.AsyncAnthropic(api_key=self.api_key)
|
|
1146
|
+
|
|
1147
|
+
def get_provider_name(self) -> str:
|
|
1148
|
+
"""Get the provider name."""
|
|
1149
|
+
return "Claude"
|
|
1150
|
+
|
|
1151
|
+
def get_supported_builtin_tools(self) -> List[str]:
|
|
1152
|
+
"""Get list of builtin tools supported by Claude."""
|
|
1153
|
+
return ["web_search", "code_execution"]
|
|
1154
|
+
|
|
1155
|
+
def get_filesystem_support(self) -> FilesystemSupport:
|
|
1156
|
+
"""Claude supports filesystem through MCP servers."""
|
|
1157
|
+
return FilesystemSupport.MCP
|