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.
Files changed (112) hide show
  1. shotgun/agents/agent_manager.py +28 -14
  2. shotgun/agents/common.py +1 -1
  3. shotgun/agents/config/README.md +89 -0
  4. shotgun/agents/config/__init__.py +10 -1
  5. shotgun/agents/config/manager.py +323 -53
  6. shotgun/agents/config/models.py +85 -21
  7. shotgun/agents/config/provider.py +51 -13
  8. shotgun/agents/config/streaming_test.py +119 -0
  9. shotgun/agents/context_analyzer/analyzer.py +6 -2
  10. shotgun/agents/conversation/__init__.py +18 -0
  11. shotgun/agents/conversation/filters.py +164 -0
  12. shotgun/agents/conversation/history/chunking.py +278 -0
  13. shotgun/agents/{history → conversation/history}/compaction.py +27 -1
  14. shotgun/agents/{history → conversation/history}/constants.py +5 -0
  15. shotgun/agents/conversation/history/file_content_deduplication.py +216 -0
  16. shotgun/agents/{history → conversation/history}/history_processors.py +267 -3
  17. shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +8 -0
  18. shotgun/agents/{conversation_manager.py → conversation/manager.py} +1 -1
  19. shotgun/agents/{conversation_history.py → conversation/models.py} +8 -94
  20. shotgun/agents/error/__init__.py +11 -0
  21. shotgun/agents/error/models.py +19 -0
  22. shotgun/agents/runner.py +230 -0
  23. shotgun/agents/tools/web_search/openai.py +1 -1
  24. shotgun/build_constants.py +2 -2
  25. shotgun/cli/clear.py +1 -1
  26. shotgun/cli/compact.py +5 -3
  27. shotgun/cli/context.py +44 -1
  28. shotgun/cli/error_handler.py +24 -0
  29. shotgun/cli/export.py +34 -34
  30. shotgun/cli/plan.py +34 -34
  31. shotgun/cli/research.py +17 -9
  32. shotgun/cli/spec/__init__.py +5 -0
  33. shotgun/cli/spec/backup.py +81 -0
  34. shotgun/cli/spec/commands.py +132 -0
  35. shotgun/cli/spec/models.py +48 -0
  36. shotgun/cli/spec/pull_service.py +219 -0
  37. shotgun/cli/specify.py +20 -19
  38. shotgun/cli/tasks.py +34 -34
  39. shotgun/codebase/core/ingestor.py +153 -7
  40. shotgun/codebase/models.py +2 -0
  41. shotgun/exceptions.py +325 -0
  42. shotgun/llm_proxy/__init__.py +17 -0
  43. shotgun/llm_proxy/client.py +215 -0
  44. shotgun/llm_proxy/models.py +137 -0
  45. shotgun/logging_config.py +42 -0
  46. shotgun/main.py +4 -0
  47. shotgun/posthog_telemetry.py +1 -1
  48. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -3
  49. shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
  50. shotgun/prompts/agents/plan.j2 +16 -0
  51. shotgun/prompts/agents/research.j2 +16 -3
  52. shotgun/prompts/agents/specify.j2 +54 -1
  53. shotgun/prompts/agents/state/system_state.j2 +0 -2
  54. shotgun/prompts/agents/tasks.j2 +16 -0
  55. shotgun/prompts/history/chunk_summarization.j2 +34 -0
  56. shotgun/prompts/history/combine_summaries.j2 +53 -0
  57. shotgun/sdk/codebase.py +14 -3
  58. shotgun/settings.py +5 -0
  59. shotgun/shotgun_web/__init__.py +67 -1
  60. shotgun/shotgun_web/client.py +42 -1
  61. shotgun/shotgun_web/constants.py +46 -0
  62. shotgun/shotgun_web/exceptions.py +29 -0
  63. shotgun/shotgun_web/models.py +390 -0
  64. shotgun/shotgun_web/shared_specs/__init__.py +32 -0
  65. shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
  66. shotgun/shotgun_web/shared_specs/hasher.py +83 -0
  67. shotgun/shotgun_web/shared_specs/models.py +71 -0
  68. shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
  69. shotgun/shotgun_web/shared_specs/utils.py +34 -0
  70. shotgun/shotgun_web/specs_client.py +703 -0
  71. shotgun/shotgun_web/supabase_client.py +31 -0
  72. shotgun/tui/app.py +73 -9
  73. shotgun/tui/containers.py +1 -1
  74. shotgun/tui/layout.py +5 -0
  75. shotgun/tui/screens/chat/chat_screen.py +372 -95
  76. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +196 -17
  77. shotgun/tui/screens/chat_screen/command_providers.py +13 -2
  78. shotgun/tui/screens/chat_screen/hint_message.py +76 -1
  79. shotgun/tui/screens/confirmation_dialog.py +40 -0
  80. shotgun/tui/screens/directory_setup.py +45 -41
  81. shotgun/tui/screens/feedback.py +10 -3
  82. shotgun/tui/screens/github_issue.py +11 -2
  83. shotgun/tui/screens/model_picker.py +28 -8
  84. shotgun/tui/screens/onboarding.py +149 -0
  85. shotgun/tui/screens/pipx_migration.py +58 -6
  86. shotgun/tui/screens/provider_config.py +66 -8
  87. shotgun/tui/screens/shared_specs/__init__.py +21 -0
  88. shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
  89. shotgun/tui/screens/shared_specs/models.py +56 -0
  90. shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
  91. shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
  92. shotgun/tui/screens/shotgun_auth.py +110 -16
  93. shotgun/tui/screens/spec_pull.py +288 -0
  94. shotgun/tui/screens/welcome.py +123 -0
  95. shotgun/tui/services/conversation_service.py +5 -2
  96. shotgun/tui/widgets/widget_coordinator.py +1 -1
  97. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/METADATA +9 -2
  98. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/RECORD +112 -77
  99. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/WHEEL +1 -1
  100. /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
  101. /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
  102. /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
  103. /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
  104. /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
  105. /shotgun/agents/{history → conversation/history}/token_counting/base.py +0 -0
  106. /shotgun/agents/{history → conversation/history}/token_counting/openai.py +0 -0
  107. /shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +0 -0
  108. /shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +0 -0
  109. /shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -0
  110. /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
  111. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/entry_points.txt +0 -0
  112. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/licenses/LICENSE +0 -0
