shotgun-sh 0.2.11.dev3__py3-none-any.whl → 0.2.19__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of shotgun-sh might be problematic. Click here for more details.

Files changed (39) hide show
  1. shotgun/agents/agent_manager.py +66 -12
  2. shotgun/agents/config/README.md +89 -0
  3. shotgun/agents/config/__init__.py +10 -1
  4. shotgun/agents/config/manager.py +287 -32
  5. shotgun/agents/config/models.py +21 -1
  6. shotgun/agents/config/provider.py +27 -0
  7. shotgun/agents/config/streaming_test.py +119 -0
  8. shotgun/agents/conversation_manager.py +14 -7
  9. shotgun/agents/history/history_processors.py +99 -3
  10. shotgun/agents/history/token_counting/openai.py +3 -1
  11. shotgun/build_constants.py +3 -3
  12. shotgun/exceptions.py +32 -0
  13. shotgun/logging_config.py +42 -0
  14. shotgun/main.py +2 -0
  15. shotgun/posthog_telemetry.py +18 -25
  16. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +3 -2
  17. shotgun/sentry_telemetry.py +157 -1
  18. shotgun/settings.py +5 -0
  19. shotgun/tui/app.py +16 -15
  20. shotgun/tui/screens/chat/chat_screen.py +156 -61
  21. shotgun/tui/screens/chat_screen/command_providers.py +13 -2
  22. shotgun/tui/screens/chat_screen/history/chat_history.py +1 -2
  23. shotgun/tui/screens/directory_setup.py +14 -5
  24. shotgun/tui/screens/feedback.py +10 -3
  25. shotgun/tui/screens/github_issue.py +111 -0
  26. shotgun/tui/screens/model_picker.py +8 -1
  27. shotgun/tui/screens/onboarding.py +431 -0
  28. shotgun/tui/screens/pipx_migration.py +12 -6
  29. shotgun/tui/screens/provider_config.py +25 -8
  30. shotgun/tui/screens/shotgun_auth.py +0 -10
  31. shotgun/tui/screens/welcome.py +32 -0
  32. shotgun/tui/services/conversation_service.py +8 -6
  33. shotgun/tui/widgets/widget_coordinator.py +3 -2
  34. shotgun_sh-0.2.19.dist-info/METADATA +465 -0
  35. {shotgun_sh-0.2.11.dev3.dist-info → shotgun_sh-0.2.19.dist-info}/RECORD +38 -33
  36. shotgun_sh-0.2.11.dev3.dist-info/METADATA +0 -130
  37. {shotgun_sh-0.2.11.dev3.dist-info → shotgun_sh-0.2.19.dist-info}/WHEEL +0 -0
  38. {shotgun_sh-0.2.11.dev3.dist-info → shotgun_sh-0.2.19.dist-info}/entry_points.txt +0 -0
  39. {shotgun_sh-0.2.11.dev3.dist-info → shotgun_sh-0.2.19.dist-info}/licenses/LICENSE +0 -0
@@ -58,7 +58,12 @@ from shotgun.agents.context_analyzer import (
58
58
  ContextCompositionTelemetry,
59
59
  ContextFormatter,
60
60
  )
61
- from shotgun.agents.models import AgentResponse, AgentType, FileOperation
61
+ from shotgun.agents.models import (
62
+ AgentResponse,
63
+ AgentType,
64
+ FileOperation,
65
+ FileOperationTracker,
66
+ )
62
67
  from shotgun.posthog_telemetry import track_event
63
68
  from shotgun.tui.screens.chat_screen.hint_message import HintMessage
64
69
  from shotgun.utils.source_detection import detect_source
@@ -610,19 +615,30 @@ class AgentManager(Widget):
610
615
  self._stream_state = _PartialStreamState()
611
616
 
612
617
  model_name = ""
618
+ supports_streaming = True # Default to streaming enabled
619
+
613
620
  if hasattr(deps, "llm_model") and deps.llm_model is not None:
614
621
  model_name = deps.llm_model.name
622
+ supports_streaming = deps.llm_model.supports_streaming
615
623
 
616
- # Check if it's a Shotgun account
617
- is_shotgun_account = (
618
- hasattr(deps, "llm_model")
619
- and deps.llm_model is not None
620
- and deps.llm_model.key_provider == KeyProvider.SHOTGUN
621
- )
622
-
623
- # Only disable streaming for GPT-5 if NOT a Shotgun account
624
- # Shotgun accounts support streaming for GPT-5
625
- is_gpt5_byok = "gpt-5" in model_name.lower() and not is_shotgun_account
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/billing/payment-methods) with OpenAI\n\n"
637
+ "Continuing without streaming (responses will appear all at once)."
638
+ )
639
+ )
640
+ )
641
+ self._post_messages_updated()
626
642
 
627
643
  # Track message send event
