shotgun-sh 0.2.11.dev7__py3-none-any.whl → 0.2.23.dev1__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 shotgun-sh might be problematic. Click here for more details.

Files changed (51) hide show
  1. shotgun/agents/agent_manager.py +25 -11
  2. shotgun/agents/config/README.md +89 -0
  3. shotgun/agents/config/__init__.py +10 -1
  4. shotgun/agents/config/manager.py +287 -32
  5. shotgun/agents/config/models.py +26 -1
  6. shotgun/agents/config/provider.py +27 -0
  7. shotgun/agents/config/streaming_test.py +119 -0
  8. shotgun/agents/error/__init__.py +11 -0
  9. shotgun/agents/error/models.py +19 -0
  10. shotgun/agents/history/token_counting/anthropic.py +8 -0
  11. shotgun/agents/runner.py +230 -0
  12. shotgun/build_constants.py +1 -1
  13. shotgun/cli/context.py +43 -0
  14. shotgun/cli/error_handler.py +24 -0
  15. shotgun/cli/export.py +34 -34
  16. shotgun/cli/plan.py +34 -34
  17. shotgun/cli/research.py +17 -9
  18. shotgun/cli/specify.py +20 -19
  19. shotgun/cli/tasks.py +34 -34
  20. shotgun/exceptions.py +323 -0
  21. shotgun/llm_proxy/__init__.py +17 -0
  22. shotgun/llm_proxy/client.py +215 -0
  23. shotgun/llm_proxy/models.py +137 -0
  24. shotgun/logging_config.py +42 -0
  25. shotgun/main.py +2 -0
  26. shotgun/posthog_telemetry.py +18 -25
  27. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +3 -2
  28. shotgun/sdk/codebase.py +14 -3
  29. shotgun/sentry_telemetry.py +140 -2
  30. shotgun/settings.py +5 -0
  31. shotgun/tui/app.py +35 -10
  32. shotgun/tui/screens/chat/chat_screen.py +192 -91
  33. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +62 -11
  34. shotgun/tui/screens/chat_screen/command_providers.py +3 -2
  35. shotgun/tui/screens/chat_screen/hint_message.py +76 -1
  36. shotgun/tui/screens/chat_screen/history/chat_history.py +37 -2
  37. shotgun/tui/screens/directory_setup.py +45 -41
  38. shotgun/tui/screens/feedback.py +10 -3
  39. shotgun/tui/screens/github_issue.py +11 -2
  40. shotgun/tui/screens/model_picker.py +8 -1
  41. shotgun/tui/screens/pipx_migration.py +12 -6
  42. shotgun/tui/screens/provider_config.py +25 -8
  43. shotgun/tui/screens/shotgun_auth.py +0 -10
  44. shotgun/tui/screens/welcome.py +32 -0
  45. shotgun/tui/widgets/widget_coordinator.py +3 -2
  46. shotgun_sh-0.2.23.dev1.dist-info/METADATA +472 -0
  47. {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/RECORD +50 -42
  48. shotgun_sh-0.2.11.dev7.dist-info/METADATA +0 -130
  49. {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/WHEEL +0 -0
  50. {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/entry_points.txt +0 -0
  51. {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,215 @@
1
+ """HTTP client for LiteLLM Proxy API."""
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ import httpx
7
+ from tenacity import (
8
+ before_sleep_log,
9
+ retry,
10
+ retry_if_exception,
11
+ stop_after_attempt,
12
+ wait_exponential_jitter,
13
+ )
14
+
15
+ from shotgun.api_endpoints import LITELLM_PROXY_BASE_URL
16
+ from shotgun.logging_config import get_logger
17
+
18
+ from .models import BudgetInfo, KeyInfoResponse, TeamInfoResponse
19
+
20
+ logger = get_logger(__name__)
21
+
22
+
23
+ def _is_retryable_http_error(exception: BaseException) -> bool:
24
+ """Check if HTTP exception should trigger a retry.
25
+
26
+ Args:
27
+ exception: The exception to check
28
+
29
+ Returns:
30
+ True if the exception is a transient error that should be retried
31
+ """
32
+ # Retry on network errors and timeouts
33
+ if isinstance(exception, (httpx.RequestError, httpx.TimeoutException)):
34
+ return True
35
+
36
+ # Retry on server errors (5xx) and rate limits (429)
37
+ if isinstance(exception, httpx.HTTPStatusError):
38
+ status_code = exception.response.status_code
39
+ return status_code >= 500 or status_code == 429
40
+
41
+ # Don't retry on other errors (e.g., 4xx client errors)
42
+ return False
43
+
44
+
45
+ class LiteLLMProxyClient:
46
+ """HTTP client for LiteLLM Proxy API.
47
+
48
+ Provides methods to query budget information and key/team metadata
49
+ from a LiteLLM proxy server.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ api_key: str,
55
+ base_url: str | None = None,
56
+ timeout: float = 10.0,
57
+ ):
58
+ """Initialize LiteLLM Proxy client.
59
+
60
+ Args:
61
+ api_key: LiteLLM API key for authentication
62
+ base_url: Base URL for LiteLLM proxy. If None, uses LITELLM_PROXY_BASE_URL
63
+ timeout: Request timeout in seconds
64
+ """
65
+ self.api_key = api_key
66
+ self.base_url = base_url or LITELLM_PROXY_BASE_URL
67
+ self.timeout = timeout
68
+
69
+ @retry(
70
+ stop=stop_after_attempt(3),
71
+ wait=wait_exponential_jitter(initial=1, max=8),
72
+ retry=retry_if_exception(_is_retryable_http_error),
73
+ before_sleep=before_sleep_log(logger, logging.WARNING),
74
+ reraise=True,
75
+ )
76
+ async def _request_with_retry(
77
+ self,
78
+ method: str,
79
+ url: str,
80
+ **kwargs: Any,
81
+ ) -> httpx.Response:
82
+ """Make async HTTP request with exponential backoff retry and jitter.
83
+
84
+ Uses tenacity to retry on transient errors (5xx, 429, network errors)
85
+ with exponential backoff and jitter. Client errors (4xx except 429)
86
+ are not retried.
87
+
88
+ Args:
89
+ method: HTTP method (GET, POST, etc.)
90
+ url: Request URL
91
+ **kwargs: Additional arguments to pass to httpx request
92
+
93
+ Returns:
94
+ HTTP response
95
+
96
+ Raises:
97
+ httpx.HTTPError: If request fails after all retries
98
+ """
99
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
100
+ response = await client.request(method, url, **kwargs)
101
+ response.raise_for_status()
102
+ return response
103
+
104
+ async def get_key_info(self) -> KeyInfoResponse:
105
+ """Get key information from LiteLLM proxy.
106
+
107
+ Returns:
108
+ Key information including spend, budget, and team_id
109
+
110
+ Raises:
111
+ httpx.HTTPError: If request fails
112
+ """
113
+ url = f"{self.base_url}/key/info"
114
+ params = {"key": self.api_key}
115
+ headers = {"Authorization": f"Bearer {self.api_key}"}
116
+
117
+ logger.debug("Fetching key info from %s", url)
118
+
119
+ response = await self._request_with_retry(
120
+ "GET", url, params=params, headers=headers
121
+ )
122
+
123
+ data = response.json()
124
+ result = KeyInfoResponse.model_validate(data)
125
+
126
+ logger.info(
127
+ "Successfully fetched key info: key_alias=%s, team_id=%s",
128
+ result.info.key_alias,
129
+ result.info.team_id,
130
+ )
131
+ return result
132
+
133
+ async def get_team_info(self, team_id: str) -> TeamInfoResponse:
134
+ """Get team information from LiteLLM proxy.
135
+
136
+ Args:
137
+ team_id: Team identifier
138
+
139
+ Returns:
140
+ Team information including spend and budget
141
+
142
+ Raises:
143
+ httpx.HTTPError: If request fails
144
+ """
145
+ url = f"{self.base_url}/team/info"
146
+ params = {"team_id": team_id}
147
+ headers = {"Authorization": f"Bearer {self.api_key}"}
148
+
149
+ logger.debug("Fetching team info from %s for team_id=%s", url, team_id)
150
+
151
+ response = await self._request_with_retry(
152
+ "GET", url, params=params, headers=headers
153
+ )
154
+
155
+ data = response.json()
156
+ result = TeamInfoResponse.model_validate(data)
157
+
158
+ logger.info(
159
+ "Successfully fetched team info: team_alias=%s",
160
+ result.team_info.team_alias,
161
+ )
162
+ return result
163
+
164
+ async def get_budget_info(self) -> BudgetInfo:
165
+ """Get team-level budget information for this key.
166
+
167
+ Budget is always configured at the team level, never at the key level.
168
+ This method fetches the team_id from the key info, then retrieves
169
+ the team's budget information.
170
+
171
+ Returns:
172
+ Team-level budget information
173
+
174
+ Raises:
175
+ httpx.HTTPError: If request fails
176
+ ValueError: If team has no budget configured
177
+ """
178
+ logger.debug("Fetching budget info")
179
+
180
+ # Get key info to retrieve team_id
181
+ key_response = await self.get_key_info()
182
+ key_info = key_response.info
183
+
184
+ # Fetch team budget (budget is always at team level)
185
+ logger.debug(
186
+ "Fetching team budget for team_id=%s",
187
+ key_info.team_id,
188
+ )
189
+ team_response = await self.get_team_info(key_info.team_id)
190
+ team_info = team_response.team_info
191
+
192
+ if team_info.max_budget is None:
193
+ raise ValueError(
194
+ f"Team (team_id={key_info.team_id}) has no max_budget configured"
195
+ )
196
+
197
+ logger.debug("Using team-level budget: $%.6f", team_info.max_budget)
198
+ return BudgetInfo.from_team_info(team_info)
199
+
200
+
201
+ # Convenience function for standalone use
202
+ async def get_budget_info(api_key: str, base_url: str | None = None) -> BudgetInfo:
203
+ """Get budget information for an API key.
204
+
205
+ Convenience function that creates a client and calls get_budget_info.
206
+
207
+ Args:
208
+ api_key: LiteLLM API key
209
+ base_url: Optional base URL for LiteLLM proxy
210
+
211
+ Returns:
212
+ Budget information
213
+ """
214
+ client = LiteLLMProxyClient(api_key, base_url=base_url)
215
+ return await client.get_budget_info()
@@ -0,0 +1,137 @@
1
+ """Pydantic models for LiteLLM Proxy API."""
2
+
3
+ from enum import StrEnum
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class BudgetSource(StrEnum):
9
+ """Source of budget information."""
10
+
11
+ KEY = "key"
12
+ TEAM = "team"
13
+
14
+
15
+ class KeyInfoData(BaseModel):
16
+ """Key information data from /key/info endpoint."""
17
+
18
+ key_name: str = Field(description="Key name/identifier")
19
+ key_alias: str | None = Field(default=None, description="Human-readable key alias")
20
+ spend: float = Field(description="Current spend for this key in USD")
21
+ max_budget: float | None = Field(
22
+ default=None, description="Maximum budget for this key in USD"
23
+ )
24
+ team_id: str = Field(description="Team ID associated with this key")
25
+ user_id: str = Field(description="User ID associated with this key")
26
+ models: list[str] = Field(
27
+ default_factory=list, description="List of models available to this key"
28
+ )
29
+
30
+
31
+ class KeyInfoResponse(BaseModel):
32
+ """Response from /key/info endpoint."""
33
+
34
+ key: str = Field(description="The API key")
35
+ info: KeyInfoData = Field(description="Key information data")
36
+
37
+
38
+ class TeamInfoData(BaseModel):
39
+ """Team information data from /team/info endpoint."""
40
+
41
+ team_id: str = Field(description="Team identifier")
42
+ team_alias: str | None = Field(
43
+ default=None, description="Human-readable team alias"
44
+ )
45
+ max_budget: float | None = Field(
46
+ default=None, description="Maximum budget for this team in USD"
47
+ )
48
+ spend: float = Field(description="Current spend for this team in USD")
49
+ models: list[str] = Field(
50
+ default_factory=list, description="List of models available to this team"
51
+ )
52
+
53
+
54
+ class TeamInfoResponse(BaseModel):
55
+ """Response from /team/info endpoint."""
56
+
57
+ team_id: str = Field(description="Team identifier")
58
+ team_info: TeamInfoData = Field(description="Team information data")
59
+
60
+
61
+ class BudgetInfo(BaseModel):
62
+ """Unified budget information.
63
+
64
+ Combines key and team budget information to provide a single view
65
+ of budget status. Budget can come from either key-level or team-level,
66
+ with key-level taking priority if set.
67
+ """
68
+
69
+ max_budget: float = Field(description="Maximum budget in USD")
70
+ spend: float = Field(description="Current spend in USD")
71
+ remaining: float = Field(description="Remaining budget in USD")
72
+ source: BudgetSource = Field(
73
+ description="Source of budget information (key or team)"
74
+ )
75
+ percentage_used: float = Field(description="Percentage of budget used (0-100)")
76
+
77
+ @classmethod
78
+ def from_key_info(cls, key_info: KeyInfoData) -> "BudgetInfo":
79
+ """Create BudgetInfo from key-level budget.
80
+
81
+ Args:
82
+ key_info: Key information containing budget data
83
+
84
+ Returns:
85
+ BudgetInfo instance with key-level budget
86
+
87
+ Raises:
88
+ ValueError: If key does not have max_budget set
89
+ """
90
+ if key_info.max_budget is None:
91
+ raise ValueError("Key does not have max_budget set")
92
+
93
+ remaining = key_info.max_budget - key_info.spend
94
+ percentage_used = (
95
+ (key_info.spend / key_info.max_budget * 100)
96
+ if key_info.max_budget > 0
97
+ else 0.0
98
+ )
99
+
100
+ return cls(
101
+ max_budget=key_info.max_budget,
102
+ spend=key_info.spend,
103
+ remaining=remaining,
104
+ source=BudgetSource.KEY,
105
+ percentage_used=percentage_used,
106
+ )
107
+
108
+ @classmethod
109
+ def from_team_info(cls, team_info: TeamInfoData) -> "BudgetInfo":
110
+ """Create BudgetInfo from team-level budget.
111
+
112
+ Args:
113
+ team_info: Team information containing budget data
114
+
115
+ Returns:
116
+ BudgetInfo instance with team-level budget
117
+
118
+ Raises:
119
+ ValueError: If team does not have max_budget set
120
+ """
121
+ if team_info.max_budget is None:
122
+ raise ValueError("Team does not have max_budget set")
123
+
124
+ remaining = team_info.max_budget - team_info.spend
125
+ percentage_used = (
126
+ (team_info.spend / team_info.max_budget * 100)
127
+ if team_info.max_budget > 0
128
+ else 0.0
129
+ )
130
+
131
+ return cls(
132
+ max_budget=team_info.max_budget,
133
+ spend=team_info.spend,
134
+ remaining=remaining,
135
+ source=BudgetSource.TEAM,
136
+ percentage_used=percentage_used,
137
+ )
shotgun/logging_config.py CHANGED
@@ -27,6 +27,44 @@ def get_log_directory() -> Path:
27
27
  return log_dir
28
28
 
29
29
 
30
+ def cleanup_old_log_files(log_dir: Path, max_files: int) -> None:
31
+ """Remove old log files, keeping only the most recent ones.
32
+
33
+ Also removes the legacy shotgun.log file if it exists.
34
+
35
+ Args:
36
+ log_dir: Directory containing log files
37
+ max_files: Maximum number of log files to keep
38
+ """
39
+ try:
40
+ # Remove legacy non-timestamped log file if it exists
41
+ legacy_log = log_dir / "shotgun.log"
42
+ if legacy_log.exists():
43
+ try:
44
+ legacy_log.unlink()
45
+ except OSError:
46
+ pass # noqa: S110
47
+
48
+ # Find all shotgun log files
49
+ log_files = sorted(
50
+ log_dir.glob("shotgun-*.log"),
51
+ key=lambda p: p.stat().st_mtime,
52
+ reverse=True, # Newest first
53
+ )
54
+
55
+ # Remove files beyond the limit
56
+ files_to_delete = log_files[max_files:]
57
+ for log_file in files_to_delete:
58
+ try:
59
+ log_file.unlink()
60
+ except OSError:
61
+ # Ignore errors when deleting individual files
62
+ pass # noqa: S110
63
+ except Exception: # noqa: S110
64
+ # Silently fail - log cleanup shouldn't break the application
65
+ pass
66
+
67
+
30
68
  class ColoredFormatter(logging.Formatter):
31
69
  """Custom formatter with colors for different log levels."""
32
70
 
@@ -123,6 +161,10 @@ def setup_logger(
123
161
  try:
124
162
  # Create file handler with ISO8601 timestamp for each run
125
163
  log_dir = get_log_directory()
164
+
165
+ # Clean up old log files before creating a new one
166
+ cleanup_old_log_files(log_dir, settings.logging.max_log_files)
167
+
126
168
  log_file = log_dir / f"shotgun-{_RUN_TIMESTAMP}.log"
127
169
 
128
170
  # Use regular FileHandler - each run gets its own isolated log file
shotgun/main.py CHANGED
@@ -55,6 +55,8 @@ logger = get_logger(__name__)
55
55
  logger.debug("Logfire observability enabled: %s", _logfire_enabled)
56
56
 
57
57
  # Initialize configuration
58
+ # Note: If config migration fails, ConfigManager will auto-create fresh config
59
+ # and set migration_failed flag for user notification
58
60
  try:
59
61
  import asyncio
60
62
 
@@ -18,6 +18,9 @@ logger = get_early_logger(__name__)
18
18
  # Global PostHog client instance
19
19
  _posthog_client = None
20
20
 
21
+ # Cache the shotgun instance ID to avoid async calls during event tracking
22
+ _shotgun_instance_id: str | None = None
23
+
21
24
 
22
25
  def setup_posthog_observability() -> bool:
23
26
  """Set up PostHog analytics for usage tracking.
@@ -25,7 +28,7 @@ def setup_posthog_observability() -> bool:
25
28
  Returns:
26
29
  True if PostHog was successfully set up, False otherwise
27
30
  """
28
- global _posthog_client
31
+ global _posthog_client, _shotgun_instance_id
29
32
 
30
33
  try:
31
34
  # Check if PostHog is already initialized
@@ -57,31 +60,20 @@ def setup_posthog_observability() -> bool:
57
60
  # Store the client for later use
58
61
  _posthog_client = posthog
59
62
 
60
- # Set user context with anonymous shotgun instance ID from config
63
+ # Cache the shotgun instance ID for later use (avoids async issues)
61
64
  try:
62
65
  import asyncio
63
66
 
64
67
  config_manager = get_config_manager()
65
- shotgun_instance_id = asyncio.run(config_manager.get_shotgun_instance_id())
66
-
67
- # Identify the user in PostHog
68
- posthog.identify( # type: ignore[attr-defined]
69
- distinct_id=shotgun_instance_id,
70
- properties={
71
- "version": __version__,
72
- "environment": environment,
73
- },
74
- )
75
-
76
- # Set default properties for all events
77
- posthog.disabled = False
78
- posthog.personal_api_key = None # Not needed for event tracking
68
+ _shotgun_instance_id = asyncio.run(config_manager.get_shotgun_instance_id())
79
69
 
80
70
  logger.debug(
81
- "PostHog user identified with anonymous ID: %s", shotgun_instance_id
71
+ "PostHog initialized with shotgun instance ID: %s",
72
+ _shotgun_instance_id,
82
73
  )
83
74
  except Exception as e:
84
- logger.warning("Failed to set user context: %s", e)
75
+ logger.warning("Failed to load shotgun instance ID: %s", e)
76
+ # Continue anyway - we'll try to get it during event tracking
85
77
 
86
78
  logger.debug(
87
79
  "PostHog analytics configured successfully (environment: %s, version: %s)",
@@ -102,18 +94,19 @@ def track_event(event_name: str, properties: dict[str, Any] | None = None) -> No
102
94
  event_name: Name of the event to track
103
95
  properties: Optional properties to include with the event
104
96
  """
105
- global _posthog_client
97
+ global _posthog_client, _shotgun_instance_id
106
98
 
107
99
  if _posthog_client is None:
108
100
  logger.debug("PostHog not initialized, skipping event: %s", event_name)
109
101
  return
110
102
 
111
103
  try:
112
- import asyncio
113
-
114
- # Get shotgun instance ID for tracking
115
- config_manager = get_config_manager()
116
- shotgun_instance_id = asyncio.run(config_manager.get_shotgun_instance_id())
104
+ # Use cached instance ID (loaded during setup)
105
+ if _shotgun_instance_id is None:
106
+ logger.warning(
107
+ "Shotgun instance ID not available, skipping event: %s", event_name
108
+ )
109
+ return
117
110
 
118
111
  # Add version and environment to properties
119
112
  if properties is None:
@@ -128,7 +121,7 @@ def track_event(event_name: str, properties: dict[str, Any] | None = None) -> No
128
121
 
129
122
  # Track the event using PostHog's capture method
130
123
  _posthog_client.capture(
131
- distinct_id=shotgun_instance_id, event=event_name, properties=properties
124
+ distinct_id=_shotgun_instance_id, event=event_name, properties=properties
132
125
  )
133
126
  logger.debug("Tracked PostHog event: %s", event_name)
134
127
  except Exception as e:
@@ -7,10 +7,11 @@ Your extensive expertise spans, among other things:
7
7
  ## KEY RULES
8
8
 
9
9
  {% if interactive_mode %}
10
- 0. Always ask CLARIFYING QUESTIONS using structured output if the user's request is ambiguous or lacks sufficient detail.
10
+ 0. Always ask CLARIFYING QUESTIONS using structured output before doing work.
11
11
  - Return your response with the clarifying_questions field populated
12
- - Do not make assumptions about what the user wants
12
+ - Do not make assumptions about what the user wants, get a clear understanding first.
13
13
  - Questions should be clear, specific, and answerable
14
+ - Do not ask too many questions that might overwhelm the user; prioritize the most important ones.
14
15
  {% endif %}
15
16
  1. Above all, prefer using tools to do the work and NEVER respond with text.
16
17
  2. IMPORTANT: Always ask for review and go ahead to move forward after using write_file().
shotgun/sdk/codebase.py CHANGED
@@ -93,6 +93,19 @@ class CodebaseSDK:
93
93
  if indexed_from_cwd is None:
94
94
  indexed_from_cwd = str(Path.cwd().resolve())
95
95
 
96
+ # Track codebase indexing started event
97
+ source = detect_source()
98
+ logger.debug(
99
+ "Tracking codebase_index_started event: source=%s",
100
+ source,
101
+ )
102
+ track_event(
103
+ "codebase_index_started",
104
+ {
105
+ "source": source,
106
+ },
107
+ )
108
+
96
109
  graph = await self.service.create_graph(
97
110
  resolved_path,
98
111
  name,
@@ -101,9 +114,7 @@ class CodebaseSDK:
101
114
  )
102
115
  file_count = sum(graph.language_stats.values()) if graph.language_stats else 0
103
116
 
104
- # Track codebase indexing event
105
- # Detect if called from TUI by checking the call stack
106
- source = detect_source()
117
+ # Track codebase indexing completion event (reuse source from start event)
107
118
 
108
119
  logger.debug(
109
120
  "Tracking codebase_indexed event: file_count=%d, node_count=%d, relationship_count=%d, source=%s",