shotgun-sh 0.1.14__py3-none-any.whl → 0.2.11__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.
- shotgun/agents/agent_manager.py +715 -75
- shotgun/agents/common.py +80 -75
- shotgun/agents/config/constants.py +21 -10
- shotgun/agents/config/manager.py +322 -97
- shotgun/agents/config/models.py +114 -84
- shotgun/agents/config/provider.py +232 -88
- shotgun/agents/context_analyzer/__init__.py +28 -0
- shotgun/agents/context_analyzer/analyzer.py +471 -0
- shotgun/agents/context_analyzer/constants.py +9 -0
- shotgun/agents/context_analyzer/formatter.py +115 -0
- shotgun/agents/context_analyzer/models.py +212 -0
- shotgun/agents/conversation_history.py +125 -2
- shotgun/agents/conversation_manager.py +57 -19
- shotgun/agents/export.py +6 -7
- shotgun/agents/history/compaction.py +10 -5
- shotgun/agents/history/context_extraction.py +93 -6
- shotgun/agents/history/history_processors.py +129 -12
- shotgun/agents/history/token_counting/__init__.py +31 -0
- shotgun/agents/history/token_counting/anthropic.py +127 -0
- shotgun/agents/history/token_counting/base.py +78 -0
- shotgun/agents/history/token_counting/openai.py +90 -0
- shotgun/agents/history/token_counting/sentencepiece_counter.py +127 -0
- shotgun/agents/history/token_counting/tokenizer_cache.py +92 -0
- shotgun/agents/history/token_counting/utils.py +144 -0
- shotgun/agents/history/token_estimation.py +12 -12
- shotgun/agents/llm.py +62 -0
- shotgun/agents/models.py +59 -4
- shotgun/agents/plan.py +6 -7
- shotgun/agents/research.py +7 -8
- shotgun/agents/specify.py +6 -7
- shotgun/agents/tasks.py +6 -7
- shotgun/agents/tools/__init__.py +0 -2
- shotgun/agents/tools/codebase/codebase_shell.py +6 -0
- shotgun/agents/tools/codebase/directory_lister.py +6 -0
- shotgun/agents/tools/codebase/file_read.py +11 -2
- shotgun/agents/tools/codebase/query_graph.py +6 -0
- shotgun/agents/tools/codebase/retrieve_code.py +6 -0
- shotgun/agents/tools/file_management.py +82 -16
- shotgun/agents/tools/registry.py +217 -0
- shotgun/agents/tools/web_search/__init__.py +55 -16
- shotgun/agents/tools/web_search/anthropic.py +76 -51
- shotgun/agents/tools/web_search/gemini.py +50 -27
- shotgun/agents/tools/web_search/openai.py +26 -17
- shotgun/agents/tools/web_search/utils.py +2 -2
- shotgun/agents/usage_manager.py +164 -0
- shotgun/api_endpoints.py +15 -0
- shotgun/cli/clear.py +53 -0
- shotgun/cli/compact.py +186 -0
- shotgun/cli/config.py +41 -67
- shotgun/cli/context.py +111 -0
- shotgun/cli/export.py +1 -1
- shotgun/cli/feedback.py +50 -0
- shotgun/cli/models.py +3 -2
- shotgun/cli/plan.py +1 -1
- shotgun/cli/research.py +1 -1
- shotgun/cli/specify.py +1 -1
- shotgun/cli/tasks.py +1 -1
- shotgun/cli/update.py +16 -2
- shotgun/codebase/core/change_detector.py +5 -3
- shotgun/codebase/core/code_retrieval.py +4 -2
- shotgun/codebase/core/ingestor.py +57 -16
- shotgun/codebase/core/manager.py +20 -7
- shotgun/codebase/core/nl_query.py +1 -1
- shotgun/codebase/models.py +4 -4
- shotgun/exceptions.py +32 -0
- shotgun/llm_proxy/__init__.py +19 -0
- shotgun/llm_proxy/clients.py +44 -0
- shotgun/llm_proxy/constants.py +15 -0
- shotgun/logging_config.py +18 -27
- shotgun/main.py +91 -12
- shotgun/posthog_telemetry.py +81 -10
- shotgun/prompts/agents/export.j2 +18 -1
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
- shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
- shotgun/prompts/agents/plan.j2 +1 -1
- shotgun/prompts/agents/research.j2 +1 -1
- shotgun/prompts/agents/specify.j2 +270 -3
- shotgun/prompts/agents/state/system_state.j2 +4 -0
- shotgun/prompts/agents/tasks.j2 +1 -1
- shotgun/prompts/loader.py +2 -2
- shotgun/prompts/tools/web_search.j2 +14 -0
- shotgun/sentry_telemetry.py +27 -18
- shotgun/settings.py +238 -0
- shotgun/shotgun_web/__init__.py +19 -0
- shotgun/shotgun_web/client.py +138 -0
- shotgun/shotgun_web/constants.py +21 -0
- shotgun/shotgun_web/models.py +47 -0
- shotgun/telemetry.py +24 -36
- shotgun/tui/app.py +251 -23
- shotgun/tui/commands/__init__.py +1 -1
- shotgun/tui/components/context_indicator.py +179 -0
- shotgun/tui/components/mode_indicator.py +70 -0
- shotgun/tui/components/status_bar.py +48 -0
- shotgun/tui/containers.py +91 -0
- shotgun/tui/dependencies.py +39 -0
- shotgun/tui/protocols.py +45 -0
- shotgun/tui/screens/chat/__init__.py +5 -0
- shotgun/tui/screens/chat/chat.tcss +54 -0
- shotgun/tui/screens/chat/chat_screen.py +1234 -0
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
- shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
- shotgun/tui/screens/chat/help_text.py +40 -0
- shotgun/tui/screens/chat/prompt_history.py +48 -0
- shotgun/tui/screens/chat.tcss +11 -0
- shotgun/tui/screens/chat_screen/command_providers.py +226 -11
- shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
- shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
- shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
- shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
- shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
- shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
- shotgun/tui/screens/confirmation_dialog.py +151 -0
- shotgun/tui/screens/feedback.py +193 -0
- shotgun/tui/screens/github_issue.py +102 -0
- shotgun/tui/screens/model_picker.py +352 -0
- shotgun/tui/screens/onboarding.py +431 -0
- shotgun/tui/screens/pipx_migration.py +153 -0
- shotgun/tui/screens/provider_config.py +156 -39
- shotgun/tui/screens/shotgun_auth.py +295 -0
- shotgun/tui/screens/welcome.py +198 -0
- shotgun/tui/services/__init__.py +5 -0
- shotgun/tui/services/conversation_service.py +184 -0
- shotgun/tui/state/__init__.py +7 -0
- shotgun/tui/state/processing_state.py +185 -0
- shotgun/tui/utils/mode_progress.py +14 -7
- shotgun/tui/widgets/__init__.py +5 -0
- shotgun/tui/widgets/widget_coordinator.py +262 -0
- shotgun/utils/datetime_utils.py +77 -0
- shotgun/utils/env_utils.py +13 -0
- shotgun/utils/file_system_utils.py +22 -2
- shotgun/utils/marketing.py +110 -0
- shotgun/utils/update_checker.py +69 -14
- shotgun_sh-0.2.11.dist-info/METADATA +130 -0
- shotgun_sh-0.2.11.dist-info/RECORD +194 -0
- {shotgun_sh-0.1.14.dist-info → shotgun_sh-0.2.11.dist-info}/entry_points.txt +1 -0
- {shotgun_sh-0.1.14.dist-info → shotgun_sh-0.2.11.dist-info}/licenses/LICENSE +1 -1
- shotgun/agents/history/token_counting.py +0 -429
- shotgun/agents/tools/user_interaction.py +0 -37
- shotgun/tui/screens/chat.py +0 -797
- shotgun/tui/screens/chat_screen/history.py +0 -350
- shotgun_sh-0.1.14.dist-info/METADATA +0 -466
- shotgun_sh-0.1.14.dist-info/RECORD +0 -133
- {shotgun_sh-0.1.14.dist-info → shotgun_sh-0.2.11.dist-info}/WHEEL +0 -0
shotgun/agents/config/manager.py
CHANGED
|
@@ -1,29 +1,38 @@
|
|
|
1
1
|
"""Configuration manager for Shotgun CLI."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
import os
|
|
5
4
|
import uuid
|
|
6
5
|
from pathlib import Path
|
|
7
6
|
from typing import Any
|
|
8
7
|
|
|
8
|
+
import aiofiles
|
|
9
|
+
import aiofiles.os
|
|
9
10
|
from pydantic import SecretStr
|
|
10
11
|
|
|
11
12
|
from shotgun.logging_config import get_logger
|
|
12
13
|
from shotgun.utils import get_shotgun_home
|
|
13
14
|
|
|
14
15
|
from .constants import (
|
|
15
|
-
ANTHROPIC_API_KEY_ENV,
|
|
16
|
-
ANTHROPIC_PROVIDER,
|
|
17
16
|
API_KEY_FIELD,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
SHOTGUN_INSTANCE_ID_FIELD,
|
|
18
|
+
SUPABASE_JWT_FIELD,
|
|
19
|
+
ConfigSection,
|
|
20
|
+
)
|
|
21
|
+
from .models import (
|
|
22
|
+
AnthropicConfig,
|
|
23
|
+
GoogleConfig,
|
|
24
|
+
ModelName,
|
|
25
|
+
OpenAIConfig,
|
|
26
|
+
ProviderType,
|
|
27
|
+
ShotgunAccountConfig,
|
|
28
|
+
ShotgunConfig,
|
|
22
29
|
)
|
|
23
|
-
from .models import ProviderType, ShotgunConfig
|
|
24
30
|
|
|
25
31
|
logger = get_logger(__name__)
|
|
26
32
|
|
|
33
|
+
# Type alias for provider configuration objects
|
|
34
|
+
ProviderConfig = OpenAIConfig | AnthropicConfig | GoogleConfig | ShotgunAccountConfig
|
|
35
|
+
|
|
27
36
|
|
|
28
37
|
class ConfigManager:
|
|
29
38
|
"""Manager for Shotgun configuration."""
|
|
@@ -41,27 +50,65 @@ class ConfigManager:
|
|
|
41
50
|
|
|
42
51
|
self._config: ShotgunConfig | None = None
|
|
43
52
|
|
|
44
|
-
def load(self) -> ShotgunConfig:
|
|
53
|
+
async def load(self, force_reload: bool = True) -> ShotgunConfig:
|
|
45
54
|
"""Load configuration from file.
|
|
46
55
|
|
|
56
|
+
Args:
|
|
57
|
+
force_reload: If True, reload from disk even if cached (default: True)
|
|
58
|
+
|
|
47
59
|
Returns:
|
|
48
60
|
ShotgunConfig: Loaded configuration or default config if file doesn't exist
|
|
49
61
|
"""
|
|
50
|
-
if self._config is not None:
|
|
62
|
+
if self._config is not None and not force_reload:
|
|
51
63
|
return self._config
|
|
52
64
|
|
|
53
|
-
if not
|
|
65
|
+
if not await aiofiles.os.path.exists(self.config_path):
|
|
54
66
|
logger.info(
|
|
55
|
-
"Configuration file not found, creating new config
|
|
67
|
+
"Configuration file not found, creating new config at: %s",
|
|
56
68
|
self.config_path,
|
|
57
69
|
)
|
|
58
|
-
# Create new config with generated
|
|
59
|
-
self._config = self.initialize()
|
|
70
|
+
# Create new config with generated shotgun_instance_id
|
|
71
|
+
self._config = await self.initialize()
|
|
60
72
|
return self._config
|
|
61
73
|
|
|
62
74
|
try:
|
|
63
|
-
with open(self.config_path, encoding="utf-8") as f:
|
|
64
|
-
|
|
75
|
+
async with aiofiles.open(self.config_path, encoding="utf-8") as f:
|
|
76
|
+
content = await f.read()
|
|
77
|
+
data = json.loads(content)
|
|
78
|
+
|
|
79
|
+
# Migration: Rename user_id to shotgun_instance_id (config v2 -> v3)
|
|
80
|
+
if "user_id" in data and SHOTGUN_INSTANCE_ID_FIELD not in data:
|
|
81
|
+
data[SHOTGUN_INSTANCE_ID_FIELD] = data.pop("user_id")
|
|
82
|
+
data["config_version"] = 3
|
|
83
|
+
logger.info(
|
|
84
|
+
"Migrated config v2->v3: renamed user_id to shotgun_instance_id"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Migration: Set shown_welcome_screen for existing BYOK users
|
|
88
|
+
# If shown_welcome_screen doesn't exist AND any BYOK provider has a key,
|
|
89
|
+
# set it to False so they see the welcome screen once
|
|
90
|
+
if "shown_welcome_screen" not in data:
|
|
91
|
+
has_byok_key = False
|
|
92
|
+
for section in ["openai", "anthropic", "google"]:
|
|
93
|
+
if (
|
|
94
|
+
section in data
|
|
95
|
+
and isinstance(data[section], dict)
|
|
96
|
+
and data[section].get("api_key")
|
|
97
|
+
):
|
|
98
|
+
has_byok_key = True
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
if has_byok_key:
|
|
102
|
+
data["shown_welcome_screen"] = False
|
|
103
|
+
logger.info(
|
|
104
|
+
"Existing BYOK user detected: set shown_welcome_screen=False to show welcome screen"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Migration: Add marketing config for v3 -> v4
|
|
108
|
+
if "marketing" not in data:
|
|
109
|
+
data["marketing"] = {"messages": {}}
|
|
110
|
+
data["config_version"] = 4
|
|
111
|
+
logger.info("Migrated config v3->v4: added marketing configuration")
|
|
65
112
|
|
|
66
113
|
# Convert plain text secrets to SecretStr objects
|
|
67
114
|
self._convert_secrets_to_secretstr(data)
|
|
@@ -69,20 +116,56 @@ class ConfigManager:
|
|
|
69
116
|
self._config = ShotgunConfig.model_validate(data)
|
|
70
117
|
logger.debug("Configuration loaded successfully from %s", self.config_path)
|
|
71
118
|
|
|
72
|
-
#
|
|
73
|
-
if not self.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
119
|
+
# Validate selected_model if in BYOK mode (no Shotgun key)
|
|
120
|
+
if not self._provider_has_api_key(self._config.shotgun):
|
|
121
|
+
should_save = False
|
|
122
|
+
|
|
123
|
+
# If selected_model is set, verify its provider has a key
|
|
124
|
+
if self._config.selected_model:
|
|
125
|
+
from .models import MODEL_SPECS
|
|
126
|
+
|
|
127
|
+
if self._config.selected_model in MODEL_SPECS:
|
|
128
|
+
spec = MODEL_SPECS[self._config.selected_model]
|
|
129
|
+
if not await self.has_provider_key(spec.provider):
|
|
130
|
+
logger.info(
|
|
131
|
+
"Selected model %s provider has no API key, finding available model",
|
|
132
|
+
self._config.selected_model.value,
|
|
133
|
+
)
|
|
134
|
+
self._config.selected_model = None
|
|
135
|
+
should_save = True
|
|
136
|
+
else:
|
|
78
137
|
logger.info(
|
|
79
|
-
"
|
|
80
|
-
|
|
81
|
-
provider.value,
|
|
138
|
+
"Selected model %s not found in MODEL_SPECS, resetting",
|
|
139
|
+
self._config.selected_model.value,
|
|
82
140
|
)
|
|
83
|
-
self._config.
|
|
84
|
-
|
|
85
|
-
|
|
141
|
+
self._config.selected_model = None
|
|
142
|
+
should_save = True
|
|
143
|
+
|
|
144
|
+
# If no selected_model or it was invalid, find first available model
|
|
145
|
+
if not self._config.selected_model:
|
|
146
|
+
for provider in ProviderType:
|
|
147
|
+
if await self.has_provider_key(provider):
|
|
148
|
+
# Set to that provider's default model
|
|
149
|
+
from .models import MODEL_SPECS, ModelName
|
|
150
|
+
|
|
151
|
+
# Find default model for this provider
|
|
152
|
+
provider_models = {
|
|
153
|
+
ProviderType.OPENAI: ModelName.GPT_5,
|
|
154
|
+
ProviderType.ANTHROPIC: ModelName.CLAUDE_HAIKU_4_5,
|
|
155
|
+
ProviderType.GOOGLE: ModelName.GEMINI_2_5_PRO,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if provider in provider_models:
|
|
159
|
+
self._config.selected_model = provider_models[provider]
|
|
160
|
+
logger.info(
|
|
161
|
+
"Set selected_model to %s (first available provider)",
|
|
162
|
+
self._config.selected_model.value,
|
|
163
|
+
)
|
|
164
|
+
should_save = True
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
if should_save:
|
|
168
|
+
await self.save(self._config)
|
|
86
169
|
|
|
87
170
|
return self._config
|
|
88
171
|
|
|
@@ -90,11 +173,11 @@ class ConfigManager:
|
|
|
90
173
|
logger.error(
|
|
91
174
|
"Failed to load configuration from %s: %s", self.config_path, e
|
|
92
175
|
)
|
|
93
|
-
logger.info("Creating new configuration with generated
|
|
94
|
-
self._config = self.initialize()
|
|
176
|
+
logger.info("Creating new configuration with generated shotgun_instance_id")
|
|
177
|
+
self._config = await self.initialize()
|
|
95
178
|
return self._config
|
|
96
179
|
|
|
97
|
-
def save(self, config: ShotgunConfig | None = None) -> None:
|
|
180
|
+
async def save(self, config: ShotgunConfig | None = None) -> None:
|
|
98
181
|
"""Save configuration to file.
|
|
99
182
|
|
|
100
183
|
Args:
|
|
@@ -104,22 +187,23 @@ class ConfigManager:
|
|
|
104
187
|
if self._config:
|
|
105
188
|
config = self._config
|
|
106
189
|
else:
|
|
107
|
-
# Create a new config with generated
|
|
190
|
+
# Create a new config with generated shotgun_instance_id
|
|
108
191
|
config = ShotgunConfig(
|
|
109
|
-
|
|
110
|
-
config_version=1,
|
|
192
|
+
shotgun_instance_id=str(uuid.uuid4()),
|
|
111
193
|
)
|
|
112
194
|
|
|
113
195
|
# Ensure directory exists
|
|
114
|
-
self.config_path.parent
|
|
196
|
+
await aiofiles.os.makedirs(self.config_path.parent, exist_ok=True)
|
|
115
197
|
|
|
116
198
|
try:
|
|
117
199
|
# Convert SecretStr to plain text for JSON serialization
|
|
118
200
|
data = config.model_dump()
|
|
119
201
|
self._convert_secretstr_to_plain(data)
|
|
202
|
+
self._convert_datetime_to_isoformat(data)
|
|
120
203
|
|
|
121
|
-
|
|
122
|
-
|
|
204
|
+
json_content = json.dumps(data, indent=2, ensure_ascii=False)
|
|
205
|
+
async with aiofiles.open(self.config_path, "w", encoding="utf-8") as f:
|
|
206
|
+
await f.write(json_content)
|
|
123
207
|
|
|
124
208
|
logger.debug("Configuration saved to %s", self.config_path)
|
|
125
209
|
self._config = config
|
|
@@ -128,16 +212,23 @@ class ConfigManager:
|
|
|
128
212
|
logger.error("Failed to save configuration to %s: %s", self.config_path, e)
|
|
129
213
|
raise
|
|
130
214
|
|
|
131
|
-
def update_provider(
|
|
215
|
+
async def update_provider(
|
|
216
|
+
self, provider: ProviderType | str, **kwargs: Any
|
|
217
|
+
) -> None:
|
|
132
218
|
"""Update provider configuration.
|
|
133
219
|
|
|
134
220
|
Args:
|
|
135
221
|
provider: Provider to update
|
|
136
222
|
**kwargs: Configuration fields to update (only api_key supported)
|
|
137
223
|
"""
|
|
138
|
-
config = self.load()
|
|
139
|
-
|
|
140
|
-
|
|
224
|
+
config = await self.load()
|
|
225
|
+
|
|
226
|
+
# Get provider config and check if it's shotgun
|
|
227
|
+
provider_config, is_shotgun = self._get_provider_config_and_type(
|
|
228
|
+
config, provider
|
|
229
|
+
)
|
|
230
|
+
# For non-shotgun providers, we need the enum for default provider logic
|
|
231
|
+
provider_enum = None if is_shotgun else self._ensure_provider_enum(provider)
|
|
141
232
|
|
|
142
233
|
# Only support api_key updates
|
|
143
234
|
if API_KEY_FIELD in kwargs:
|
|
@@ -152,50 +243,76 @@ class ConfigManager:
|
|
|
152
243
|
raise ValueError(f"Unsupported configuration fields: {unsupported_fields}")
|
|
153
244
|
|
|
154
245
|
# If no other providers have keys configured and we just added one,
|
|
155
|
-
# set
|
|
156
|
-
if API_KEY_FIELD in kwargs and api_key_value is not None:
|
|
246
|
+
# set selected_model to that provider's default model (only for LLM providers, not shotgun)
|
|
247
|
+
if not is_shotgun and API_KEY_FIELD in kwargs and api_key_value is not None:
|
|
248
|
+
# provider_enum is guaranteed to be non-None here since is_shotgun is False
|
|
249
|
+
if provider_enum is None:
|
|
250
|
+
raise RuntimeError("Provider enum should not be None for LLM providers")
|
|
157
251
|
other_providers = [p for p in ProviderType if p != provider_enum]
|
|
158
252
|
has_other_keys = any(self.has_provider_key(p) for p in other_providers)
|
|
159
253
|
if not has_other_keys:
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
254
|
+
# Set selected_model to this provider's default model
|
|
255
|
+
from .models import ModelName
|
|
256
|
+
|
|
257
|
+
provider_models = {
|
|
258
|
+
ProviderType.OPENAI: ModelName.GPT_5,
|
|
259
|
+
ProviderType.ANTHROPIC: ModelName.CLAUDE_HAIKU_4_5,
|
|
260
|
+
ProviderType.GOOGLE: ModelName.GEMINI_2_5_PRO,
|
|
261
|
+
}
|
|
262
|
+
if provider_enum in provider_models:
|
|
263
|
+
config.selected_model = provider_models[provider_enum]
|
|
264
|
+
|
|
265
|
+
# Mark welcome screen as shown when BYOK provider is configured
|
|
266
|
+
# This prevents the welcome screen from showing again after user has made their choice
|
|
267
|
+
config.shown_welcome_screen = True
|
|
268
|
+
|
|
269
|
+
await self.save(config)
|
|
270
|
+
|
|
271
|
+
async def clear_provider_key(self, provider: ProviderType | str) -> None:
|
|
272
|
+
"""Remove the API key for the given provider (LLM provider or shotgun)."""
|
|
273
|
+
config = await self.load()
|
|
274
|
+
|
|
275
|
+
# Get provider config (shotgun or LLM provider)
|
|
276
|
+
provider_config, is_shotgun = self._get_provider_config_and_type(
|
|
277
|
+
config, provider
|
|
278
|
+
)
|
|
163
279
|
|
|
164
|
-
def clear_provider_key(self, provider: ProviderType | str) -> None:
|
|
165
|
-
"""Remove the API key for the given provider."""
|
|
166
|
-
config = self.load()
|
|
167
|
-
provider_enum = self._ensure_provider_enum(provider)
|
|
168
|
-
provider_config = self._get_provider_config(config, provider_enum)
|
|
169
280
|
provider_config.api_key = None
|
|
170
|
-
self.save(config)
|
|
171
281
|
|
|
172
|
-
|
|
282
|
+
# For Shotgun Account, also clear the JWT
|
|
283
|
+
if is_shotgun and isinstance(provider_config, ShotgunAccountConfig):
|
|
284
|
+
provider_config.supabase_jwt = None
|
|
285
|
+
|
|
286
|
+
await self.save(config)
|
|
287
|
+
|
|
288
|
+
async def update_selected_model(self, model_name: "ModelName") -> None:
|
|
289
|
+
"""Update the selected model.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
model_name: Model to select
|
|
293
|
+
"""
|
|
294
|
+
config = await self.load()
|
|
295
|
+
config.selected_model = model_name
|
|
296
|
+
await self.save(config)
|
|
297
|
+
|
|
298
|
+
async def has_provider_key(self, provider: ProviderType | str) -> bool:
|
|
173
299
|
"""Check if the given provider has a non-empty API key configured.
|
|
174
300
|
|
|
175
|
-
This checks
|
|
301
|
+
This checks only the configuration file.
|
|
176
302
|
"""
|
|
177
|
-
|
|
303
|
+
# Use force_reload=False to avoid infinite loop when called from load()
|
|
304
|
+
config = await self.load(force_reload=False)
|
|
178
305
|
provider_enum = self._ensure_provider_enum(provider)
|
|
179
306
|
provider_config = self._get_provider_config(config, provider_enum)
|
|
180
307
|
|
|
181
|
-
|
|
182
|
-
if self._provider_has_api_key(provider_config):
|
|
183
|
-
return True
|
|
184
|
-
|
|
185
|
-
# Check environment variable
|
|
186
|
-
if provider_enum == ProviderType.OPENAI:
|
|
187
|
-
return bool(os.getenv(OPENAI_API_KEY_ENV))
|
|
188
|
-
elif provider_enum == ProviderType.ANTHROPIC:
|
|
189
|
-
return bool(os.getenv(ANTHROPIC_API_KEY_ENV))
|
|
190
|
-
elif provider_enum == ProviderType.GOOGLE:
|
|
191
|
-
return bool(os.getenv(GEMINI_API_KEY_ENV))
|
|
308
|
+
return self._provider_has_api_key(provider_config)
|
|
192
309
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def has_any_provider_key(self) -> bool:
|
|
310
|
+
async def has_any_provider_key(self) -> bool:
|
|
196
311
|
"""Determine whether any provider has a configured API key."""
|
|
197
|
-
|
|
198
|
-
|
|
312
|
+
# Use force_reload=False to avoid infinite loop when called from load()
|
|
313
|
+
config = await self.load(force_reload=False)
|
|
314
|
+
# Check LLM provider keys (BYOK)
|
|
315
|
+
has_llm_key = any(
|
|
199
316
|
self._provider_has_api_key(self._get_provider_config(config, provider))
|
|
200
317
|
for provider in (
|
|
201
318
|
ProviderType.OPENAI,
|
|
@@ -203,50 +320,93 @@ class ConfigManager:
|
|
|
203
320
|
ProviderType.GOOGLE,
|
|
204
321
|
)
|
|
205
322
|
)
|
|
323
|
+
# Also check Shotgun Account key
|
|
324
|
+
has_shotgun_key = self._provider_has_api_key(config.shotgun)
|
|
325
|
+
return has_llm_key or has_shotgun_key
|
|
206
326
|
|
|
207
|
-
def initialize(self) -> ShotgunConfig:
|
|
327
|
+
async def initialize(self) -> ShotgunConfig:
|
|
208
328
|
"""Initialize configuration with defaults and save to file.
|
|
209
329
|
|
|
210
330
|
Returns:
|
|
211
331
|
Default ShotgunConfig
|
|
212
332
|
"""
|
|
213
|
-
# Generate unique
|
|
333
|
+
# Generate unique shotgun instance ID for new config
|
|
214
334
|
config = ShotgunConfig(
|
|
215
|
-
|
|
216
|
-
config_version=1,
|
|
335
|
+
shotgun_instance_id=str(uuid.uuid4()),
|
|
217
336
|
)
|
|
218
|
-
self.save(config)
|
|
337
|
+
await self.save(config)
|
|
219
338
|
logger.info(
|
|
220
|
-
"Configuration initialized at %s with
|
|
339
|
+
"Configuration initialized at %s with shotgun_instance_id: %s",
|
|
221
340
|
self.config_path,
|
|
222
|
-
config.
|
|
341
|
+
config.shotgun_instance_id,
|
|
223
342
|
)
|
|
224
343
|
return config
|
|
225
344
|
|
|
226
345
|
def _convert_secrets_to_secretstr(self, data: dict[str, Any]) -> None:
|
|
227
346
|
"""Convert plain text secrets in data to SecretStr objects."""
|
|
228
|
-
for
|
|
229
|
-
if
|
|
347
|
+
for section in ConfigSection:
|
|
348
|
+
if section.value in data and isinstance(data[section.value], dict):
|
|
349
|
+
# Convert API key
|
|
230
350
|
if (
|
|
231
|
-
API_KEY_FIELD in data[
|
|
232
|
-
and data[
|
|
351
|
+
API_KEY_FIELD in data[section.value]
|
|
352
|
+
and data[section.value][API_KEY_FIELD] is not None
|
|
233
353
|
):
|
|
234
|
-
data[
|
|
235
|
-
data[
|
|
354
|
+
data[section.value][API_KEY_FIELD] = SecretStr(
|
|
355
|
+
data[section.value][API_KEY_FIELD]
|
|
356
|
+
)
|
|
357
|
+
# Convert supabase JWT (shotgun section only)
|
|
358
|
+
if (
|
|
359
|
+
section == ConfigSection.SHOTGUN
|
|
360
|
+
and SUPABASE_JWT_FIELD in data[section.value]
|
|
361
|
+
and data[section.value][SUPABASE_JWT_FIELD] is not None
|
|
362
|
+
):
|
|
363
|
+
data[section.value][SUPABASE_JWT_FIELD] = SecretStr(
|
|
364
|
+
data[section.value][SUPABASE_JWT_FIELD]
|
|
236
365
|
)
|
|
237
366
|
|
|
238
367
|
def _convert_secretstr_to_plain(self, data: dict[str, Any]) -> None:
|
|
239
368
|
"""Convert SecretStr objects in data to plain text for JSON serialization."""
|
|
240
|
-
for
|
|
241
|
-
if
|
|
369
|
+
for section in ConfigSection:
|
|
370
|
+
if section.value in data and isinstance(data[section.value], dict):
|
|
371
|
+
# Convert API key
|
|
242
372
|
if (
|
|
243
|
-
API_KEY_FIELD in data[
|
|
244
|
-
and data[
|
|
373
|
+
API_KEY_FIELD in data[section.value]
|
|
374
|
+
and data[section.value][API_KEY_FIELD] is not None
|
|
245
375
|
):
|
|
246
|
-
if hasattr(data[
|
|
247
|
-
data[
|
|
376
|
+
if hasattr(data[section.value][API_KEY_FIELD], "get_secret_value"):
|
|
377
|
+
data[section.value][API_KEY_FIELD] = data[section.value][
|
|
248
378
|
API_KEY_FIELD
|
|
249
379
|
].get_secret_value()
|
|
380
|
+
# Convert supabase JWT (shotgun section only)
|
|
381
|
+
if (
|
|
382
|
+
section == ConfigSection.SHOTGUN
|
|
383
|
+
and SUPABASE_JWT_FIELD in data[section.value]
|
|
384
|
+
and data[section.value][SUPABASE_JWT_FIELD] is not None
|
|
385
|
+
):
|
|
386
|
+
if hasattr(
|
|
387
|
+
data[section.value][SUPABASE_JWT_FIELD], "get_secret_value"
|
|
388
|
+
):
|
|
389
|
+
data[section.value][SUPABASE_JWT_FIELD] = data[section.value][
|
|
390
|
+
SUPABASE_JWT_FIELD
|
|
391
|
+
].get_secret_value()
|
|
392
|
+
|
|
393
|
+
def _convert_datetime_to_isoformat(self, data: dict[str, Any]) -> None:
|
|
394
|
+
"""Convert datetime objects in data to ISO8601 format strings for JSON serialization."""
|
|
395
|
+
from datetime import datetime
|
|
396
|
+
|
|
397
|
+
def convert_dict(d: dict[str, Any]) -> None:
|
|
398
|
+
"""Recursively convert datetime objects in a dict."""
|
|
399
|
+
for key, value in d.items():
|
|
400
|
+
if isinstance(value, datetime):
|
|
401
|
+
d[key] = value.isoformat()
|
|
402
|
+
elif isinstance(value, dict):
|
|
403
|
+
convert_dict(value)
|
|
404
|
+
elif isinstance(value, list):
|
|
405
|
+
for item in value:
|
|
406
|
+
if isinstance(item, dict):
|
|
407
|
+
convert_dict(item)
|
|
408
|
+
|
|
409
|
+
convert_dict(data)
|
|
250
410
|
|
|
251
411
|
def _ensure_provider_enum(self, provider: ProviderType | str) -> ProviderType:
|
|
252
412
|
"""Normalize provider values to ProviderType enum."""
|
|
@@ -279,16 +439,81 @@ class ConfigManager:
|
|
|
279
439
|
|
|
280
440
|
return bool(value.strip())
|
|
281
441
|
|
|
282
|
-
def
|
|
283
|
-
"""
|
|
442
|
+
def _is_shotgun_provider(self, provider: ProviderType | str) -> bool:
|
|
443
|
+
"""Check if provider string represents Shotgun Account.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
provider: Provider type or string
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
True if provider is shotgun account
|
|
450
|
+
"""
|
|
451
|
+
return (
|
|
452
|
+
isinstance(provider, str)
|
|
453
|
+
and provider.lower() == ConfigSection.SHOTGUN.value
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
def _get_provider_config_and_type(
|
|
457
|
+
self, config: ShotgunConfig, provider: ProviderType | str
|
|
458
|
+
) -> tuple[ProviderConfig, bool]:
|
|
459
|
+
"""Get provider config, handling shotgun as special case.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
config: Shotgun configuration
|
|
463
|
+
provider: Provider type or string
|
|
284
464
|
|
|
285
465
|
Returns:
|
|
286
|
-
|
|
466
|
+
Tuple of (provider_config, is_shotgun)
|
|
287
467
|
"""
|
|
288
|
-
|
|
289
|
-
|
|
468
|
+
if self._is_shotgun_provider(provider):
|
|
469
|
+
return (config.shotgun, True)
|
|
470
|
+
|
|
471
|
+
provider_enum = self._ensure_provider_enum(provider)
|
|
472
|
+
return (self._get_provider_config(config, provider_enum), False)
|
|
473
|
+
|
|
474
|
+
async def get_shotgun_instance_id(self) -> str:
|
|
475
|
+
"""Get the shotgun instance ID from configuration.
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
The unique shotgun instance ID string
|
|
479
|
+
"""
|
|
480
|
+
config = await self.load()
|
|
481
|
+
return config.shotgun_instance_id
|
|
482
|
+
|
|
483
|
+
async def update_shotgun_account(
|
|
484
|
+
self, api_key: str | None = None, supabase_jwt: str | None = None
|
|
485
|
+
) -> None:
|
|
486
|
+
"""Update Shotgun Account configuration.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
api_key: LiteLLM proxy API key (optional)
|
|
490
|
+
supabase_jwt: Supabase authentication JWT (optional)
|
|
491
|
+
"""
|
|
492
|
+
config = await self.load()
|
|
493
|
+
|
|
494
|
+
if api_key is not None:
|
|
495
|
+
config.shotgun.api_key = SecretStr(api_key) if api_key else None
|
|
496
|
+
|
|
497
|
+
if supabase_jwt is not None:
|
|
498
|
+
config.shotgun.supabase_jwt = (
|
|
499
|
+
SecretStr(supabase_jwt) if supabase_jwt else None
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
await self.save(config)
|
|
503
|
+
logger.info("Updated Shotgun Account configuration")
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# Global singleton instance
|
|
507
|
+
_config_manager_instance: ConfigManager | None = None
|
|
290
508
|
|
|
291
509
|
|
|
292
510
|
def get_config_manager() -> ConfigManager:
|
|
293
|
-
"""Get the global ConfigManager instance.
|
|
294
|
-
|
|
511
|
+
"""Get the global singleton ConfigManager instance.
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
The singleton ConfigManager instance
|
|
515
|
+
"""
|
|
516
|
+
global _config_manager_instance
|
|
517
|
+
if _config_manager_instance is None:
|
|
518
|
+
_config_manager_instance = ConfigManager()
|
|
519
|
+
return _config_manager_instance
|