ripperdoc 0.2.2__py3-none-any.whl → 0.2.4__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 (61) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +9 -2
  3. ripperdoc/cli/commands/agents_cmd.py +8 -4
  4. ripperdoc/cli/commands/context_cmd.py +3 -3
  5. ripperdoc/cli/commands/cost_cmd.py +5 -0
  6. ripperdoc/cli/commands/doctor_cmd.py +12 -4
  7. ripperdoc/cli/commands/memory_cmd.py +6 -13
  8. ripperdoc/cli/commands/models_cmd.py +36 -6
  9. ripperdoc/cli/commands/resume_cmd.py +4 -2
  10. ripperdoc/cli/commands/status_cmd.py +1 -1
  11. ripperdoc/cli/ui/rich_ui.py +135 -2
  12. ripperdoc/cli/ui/thinking_spinner.py +128 -0
  13. ripperdoc/core/agents.py +174 -6
  14. ripperdoc/core/config.py +9 -1
  15. ripperdoc/core/default_tools.py +6 -0
  16. ripperdoc/core/providers/__init__.py +47 -0
  17. ripperdoc/core/providers/anthropic.py +147 -0
  18. ripperdoc/core/providers/base.py +236 -0
  19. ripperdoc/core/providers/gemini.py +496 -0
  20. ripperdoc/core/providers/openai.py +253 -0
  21. ripperdoc/core/query.py +337 -141
  22. ripperdoc/core/query_utils.py +65 -24
  23. ripperdoc/core/system_prompt.py +67 -61
  24. ripperdoc/core/tool.py +12 -3
  25. ripperdoc/sdk/client.py +12 -1
  26. ripperdoc/tools/ask_user_question_tool.py +433 -0
  27. ripperdoc/tools/background_shell.py +104 -18
  28. ripperdoc/tools/bash_tool.py +33 -13
  29. ripperdoc/tools/enter_plan_mode_tool.py +223 -0
  30. ripperdoc/tools/exit_plan_mode_tool.py +150 -0
  31. ripperdoc/tools/file_edit_tool.py +13 -0
  32. ripperdoc/tools/file_read_tool.py +16 -0
  33. ripperdoc/tools/file_write_tool.py +13 -0
  34. ripperdoc/tools/glob_tool.py +5 -1
  35. ripperdoc/tools/ls_tool.py +14 -10
  36. ripperdoc/tools/mcp_tools.py +113 -4
  37. ripperdoc/tools/multi_edit_tool.py +12 -0
  38. ripperdoc/tools/notebook_edit_tool.py +12 -0
  39. ripperdoc/tools/task_tool.py +88 -5
  40. ripperdoc/tools/todo_tool.py +1 -3
  41. ripperdoc/tools/tool_search_tool.py +8 -4
  42. ripperdoc/utils/file_watch.py +134 -0
  43. ripperdoc/utils/git_utils.py +36 -38
  44. ripperdoc/utils/json_utils.py +1 -2
  45. ripperdoc/utils/log.py +3 -4
  46. ripperdoc/utils/mcp.py +49 -10
  47. ripperdoc/utils/memory.py +1 -3
  48. ripperdoc/utils/message_compaction.py +5 -11
  49. ripperdoc/utils/messages.py +9 -13
  50. ripperdoc/utils/output_utils.py +1 -3
  51. ripperdoc/utils/prompt.py +17 -0
  52. ripperdoc/utils/session_usage.py +7 -0
  53. ripperdoc/utils/shell_utils.py +159 -0
  54. ripperdoc/utils/token_estimation.py +33 -0
  55. {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.4.dist-info}/METADATA +3 -1
  56. ripperdoc-0.2.4.dist-info/RECORD +99 -0
  57. ripperdoc-0.2.2.dist-info/RECORD +0 -86
  58. {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.4.dist-info}/WHEEL +0 -0
  59. {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.4.dist-info}/entry_points.txt +0 -0
  60. {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.4.dist-info}/licenses/LICENSE +0 -0
  61. {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.4.dist-info}/top_level.txt +0 -0
ripperdoc/core/agents.py CHANGED
@@ -11,11 +11,63 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple
11
11
  import yaml
