shotgun-sh 0.2.17__py3-none-any.whl → 0.4.0.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.
Files changed (150) hide show
  1. shotgun/agents/agent_manager.py +219 -37
  2. shotgun/agents/common.py +79 -78
  3. shotgun/agents/config/README.md +89 -0
  4. shotgun/agents/config/__init__.py +10 -1
  5. shotgun/agents/config/manager.py +364 -53
  6. shotgun/agents/config/models.py +101 -21
  7. shotgun/agents/config/provider.py +51 -13
  8. shotgun/agents/config/streaming_test.py +119 -0
  9. shotgun/agents/context_analyzer/analyzer.py +6 -2
  10. shotgun/agents/conversation/__init__.py +18 -0
  11. shotgun/agents/conversation/filters.py +164 -0
  12. shotgun/agents/conversation/history/chunking.py +278 -0
  13. shotgun/agents/{history → conversation/history}/compaction.py +27 -1
  14. shotgun/agents/{history → conversation/history}/constants.py +5 -0
  15. shotgun/agents/conversation/history/file_content_deduplication.py +239 -0
  16. shotgun/agents/{history → conversation/history}/history_processors.py +267 -3
  17. shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +8 -0
  18. shotgun/agents/{conversation_manager.py → conversation/manager.py} +1 -1
  19. shotgun/agents/{conversation_history.py → conversation/models.py} +8 -94
  20. shotgun/agents/error/__init__.py +11 -0
  21. shotgun/agents/error/models.py +19 -0
  22. shotgun/agents/export.py +12 -13
  23. shotgun/agents/models.py +66 -1
  24. shotgun/agents/plan.py +12 -13
  25. shotgun/agents/research.py +13 -10
  26. shotgun/agents/router/__init__.py +47 -0
  27. shotgun/agents/router/models.py +376 -0
  28. shotgun/agents/router/router.py +185 -0
  29. shotgun/agents/router/tools/__init__.py +18 -0
  30. shotgun/agents/router/tools/delegation_tools.py +503 -0
  31. shotgun/agents/router/tools/plan_tools.py +322 -0
  32. shotgun/agents/runner.py +230 -0
  33. shotgun/agents/specify.py +12 -13
  34. shotgun/agents/tasks.py +12 -13
  35. shotgun/agents/tools/file_management.py +49 -1
  36. shotgun/agents/tools/registry.py +2 -0
  37. shotgun/agents/tools/web_search/__init__.py +1 -2
  38. shotgun/agents/tools/web_search/gemini.py +1 -3
  39. shotgun/agents/tools/web_search/openai.py +1 -1
  40. shotgun/build_constants.py +2 -2
  41. shotgun/cli/clear.py +1 -1
  42. shotgun/cli/compact.py +5 -3
  43. shotgun/cli/context.py +44 -1
  44. shotgun/cli/error_handler.py +24 -0
  45. shotgun/cli/export.py +34 -34
  46. shotgun/cli/plan.py +34 -34
  47. shotgun/cli/research.py +17 -9
  48. shotgun/cli/spec/__init__.py +5 -0
  49. shotgun/cli/spec/backup.py +81 -0
  50. shotgun/cli/spec/commands.py +132 -0
  51. shotgun/cli/spec/models.py +48 -0
  52. shotgun/cli/spec/pull_service.py +219 -0
  53. shotgun/cli/specify.py +20 -19
  54. shotgun/cli/tasks.py +34 -34
  55. shotgun/codebase/core/change_detector.py +1 -1
  56. shotgun/codebase/core/ingestor.py +154 -8
  57. shotgun/codebase/core/manager.py +1 -1
  58. shotgun/codebase/models.py +2 -0
  59. shotgun/exceptions.py +325 -0
  60. shotgun/llm_proxy/__init__.py +17 -0
  61. shotgun/llm_proxy/client.py +215 -0
  62. shotgun/llm_proxy/models.py +137 -0
  63. shotgun/logging_config.py +42 -0
  64. shotgun/main.py +4 -0
  65. shotgun/posthog_telemetry.py +1 -1
  66. shotgun/prompts/agents/export.j2 +2 -0
  67. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +23 -3
  68. shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
  69. shotgun/prompts/agents/partials/router_delegation_mode.j2 +36 -0
  70. shotgun/prompts/agents/plan.j2 +29 -1
  71. shotgun/prompts/agents/research.j2 +75 -23
  72. shotgun/prompts/agents/router.j2 +440 -0
  73. shotgun/prompts/agents/specify.j2 +80 -4
  74. shotgun/prompts/agents/state/system_state.j2 +15 -8
  75. shotgun/prompts/agents/tasks.j2 +63 -23
  76. shotgun/prompts/history/chunk_summarization.j2 +34 -0
  77. shotgun/prompts/history/combine_summaries.j2 +53 -0
  78. shotgun/sdk/codebase.py +14 -3
  79. shotgun/settings.py +5 -0
  80. shotgun/shotgun_web/__init__.py +67 -1
  81. shotgun/shotgun_web/client.py +42 -1
  82. shotgun/shotgun_web/constants.py +46 -0
  83. shotgun/shotgun_web/exceptions.py +29 -0
  84. shotgun/shotgun_web/models.py +390 -0
  85. shotgun/shotgun_web/shared_specs/__init__.py +32 -0
  86. shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
  87. shotgun/shotgun_web/shared_specs/hasher.py +83 -0
  88. shotgun/shotgun_web/shared_specs/models.py +71 -0
  89. shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
  90. shotgun/shotgun_web/shared_specs/utils.py +34 -0
  91. shotgun/shotgun_web/specs_client.py +703 -0
  92. shotgun/shotgun_web/supabase_client.py +31 -0
  93. shotgun/tui/app.py +78 -15
  94. shotgun/tui/components/mode_indicator.py +120 -25
  95. shotgun/tui/components/status_bar.py +2 -2
  96. shotgun/tui/containers.py +1 -1
  97. shotgun/tui/dependencies.py +64 -9
  98. shotgun/tui/layout.py +5 -0
  99. shotgun/tui/protocols.py +37 -0
  100. shotgun/tui/screens/chat/chat.tcss +9 -1
  101. shotgun/tui/screens/chat/chat_screen.py +1015 -106
  102. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +196 -17
  103. shotgun/tui/screens/chat_screen/command_providers.py +13 -89
  104. shotgun/tui/screens/chat_screen/hint_message.py +76 -1
  105. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  106. shotgun/tui/screens/chat_screen/history/chat_history.py +12 -0
  107. shotgun/tui/screens/chat_screen/history/formatters.py +53 -15
  108. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  109. shotgun/tui/screens/chat_screen/messages.py +219 -0
  110. shotgun/tui/screens/confirmation_dialog.py +40 -0
  111. shotgun/tui/screens/directory_setup.py +45 -41
  112. shotgun/tui/screens/feedback.py +10 -3
  113. shotgun/tui/screens/github_issue.py +11 -2
  114. shotgun/tui/screens/model_picker.py +28 -8
  115. shotgun/tui/screens/onboarding.py +179 -26
  116. shotgun/tui/screens/pipx_migration.py +58 -6
  117. shotgun/tui/screens/provider_config.py +66 -8
  118. shotgun/tui/screens/shared_specs/__init__.py +21 -0
  119. shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
  120. shotgun/tui/screens/shared_specs/models.py +56 -0
  121. shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
  122. shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
  123. shotgun/tui/screens/shotgun_auth.py +110 -16
  124. shotgun/tui/screens/spec_pull.py +288 -0
  125. shotgun/tui/screens/welcome.py +123 -0
  126. shotgun/tui/services/conversation_service.py +5 -2
  127. shotgun/tui/utils/mode_progress.py +20 -86
  128. shotgun/tui/widgets/__init__.py +2 -1
  129. shotgun/tui/widgets/approval_widget.py +152 -0
  130. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  131. shotgun/tui/widgets/plan_panel.py +129 -0
  132. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  133. shotgun/tui/widgets/widget_coordinator.py +1 -1
  134. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/METADATA +11 -4
  135. shotgun_sh-0.4.0.dev1.dist-info/RECORD +242 -0
  136. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/WHEEL +1 -1
  137. shotgun_sh-0.2.17.dist-info/RECORD +0 -194
  138. /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
  139. /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
  140. /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
  141. /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
  142. /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
  143. /shotgun/agents/{history → conversation/history}/token_counting/base.py +0 -0
  144. /shotgun/agents/{history → conversation/history}/token_counting/openai.py +0 -0
  145. /shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +0 -0
  146. /shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +0 -0
  147. /shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -0
  148. /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
  149. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/entry_points.txt +0 -0
  150. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/licenses/LICENSE +0 -0
