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.
- shotgun/agents/agent_manager.py +25 -11
- shotgun/agents/config/README.md +89 -0
- shotgun/agents/config/__init__.py +10 -1
- shotgun/agents/config/manager.py +287 -32
- shotgun/agents/config/models.py +26 -1
- shotgun/agents/config/provider.py +27 -0
- shotgun/agents/config/streaming_test.py +119 -0
- shotgun/agents/error/__init__.py +11 -0
- shotgun/agents/error/models.py +19 -0
- shotgun/agents/history/token_counting/anthropic.py +8 -0
- shotgun/agents/runner.py +230 -0
- shotgun/build_constants.py +1 -1
- shotgun/cli/context.py +43 -0
- 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/specify.py +20 -19
- shotgun/cli/tasks.py +34 -34
- shotgun/exceptions.py +323 -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 +2 -0
- shotgun/posthog_telemetry.py +18 -25
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +3 -2
- shotgun/sdk/codebase.py +14 -3
- shotgun/sentry_telemetry.py +140 -2
- shotgun/settings.py +5 -0
- shotgun/tui/app.py +35 -10
- shotgun/tui/screens/chat/chat_screen.py +192 -91
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +62 -11
- shotgun/tui/screens/chat_screen/command_providers.py +3 -2
- shotgun/tui/screens/chat_screen/hint_message.py +76 -1
- shotgun/tui/screens/chat_screen/history/chat_history.py +37 -2
- 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 +8 -1
- shotgun/tui/screens/pipx_migration.py +12 -6
- shotgun/tui/screens/provider_config.py +25 -8
- shotgun/tui/screens/shotgun_auth.py +0 -10
- shotgun/tui/screens/welcome.py +32 -0
- shotgun/tui/widgets/widget_coordinator.py +3 -2
- shotgun_sh-0.2.23.dev1.dist-info/METADATA +472 -0
- {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/RECORD +50 -42
- shotgun_sh-0.2.11.dev7.dist-info/METADATA +0 -130
- {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/licenses/LICENSE +0 -0
shotgun/agents/agent_manager.py
CHANGED
|
@@ -615,19 +615,33 @@ class AgentManager(Widget):
|
|
|
615
615
|
self._stream_state = _PartialStreamState()
|
|
616
616
|
|
|
617
617
|
model_name = ""
|
|
618
|
+
supports_streaming = True # Default to streaming enabled
|
|
619
|
+
|
|
618
620
|
if hasattr(deps, "llm_model") and deps.llm_model is not None:
|
|
619
621
|
model_name = deps.llm_model.name
|
|
622
|
+
supports_streaming = deps.llm_model.supports_streaming
|
|
620
623
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
624
|
+
# Add hint message if streaming is disabled for BYOK GPT-5 models
|
|
625
|
+
if (
|
|
626
|
+
not supports_streaming
|
|
627
|
+
and deps.llm_model.key_provider == KeyProvider.BYOK
|
|
628
|
+
):
|
|
629
|
+
self.ui_message_history.append(
|
|
630
|
+
HintMessage(
|
|
631
|
+
message=(
|
|
632
|
+
"⚠️ **Streaming not available for GPT-5**\n\n"
|
|
633
|
+
"Your OpenAI organization doesn't have streaming enabled for this model.\n\n"
|
|
634
|
+
"**Options:**\n"
|
|
635
|
+
"- Get a [Shotgun Account](https://shotgun.sh) - streaming works out of the box\n"
|
|
636
|
+
"- Complete [Biometric Verification](https://platform.openai.com/settings/organization/general) with OpenAI, then:\n"
|
|
637
|
+
" 1. Press `Ctrl+P` → Open Provider Setup\n"
|
|
638
|
+
" 2. Select OpenAI → Clear key\n"
|
|
639
|
+
" 3. Re-add your OpenAI API key\n\n"
|
|
640
|
+
"Continuing without streaming (responses will appear all at once)."
|
|
641
|
+
)
|
|
642
|
+
)
|
|
643
|
+
)
|
|
644
|
+
self._post_messages_updated()
|
|
631
645
|
|
|
632
646
|
# Track message send event
|
|
633
647
|
event_name = f"message_send_{self._current_agent_type.value}"
|
|
@@ -647,7 +661,7 @@ class AgentManager(Widget):
|
|
|
647
661
|
usage_limits=usage_limits,
|
|
648
662
|
message_history=message_history,
|
|
649
663
|
event_stream_handler=self._handle_event_stream
|
|
650
|
-
if
|
|
664
|
+
if supports_streaming
|
|
651
665
|
else None,
|
|
652
666
|
**kwargs,
|
|
653
667
|
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Configuration Management
|
|
2
|
+
|
|
3
|
+
This directory contains the configuration management system for Shotgun, including models, migrations, and provider integration.
|
|
4
|
+
|
|
5
|
+
## Config Version History
|
|
6
|
+
|
|
7
|
+
### Version 1 (Config Versioning Introduced)
|
|
8
|
+
|
|
9
|
+
- **Commit**: `f36defc` (Sep 19, 2025)
|
|
10
|
+
- **Title**: "feat: add Sentry error tracking with anonymous user identification"
|
|
11
|
+
- **Key Fields**: `user_id`, `config_version: 1`
|
|
12
|
+
- **Note**: First version to include explicit versioning
|
|
13
|
+
|
|
14
|
+
### Version 2 (Shotgun Account Provider)
|
|
15
|
+
|
|
16
|
+
- **Commit**: `37a5add` (Oct 3, 2025)
|
|
17
|
+
- **Title**: "feat: add Shotgun Account provider with LiteLLM proxy support"
|
|
18
|
+
- **Key Fields**: `user_id`, `config_version: 2`, added `shotgun` provider config
|
|
19
|
+
- **Note**: Configs without a version field default to v2 during migration
|
|
20
|
+
|
|
21
|
+
### Version 3 (OAuth Authentication)
|
|
22
|
+
|
|
23
|
+
- **Commit**: `39d2af9` (Oct 6, 2025)
|
|
24
|
+
- **Title**: "feat: implement OAuth-style authentication flow for Shotgun Account"
|
|
25
|
+
- **Key Changes**:
|
|
26
|
+
- Renamed `user_id` → `shotgun_instance_id`
|
|
27
|
+
- Added `supabase_jwt` field to Shotgun Account config
|
|
28
|
+
- **Git Tags**: Both `0.2.11.dev1` and `0.2.11.dev2` are at this version
|
|
29
|
+
|
|
30
|
+
### Version 4 (Marketing Messages)
|
|
31
|
+
|
|
32
|
+
- **Commit**: `8638a6d` (Nov 4, 2025)
|
|
33
|
+
- **Title**: "feat: add marketing message system for GitHub star promotion"
|
|
34
|
+
- **Key Changes**:
|
|
35
|
+
- Added `marketing` configuration with message tracking
|
|
36
|
+
- Added `shown_welcome_screen` field (set to `False` for existing BYOK users)
|
|
37
|
+
|
|
38
|
+
### Version 5 (Streaming Detection) - CURRENT
|
|
39
|
+
|
|
40
|
+
- **Commit**: `fded351` (Nov 6, 2025)
|
|
41
|
+
- **Title**: "feat: add config migration for streaming capability field (v4->v5)"
|
|
42
|
+
- **Key Changes**:
|
|
43
|
+
- Added `supports_streaming` field to OpenAI config
|
|
44
|
+
- Added `shown_onboarding_popup` timestamp field
|
|
45
|
+
- Added `supabase_jwt` to Shotgun Account config
|
|
46
|
+
|
|
47
|
+
## Migration System
|
|
48
|
+
|
|
49
|
+
The migration system is designed to be sequential and idempotent. Migrations are defined in `manager.py`:
|
|
50
|
+
|
|
51
|
+
- `_migrate_v2_to_v3()`: Renames `user_id` to `shotgun_instance_id`
|
|
52
|
+
- `_migrate_v3_to_v4()`: Adds marketing config and welcome screen flag
|
|
53
|
+
- `_migrate_v4_to_v5()`: Adds streaming support fields
|
|
54
|
+
|
|
55
|
+
All migrations preserve user data (API keys, settings) and can be safely run multiple times.
|
|
56
|
+
|
|
57
|
+
## Adding a New Config Version
|
|
58
|
+
|
|
59
|
+
When adding a new config version:
|
|
60
|
+
|
|
61
|
+
1. **Update `models.py`**:
|
|
62
|
+
- Increment `CURRENT_CONFIG_VERSION` constant
|
|
63
|
+
- Add new fields to appropriate config models
|
|
64
|
+
|
|
65
|
+
2. **Create migration function in `manager.py`**:
|
|
66
|
+
```python
|
|
67
|
+
def _migrate_vN_to_vN+1(data: dict[str, Any]) -> dict[str, Any]:
|
|
68
|
+
"""Migrate config from version N to N+1."""
|
|
69
|
+
data["config_version"] = N + 1
|
|
70
|
+
# Add migration logic
|
|
71
|
+
return data
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
3. **Register migration**:
|
|
75
|
+
- Add to `migrations` dict in `_apply_migrations()`
|
|
76
|
+
|
|
77
|
+
4. **Add tests in `test/unit/test_config_migrations.py`**:
|
|
78
|
+
- Create example config for version N
|
|
79
|
+
- Test individual migration function
|
|
80
|
+
- Test sequential migration from version N to current
|
|
81
|
+
- Test with populated configs (non-empty API keys, etc.)
|
|
82
|
+
- Test edge cases
|
|
83
|
+
|
|
84
|
+
## Files
|
|
85
|
+
|
|
86
|
+
- **`models.py`**: Pydantic models for configuration schema
|
|
87
|
+
- **`manager.py`**: ConfigManager class and migration functions
|
|
88
|
+
- **`provider.py`**: LLM provider integration and model creation
|
|
89
|
+
- **`streaming_test.py`**: OpenAI streaming capability detection
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
"""Configuration module for Shotgun CLI."""
|
|
2
2
|
|
|
3
|
-
from .manager import
|
|
3
|
+
from .manager import (
|
|
4
|
+
BACKUP_DIR_NAME,
|
|
5
|
+
ConfigManager,
|
|
6
|
+
ConfigMigrationError,
|
|
7
|
+
get_backup_dir,
|
|
8
|
+
get_config_manager,
|
|
9
|
+
)
|
|
4
10
|
from .models import ProviderType, ShotgunConfig
|
|
5
11
|
from .provider import get_provider_model
|
|
6
12
|
|
|
7
13
|
__all__ = [
|
|
14
|
+
"BACKUP_DIR_NAME",
|
|
8
15
|
"ConfigManager",
|
|
16
|
+
"ConfigMigrationError",
|
|
17
|
+
"get_backup_dir",
|
|
9
18
|
"get_config_manager",
|
|
10
19
|
"ProviderType",
|
|
11
20
|
"ShotgunConfig",
|
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,44 +260,49 @@ 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
|
-
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"
|
|
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}"
|
|
105
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}"
|
|
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)
|
|
@@ -169,12 +363,56 @@ class ConfigManager:
|
|
|
169
363
|
|
|
170
364
|
return self._config
|
|
171
365
|
|
|
172
|
-
except
|
|
366
|
+
except ConfigMigrationError as migration_error:
|
|
367
|
+
# Migration failed - automatically create fresh config with migration info
|
|
173
368
|
logger.error(
|
|
174
|
-
"
|
|
369
|
+
"Config migration failed, creating fresh config: %s", migration_error
|
|
175
370
|
)
|
|
176
|
-
|
|
371
|
+
backup_path = migration_error.backup_path
|
|
372
|
+
|
|
373
|
+
# Create fresh config with migration failure info
|
|
374
|
+
self._config = await self.initialize()
|
|
375
|
+
self._config.migration_failed = True
|
|
376
|
+
if backup_path:
|
|
377
|
+
self._config.migration_backup_path = str(backup_path)
|
|
378
|
+
|
|
379
|
+
# Save the fresh config
|
|
380
|
+
await self.save(self._config)
|
|
381
|
+
logger.info("Created fresh config after migration failure")
|
|
382
|
+
|
|
383
|
+
return self._config
|
|
384
|
+
|
|
385
|
+
except json.JSONDecodeError as json_error:
|
|
386
|
+
# Invalid JSON - create backup and fresh config
|
|
387
|
+
logger.error("Config file has invalid JSON: %s", json_error)
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
backup_path = _create_backup(self.config_path)
|
|
391
|
+
except OSError:
|
|
392
|
+
backup_path = None
|
|
393
|
+
|
|
394
|
+
self._config = await self.initialize()
|
|
395
|
+
self._config.migration_failed = True
|
|
396
|
+
if backup_path:
|
|
397
|
+
self._config.migration_backup_path = str(backup_path)
|
|
398
|
+
|
|
399
|
+
await self.save(self._config)
|
|
400
|
+
logger.info("Created fresh config after JSON parse error")
|
|
401
|
+
|
|
402
|
+
return self._config
|
|
403
|
+
|
|
404
|
+
except Exception as e:
|
|
405
|
+
# Generic error - create fresh config
|
|
406
|
+
logger.error("Failed to load config: %s", e)
|
|
407
|
+
|
|
177
408
|
self._config = await self.initialize()
|
|
409
|
+
self._config.migration_failed = True
|
|
410
|
+
if backup_path:
|
|
411
|
+
self._config.migration_backup_path = str(backup_path)
|
|
412
|
+
|
|
413
|
+
await self.save(self._config)
|
|
414
|
+
logger.info("Created fresh config after load error")
|
|
415
|
+
|
|
178
416
|
return self._config
|
|
179
417
|
|
|
180
418
|
async def save(self, config: ShotgunConfig | None = None) -> None:
|
|
@@ -237,6 +475,11 @@ class ConfigManager:
|
|
|
237
475
|
SecretStr(api_key_value) if api_key_value is not None else None
|
|
238
476
|
)
|
|
239
477
|
|
|
478
|
+
# Reset streaming capabilities when OpenAI API key is changed
|
|
479
|
+
if not is_shotgun and provider_enum == ProviderType.OPENAI:
|
|
480
|
+
if isinstance(provider_config, OpenAIConfig):
|
|
481
|
+
provider_config.supports_streaming = None
|
|
482
|
+
|
|
240
483
|
# Reject other fields
|
|
241
484
|
unsupported_fields = set(kwargs.keys()) - {API_KEY_FIELD}
|
|
242
485
|
if unsupported_fields:
|
|
@@ -266,6 +509,11 @@ class ConfigManager:
|
|
|
266
509
|
# This prevents the welcome screen from showing again after user has made their choice
|
|
267
510
|
config.shown_welcome_screen = True
|
|
268
511
|
|
|
512
|
+
# Clear migration failure flag when user successfully configures a provider
|
|
513
|
+
if API_KEY_FIELD in kwargs and api_key_value is not None:
|
|
514
|
+
config.migration_failed = False
|
|
515
|
+
config.migration_backup_path = None
|
|
516
|
+
|
|
269
517
|
await self.save(config)
|
|
270
518
|
|
|
271
519
|
async def clear_provider_key(self, provider: ProviderType | str) -> None:
|
|
@@ -283,6 +531,13 @@ class ConfigManager:
|
|
|
283
531
|
if is_shotgun and isinstance(provider_config, ShotgunAccountConfig):
|
|
284
532
|
provider_config.supabase_jwt = None
|
|
285
533
|
|
|
534
|
+
# Reset streaming capabilities when OpenAI API key is cleared
|
|
535
|
+
if not is_shotgun:
|
|
536
|
+
provider_enum = self._ensure_provider_enum(provider)
|
|
537
|
+
if provider_enum == ProviderType.OPENAI:
|
|
538
|
+
if isinstance(provider_config, OpenAIConfig):
|
|
539
|
+
provider_config.supports_streaming = None
|
|
540
|
+
|
|
286
541
|
await self.save(config)
|
|
287
542
|
|
|
288
543
|
async def update_selected_model(self, model_name: "ModelName") -> None:
|
shotgun/agents/config/models.py
CHANGED
|
@@ -56,6 +56,10 @@ class ModelConfig(BaseModel):
|
|
|
56
56
|
max_input_tokens: int
|
|
57
57
|
max_output_tokens: int
|
|
58
58
|
api_key: str
|
|
59
|
+
supports_streaming: bool = Field(
|
|
60
|
+
default=True,
|
|
61
|
+
description="Whether this model configuration supports streaming. False only for BYOK GPT-5 models without streaming enabled.",
|
|
62
|
+
)
|
|
59
63
|
_model_instance: Model | None = PrivateAttr(default=None)
|
|
60
64
|
|
|
61
65
|
class Config:
|
|
@@ -82,6 +86,15 @@ class ModelConfig(BaseModel):
|
|
|
82
86
|
}
|
|
83
87
|
return f"{provider_prefix[self.provider]}:{self.name}"
|
|
84
88
|
|
|
89
|
+
@property
|
|
90
|
+
def is_shotgun_account(self) -> bool:
|
|
91
|
+
"""Check if this model is using Shotgun Account authentication.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
True if using Shotgun Account, False if BYOK
|
|
95
|
+
"""
|
|
96
|
+
return self.key_provider == KeyProvider.SHOTGUN
|
|
97
|
+
|
|
85
98
|
|
|
86
99
|
# Model specifications registry (static metadata)
|
|
87
100
|
MODEL_SPECS: dict[ModelName, ModelSpec] = {
|
|
@@ -148,6 +161,10 @@ class OpenAIConfig(BaseModel):
|
|
|
148
161
|
"""Configuration for OpenAI provider."""
|
|
149
162
|
|
|
150
163
|
api_key: SecretStr | None = None
|
|
164
|
+
supports_streaming: bool | None = Field(
|
|
165
|
+
default=None,
|
|
166
|
+
description="Whether streaming is supported for this API key. None = not tested yet",
|
|
167
|
+
)
|
|
151
168
|
|
|
152
169
|
|
|
153
170
|
class AnthropicConfig(BaseModel):
|
|
@@ -200,7 +217,7 @@ class ShotgunConfig(BaseModel):
|
|
|
200
217
|
shotgun_instance_id: str = Field(
|
|
201
218
|
description="Unique shotgun instance identifier (also used for anonymous telemetry)",
|
|
202
219
|
)
|
|
203
|
-
config_version: int = Field(default=
|
|
220
|
+
config_version: int = Field(default=5, description="Configuration schema version")
|
|
204
221
|
shown_welcome_screen: bool = Field(
|
|
205
222
|
default=False,
|
|
206
223
|
description="Whether the welcome screen has been shown to the user",
|
|
@@ -213,3 +230,11 @@ class ShotgunConfig(BaseModel):
|
|
|
213
230
|
default_factory=MarketingConfig,
|
|
214
231
|
description="Marketing messages configuration and tracking",
|
|
215
232
|
)
|
|
233
|
+
migration_failed: bool = Field(
|
|
234
|
+
default=False,
|
|
235
|
+
description="Whether the last config migration failed (cleared after user configures a provider)",
|
|
236
|
+
)
|
|
237
|
+
migration_backup_path: str | None = Field(
|
|
238
|
+
default=None,
|
|
239
|
+
description="Path to the backup file created when migration failed",
|
|
240
|
+
)
|
|
@@ -25,6 +25,7 @@ from .models import (
|
|
|
25
25
|
ProviderType,
|
|
26
26
|
ShotgunConfig,
|
|
27
27
|
)
|
|
28
|
+
from .streaming_test import check_streaming_capability
|
|
28
29
|
|
|
29
30
|
logger = get_logger(__name__)
|
|
30
31
|
|
|
@@ -207,6 +208,7 @@ async def get_provider_model(
|
|
|
207
208
|
spec = MODEL_SPECS[model_name]
|
|
208
209
|
|
|
209
210
|
# Use Shotgun Account with determined model (provider = actual LLM provider)
|
|
211
|
+
# Shotgun accounts always support streaming (via LiteLLM proxy)
|
|
210
212
|
return ModelConfig(
|
|
211
213
|
name=spec.name,
|
|
212
214
|
provider=spec.provider, # Actual LLM provider (OPENAI/ANTHROPIC/GOOGLE)
|
|
@@ -214,6 +216,7 @@ async def get_provider_model(
|
|
|
214
216
|
max_input_tokens=spec.max_input_tokens,
|
|
215
217
|
max_output_tokens=spec.max_output_tokens,
|
|
216
218
|
api_key=shotgun_api_key,
|
|
219
|
+
supports_streaming=True, # Shotgun accounts always support streaming
|
|
217
220
|
)
|
|
218
221
|
|
|
219
222
|
# Priority 2: Fall back to individual provider keys
|
|
@@ -260,6 +263,29 @@ async def get_provider_model(
|
|
|
260
263
|
raise ValueError(f"Model '{model_name.value}' not found")
|
|
261
264
|
spec = MODEL_SPECS[model_name]
|
|
262
265
|
|
|
266
|
+
# Check and test streaming capability for GPT-5 family models
|
|
267
|
+
supports_streaming = True # Default to True for all models
|
|
268
|
+
if model_name in (ModelName.GPT_5, ModelName.GPT_5_MINI):
|
|
269
|
+
# Check if streaming capability has been tested
|
|
270
|
+
streaming_capability = config.openai.supports_streaming
|
|
271
|
+
|
|
272
|
+
if streaming_capability is None:
|
|
273
|
+
# Not tested yet - run streaming test (test once for all GPT-5 models)
|
|
274
|
+
logger.info("Testing streaming capability for OpenAI GPT-5 family...")
|
|
275
|
+
streaming_capability = await check_streaming_capability(
|
|
276
|
+
api_key, model_name.value
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Save result to config (applies to all OpenAI models)
|
|
280
|
+
config.openai.supports_streaming = streaming_capability
|
|
281
|
+
await config_manager.save(config)
|
|
282
|
+
logger.info(
|
|
283
|
+
f"Streaming test result: "
|
|
284
|
+
f"{'enabled' if streaming_capability else 'disabled'}"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
supports_streaming = streaming_capability
|
|
288
|
+
|
|
263
289
|
# Create fully configured ModelConfig
|
|
264
290
|
return ModelConfig(
|
|
265
291
|
name=spec.name,
|
|
@@ -268,6 +294,7 @@ async def get_provider_model(
|
|
|
268
294
|
max_input_tokens=spec.max_input_tokens,
|
|
269
295
|
max_output_tokens=spec.max_output_tokens,
|
|
270
296
|
api_key=api_key,
|
|
297
|
+
supports_streaming=supports_streaming,
|
|
271
298
|
)
|
|
272
299
|
|
|
273
300
|
elif provider_enum == ProviderType.ANTHROPIC:
|