12
12
 
13
13
  from ripperdoc.utils.log import get_logger
14
+ from ripperdoc.tools.ask_user_question_tool import AskUserQuestionTool
15
+ from ripperdoc.tools.bash_output_tool import BashOutputTool
16
+ from ripperdoc.tools.bash_tool import BashTool
17
+ from ripperdoc.tools.enter_plan_mode_tool import EnterPlanModeTool
18
+ from ripperdoc.tools.exit_plan_mode_tool import ExitPlanModeTool
19
+ from ripperdoc.tools.file_edit_tool import FileEditTool
20
+ from ripperdoc.tools.file_read_tool import FileReadTool
21
+ from ripperdoc.tools.file_write_tool import FileWriteTool
22
+ from ripperdoc.tools.glob_tool import GlobTool
23
+ from ripperdoc.tools.grep_tool import GrepTool
24
+ from ripperdoc.tools.kill_bash_tool import KillBashTool
25
+ from ripperdoc.tools.ls_tool import LSTool
26
+ from ripperdoc.tools.multi_edit_tool import MultiEditTool
27
+ from ripperdoc.tools.notebook_edit_tool import NotebookEditTool
28
+ from ripperdoc.tools.todo_tool import TodoReadTool, TodoWriteTool
29
+ from ripperdoc.tools.tool_search_tool import ToolSearchTool
30
+ from ripperdoc.tools.mcp_tools import (
31
+ ListMcpResourcesTool,
32
+ ListMcpServersTool,
33
+ ReadMcpResourceTool,
34
+ )
14
35
 
15
36
 
16
37
  logger = get_logger()
17
38
 
18
39
 
40
+ def _safe_tool_name(factory: Any, fallback: str) -> str:
41
+ try:
42
+ name = getattr(factory(), "name", None)
43
+ return str(name) if name else fallback
44
+ except Exception:
45
+ return fallback
46
+
47
+
48
+ GLOB_TOOL_NAME = _safe_tool_name(GlobTool, "Glob")
49
+ GREP_TOOL_NAME = _safe_tool_name(GrepTool, "Grep")
50
+ VIEW_TOOL_NAME = _safe_tool_name(FileReadTool, "View")
51
+ FILE_EDIT_TOOL_NAME = _safe_tool_name(FileEditTool, "FileEdit")
52
+ MULTI_EDIT_TOOL_NAME = _safe_tool_name(MultiEditTool, "MultiEdit")
53
+ NOTEBOOK_EDIT_TOOL_NAME = _safe_tool_name(NotebookEditTool, "NotebookEdit")
54
+ FILE_WRITE_TOOL_NAME = _safe_tool_name(FileWriteTool, "FileWrite")
55
+ LS_TOOL_NAME = _safe_tool_name(LSTool, "LS")
56
+ BASH_TOOL_NAME = _safe_tool_name(BashTool, "Bash")
57
+ BASH_OUTPUT_TOOL_NAME = _safe_tool_name(BashOutputTool, "BashOutput")
58
+ KILL_BASH_TOOL_NAME = _safe_tool_name(KillBashTool, "KillBash")
59
+ TODO_READ_TOOL_NAME = _safe_tool_name(TodoReadTool, "TodoRead")
60
+ TODO_WRITE_TOOL_NAME = _safe_tool_name(TodoWriteTool, "TodoWrite")
61
+ ASK_USER_QUESTION_TOOL_NAME = _safe_tool_name(AskUserQuestionTool, "AskUserQuestion")
62
+ ENTER_PLAN_MODE_TOOL_NAME = _safe_tool_name(EnterPlanModeTool, "EnterPlanMode")
63
+ EXIT_PLAN_MODE_TOOL_NAME = _safe_tool_name(ExitPlanModeTool, "ExitPlanMode")
64
+ TOOL_SEARCH_TOOL_NAME = _safe_tool_name(ToolSearchTool, "ToolSearch")
65
+ MCP_LIST_SERVERS_TOOL_NAME = _safe_tool_name(ListMcpServersTool, "ListMcpServers")
66
+ MCP_LIST_RESOURCES_TOOL_NAME = _safe_tool_name(ListMcpResourcesTool, "ListMcpResources")
67
+ MCP_READ_RESOURCE_TOOL_NAME = _safe_tool_name(ReadMcpResourceTool, "ReadMcpResource")
68
+ TASK_TOOL_NAME = "Task"
69
+
70
+
19
71
  AGENT_DIR_NAME = "agents"