shotgun/exceptions.py CHANGED
@@ -1,13 +1,57 @@
1
1
  """General exceptions for Shotgun application."""
2
2
 
3
+ from shotgun.utils import get_shotgun_home
4
+
5
+ # Shotgun Account signup URL for BYOK users
6
+ SHOTGUN_SIGNUP_URL = "https://shotgun.sh"
7
+ SHOTGUN_CONTACT_EMAIL = "contact@shotgun.sh"
8
+
3
9
 
4
10
  class ErrorNotPickedUpBySentry(Exception): # noqa: N818
5
11
  """Base for user-actionable errors that shouldn't be sent to Sentry.
6
12
 
7
13
  These errors represent expected user conditions requiring action
8
14
  rather than bugs that need tracking.
15
+
16
+ All subclasses should implement to_markdown() and to_plain_text() methods
17
+ for consistent error message formatting.
9
18
  """
10
19
 
20
+ def to_markdown(self) -> str:
21
+ """Generate markdown-formatted error message for TUI.
22
+
23
+ Subclasses should override this method.
24
+ """
25
+ return f"⚠️ {str(self)}"
26
+
27
+ def to_plain_text(self) -> str:
28
+ """Generate plain text error message for CLI.
29
+
30
+ Subclasses should override this method.
31
+ """
32
+ return f"⚠️ {str(self)}"
33
+
34
+
35
+ # ============================================================================
36
+ # User Action Required Errors
37
+ # ============================================================================
38
+
39
+
40
+ class AgentCancelledException(ErrorNotPickedUpBySentry):
41
+ """Raised when user cancels an agent operation."""
42
+
43
+ def __init__(self) -> None:
44
+ """Initialize the exception."""
45
+ super().__init__("Operation cancelled by user")
46
+
47
+ def to_markdown(self) -> str:
48
+ """Generate markdown-formatted error message for TUI."""
49
+ return "⚠️ Operation cancelled by user"
50
+
51
+ def to_plain_text(self) -> str:
52
+ """Generate plain text error message for CLI."""
53
+ return "⚠️ Operation cancelled by user"
54
+
11
55
 
