shotgun-sh 0.2.17__py3-none-any.whl → 0.3.3.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.
- shotgun/agents/agent_manager.py +28 -14
- shotgun/agents/common.py +1 -1
- shotgun/agents/config/README.md +89 -0
- shotgun/agents/config/__init__.py +10 -1
- shotgun/agents/config/manager.py +323 -53
- shotgun/agents/config/models.py +85 -21
- shotgun/agents/config/provider.py +51 -13
- shotgun/agents/config/streaming_test.py +119 -0
- shotgun/agents/context_analyzer/analyzer.py +6 -2
- shotgun/agents/conversation/__init__.py +18 -0
- shotgun/agents/conversation/filters.py +164 -0
- shotgun/agents/conversation/history/chunking.py +278 -0
- shotgun/agents/{history → conversation/history}/compaction.py +27 -1
- shotgun/agents/{history → conversation/history}/constants.py +5 -0
- shotgun/agents/conversation/history/file_content_deduplication.py +216 -0
- shotgun/agents/{history → conversation/history}/history_processors.py +267 -3
- shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +8 -0
- shotgun/agents/{conversation_manager.py → conversation/manager.py} +1 -1
- shotgun/agents/{conversation_history.py → conversation/models.py} +8 -94
- shotgun/agents/error/__init__.py +11 -0
- shotgun/agents/error/models.py +19 -0
- shotgun/agents/runner.py +230 -0
- shotgun/agents/tools/web_search/openai.py +1 -1
- shotgun/build_constants.py +2 -2
- shotgun/cli/clear.py +1 -1
- shotgun/cli/compact.py +5 -3
- shotgun/cli/context.py +44 -1
- shotgun/cli/error_handler.py +24 -0
- shotgun/cli/export.py +34 -34
- shotgun/cli/plan.py +34 -34
- shotgun/cli/research.py +17 -9
- shotgun/cli/spec/__init__.py +5 -0
- shotgun/cli/spec/backup.py +81 -0
- shotgun/cli/spec/commands.py +132 -0
- shotgun/cli/spec/models.py +48 -0
- shotgun/cli/spec/pull_service.py +219 -0
- shotgun/cli/specify.py +20 -19
- shotgun/cli/tasks.py +34 -34
- shotgun/codebase/core/ingestor.py +153 -7
- shotgun/codebase/models.py +2 -0
- shotgun/exceptions.py +325 -0
- shotgun/llm_proxy/__init__.py +17 -0
- shotgun/llm_proxy/client.py +215 -0
- shotgun/llm_proxy/models.py +137 -0
- shotgun/logging_config.py +42 -0
- shotgun/main.py +4 -0
- shotgun/posthog_telemetry.py +1 -1
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -3
- shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
- shotgun/prompts/agents/plan.j2 +16 -0
- shotgun/prompts/agents/research.j2 +16 -3
- shotgun/prompts/agents/specify.j2 +54 -1
- shotgun/prompts/agents/state/system_state.j2 +0 -2
- shotgun/prompts/agents/tasks.j2 +16 -0
- shotgun/prompts/history/chunk_summarization.j2 +34 -0
- shotgun/prompts/history/combine_summaries.j2 +53 -0
- shotgun/sdk/codebase.py +14 -3
- shotgun/settings.py +5 -0
- shotgun/shotgun_web/__init__.py +67 -1
- shotgun/shotgun_web/client.py +42 -1
- shotgun/shotgun_web/constants.py +46 -0
- shotgun/shotgun_web/exceptions.py +29 -0
- shotgun/shotgun_web/models.py +390 -0
- shotgun/shotgun_web/shared_specs/__init__.py +32 -0
- shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
- shotgun/shotgun_web/shared_specs/hasher.py +83 -0
- shotgun/shotgun_web/shared_specs/models.py +71 -0
- shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
- shotgun/shotgun_web/shared_specs/utils.py +34 -0
- shotgun/shotgun_web/specs_client.py +703 -0
- shotgun/shotgun_web/supabase_client.py +31 -0
- shotgun/tui/app.py +73 -9
- shotgun/tui/containers.py +1 -1
- shotgun/tui/layout.py +5 -0
- shotgun/tui/screens/chat/chat_screen.py +372 -95
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +196 -17
- shotgun/tui/screens/chat_screen/command_providers.py +13 -2
- shotgun/tui/screens/chat_screen/hint_message.py +76 -1
- shotgun/tui/screens/confirmation_dialog.py +40 -0
- shotgun/tui/screens/directory_setup.py +45 -41
- shotgun/tui/screens/feedback.py +10 -3
- shotgun/tui/screens/github_issue.py +11 -2
- shotgun/tui/screens/model_picker.py +28 -8
- shotgun/tui/screens/onboarding.py +149 -0
- shotgun/tui/screens/pipx_migration.py +58 -6
- shotgun/tui/screens/provider_config.py +66 -8
- shotgun/tui/screens/shared_specs/__init__.py +21 -0
- shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
- shotgun/tui/screens/shared_specs/models.py +56 -0
- shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
- shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
- shotgun/tui/screens/shotgun_auth.py +110 -16
- shotgun/tui/screens/spec_pull.py +288 -0
- shotgun/tui/screens/welcome.py +123 -0
- shotgun/tui/services/conversation_service.py +5 -2
- shotgun/tui/widgets/widget_coordinator.py +1 -1
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/METADATA +9 -2
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/RECORD +112 -77
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/WHEEL +1 -1
- /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
- /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
- /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/base.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/openai.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/licenses/LICENSE +0 -0
shotgun/agents/config/manager.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""Configuration manager for Shotgun CLI."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import shutil
|
|
4
5
|
import uuid
|
|
6
|
+
from datetime import datetime
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
from typing import Any
|
|
7
9
|
|
|
@@ -30,9 +32,196 @@ from .models import (
|
|
|
30
32
|
|
|
31
33
|
logger = get_logger(__name__)
|
|
32
34
|
|
|
35
|
+
|
|
36
|
+
class ConfigMigrationError(Exception):
|
|
37
|
+
"""Exception raised when config migration fails."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, message: str, backup_path: Path | None = None):
|
|
40
|
+
"""Initialize with error message and optional backup path.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
message: Error message describing what went wrong
|
|
44
|
+
backup_path: Path to backup file if one was created
|
|
45
|
+
"""
|
|
46
|
+
self.backup_path = backup_path
|
|
47
|
+
super().__init__(message)
|
|
48
|
+
|
|
49
|
+
|
|
33
50
|
# Type alias for provider configuration objects
|
|
34
51
|
ProviderConfig = OpenAIConfig | AnthropicConfig | GoogleConfig | ShotgunAccountConfig
|
|
35
52
|
|
|
53
|
+
# Current config version
|
|
54
|
+
CURRENT_CONFIG_VERSION = 5
|
|
55
|
+
|
|
56
|
+
# Backup directory name
|
|
57
|
+
BACKUP_DIR_NAME = "backup"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_backup_dir(config_path: Path) -> Path:
|
|
61
|
+
"""Get the backup directory path for a given config file.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
config_path: Path to the config file
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Path to the backup directory (e.g., ~/.shotgun-sh/backup/)
|
|
68
|
+
"""
|
|
69
|
+
return config_path.parent / BACKUP_DIR_NAME
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _create_backup(config_path: Path) -> Path:
|
|
73
|
+
"""Create a timestamped backup of the config file before migration.
|
|
74
|
+
|
|
75
|
+
Backups are saved to ~/.shotgun-sh/backup/ directory.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
config_path: Path to the config file to backup
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Path to the backup file in the backup directory
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
OSError: If backup creation fails
|
|
85
|
+
"""
|
|
86
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
87
|
+
backup_dir = get_backup_dir(config_path)
|
|
88
|
+
backup_path = backup_dir / f"config.backup.{timestamp}.json"
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
# Create backup directory if it doesn't exist
|
|
92
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
shutil.copy2(config_path, backup_path)
|
|
94
|
+
logger.info(f"Created config backup at {backup_path}")
|
|
95
|
+
return backup_path
|
|
96
|
+
except Exception as e:
|
|
97
|
+
logger.error(f"Failed to create config backup: {e}")
|
|
98
|
+
raise OSError(f"Failed to create config backup: {e}") from e
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _migrate_v2_to_v3(data: dict[str, Any]) -> dict[str, Any]:
|
|
102
|
+
"""Migrate config from version 2 to version 3.
|
|
103
|
+
|
|
104
|
+
Changes:
|
|
105
|
+
- Rename 'user_id' field to 'shotgun_instance_id'
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
data: Config data dict at version 2
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Modified config data dict at version 3
|
|
112
|
+
"""
|
|
113
|
+
if "user_id" in data and SHOTGUN_INSTANCE_ID_FIELD not in data:
|
|
114
|
+
data[SHOTGUN_INSTANCE_ID_FIELD] = data.pop("user_id")
|
|
115
|
+
data["config_version"] = 3
|
|
116
|
+
logger.info("Migrated config v2->v3: renamed user_id to shotgun_instance_id")
|
|
117
|
+
|
|
118
|
+
return data
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _migrate_v3_to_v4(data: dict[str, Any]) -> dict[str, Any]:
|
|
122
|
+
"""Migrate config from version 3 to version 4.
|
|
123
|
+
|
|
124
|
+
Changes:
|
|
125
|
+
- Add 'marketing' field with empty messages dict
|
|
126
|
+
- Set 'shown_welcome_screen' to False for existing BYOK users
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
data: Config data dict at version 3
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Modified config data dict at version 4
|
|
133
|
+
"""
|
|
134
|
+
# Add marketing config
|
|
135
|
+
if "marketing" not in data:
|
|
136
|
+
data["marketing"] = {"messages": {}}
|
|
137
|
+
logger.info("Migrated config v3->v4: added marketing configuration")
|
|
138
|
+
|
|
139
|
+
# Set shown_welcome_screen for existing BYOK users
|
|
140
|
+
# If shown_welcome_screen doesn't exist AND any BYOK provider has a key,
|
|
141
|
+
# set it to False so they see the welcome screen once
|
|
142
|
+
if "shown_welcome_screen" not in data:
|
|
143
|
+
has_byok_key = False
|
|
144
|
+
for section in ["openai", "anthropic", "google"]:
|
|
145
|
+
if (
|
|
146
|
+
section in data
|
|
147
|
+
and isinstance(data[section], dict)
|
|
148
|
+
and data[section].get("api_key")
|
|
149
|
+
):
|
|
150
|
+
has_byok_key = True
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
if has_byok_key:
|
|
154
|
+
data["shown_welcome_screen"] = False
|
|
155
|
+
logger.info(
|
|
156
|
+
"Existing BYOK user detected: set shown_welcome_screen=False to show welcome screen"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
data["config_version"] = 4
|
|
160
|
+
return data
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _migrate_v4_to_v5(data: dict[str, Any]) -> dict[str, Any]:
|
|
164
|
+
"""Migrate config from version 4 to version 5.
|
|
165
|
+
|
|
166
|
+
Changes:
|
|
167
|
+
- Add 'supports_streaming' field to OpenAI config (initially None for auto-detection)
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
data: Config data dict at version 4
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Modified config data dict at version 5
|
|
174
|
+
"""
|
|
175
|
+
if "openai" in data and isinstance(data["openai"], dict):
|
|
176
|
+
if "supports_streaming" not in data["openai"]:
|
|
177
|
+
data["openai"]["supports_streaming"] = None
|
|
178
|
+
logger.info(
|
|
179
|
+
"Migrated config v4->v5: added streaming capability detection for OpenAI"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
data["config_version"] = 5
|
|
183
|
+
return data
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _apply_migrations(data: dict[str, Any]) -> dict[str, Any]:
|
|
187
|
+
"""Apply all necessary migrations to bring config to current version.
|
|
188
|
+
|
|
189
|
+
Migrations are applied sequentially from the config's current version
|
|
190
|
+
to CURRENT_CONFIG_VERSION.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
data: Config data dict at any version
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Config data dict at CURRENT_CONFIG_VERSION
|
|
197
|
+
"""
|
|
198
|
+
# Get current version (default to 2 for very old configs)
|
|
199
|
+
current_version = data.get("config_version", 2)
|
|
200
|
+
|
|
201
|
+
# Define migrations in order
|
|
202
|
+
migrations = {
|
|
203
|
+
2: _migrate_v2_to_v3,
|
|
204
|
+
3: _migrate_v3_to_v4,
|
|
205
|
+
4: _migrate_v4_to_v5,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# Apply migrations sequentially
|
|
209
|
+
while current_version < CURRENT_CONFIG_VERSION:
|
|
210
|
+
if current_version in migrations:
|
|
211
|
+
logger.info(
|
|
212
|
+
f"Applying migration from v{current_version} to v{current_version + 1}"
|
|
213
|
+
)
|
|
214
|
+
data = migrations[current_version](data)
|
|
215
|
+
current_version = data.get("config_version", current_version + 1)
|
|
216
|
+
else:
|
|
217
|
+
logger.warning(
|
|
218
|
+
f"No migration defined for v{current_version}, skipping to v{current_version + 1}"
|
|
219
|
+
)
|
|
220
|
+
current_version += 1
|
|
221
|
+
data["config_version"] = current_version
|
|
222
|
+
|
|
223
|
+
return data
|
|
224
|
+
|
|
36
225
|
|
|
37
226
|
class ConfigManager:
|
|
38
227
|
"""Manager for Shotgun configuration."""
|
|
@@ -71,71 +260,88 @@ class ConfigManager:
|
|
|
71
260
|
self._config = await self.initialize()
|
|
72
261
|
return self._config
|
|
73
262
|
|
|
263
|
+
backup_path: Path | None = None
|
|
74
264
|
try:
|
|
75
265
|
async with aiofiles.open(self.config_path, encoding="utf-8") as f:
|
|
76
266
|
content = await f.read()
|
|
77
267
|
data = json.loads(content)
|
|
78
268
|
|
|
79
|
-
#
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
269
|
+
# Get current version to determine if migration is needed
|
|
270
|
+
current_version = data.get("config_version", 2)
|
|
271
|
+
|
|
272
|
+
# Create backup before migration if config needs upgrading
|
|
273
|
+
if current_version < CURRENT_CONFIG_VERSION:
|
|
83
274
|
logger.info(
|
|
84
|
-
"
|
|
275
|
+
f"Config needs migration from v{current_version} to v{CURRENT_CONFIG_VERSION}"
|
|
85
276
|
)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
277
|
+
try:
|
|
278
|
+
backup_path = _create_backup(self.config_path)
|
|
279
|
+
except OSError as backup_error:
|
|
280
|
+
logger.warning(
|
|
281
|
+
f"Could not create backup before migration: {backup_error}"
|
|
282
|
+
)
|
|
283
|
+
# Continue without backup - better than failing completely
|
|
284
|
+
|
|
285
|
+
# Apply all necessary migrations to bring config to current version
|
|
286
|
+
try:
|
|
287
|
+
data = _apply_migrations(data)
|
|
288
|
+
except Exception as migration_error:
|
|
289
|
+
error_msg = (
|
|
290
|
+
f"Failed to migrate configuration from v{current_version} to v{CURRENT_CONFIG_VERSION}. "
|
|
291
|
+
f"Error: {migration_error}"
|
|
292
|
+
)
|
|
293
|
+
if backup_path:
|
|
294
|
+
error_msg += f"\n\nYour original config has been backed up to:\n{backup_path}"
|
|
295
|
+
error_msg += (
|
|
296
|
+
"\n\nTo start fresh, delete or rename your config file:\n"
|
|
297
|
+
f" rm {self.config_path}\n"
|
|
298
|
+
f" shotgun config init\n\n"
|
|
299
|
+
"To restore your backup:\n"
|
|
300
|
+
f" cp {backup_path} {self.config_path}"
|
|
105
301
|
)
|
|
302
|
+
else:
|
|
303
|
+
error_msg += "\n\nTo start fresh, run: shotgun config init"
|
|
106
304
|
|
|
107
|
-
|
|
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")
|
|
305
|
+
raise ConfigMigrationError(error_msg, backup_path) from migration_error
|
|
112
306
|
|
|
113
307
|
# Convert plain text secrets to SecretStr objects
|
|
114
308
|
self._convert_secrets_to_secretstr(data)
|
|
115
309
|
|
|
310
|
+
# Clean up invalid selected_model before Pydantic validation
|
|
311
|
+
if "selected_model" in data and data["selected_model"] is not None:
|
|
312
|
+
from .models import MODEL_SPECS, ModelName
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
# Try to convert to ModelName enum
|
|
316
|
+
model_name = ModelName(data["selected_model"])
|
|
317
|
+
# Check if it exists in MODEL_SPECS
|
|
318
|
+
if model_name not in MODEL_SPECS:
|
|
319
|
+
data["selected_model"] = None
|
|
320
|
+
except (ValueError, KeyError):
|
|
321
|
+
# Invalid model name - reset to None
|
|
322
|
+
data["selected_model"] = None
|
|
323
|
+
|
|
116
324
|
self._config = ShotgunConfig.model_validate(data)
|
|
117
325
|
logger.debug("Configuration loaded successfully from %s", self.config_path)
|
|
118
326
|
|
|
119
|
-
#
|
|
120
|
-
|
|
121
|
-
|
|
327
|
+
# Clear migration_failed flag if config loaded successfully
|
|
328
|
+
should_save = False
|
|
329
|
+
if self._config.migration_failed:
|
|
330
|
+
self._config.migration_failed = False
|
|
331
|
+
self._config.migration_backup_path = None
|
|
332
|
+
should_save = True
|
|
122
333
|
|
|
334
|
+
# Validate selected_model for BYOK mode - verify provider has a key
|
|
335
|
+
if not self._provider_has_api_key(self._config.shotgun):
|
|
123
336
|
# If selected_model is set, verify its provider has a key
|
|
124
337
|
if self._config.selected_model:
|
|
125
338
|
from .models import MODEL_SPECS
|
|
126
339
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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:
|
|
340
|
+
spec = MODEL_SPECS[self._config.selected_model]
|
|
341
|
+
if not await self.has_provider_key(spec.provider):
|
|
342
|
+
# Provider has no key - reset to None
|
|
137
343
|
logger.info(
|
|
138
|
-
"Selected model %s
|
|
344
|
+
"Selected model %s provider has no API key, finding available model",
|
|
139
345
|
self._config.selected_model.value,
|
|
140
346
|
)
|
|
141
347
|
self._config.selected_model = None
|
|
@@ -150,17 +356,13 @@ class ConfigManager:
|
|
|
150
356
|
|
|
151
357
|
# Find default model for this provider
|
|
152
358
|
provider_models = {
|
|
153
|
-
ProviderType.OPENAI: ModelName.
|
|
359
|
+
ProviderType.OPENAI: ModelName.GPT_5_1,
|
|
154
360
|
ProviderType.ANTHROPIC: ModelName.CLAUDE_HAIKU_4_5,
|
|
155
361
|
ProviderType.GOOGLE: ModelName.GEMINI_2_5_PRO,
|
|
156
362
|
}
|
|
157
363
|
|
|
158
364
|
if provider in provider_models:
|
|
159
365
|
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
366
|
should_save = True
|
|
165
367
|
break
|
|
166
368
|
|
|
@@ -169,12 +371,56 @@ class ConfigManager:
|
|
|
169
371
|
|
|
170
372
|
return self._config
|
|
171
373
|
|
|
172
|
-
except
|
|
374
|
+
except ConfigMigrationError as migration_error:
|
|
375
|
+
# Migration failed - automatically create fresh config with migration info
|
|
173
376
|
logger.error(
|
|
174
|
-
"
|
|
377
|
+
"Config migration failed, creating fresh config: %s", migration_error
|
|
175
378
|
)
|
|
176
|
-
|
|
379
|
+
backup_path = migration_error.backup_path
|
|
380
|
+
|
|
381
|
+
# Create fresh config with migration failure info
|
|
382
|
+
self._config = await self.initialize()
|
|
383
|
+
self._config.migration_failed = True
|
|
384
|
+
if backup_path:
|
|
385
|
+
self._config.migration_backup_path = str(backup_path)
|
|
386
|
+
|
|
387
|
+
# Save the fresh config
|
|
388
|
+
await self.save(self._config)
|
|
389
|
+
logger.info("Created fresh config after migration failure")
|
|
390
|
+
|
|
391
|
+
return self._config
|
|
392
|
+
|
|
393
|
+
except json.JSONDecodeError as json_error:
|
|
394
|
+
# Invalid JSON - create backup and fresh config
|
|
395
|
+
logger.error("Config file has invalid JSON: %s", json_error)
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
backup_path = _create_backup(self.config_path)
|
|
399
|
+
except OSError:
|
|
400
|
+
backup_path = None
|
|
401
|
+
|
|
402
|
+
self._config = await self.initialize()
|
|
403
|
+
self._config.migration_failed = True
|
|
404
|
+
if backup_path:
|
|
405
|
+
self._config.migration_backup_path = str(backup_path)
|
|
406
|
+
|
|
407
|
+
await self.save(self._config)
|
|
408
|
+
logger.info("Created fresh config after JSON parse error")
|
|
409
|
+
|
|
410
|
+
return self._config
|
|
411
|
+
|
|
412
|
+
except Exception as e:
|
|
413
|
+
# Generic error - create fresh config
|
|
414
|
+
logger.error("Failed to load config: %s", e)
|
|
415
|
+
|
|
177
416
|
self._config = await self.initialize()
|
|
417
|
+
self._config.migration_failed = True
|
|
418
|
+
if backup_path:
|
|
419
|
+
self._config.migration_backup_path = str(backup_path)
|
|
420
|
+
|
|
421
|
+
await self.save(self._config)
|
|
422
|
+
logger.info("Created fresh config after load error")
|
|
423
|
+
|
|
178
424
|
return self._config
|
|
179
425
|
|
|
180
426
|
async def save(self, config: ShotgunConfig | None = None) -> None:
|
|
@@ -237,6 +483,11 @@ class ConfigManager:
|
|
|
237
483
|
SecretStr(api_key_value) if api_key_value is not None else None
|
|
238
484
|
)
|
|
239
485
|
|
|
486
|
+
# Reset streaming capabilities when OpenAI API key is changed
|
|
487
|
+
if not is_shotgun and provider_enum == ProviderType.OPENAI:
|
|
488
|
+
if isinstance(provider_config, OpenAIConfig):
|
|
489
|
+
provider_config.supports_streaming = None
|
|
490
|
+
|
|
240
491
|
# Reject other fields
|
|
241
492
|
unsupported_fields = set(kwargs.keys()) - {API_KEY_FIELD}
|
|
242
493
|
if unsupported_fields:
|
|
@@ -255,7 +506,7 @@ class ConfigManager:
|
|
|
255
506
|
from .models import ModelName
|
|
256
507
|
|
|
257
508
|
provider_models = {
|
|
258
|
-
ProviderType.OPENAI: ModelName.
|
|
509
|
+
ProviderType.OPENAI: ModelName.GPT_5_1,
|
|
259
510
|
ProviderType.ANTHROPIC: ModelName.CLAUDE_HAIKU_4_5,
|
|
260
511
|
ProviderType.GOOGLE: ModelName.GEMINI_2_5_PRO,
|
|
261
512
|
}
|
|
@@ -266,6 +517,11 @@ class ConfigManager:
|
|
|
266
517
|
# This prevents the welcome screen from showing again after user has made their choice
|
|
267
518
|
config.shown_welcome_screen = True
|
|
268
519
|
|
|
520
|
+
# Clear migration failure flag when user successfully configures a provider
|
|
521
|
+
if API_KEY_FIELD in kwargs and api_key_value is not None:
|
|
522
|
+
config.migration_failed = False
|
|
523
|
+
config.migration_backup_path = None
|
|
524
|
+
|
|
269
525
|
await self.save(config)
|
|
270
526
|
|
|
271
527
|
async def clear_provider_key(self, provider: ProviderType | str) -> None:
|
|
@@ -283,6 +539,13 @@ class ConfigManager:
|
|
|
283
539
|
if is_shotgun and isinstance(provider_config, ShotgunAccountConfig):
|
|
284
540
|
provider_config.supabase_jwt = None
|
|
285
541
|
|
|
542
|
+
# Reset streaming capabilities when OpenAI API key is cleared
|
|
543
|
+
if not is_shotgun:
|
|
544
|
+
provider_enum = self._ensure_provider_enum(provider)
|
|
545
|
+
if provider_enum == ProviderType.OPENAI:
|
|
546
|
+
if isinstance(provider_config, OpenAIConfig):
|
|
547
|
+
provider_config.supports_streaming = None
|
|
548
|
+
|
|
286
549
|
await self.save(config)
|
|
287
550
|
|
|
288
551
|
async def update_selected_model(self, model_name: "ModelName") -> None:
|
|
@@ -481,13 +744,17 @@ class ConfigManager:
|
|
|
481
744
|
return config.shotgun_instance_id
|
|
482
745
|
|
|
483
746
|
async def update_shotgun_account(
|
|
484
|
-
self,
|
|
747
|
+
self,
|
|
748
|
+
api_key: str | None = None,
|
|
749
|
+
supabase_jwt: str | None = None,
|
|
750
|
+
workspace_id: str | None = None,
|
|
485
751
|
) -> None:
|
|
486
752
|
"""Update Shotgun Account configuration.
|
|
487
753
|
|
|
488
754
|
Args:
|
|
489
755
|
api_key: LiteLLM proxy API key (optional)
|
|
490
756
|
supabase_jwt: Supabase authentication JWT (optional)
|
|
757
|
+
workspace_id: Default workspace ID for shared specs (optional)
|
|
491
758
|
"""
|
|
492
759
|
config = await self.load()
|
|
493
760
|
|
|
@@ -499,6 +766,9 @@ class ConfigManager:
|
|
|
499
766
|
SecretStr(supabase_jwt) if supabase_jwt else None
|
|
500
767
|
)
|
|
501
768
|
|
|
769
|
+
if workspace_id is not None:
|
|
770
|
+
config.shotgun.workspace_id = workspace_id
|
|
771
|
+
|
|
502
772
|
await self.save(config)
|
|
503
773
|
logger.info("Updated Shotgun Account configuration")
|
|
504
774
|
|
shotgun/agents/config/models.py
CHANGED
|
@@ -25,13 +25,17 @@ class KeyProvider(StrEnum):
|
|
|
25
25
|
class ModelName(StrEnum):
|
|
26
26
|
"""Available AI model names."""
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
GPT_5_1 = "gpt-5.1"
|
|
29
|
+
GPT_5_1_CODEX = "gpt-5.1-codex"
|
|
30
|
+
GPT_5_1_CODEX_MINI = "gpt-5.1-codex-mini"
|
|
31
|
+
CLAUDE_OPUS_4_5 = "claude-opus-4-5"
|
|
32
|
+
CLAUDE_SONNET_4 = "claude-sonnet-4"
|
|
31
33
|
CLAUDE_SONNET_4_5 = "claude-sonnet-4-5"
|
|
32
34
|
CLAUDE_HAIKU_4_5 = "claude-haiku-4-5"
|
|
33
35
|
GEMINI_2_5_PRO = "gemini-2.5-pro"
|
|
34
36
|
GEMINI_2_5_FLASH = "gemini-2.5-flash"
|
|
37
|
+
GEMINI_2_5_FLASH_LITE = "gemini-2.5-flash-lite"
|
|
38
|
+
GEMINI_3_PRO_PREVIEW = "gemini-3-pro-preview"
|
|
35
39
|
|
|
36
40
|
|
|
37
41
|
class ModelSpec(BaseModel):
|
|
@@ -56,6 +60,10 @@ class ModelConfig(BaseModel):
|
|
|
56
60
|
max_input_tokens: int
|
|
57
61
|
max_output_tokens: int
|
|
58
62
|
api_key: str
|
|
63
|
+
supports_streaming: bool = Field(
|
|
64
|
+
default=True,
|
|
65
|
+
description="Whether this model configuration supports streaming. False only for BYOK GPT-5 models without streaming enabled.",
|
|
66
|
+
)
|
|
59
67
|
_model_instance: Model | None = PrivateAttr(default=None)
|
|
60
68
|
|
|
61
69
|
class Config:
|
|
@@ -82,32 +90,41 @@ class ModelConfig(BaseModel):
|
|
|
82
90
|
}
|
|
83
91
|
return f"{provider_prefix[self.provider]}:{self.name}"
|
|
84
92
|
|
|
93
|
+
@property
|
|
94
|
+
def is_shotgun_account(self) -> bool:
|
|
95
|
+
"""Check if this model is using Shotgun Account authentication.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
True if using Shotgun Account, False if BYOK
|
|
99
|
+
"""
|
|
100
|
+
return self.key_provider == KeyProvider.SHOTGUN
|
|
101
|
+
|
|
85
102
|
|
|
86
103
|
# Model specifications registry (static metadata)
|
|
87
104
|
MODEL_SPECS: dict[ModelName, ModelSpec] = {
|
|
88
|
-
ModelName.
|
|
89
|
-
name=ModelName.
|
|
105
|
+
ModelName.GPT_5_1: ModelSpec(
|
|
106
|
+
name=ModelName.GPT_5_1,
|
|
90
107
|
provider=ProviderType.OPENAI,
|
|
91
|
-
max_input_tokens=
|
|
108
|
+
max_input_tokens=272_000,
|
|
92
109
|
max_output_tokens=128_000,
|
|
93
|
-
litellm_proxy_model_name="openai/gpt-5",
|
|
94
|
-
short_name="GPT-5",
|
|
110
|
+
litellm_proxy_model_name="openai/gpt-5.1",
|
|
111
|
+
short_name="GPT-5.1",
|
|
95
112
|
),
|
|
96
|
-
ModelName.
|
|
97
|
-
name=ModelName.
|
|
113
|
+
ModelName.GPT_5_1_CODEX: ModelSpec(
|
|
114
|
+
name=ModelName.GPT_5_1_CODEX,
|
|
98
115
|
provider=ProviderType.OPENAI,
|
|
99
|
-
max_input_tokens=
|
|
116
|
+
max_input_tokens=272_000,
|
|
100
117
|
max_output_tokens=128_000,
|
|
101
|
-
litellm_proxy_model_name="openai/gpt-5-
|
|
102
|
-
short_name="GPT-5
|
|
118
|
+
litellm_proxy_model_name="openai/gpt-5.1-codex",
|
|
119
|
+
short_name="GPT-5.1 Codex",
|
|
103
120
|
),
|
|
104
|
-
ModelName.
|
|
105
|
-
name=ModelName.
|
|
106
|
-
provider=ProviderType.
|
|
107
|
-
max_input_tokens=
|
|
108
|
-
max_output_tokens=
|
|
109
|
-
litellm_proxy_model_name="
|
|
110
|
-
short_name="
|
|
121
|
+
ModelName.GPT_5_1_CODEX_MINI: ModelSpec(
|
|
122
|
+
name=ModelName.GPT_5_1_CODEX_MINI,
|
|
123
|
+
provider=ProviderType.OPENAI,
|
|
124
|
+
max_input_tokens=272_000,
|
|
125
|
+
max_output_tokens=128_000,
|
|
126
|
+
litellm_proxy_model_name="openai/gpt-5.1-codex-mini",
|
|
127
|
+
short_name="GPT-5.1 Codex Mini",
|
|
111
128
|
),
|
|
112
129
|
ModelName.CLAUDE_SONNET_4_5: ModelSpec(
|
|
113
130
|
name=ModelName.CLAUDE_SONNET_4_5,
|
|
@@ -141,6 +158,38 @@ MODEL_SPECS: dict[ModelName, ModelSpec] = {
|
|
|
141
158
|
litellm_proxy_model_name="gemini/gemini-2.5-flash",
|
|
142
159
|
short_name="Gemini 2.5 Flash",
|
|
143
160
|
),
|
|
161
|
+
ModelName.CLAUDE_OPUS_4_5: ModelSpec(
|
|
162
|
+
name=ModelName.CLAUDE_OPUS_4_5,
|
|
163
|
+
provider=ProviderType.ANTHROPIC,
|
|
164
|
+
max_input_tokens=200_000,
|
|
165
|
+
max_output_tokens=64_000,
|
|
166
|
+
litellm_proxy_model_name="anthropic/claude-opus-4-5",
|
|
167
|
+
short_name="Opus 4.5",
|
|
168
|
+
),
|
|
169
|
+
ModelName.CLAUDE_SONNET_4: ModelSpec(
|
|
170
|
+
name=ModelName.CLAUDE_SONNET_4,
|
|
171
|
+
provider=ProviderType.ANTHROPIC,
|
|
172
|
+
max_input_tokens=200_000,
|
|
173
|
+
max_output_tokens=64_000,
|
|
174
|
+
litellm_proxy_model_name="anthropic/claude-sonnet-4",
|
|
175
|
+
short_name="Sonnet 4",
|
|
176
|
+
),
|
|
177
|
+
ModelName.GEMINI_2_5_FLASH_LITE: ModelSpec(
|
|
178
|
+
name=ModelName.GEMINI_2_5_FLASH_LITE,
|
|
179
|
+
provider=ProviderType.GOOGLE,
|
|
180
|
+
max_input_tokens=1_048_576,
|
|
181
|
+
max_output_tokens=65_536,
|
|
182
|
+
litellm_proxy_model_name="gemini/gemini-2.5-flash-lite",
|
|
183
|
+
short_name="Gemini 2.5 Flash Lite",
|
|
184
|
+
),
|
|
185
|
+
ModelName.GEMINI_3_PRO_PREVIEW: ModelSpec(
|
|
186
|
+
name=ModelName.GEMINI_3_PRO_PREVIEW,
|
|
187
|
+
provider=ProviderType.GOOGLE,
|
|
188
|
+
max_input_tokens=1_048_576,
|
|
189
|
+
max_output_tokens=65_536,
|
|
190
|
+
litellm_proxy_model_name="gemini/gemini-3-pro-preview",
|
|
191
|
+
short_name="Gemini 3 Pro",
|
|
192
|
+
),
|
|
144
193
|
}
|
|
145
194
|
|
|
146
195
|
|
|
@@ -148,6 +197,10 @@ class OpenAIConfig(BaseModel):
|
|
|
148
197
|
"""Configuration for OpenAI provider."""
|
|
149
198
|
|
|
150
199
|
api_key: SecretStr | None = None
|
|
200
|
+
supports_streaming: bool | None = Field(
|
|
201
|
+
default=None,
|
|
202
|
+
description="Whether streaming is supported for this API key. None = not tested yet",
|
|
203
|
+
)
|
|
151
204
|
|
|
152
205
|
|
|
153
206
|
class AnthropicConfig(BaseModel):
|
|
@@ -169,6 +222,9 @@ class ShotgunAccountConfig(BaseModel):
|
|
|
169
222
|
supabase_jwt: SecretStr | None = Field(
|
|
170
223
|
default=None, description="Supabase authentication JWT"
|
|
171
224
|
)
|
|
225
|
+
workspace_id: str | None = Field(
|
|
226
|
+
default=None, description="Default workspace ID for shared specs"
|
|
227
|
+
)
|
|
172
228
|
|
|
173
229
|
|
|
174
230
|
class MarketingMessageRecord(BaseModel):
|
|
@@ -200,7 +256,7 @@ class ShotgunConfig(BaseModel):
|
|
|
200
256
|
shotgun_instance_id: str = Field(
|
|
201
257
|
description="Unique shotgun instance identifier (also used for anonymous telemetry)",
|
|
202
258
|
)
|
|
203
|
-
config_version: int = Field(default=
|
|
259
|
+
config_version: int = Field(default=5, description="Configuration schema version")
|
|
204
260
|
shown_welcome_screen: bool = Field(
|
|
205
261
|
default=False,
|
|
206
262
|
description="Whether the welcome screen has been shown to the user",
|
|
@@ -213,3 +269,11 @@ class ShotgunConfig(BaseModel):
|
|
|
213
269
|
default_factory=MarketingConfig,
|
|
214
270
|
description="Marketing messages configuration and tracking",
|
|
215
271
|
)
|
|
272
|
+
migration_failed: bool = Field(
|
|
273
|
+
default=False,
|
|
274
|
+
description="Whether the last config migration failed (cleared after user configures a provider)",
|
|
275
|
+
)
|
|
276
|
+
migration_backup_path: str | None = Field(
|
|
277
|
+
default=None,
|
|
278
|
+
description="Path to the backup file created when migration failed",
|
|
279
|
+
)
|