gobby 0.2.8__py3-none-any.whl → 0.2.11__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.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +6 -0
- gobby/adapters/base.py +11 -2
- gobby/adapters/claude_code.py +5 -28
- gobby/adapters/codex_impl/adapter.py +38 -43
- gobby/adapters/copilot.py +324 -0
- gobby/adapters/cursor.py +373 -0
- gobby/adapters/gemini.py +2 -26
- gobby/adapters/windsurf.py +359 -0
- gobby/agents/definitions.py +162 -2
- gobby/agents/isolation.py +33 -1
- gobby/agents/pty_reader.py +192 -0
- gobby/agents/registry.py +10 -1
- gobby/agents/runner.py +24 -8
- gobby/agents/sandbox.py +8 -3
- gobby/agents/session.py +4 -0
- gobby/agents/spawn.py +9 -2
- gobby/agents/spawn_executor.py +49 -61
- gobby/agents/spawners/command_builder.py +4 -4
- gobby/app_context.py +64 -0
- gobby/cli/__init__.py +4 -0
- gobby/cli/install.py +259 -4
- gobby/cli/installers/__init__.py +12 -0
- gobby/cli/installers/copilot.py +242 -0
- gobby/cli/installers/cursor.py +244 -0
- gobby/cli/installers/shared.py +3 -0
- gobby/cli/installers/windsurf.py +242 -0
- gobby/cli/pipelines.py +639 -0
- gobby/cli/sessions.py +3 -1
- gobby/cli/skills.py +209 -0
- gobby/cli/tasks/crud.py +6 -5
- gobby/cli/tasks/search.py +1 -1
- gobby/cli/ui.py +116 -0
- gobby/cli/utils.py +5 -17
- gobby/cli/workflows.py +38 -17
- gobby/config/app.py +5 -0
- gobby/config/features.py +0 -20
- gobby/config/skills.py +23 -2
- gobby/config/tasks.py +4 -0
- gobby/hooks/broadcaster.py +9 -0
- gobby/hooks/event_handlers/__init__.py +155 -0
- gobby/hooks/event_handlers/_agent.py +175 -0
- gobby/hooks/event_handlers/_base.py +92 -0
- gobby/hooks/event_handlers/_misc.py +66 -0
- gobby/hooks/event_handlers/_session.py +487 -0
- gobby/hooks/event_handlers/_tool.py +196 -0
- gobby/hooks/events.py +48 -0
- gobby/hooks/hook_manager.py +27 -3
- gobby/install/copilot/hooks/hook_dispatcher.py +203 -0
- gobby/install/cursor/hooks/hook_dispatcher.py +203 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +8 -0
- gobby/install/windsurf/hooks/hook_dispatcher.py +205 -0
- gobby/llm/__init__.py +14 -1
- gobby/llm/claude.py +594 -43
- gobby/llm/service.py +149 -0
- gobby/mcp_proxy/importer.py +4 -41
- gobby/mcp_proxy/instructions.py +9 -27
- gobby/mcp_proxy/manager.py +13 -3
- gobby/mcp_proxy/models.py +1 -0
- gobby/mcp_proxy/registries.py +66 -5
- gobby/mcp_proxy/server.py +6 -2
- gobby/mcp_proxy/services/recommendation.py +2 -28
- gobby/mcp_proxy/services/tool_filter.py +7 -0
- gobby/mcp_proxy/services/tool_proxy.py +19 -1
- gobby/mcp_proxy/stdio.py +37 -21
- gobby/mcp_proxy/tools/agents.py +7 -0
- gobby/mcp_proxy/tools/artifacts.py +3 -3
- gobby/mcp_proxy/tools/hub.py +30 -1
- gobby/mcp_proxy/tools/orchestration/cleanup.py +5 -5
- gobby/mcp_proxy/tools/orchestration/monitor.py +1 -1
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +8 -3
- gobby/mcp_proxy/tools/orchestration/review.py +17 -4
- gobby/mcp_proxy/tools/orchestration/wait.py +7 -7
- gobby/mcp_proxy/tools/pipelines/__init__.py +254 -0
- gobby/mcp_proxy/tools/pipelines/_discovery.py +67 -0
- gobby/mcp_proxy/tools/pipelines/_execution.py +281 -0
- gobby/mcp_proxy/tools/sessions/_crud.py +4 -4
- gobby/mcp_proxy/tools/sessions/_handoff.py +1 -1
- gobby/mcp_proxy/tools/skills/__init__.py +184 -30
- gobby/mcp_proxy/tools/spawn_agent.py +229 -14
- gobby/mcp_proxy/tools/task_readiness.py +27 -4
- gobby/mcp_proxy/tools/tasks/_context.py +8 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +27 -1
- gobby/mcp_proxy/tools/tasks/_helpers.py +1 -1
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +125 -8
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +2 -1
- gobby/mcp_proxy/tools/tasks/_search.py +1 -1
- gobby/mcp_proxy/tools/workflows/__init__.py +273 -0
- gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
- gobby/mcp_proxy/tools/workflows/_import.py +112 -0
- gobby/mcp_proxy/tools/workflows/_lifecycle.py +332 -0
- gobby/mcp_proxy/tools/workflows/_query.py +226 -0
- gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
- gobby/mcp_proxy/tools/workflows/_terminal.py +175 -0
- gobby/mcp_proxy/tools/worktrees.py +54 -15
- gobby/memory/components/__init__.py +0 -0
- gobby/memory/components/ingestion.py +98 -0
- gobby/memory/components/search.py +108 -0
- gobby/memory/context.py +5 -5
- gobby/memory/manager.py +16 -25
- gobby/paths.py +51 -0
- gobby/prompts/loader.py +1 -35
- gobby/runner.py +131 -16
- gobby/servers/http.py +193 -150
- gobby/servers/routes/__init__.py +2 -0
- gobby/servers/routes/admin.py +56 -0
- gobby/servers/routes/mcp/endpoints/execution.py +33 -32
- gobby/servers/routes/mcp/endpoints/registry.py +8 -8
- gobby/servers/routes/mcp/hooks.py +10 -1
- gobby/servers/routes/pipelines.py +227 -0
- gobby/servers/websocket.py +314 -1
- gobby/sessions/analyzer.py +89 -3
- gobby/sessions/manager.py +5 -5
- gobby/sessions/transcripts/__init__.py +3 -0
- gobby/sessions/transcripts/claude.py +5 -0
- gobby/sessions/transcripts/codex.py +5 -0
- gobby/sessions/transcripts/gemini.py +5 -0
- gobby/skills/hubs/__init__.py +25 -0
- gobby/skills/hubs/base.py +234 -0
- gobby/skills/hubs/claude_plugins.py +328 -0
- gobby/skills/hubs/clawdhub.py +289 -0
- gobby/skills/hubs/github_collection.py +465 -0
- gobby/skills/hubs/manager.py +263 -0
- gobby/skills/hubs/skillhub.py +342 -0
- gobby/skills/parser.py +23 -0
- gobby/skills/sync.py +5 -4
- gobby/storage/artifacts.py +19 -0
- gobby/storage/memories.py +4 -4
- gobby/storage/migrations.py +118 -3
- gobby/storage/pipelines.py +367 -0
- gobby/storage/sessions.py +23 -4
- gobby/storage/skills.py +48 -8
- gobby/storage/tasks/_aggregates.py +2 -2
- gobby/storage/tasks/_lifecycle.py +4 -4
- gobby/storage/tasks/_models.py +7 -1
- gobby/storage/tasks/_queries.py +3 -3
- gobby/sync/memories.py +4 -3
- gobby/tasks/commits.py +48 -17
- gobby/tasks/external_validator.py +4 -17
- gobby/tasks/validation.py +13 -87
- gobby/tools/summarizer.py +18 -51
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +80 -0
- gobby/workflows/context_actions.py +265 -27
- gobby/workflows/definitions.py +119 -1
- gobby/workflows/detection_helpers.py +23 -11
- gobby/workflows/enforcement/__init__.py +11 -1
- gobby/workflows/enforcement/blocking.py +96 -0
- gobby/workflows/enforcement/handlers.py +35 -1
- gobby/workflows/enforcement/task_policy.py +18 -0
- gobby/workflows/engine.py +26 -4
- gobby/workflows/evaluator.py +8 -5
- gobby/workflows/lifecycle_evaluator.py +59 -27
- gobby/workflows/loader.py +567 -30
- gobby/workflows/lobster_compat.py +147 -0
- gobby/workflows/pipeline_executor.py +801 -0
- gobby/workflows/pipeline_state.py +172 -0
- gobby/workflows/pipeline_webhooks.py +206 -0
- gobby/workflows/premature_stop.py +5 -0
- gobby/worktrees/git.py +135 -20
- {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/METADATA +56 -22
- {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/RECORD +166 -122
- gobby/hooks/event_handlers.py +0 -1008
- gobby/mcp_proxy/tools/workflows.py +0 -1023
- {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/WHEEL +0 -0
- {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/top_level.txt +0 -0
gobby/llm/claude.py
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Claude implementation of LLMProvider.
|
|
3
|
+
|
|
4
|
+
Supports two authentication modes:
|
|
5
|
+
- subscription: Uses Claude Agent SDK via Claude CLI (requires CLI installed)
|
|
6
|
+
- api_key: Uses LiteLLM with anthropic/ prefix (BYOK, no CLI needed)
|
|
3
7
|
"""
|
|
4
8
|
|
|
5
9
|
import asyncio
|
|
@@ -8,8 +12,9 @@ import logging
|
|
|
8
12
|
import os
|
|
9
13
|
import shutil
|
|
10
14
|
import time
|
|
15
|
+
from collections.abc import AsyncIterator
|
|
11
16
|
from dataclasses import dataclass, field
|
|
12
|
-
from typing import Any
|
|
17
|
+
from typing import Any, Literal, cast
|
|
13
18
|
|
|
14
19
|
from claude_agent_sdk import (
|
|
15
20
|
AssistantMessage,
|
|
@@ -26,6 +31,9 @@ from claude_agent_sdk import (
|
|
|
26
31
|
from gobby.config.app import DaemonConfig
|
|
27
32
|
from gobby.llm.base import LLMProvider
|
|
28
33
|
|
|
34
|
+
# Type alias for auth mode
|
|
35
|
+
AuthMode = Literal["subscription", "api_key"]
|
|
36
|
+
|
|
29
37
|
|
|
30
38
|
@dataclass
|
|
31
39
|
class ToolCall:
|
|
@@ -55,14 +63,82 @@ class MCPToolResult:
|
|
|
55
63
|
"""List of tool calls made during generation."""
|
|
56
64
|
|
|
57
65
|
|
|
66
|
+
# Streaming event types for stream_with_mcp_tools
|
|
67
|
+
@dataclass
|
|
68
|
+
class TextChunk:
|
|
69
|
+
"""A chunk of text from the streaming response."""
|
|
70
|
+
|
|
71
|
+
content: str
|
|
72
|
+
"""The text content."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class ToolCallEvent:
|
|
77
|
+
"""Event when a tool is being called."""
|
|
78
|
+
|
|
79
|
+
tool_call_id: str
|
|
80
|
+
"""Unique ID for this tool call."""
|
|
81
|
+
|
|
82
|
+
tool_name: str
|
|
83
|
+
"""Full tool name (e.g., mcp__gobby-tasks__create_task)."""
|
|
84
|
+
|
|
85
|
+
server_name: str
|
|
86
|
+
"""Extracted server name (e.g., gobby-tasks)."""
|
|
87
|
+
|
|
88
|
+
arguments: dict[str, Any]
|
|
89
|
+
"""Arguments passed to the tool."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class ToolResultEvent:
|
|
94
|
+
"""Event when a tool call completes."""
|
|
95
|
+
|
|
96
|
+
tool_call_id: str
|
|
97
|
+
"""ID matching the original ToolCallEvent."""
|
|
98
|
+
|
|
99
|
+
success: bool
|
|
100
|
+
"""Whether the tool call succeeded."""
|
|
101
|
+
|
|
102
|
+
result: Any = None
|
|
103
|
+
"""Result data if successful."""
|
|
104
|
+
|
|
105
|
+
error: str | None = None
|
|
106
|
+
"""Error message if failed."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class DoneEvent:
|
|
111
|
+
"""Event when streaming is complete."""
|
|
112
|
+
|
|
113
|
+
tool_calls_count: int
|
|
114
|
+
"""Total number of tool calls made."""
|
|
115
|
+
|
|
116
|
+
cost_usd: float | None = None
|
|
117
|
+
"""Cost in USD if available."""
|
|
118
|
+
|
|
119
|
+
duration_ms: float | None = None
|
|
120
|
+
"""Duration in milliseconds if available."""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# Union type for all streaming events
|
|
124
|
+
ChatEvent = TextChunk | ToolCallEvent | ToolResultEvent | DoneEvent
|
|
125
|
+
|
|
126
|
+
|
|
58
127
|
logger = logging.getLogger(__name__)
|
|
59
128
|
|
|
60
129
|
|
|
61
130
|
class ClaudeLLMProvider(LLMProvider):
|
|
62
131
|
"""
|
|
63
|
-
Claude implementation of LLMProvider
|
|
132
|
+
Claude implementation of LLMProvider.
|
|
133
|
+
|
|
134
|
+
Supports two authentication modes:
|
|
135
|
+
- subscription (default): Uses Claude Agent SDK via Claude CLI
|
|
136
|
+
- api_key: Uses LiteLLM with anthropic/ prefix (BYOK, no CLI needed)
|
|
64
137
|
|
|
65
|
-
|
|
138
|
+
The auth_mode is determined by:
|
|
139
|
+
1. Constructor parameter (highest priority)
|
|
140
|
+
2. Config file: llm_providers.claude.auth_mode
|
|
141
|
+
3. Default: "subscription"
|
|
66
142
|
"""
|
|
67
143
|
|
|
68
144
|
@property
|
|
@@ -70,16 +146,40 @@ class ClaudeLLMProvider(LLMProvider):
|
|
|
70
146
|
"""Return provider name."""
|
|
71
147
|
return "claude"
|
|
72
148
|
|
|
73
|
-
|
|
149
|
+
@property
|
|
150
|
+
def auth_mode(self) -> AuthMode:
|
|
151
|
+
"""Return current authentication mode."""
|
|
152
|
+
return self._auth_mode
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
config: DaemonConfig,
|
|
157
|
+
auth_mode: AuthMode | None = None,
|
|
158
|
+
):
|
|
74
159
|
"""
|
|
75
160
|
Initialize ClaudeLLMProvider.
|
|
76
161
|
|
|
77
162
|
Args:
|
|
78
163
|
config: Client configuration.
|
|
164
|
+
auth_mode: Authentication mode override. If None, uses config or default.
|
|
79
165
|
"""
|
|
80
166
|
self.config = config
|
|
81
167
|
self.logger = logger
|
|
82
|
-
self.
|
|
168
|
+
self._litellm: Any = None
|
|
169
|
+
|
|
170
|
+
# Determine auth mode from param -> config -> default
|
|
171
|
+
self._auth_mode: AuthMode = "subscription"
|
|
172
|
+
if auth_mode:
|
|
173
|
+
self._auth_mode = auth_mode
|
|
174
|
+
elif config.llm_providers and config.llm_providers.claude:
|
|
175
|
+
self._auth_mode = config.llm_providers.claude.auth_mode # type: ignore[assignment]
|
|
176
|
+
|
|
177
|
+
# Set up based on auth mode
|
|
178
|
+
if self._auth_mode == "subscription":
|
|
179
|
+
self._claude_cli_path = self._find_cli_path()
|
|
180
|
+
else: # api_key
|
|
181
|
+
self._claude_cli_path = None
|
|
182
|
+
self._setup_litellm()
|
|
83
183
|
|
|
84
184
|
def _find_cli_path(self) -> str | None:
|
|
85
185
|
"""
|
|
@@ -147,17 +247,37 @@ class ClaudeLLMProvider(LLMProvider):
|
|
|
147
247
|
|
|
148
248
|
return cli_path
|
|
149
249
|
|
|
150
|
-
|
|
151
|
-
self, context: dict[str, Any], prompt_template: str | None = None
|
|
152
|
-
) -> str:
|
|
250
|
+
def _setup_litellm(self) -> None:
|
|
153
251
|
"""
|
|
154
|
-
|
|
252
|
+
Initialize LiteLLM for api_key mode.
|
|
253
|
+
|
|
254
|
+
LiteLLM reads ANTHROPIC_API_KEY from the environment automatically.
|
|
155
255
|
"""
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
return "Session summary unavailable (Claude CLI not found)"
|
|
256
|
+
try:
|
|
257
|
+
import litellm
|
|
159
258
|
|
|
160
|
-
|
|
259
|
+
self._litellm = litellm
|
|
260
|
+
self.logger.debug("LiteLLM initialized for Claude api_key mode")
|
|
261
|
+
except ImportError:
|
|
262
|
+
self.logger.error("litellm package required for api_key mode")
|
|
263
|
+
|
|
264
|
+
def _format_summary_context(self, context: dict[str, Any], prompt_template: str | None) -> str:
|
|
265
|
+
"""
|
|
266
|
+
Format context and validate prompt template for summary generation.
|
|
267
|
+
|
|
268
|
+
Transforms list/dict values to strings for template substitution
|
|
269
|
+
and validates that a prompt template is provided.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
context: Raw context dict with transcript_summary, last_messages, etc.
|
|
273
|
+
prompt_template: Template string with placeholders for context values.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Formatted prompt string ready for LLM consumption.
|
|
277
|
+
|
|
278
|
+
Raises:
|
|
279
|
+
ValueError: If prompt_template is None.
|
|
280
|
+
"""
|
|
161
281
|
# Transform list/dict values to strings for template substitution
|
|
162
282
|
formatted_context = {
|
|
163
283
|
"transcript_summary": context.get("transcript_summary", ""),
|
|
@@ -171,13 +291,68 @@ class ClaudeLLMProvider(LLMProvider):
|
|
|
171
291
|
},
|
|
172
292
|
}
|
|
173
293
|
|
|
174
|
-
#
|
|
294
|
+
# Validate prompt_template is provided
|
|
175
295
|
if not prompt_template:
|
|
176
296
|
raise ValueError(
|
|
177
297
|
"prompt_template is required for generate_summary. "
|
|
178
298
|
"Configure 'session_summary.prompt' in ~/.gobby/config.yaml"
|
|
179
299
|
)
|
|
180
|
-
|
|
300
|
+
|
|
301
|
+
return prompt_template.format(**formatted_context)
|
|
302
|
+
|
|
303
|
+
async def _retry_async(
|
|
304
|
+
self,
|
|
305
|
+
operation: Any,
|
|
306
|
+
max_retries: int = 3,
|
|
307
|
+
delay: float = 1.0,
|
|
308
|
+
on_retry: Any | None = None,
|
|
309
|
+
) -> Any:
|
|
310
|
+
"""
|
|
311
|
+
Execute an async operation with retry logic.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
operation: Callable that returns an awaitable (coroutine factory).
|
|
315
|
+
max_retries: Maximum number of attempts (default: 3).
|
|
316
|
+
delay: Delay in seconds between retries (default: 1.0).
|
|
317
|
+
on_retry: Optional callback(attempt: int, error: Exception) called on retry.
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
Result of the operation if successful.
|
|
321
|
+
|
|
322
|
+
Raises:
|
|
323
|
+
Exception: The last exception if all retries fail.
|
|
324
|
+
"""
|
|
325
|
+
for attempt in range(max_retries):
|
|
326
|
+
try:
|
|
327
|
+
return await operation()
|
|
328
|
+
except Exception as e:
|
|
329
|
+
if attempt < max_retries - 1:
|
|
330
|
+
if on_retry:
|
|
331
|
+
on_retry(attempt, e)
|
|
332
|
+
await asyncio.sleep(delay)
|
|
333
|
+
else:
|
|
334
|
+
raise
|
|
335
|
+
|
|
336
|
+
async def generate_summary(
|
|
337
|
+
self, context: dict[str, Any], prompt_template: str | None = None
|
|
338
|
+
) -> str:
|
|
339
|
+
"""
|
|
340
|
+
Generate session summary using Claude.
|
|
341
|
+
"""
|
|
342
|
+
if self._auth_mode == "subscription":
|
|
343
|
+
return await self._generate_summary_sdk(context, prompt_template)
|
|
344
|
+
else:
|
|
345
|
+
return await self._generate_summary_litellm(context, prompt_template)
|
|
346
|
+
|
|
347
|
+
async def _generate_summary_sdk(
|
|
348
|
+
self, context: dict[str, Any], prompt_template: str | None = None
|
|
349
|
+
) -> str:
|
|
350
|
+
"""Generate session summary using Claude Agent SDK (subscription mode)."""
|
|
351
|
+
cli_path = self._verify_cli_path()
|
|
352
|
+
if not cli_path:
|
|
353
|
+
return "Session summary unavailable (Claude CLI not found)"
|
|
354
|
+
|
|
355
|
+
prompt = self._format_summary_context(context, prompt_template)
|
|
181
356
|
|
|
182
357
|
# Configure Claude Agent SDK
|
|
183
358
|
options = ClaudeAgentOptions(
|
|
@@ -205,8 +380,45 @@ class ClaudeLLMProvider(LLMProvider):
|
|
|
205
380
|
self.logger.error(f"Failed to generate summary with Claude: {e}")
|
|
206
381
|
return f"Session summary generation failed: {e}"
|
|
207
382
|
|
|
383
|
+
async def _generate_summary_litellm(
|
|
384
|
+
self, context: dict[str, Any], prompt_template: str | None = None
|
|
385
|
+
) -> str:
|
|
386
|
+
"""Generate session summary using LiteLLM (api_key mode)."""
|
|
387
|
+
if not self._litellm:
|
|
388
|
+
return "Session summary unavailable (LiteLLM not initialized)"
|
|
389
|
+
|
|
390
|
+
prompt = self._format_summary_context(context, prompt_template)
|
|
391
|
+
|
|
392
|
+
try:
|
|
393
|
+
response = await self._litellm.acompletion(
|
|
394
|
+
model=f"anthropic/{self.config.session_summary.model}",
|
|
395
|
+
messages=[
|
|
396
|
+
{
|
|
397
|
+
"role": "system",
|
|
398
|
+
"content": "You are a session summary generator. Create comprehensive, actionable summaries.",
|
|
399
|
+
},
|
|
400
|
+
{"role": "user", "content": prompt},
|
|
401
|
+
],
|
|
402
|
+
max_tokens=4000,
|
|
403
|
+
)
|
|
404
|
+
return response.choices[0].message.content or ""
|
|
405
|
+
except Exception as e:
|
|
406
|
+
self.logger.error(f"Failed to generate summary with LiteLLM: {e}")
|
|
407
|
+
return f"Session summary generation failed: {e}"
|
|
408
|
+
|
|
208
409
|
async def synthesize_title(
|
|
209
410
|
self, user_prompt: str, prompt_template: str | None = None
|
|
411
|
+
) -> str | None:
|
|
412
|
+
"""
|
|
413
|
+
Synthesize session title using Claude.
|
|
414
|
+
"""
|
|
415
|
+
if self._auth_mode == "subscription":
|
|
416
|
+
return await self._synthesize_title_sdk(user_prompt, prompt_template)
|
|
417
|
+
else:
|
|
418
|
+
return await self._synthesize_title_litellm(user_prompt, prompt_template)
|
|
419
|
+
|
|
420
|
+
async def _synthesize_title_sdk(
|
|
421
|
+
self, user_prompt: str, prompt_template: str | None = None
|
|
210
422
|
) -> str | None:
|
|
211
423
|
"""
|
|
212
424
|
Synthesize session title using Claude.
|
|
@@ -243,26 +455,63 @@ class ClaudeLLMProvider(LLMProvider):
|
|
|
243
455
|
title_text = block.text
|
|
244
456
|
return title_text.strip()
|
|
245
457
|
|
|
458
|
+
def _on_retry(attempt: int, error: Exception) -> None:
|
|
459
|
+
self.logger.warning(
|
|
460
|
+
f"Title synthesis failed (attempt {attempt + 1}), retrying: {error}"
|
|
461
|
+
)
|
|
462
|
+
|
|
246
463
|
try:
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
return await _run_query()
|
|
252
|
-
except Exception as e:
|
|
253
|
-
if attempt < max_retries - 1:
|
|
254
|
-
self.logger.warning(
|
|
255
|
-
f"Title synthesis failed (attempt {attempt + 1}), retrying: {e}"
|
|
256
|
-
)
|
|
257
|
-
await asyncio.sleep(1)
|
|
258
|
-
else:
|
|
259
|
-
raise e
|
|
260
|
-
# This should be unreachable, but mypy can't prove it
|
|
261
|
-
return None # pragma: no cover
|
|
464
|
+
result = await self._retry_async(
|
|
465
|
+
_run_query, max_retries=3, delay=1.0, on_retry=_on_retry
|
|
466
|
+
)
|
|
467
|
+
return cast(str, result)
|
|
262
468
|
except Exception as e:
|
|
263
469
|
self.logger.error(f"Failed to synthesize title with Claude: {e}")
|
|
264
470
|
return None
|
|
265
471
|
|
|
472
|
+
async def _synthesize_title_litellm(
|
|
473
|
+
self, user_prompt: str, prompt_template: str | None = None
|
|
474
|
+
) -> str | None:
|
|
475
|
+
"""Synthesize session title using LiteLLM (api_key mode)."""
|
|
476
|
+
if not self._litellm:
|
|
477
|
+
return None
|
|
478
|
+
|
|
479
|
+
# Build prompt - prompt_template is required
|
|
480
|
+
if not prompt_template:
|
|
481
|
+
raise ValueError(
|
|
482
|
+
"prompt_template is required for synthesize_title. "
|
|
483
|
+
"Configure 'title_synthesis.prompt' in ~/.gobby/config.yaml"
|
|
484
|
+
)
|
|
485
|
+
prompt = prompt_template.format(user_prompt=user_prompt)
|
|
486
|
+
|
|
487
|
+
async def _run_query() -> str:
|
|
488
|
+
response = await self._litellm.acompletion(
|
|
489
|
+
model=f"anthropic/{self.config.title_synthesis.model}",
|
|
490
|
+
messages=[
|
|
491
|
+
{
|
|
492
|
+
"role": "system",
|
|
493
|
+
"content": "You are a session title generator. Create concise, descriptive titles.",
|
|
494
|
+
},
|
|
495
|
+
{"role": "user", "content": prompt},
|
|
496
|
+
],
|
|
497
|
+
max_tokens=100,
|
|
498
|
+
)
|
|
499
|
+
return (response.choices[0].message.content or "").strip()
|
|
500
|
+
|
|
501
|
+
def _on_retry(attempt: int, error: Exception) -> None:
|
|
502
|
+
self.logger.warning(
|
|
503
|
+
f"Title synthesis failed (attempt {attempt + 1}), retrying: {error}"
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
try:
|
|
507
|
+
result = await self._retry_async(
|
|
508
|
+
_run_query, max_retries=3, delay=1.0, on_retry=_on_retry
|
|
509
|
+
)
|
|
510
|
+
return cast(str, result)
|
|
511
|
+
except Exception as e:
|
|
512
|
+
self.logger.error(f"Failed to synthesize title with LiteLLM: {e}")
|
|
513
|
+
return None
|
|
514
|
+
|
|
266
515
|
async def generate_text(
|
|
267
516
|
self,
|
|
268
517
|
prompt: str,
|
|
@@ -272,6 +521,18 @@ class ClaudeLLMProvider(LLMProvider):
|
|
|
272
521
|
"""
|
|
273
522
|
Generate text using Claude.
|
|
274
523
|
"""
|
|
524
|
+
if self._auth_mode == "subscription":
|
|
525
|
+
return await self._generate_text_sdk(prompt, system_prompt, model)
|
|
526
|
+
else:
|
|
527
|
+
return await self._generate_text_litellm(prompt, system_prompt, model)
|
|
528
|
+
|
|
529
|
+
async def _generate_text_sdk(
|
|
530
|
+
self,
|
|
531
|
+
prompt: str,
|
|
532
|
+
system_prompt: str | None = None,
|
|
533
|
+
model: str | None = None,
|
|
534
|
+
) -> str:
|
|
535
|
+
"""Generate text using Claude Agent SDK (subscription mode)."""
|
|
275
536
|
cli_path = self._verify_cli_path()
|
|
276
537
|
if not cli_path:
|
|
277
538
|
return "Generation unavailable (Claude CLI not found)"
|
|
@@ -323,6 +584,36 @@ class ClaudeLLMProvider(LLMProvider):
|
|
|
323
584
|
self.logger.error(f"Failed to generate text with Claude: {e}", exc_info=True)
|
|
324
585
|
return f"Generation failed: {e}"
|
|
325
586
|
|
|
587
|
+
async def _generate_text_litellm(
|
|
588
|
+
self,
|
|
589
|
+
prompt: str,
|
|
590
|
+
system_prompt: str | None = None,
|
|
591
|
+
model: str | None = None,
|
|
592
|
+
) -> str:
|
|
593
|
+
"""Generate text using LiteLLM (api_key mode)."""
|
|
594
|
+
if not self._litellm:
|
|
595
|
+
return "Generation unavailable (LiteLLM not initialized)"
|
|
596
|
+
|
|
597
|
+
model = model or "claude-haiku-4-5"
|
|
598
|
+
litellm_model = f"anthropic/{model}"
|
|
599
|
+
|
|
600
|
+
try:
|
|
601
|
+
response = await self._litellm.acompletion(
|
|
602
|
+
model=litellm_model,
|
|
603
|
+
messages=[
|
|
604
|
+
{
|
|
605
|
+
"role": "system",
|
|
606
|
+
"content": system_prompt or "You are a helpful assistant.",
|
|
607
|
+
},
|
|
608
|
+
{"role": "user", "content": prompt},
|
|
609
|
+
],
|
|
610
|
+
max_tokens=4000,
|
|
611
|
+
)
|
|
612
|
+
return response.choices[0].message.content or ""
|
|
613
|
+
except Exception as e:
|
|
614
|
+
self.logger.error(f"Failed to generate text with LiteLLM: {e}", exc_info=True)
|
|
615
|
+
return f"Generation failed: {e}"
|
|
616
|
+
|
|
326
617
|
async def generate_with_mcp_tools(
|
|
327
618
|
self,
|
|
328
619
|
prompt: str,
|
|
@@ -338,6 +629,9 @@ class ClaudeLLMProvider(LLMProvider):
|
|
|
338
629
|
This method enables the agent to call MCP tools during generation,
|
|
339
630
|
tracking all tool calls made and returning them alongside the final text.
|
|
340
631
|
|
|
632
|
+
Note: This method requires subscription mode (Claude Agent SDK).
|
|
633
|
+
In api_key mode, returns an error message.
|
|
634
|
+
|
|
341
635
|
Args:
|
|
342
636
|
prompt: User prompt to process.
|
|
343
637
|
allowed_tools: List of allowed MCP tool patterns.
|
|
@@ -364,6 +658,14 @@ class ClaudeLLMProvider(LLMProvider):
|
|
|
364
658
|
>>> for call in result.tool_calls:
|
|
365
659
|
... print(f"Called {call.tool_name} with {call.arguments}")
|
|
366
660
|
"""
|
|
661
|
+
# MCP tools require subscription mode (Claude Agent SDK)
|
|
662
|
+
if self._auth_mode == "api_key":
|
|
663
|
+
return MCPToolResult(
|
|
664
|
+
text="MCP tools require subscription mode. "
|
|
665
|
+
"Set auth_mode: subscription in llm_providers.claude config.",
|
|
666
|
+
tool_calls=[],
|
|
667
|
+
)
|
|
668
|
+
|
|
367
669
|
cli_path = self._verify_cli_path()
|
|
368
670
|
if not cli_path:
|
|
369
671
|
return MCPToolResult(
|
|
@@ -421,7 +723,7 @@ class ClaudeLLMProvider(LLMProvider):
|
|
|
421
723
|
parts = full_tool_name.split("__")
|
|
422
724
|
if len(parts) >= 2:
|
|
423
725
|
return parts[1]
|
|
424
|
-
return "
|
|
726
|
+
return "builtin"
|
|
425
727
|
|
|
426
728
|
# Run async query
|
|
427
729
|
async def _run_query() -> str:
|
|
@@ -487,6 +789,160 @@ class ClaudeLLMProvider(LLMProvider):
|
|
|
487
789
|
tool_calls=tool_calls,
|
|
488
790
|
)
|
|
489
791
|
|
|
792
|
+
async def stream_with_mcp_tools(
|
|
793
|
+
self,
|
|
794
|
+
prompt: str,
|
|
795
|
+
allowed_tools: list[str],
|
|
796
|
+
system_prompt: str | None = None,
|
|
797
|
+
model: str | None = None,
|
|
798
|
+
max_turns: int = 10,
|
|
799
|
+
) -> AsyncIterator[ChatEvent]:
|
|
800
|
+
"""
|
|
801
|
+
Stream generation with MCP tools, yielding events as they occur.
|
|
802
|
+
|
|
803
|
+
This method enables real-time streaming of text and tool call events
|
|
804
|
+
during multi-turn agent conversations. Unlike generate_with_mcp_tools(),
|
|
805
|
+
this yields events incrementally rather than waiting for completion.
|
|
806
|
+
|
|
807
|
+
Note: This method requires subscription mode (Claude Agent SDK).
|
|
808
|
+
|
|
809
|
+
Args:
|
|
810
|
+
prompt: User prompt to process.
|
|
811
|
+
allowed_tools: List of allowed MCP tool patterns.
|
|
812
|
+
Tools should be in format "mcp__{server}__{tool}" or patterns
|
|
813
|
+
like "mcp__gobby-tasks__*" for all tools from a server.
|
|
814
|
+
system_prompt: Optional system prompt.
|
|
815
|
+
model: Optional model override (default: claude-sonnet-4-5).
|
|
816
|
+
max_turns: Maximum number of agentic turns (default: 10).
|
|
817
|
+
|
|
818
|
+
Yields:
|
|
819
|
+
ChatEvent: One of TextChunk, ToolCallEvent, ToolResultEvent, or DoneEvent.
|
|
820
|
+
|
|
821
|
+
Example:
|
|
822
|
+
>>> async for event in provider.stream_with_mcp_tools(
|
|
823
|
+
... prompt="Create a task called 'Fix bug'",
|
|
824
|
+
... allowed_tools=["mcp__gobby-tasks__*"],
|
|
825
|
+
... ):
|
|
826
|
+
... if isinstance(event, TextChunk):
|
|
827
|
+
... print(event.content, end="")
|
|
828
|
+
... elif isinstance(event, ToolCallEvent):
|
|
829
|
+
... print(f"Calling {event.tool_name}...")
|
|
830
|
+
"""
|
|
831
|
+
# MCP tools require subscription mode (Claude Agent SDK)
|
|
832
|
+
if self._auth_mode == "api_key":
|
|
833
|
+
yield TextChunk(
|
|
834
|
+
content="MCP tools require subscription mode. "
|
|
835
|
+
"Set auth_mode: subscription in llm_providers.claude config."
|
|
836
|
+
)
|
|
837
|
+
yield DoneEvent(tool_calls_count=0)
|
|
838
|
+
return
|
|
839
|
+
|
|
840
|
+
cli_path = self._verify_cli_path()
|
|
841
|
+
if not cli_path:
|
|
842
|
+
yield TextChunk(content="Generation unavailable (Claude CLI not found)")
|
|
843
|
+
yield DoneEvent(tool_calls_count=0)
|
|
844
|
+
return
|
|
845
|
+
|
|
846
|
+
# Build mcp_servers config - use .mcp.json if gobby tools requested
|
|
847
|
+
from pathlib import Path
|
|
848
|
+
|
|
849
|
+
mcp_servers_config: dict[str, Any] | str | None = None
|
|
850
|
+
|
|
851
|
+
if any("gobby" in t for t in allowed_tools):
|
|
852
|
+
cwd_config = Path.cwd() / ".mcp.json"
|
|
853
|
+
if cwd_config.exists():
|
|
854
|
+
mcp_servers_config = str(cwd_config)
|
|
855
|
+
else:
|
|
856
|
+
gobby_root = Path(__file__).parent.parent.parent.parent
|
|
857
|
+
gobby_config = gobby_root / ".mcp.json"
|
|
858
|
+
if gobby_config.exists():
|
|
859
|
+
mcp_servers_config = str(gobby_config)
|
|
860
|
+
|
|
861
|
+
# Configure Claude Agent SDK with MCP tools
|
|
862
|
+
options = ClaudeAgentOptions(
|
|
863
|
+
system_prompt=system_prompt
|
|
864
|
+
or "You are Gobby, a helpful assistant with access to tools.",
|
|
865
|
+
max_turns=max_turns,
|
|
866
|
+
model=model or "claude-sonnet-4-5",
|
|
867
|
+
allowed_tools=allowed_tools,
|
|
868
|
+
permission_mode="bypassPermissions",
|
|
869
|
+
cli_path=cli_path,
|
|
870
|
+
mcp_servers=mcp_servers_config if mcp_servers_config is not None else {},
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
def _parse_server_name(full_tool_name: str) -> str:
|
|
874
|
+
"""Extract server name from mcp__{server}__{tool} format."""
|
|
875
|
+
if full_tool_name.startswith("mcp__"):
|
|
876
|
+
parts = full_tool_name.split("__")
|
|
877
|
+
if len(parts) >= 2:
|
|
878
|
+
return parts[1]
|
|
879
|
+
return "builtin"
|
|
880
|
+
|
|
881
|
+
tool_calls_count = 0
|
|
882
|
+
pending_tool_calls: dict[str, str] = {} # Map tool_use_id -> tool_name
|
|
883
|
+
needs_spacing_before_text = False # Track if we need spacing before text
|
|
884
|
+
|
|
885
|
+
try:
|
|
886
|
+
async for message in query(prompt=prompt, options=options):
|
|
887
|
+
if isinstance(message, ResultMessage):
|
|
888
|
+
# Final result - extract metadata
|
|
889
|
+
cost_usd = getattr(message, "total_cost_usd", None)
|
|
890
|
+
duration_ms = getattr(message, "duration_ms", None)
|
|
891
|
+
yield DoneEvent(
|
|
892
|
+
tool_calls_count=tool_calls_count,
|
|
893
|
+
cost_usd=cost_usd,
|
|
894
|
+
duration_ms=duration_ms,
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
elif isinstance(message, AssistantMessage):
|
|
898
|
+
for block in message.content:
|
|
899
|
+
if isinstance(block, TextBlock):
|
|
900
|
+
# Add spacing before text that follows tool calls/results
|
|
901
|
+
# This ensures proper paragraph separation in the UI
|
|
902
|
+
text = block.text
|
|
903
|
+
if needs_spacing_before_text and text:
|
|
904
|
+
# Ensure we have a proper paragraph break (double newline)
|
|
905
|
+
# even if the text starts with a single newline
|
|
906
|
+
text = text.lstrip("\n")
|
|
907
|
+
if text:
|
|
908
|
+
text = "\n\n" + text
|
|
909
|
+
yield TextChunk(content=text)
|
|
910
|
+
needs_spacing_before_text = False
|
|
911
|
+
elif isinstance(block, ToolUseBlock):
|
|
912
|
+
tool_calls_count += 1
|
|
913
|
+
server_name = _parse_server_name(block.name)
|
|
914
|
+
pending_tool_calls[block.id] = block.name
|
|
915
|
+
yield ToolCallEvent(
|
|
916
|
+
tool_call_id=block.id,
|
|
917
|
+
tool_name=block.name,
|
|
918
|
+
server_name=server_name,
|
|
919
|
+
arguments=block.input if isinstance(block.input, dict) else {},
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
elif isinstance(message, UserMessage):
|
|
923
|
+
# UserMessage may contain tool results
|
|
924
|
+
if isinstance(message.content, list):
|
|
925
|
+
for block in message.content:
|
|
926
|
+
if isinstance(block, ToolResultBlock):
|
|
927
|
+
# Determine success based on is_error attribute
|
|
928
|
+
is_error = getattr(block, "is_error", False)
|
|
929
|
+
yield ToolResultEvent(
|
|
930
|
+
tool_call_id=block.tool_use_id,
|
|
931
|
+
success=not is_error,
|
|
932
|
+
result=block.content if not is_error else None,
|
|
933
|
+
error=str(block.content) if is_error else None,
|
|
934
|
+
)
|
|
935
|
+
needs_spacing_before_text = True
|
|
936
|
+
|
|
937
|
+
except ExceptionGroup as eg:
|
|
938
|
+
errors = [f"{type(exc).__name__}: {exc}" for exc in eg.exceptions]
|
|
939
|
+
yield TextChunk(content=f"Generation failed: {'; '.join(errors)}")
|
|
940
|
+
yield DoneEvent(tool_calls_count=tool_calls_count)
|
|
941
|
+
except Exception as e:
|
|
942
|
+
self.logger.error(f"Failed to stream with MCP tools: {e}", exc_info=True)
|
|
943
|
+
yield TextChunk(content=f"Generation failed: {e}")
|
|
944
|
+
yield DoneEvent(tool_calls_count=tool_calls_count)
|
|
945
|
+
|
|
490
946
|
async def describe_image(
|
|
491
947
|
self,
|
|
492
948
|
image_path: str,
|
|
@@ -495,7 +951,8 @@ class ClaudeLLMProvider(LLMProvider):
|
|
|
495
951
|
"""
|
|
496
952
|
Generate a text description of an image using Claude's vision capabilities.
|
|
497
953
|
|
|
498
|
-
|
|
954
|
+
In subscription mode, uses Claude Agent SDK.
|
|
955
|
+
In api_key mode, uses LiteLLM with anthropic/ prefix.
|
|
499
956
|
|
|
500
957
|
Args:
|
|
501
958
|
image_path: Path to the image file to describe
|
|
@@ -504,6 +961,21 @@ class ClaudeLLMProvider(LLMProvider):
|
|
|
504
961
|
Returns:
|
|
505
962
|
Text description of the image
|
|
506
963
|
"""
|
|
964
|
+
if self._auth_mode == "subscription":
|
|
965
|
+
return await self._describe_image_sdk(image_path, context)
|
|
966
|
+
else:
|
|
967
|
+
return await self._describe_image_litellm(image_path, context)
|
|
968
|
+
|
|
969
|
+
def _prepare_image_data(self, image_path: str) -> tuple[str, str] | str:
|
|
970
|
+
"""
|
|
971
|
+
Validate and prepare image data for API calls.
|
|
972
|
+
|
|
973
|
+
Args:
|
|
974
|
+
image_path: Path to the image file.
|
|
975
|
+
|
|
976
|
+
Returns:
|
|
977
|
+
Tuple of (image_base64, mime_type) on success, or error string on failure.
|
|
978
|
+
"""
|
|
507
979
|
import base64
|
|
508
980
|
import mimetypes
|
|
509
981
|
from pathlib import Path
|
|
@@ -524,21 +996,103 @@ class ClaudeLLMProvider(LLMProvider):
|
|
|
524
996
|
# Determine media type
|
|
525
997
|
mime_type, _ = mimetypes.guess_type(str(path))
|
|
526
998
|
if mime_type not in ["image/jpeg", "image/png", "image/gif", "image/webp"]:
|
|
527
|
-
# Default to png for unknown types
|
|
528
999
|
mime_type = "image/png"
|
|
529
1000
|
|
|
1001
|
+
return (image_base64, mime_type)
|
|
1002
|
+
|
|
1003
|
+
async def _describe_image_sdk(
|
|
1004
|
+
self,
|
|
1005
|
+
image_path: str,
|
|
1006
|
+
context: str | None = None,
|
|
1007
|
+
) -> str:
|
|
1008
|
+
"""Describe image using Claude Agent SDK (subscription mode)."""
|
|
1009
|
+
cli_path = self._verify_cli_path()
|
|
1010
|
+
if not cli_path:
|
|
1011
|
+
return "Image description unavailable (Claude CLI not found)"
|
|
1012
|
+
|
|
1013
|
+
# Prepare image data
|
|
1014
|
+
result = self._prepare_image_data(image_path)
|
|
1015
|
+
if isinstance(result, str):
|
|
1016
|
+
return result
|
|
1017
|
+
image_base64, mime_type = result
|
|
1018
|
+
|
|
1019
|
+
# Build prompt with image
|
|
1020
|
+
text_prompt = "Please describe this image in detail, focusing on the key visual elements and any text visible."
|
|
1021
|
+
if context:
|
|
1022
|
+
text_prompt = f"{context}\n\n{text_prompt}"
|
|
1023
|
+
|
|
1024
|
+
# Configure Claude Agent SDK
|
|
1025
|
+
options = ClaudeAgentOptions(
|
|
1026
|
+
system_prompt="You are a vision assistant that describes images in detail.",
|
|
1027
|
+
max_turns=1,
|
|
1028
|
+
model="claude-haiku-4-5",
|
|
1029
|
+
tools=[],
|
|
1030
|
+
allowed_tools=[],
|
|
1031
|
+
permission_mode="default",
|
|
1032
|
+
cli_path=cli_path,
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
# Build async generator yielding structured message with image content
|
|
1036
|
+
# The SDK accepts AsyncIterable[dict] for multimodal input
|
|
1037
|
+
async def _message_generator() -> Any:
|
|
1038
|
+
yield {
|
|
1039
|
+
"role": "user",
|
|
1040
|
+
"content": [
|
|
1041
|
+
{"type": "text", "text": text_prompt},
|
|
1042
|
+
{
|
|
1043
|
+
"type": "image",
|
|
1044
|
+
"source": {
|
|
1045
|
+
"type": "base64",
|
|
1046
|
+
"media_type": mime_type,
|
|
1047
|
+
"data": image_base64,
|
|
1048
|
+
},
|
|
1049
|
+
},
|
|
1050
|
+
],
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
async def _run_query() -> str:
|
|
1054
|
+
result_text = ""
|
|
1055
|
+
async for message in query(prompt=_message_generator(), options=options):
|
|
1056
|
+
if isinstance(message, AssistantMessage):
|
|
1057
|
+
for block in message.content:
|
|
1058
|
+
if isinstance(block, TextBlock):
|
|
1059
|
+
result_text += block.text
|
|
1060
|
+
elif isinstance(message, ResultMessage):
|
|
1061
|
+
if message.result:
|
|
1062
|
+
result_text = message.result
|
|
1063
|
+
return result_text
|
|
1064
|
+
|
|
1065
|
+
try:
|
|
1066
|
+
return await _run_query()
|
|
1067
|
+
except Exception as e:
|
|
1068
|
+
self.logger.error(f"Failed to describe image with Claude SDK: {e}")
|
|
1069
|
+
return f"Image description failed: {e}"
|
|
1070
|
+
|
|
1071
|
+
async def _describe_image_litellm(
|
|
1072
|
+
self,
|
|
1073
|
+
image_path: str,
|
|
1074
|
+
context: str | None = None,
|
|
1075
|
+
) -> str:
|
|
1076
|
+
"""Describe image using LiteLLM (api_key mode)."""
|
|
1077
|
+
if not self._litellm:
|
|
1078
|
+
return "Image description unavailable (LiteLLM not initialized)"
|
|
1079
|
+
|
|
1080
|
+
# Prepare image data
|
|
1081
|
+
result = self._prepare_image_data(image_path)
|
|
1082
|
+
if isinstance(result, str):
|
|
1083
|
+
return result
|
|
1084
|
+
image_base64, mime_type = result
|
|
1085
|
+
|
|
530
1086
|
# Build prompt
|
|
531
1087
|
prompt = "Please describe this image in detail, focusing on the key visual elements and any text visible."
|
|
532
1088
|
if context:
|
|
533
1089
|
prompt = f"{context}\n\n{prompt}"
|
|
534
1090
|
|
|
535
|
-
# Use LiteLLM for unified cost tracking
|
|
536
1091
|
try:
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
model="anthropic/claude-haiku-4-5-20251001", # Use haiku for cost efficiency
|
|
1092
|
+
# Route through LiteLLM with anthropic prefix
|
|
1093
|
+
# Use same model as SDK path for consistency
|
|
1094
|
+
response = await self._litellm.acompletion(
|
|
1095
|
+
model="anthropic/claude-haiku-4-5",
|
|
542
1096
|
messages=[
|
|
543
1097
|
{
|
|
544
1098
|
"role": "user",
|
|
@@ -558,9 +1112,6 @@ class ClaudeLLMProvider(LLMProvider):
|
|
|
558
1112
|
return "No description generated"
|
|
559
1113
|
return response.choices[0].message.content or "No description generated"
|
|
560
1114
|
|
|
561
|
-
except ImportError:
|
|
562
|
-
self.logger.error("LiteLLM not installed, falling back to unavailable")
|
|
563
|
-
return "Image description unavailable (LiteLLM not installed)"
|
|
564
1115
|
except Exception as e:
|
|
565
|
-
self.logger.error(f"Failed to describe image with
|
|
1116
|
+
self.logger.error(f"Failed to describe image with LiteLLM: {e}")
|
|
566
1117
|
return f"Image description failed: {e}"
|