20
72
 
21
73
 
@@ -50,9 +102,99 @@ class AgentLoadResult:
50
102
  failed_files: List[Tuple[Path, str]]
51
103
 
52
104
 
53
- GENERAL_AGENT_PROMPT = """You are a general-purpose subagent for Ripperdoc. Work autonomously on the task provided by the parent agent. Use the allowed tools to research, edit files, and run commands as needed. When you finish, provide a concise report describing what you changed, what you investigated, and any follow-ups the parent agent should share with the user."""
54
-
55
- CODE_REVIEW_AGENT_PROMPT = """You are a code review subagent. Inspect the code and summarize risks, bugs, missing tests, security concerns, and regressions. Do not make code changes. Provide clear, actionable feedback that the parent agent can relay to the user."""
105
+ GENERAL_AGENT_PROMPT = (
106
+ "You are a general-purpose subagent for Ripperdoc. Work autonomously on the task "
107
+ "provided by the parent agent. Use the allowed tools to research, edit files, and "
108
+ "run commands as needed. When you finish, provide a concise report describing what "
109
+ "you changed, what you investigated, and any follow-ups the parent agent should "
110
+ "share with the user."
111
+ )
112
+
113
+ CODE_REVIEW_AGENT_PROMPT = (
114
+ "You are a code review subagent. Inspect the code and summarize risks, bugs, "
115
+ "missing tests, security concerns, and regressions. Do not make code changes. "
116
+ "Provide clear, actionable feedback that the parent agent can relay to the user."
117
+ )
118
+
119
+ EXPLORE_AGENT_PROMPT = (
120
+ "You are a file search specialist. "
121
+ "You excel at thoroughly navigating and exploring codebases.\n\n"
122
+ "=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===\n"
123
+ "This is a READ-ONLY exploration task. You are STRICTLY PROHIBITED from:\n"
124
+ "- Creating new files (no Write, touch, or file creation of any kind)\n"
125
+ "- Modifying existing files (no Edit operations)\n"
126
+ "- Deleting files (no rm or deletion)\n"
127
+ "- Moving or copying files (no mv or cp)\n"
128
+ "- Creating temporary files anywhere, including /tmp\n"
129
+ "- Using redirect operators (>, >>, |) or heredocs to write to files\n"
130
+ "- Running ANY commands that change system state\n\n"
131
+ "Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access "
132
+ "to file editing tools - attempting to edit files will fail.\n\n"
133
+ "Your strengths:\n"
134
+ "- Rapidly finding files using glob patterns\n"
135
+ "- Searching code and text with powerful regex patterns\n"
136
+ "- Reading and analyzing file contents\n\n"
137
+ "Guidelines:\n"
138
+ f"- Use {GLOB_TOOL_NAME} for broad file pattern matching\n"
139
+ f"- Use {GREP_TOOL_NAME} for searching file contents with regex\n"
140
+ f"- Use {VIEW_TOOL_NAME} when you know the specific file path you need to read\n"
141
+ f"- Use {BASH_TOOL_NAME} ONLY for read-only operations (ls, git status, git log, git diff, find, cat, head, tail)\n"
142
+ f"- NEVER use {BASH_TOOL_NAME} for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification\n"
143
+ "- Adapt your search approach based on the thoroughness level specified by the caller\n"
144
+ "- Return file paths as absolute paths in your final response\n"
145
+ "- For clear communication, avoid using emojis\n"
146
+ "- Communicate your final report directly as a regular message - do NOT attempt to create files\n\n"
147
+ "NOTE: You are meant to be a fast agent that returns output as quickly as possible. In order to achieve this you must:\n"
148
+ "- Make efficient use of the tools that you have at your disposal: be smart about how you search for files and implementations\n"
149
+ "- Wherever possible you should try to spawn multiple parallel tool calls for grepping and reading files\n\n"
150
+ "Complete the user's search request efficiently and report your findings clearly."
151
+ )
152
+
153
+ PLAN_AGENT_PROMPT = (
154
+ "You are a software architect and planning specialist. Your role is "
155
+ "to explore the codebase and design implementation plans.\n\n"
156
+ "=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===\n"
157
+ "This is a READ-ONLY planning task. You are STRICTLY PROHIBITED from:\n"
158
+ "- Creating new files (no Write, touch, or file creation of any kind)\n"
159
+ "- Modifying existing files (no Edit operations)\n"
160
+ "- Deleting files (no rm or deletion)\n"
161
+ "- Moving or copying files (no mv or cp)\n"
162
+ "- Creating temporary files anywhere, including /tmp\n"
163
+ "- Using redirect operators (>, >>, |) or heredocs to write to files\n"
164
+ "- Running ANY commands that change system state\n\n"
165
+ "Your role is EXCLUSIVELY to explore the codebase and design implementation plans. "
166
+ "You do NOT have access to file editing tools - attempting to edit files will fail.\n\n"
167
+ "You will be provided with a set of requirements and optionally a perspective on how "
168
+ "to approach the design process.\n\n"
169
+ "## Your Process\n\n"
170
+ "1. **Understand Requirements**: Focus on the requirements provided and apply your "
171
+ "assigned perspective throughout the design process.\n\n"
172
+ "2. **Explore Thoroughly**:\n"
173
+ " - Read any files provided to you in the initial prompt\n"
174
+ f" - Find existing patterns and conventions using {GLOB_TOOL_NAME}, {GREP_TOOL_NAME}, and {VIEW_TOOL_NAME}\n"
175
+ " - Understand the current architecture\n"
176
+ " - Identify similar features as reference\n"
177
+ " - Trace through relevant code paths\n"
178
+ f" - Use {BASH_TOOL_NAME} ONLY for read-only operations (ls, git status, git log, git diff, find, cat, head, tail)\n"
179
+ f" - NEVER use {BASH_TOOL_NAME} for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification\n\n"
180
+ "3. **Design Solution**:\n"
181
+ " - Create implementation approach based on your assigned perspective\n"
182
+ " - Consider trade-offs and architectural decisions\n"
183
+ " - Follow existing patterns where appropriate\n\n"
184
+ "4. **Detail the Plan**:\n"
185
+ " - Provide step-by-step implementation strategy\n"
186
+ " - Identify dependencies and sequencing\n"
187
+ " - Anticipate potential challenges\n\n"
188
+ "## Required Output\n\n"
189
+ "End your response with:\n\n"
190
+ "### Critical Files for Implementation\n"
191
+ "List 3-5 files most critical for implementing this plan:\n"
192
+ "- path/to/file1.ts - [Brief reason: e.g., \"Core logic to modify\"]\n"
193
+ "- path/to/file2.ts - [Brief reason: e.g., \"Interfaces to implement\"]\n"
194
+ "- path/to/file3.ts - [Brief reason: e.g., \"Pattern to follow\"]\n\n"
195
+ "REMEMBER: You can ONLY explore and plan. You CANNOT and MUST NOT write, edit, or "
196
+ "modify any files. You do NOT have access to file editing tools."
197
+ )
56
198
 