@@ -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
- # 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}"
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
- # 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)
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
- # Validate selected_model if in BYOK mode (no Shotgun key)
120
- if not self._provider_has_api_key(self._config.shotgun):
121
- should_save = False
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
- if self._config.selected_model in MODEL_SPECS:
128
- spec = MODEL_SPECS[self._config.selected_model]
129
- if not await self.has_provider_key(spec.provider):
130
- logger.info(
131
- "Selected model %s provider has no API key, finding available model",
132
- self._config.selected_model.value,
133
- )
134
- self._config.selected_model = None
135
- should_save = True
136
- else:
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 not found in MODEL_SPECS, resetting",
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.GPT_5,
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 Exception as e:
374
+ except ConfigMigrationError as migration_error:
375
+ # Migration failed - automatically create fresh config with migration info
173
376
  logger.error(
174
- "Failed to load configuration from %s: %s", self.config_path, e
377
+ "Config migration failed, creating fresh config: %s", migration_error
175
378
  )
176
- logger.info("Creating new configuration with generated shotgun_instance_id")
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.GPT_5,
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, api_key: str | None = None, supabase_jwt: str | None = None
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
 
@@ -25,13 +25,17 @@ class KeyProvider(StrEnum):
25
25
  class ModelName(StrEnum):
26
26
  """Available AI model names."""
27
27
 
28
- GPT_5 = "gpt-5"
29
- GPT_5_MINI = "gpt-5-mini"
30
- CLAUDE_OPUS_4_1 = "claude-opus-4-1"
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.GPT_5: ModelSpec(
89
- name=ModelName.GPT_5,
105
+ ModelName.GPT_5_1: ModelSpec(
106
+ name=ModelName.GPT_5_1,
90
107
  provider=ProviderType.OPENAI,
91
- max_input_tokens=400_000,
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.GPT_5_MINI: ModelSpec(
97
- name=ModelName.GPT_5_MINI,
113
+ ModelName.GPT_5_1_CODEX: ModelSpec(
114
+ name=ModelName.GPT_5_1_CODEX,
98
115
  provider=ProviderType.OPENAI,
99
- max_input_tokens=400_000,
116
+ max_input_tokens=272_000,
100
117
  max_output_tokens=128_000,
101
- litellm_proxy_model_name="openai/gpt-5-mini",
102
- short_name="GPT-5 Mini",
118
+ litellm_proxy_model_name="openai/gpt-5.1-codex",
119
+ short_name="GPT-5.1 Codex",
103
120
  ),
104
- ModelName.CLAUDE_OPUS_4_1: ModelSpec(
105
- name=ModelName.CLAUDE_OPUS_4_1,
106
- provider=ProviderType.ANTHROPIC,
107
- max_input_tokens=200_000,
108
- max_output_tokens=32_000,
109
- litellm_proxy_model_name="anthropic/claude-opus-4-1",
110
- short_name="Opus 4.1",
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=4, description="Configuration schema version")
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
+ )