628
644
  event_name = f"message_send_{self._current_agent_type.value}"
@@ -642,7 +658,7 @@ class AgentManager(Widget):
642
658
  usage_limits=usage_limits,
643
659
  message_history=message_history,
644
660
  event_stream_handler=self._handle_event_stream
645
- if not is_gpt5_byok
661
+ if supports_streaming
646
662
  else None,
647
663
  **kwargs,
648
664
  )
@@ -769,6 +785,12 @@ class AgentManager(Widget):
769
785
  HintMessage(message=agent_response.response)
770
786
  )
771
787
 
788
+ # Add file operation hints before questions (so they appear first in UI)
789
+ if file_operations:
790
+ file_hint = self._create_file_operation_hint(file_operations)
791
+ if file_hint:
792
+ self.ui_message_history.append(HintMessage(message=file_hint))
793
+
772
794
  if len(agent_response.clarifying_questions) == 1:
773
795
  # Single question - treat as non-blocking suggestion, DON'T enter Q&A mode
774
796
  self.ui_message_history.append(
@@ -1134,6 +1156,38 @@ class AgentManager(Widget):
1134
1156
  )
1135
1157
  )
1136
1158
 
1159
+ def _create_file_operation_hint(
1160
+ self, file_operations: list[FileOperation]
1161
+ ) -> str | None:
1162
+ """Create a hint message for file operations.
1163
+
1164
+ Args:
1165
+ file_operations: List of file operations to create a hint for
1166
+
1167
+ Returns:
1168
+ Hint message string or None if no operations
1169
+ """
1170
+ if not file_operations:
1171
+ return None
1172
+
1173
+ tracker = FileOperationTracker(operations=file_operations)
1174
+ display_path = tracker.get_display_path()
1175
+
1176
+ if not display_path:
1177
+ return None
1178
+
1179
+ path_obj = Path(display_path)
1180
+
1181
+ if len(file_operations) == 1:
1182
+ return f"📝 Modified: `{display_path}`"
1183
+ else:
1184
+ num_files = len({op.file_path for op in file_operations})
1185
+ if path_obj.is_dir():
1186
+ return f"📁 Modified {num_files} files in: `{display_path}`"
1187
+ else:
1188
+ # Common path is a file, show parent directory
1189
+ return f"📁 Modified {num_files} files in: `{path_obj.parent}`"
1190
+
1137
1191
  def _post_messages_updated(
1138
1192
  self, file_operations: list[FileOperation] | None = None
1139
1193
  ) -> None:
@@ -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 ConfigManager, get_config_manager
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",
@@ -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
- # 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
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
- "Migrated config v2->v3: renamed user_id to shotgun_instance_id"
275
+ f"Config needs migration from v{current_version} to v{CURRENT_CONFIG_VERSION}"
85
276
  )
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"
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
- # 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")
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 Exception as e:
366
+ except ConfigMigrationError as migration_error:
367
+ # Migration failed - automatically create fresh config with migration info
173
368
  logger.error(
174
- "Failed to load configuration from %s: %s", self.config_path, e
369
+ "Config migration failed, creating fresh config: %s", migration_error
175
370
  )
176
- logger.info("Creating new configuration with generated shotgun_instance_id")
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:
@@ -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:
@@ -148,6 +152,10 @@ class OpenAIConfig(BaseModel):
148
152
  """Configuration for OpenAI provider."""
149
153
 
150
154
  api_key: SecretStr | None = None
155
+ supports_streaming: bool | None = Field(
156
+ default=None,
157
+ description="Whether streaming is supported for this API key. None = not tested yet",
158
+ )
151
159
 
152
160
 
153
161
  class AnthropicConfig(BaseModel):
@@ -200,12 +208,24 @@ class ShotgunConfig(BaseModel):
200
208
  shotgun_instance_id: str = Field(
201
209
  description="Unique shotgun instance identifier (also used for anonymous telemetry)",
202
210
  )
203
- config_version: int = Field(default=4, description="Configuration schema version")
211
+ config_version: int = Field(default=5, description="Configuration schema version")
204
212
  shown_welcome_screen: bool = Field(
205
213
  default=False,
206
214
  description="Whether the welcome screen has been shown to the user",
207
215
  )
216
+ shown_onboarding_popup: datetime | None = Field(
217
+ default=None,
218
+ description="Timestamp when the onboarding popup was shown to the user (ISO8601 format)",
219
+ )
208
220
  marketing: MarketingConfig = Field(
209
221
  default_factory=MarketingConfig,
210
222
  description="Marketing messages configuration and tracking",
211
223
  )
224
+ migration_failed: bool = Field(
225
+ default=False,
226
+ description="Whether the last config migration failed (cleared after user configures a provider)",
227
+ )
228
+ migration_backup_path: str | None = Field(
229
+ default=None,
230
+ description="Path to the backup file created when migration failed",
231
+ )