12
56
  class ContextSizeLimitExceeded(ErrorNotPickedUpBySentry):
13
57
  """Raised when conversation context exceeds the model's limits.
@@ -30,3 +74,284 @@ class ContextSizeLimitExceeded(ErrorNotPickedUpBySentry):
30
74
  super().__init__(
31
75
  f"Context too large for {model_name} (limit: {max_tokens:,} tokens)"
32
76
  )
77
+
78
+ def to_markdown(self) -> str:
79
+ """Generate markdown-formatted error message for TUI."""
80
+ return (
81
+ f"⚠️ **Context too large for {self.model_name}**\n\n"
82
+ f"Your conversation history exceeds this model's limit ({self.max_tokens:,} tokens).\n\n"
83
+ f"**Choose an action:**\n\n"
84
+ f"1. Switch to a larger model (`Ctrl+P` → Change Model)\n"
85
+ f"2. Switch to a larger model, compact (`/compact`), then switch back to {self.model_name}\n"
86
+ f"3. Clear conversation (`/clear`)\n"
87
+ )
88
+
89
+ def to_plain_text(self) -> str:
90
+ """Generate plain text error message for CLI."""
91
+ return (
92
+ f"⚠️ Context too large for {self.model_name}\n\n"
93
+ f"Your conversation history exceeds this model's limit ({self.max_tokens:,} tokens).\n\n"
94
+ f"Choose an action:\n"
95
+ f"1. Switch to a larger model\n"
96
+ f"2. Switch to a larger model, compact, then switch back to {self.model_name}\n"
97
+ f"3. Clear conversation\n"
98
+ )
99
+
100
+
101
+ # ============================================================================
102
+ # Shotgun Account Errors (show contact email in TUI)
103
+ # ============================================================================
104
+
105
+
106
+ class ShotgunAccountException(ErrorNotPickedUpBySentry):
107
+ """Base class for Shotgun Account service errors.
108
+
109
+ TUI will check isinstance() of this class to show contact email UI.
110
+ """
111
+
112
+
113
+ class BudgetExceededException(ShotgunAccountException):
114
+ """Raised when Shotgun Account budget has been exceeded.
115
+
116
+ This is a user-actionable error - they need to contact support
117
+ to increase their budget limit. This is a temporary exception
118
+ until self-service budget increases are implemented.
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ current_cost: float | None = None,
124
+ max_budget: float | None = None,
125
+ message: str | None = None,
126
+ ):
127
+ """Initialize the exception.
128
+
129
+ Args:
130
+ current_cost: Current total spend/cost (optional)
131
+ max_budget: Maximum budget limit (optional)
132
+ message: Optional custom error message from API
133
+ """
134
+ self.current_cost = current_cost
135
+ self.max_budget = max_budget
136
+ self.api_message = message
137
+
138
+ if message:
139
+ error_msg = message
140
+ elif current_cost is not None and max_budget is not None:
141
+ error_msg = f"Budget exceeded: ${current_cost:.2f} / ${max_budget:.2f}"
142
+ else:
143
+ error_msg = "Budget exceeded"
144
+
145
+ super().__init__(error_msg)
146
+
147
+ def to_markdown(self) -> str:
148
+ """Generate markdown-formatted error message for TUI.
149
+
150
+ Note: TUI will detect ShotgunAccountException and automatically
151
+ show email contact UI component.
152
+ """
153
+ return (
154
+ "⚠️ **Your Shotgun Account budget has been exceeded!**\n\n"
155
+ "Your account has reached its spending limit and cannot process more requests.\n\n"
156
+ "**Action Required:** Top up your account to continue using Shotgun.\n\n"
157
+ "👉 **[Top Up Now at https://app.shotgun.sh/dashboard](https://app.shotgun.sh/dashboard)**\n\n"
158
+ "**Need help?** Contact us if you have questions about your budget.\n\n"
159
+ f"_Error details: {str(self)}_"
160
+ )
161
+
162
+ def to_plain_text(self) -> str:
163
+ """Generate plain text error message for CLI."""
164
+ return (
165
+ "⚠️ Your Shotgun Account budget has been exceeded!\n\n"
166
+ "Your account has reached its spending limit and cannot process more requests.\n\n"
167
+ "Action Required: Top up your account to continue using Shotgun.\n\n"
168
+ "→ Top Up Now: https://app.shotgun.sh/dashboard\n\n"
169
+ f"Need help? Contact: {SHOTGUN_CONTACT_EMAIL}\n\n"
170
+ f"Error details: {str(self)}"
171
+ )
172
+
173
+
174
+ class ShotgunServiceOverloadException(ShotgunAccountException):
175
+ """Raised when Shotgun Account AI service is overloaded."""
176
+
177
+ def __init__(self, message: str | None = None):
178
+ """Initialize the exception.
179
+
180
+ Args:
181
+ message: Optional custom error message from API
182
+ """
183
+ super().__init__(message or "Service temporarily overloaded")
184
+
185
+ def to_markdown(self) -> str:
186
+ """Generate markdown-formatted error message for TUI."""
187
+ return "⚠️ The AI service is temporarily overloaded. Please wait a moment and try again."
188
+
189
+ def to_plain_text(self) -> str:
190
+ """Generate plain text error message for CLI."""
191
+ return "⚠️ The AI service is temporarily overloaded. Please wait a moment and try again."
192
+
193
+
194
+ class ShotgunRateLimitException(ShotgunAccountException):
195
+ """Raised when Shotgun Account rate limit is reached."""
196
+
197
+ def __init__(self, message: str | None = None):
198
+ """Initialize the exception.
199
+
200
+ Args:
201
+ message: Optional custom error message from API
202
+ """
203
+ super().__init__(message or "Rate limit reached")
204
+
205
+ def to_markdown(self) -> str:
206
+ """Generate markdown-formatted error message for TUI."""
207
+ return "⚠️ Rate limit reached. Please wait before trying again."
208
+
209
+ def to_plain_text(self) -> str:
210
+ """Generate plain text error message for CLI."""
211
+ return "⚠️ Rate limit reached. Please wait before trying again."
212
+
213
+
214
+ # ============================================================================
215
+ # BYOK (Bring Your Own Key) API Errors
216
+ # ============================================================================
217
+
218
+
219
+ class BYOKAPIException(ErrorNotPickedUpBySentry):
220
+ """Base class for BYOK API errors.
221
+
222
+ All BYOK errors suggest using Shotgun Account to avoid the issue.
223
+ """
224
+
225
+ def __init__(self, message: str, specific_error: str = "API error"):
226
+ """Initialize the exception.
227
+
228
+ Args:
229
+ message: The error message from the API
230
+ specific_error: Human-readable error type label
231
+ """
232
+ self.api_message = message
233
+ self.specific_error = specific_error
234
+ super().__init__(message)
235
+
236
+ def to_markdown(self) -> str:
237
+ """Generate markdown-formatted error message for TUI."""
238
+ return (
239
+ f"⚠️ **{self.specific_error}**: {self.api_message}\n\n"
240
+ f"_This could be avoided with a [Shotgun Account]({SHOTGUN_SIGNUP_URL})._"
241
+ )
242
+
243
+ def to_plain_text(self) -> str:
244
+ """Generate plain text error message for CLI."""
245
+ return (
246
+ f"⚠️ {self.specific_error}: {self.api_message}\n\n"
247
+ f"This could be avoided with a Shotgun Account: {SHOTGUN_SIGNUP_URL}"
248
+ )
249
+
250
+
251
+ class BYOKRateLimitException(BYOKAPIException):
252
+ """Raised when BYOK user hits rate limit."""
253
+
254
+ def __init__(self, message: str):
255
+ """Initialize the exception.
256
+
257
+ Args:
258
+ message: The error message from the API
259
+ """
260
+ super().__init__(message, specific_error="Rate limit reached")
261
+
262
+
263
+ class BYOKQuotaBillingException(BYOKAPIException):
264
+ """Raised when BYOK user has quota or billing issues."""
265
+
266
+ def __init__(self, message: str):
267
+ """Initialize the exception.
268
+
269
+ Args:
270
+ message: The error message from the API
271
+ """
272
+ super().__init__(message, specific_error="Quota or billing issue")
273
+
274
+
275
+ class BYOKAuthenticationException(BYOKAPIException):
276
+ """Raised when BYOK authentication fails."""
277
+
278
+ def __init__(self, message: str):
279
+ """Initialize the exception.
280
+
281
+ Args:
282
+ message: The error message from the API
283
+ """
284
+ super().__init__(message, specific_error="Authentication error")
285
+
286
+
287
+ class BYOKServiceOverloadException(BYOKAPIException):
288
+ """Raised when BYOK service is overloaded."""
289
+
290
+ def __init__(self, message: str):
291
+ """Initialize the exception.
292
+
293
+ Args:
294
+ message: The error message from the API
295
+ """
296
+ super().__init__(message, specific_error="Service overloaded")
297
+
298
+
299
+ class BYOKGenericAPIException(BYOKAPIException):
300
+ """Raised for generic BYOK API errors."""
301
+
302
+ def __init__(self, message: str):
303
+ """Initialize the exception.
304
+
305
+ Args:
306
+ message: The error message from the API
307
+ """
308
+ super().__init__(message, specific_error="API error")
309
+
310
+
311
+ # ============================================================================
312
+ # Generic Errors
313
+ # ============================================================================
314
+
315
+
316
+ class GenericAPIStatusException(ErrorNotPickedUpBySentry):
317
+ """Raised for generic API status errors that don't fit other categories."""
318
+
319
+ def __init__(self, message: str):
320
+ """Initialize the exception.
321
+
322
+ Args:
323
+ message: The error message from the API
324
+ """
325
+ self.api_message = message
326
+ super().__init__(message)
327
+
328
+ def to_markdown(self) -> str:
329
+ """Generate markdown-formatted error message for TUI."""
330
+ return f"⚠️ AI service error: {self.api_message}"
331
+
332
+ def to_plain_text(self) -> str:
333
+ """Generate plain text error message for CLI."""
334
+ return f"⚠️ AI service error: {self.api_message}"
335
+
336
+
337
+ class UnknownAgentException(ErrorNotPickedUpBySentry):
338
+ """Raised for unknown/unclassified agent errors."""
339
+
340
+ def __init__(self, original_exception: Exception):
341
+ """Initialize the exception.
342
+
343
+ Args:
344
+ original_exception: The original exception that was caught
345
+ """
346
+ self.original_exception = original_exception
347
+ super().__init__(str(original_exception))
348
+
349
+ def to_markdown(self) -> str:
350
+ """Generate markdown-formatted error message for TUI."""
351
+ log_path = get_shotgun_home() / "logs" / "shotgun.log"
352
+ return f"⚠️ An error occurred: {str(self.original_exception)}\n\nCheck logs at {log_path}"
353
+
354
+ def to_plain_text(self) -> str:
355
+ """Generate plain text error message for CLI."""
356
+ log_path = get_shotgun_home() / "logs" / "shotgun.log"
357
+ return f"⚠️ An error occurred: {str(self.original_exception)}\n\nCheck logs at {log_path}"
@@ -1,5 +1,6 @@
1
1
  """LiteLLM proxy client utilities and configuration."""