57
199
 
58
200
  def _built_in_agents() -> List[AgentDefinition]:
@@ -79,6 +221,34 @@ def _built_in_agents() -> List[AgentDefinition]:
79
221
  location=AgentLocation.BUILT_IN,
80
222
  color="yellow",
81
223
  ),
224
+ AgentDefinition(
225
+ agent_type="explore",
226
+ when_to_use=(
227
+ 'Fast agent specialized for exploring codebases. Use this when you need to quickly find '
228
+ 'files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), '
229
+ 'or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, '
230
+ 'specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, '
231
+ 'or "very thorough" for comprehensive analysis across multiple locations and naming conventions.'
232
+ ),
233
+ tools=["View", "Glob", "Grep"],
234
+ system_prompt=EXPLORE_AGENT_PROMPT,
235
+ location=AgentLocation.BUILT_IN,
236
+ color="green",
237
+ model="task",
238
+ ),
239
+ AgentDefinition(
240
+ agent_type="plan",
241
+ when_to_use=(
242
+ "Software architect agent for designing implementation plans. Use this when "
243
+ "you need to plan the implementation strategy for a task. Returns step-by-step "
244
+ "plans, identifies critical files, and considers architectural trade-offs."
245
+ ),
246
+ tools=["View", "Glob", "Grep"],
247
+ system_prompt=PLAN_AGENT_PROMPT,
248
+ location=AgentLocation.BUILT_IN,
249
+ color="blue",
250
+ model=None,
251
+ ),
82
252
  ]
