gobby 0.2.5__py3-none-any.whl → 0.2.6__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/adapters/claude_code.py +13 -4
- gobby/adapters/codex.py +43 -3
- gobby/agents/runner.py +8 -0
- gobby/cli/__init__.py +6 -0
- gobby/cli/clones.py +419 -0
- gobby/cli/conductor.py +266 -0
- gobby/cli/installers/antigravity.py +3 -9
- gobby/cli/installers/claude.py +9 -9
- gobby/cli/installers/codex.py +2 -8
- gobby/cli/installers/gemini.py +2 -8
- gobby/cli/installers/shared.py +71 -8
- gobby/cli/skills.py +858 -0
- gobby/cli/tasks/ai.py +0 -440
- gobby/cli/tasks/crud.py +44 -6
- gobby/cli/tasks/main.py +0 -4
- gobby/cli/tui.py +2 -2
- gobby/cli/utils.py +3 -3
- gobby/clones/__init__.py +13 -0
- gobby/clones/git.py +547 -0
- gobby/conductor/__init__.py +16 -0
- gobby/conductor/alerts.py +135 -0
- gobby/conductor/loop.py +164 -0
- gobby/conductor/monitors/__init__.py +11 -0
- gobby/conductor/monitors/agents.py +116 -0
- gobby/conductor/monitors/tasks.py +155 -0
- gobby/conductor/pricing.py +234 -0
- gobby/conductor/token_tracker.py +160 -0
- gobby/config/app.py +63 -1
- gobby/config/search.py +110 -0
- gobby/config/servers.py +1 -1
- gobby/config/skills.py +43 -0
- gobby/config/tasks.py +6 -14
- gobby/hooks/event_handlers.py +145 -2
- gobby/hooks/hook_manager.py +48 -2
- gobby/hooks/skill_manager.py +130 -0
- gobby/install/claude/hooks/hook_dispatcher.py +4 -4
- gobby/install/codex/hooks/hook_dispatcher.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
- gobby/llm/claude.py +22 -34
- gobby/llm/claude_executor.py +46 -256
- gobby/llm/codex_executor.py +59 -291
- gobby/llm/executor.py +21 -0
- gobby/llm/gemini.py +134 -110
- gobby/llm/litellm_executor.py +143 -6
- gobby/llm/resolver.py +95 -33
- gobby/mcp_proxy/instructions.py +54 -0
- gobby/mcp_proxy/models.py +15 -0
- gobby/mcp_proxy/registries.py +68 -5
- gobby/mcp_proxy/server.py +33 -3
- gobby/mcp_proxy/services/tool_proxy.py +81 -1
- gobby/mcp_proxy/stdio.py +2 -1
- gobby/mcp_proxy/tools/__init__.py +0 -2
- gobby/mcp_proxy/tools/agent_messaging.py +317 -0
- gobby/mcp_proxy/tools/clones.py +903 -0
- gobby/mcp_proxy/tools/memory.py +1 -24
- gobby/mcp_proxy/tools/metrics.py +65 -1
- gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
- gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
- gobby/mcp_proxy/tools/session_messages.py +1 -2
- gobby/mcp_proxy/tools/skills/__init__.py +631 -0
- gobby/mcp_proxy/tools/task_orchestration.py +7 -0
- gobby/mcp_proxy/tools/task_readiness.py +14 -0
- gobby/mcp_proxy/tools/task_sync.py +1 -1
- gobby/mcp_proxy/tools/tasks/_context.py +0 -20
- gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
- gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +60 -29
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
- gobby/mcp_proxy/tools/workflows.py +1 -1
- gobby/mcp_proxy/tools/worktrees.py +5 -0
- gobby/memory/backends/__init__.py +6 -1
- gobby/memory/backends/mem0.py +6 -1
- gobby/memory/extractor.py +477 -0
- gobby/memory/manager.py +11 -2
- gobby/prompts/defaults/handoff/compact.md +63 -0
- gobby/prompts/defaults/handoff/session_end.md +57 -0
- gobby/prompts/defaults/memory/extract.md +61 -0
- gobby/runner.py +37 -16
- gobby/search/__init__.py +48 -6
- gobby/search/backends/__init__.py +159 -0
- gobby/search/backends/embedding.py +225 -0
- gobby/search/embeddings.py +238 -0
- gobby/search/models.py +148 -0
- gobby/search/unified.py +496 -0
- gobby/servers/http.py +23 -8
- gobby/servers/routes/admin.py +280 -0
- gobby/servers/routes/mcp/tools.py +241 -52
- gobby/servers/websocket.py +2 -2
- gobby/sessions/analyzer.py +2 -0
- gobby/sessions/transcripts/base.py +1 -0
- gobby/sessions/transcripts/claude.py +64 -5
- gobby/skills/__init__.py +91 -0
- gobby/skills/loader.py +685 -0
- gobby/skills/manager.py +384 -0
- gobby/skills/parser.py +258 -0
- gobby/skills/search.py +463 -0
- gobby/skills/sync.py +119 -0
- gobby/skills/updater.py +385 -0
- gobby/skills/validator.py +368 -0
- gobby/storage/clones.py +378 -0
- gobby/storage/database.py +1 -1
- gobby/storage/memories.py +43 -13
- gobby/storage/migrations.py +180 -6
- gobby/storage/sessions.py +73 -0
- gobby/storage/skills.py +749 -0
- gobby/storage/tasks/_crud.py +4 -4
- gobby/storage/tasks/_lifecycle.py +41 -6
- gobby/storage/tasks/_manager.py +14 -5
- gobby/storage/tasks/_models.py +8 -3
- gobby/sync/memories.py +39 -4
- gobby/sync/tasks.py +83 -6
- gobby/tasks/__init__.py +1 -2
- gobby/tasks/validation.py +24 -15
- gobby/tui/api_client.py +4 -7
- gobby/tui/app.py +5 -3
- gobby/tui/screens/orchestrator.py +1 -2
- gobby/tui/screens/tasks.py +2 -4
- gobby/tui/ws_client.py +1 -1
- gobby/utils/daemon_client.py +2 -2
- gobby/workflows/actions.py +84 -2
- gobby/workflows/context_actions.py +43 -0
- gobby/workflows/detection_helpers.py +115 -31
- gobby/workflows/engine.py +13 -2
- gobby/workflows/lifecycle_evaluator.py +29 -1
- gobby/workflows/loader.py +19 -6
- gobby/workflows/memory_actions.py +74 -0
- gobby/workflows/summary_actions.py +17 -0
- gobby/workflows/task_enforcement_actions.py +448 -6
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/METADATA +82 -21
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/RECORD +136 -107
- gobby/install/codex/prompts/forget.md +0 -7
- gobby/install/codex/prompts/memories.md +0 -7
- gobby/install/codex/prompts/recall.md +0 -7
- gobby/install/codex/prompts/remember.md +0 -13
- gobby/llm/gemini_executor.py +0 -339
- gobby/mcp_proxy/tools/task_expansion.py +0 -591
- gobby/tasks/context.py +0 -747
- gobby/tasks/criteria.py +0 -342
- gobby/tasks/expansion.py +0 -626
- gobby/tasks/prompts/expand.py +0 -327
- gobby/tasks/research.py +0 -421
- gobby/tasks/tdd.py +0 -352
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/WHEEL +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/top_level.txt +0 -0
gobby/llm/gemini.py
CHANGED
|
@@ -1,29 +1,31 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Gemini implementation of LLMProvider.
|
|
2
|
+
Gemini implementation of LLMProvider using LiteLLM.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
- api_key:
|
|
6
|
-
- adc:
|
|
4
|
+
Routes all calls through LiteLLM for unified cost tracking:
|
|
5
|
+
- api_key mode: Uses gemini/model-name prefix
|
|
6
|
+
- adc mode: Uses vertex_ai/model-name prefix (requires VERTEXAI_PROJECT, VERTEXAI_LOCATION)
|
|
7
|
+
|
|
8
|
+
This provider replaces direct google-generativeai SDK usage with LiteLLM routing.
|
|
7
9
|
"""
|
|
8
10
|
|
|
9
11
|
import json
|
|
10
12
|
import logging
|
|
11
|
-
import os
|
|
12
13
|
from typing import Any, Literal
|
|
13
14
|
|
|
14
15
|
from gobby.config.app import DaemonConfig
|
|
15
16
|
from gobby.llm.base import AuthMode, LLMProvider
|
|
17
|
+
from gobby.llm.litellm_executor import get_litellm_model, setup_provider_env
|
|
16
18
|
|
|
17
19
|
logger = logging.getLogger(__name__)
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
class GeminiProvider(LLMProvider):
|
|
21
23
|
"""
|
|
22
|
-
Gemini implementation of LLMProvider using
|
|
24
|
+
Gemini implementation of LLMProvider using LiteLLM for unified cost tracking.
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
- api_key:
|
|
26
|
-
- adc:
|
|
26
|
+
All calls are routed through LiteLLM:
|
|
27
|
+
- api_key mode: Uses gemini/model-name prefix (requires GEMINI_API_KEY)
|
|
28
|
+
- adc mode: Uses vertex_ai/model-name prefix (requires VERTEXAI_PROJECT, VERTEXAI_LOCATION)
|
|
27
29
|
"""
|
|
28
30
|
|
|
29
31
|
def __init__(
|
|
@@ -32,7 +34,7 @@ class GeminiProvider(LLMProvider):
|
|
|
32
34
|
auth_mode: Literal["api_key", "adc"] | None = None,
|
|
33
35
|
):
|
|
34
36
|
"""
|
|
35
|
-
Initialize GeminiProvider.
|
|
37
|
+
Initialize GeminiProvider with LiteLLM routing.
|
|
36
38
|
|
|
37
39
|
Args:
|
|
38
40
|
config: Client configuration.
|
|
@@ -41,8 +43,7 @@ class GeminiProvider(LLMProvider):
|
|
|
41
43
|
"""
|
|
42
44
|
self.config = config
|
|
43
45
|
self.logger = logger
|
|
44
|
-
self.
|
|
45
|
-
self.model_title = None
|
|
46
|
+
self._litellm = None
|
|
46
47
|
|
|
47
48
|
# Determine auth mode from config or parameter
|
|
48
49
|
self._auth_mode: AuthMode = "api_key" # Default
|
|
@@ -51,53 +52,25 @@ class GeminiProvider(LLMProvider):
|
|
|
51
52
|
elif config.llm_providers and config.llm_providers.gemini:
|
|
52
53
|
self._auth_mode = config.llm_providers.gemini.auth_mode
|
|
53
54
|
|
|
55
|
+
# Set up environment for provider/auth_mode
|
|
56
|
+
setup_provider_env("gemini", self._auth_mode) # type: ignore[arg-type]
|
|
57
|
+
|
|
54
58
|
try:
|
|
55
|
-
import
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
try:
|
|
62
|
-
import google.auth
|
|
63
|
-
|
|
64
|
-
credentials, project = google.auth.default()
|
|
65
|
-
genai.configure(credentials=credentials)
|
|
66
|
-
self.genai = genai
|
|
67
|
-
self.logger.debug("Gemini initialized with ADC credentials")
|
|
68
|
-
except Exception as e:
|
|
69
|
-
self.logger.error(
|
|
70
|
-
f"Failed to initialize Gemini with ADC: {e}. "
|
|
71
|
-
"Run 'gcloud auth application-default login' to authenticate."
|
|
72
|
-
)
|
|
73
|
-
self.genai = None
|
|
74
|
-
else:
|
|
75
|
-
# Use API key from environment
|
|
76
|
-
api_key = os.environ.get("GEMINI_API_KEY")
|
|
77
|
-
if api_key:
|
|
78
|
-
genai.configure(api_key=api_key)
|
|
79
|
-
self.genai = genai
|
|
80
|
-
self.logger.debug("Gemini initialized with API key")
|
|
81
|
-
else:
|
|
82
|
-
self.logger.warning("GEMINI_API_KEY not found in environment variables.")
|
|
83
|
-
self.genai = None
|
|
84
|
-
|
|
85
|
-
# Initialize models if genai is configured
|
|
86
|
-
if self.genai:
|
|
87
|
-
summary_model_name = self.config.session_summary.model or "gemini-1.5-pro"
|
|
88
|
-
title_model_name = self.config.title_synthesis.model or "gemini-1.5-flash"
|
|
89
|
-
|
|
90
|
-
self.model_summary = genai.GenerativeModel(summary_model_name)
|
|
91
|
-
self.model_title = genai.GenerativeModel(title_model_name)
|
|
59
|
+
import litellm
|
|
60
|
+
|
|
61
|
+
self._litellm = litellm
|
|
62
|
+
self.logger.debug(
|
|
63
|
+
f"GeminiProvider initialized with LiteLLM (auth_mode={self._auth_mode})"
|
|
64
|
+
)
|
|
92
65
|
|
|
93
66
|
except ImportError:
|
|
94
67
|
self.logger.error(
|
|
95
|
-
"
|
|
68
|
+
"litellm package not found. Please install with `pip install litellm`."
|
|
96
69
|
)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
70
|
+
|
|
71
|
+
def _get_model(self, base_model: str) -> str:
|
|
72
|
+
"""Get the LiteLLM-formatted model name with appropriate prefix."""
|
|
73
|
+
return get_litellm_model(base_model, "gemini", self._auth_mode) # type: ignore[arg-type]
|
|
101
74
|
|
|
102
75
|
@property
|
|
103
76
|
def provider_name(self) -> str:
|
|
@@ -113,10 +86,10 @@ class GeminiProvider(LLMProvider):
|
|
|
113
86
|
self, context: dict[str, Any], prompt_template: str | None = None
|
|
114
87
|
) -> str:
|
|
115
88
|
"""
|
|
116
|
-
Generate session summary using Gemini.
|
|
89
|
+
Generate session summary using Gemini via LiteLLM.
|
|
117
90
|
"""
|
|
118
|
-
if not self.
|
|
119
|
-
return "Session summary unavailable (
|
|
91
|
+
if not self._litellm:
|
|
92
|
+
return "Session summary unavailable (LiteLLM not initialized)"
|
|
120
93
|
|
|
121
94
|
# Build formatted context for prompt template
|
|
122
95
|
formatted_context = {
|
|
@@ -140,20 +113,33 @@ class GeminiProvider(LLMProvider):
|
|
|
140
113
|
prompt = prompt_template.format(**formatted_context)
|
|
141
114
|
|
|
142
115
|
try:
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
116
|
+
model_name = self.config.session_summary.model or "gemini-1.5-pro"
|
|
117
|
+
litellm_model = self._get_model(model_name)
|
|
118
|
+
|
|
119
|
+
response = await self._litellm.acompletion(
|
|
120
|
+
model=litellm_model,
|
|
121
|
+
messages=[
|
|
122
|
+
{
|
|
123
|
+
"role": "system",
|
|
124
|
+
"content": "You are a session summary generator. Create comprehensive, actionable summaries.",
|
|
125
|
+
},
|
|
126
|
+
{"role": "user", "content": prompt},
|
|
127
|
+
],
|
|
128
|
+
max_tokens=4000,
|
|
129
|
+
timeout=120,
|
|
130
|
+
)
|
|
131
|
+
return response.choices[0].message.content or ""
|
|
146
132
|
except Exception as e:
|
|
147
|
-
self.logger.error(f"Failed to generate summary with Gemini: {e}")
|
|
133
|
+
self.logger.error(f"Failed to generate summary with Gemini via LiteLLM: {e}")
|
|
148
134
|
return f"Session summary generation failed: {e}"
|
|
149
135
|
|
|
150
136
|
async def synthesize_title(
|
|
151
137
|
self, user_prompt: str, prompt_template: str | None = None
|
|
152
138
|
) -> str | None:
|
|
153
139
|
"""
|
|
154
|
-
Synthesize session title using Gemini.
|
|
140
|
+
Synthesize session title using Gemini via LiteLLM.
|
|
155
141
|
"""
|
|
156
|
-
if not self.
|
|
142
|
+
if not self._litellm:
|
|
157
143
|
return None
|
|
158
144
|
|
|
159
145
|
# Build prompt - prompt_template is required
|
|
@@ -165,10 +151,24 @@ class GeminiProvider(LLMProvider):
|
|
|
165
151
|
prompt = prompt_template.format(user_prompt=user_prompt)
|
|
166
152
|
|
|
167
153
|
try:
|
|
168
|
-
|
|
169
|
-
|
|
154
|
+
model_name = self.config.title_synthesis.model or "gemini-1.5-flash"
|
|
155
|
+
litellm_model = self._get_model(model_name)
|
|
156
|
+
|
|
157
|
+
response = await self._litellm.acompletion(
|
|
158
|
+
model=litellm_model,
|
|
159
|
+
messages=[
|
|
160
|
+
{
|
|
161
|
+
"role": "system",
|
|
162
|
+
"content": "You are a session title generator. Create concise, descriptive titles.",
|
|
163
|
+
},
|
|
164
|
+
{"role": "user", "content": prompt},
|
|
165
|
+
],
|
|
166
|
+
max_tokens=50,
|
|
167
|
+
timeout=30,
|
|
168
|
+
)
|
|
169
|
+
return (response.choices[0].message.content or "").strip()
|
|
170
170
|
except Exception as e:
|
|
171
|
-
self.logger.error(f"Failed to synthesize title with Gemini: {e}")
|
|
171
|
+
self.logger.error(f"Failed to synthesize title with Gemini via LiteLLM: {e}")
|
|
172
172
|
return None
|
|
173
173
|
|
|
174
174
|
async def generate_text(
|
|
@@ -178,28 +178,30 @@ class GeminiProvider(LLMProvider):
|
|
|
178
178
|
model: str | None = None,
|
|
179
179
|
) -> str:
|
|
180
180
|
"""
|
|
181
|
-
Generate text using Gemini.
|
|
181
|
+
Generate text using Gemini via LiteLLM.
|
|
182
182
|
"""
|
|
183
|
-
if not self.
|
|
184
|
-
return "Generation unavailable (
|
|
183
|
+
if not self._litellm:
|
|
184
|
+
return "Generation unavailable (LiteLLM not initialized)"
|
|
185
185
|
|
|
186
186
|
model_name = model or "gemini-1.5-flash"
|
|
187
|
+
litellm_model = self._get_model(model_name)
|
|
187
188
|
|
|
188
189
|
try:
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
190
|
+
response = await self._litellm.acompletion(
|
|
191
|
+
model=litellm_model,
|
|
192
|
+
messages=[
|
|
193
|
+
{
|
|
194
|
+
"role": "system",
|
|
195
|
+
"content": system_prompt or "You are a helpful assistant.",
|
|
196
|
+
},
|
|
197
|
+
{"role": "user", "content": prompt},
|
|
198
|
+
],
|
|
199
|
+
max_tokens=4000,
|
|
200
|
+
timeout=120,
|
|
201
|
+
)
|
|
202
|
+
return response.choices[0].message.content or ""
|
|
201
203
|
except Exception as e:
|
|
202
|
-
self.logger.error(f"Failed to generate text with Gemini: {e}")
|
|
204
|
+
self.logger.error(f"Failed to generate text with Gemini via LiteLLM: {e}")
|
|
203
205
|
return f"Generation failed: {e}"
|
|
204
206
|
|
|
205
207
|
async def describe_image(
|
|
@@ -208,9 +210,7 @@ class GeminiProvider(LLMProvider):
|
|
|
208
210
|
context: str | None = None,
|
|
209
211
|
) -> str:
|
|
210
212
|
"""
|
|
211
|
-
Generate a text description of an image using Gemini's vision
|
|
212
|
-
|
|
213
|
-
Uses Gemini 1.5 Flash for efficient image description.
|
|
213
|
+
Generate a text description of an image using Gemini's vision via LiteLLM.
|
|
214
214
|
|
|
215
215
|
Args:
|
|
216
216
|
image_path: Path to the image file
|
|
@@ -219,40 +219,64 @@ class GeminiProvider(LLMProvider):
|
|
|
219
219
|
Returns:
|
|
220
220
|
Text description of the image
|
|
221
221
|
"""
|
|
222
|
+
import base64
|
|
223
|
+
import mimetypes
|
|
222
224
|
from pathlib import Path
|
|
223
225
|
|
|
224
|
-
if not self.
|
|
225
|
-
return "Image description unavailable (
|
|
226
|
+
if not self._litellm:
|
|
227
|
+
return "Image description unavailable (LiteLLM not initialized)"
|
|
226
228
|
|
|
227
229
|
path = Path(image_path)
|
|
228
230
|
if not path.exists():
|
|
229
231
|
return f"Image not found: {image_path}"
|
|
230
232
|
|
|
231
233
|
try:
|
|
232
|
-
#
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
234
|
+
# Read and encode image
|
|
235
|
+
image_data = path.read_bytes()
|
|
236
|
+
image_base64 = base64.standard_b64encode(image_data).decode("utf-8")
|
|
237
|
+
|
|
238
|
+
# Determine media type
|
|
239
|
+
mime_type, _ = mimetypes.guess_type(str(path))
|
|
240
|
+
if mime_type not in [
|
|
241
|
+
"image/jpeg",
|
|
242
|
+
"image/png",
|
|
243
|
+
"image/webp",
|
|
244
|
+
"image/heic",
|
|
245
|
+
"image/heif",
|
|
246
|
+
]:
|
|
247
|
+
mime_type = "image/png"
|
|
248
|
+
|
|
249
|
+
# Build prompt
|
|
250
|
+
prompt = (
|
|
251
|
+
"Please describe this image in detail, focusing on key visual elements, "
|
|
252
|
+
"any text visible, and the overall context or meaning."
|
|
253
|
+
)
|
|
254
|
+
if context:
|
|
255
|
+
prompt = f"{context}\n\n{prompt}"
|
|
256
|
+
|
|
257
|
+
# Use gemini-1.5-flash via LiteLLM for efficient vision tasks
|
|
258
|
+
litellm_model = self._get_model("gemini-1.5-flash")
|
|
259
|
+
|
|
260
|
+
response = await self._litellm.acompletion(
|
|
261
|
+
model=litellm_model,
|
|
262
|
+
messages=[
|
|
263
|
+
{
|
|
264
|
+
"role": "user",
|
|
265
|
+
"content": [
|
|
266
|
+
{"type": "text", "text": prompt},
|
|
267
|
+
{
|
|
268
|
+
"type": "image_url",
|
|
269
|
+
"image_url": {"url": f"data:{mime_type};base64,{image_base64}"},
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
}
|
|
273
|
+
],
|
|
274
|
+
max_tokens=1000,
|
|
275
|
+
timeout=60,
|
|
276
|
+
)
|
|
250
277
|
|
|
251
|
-
|
|
278
|
+
return response.choices[0].message.content or "No description generated"
|
|
252
279
|
|
|
253
|
-
except ImportError:
|
|
254
|
-
self.logger.error("PIL/Pillow not installed. Required for image description.")
|
|
255
|
-
return "Image description unavailable (PIL not installed)"
|
|
256
280
|
except Exception as e:
|
|
257
|
-
self.logger.error(f"Failed to describe image with Gemini: {e}")
|
|
281
|
+
self.logger.error(f"Failed to describe image with Gemini via LiteLLM: {e}")
|
|
258
282
|
return f"Image description failed: {e}"
|
gobby/llm/litellm_executor.py
CHANGED
|
@@ -4,17 +4,22 @@ LiteLLM implementation of AgentExecutor.
|
|
|
4
4
|
Provides a unified interface to 100+ LLM providers using OpenAI-compatible
|
|
5
5
|
function calling API. Supports models from OpenAI, Anthropic, Mistral,
|
|
6
6
|
Cohere, and many others through a single interface.
|
|
7
|
+
|
|
8
|
+
This executor is the unified path for all api_key and adc authentication modes
|
|
9
|
+
across all providers (Claude, Gemini, Codex/OpenAI). Provider-specific executors
|
|
10
|
+
are only used for subscription/cli modes that require special SDK integrations.
|
|
7
11
|
"""
|
|
8
12
|
|
|
9
13
|
import asyncio
|
|
10
14
|
import json
|
|
11
15
|
import logging
|
|
12
16
|
import os
|
|
13
|
-
from typing import Any
|
|
17
|
+
from typing import Any, Literal
|
|
14
18
|
|
|
15
19
|
from gobby.llm.executor import (
|
|
16
20
|
AgentExecutor,
|
|
17
21
|
AgentResult,
|
|
22
|
+
CostInfo,
|
|
18
23
|
ToolCallRecord,
|
|
19
24
|
ToolHandler,
|
|
20
25
|
ToolResult,
|
|
@@ -23,6 +28,91 @@ from gobby.llm.executor import (
|
|
|
23
28
|
|
|
24
29
|
logger = logging.getLogger(__name__)
|
|
25
30
|
|
|
31
|
+
# Provider type for routing
|
|
32
|
+
ProviderType = Literal["claude", "gemini", "codex", "openai", "litellm"]
|
|
33
|
+
AuthModeType = Literal["api_key", "adc"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_litellm_model(
|
|
37
|
+
model: str,
|
|
38
|
+
provider: ProviderType | None = None,
|
|
39
|
+
auth_mode: AuthModeType | None = None,
|
|
40
|
+
) -> str:
|
|
41
|
+
"""
|
|
42
|
+
Map provider/model/auth_mode to LiteLLM model string format.
|
|
43
|
+
|
|
44
|
+
LiteLLM uses prefixes to route to the correct provider:
|
|
45
|
+
- anthropic/model-name -> Anthropic API
|
|
46
|
+
- gemini/model-name -> Google AI Studio (API key)
|
|
47
|
+
- vertex_ai/model-name -> Google Vertex AI (ADC)
|
|
48
|
+
- No prefix -> OpenAI (default)
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
model: The model name (e.g., "claude-sonnet-4-5", "gemini-2.0-flash")
|
|
52
|
+
provider: The provider type (claude, gemini, codex, openai)
|
|
53
|
+
auth_mode: The authentication mode (api_key, adc)
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
LiteLLM-formatted model string with appropriate prefix.
|
|
57
|
+
|
|
58
|
+
Examples:
|
|
59
|
+
>>> get_litellm_model("claude-sonnet-4-5", provider="claude")
|
|
60
|
+
"anthropic/claude-sonnet-4-5"
|
|
61
|
+
>>> get_litellm_model("gemini-2.0-flash", provider="gemini", auth_mode="api_key")
|
|
62
|
+
"gemini/gemini-2.0-flash"
|
|
63
|
+
>>> get_litellm_model("gemini-2.0-flash", provider="gemini", auth_mode="adc")
|
|
64
|
+
"vertex_ai/gemini-2.0-flash"
|
|
65
|
+
>>> get_litellm_model("gpt-4o", provider="codex")
|
|
66
|
+
"gpt-4o"
|
|
67
|
+
"""
|
|
68
|
+
# If model already has a prefix, assume it's already formatted
|
|
69
|
+
if "/" in model:
|
|
70
|
+
return model
|
|
71
|
+
|
|
72
|
+
if provider == "claude":
|
|
73
|
+
return f"anthropic/{model}"
|
|
74
|
+
elif provider == "gemini":
|
|
75
|
+
if auth_mode == "adc":
|
|
76
|
+
# ADC uses Vertex AI endpoint
|
|
77
|
+
return f"vertex_ai/{model}"
|
|
78
|
+
# API key uses Gemini API endpoint
|
|
79
|
+
return f"gemini/{model}"
|
|
80
|
+
elif provider in ("codex", "openai"):
|
|
81
|
+
# OpenAI models don't need a prefix
|
|
82
|
+
return model
|
|
83
|
+
else:
|
|
84
|
+
# Default: return as-is (OpenAI-compatible or already prefixed)
|
|
85
|
+
return model
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def setup_provider_env(
|
|
89
|
+
provider: ProviderType | None = None,
|
|
90
|
+
auth_mode: AuthModeType | None = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""
|
|
93
|
+
Set up environment variables needed for specific provider/auth_mode combinations.
|
|
94
|
+
|
|
95
|
+
For Gemini ADC mode via Vertex AI, this ensures VERTEXAI_PROJECT and
|
|
96
|
+
VERTEXAI_LOCATION are set from common Google Cloud environment variables.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
provider: The provider type
|
|
100
|
+
auth_mode: The authentication mode
|
|
101
|
+
"""
|
|
102
|
+
if provider == "gemini" and auth_mode == "adc":
|
|
103
|
+
# Vertex AI needs project and location
|
|
104
|
+
# Check if already set, otherwise try common GCP env vars
|
|
105
|
+
if "VERTEXAI_PROJECT" not in os.environ:
|
|
106
|
+
project = os.environ.get("GOOGLE_CLOUD_PROJECT") or os.environ.get("GCLOUD_PROJECT")
|
|
107
|
+
if project:
|
|
108
|
+
os.environ["VERTEXAI_PROJECT"] = project
|
|
109
|
+
logger.debug(f"Set VERTEXAI_PROJECT from GCP env: {project}")
|
|
110
|
+
|
|
111
|
+
if "VERTEXAI_LOCATION" not in os.environ:
|
|
112
|
+
location = os.environ.get("GOOGLE_CLOUD_REGION", "us-central1")
|
|
113
|
+
os.environ["VERTEXAI_LOCATION"] = location
|
|
114
|
+
logger.debug(f"Set VERTEXAI_LOCATION: {location}")
|
|
115
|
+
|
|
26
116
|
|
|
27
117
|
class LiteLLMExecutor(AgentExecutor):
|
|
28
118
|
"""
|
|
@@ -31,6 +121,12 @@ class LiteLLMExecutor(AgentExecutor):
|
|
|
31
121
|
Uses LiteLLM's unified API to access 100+ LLM providers with OpenAI-compatible
|
|
32
122
|
function calling. Supports models from OpenAI, Anthropic, Mistral, Cohere, etc.
|
|
33
123
|
|
|
124
|
+
This is the unified executor for all api_key and adc authentication modes:
|
|
125
|
+
- Claude (api_key) -> anthropic/model-name
|
|
126
|
+
- Gemini (api_key) -> gemini/model-name
|
|
127
|
+
- Gemini (adc) -> vertex_ai/model-name
|
|
128
|
+
- Codex/OpenAI (api_key) -> model-name (no prefix)
|
|
129
|
+
|
|
34
130
|
The executor implements a proper agentic loop:
|
|
35
131
|
1. Send prompt to LLM with function/tool schemas
|
|
36
132
|
2. When LLM requests a function call, call tool_handler
|
|
@@ -38,12 +134,17 @@ class LiteLLMExecutor(AgentExecutor):
|
|
|
38
134
|
4. Repeat until LLM stops requesting functions or limits are reached
|
|
39
135
|
|
|
40
136
|
Example:
|
|
41
|
-
>>> executor = LiteLLMExecutor(
|
|
137
|
+
>>> executor = LiteLLMExecutor(
|
|
138
|
+
... default_model="claude-sonnet-4-5",
|
|
139
|
+
... provider="claude",
|
|
140
|
+
... auth_mode="api_key",
|
|
141
|
+
... )
|
|
42
142
|
>>> result = await executor.run(
|
|
43
143
|
... prompt="Create a task",
|
|
44
144
|
... tools=[ToolSchema(name="create_task", ...)],
|
|
45
145
|
... tool_handler=my_handler,
|
|
46
146
|
... )
|
|
147
|
+
>>> print(result.cost_info) # Unified cost tracking
|
|
47
148
|
"""
|
|
48
149
|
|
|
49
150
|
def __init__(
|
|
@@ -51,20 +152,28 @@ class LiteLLMExecutor(AgentExecutor):
|
|
|
51
152
|
default_model: str = "gpt-4o-mini",
|
|
52
153
|
api_base: str | None = None,
|
|
53
154
|
api_keys: dict[str, str] | None = None,
|
|
155
|
+
provider: ProviderType | None = None,
|
|
156
|
+
auth_mode: AuthModeType | None = None,
|
|
54
157
|
):
|
|
55
158
|
"""
|
|
56
159
|
Initialize LiteLLMExecutor.
|
|
57
160
|
|
|
58
161
|
Args:
|
|
59
162
|
default_model: Default model to use if not specified in run().
|
|
60
|
-
Examples: "gpt-4o-mini", "claude-
|
|
61
|
-
"
|
|
163
|
+
Examples: "gpt-4o-mini", "claude-sonnet-4-5",
|
|
164
|
+
"gemini-2.0-flash"
|
|
62
165
|
api_base: Optional custom API base URL (e.g., OpenRouter endpoint).
|
|
63
166
|
api_keys: Optional dict of API keys to set in environment.
|
|
64
167
|
Keys should be like "OPENAI_API_KEY", "ANTHROPIC_API_KEY", etc.
|
|
168
|
+
provider: Provider type for model routing (claude, gemini, codex, openai).
|
|
169
|
+
Used to determine the correct LiteLLM model prefix.
|
|
170
|
+
auth_mode: Authentication mode (api_key, adc).
|
|
171
|
+
Used for Gemini to choose between gemini/ and vertex_ai/ prefixes.
|
|
65
172
|
"""
|
|
66
173
|
self.default_model = default_model
|
|
67
174
|
self.api_base = api_base
|
|
175
|
+
self.provider = provider
|
|
176
|
+
self.auth_mode = auth_mode
|
|
68
177
|
self.logger = logger
|
|
69
178
|
self._litellm: Any = None
|
|
70
179
|
|
|
@@ -80,7 +189,12 @@ class LiteLLMExecutor(AgentExecutor):
|
|
|
80
189
|
os.environ[key] = value
|
|
81
190
|
self.logger.debug(f"Set {key} from config")
|
|
82
191
|
|
|
83
|
-
|
|
192
|
+
# Set up provider-specific environment variables
|
|
193
|
+
setup_provider_env(provider, auth_mode)
|
|
194
|
+
|
|
195
|
+
self.logger.debug(
|
|
196
|
+
f"LiteLLM executor initialized (provider={provider}, auth_mode={auth_mode})"
|
|
197
|
+
)
|
|
84
198
|
|
|
85
199
|
except ImportError as e:
|
|
86
200
|
raise ImportError(
|
|
@@ -151,7 +265,13 @@ class LiteLLMExecutor(AgentExecutor):
|
|
|
151
265
|
)
|
|
152
266
|
|
|
153
267
|
tool_calls_records: list[ToolCallRecord] = []
|
|
154
|
-
|
|
268
|
+
# Apply model routing based on provider/auth_mode
|
|
269
|
+
raw_model = model or self.default_model
|
|
270
|
+
effective_model = get_litellm_model(raw_model, self.provider, self.auth_mode)
|
|
271
|
+
self.logger.debug(f"Model routing: {raw_model} -> {effective_model}")
|
|
272
|
+
|
|
273
|
+
# Track cumulative costs across turns (outer scope for timeout handler)
|
|
274
|
+
cost_tracker = [CostInfo(model=effective_model)]
|
|
155
275
|
|
|
156
276
|
# Track turns in outer scope so timeout handler can access the count
|
|
157
277
|
turns_counter = [0]
|
|
@@ -197,6 +317,19 @@ class LiteLLMExecutor(AgentExecutor):
|
|
|
197
317
|
# Call LiteLLM
|
|
198
318
|
response = await litellm.acompletion(**completion_kwargs)
|
|
199
319
|
|
|
320
|
+
# Track costs
|
|
321
|
+
if hasattr(response, "usage") and response.usage:
|
|
322
|
+
cost_tracker[0].prompt_tokens += response.usage.prompt_tokens or 0
|
|
323
|
+
cost_tracker[0].completion_tokens += response.usage.completion_tokens or 0
|
|
324
|
+
|
|
325
|
+
# Calculate cost using LiteLLM's cost tracking
|
|
326
|
+
try:
|
|
327
|
+
turn_cost = litellm.completion_cost(response)
|
|
328
|
+
cost_tracker[0].total_cost += turn_cost
|
|
329
|
+
except Exception: # nosec B110 - best effort cost tracking, failure is non-critical
|
|
330
|
+
# Cost calculation may fail for some models
|
|
331
|
+
pass
|
|
332
|
+
|
|
200
333
|
except Exception as e:
|
|
201
334
|
self.logger.error(f"LiteLLM API error: {e}")
|
|
202
335
|
return AgentResult(
|
|
@@ -205,6 +338,7 @@ class LiteLLMExecutor(AgentExecutor):
|
|
|
205
338
|
tool_calls=tool_calls_records,
|
|
206
339
|
error=f"LiteLLM API error: {e}",
|
|
207
340
|
turns_used=turns_used,
|
|
341
|
+
cost_info=cost_tracker[0],
|
|
208
342
|
)
|
|
209
343
|
|
|
210
344
|
# Process response
|
|
@@ -222,6 +356,7 @@ class LiteLLMExecutor(AgentExecutor):
|
|
|
222
356
|
status="success",
|
|
223
357
|
tool_calls=tool_calls_records,
|
|
224
358
|
turns_used=turns_used,
|
|
359
|
+
cost_info=cost_tracker[0],
|
|
225
360
|
)
|
|
226
361
|
|
|
227
362
|
# Add assistant message to history
|
|
@@ -288,6 +423,7 @@ class LiteLLMExecutor(AgentExecutor):
|
|
|
288
423
|
status="partial",
|
|
289
424
|
tool_calls=tool_calls_records,
|
|
290
425
|
turns_used=turns_used,
|
|
426
|
+
cost_info=cost_tracker[0],
|
|
291
427
|
)
|
|
292
428
|
|
|
293
429
|
# Run with timeout
|
|
@@ -300,4 +436,5 @@ class LiteLLMExecutor(AgentExecutor):
|
|
|
300
436
|
tool_calls=tool_calls_records,
|
|
301
437
|
error=f"Execution timed out after {timeout}s",
|
|
302
438
|
turns_used=turns_counter[0],
|
|
439
|
+
cost_info=cost_tracker[0],
|
|
303
440
|
)
|