foundry-mcp 0.8.22__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of foundry-mcp might be problematic. Click here for more details.
- foundry_mcp/__init__.py +13 -0
- foundry_mcp/cli/__init__.py +67 -0
- foundry_mcp/cli/__main__.py +9 -0
- foundry_mcp/cli/agent.py +96 -0
- foundry_mcp/cli/commands/__init__.py +37 -0
- foundry_mcp/cli/commands/cache.py +137 -0
- foundry_mcp/cli/commands/dashboard.py +148 -0
- foundry_mcp/cli/commands/dev.py +446 -0
- foundry_mcp/cli/commands/journal.py +377 -0
- foundry_mcp/cli/commands/lifecycle.py +274 -0
- foundry_mcp/cli/commands/modify.py +824 -0
- foundry_mcp/cli/commands/plan.py +640 -0
- foundry_mcp/cli/commands/pr.py +393 -0
- foundry_mcp/cli/commands/review.py +667 -0
- foundry_mcp/cli/commands/session.py +472 -0
- foundry_mcp/cli/commands/specs.py +686 -0
- foundry_mcp/cli/commands/tasks.py +807 -0
- foundry_mcp/cli/commands/testing.py +676 -0
- foundry_mcp/cli/commands/validate.py +982 -0
- foundry_mcp/cli/config.py +98 -0
- foundry_mcp/cli/context.py +298 -0
- foundry_mcp/cli/logging.py +212 -0
- foundry_mcp/cli/main.py +44 -0
- foundry_mcp/cli/output.py +122 -0
- foundry_mcp/cli/registry.py +110 -0
- foundry_mcp/cli/resilience.py +178 -0
- foundry_mcp/cli/transcript.py +217 -0
- foundry_mcp/config.py +1454 -0
- foundry_mcp/core/__init__.py +144 -0
- foundry_mcp/core/ai_consultation.py +1773 -0
- foundry_mcp/core/batch_operations.py +1202 -0
- foundry_mcp/core/cache.py +195 -0
- foundry_mcp/core/capabilities.py +446 -0
- foundry_mcp/core/concurrency.py +898 -0
- foundry_mcp/core/context.py +540 -0
- foundry_mcp/core/discovery.py +1603 -0
- foundry_mcp/core/error_collection.py +728 -0
- foundry_mcp/core/error_store.py +592 -0
- foundry_mcp/core/health.py +749 -0
- foundry_mcp/core/intake.py +933 -0
- foundry_mcp/core/journal.py +700 -0
- foundry_mcp/core/lifecycle.py +412 -0
- foundry_mcp/core/llm_config.py +1376 -0
- foundry_mcp/core/llm_patterns.py +510 -0
- foundry_mcp/core/llm_provider.py +1569 -0
- foundry_mcp/core/logging_config.py +374 -0
- foundry_mcp/core/metrics_persistence.py +584 -0
- foundry_mcp/core/metrics_registry.py +327 -0
- foundry_mcp/core/metrics_store.py +641 -0
- foundry_mcp/core/modifications.py +224 -0
- foundry_mcp/core/naming.py +146 -0
- foundry_mcp/core/observability.py +1216 -0
- foundry_mcp/core/otel.py +452 -0
- foundry_mcp/core/otel_stubs.py +264 -0
- foundry_mcp/core/pagination.py +255 -0
- foundry_mcp/core/progress.py +387 -0
- foundry_mcp/core/prometheus.py +564 -0
- foundry_mcp/core/prompts/__init__.py +464 -0
- foundry_mcp/core/prompts/fidelity_review.py +691 -0
- foundry_mcp/core/prompts/markdown_plan_review.py +515 -0
- foundry_mcp/core/prompts/plan_review.py +627 -0
- foundry_mcp/core/providers/__init__.py +237 -0
- foundry_mcp/core/providers/base.py +515 -0
- foundry_mcp/core/providers/claude.py +472 -0
- foundry_mcp/core/providers/codex.py +637 -0
- foundry_mcp/core/providers/cursor_agent.py +630 -0
- foundry_mcp/core/providers/detectors.py +515 -0
- foundry_mcp/core/providers/gemini.py +426 -0
- foundry_mcp/core/providers/opencode.py +718 -0
- foundry_mcp/core/providers/opencode_wrapper.js +308 -0
- foundry_mcp/core/providers/package-lock.json +24 -0
- foundry_mcp/core/providers/package.json +25 -0
- foundry_mcp/core/providers/registry.py +607 -0
- foundry_mcp/core/providers/test_provider.py +171 -0
- foundry_mcp/core/providers/validation.py +857 -0
- foundry_mcp/core/rate_limit.py +427 -0
- foundry_mcp/core/research/__init__.py +68 -0
- foundry_mcp/core/research/memory.py +528 -0
- foundry_mcp/core/research/models.py +1234 -0
- foundry_mcp/core/research/providers/__init__.py +40 -0
- foundry_mcp/core/research/providers/base.py +242 -0
- foundry_mcp/core/research/providers/google.py +507 -0
- foundry_mcp/core/research/providers/perplexity.py +442 -0
- foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
- foundry_mcp/core/research/providers/tavily.py +383 -0
- foundry_mcp/core/research/workflows/__init__.py +25 -0
- foundry_mcp/core/research/workflows/base.py +298 -0
- foundry_mcp/core/research/workflows/chat.py +271 -0
- foundry_mcp/core/research/workflows/consensus.py +539 -0
- foundry_mcp/core/research/workflows/deep_research.py +4142 -0
- foundry_mcp/core/research/workflows/ideate.py +682 -0
- foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
- foundry_mcp/core/resilience.py +600 -0
- foundry_mcp/core/responses.py +1624 -0
- foundry_mcp/core/review.py +366 -0
- foundry_mcp/core/security.py +438 -0
- foundry_mcp/core/spec.py +4119 -0
- foundry_mcp/core/task.py +2463 -0
- foundry_mcp/core/testing.py +839 -0
- foundry_mcp/core/validation.py +2357 -0
- foundry_mcp/dashboard/__init__.py +32 -0
- foundry_mcp/dashboard/app.py +119 -0
- foundry_mcp/dashboard/components/__init__.py +17 -0
- foundry_mcp/dashboard/components/cards.py +88 -0
- foundry_mcp/dashboard/components/charts.py +177 -0
- foundry_mcp/dashboard/components/filters.py +136 -0
- foundry_mcp/dashboard/components/tables.py +195 -0
- foundry_mcp/dashboard/data/__init__.py +11 -0
- foundry_mcp/dashboard/data/stores.py +433 -0
- foundry_mcp/dashboard/launcher.py +300 -0
- foundry_mcp/dashboard/views/__init__.py +12 -0
- foundry_mcp/dashboard/views/errors.py +217 -0
- foundry_mcp/dashboard/views/metrics.py +164 -0
- foundry_mcp/dashboard/views/overview.py +96 -0
- foundry_mcp/dashboard/views/providers.py +83 -0
- foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
- foundry_mcp/dashboard/views/tool_usage.py +139 -0
- foundry_mcp/prompts/__init__.py +9 -0
- foundry_mcp/prompts/workflows.py +525 -0
- foundry_mcp/resources/__init__.py +9 -0
- foundry_mcp/resources/specs.py +591 -0
- foundry_mcp/schemas/__init__.py +38 -0
- foundry_mcp/schemas/intake-schema.json +89 -0
- foundry_mcp/schemas/sdd-spec-schema.json +414 -0
- foundry_mcp/server.py +150 -0
- foundry_mcp/tools/__init__.py +10 -0
- foundry_mcp/tools/unified/__init__.py +92 -0
- foundry_mcp/tools/unified/authoring.py +3620 -0
- foundry_mcp/tools/unified/context_helpers.py +98 -0
- foundry_mcp/tools/unified/documentation_helpers.py +268 -0
- foundry_mcp/tools/unified/environment.py +1341 -0
- foundry_mcp/tools/unified/error.py +479 -0
- foundry_mcp/tools/unified/health.py +225 -0
- foundry_mcp/tools/unified/journal.py +841 -0
- foundry_mcp/tools/unified/lifecycle.py +640 -0
- foundry_mcp/tools/unified/metrics.py +777 -0
- foundry_mcp/tools/unified/plan.py +876 -0
- foundry_mcp/tools/unified/pr.py +294 -0
- foundry_mcp/tools/unified/provider.py +589 -0
- foundry_mcp/tools/unified/research.py +1283 -0
- foundry_mcp/tools/unified/review.py +1042 -0
- foundry_mcp/tools/unified/review_helpers.py +314 -0
- foundry_mcp/tools/unified/router.py +102 -0
- foundry_mcp/tools/unified/server.py +565 -0
- foundry_mcp/tools/unified/spec.py +1283 -0
- foundry_mcp/tools/unified/task.py +3846 -0
- foundry_mcp/tools/unified/test.py +431 -0
- foundry_mcp/tools/unified/verification.py +520 -0
- foundry_mcp-0.8.22.dist-info/METADATA +344 -0
- foundry_mcp-0.8.22.dist-info/RECORD +153 -0
- foundry_mcp-0.8.22.dist-info/WHEEL +4 -0
- foundry_mcp-0.8.22.dist-info/entry_points.txt +3 -0
- foundry_mcp-0.8.22.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Gemini CLI provider implementation.
|
|
3
|
+
|
|
4
|
+
Bridges the `gemini` command-line interface to the ProviderContext contract by
|
|
5
|
+
handling availability checks, safe command construction, response parsing, and
|
|
6
|
+
token usage normalization.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
from typing import Any, Dict, List, Optional, Protocol, Sequence
|
|
16
|
+
|
|
17
|
+
from .base import (
|
|
18
|
+
ProviderCapability,
|
|
19
|
+
ProviderContext,
|
|
20
|
+
ProviderExecutionError,
|
|
21
|
+
ProviderHooks,
|
|
22
|
+
ProviderMetadata,
|
|
23
|
+
ProviderRequest,
|
|
24
|
+
ProviderResult,
|
|
25
|
+
ProviderStatus,
|
|
26
|
+
ProviderTimeoutError,
|
|
27
|
+
ProviderUnavailableError,
|
|
28
|
+
StreamChunk,
|
|
29
|
+
TokenUsage,
|
|
30
|
+
)
|
|
31
|
+
from .detectors import detect_provider_availability
|
|
32
|
+
from .registry import register_provider
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
DEFAULT_BINARY = "gemini"
|
|
37
|
+
DEFAULT_TIMEOUT_SECONDS = 360
|
|
38
|
+
AVAILABILITY_OVERRIDE_ENV = "GEMINI_CLI_AVAILABLE_OVERRIDE"
|
|
39
|
+
CUSTOM_BINARY_ENV = "GEMINI_CLI_BINARY"
|
|
40
|
+
|
|
41
|
+
# Read-only tools allowed for safe codebase exploration
|
|
42
|
+
# Based on Gemini CLI tool names (both class names and function names supported)
|
|
43
|
+
ALLOWED_TOOLS = [
|
|
44
|
+
# Core file operations (read-only)
|
|
45
|
+
"ReadFileTool",
|
|
46
|
+
"read_file",
|
|
47
|
+
"ReadManyFilesTool",
|
|
48
|
+
"read_many_files",
|
|
49
|
+
"LSTool",
|
|
50
|
+
"list_directory",
|
|
51
|
+
"GlobTool",
|
|
52
|
+
"glob",
|
|
53
|
+
"GrepTool",
|
|
54
|
+
"search_file_content",
|
|
55
|
+
# Shell commands - file viewing
|
|
56
|
+
"ShellTool(cat)",
|
|
57
|
+
"ShellTool(head)",
|
|
58
|
+
"ShellTool(tail)",
|
|
59
|
+
"ShellTool(bat)",
|
|
60
|
+
# Shell commands - directory listing/navigation
|
|
61
|
+
"ShellTool(ls)",
|
|
62
|
+
"ShellTool(tree)",
|
|
63
|
+
"ShellTool(pwd)",
|
|
64
|
+
"ShellTool(which)",
|
|
65
|
+
"ShellTool(whereis)",
|
|
66
|
+
# Shell commands - search/find
|
|
67
|
+
"ShellTool(grep)",
|
|
68
|
+
"ShellTool(rg)",
|
|
69
|
+
"ShellTool(ag)",
|
|
70
|
+
"ShellTool(find)",
|
|
71
|
+
"ShellTool(fd)",
|
|
72
|
+
# Shell commands - git operations (read-only)
|
|
73
|
+
"ShellTool(git log)",
|
|
74
|
+
"ShellTool(git show)",
|
|
75
|
+
"ShellTool(git diff)",
|
|
76
|
+
"ShellTool(git status)",
|
|
77
|
+
"ShellTool(git grep)",
|
|
78
|
+
"ShellTool(git blame)",
|
|
79
|
+
# Shell commands - text processing
|
|
80
|
+
"ShellTool(wc)",
|
|
81
|
+
"ShellTool(cut)",
|
|
82
|
+
"ShellTool(paste)",
|
|
83
|
+
"ShellTool(column)",
|
|
84
|
+
"ShellTool(sort)",
|
|
85
|
+
"ShellTool(uniq)",
|
|
86
|
+
# Shell commands - data formats
|
|
87
|
+
"ShellTool(jq)",
|
|
88
|
+
"ShellTool(yq)",
|
|
89
|
+
# Shell commands - file analysis
|
|
90
|
+
"ShellTool(file)",
|
|
91
|
+
"ShellTool(stat)",
|
|
92
|
+
"ShellTool(du)",
|
|
93
|
+
"ShellTool(df)",
|
|
94
|
+
# Shell commands - checksums/hashing
|
|
95
|
+
"ShellTool(md5sum)",
|
|
96
|
+
"ShellTool(shasum)",
|
|
97
|
+
"ShellTool(sha256sum)",
|
|
98
|
+
"ShellTool(sha512sum)",
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
# System prompt addition warning about piped command vulnerability
|
|
102
|
+
PIPED_COMMAND_WARNING = """
|
|
103
|
+
IMPORTANT SECURITY NOTE: When using shell commands, avoid piped commands (e.g., cat file.txt | wc -l).
|
|
104
|
+
Piped commands bypass the tool allowlist checks in Gemini CLI - only the first command in a pipe is validated.
|
|
105
|
+
Instead, use sequential commands or alternative approaches to achieve the same result safely.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class RunnerProtocol(Protocol):
|
|
110
|
+
"""Callable signature used for executing Gemini CLI commands."""
|
|
111
|
+
|
|
112
|
+
def __call__(
|
|
113
|
+
self,
|
|
114
|
+
command: Sequence[str],
|
|
115
|
+
*,
|
|
116
|
+
timeout: Optional[int] = None,
|
|
117
|
+
env: Optional[Dict[str, str]] = None,
|
|
118
|
+
input_data: Optional[str] = None,
|
|
119
|
+
) -> subprocess.CompletedProcess[str]:
|
|
120
|
+
raise NotImplementedError
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _default_runner(
|
|
124
|
+
command: Sequence[str],
|
|
125
|
+
*,
|
|
126
|
+
timeout: Optional[int] = None,
|
|
127
|
+
env: Optional[Dict[str, str]] = None,
|
|
128
|
+
input_data: Optional[str] = None,
|
|
129
|
+
) -> subprocess.CompletedProcess[str]:
|
|
130
|
+
"""Invoke the Gemini CLI via subprocess."""
|
|
131
|
+
return subprocess.run( # noqa: S603,S607 - intentional CLI invocation
|
|
132
|
+
list(command),
|
|
133
|
+
capture_output=True,
|
|
134
|
+
text=True,
|
|
135
|
+
input=input_data,
|
|
136
|
+
timeout=timeout,
|
|
137
|
+
env=env,
|
|
138
|
+
check=False,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
GEMINI_METADATA = ProviderMetadata(
|
|
143
|
+
provider_id="gemini",
|
|
144
|
+
display_name="Google Gemini CLI",
|
|
145
|
+
models=[], # Model validation delegated to CLI
|
|
146
|
+
default_model="pro",
|
|
147
|
+
capabilities={ProviderCapability.TEXT, ProviderCapability.STREAMING, ProviderCapability.VISION},
|
|
148
|
+
security_flags={"writes_allowed": False},
|
|
149
|
+
extra={"cli": "gemini", "output_format": "json"},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class GeminiProvider(ProviderContext):
|
|
154
|
+
"""ProviderContext implementation backed by the Gemini CLI."""
|
|
155
|
+
|
|
156
|
+
def __init__(
|
|
157
|
+
self,
|
|
158
|
+
metadata: ProviderMetadata,
|
|
159
|
+
hooks: ProviderHooks,
|
|
160
|
+
*,
|
|
161
|
+
model: Optional[str] = None,
|
|
162
|
+
binary: Optional[str] = None,
|
|
163
|
+
runner: Optional[RunnerProtocol] = None,
|
|
164
|
+
env: Optional[Dict[str, str]] = None,
|
|
165
|
+
timeout: Optional[int] = None,
|
|
166
|
+
):
|
|
167
|
+
super().__init__(metadata, hooks)
|
|
168
|
+
self._runner = runner or _default_runner
|
|
169
|
+
self._binary = binary or os.environ.get(CUSTOM_BINARY_ENV, DEFAULT_BINARY)
|
|
170
|
+
self._env = env
|
|
171
|
+
self._timeout = timeout or DEFAULT_TIMEOUT_SECONDS
|
|
172
|
+
self._model = model or metadata.default_model or "pro"
|
|
173
|
+
|
|
174
|
+
def _validate_request(self, request: ProviderRequest) -> None:
|
|
175
|
+
"""Validate and normalize request, ignoring unsupported parameters."""
|
|
176
|
+
unsupported: List[str] = []
|
|
177
|
+
if request.temperature is not None:
|
|
178
|
+
unsupported.append("temperature")
|
|
179
|
+
if request.max_tokens is not None:
|
|
180
|
+
unsupported.append("max_tokens")
|
|
181
|
+
if request.attachments:
|
|
182
|
+
unsupported.append("attachments")
|
|
183
|
+
if unsupported:
|
|
184
|
+
# Log warning but continue - ignore unsupported parameters
|
|
185
|
+
logger.warning(
|
|
186
|
+
f"Gemini CLI ignoring unsupported parameters: {', '.join(unsupported)}"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def _build_prompt(self, request: ProviderRequest) -> str:
|
|
190
|
+
# Build the system prompt with security warning
|
|
191
|
+
system_parts = []
|
|
192
|
+
if request.system_prompt:
|
|
193
|
+
system_parts.append(request.system_prompt.strip())
|
|
194
|
+
system_parts.append(PIPED_COMMAND_WARNING.strip())
|
|
195
|
+
|
|
196
|
+
if system_parts:
|
|
197
|
+
return f"{chr(10).join(system_parts)}\n\n{request.prompt}"
|
|
198
|
+
return request.prompt
|
|
199
|
+
|
|
200
|
+
def _build_command(self, model: str) -> List[str]:
|
|
201
|
+
"""
|
|
202
|
+
Build Gemini CLI command with read-only tool restrictions.
|
|
203
|
+
|
|
204
|
+
Prompt is passed via stdin to avoid CLI argument length limits.
|
|
205
|
+
"""
|
|
206
|
+
command = [self._binary, "--output-format", "json"]
|
|
207
|
+
|
|
208
|
+
# Add allowed tools for read-only enforcement
|
|
209
|
+
for tool in ALLOWED_TOOLS:
|
|
210
|
+
command.extend(["--allowed-tools", tool])
|
|
211
|
+
|
|
212
|
+
# Insert model if specified
|
|
213
|
+
if model:
|
|
214
|
+
command[1:1] = ["-m", model]
|
|
215
|
+
|
|
216
|
+
return command
|
|
217
|
+
|
|
218
|
+
def _run(
|
|
219
|
+
self, command: Sequence[str], timeout: Optional[float], input_data: Optional[str] = None
|
|
220
|
+
) -> subprocess.CompletedProcess[str]:
|
|
221
|
+
try:
|
|
222
|
+
return self._runner(
|
|
223
|
+
command, timeout=int(timeout) if timeout else None, env=self._env, input_data=input_data
|
|
224
|
+
)
|
|
225
|
+
except FileNotFoundError as exc:
|
|
226
|
+
raise ProviderUnavailableError(
|
|
227
|
+
f"Gemini CLI '{self._binary}' is not available on PATH.",
|
|
228
|
+
provider=self.metadata.provider_id,
|
|
229
|
+
) from exc
|
|
230
|
+
except subprocess.TimeoutExpired as exc:
|
|
231
|
+
raise ProviderTimeoutError(
|
|
232
|
+
f"Command timed out after {exc.timeout} seconds",
|
|
233
|
+
provider=self.metadata.provider_id,
|
|
234
|
+
) from exc
|
|
235
|
+
|
|
236
|
+
def _parse_output(self, raw: str) -> Dict[str, Any]:
|
|
237
|
+
text = raw.strip()
|
|
238
|
+
if not text:
|
|
239
|
+
raise ProviderExecutionError(
|
|
240
|
+
"Gemini CLI returned empty output.",
|
|
241
|
+
provider=self.metadata.provider_id,
|
|
242
|
+
)
|
|
243
|
+
try:
|
|
244
|
+
return json.loads(text)
|
|
245
|
+
except json.JSONDecodeError as exc:
|
|
246
|
+
logger.debug(f"Gemini CLI JSON parse error: {exc}")
|
|
247
|
+
raise ProviderExecutionError(
|
|
248
|
+
"Gemini CLI returned invalid JSON response",
|
|
249
|
+
provider=self.metadata.provider_id,
|
|
250
|
+
) from exc
|
|
251
|
+
|
|
252
|
+
def _extract_usage(self, payload: Dict[str, Any]) -> TokenUsage:
|
|
253
|
+
stats = payload.get("stats") or {}
|
|
254
|
+
models_section = stats.get("models") or {}
|
|
255
|
+
first_model = next(iter(models_section.values()), {})
|
|
256
|
+
tokens = first_model.get("tokens") or {}
|
|
257
|
+
return TokenUsage(
|
|
258
|
+
input_tokens=int(tokens.get("prompt") or tokens.get("input") or 0),
|
|
259
|
+
output_tokens=int(tokens.get("candidates") or tokens.get("output") or 0),
|
|
260
|
+
total_tokens=int(tokens.get("total") or 0),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def _resolve_model(self, request: ProviderRequest) -> str:
|
|
264
|
+
# 1. Check request.model first (from ProviderRequest constructor)
|
|
265
|
+
if request.model:
|
|
266
|
+
return str(request.model)
|
|
267
|
+
# 2. Fallback to metadata override (legacy/alternative path)
|
|
268
|
+
model_override = request.metadata.get("model") if request.metadata else None
|
|
269
|
+
if model_override:
|
|
270
|
+
return str(model_override)
|
|
271
|
+
# 3. Fallback to instance default
|
|
272
|
+
return self._model
|
|
273
|
+
|
|
274
|
+
def _emit_stream_if_requested(self, content: str, *, stream: bool) -> None:
|
|
275
|
+
if not stream or not content:
|
|
276
|
+
return
|
|
277
|
+
self._emit_stream_chunk(StreamChunk(content=content, index=0))
|
|
278
|
+
|
|
279
|
+
def _extract_error_from_output(self, stdout: str) -> Optional[str]:
|
|
280
|
+
"""
|
|
281
|
+
Extract error message from Gemini CLI output.
|
|
282
|
+
|
|
283
|
+
Gemini CLI outputs errors as text lines followed by JSON. Example:
|
|
284
|
+
'Error when talking to Gemini API Full report available at: /tmp/...
|
|
285
|
+
{"error": {"type": "Error", "message": "[object Object]", "code": 1}}'
|
|
286
|
+
|
|
287
|
+
The JSON message field is often unhelpful ("[object Object]"), so we
|
|
288
|
+
prefer the text prefix which contains the actual error description.
|
|
289
|
+
"""
|
|
290
|
+
if not stdout:
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
lines = stdout.strip().split("\n")
|
|
294
|
+
error_parts: List[str] = []
|
|
295
|
+
|
|
296
|
+
for line in lines:
|
|
297
|
+
line = line.strip()
|
|
298
|
+
if not line:
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
# Skip "Loaded cached credentials" info line
|
|
302
|
+
if line.startswith("Loaded cached"):
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
# Try to parse as JSON
|
|
306
|
+
if line.startswith("{"):
|
|
307
|
+
try:
|
|
308
|
+
payload = json.loads(line)
|
|
309
|
+
error = payload.get("error", {})
|
|
310
|
+
if isinstance(error, dict):
|
|
311
|
+
msg = error.get("message", "")
|
|
312
|
+
# Skip unhelpful "[object Object]" message
|
|
313
|
+
if msg and msg != "[object Object]":
|
|
314
|
+
error_parts.append(msg)
|
|
315
|
+
except json.JSONDecodeError:
|
|
316
|
+
pass
|
|
317
|
+
else:
|
|
318
|
+
# Text line - likely contains the actual error message
|
|
319
|
+
# Extract the part before "Full report available at:"
|
|
320
|
+
if "Full report available at:" in line:
|
|
321
|
+
line = line.split("Full report available at:")[0].strip()
|
|
322
|
+
if line:
|
|
323
|
+
error_parts.append(line)
|
|
324
|
+
|
|
325
|
+
return "; ".join(error_parts) if error_parts else None
|
|
326
|
+
|
|
327
|
+
def _execute(self, request: ProviderRequest) -> ProviderResult:
|
|
328
|
+
self._validate_request(request)
|
|
329
|
+
model = self._resolve_model(request)
|
|
330
|
+
prompt = self._build_prompt(request)
|
|
331
|
+
command = self._build_command(model)
|
|
332
|
+
timeout = request.timeout or self._timeout
|
|
333
|
+
# Pass prompt via stdin to avoid CLI argument length limits
|
|
334
|
+
completed = self._run(command, timeout=timeout, input_data=prompt)
|
|
335
|
+
|
|
336
|
+
if completed.returncode != 0:
|
|
337
|
+
stderr = (completed.stderr or "").strip()
|
|
338
|
+
logger.debug(f"Gemini CLI stderr: {stderr or 'no stderr'}")
|
|
339
|
+
|
|
340
|
+
# Extract error from stdout (Gemini outputs errors as text + JSON)
|
|
341
|
+
stdout_error = self._extract_error_from_output(completed.stdout)
|
|
342
|
+
|
|
343
|
+
error_msg = f"Gemini CLI exited with code {completed.returncode}"
|
|
344
|
+
if stdout_error:
|
|
345
|
+
error_msg += f": {stdout_error[:500]}"
|
|
346
|
+
elif stderr:
|
|
347
|
+
error_msg += f": {stderr[:500]}"
|
|
348
|
+
raise ProviderExecutionError(
|
|
349
|
+
error_msg,
|
|
350
|
+
provider=self.metadata.provider_id,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
payload = self._parse_output(completed.stdout)
|
|
354
|
+
content = str(payload.get("response") or payload.get("content") or "").strip()
|
|
355
|
+
reported_model = payload.get("model") or next(
|
|
356
|
+
iter((payload.get("stats") or {}).get("models") or {}), model
|
|
357
|
+
)
|
|
358
|
+
usage = self._extract_usage(payload)
|
|
359
|
+
|
|
360
|
+
self._emit_stream_if_requested(content, stream=request.stream)
|
|
361
|
+
|
|
362
|
+
return ProviderResult(
|
|
363
|
+
content=content,
|
|
364
|
+
provider_id=self.metadata.provider_id,
|
|
365
|
+
model_used=f"{self.metadata.provider_id}:{reported_model}",
|
|
366
|
+
status=ProviderStatus.SUCCESS,
|
|
367
|
+
tokens=usage,
|
|
368
|
+
stderr=(completed.stderr or "").strip() or None,
|
|
369
|
+
raw_payload=payload,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def is_gemini_available() -> bool:
|
|
374
|
+
"""Gemini CLI availability check."""
|
|
375
|
+
return detect_provider_availability("gemini")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def create_provider(
|
|
379
|
+
*,
|
|
380
|
+
hooks: ProviderHooks,
|
|
381
|
+
model: Optional[str] = None,
|
|
382
|
+
dependencies: Optional[Dict[str, object]] = None,
|
|
383
|
+
overrides: Optional[Dict[str, object]] = None,
|
|
384
|
+
) -> GeminiProvider:
|
|
385
|
+
"""
|
|
386
|
+
Factory used by the provider registry.
|
|
387
|
+
|
|
388
|
+
dependencies/overrides allow callers (or tests) to inject runner/env/binary.
|
|
389
|
+
"""
|
|
390
|
+
dependencies = dependencies or {}
|
|
391
|
+
overrides = overrides or {}
|
|
392
|
+
runner = dependencies.get("runner")
|
|
393
|
+
env = dependencies.get("env")
|
|
394
|
+
binary = overrides.get("binary") or dependencies.get("binary")
|
|
395
|
+
timeout = overrides.get("timeout")
|
|
396
|
+
selected_model = overrides.get("model") if overrides.get("model") else model
|
|
397
|
+
|
|
398
|
+
return GeminiProvider(
|
|
399
|
+
metadata=GEMINI_METADATA,
|
|
400
|
+
hooks=hooks,
|
|
401
|
+
model=selected_model, # type: ignore[arg-type]
|
|
402
|
+
binary=binary, # type: ignore[arg-type]
|
|
403
|
+
runner=runner if runner is not None else None, # type: ignore[arg-type]
|
|
404
|
+
env=env if env is not None else None, # type: ignore[arg-type]
|
|
405
|
+
timeout=timeout if timeout is not None else None, # type: ignore[arg-type]
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# Register the provider immediately so consumers can resolve it by id.
|
|
410
|
+
register_provider(
|
|
411
|
+
"gemini",
|
|
412
|
+
factory=create_provider,
|
|
413
|
+
metadata=GEMINI_METADATA,
|
|
414
|
+
availability_check=is_gemini_available,
|
|
415
|
+
description="Google Gemini CLI adapter",
|
|
416
|
+
tags=("cli", "text", "vision"),
|
|
417
|
+
replace=True,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
__all__ = [
|
|
422
|
+
"GeminiProvider",
|
|
423
|
+
"create_provider",
|
|
424
|
+
"is_gemini_available",
|
|
425
|
+
"GEMINI_METADATA",
|
|
426
|
+
]
|