gobby 0.2.5__py3-none-any.whl → 0.2.7__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 (244) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +13 -4
  4. gobby/adapters/codex_impl/__init__.py +28 -0
  5. gobby/adapters/codex_impl/adapter.py +722 -0
  6. gobby/adapters/codex_impl/client.py +679 -0
  7. gobby/adapters/codex_impl/protocol.py +20 -0
  8. gobby/adapters/codex_impl/types.py +68 -0
  9. gobby/agents/definitions.py +11 -1
  10. gobby/agents/isolation.py +395 -0
  11. gobby/agents/runner.py +8 -0
  12. gobby/agents/sandbox.py +261 -0
  13. gobby/agents/spawn.py +42 -287
  14. gobby/agents/spawn_executor.py +385 -0
  15. gobby/agents/spawners/__init__.py +24 -0
  16. gobby/agents/spawners/command_builder.py +189 -0
  17. gobby/agents/spawners/embedded.py +21 -2
  18. gobby/agents/spawners/headless.py +21 -2
  19. gobby/agents/spawners/prompt_manager.py +125 -0
  20. gobby/cli/__init__.py +6 -0
  21. gobby/cli/clones.py +419 -0
  22. gobby/cli/conductor.py +266 -0
  23. gobby/cli/install.py +4 -4
  24. gobby/cli/installers/antigravity.py +3 -9
  25. gobby/cli/installers/claude.py +15 -9
  26. gobby/cli/installers/codex.py +2 -8
  27. gobby/cli/installers/gemini.py +8 -8
  28. gobby/cli/installers/shared.py +175 -13
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/skills.py +858 -0
  31. gobby/cli/tasks/ai.py +0 -440
  32. gobby/cli/tasks/crud.py +44 -6
  33. gobby/cli/tasks/main.py +0 -4
  34. gobby/cli/tui.py +2 -2
  35. gobby/cli/utils.py +12 -5
  36. gobby/clones/__init__.py +13 -0
  37. gobby/clones/git.py +547 -0
  38. gobby/conductor/__init__.py +16 -0
  39. gobby/conductor/alerts.py +135 -0
  40. gobby/conductor/loop.py +164 -0
  41. gobby/conductor/monitors/__init__.py +11 -0
  42. gobby/conductor/monitors/agents.py +116 -0
  43. gobby/conductor/monitors/tasks.py +155 -0
  44. gobby/conductor/pricing.py +234 -0
  45. gobby/conductor/token_tracker.py +160 -0
  46. gobby/config/__init__.py +12 -97
  47. gobby/config/app.py +69 -91
  48. gobby/config/extensions.py +2 -2
  49. gobby/config/features.py +7 -130
  50. gobby/config/search.py +110 -0
  51. gobby/config/servers.py +1 -1
  52. gobby/config/skills.py +43 -0
  53. gobby/config/tasks.py +9 -41
  54. gobby/hooks/__init__.py +0 -13
  55. gobby/hooks/event_handlers.py +188 -2
  56. gobby/hooks/hook_manager.py +50 -4
  57. gobby/hooks/plugins.py +1 -1
  58. gobby/hooks/skill_manager.py +130 -0
  59. gobby/hooks/webhooks.py +1 -1
  60. gobby/install/claude/hooks/hook_dispatcher.py +4 -4
  61. gobby/install/codex/hooks/hook_dispatcher.py +1 -1
  62. gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
  63. gobby/llm/claude.py +22 -34
  64. gobby/llm/claude_executor.py +46 -256
  65. gobby/llm/codex_executor.py +59 -291
  66. gobby/llm/executor.py +21 -0
  67. gobby/llm/gemini.py +134 -110
  68. gobby/llm/litellm_executor.py +143 -6
  69. gobby/llm/resolver.py +98 -35
  70. gobby/mcp_proxy/importer.py +62 -4
  71. gobby/mcp_proxy/instructions.py +56 -0
  72. gobby/mcp_proxy/models.py +15 -0
  73. gobby/mcp_proxy/registries.py +68 -8
  74. gobby/mcp_proxy/server.py +33 -3
  75. gobby/mcp_proxy/services/recommendation.py +43 -11
  76. gobby/mcp_proxy/services/tool_proxy.py +81 -1
  77. gobby/mcp_proxy/stdio.py +2 -1
  78. gobby/mcp_proxy/tools/__init__.py +0 -2
  79. gobby/mcp_proxy/tools/agent_messaging.py +317 -0
  80. gobby/mcp_proxy/tools/agents.py +31 -731
  81. gobby/mcp_proxy/tools/clones.py +518 -0
  82. gobby/mcp_proxy/tools/memory.py +3 -26
  83. gobby/mcp_proxy/tools/metrics.py +65 -1
  84. gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
  85. gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
  86. gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
  87. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  88. gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
  89. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  90. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  91. gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
  92. gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
  93. gobby/mcp_proxy/tools/skills/__init__.py +616 -0
  94. gobby/mcp_proxy/tools/spawn_agent.py +417 -0
  95. gobby/mcp_proxy/tools/task_orchestration.py +7 -0
  96. gobby/mcp_proxy/tools/task_readiness.py +14 -0
  97. gobby/mcp_proxy/tools/task_sync.py +1 -1
  98. gobby/mcp_proxy/tools/tasks/_context.py +0 -20
  99. gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
  100. gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
  101. gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
  102. gobby/mcp_proxy/tools/tasks/_lifecycle.py +110 -45
  103. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
  104. gobby/mcp_proxy/tools/workflows.py +1 -1
  105. gobby/mcp_proxy/tools/worktrees.py +0 -338
  106. gobby/memory/backends/__init__.py +6 -1
  107. gobby/memory/backends/mem0.py +6 -1
  108. gobby/memory/extractor.py +477 -0
  109. gobby/memory/ingestion/__init__.py +5 -0
  110. gobby/memory/ingestion/multimodal.py +221 -0
  111. gobby/memory/manager.py +73 -285
  112. gobby/memory/search/__init__.py +10 -0
  113. gobby/memory/search/coordinator.py +248 -0
  114. gobby/memory/services/__init__.py +5 -0
  115. gobby/memory/services/crossref.py +142 -0
  116. gobby/prompts/loader.py +5 -2
  117. gobby/runner.py +37 -16
  118. gobby/search/__init__.py +48 -6
  119. gobby/search/backends/__init__.py +159 -0
  120. gobby/search/backends/embedding.py +225 -0
  121. gobby/search/embeddings.py +238 -0
  122. gobby/search/models.py +148 -0
  123. gobby/search/unified.py +496 -0
  124. gobby/servers/http.py +24 -12
  125. gobby/servers/routes/admin.py +294 -0
  126. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  127. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  128. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  129. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  130. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  131. gobby/servers/routes/mcp/hooks.py +1 -1
  132. gobby/servers/routes/mcp/tools.py +48 -1317
  133. gobby/servers/websocket.py +2 -2
  134. gobby/sessions/analyzer.py +2 -0
  135. gobby/sessions/lifecycle.py +1 -1
  136. gobby/sessions/processor.py +10 -0
  137. gobby/sessions/transcripts/base.py +2 -0
  138. gobby/sessions/transcripts/claude.py +79 -10
  139. gobby/skills/__init__.py +91 -0
  140. gobby/skills/loader.py +685 -0
  141. gobby/skills/manager.py +384 -0
  142. gobby/skills/parser.py +286 -0
  143. gobby/skills/search.py +463 -0
  144. gobby/skills/sync.py +119 -0
  145. gobby/skills/updater.py +385 -0
  146. gobby/skills/validator.py +368 -0
  147. gobby/storage/clones.py +378 -0
  148. gobby/storage/database.py +1 -1
  149. gobby/storage/memories.py +43 -13
  150. gobby/storage/migrations.py +162 -201
  151. gobby/storage/sessions.py +116 -7
  152. gobby/storage/skills.py +782 -0
  153. gobby/storage/tasks/_crud.py +4 -4
  154. gobby/storage/tasks/_lifecycle.py +57 -7
  155. gobby/storage/tasks/_manager.py +14 -5
  156. gobby/storage/tasks/_models.py +8 -3
  157. gobby/sync/memories.py +40 -5
  158. gobby/sync/tasks.py +83 -6
  159. gobby/tasks/__init__.py +1 -2
  160. gobby/tasks/external_validator.py +1 -1
  161. gobby/tasks/validation.py +46 -35
  162. gobby/tools/summarizer.py +91 -10
  163. gobby/tui/api_client.py +4 -7
  164. gobby/tui/app.py +5 -3
  165. gobby/tui/screens/orchestrator.py +1 -2
  166. gobby/tui/screens/tasks.py +2 -4
  167. gobby/tui/ws_client.py +1 -1
  168. gobby/utils/daemon_client.py +2 -2
  169. gobby/utils/project_context.py +2 -3
  170. gobby/utils/status.py +13 -0
  171. gobby/workflows/actions.py +221 -1135
  172. gobby/workflows/artifact_actions.py +31 -0
  173. gobby/workflows/autonomous_actions.py +11 -0
  174. gobby/workflows/context_actions.py +93 -1
  175. gobby/workflows/detection_helpers.py +115 -31
  176. gobby/workflows/enforcement/__init__.py +47 -0
  177. gobby/workflows/enforcement/blocking.py +269 -0
  178. gobby/workflows/enforcement/commit_policy.py +283 -0
  179. gobby/workflows/enforcement/handlers.py +269 -0
  180. gobby/workflows/{task_enforcement_actions.py → enforcement/task_policy.py} +29 -388
  181. gobby/workflows/engine.py +13 -2
  182. gobby/workflows/git_utils.py +106 -0
  183. gobby/workflows/lifecycle_evaluator.py +29 -1
  184. gobby/workflows/llm_actions.py +30 -0
  185. gobby/workflows/loader.py +19 -6
  186. gobby/workflows/mcp_actions.py +20 -1
  187. gobby/workflows/memory_actions.py +154 -0
  188. gobby/workflows/safe_evaluator.py +183 -0
  189. gobby/workflows/session_actions.py +44 -0
  190. gobby/workflows/state_actions.py +60 -1
  191. gobby/workflows/stop_signal_actions.py +55 -0
  192. gobby/workflows/summary_actions.py +111 -1
  193. gobby/workflows/task_sync_actions.py +347 -0
  194. gobby/workflows/todo_actions.py +34 -1
  195. gobby/workflows/webhook_actions.py +185 -0
  196. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/METADATA +87 -21
  197. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/RECORD +201 -172
  198. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
  199. gobby/adapters/codex.py +0 -1292
  200. gobby/install/claude/commands/gobby/bug.md +0 -51
  201. gobby/install/claude/commands/gobby/chore.md +0 -51
  202. gobby/install/claude/commands/gobby/epic.md +0 -52
  203. gobby/install/claude/commands/gobby/eval.md +0 -235
  204. gobby/install/claude/commands/gobby/feat.md +0 -49
  205. gobby/install/claude/commands/gobby/nit.md +0 -52
  206. gobby/install/claude/commands/gobby/ref.md +0 -52
  207. gobby/install/codex/prompts/forget.md +0 -7
  208. gobby/install/codex/prompts/memories.md +0 -7
  209. gobby/install/codex/prompts/recall.md +0 -7
  210. gobby/install/codex/prompts/remember.md +0 -13
  211. gobby/llm/gemini_executor.py +0 -339
  212. gobby/mcp_proxy/tools/session_messages.py +0 -1056
  213. gobby/mcp_proxy/tools/task_expansion.py +0 -591
  214. gobby/prompts/defaults/expansion/system.md +0 -119
  215. gobby/prompts/defaults/expansion/user.md +0 -48
  216. gobby/prompts/defaults/external_validation/agent.md +0 -72
  217. gobby/prompts/defaults/external_validation/external.md +0 -63
  218. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  219. gobby/prompts/defaults/external_validation/system.md +0 -6
  220. gobby/prompts/defaults/features/import_mcp.md +0 -22
  221. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  222. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  223. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  224. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  225. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  226. gobby/prompts/defaults/features/server_description.md +0 -20
  227. gobby/prompts/defaults/features/server_description_system.md +0 -6
  228. gobby/prompts/defaults/features/task_description.md +0 -31
  229. gobby/prompts/defaults/features/task_description_system.md +0 -6
  230. gobby/prompts/defaults/features/tool_summary.md +0 -17
  231. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  232. gobby/prompts/defaults/research/step.md +0 -58
  233. gobby/prompts/defaults/validation/criteria.md +0 -47
  234. gobby/prompts/defaults/validation/validate.md +0 -38
  235. gobby/storage/migrations_legacy.py +0 -1359
  236. gobby/tasks/context.py +0 -747
  237. gobby/tasks/criteria.py +0 -342
  238. gobby/tasks/expansion.py +0 -626
  239. gobby/tasks/prompts/expand.py +0 -327
  240. gobby/tasks/research.py +0 -421
  241. gobby/tasks/tdd.py +0 -352
  242. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
  243. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
  244. {gobby-0.2.5.dist-info → gobby-0.2.7.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
- Supports two authentication modes:
5
- - api_key: Use GEMINI_API_KEY environment variable (BYOK)
6
- - adc: Use Google Application Default Credentials (subscription-based via gcloud auth)
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 google-generativeai package.
24
+ Gemini implementation of LLMProvider using LiteLLM for unified cost tracking.
23
25
 
24
- Supports two authentication modes:
25
- - api_key: Use GEMINI_API_KEY environment variable (BYOK)
26
- - adc: Use Google Application Default Credentials (run `gcloud auth application-default login`)
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.model_summary = None
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 google.generativeai as genai
56
-
57
- # Initialize based on auth mode
58
- if self._auth_mode == "adc":
59
- # Use Application Default Credentials
60
- # User must run: gcloud auth application-default login
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
- "google-generativeai package not found. Please install with `pip install google-generativeai`."
68
+ "litellm package not found. Please install with `pip install litellm`."
96
69
  )
