foundry-mcp 0.7.0__py3-none-any.whl → 0.8.10__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 (54) hide show
  1. foundry_mcp/cli/__init__.py +0 -13
  2. foundry_mcp/cli/commands/session.py +1 -8
  3. foundry_mcp/cli/context.py +39 -0
  4. foundry_mcp/config.py +381 -7
  5. foundry_mcp/core/batch_operations.py +1196 -0
  6. foundry_mcp/core/discovery.py +1 -1
  7. foundry_mcp/core/llm_config.py +8 -0
  8. foundry_mcp/core/naming.py +25 -2
  9. foundry_mcp/core/prometheus.py +0 -13
  10. foundry_mcp/core/providers/__init__.py +12 -0
  11. foundry_mcp/core/providers/base.py +39 -0
  12. foundry_mcp/core/providers/claude.py +45 -1
  13. foundry_mcp/core/providers/codex.py +64 -3
  14. foundry_mcp/core/providers/cursor_agent.py +22 -3
  15. foundry_mcp/core/providers/detectors.py +34 -7
  16. foundry_mcp/core/providers/gemini.py +63 -1
  17. foundry_mcp/core/providers/opencode.py +95 -71
  18. foundry_mcp/core/providers/package-lock.json +4 -4
  19. foundry_mcp/core/providers/package.json +1 -1
  20. foundry_mcp/core/providers/validation.py +128 -0
  21. foundry_mcp/core/research/memory.py +103 -0
  22. foundry_mcp/core/research/models.py +783 -0
  23. foundry_mcp/core/research/providers/__init__.py +40 -0
  24. foundry_mcp/core/research/providers/base.py +242 -0
  25. foundry_mcp/core/research/providers/google.py +507 -0
  26. foundry_mcp/core/research/providers/perplexity.py +442 -0
  27. foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
  28. foundry_mcp/core/research/providers/tavily.py +383 -0
  29. foundry_mcp/core/research/workflows/__init__.py +5 -2
  30. foundry_mcp/core/research/workflows/base.py +106 -12
  31. foundry_mcp/core/research/workflows/consensus.py +160 -17
  32. foundry_mcp/core/research/workflows/deep_research.py +4020 -0
  33. foundry_mcp/core/responses.py +240 -0
  34. foundry_mcp/core/spec.py +1 -0
  35. foundry_mcp/core/task.py +141 -12
  36. foundry_mcp/core/validation.py +6 -1
  37. foundry_mcp/server.py +0 -52
  38. foundry_mcp/tools/unified/__init__.py +37 -18
  39. foundry_mcp/tools/unified/authoring.py +0 -33
  40. foundry_mcp/tools/unified/environment.py +202 -29
  41. foundry_mcp/tools/unified/plan.py +20 -1
  42. foundry_mcp/tools/unified/provider.py +0 -40
  43. foundry_mcp/tools/unified/research.py +644 -19
  44. foundry_mcp/tools/unified/review.py +5 -2
  45. foundry_mcp/tools/unified/review_helpers.py +16 -1
  46. foundry_mcp/tools/unified/server.py +9 -24
  47. foundry_mcp/tools/unified/task.py +528 -9
  48. {foundry_mcp-0.7.0.dist-info → foundry_mcp-0.8.10.dist-info}/METADATA +2 -1
  49. {foundry_mcp-0.7.0.dist-info → foundry_mcp-0.8.10.dist-info}/RECORD +52 -46
  50. foundry_mcp/cli/flags.py +0 -266
  51. foundry_mcp/core/feature_flags.py +0 -592
  52. {foundry_mcp-0.7.0.dist-info → foundry_mcp-0.8.10.dist-info}/WHEEL +0 -0
  53. {foundry_mcp-0.7.0.dist-info → foundry_mcp-0.8.10.dist-info}/entry_points.txt +0 -0
  54. {foundry_mcp-0.7.0.dist-info → foundry_mcp-0.8.10.dist-info}/licenses/LICENSE +0 -0
@@ -385,7 +385,7 @@ class ServerCapabilities:
385
385
  max_batch_size: int = 100
386
386
  rate_limit_headers: bool = True
387
387
  supported_formats: List[str] = field(default_factory=lambda: ["json"])