2
2
 
3
+ from .client import LiteLLMProxyClient, get_budget_info
3
4
  from .clients import (
4
5
  create_anthropic_proxy_provider,
5
6
  create_litellm_provider,
@@ -9,6 +10,14 @@ from .constants import (
9
10
  LITELLM_PROXY_BASE_URL,
10
11
  LITELLM_PROXY_OPENAI_BASE,
11
12
  )
13
+ from .models import (
14
+ BudgetInfo,
15
+ BudgetSource,
16
+ KeyInfoData,
17
+ KeyInfoResponse,
18
+ TeamInfoData,
19
+ TeamInfoResponse,
20
+ )
12
21
 
13
22
  __all__ = [
14
23
  "LITELLM_PROXY_BASE_URL",
@@ -16,4 +25,12 @@ __all__ = [
16
25
  "LITELLM_PROXY_OPENAI_BASE",
17
26
  "create_litellm_provider",
18
27
  "create_anthropic_proxy_provider",
28
+ "LiteLLMProxyClient",
29
+ "get_budget_info",
30
+ "BudgetInfo",
31
+ "BudgetSource",
32
+ "KeyInfoData",
33
+ "KeyInfoResponse",
34
+ "TeamInfoData",
35
+ "TeamInfoResponse",
19
36
  ]
@@ -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()