97
- self.genai = None
98
- except Exception as e:
99
- self.logger.error(f"Failed to initialize Gemini client: {e}")
100
- self.genai = None
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.genai or not self.model_summary:
119
- return "Session summary unavailable (Gemini client not initialized)"
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
- # Gemini async generation
144
- response = await self.model_summary.generate_content_async(prompt)
145
- return response.text or ""
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.genai or not self.model_title:
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
- response = await self.model_title.generate_content_async(prompt)
169
- return (response.text or "").strip()
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.genai:
184
- return "Generation unavailable (Gemini client not initialized)"
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
- # Note: Gemini system prompts are configured at model creation,
190
- # but simple generation usually just includes it in the prompt or uses default.
191
- # For simplicity we'll just generate content.
192
- model_instance = self.genai.GenerativeModel(model_name)
193
-
194
- full_prompt = prompt
195
- if system_prompt:
196
- # Prepend system prompt if provided
197
- full_prompt = f"{system_prompt}\n\n{prompt}"
198
-
199
- response = await model_instance.generate_content_async(full_prompt)
200
- return response.text or ""
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 capabilities.
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.genai:
225
- return "Image description unavailable (Gemini client not initialized)"
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
- # Use PIL to load the image - Gemini accepts PIL images directly
233
- from PIL import Image
234
-
235
- # Use context manager to ensure image file handle is properly closed
236
- with Image.open(path) as image:
237
- # Build prompt
238
- prompt = (
239
- "Please describe this image in detail, focusing on key visual elements, "
240
- "any text visible, and the overall context or meaning."
241
- )
242
- if context:
243
- prompt = f"{context}\n\n{prompt}"
244
-
245
- # Use gemini-1.5-flash for efficient vision tasks
246
- model = self.genai.GenerativeModel("gemini-1.5-flash")
247
-
248
- # Generate content with image and prompt
249
- response = await model.generate_content_async([prompt, image])
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
- return response.text or "No description generated"
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}"
@@ -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(default_model="gpt-4o-mini")
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-3-sonnet-20240229",
61
- "mistral/mistral-large-latest"
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
- self.logger.debug("LiteLLM executor initialized")
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
- effective_model = model or self.default_model
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
  )