388
- feature_flags_enabled: bool = True
388
+ feature_flags_enabled: bool = False
389
389
 
390
390
  def to_dict(self) -> Dict[str, Any]:
391
391
  """
@@ -163,6 +163,14 @@ class ProviderSpec:
163
163
  "[api]provider/model or [cli]transport[:backend/model|:model]"
164
164
  )
165
165
 
166
+ @classmethod
167
+ def parse_flexible(cls, spec: str) -> "ProviderSpec":
168
+ """Parse with fallback for simple provider IDs."""
169
+ spec = spec.strip()
170
+ if spec.startswith("["):
171
+ return cls.parse(spec)
172
+ return cls(type="cli", provider=spec.lower(), raw=spec)
173
+
166
174
  def validate(self) -> List[str]:
167
175
  """Validate the provider specification.
168
176
 
@@ -4,17 +4,34 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import functools
7
+ import json
7
8
  import logging
8
9
  import time
9
10
  from typing import Any, Callable
10
11
 
11
12
  from mcp.server.fastmcp import FastMCP
13
+ from mcp.types import TextContent
12
14
 
13
15
  from foundry_mcp.core.observability import mcp_tool
14
16
 
15
17
  logger = logging.getLogger(__name__)
16
18
 
17
19
 
20
+ def _minify_response(result: dict[str, Any]) -> TextContent:
21
+ """Convert dict to TextContent with minified JSON.
22
+
23
+ Args:
24
+ result: Dictionary to serialize
25
+
26
+ Returns:
27
+ TextContent with minified JSON string
28
+ """
29
+ return TextContent(
30
+ type="text",
31
+ text=json.dumps(result, separators=(",", ":"), default=str),
32
+ )
33
+
34
+
18
35
  def canonical_tool(
19
36
  mcp: FastMCP,
20
37
  *,
@@ -45,7 +62,10 @@ def canonical_tool(
45
62
  """Async wrapper for async underlying functions."""
46
63
  start_time = time.perf_counter()
47
64
  try:
48
- return await func(*args, **kwargs)
65
+ result = await func(*args, **kwargs)
66
+ if isinstance(result, dict):
67
+ return _minify_response(result)
68
+ return result
49
69
  except Exception as e:
50
70
  duration_ms = (time.perf_counter() - start_time) * 1000
51
71
  _collect_tool_error(
@@ -64,7 +84,10 @@ def canonical_tool(
64
84
  """Sync wrapper for sync underlying functions."""
65
85
  start_time = time.perf_counter()
66
86
  try:
67
- return func(*args, **kwargs)
87
+ result = func(*args, **kwargs)
88
+ if isinstance(result, dict):
89
+ return _minify_response(result)
90
+ return result
68
91
  except Exception as e:
69
92
  duration_ms = (time.perf_counter() - start_time) * 1000
70
93
  _collect_tool_error(
@@ -159,7 +159,6 @@ class PrometheusExporter:
159
159
  # Manifest/discovery metrics
160
160
  self._manifest_tokens: Any = None
161
161
  self._manifest_tool_count: Any = None
162
- self._feature_flag_state: Any = None
163
162
 
164
163
  # Health check metrics
165
164
  self._health_status: Any = None
@@ -236,11 +235,6 @@ class PrometheusExporter:
236
235
  "Tool count for the advertised tool manifest",
237
236
  ["manifest"], # unified|legacy
238
237
  )
239
- self._feature_flag_state = Gauge(
240
- f"{ns}_feature_flag_state",
241
- "Feature flag state (1=enabled, 0=disabled)",
242
- ["flag"],
243
- )
244
238
 
245
239
  # Health check metrics
246
240
  self._health_status = Gauge(
@@ -396,13 +390,6 @@ class PrometheusExporter:
396
390
  self._manifest_tokens.labels(manifest=manifest_label).set(int(tokens))
397
391
  self._manifest_tool_count.labels(manifest=manifest_label).set(int(tool_count))
398
392
 
399
- def record_feature_flag_state(self, flag: str, enabled: bool) -> None:
400
- """Record feature flag enabled/disabled state."""
401
- if not self.is_enabled():
402
- return
403
-
404
- self._feature_flag_state.labels(flag=flag).set(1 if enabled else 0)
405
-
406
393
  # -------------------------------------------------------------------------
407
394
  # Health Check Metrics
408
395
  # -------------------------------------------------------------------------
@@ -56,6 +56,7 @@ from foundry_mcp.core.providers.base import (
56
56
  ProviderUnavailableError,
57
57
  ProviderExecutionError,
58
58
  ProviderTimeoutError,
59
+ ContextWindowError,
59
60
  # ABC
60
61
  ProviderContext,
61
62
  )
@@ -124,6 +125,11 @@ from foundry_mcp.core.providers.validation import (
124
125
  reset_rate_limiters,
125
126
  # Execution wrapper
126
127
  with_validation_and_resilience,
128
+ # Context window detection
129
+ CONTEXT_WINDOW_ERROR_PATTERNS,
130
+ is_context_window_error,
131
+ extract_token_counts,
132
+ create_context_window_guidance,
127
133
  )
128
134
 
129
135
  # ---------------------------------------------------------------------------
@@ -160,6 +166,7 @@ __all__ = [
160
166
  "ProviderUnavailableError",
161
167
  "ProviderExecutionError",
162
168
  "ProviderTimeoutError",
169
+ "ContextWindowError",
163
170
  # ABC
164
171
  "ProviderContext",
165
172
  # === Detection (detectors.py) ===
@@ -222,4 +229,9 @@ __all__ = [
222
229
  "reset_rate_limiters",
223
230
  # Execution wrapper
224
231
  "with_validation_and_resilience",
232
+ # Context window detection
233
+ "CONTEXT_WINDOW_ERROR_PATTERNS",
234
+ "is_context_window_error",
235
+ "extract_token_counts",
236
+ "create_context_window_guidance",
225
237
  ]
@@ -277,6 +277,44 @@ class ProviderTimeoutError(ProviderError):
277
277
  """Raised when a provider exceeds its allotted execution time."""
278
278
 
279
279
 
280
+ class ContextWindowError(ProviderExecutionError):
281
+ """Raised when prompt exceeds the model's context window limit.
282
+
283
+ This error indicates the prompt/context size exceeded what the model
284
+ can process. It includes token counts to help with debugging and
285
+ provides actionable guidance for resolution.
286
+
287
+ Attributes:
288
+ prompt_tokens: Estimated tokens in the prompt (if known)
289
+ max_tokens: Maximum context window size (if known)
290
+ provider: Provider that raised the error
291
+ truncation_needed: How many tokens need to be removed
292
+ """
293
+
294
+ def __init__(
295
+ self,
296
+ message: str,
297
+ *,
298
+ provider: Optional[str] = None,
299
+ prompt_tokens: Optional[int] = None,
300
+ max_tokens: Optional[int] = None,
301
+ ):
302
+ """Initialize context window error.
303
+
304
+ Args:
305
+ message: Error message describing the issue
306
+ provider: Provider ID that raised the error
307
+ prompt_tokens: Number of tokens in the prompt (if known)
308
+ max_tokens: Maximum tokens allowed (if known)
309
+ """
310
+ super().__init__(message, provider=provider)
311
+ self.prompt_tokens = prompt_tokens
312
+ self.max_tokens = max_tokens
313
+ self.truncation_needed = (
314
+ (prompt_tokens - max_tokens) if prompt_tokens and max_tokens else None
315
+ )
316
+
317
+
280
318
  # =============================================================================
281
319
  # Lifecycle Hooks
282
320
  # =============================================================================
@@ -471,6 +509,7 @@ __all__ = [
471
509
  "ProviderUnavailableError",
472
510
  "ProviderExecutionError",
473
511
  "ProviderTimeoutError",
512
+ "ContextWindowError",
474
513
  # ABC
475
514
  "ProviderContext",
476
515
  ]
@@ -314,9 +314,14 @@ class ClaudeProvider(ProviderContext):
314
314
  )
315
315
 
316
316
  def _resolve_model(self, request: ProviderRequest) -> str:
317
+ # 1. Check request.model first (from ProviderRequest constructor)
318
+ if request.model:
319
+ return str(request.model)
320
+ # 2. Fallback to metadata override (legacy/alternative path)
317
321
  model_override = request.metadata.get("model") if request.metadata else None
318
322
  if model_override:
319
323
  return str(model_override)
324
+ # 3. Fallback to instance default
320
325
  return self._model
321
326
 
322
327
  def _emit_stream_if_requested(self, content: str, *, stream: bool) -> None:
@@ -324,6 +329,36 @@ class ClaudeProvider(ProviderContext):
324
329
  return
325
330
  self._emit_stream_chunk(StreamChunk(content=content, index=0))
326
331
 
332
+ def _extract_error_from_json(self, stdout: str) -> Optional[str]:
333
+ """
334
+ Extract error message from Claude CLI JSON output.
335
+
336
+ Claude CLI outputs errors as JSON with is_error: true and error in 'result' field.
337
+ Example: {"type":"result","is_error":true,"result":"API Error: 404 {...}"}
338
+ """
339
+ if not stdout:
340
+ return None
341
+
342
+ try:
343
+ payload = json.loads(stdout.strip())
344
+ except json.JSONDecodeError:
345
+ return None
346
+
347
+ # Check for error indicator
348
+ if payload.get("is_error"):
349
+ result = payload.get("result", "")
350
+ if result:
351
+ return str(result)
352
+
353
+ # Also check for explicit error field
354
+ error = payload.get("error")
355
+ if error:
356
+ if isinstance(error, dict):
357
+ return error.get("message") or str(error)
358
+ return str(error)
359
+
360
+ return None
361
+
327
362
  def _execute(self, request: ProviderRequest) -> ProviderResult:
328
363
  self._validate_request(request)
329
364
  model = self._resolve_model(request)
@@ -334,8 +369,17 @@ class ClaudeProvider(ProviderContext):
334
369
  if completed.returncode != 0:
335
370
  stderr = (completed.stderr or "").strip()
336
371
  logger.debug(f"Claude CLI stderr: {stderr or 'no stderr'}")
372
+
373
+ # Extract error from JSON stdout (Claude outputs errors there with is_error: true)
374
+ json_error = self._extract_error_from_json(completed.stdout)
375
+
376
+ error_msg = f"Claude CLI exited with code {completed.returncode}"
377
+ if json_error:
378
+ error_msg += f": {json_error[:500]}"
379
+ elif stderr:
380
+ error_msg += f": {stderr[:500]}"
337
381
  raise ProviderExecutionError(
338
- f"Claude CLI exited with code {completed.returncode}",
382
+ error_msg,
339
383
  provider=self.metadata.provider_id,
340
384
  )
341
385
 
@@ -332,7 +332,8 @@ class CodexProvider(ProviderContext):
332
332
 
333
333
  def _build_command(self, model: str, prompt: str, attachments: List[str]) -> List[str]:
334
334
  # Note: codex CLI requires --json flag for JSONL output (non-interactive mode)
335
- command = [self._binary, "exec", "--sandbox", "read-only", "--json"]
335
+ # --skip-git-repo-check allows running outside trusted git directories
336
+ command = [self._binary, "exec", "--sandbox", "read-only", "--skip-git-repo-check", "--json"]
336
337
  if model:
337
338
  command.extend(["-m", model])
338
339
  for path in attachments:
@@ -478,10 +479,61 @@ class CodexProvider(ProviderContext):
478
479
 
479
480
  return final_content, usage, metadata, reported_model
480
481
 
482
+ def _extract_error_from_jsonl(self, stdout: str) -> Optional[str]:
483
+ """
484
+ Extract error message from Codex JSONL output.
485
+
486
+ Codex CLI outputs errors as JSONL events to stdout, not stderr.
487
+ Look for {"type":"error"} or {"type":"turn.failed"} events.
488
+ """
489
+ if not stdout:
490
+ return None
491
+
492
+ errors: List[str] = []
493
+ for line in stdout.strip().splitlines():
494
+ if not line.strip():
495
+ continue
496
+ try:
497
+ event = json.loads(line)
498
+ except json.JSONDecodeError:
499
+ continue
500
+
501
+ event_type = str(event.get("type", "")).lower()
502
+
503
+ # Extract from {"type":"error","message":"..."}
504
+ if event_type == "error":
505
+ msg = event.get("message", "")
506
+ # Skip reconnection messages, get the final error
507
+ if msg and not msg.startswith("Reconnecting"):
508
+ errors.append(msg)
509
+
510
+ # Extract from {"type":"turn.failed","error":{"message":"..."}}
511
+ elif event_type == "turn.failed":
512
+ error_obj = event.get("error", {})
513
+ if isinstance(error_obj, dict):
514
+ msg = error_obj.get("message", "")
515
+ if msg:
516
+ errors.append(msg)
517
+
518
+ # Return the last (most specific) error, or join if multiple
519
+ if errors:
520
+ # Deduplicate while preserving order
521
+ seen = set()
522
+ unique_errors = []
523
+ for e in errors:
524
+ if e not in seen:
525
+ seen.add(e)
526
+ unique_errors.append(e)
527
+ return "; ".join(unique_errors)
528
+ return None
529
+
481
530
  def _execute(self, request: ProviderRequest) -> ProviderResult:
482
531
  self._validate_request(request)
532
+ # Resolve model: request.model takes precedence, then metadata, then instance default
483
533
  model = (
484
- str(request.metadata.get("model")) if request.metadata and "model" in request.metadata else self._model
534
+ request.model
535
+ or (str(request.metadata.get("model")) if request.metadata and "model" in request.metadata else None)
536
+ or self._model
485
537
  )
486
538
  prompt = self._build_prompt(request)
487
539
  attachments = self._normalize_attachment_paths(request)
@@ -492,8 +544,17 @@ class CodexProvider(ProviderContext):
492
544
  if completed.returncode != 0:
493
545
  stderr = (completed.stderr or "").strip()
494
546
  logger.debug(f"Codex CLI stderr: {stderr or 'no stderr'}")
547
+
548
+ # Extract error message from JSONL stdout (Codex outputs errors there, not stderr)
549
+ jsonl_error = self._extract_error_from_jsonl(completed.stdout)
550
+
551
+ error_msg = f"Codex CLI exited with code {completed.returncode}"
552
+ if jsonl_error:
553
+ error_msg += f": {jsonl_error[:500]}"
554
+ elif stderr:
555
+ error_msg += f": {stderr[:500]}"
495
556
  raise ProviderExecutionError(
496
- f"Codex CLI exited with code {completed.returncode}",
557
+ error_msg,
497
558
  provider=self.metadata.provider_id,
498
559
  )
499
560
 
@@ -437,16 +437,32 @@ class CursorAgentProvider(ProviderContext):
437
437
  return retry_process, False
438
438
 
439
439
  stderr_text = (retry_process.stderr or stderr_text).strip()
440
+ # Cursor Agent outputs errors to stdout as plain text, not stderr
441
+ stdout_text = (retry_process.stdout or "").strip()
440
442
  logger.debug(f"Cursor Agent CLI stderr (retry): {stderr_text or 'no stderr'}")
443
+ error_msg = f"Cursor Agent CLI exited with code {retry_process.returncode}"
444
+ if stdout_text and not stdout_text.startswith("{"):
445
+ # Plain text error in stdout (not JSON response)
446
+ error_msg += f": {stdout_text[:500]}"
447
+ elif stderr_text:
448
+ error_msg += f": {stderr_text[:500]}"
441
449
  raise ProviderExecutionError(
442
- f"Cursor Agent CLI exited with code {retry_process.returncode}",
450
+ error_msg,
443
451
  provider=self.metadata.provider_id,
444
452
  )
445
453
 
446
454
  stderr_text = (completed.stderr or "").strip()
455
+ # Cursor Agent outputs errors to stdout as plain text, not stderr
456
+ stdout_text = (completed.stdout or "").strip()
447
457
  logger.debug(f"Cursor Agent CLI stderr: {stderr_text or 'no stderr'}")
458
+ error_msg = f"Cursor Agent CLI exited with code {completed.returncode}"
459
+ if stdout_text and not stdout_text.startswith("{"):
460
+ # Plain text error in stdout (not JSON response)
461
+ error_msg += f": {stdout_text[:500]}"
462
+ elif stderr_text:
463
+ error_msg += f": {stderr_text[:500]}"
448
464
  raise ProviderExecutionError(
449
- f"Cursor Agent CLI exited with code {completed.returncode}",
465
+ error_msg,
450
466
  provider=self.metadata.provider_id,
451
467
  )
452
468
 
@@ -492,8 +508,11 @@ class CursorAgentProvider(ProviderContext):
492
508
  provider=self.metadata.provider_id,
493
509
  )
494
510
 
511
+ # Resolve model: request.model takes precedence, then metadata, then instance default
495
512
  model = (
496
- str(request.metadata.get("model")) if request.metadata and "model" in request.metadata else self._model
513
+ request.model
514
+ or (str(request.metadata.get("model")) if request.metadata and "model" in request.metadata else None)
515
+ or self._model
497
516
  )
498
517
 
499
518
  # Backup and replace HOME config with read-only version
@@ -31,11 +31,23 @@ import logging
31
31
  import os
32
32
  import shutil
33
33
  import subprocess
34
+ import time
34
35
  from dataclasses import dataclass, field
35
- from typing import Dict, Iterable, Optional, Sequence
36
+ from typing import Dict, Iterable, Optional, Sequence, Tuple
36
37
 
37
38
  logger = logging.getLogger(__name__)
38
39
 
40
+ # Cache for provider availability: {provider_id: (is_available, timestamp)}
41
+ _AVAILABILITY_CACHE: Dict[str, Tuple[bool, float]] = {}
42
+
43
+ def _get_cache_ttl() -> float:
44
+ """Get cache TTL from config or default to 3600s."""
45
+ try:
46
+ from foundry_mcp.config import get_config
47
+ return float(get_config().providers.get("availability_cache_ttl", 3600))
48
+ except Exception:
49
+ return 3600.0
50
+
39
51
  # Environment variable for test mode (bypasses real CLI probes)
40
52
  _TEST_MODE_ENV = "FOUNDRY_PROVIDER_TEST_MODE"
41
53
 
@@ -173,13 +185,14 @@ class ProviderDetector:
173
185
 
174
186
  def is_available(self, *, use_probe: bool = True) -> bool:
175
187
  """
176
- Check whether this provider is available.
188
+ Check whether this provider is available (with caching).
177
189
 
178
190
  Resolution order:
179
- 1. Check override_env (if set, returns its boolean value)
191
+ 1. Check override_env (if set, returns its boolean value - takes precedence)
180
192
  2. In test mode, return False (no real CLI available)
181
- 3. Resolve binary via PATH
182
- 4. Optionally run health probe
193
+ 3. Check cache (if valid)
194
+ 4. Resolve binary via PATH
195
+ 5. Optionally run health probe
183
196
 
184
197
  Args:
185
198
  use_probe: When True, run health probe after finding binary.
@@ -188,7 +201,7 @@ class ProviderDetector:
188
201
  Returns:
189
202
  True if provider is available, False otherwise
190
203
  """
191
- # Check environment override first
204
+ # Check environment override first (takes precedence over cache)
192
205
  if self.override_env:
193
206
  override = _coerce_bool(os.environ.get(self.override_env))
194
207
  if override is not None:
@@ -207,6 +220,14 @@ class ProviderDetector:
207
220
  )
208
221
  return False
209
222
 
223
+ # Check cache (only for non-overridden, non-test-mode cases)
224
+ cache_key = f"{self.provider_id}:{use_probe}"
225
+ cached = _AVAILABILITY_CACHE.get(cache_key)
226
+ if cached is not None:
227
+ is_avail, cached_time = cached
228
+ if time.time() - cached_time < _get_cache_ttl():
229
+ return is_avail
230
+
210
231
  # Resolve binary path
211
232
  executable = self.resolve_binary()
212
233
  if not executable:
@@ -214,14 +235,18 @@ class ProviderDetector:
214
235
  "Provider '%s' unavailable (binary not found in PATH)",
215
236
  self.provider_id,
216
237
  )
238
+ _AVAILABILITY_CACHE[cache_key] = (False, time.time())
217
239
  return False
218
240
 
219
241
  # Skip probe if not requested
220
242
  if not use_probe:
243
+ _AVAILABILITY_CACHE[cache_key] = (True, time.time())
221
244
  return True
222
245
 
223
246
  # Run health probe
224
- return self._run_probe(executable)
247
+ result = self._run_probe(executable)
248
+ _AVAILABILITY_CACHE[cache_key] = (result, time.time())
249
+ return result
225
250
 
226
251
  def get_unavailability_reason(self, *, use_probe: bool = True) -> Optional[str]:
227
252
  """
@@ -468,7 +493,9 @@ def reset_detectors() -> None:
468
493
  Reset detectors to the default set.
469
494
 
470
495
  Primarily used by tests to restore a clean state.
496
+ Also clears the availability cache to ensure fresh detection.
471
497
  """
498
+ _AVAILABILITY_CACHE.clear()
472
499
  _reset_default_detectors()
473
500
 
474
501
 
@@ -252,9 +252,14 @@ class GeminiProvider(ProviderContext):
252
252
  )
253
253
 
254
254
  def _resolve_model(self, request: ProviderRequest) -> str:
255
+ # 1. Check request.model first (from ProviderRequest constructor)
256
+ if request.model:
257
+ return str(request.model)
258
+ # 2. Fallback to metadata override (legacy/alternative path)
255
259
  model_override = request.metadata.get("model") if request.metadata else None
256
260
  if model_override:
257
261
  return str(model_override)
262
+ # 3. Fallback to instance default
258
263
  return self._model
259
264
 
260
265
  def _emit_stream_if_requested(self, content: str, *, stream: bool) -> None:
@@ -262,6 +267,54 @@ class GeminiProvider(ProviderContext):
262
267
  return
263
268
  self._emit_stream_chunk(StreamChunk(content=content, index=0))
264
269
 
270
+ def _extract_error_from_output(self, stdout: str) -> Optional[str]:
271
+ """
272
+ Extract error message from Gemini CLI output.
273
+
274
+ Gemini CLI outputs errors as text lines followed by JSON. Example:
275
+ 'Error when talking to Gemini API Full report available at: /tmp/...
276
+ {"error": {"type": "Error", "message": "[object Object]", "code": 1}}'
277
+
278
+ The JSON message field is often unhelpful ("[object Object]"), so we
279
+ prefer the text prefix which contains the actual error description.
280
+ """
281
+ if not stdout:
282
+ return None
283
+
284
+ lines = stdout.strip().split("\n")
285
+ error_parts: List[str] = []
286
+
287
+ for line in lines:
288
+ line = line.strip()
289
+ if not line:
290
+ continue
291
+
292
+ # Skip "Loaded cached credentials" info line
293
+ if line.startswith("Loaded cached"):
294
+ continue
295
+
296
+ # Try to parse as JSON
297
+ if line.startswith("{"):
298
+ try:
299
+ payload = json.loads(line)
300
+ error = payload.get("error", {})
301
+ if isinstance(error, dict):
302
+ msg = error.get("message", "")
303
+ # Skip unhelpful "[object Object]" message
304
+ if msg and msg != "[object Object]":
305
+ error_parts.append(msg)
306
+ except json.JSONDecodeError:
307
+ pass
308
+ else:
309
+ # Text line - likely contains the actual error message
310
+ # Extract the part before "Full report available at:"
311
+ if "Full report available at:" in line:
312
+ line = line.split("Full report available at:")[0].strip()
313
+ if line:
314
+ error_parts.append(line)
315
+
316
+ return "; ".join(error_parts) if error_parts else None
317
+
265
318
  def _execute(self, request: ProviderRequest) -> ProviderResult:
266
319
  self._validate_request(request)
267
320
  model = self._resolve_model(request)
@@ -273,8 +326,17 @@ class GeminiProvider(ProviderContext):
273
326
  if completed.returncode != 0:
274
327
  stderr = (completed.stderr or "").strip()
275
328
  logger.debug(f"Gemini CLI stderr: {stderr or 'no stderr'}")
329
+
330
+ # Extract error from stdout (Gemini outputs errors as text + JSON)
331
+ stdout_error = self._extract_error_from_output(completed.stdout)
332
+
333
+ error_msg = f"Gemini CLI exited with code {completed.returncode}"
334
+ if stdout_error:
335
+ error_msg += f": {stdout_error[:500]}"
336
+ elif stderr:
337
+ error_msg += f": {stderr[:500]}"
276
338
  raise ProviderExecutionError(
277
- f"Gemini CLI exited with code {completed.returncode}",
339
+ error_msg,
278
340
  provider=self.metadata.provider_id,
279
341
  )
280
342