83
253
 
84
254
 
@@ -136,9 +306,7 @@ def _parse_agent_file(
136
306
  try:
137
307
  text = path.read_text(encoding="utf-8")
138
308
  except Exception as exc:
139
- logger.exception(
140
- "Failed to read agent file", extra={"error": str(exc), "path": str(path)}
141
- )
309
+ logger.exception("Failed to read agent file", extra={"error": str(exc), "path": str(path)})
142
310
  return None, f"Failed to read agent file {path}: {exc}"
143
311
 
144
312
  frontmatter, body = _split_frontmatter(text)
ripperdoc/core/config.py CHANGED
@@ -100,6 +100,8 @@ class ModelProfile(BaseModel):
100
100
  provider: ProviderType
101
101
  model: str
102
102
  api_key: Optional[str] = None
103
+ # Anthropic supports either api_key or auth_token; api_key takes precedence when both are set.
104
+ auth_token: Optional[str] = None
103
105
  api_base: Optional[str] = None
104
106
  max_tokens: int = 4096
105
107
  temperature: float = 0.7
@@ -108,6 +110,9 @@ class ModelProfile(BaseModel):
108
110
  # Tool handling for OpenAI-compatible providers. "native" uses tool_calls, "text" flattens tool
109
111
  # interactions into plain text to support providers that reject tool roles.
110
112
  openai_tool_mode: Literal["native", "text"] = "native"
113
+ # Pricing (USD per 1M tokens). Leave as 0 to skip cost calculation.
114
+ input_cost_per_million_tokens: float = 0.0
115
+ output_cost_per_million_tokens: float = 0.0
111
116
 
112
117
 
113
118
  class ModelPointers(BaseModel):
@@ -255,7 +260,10 @@ class ConfigManager:
255
260
  self._project_config = ProjectConfig()
256
261
  logger.debug(
257
262
  "[config] Project config not found; using defaults",
258
- extra={"path": str(config_path), "project_path": str(self.current_project_path)},
263
+ extra={
264
+ "path": str(config_path),
265
+ "project_path": str(self.current_project_path),
266
+ },
259
267
  )
260
268
 
261
269
  return self._project_config
@@ -18,6 +18,9 @@ from ripperdoc.tools.glob_tool import GlobTool
18
18
  from ripperdoc.tools.ls_tool import LSTool
19
19
  from ripperdoc.tools.grep_tool import GrepTool
20
20
  from ripperdoc.tools.todo_tool import TodoReadTool, TodoWriteTool
21
+ from ripperdoc.tools.ask_user_question_tool import AskUserQuestionTool
22
+ from ripperdoc.tools.enter_plan_mode_tool import EnterPlanModeTool
23
+ from ripperdoc.tools.exit_plan_mode_tool import ExitPlanModeTool
21
24
  from ripperdoc.tools.task_tool import TaskTool
22
25
  from ripperdoc.tools.tool_search_tool import ToolSearchTool
23
26
  from ripperdoc.tools.mcp_tools import (
@@ -47,6 +50,9 @@ def get_default_tools() -> List[Tool[Any, Any]]:
47
50
  GrepTool(),
48
51
  TodoReadTool(),
49
52
  TodoWriteTool(),
53
+ AskUserQuestionTool(),
54
+ EnterPlanModeTool(),
55
+ ExitPlanModeTool(),
50
56
  ToolSearchTool(),
51
57
  ListMcpServersTool(),
52
58
  ListMcpResourcesTool(),
@@ -0,0 +1,47 @@
1
+ """Provider client registry with optional dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ from typing import Optional, TYPE_CHECKING, Type, cast
7
+
8
+ from ripperdoc.core.config import ProviderType
9
+ from ripperdoc.core.providers.base import ProviderClient
10
+ from ripperdoc.utils.log import get_logger
11
+
12
+ if TYPE_CHECKING: # pragma: no cover - type checking only
13
+ from ripperdoc.core.providers.anthropic import AnthropicClient # noqa: F401
14
+ from ripperdoc.core.providers.gemini import GeminiClient # noqa: F401
15
+ from ripperdoc.core.providers.openai import OpenAIClient # noqa: F401
16
+
17
+ logger = get_logger()
18
+
19
+
20
+ def _load_client(module: str, cls: str, extra: str) -> Type[ProviderClient]:
21
+ """Dynamically import a provider client, pointing users to the right extra."""
22
+ try:
23
+ mod = importlib.import_module(f"ripperdoc.core.providers.{module}")
24
+ client_cls = cast(Type[ProviderClient], getattr(mod, cls, None))
25
+ if client_cls is None:
26
+ raise ImportError(f"{cls} not found in {module}")
27
+ return client_cls
28
+ except ImportError as exc:
29
+ raise RuntimeError(
30
+ f"{cls} requires optional dependency group '{extra}'. "
31
+ f"Install with `pip install ripperdoc[{extra}]`."
32
+ ) from exc
33
+
34
+
35
+ def get_provider_client(provider: ProviderType) -> Optional[ProviderClient]:
36
+ """Return a provider client for the given protocol."""
37
+ if provider == ProviderType.ANTHROPIC:
38
+ return _load_client("anthropic", "AnthropicClient", "anthropic")()
39
+ if provider == ProviderType.OPENAI_COMPATIBLE:
40
+ return _load_client("openai", "OpenAIClient", "openai")()
41
+ if provider == ProviderType.GEMINI:
42
+ return _load_client("gemini", "GeminiClient", "gemini")()
43
+ logger.warning("[providers] Unsupported provider", extra={"provider": provider})
44
+ return None
45
+
46
+
47
+ __all__ = ["ProviderClient", "get_provider_client"]
@@ -0,0 +1,147 @@
1
+ """Anthropic provider client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from typing import Any, Awaitable, Callable, Dict, List, Optional
8
+
9
+ from anthropic import AsyncAnthropic
10
+
11
+ from ripperdoc.core.config import ModelProfile
12
+ from ripperdoc.core.providers.base import (
13
+ ProgressCallback,
14
+ ProviderClient,
15
+ ProviderResponse,
16
+ call_with_timeout_and_retries,
17
+ iter_with_timeout,
18
+ sanitize_tool_history,
19
+ )
20
+ from ripperdoc.core.query_utils import (
21
+ anthropic_usage_tokens,
22
+ build_anthropic_tool_schemas,
23
+ content_blocks_from_anthropic_response,
24
+ estimate_cost_usd,
25
+ )
26
+ from ripperdoc.core.tool import Tool
27
+ from ripperdoc.utils.log import get_logger
28
+ from ripperdoc.utils.session_usage import record_usage
29
+
30
+ logger = get_logger()
31
+
32
+
33
+ class AnthropicClient(ProviderClient):
34
+ """Anthropic client with streaming and non-streaming support."""
35
+
36
+ def __init__(self, client_factory: Optional[Callable[[], Awaitable[AsyncAnthropic]]] = None):
37
+ self._client_factory = client_factory
38
+
39
+ async def _client(self, kwargs: Dict[str, Any]) -> AsyncAnthropic:
40
+ if self._client_factory:
41
+ return await self._client_factory()
42
+ return AsyncAnthropic(**kwargs)
43
+
44
+ async def call(
45
+ self,
46
+ *,
47
+ model_profile: ModelProfile,
48
+ system_prompt: str,
49
+ normalized_messages: Any,
50
+ tools: List[Tool[Any, Any]],
51
+ tool_mode: str,
52
+ stream: bool,
53
+ progress_callback: Optional[ProgressCallback],
54
+ request_timeout: Optional[float],
55
+ max_retries: int,
56
+ ) -> ProviderResponse:
57
+ start_time = time.time()
58
+ tool_schemas = await build_anthropic_tool_schemas(tools)
59
+ collected_text: List[str] = []
60
+
61
+ anthropic_kwargs = {"base_url": model_profile.api_base}
62
+ if model_profile.api_key:
63
+ anthropic_kwargs["api_key"] = model_profile.api_key
64
+ auth_token = getattr(model_profile, "auth_token", None)
65
+ if auth_token:
66
+ anthropic_kwargs["auth_token"] = auth_token
67
+
68
+ normalized_messages = sanitize_tool_history(list(normalized_messages))
69
+
70
+ async with await self._client(anthropic_kwargs) as client:
71
+
72
+ async def _stream_request() -> Any:
73
+ stream_cm = client.messages.stream(
74
+ model=model_profile.model,
75
+ max_tokens=model_profile.max_tokens,
76
+ system=system_prompt,
77
+ messages=normalized_messages, # type: ignore[arg-type]
78
+ tools=tool_schemas if tool_schemas else None, # type: ignore
79
+ temperature=model_profile.temperature,
80
+ )
81
+ stream_resp = (
82
+ await asyncio.wait_for(stream_cm.__aenter__(), timeout=request_timeout)
83
+ if request_timeout and request_timeout > 0
84
+ else await stream_cm.__aenter__()
85
+ )
86
+ try:
87
+ async for text in iter_with_timeout(stream_resp.text_stream, request_timeout):
88
+ if text:
89
+ collected_text.append(text)
90
+ if progress_callback:
91
+ try:
92
+ await progress_callback(text)
93
+ except Exception:
94
+ logger.exception("[anthropic_client] Stream callback failed")
95
+ getter = getattr(stream_resp, "get_final_response", None) or getattr(
96
+ stream_resp, "get_final_message", None
97
+ )
98
+ if getter:
99
+ return await getter()
100
+ return None
101
+ finally:
102
+ await stream_cm.__aexit__(None, None, None)
103
+
104
+ async def _non_stream_request() -> Any:
105
+ return await client.messages.create(
106
+ model=model_profile.model,
107
+ max_tokens=model_profile.max_tokens,
108
+ system=system_prompt,
109
+ messages=normalized_messages, # type: ignore[arg-type]
110
+ tools=tool_schemas if tool_schemas else None, # type: ignore
111
+ temperature=model_profile.temperature,
112
+ )
113
+
114
+ timeout_for_call = None if stream else request_timeout
115
+ response = await call_with_timeout_and_retries(
116
+ _stream_request if stream else _non_stream_request,
117
+ timeout_for_call,
118
+ max_retries,
119
+ )
120
+
121
+ duration_ms = (time.time() - start_time) * 1000
122
+ usage_tokens = anthropic_usage_tokens(getattr(response, "usage", None))
123
+ cost_usd = estimate_cost_usd(model_profile, usage_tokens)
124
+ record_usage(
125
+ model_profile.model, duration_ms=duration_ms, cost_usd=cost_usd, **usage_tokens
126
+ )
127
+
128
+ content_blocks = content_blocks_from_anthropic_response(response, tool_mode)
129
+ if stream and collected_text and tool_mode == "text":
130
+ content_blocks = [{"type": "text", "text": "".join(collected_text)}]
131
+
132
+ logger.info(
133
+ "[anthropic_client] Response received",
134
+ extra={
135
+ "model": model_profile.model,
136
+ "duration_ms": round(duration_ms, 2),
137
+ "tool_mode": tool_mode,
138
+ "tool_schemas": len(tool_schemas),
139
+ },
140
+ )
141
+
142
+ return ProviderResponse(
143
+ content_blocks=content_blocks,
144
+ usage_tokens=usage_tokens,
145
+ cost_usd=cost_usd,
146
+ duration_ms=duration_ms,
147
+ )