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.

Files changed (51) hide show
  1. shotgun/agents/agent_manager.py +25 -11
  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 +26 -1
  6. shotgun/agents/config/provider.py +27 -0
  7. shotgun/agents/config/streaming_test.py +119 -0
  8. shotgun/agents/error/__init__.py +11 -0
  9. shotgun/agents/error/models.py +19 -0
  10. shotgun/agents/history/token_counting/anthropic.py +8 -0
  11. shotgun/agents/runner.py +230 -0
  12. shotgun/build_constants.py +1 -1
  13. shotgun/cli/context.py +43 -0
  14. shotgun/cli/error_handler.py +24 -0
  15. shotgun/cli/export.py +34 -34
  16. shotgun/cli/plan.py +34 -34
  17. shotgun/cli/research.py +17 -9
  18. shotgun/cli/specify.py +20 -19
  19. shotgun/cli/tasks.py +34 -34
  20. shotgun/exceptions.py +323 -0
  21. shotgun/llm_proxy/__init__.py +17 -0
  22. shotgun/llm_proxy/client.py +215 -0
  23. shotgun/llm_proxy/models.py +137 -0
  24. shotgun/logging_config.py +42 -0
  25. shotgun/main.py +2 -0
  26. shotgun/posthog_telemetry.py +18 -25
  27. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +3 -2
  28. shotgun/sdk/codebase.py +14 -3
  29. shotgun/sentry_telemetry.py +140 -2
  30. shotgun/settings.py +5 -0
  31. shotgun/tui/app.py +35 -10
  32. shotgun/tui/screens/chat/chat_screen.py +192 -91
  33. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +62 -11
  34. shotgun/tui/screens/chat_screen/command_providers.py +3 -2
  35. shotgun/tui/screens/chat_screen/hint_message.py +76 -1
  36. shotgun/tui/screens/chat_screen/history/chat_history.py +37 -2
  37. shotgun/tui/screens/directory_setup.py +45 -41
  38. shotgun/tui/screens/feedback.py +10 -3
  39. shotgun/tui/screens/github_issue.py +11 -2
  40. shotgun/tui/screens/model_picker.py +8 -1
  41. shotgun/tui/screens/pipx_migration.py +12 -6
  42. shotgun/tui/screens/provider_config.py +25 -8
  43. shotgun/tui/screens/shotgun_auth.py +0 -10
  44. shotgun/tui/screens/welcome.py +32 -0
  45. shotgun/tui/widgets/widget_coordinator.py +3 -2
  46. shotgun_sh-0.2.23.dev1.dist-info/METADATA +472 -0
  47. {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/RECORD +50 -42
  48. shotgun_sh-0.2.11.dev7.dist-info/METADATA +0 -130
  49. {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/WHEEL +0 -0
  50. {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/entry_points.txt +0 -0
  51. {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/licenses/LICENSE +0 -0
@@ -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
- # Check if it's a Shotgun account
622
- is_shotgun_account = (
623
- hasattr(deps, "llm_model")
624
- and deps.llm_model is not None
625
- and deps.llm_model.key_provider == KeyProvider.SHOTGUN
626
- )
627
-
628
- # Only disable streaming for GPT-5 if NOT a Shotgun account
629
- # Shotgun accounts support streaming for GPT-5
630
- 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/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 not is_gpt5_byok
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 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:
@@ -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=4, description="Configuration schema version")
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: