shotgun-sh 0.2.17__py3-none-any.whl → 0.4.0.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 (150) hide show
  1. shotgun/agents/agent_manager.py +219 -37
  2. shotgun/agents/common.py +79 -78
  3. shotgun/agents/config/README.md +89 -0
  4. shotgun/agents/config/__init__.py +10 -1
  5. shotgun/agents/config/manager.py +364 -53
  6. shotgun/agents/config/models.py +101 -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 +239 -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/export.py +12 -13
  23. shotgun/agents/models.py +66 -1
  24. shotgun/agents/plan.py +12 -13
  25. shotgun/agents/research.py +13 -10
  26. shotgun/agents/router/__init__.py +47 -0
  27. shotgun/agents/router/models.py +376 -0
  28. shotgun/agents/router/router.py +185 -0
  29. shotgun/agents/router/tools/__init__.py +18 -0
  30. shotgun/agents/router/tools/delegation_tools.py +503 -0
  31. shotgun/agents/router/tools/plan_tools.py +322 -0
  32. shotgun/agents/runner.py +230 -0
  33. shotgun/agents/specify.py +12 -13
  34. shotgun/agents/tasks.py +12 -13
  35. shotgun/agents/tools/file_management.py +49 -1
  36. shotgun/agents/tools/registry.py +2 -0
  37. shotgun/agents/tools/web_search/__init__.py +1 -2
  38. shotgun/agents/tools/web_search/gemini.py +1 -3
  39. shotgun/agents/tools/web_search/openai.py +1 -1
  40. shotgun/build_constants.py +2 -2
  41. shotgun/cli/clear.py +1 -1
  42. shotgun/cli/compact.py +5 -3
  43. shotgun/cli/context.py +44 -1
  44. shotgun/cli/error_handler.py +24 -0
  45. shotgun/cli/export.py +34 -34
  46. shotgun/cli/plan.py +34 -34
  47. shotgun/cli/research.py +17 -9
  48. shotgun/cli/spec/__init__.py +5 -0
  49. shotgun/cli/spec/backup.py +81 -0
  50. shotgun/cli/spec/commands.py +132 -0
  51. shotgun/cli/spec/models.py +48 -0
  52. shotgun/cli/spec/pull_service.py +219 -0
  53. shotgun/cli/specify.py +20 -19
  54. shotgun/cli/tasks.py +34 -34
  55. shotgun/codebase/core/change_detector.py +1 -1
  56. shotgun/codebase/core/ingestor.py +154 -8
  57. shotgun/codebase/core/manager.py +1 -1
  58. shotgun/codebase/models.py +2 -0
  59. shotgun/exceptions.py +325 -0
  60. shotgun/llm_proxy/__init__.py +17 -0
  61. shotgun/llm_proxy/client.py +215 -0
  62. shotgun/llm_proxy/models.py +137 -0
  63. shotgun/logging_config.py +42 -0
  64. shotgun/main.py +4 -0
  65. shotgun/posthog_telemetry.py +1 -1
  66. shotgun/prompts/agents/export.j2 +2 -0
  67. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +23 -3
  68. shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
  69. shotgun/prompts/agents/partials/router_delegation_mode.j2 +36 -0
  70. shotgun/prompts/agents/plan.j2 +29 -1
  71. shotgun/prompts/agents/research.j2 +75 -23
  72. shotgun/prompts/agents/router.j2 +440 -0
  73. shotgun/prompts/agents/specify.j2 +80 -4
  74. shotgun/prompts/agents/state/system_state.j2 +15 -8
  75. shotgun/prompts/agents/tasks.j2 +63 -23
  76. shotgun/prompts/history/chunk_summarization.j2 +34 -0
  77. shotgun/prompts/history/combine_summaries.j2 +53 -0
  78. shotgun/sdk/codebase.py +14 -3
  79. shotgun/settings.py +5 -0
  80. shotgun/shotgun_web/__init__.py +67 -1
  81. shotgun/shotgun_web/client.py +42 -1
  82. shotgun/shotgun_web/constants.py +46 -0
  83. shotgun/shotgun_web/exceptions.py +29 -0
  84. shotgun/shotgun_web/models.py +390 -0
  85. shotgun/shotgun_web/shared_specs/__init__.py +32 -0
  86. shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
  87. shotgun/shotgun_web/shared_specs/hasher.py +83 -0
  88. shotgun/shotgun_web/shared_specs/models.py +71 -0
  89. shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
  90. shotgun/shotgun_web/shared_specs/utils.py +34 -0
  91. shotgun/shotgun_web/specs_client.py +703 -0
  92. shotgun/shotgun_web/supabase_client.py +31 -0
  93. shotgun/tui/app.py +78 -15
  94. shotgun/tui/components/mode_indicator.py +120 -25
  95. shotgun/tui/components/status_bar.py +2 -2
  96. shotgun/tui/containers.py +1 -1
  97. shotgun/tui/dependencies.py +64 -9
  98. shotgun/tui/layout.py +5 -0
  99. shotgun/tui/protocols.py +37 -0
  100. shotgun/tui/screens/chat/chat.tcss +9 -1
  101. shotgun/tui/screens/chat/chat_screen.py +1015 -106
  102. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +196 -17
  103. shotgun/tui/screens/chat_screen/command_providers.py +13 -89
  104. shotgun/tui/screens/chat_screen/hint_message.py +76 -1
  105. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  106. shotgun/tui/screens/chat_screen/history/chat_history.py +12 -0
  107. shotgun/tui/screens/chat_screen/history/formatters.py +53 -15
  108. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  109. shotgun/tui/screens/chat_screen/messages.py +219 -0
  110. shotgun/tui/screens/confirmation_dialog.py +40 -0
  111. shotgun/tui/screens/directory_setup.py +45 -41
  112. shotgun/tui/screens/feedback.py +10 -3
  113. shotgun/tui/screens/github_issue.py +11 -2
  114. shotgun/tui/screens/model_picker.py +28 -8
  115. shotgun/tui/screens/onboarding.py +179 -26
  116. shotgun/tui/screens/pipx_migration.py +58 -6
  117. shotgun/tui/screens/provider_config.py +66 -8
  118. shotgun/tui/screens/shared_specs/__init__.py +21 -0
  119. shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
  120. shotgun/tui/screens/shared_specs/models.py +56 -0
  121. shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
  122. shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
  123. shotgun/tui/screens/shotgun_auth.py +110 -16
  124. shotgun/tui/screens/spec_pull.py +288 -0
  125. shotgun/tui/screens/welcome.py +123 -0
  126. shotgun/tui/services/conversation_service.py +5 -2
  127. shotgun/tui/utils/mode_progress.py +20 -86
  128. shotgun/tui/widgets/__init__.py +2 -1
  129. shotgun/tui/widgets/approval_widget.py +152 -0
  130. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  131. shotgun/tui/widgets/plan_panel.py +129 -0
  132. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  133. shotgun/tui/widgets/widget_coordinator.py +1 -1
  134. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/METADATA +11 -4
  135. shotgun_sh-0.4.0.dev1.dist-info/RECORD +242 -0
  136. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/WHEEL +1 -1
  137. shotgun_sh-0.2.17.dist-info/RECORD +0 -194
  138. /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
  139. /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
  140. /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
  141. /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
  142. /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
  143. /shotgun/agents/{history → conversation/history}/token_counting/base.py +0 -0
  144. /shotgun/agents/{history → conversation/history}/token_counting/openai.py +0 -0
  145. /shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +0 -0
  146. /shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +0 -0
  147. /shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -0
  148. /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
  149. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/entry_points.txt +0 -0
  150. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/licenses/LICENSE +0 -0
@@ -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,21 @@ 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
+ )
228
+
229
+ @property
230
+ def has_valid_account(self) -> bool:
231
+ """Check if the user has a valid Shotgun Account configured.
232
+
233
+ Returns:
234
+ True if api_key is set and non-empty, False otherwise
235
+ """
236
+ if self.api_key is None:
237
+ return False
238
+ value = self.api_key.get_secret_value()
239
+ return bool(value and value.strip())
172
240
 
173
241
 
174
242
  class MarketingMessageRecord(BaseModel):
@@ -200,7 +268,7 @@ class ShotgunConfig(BaseModel):
200
268
  shotgun_instance_id: str = Field(
201
269
  description="Unique shotgun instance identifier (also used for anonymous telemetry)",
202
270
  )
203
- config_version: int = Field(default=4, description="Configuration schema version")
271
+ config_version: int = Field(default=5, description="Configuration schema version")
204
272
  shown_welcome_screen: bool = Field(
205
273
  default=False,
206
274
  description="Whether the welcome screen has been shown to the user",
@@ -213,3 +281,15 @@ class ShotgunConfig(BaseModel):
213
281
  default_factory=MarketingConfig,
214
282
  description="Marketing messages configuration and tracking",
215
283
  )
284
+ migration_failed: bool = Field(
285
+ default=False,
286
+ description="Whether the last config migration failed (cleared after user configures a provider)",
287
+ )
288
+ migration_backup_path: str | None = Field(
289
+ default=None,
290
+ description="Path to the backup file created when migration failed",
291
+ )
292
+ router_mode: str = Field(
293
+ default="planning",
294
+ description="Router execution mode: 'planning' or 'drafting'",
295
+ )
@@ -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
 
@@ -46,13 +47,13 @@ def get_default_model_for_provider(config: ShotgunConfig) -> ModelName:
46
47
  """
47
48
  # Priority 1: Shotgun Account
48
49
  if _get_api_key(config.shotgun.api_key):
49
- return ModelName.GPT_5
50
+ return ModelName.GPT_5_1
50
51
 
51
52
  # Priority 2: Individual provider keys
52
53
  if _get_api_key(config.anthropic.api_key):
53
54
  return ModelName.CLAUDE_HAIKU_4_5
54
55
  if _get_api_key(config.openai.api_key):
55
- return ModelName.GPT_5
56
+ return ModelName.GPT_5_1
56
57
  if _get_api_key(config.google.api_key):
57
58
  return ModelName.GEMINI_2_5_PRO
58
59
 
@@ -200,13 +201,16 @@ async def get_provider_model(
200
201
  model_name = provider_or_model
201
202
  else:
202
203
  # No specific model requested - use selected or default
203
- model_name = config.selected_model or ModelName.GPT_5
204
+ model_name = config.selected_model or get_default_model_for_provider(config)
204
205
 
206
+ # Gracefully fall back if the selected model doesn't exist (backwards compatibility)
205
207
  if model_name not in MODEL_SPECS:
206
- raise ValueError(f"Model '{model_name.value}' not found")
208
+ model_name = get_default_model_for_provider(config)
209
+
207
210
  spec = MODEL_SPECS[model_name]
208
211
 
209
212
  # Use Shotgun Account with determined model (provider = actual LLM provider)
213
+ # Shotgun accounts always support streaming (via LiteLLM proxy)
210
214
  return ModelConfig(
211
215
  name=spec.name,
212
216
  provider=spec.provider, # Actual LLM provider (OPENAI/ANTHROPIC/GOOGLE)
@@ -214,6 +218,7 @@ async def get_provider_model(
214
218
  max_input_tokens=spec.max_input_tokens,
215
219
  max_output_tokens=spec.max_output_tokens,
216
220
  api_key=shotgun_api_key,
221
+ supports_streaming=True, # Shotgun accounts always support streaming
217
222
  )
218
223
 
219
224
  # Priority 2: Fall back to individual provider keys
@@ -222,10 +227,12 @@ async def get_provider_model(
222
227
  if isinstance(provider_or_model, ModelName):
223
228
  # Look up the model spec
224
229
  if provider_or_model not in MODEL_SPECS:
225
- raise ValueError(f"Model '{provider_or_model.value}' not found")
226
- spec = MODEL_SPECS[provider_or_model]
227
- provider_enum = spec.provider
228
- requested_model = provider_or_model
230
+ requested_model = None # Fall back to provider default
231
+ provider_enum = None # Will be determined below
232
+ else:
233
+ spec = MODEL_SPECS[provider_or_model]
234
+ provider_enum = spec.provider
235
+ requested_model = provider_or_model
229
236
  else:
230
237
  # Convert string to ProviderType enum if needed (backward compatible)
231
238
  if provider_or_model:
@@ -254,12 +261,40 @@ async def get_provider_model(
254
261
  if not api_key:
255
262
  raise ValueError("OpenAI API key not configured. Set via config.")
256
263
 
257
- # Use requested model or default to gpt-5
258
- model_name = requested_model if requested_model else ModelName.GPT_5
264
+ # Use requested model or default to gpt-5.1
265
+ model_name = requested_model if requested_model else ModelName.GPT_5_1
266
+ # Gracefully fall back if model doesn't exist
259
267
  if model_name not in MODEL_SPECS:
260
- raise ValueError(f"Model '{model_name.value}' not found")
268
+ model_name = ModelName.GPT_5_1
261
269
  spec = MODEL_SPECS[model_name]
262
270
 
271
+ # Check and test streaming capability for GPT-5 family models
272
+ supports_streaming = True # Default to True for all models
273
+ if model_name in (
274
+ ModelName.GPT_5_1,
275
+ ModelName.GPT_5_1_CODEX,
276
+ ModelName.GPT_5_1_CODEX_MINI,
277
+ ):
278
+ # Check if streaming capability has been tested
279
+ streaming_capability = config.openai.supports_streaming
280
+
281
+ if streaming_capability is None:
282
+ # Not tested yet - run streaming test (test once for all GPT-5 models)
283
+ logger.info("Testing streaming capability for OpenAI GPT-5 family...")
284
+ streaming_capability = await check_streaming_capability(
285
+ api_key, model_name.value
286
+ )
287
+
288
+ # Save result to config (applies to all OpenAI models)
289
+ config.openai.supports_streaming = streaming_capability
290
+ await config_manager.save(config)
291
+ logger.info(
292
+ f"Streaming test result: "
293
+ f"{'enabled' if streaming_capability else 'disabled'}"
294
+ )
295
+
296
+ supports_streaming = streaming_capability
297
+
263
298
  # Create fully configured ModelConfig
264
299
  return ModelConfig(
265
300
  name=spec.name,
@@ -268,6 +303,7 @@ async def get_provider_model(
268
303
  max_input_tokens=spec.max_input_tokens,
269
304
  max_output_tokens=spec.max_output_tokens,
270
305
  api_key=api_key,
306
+ supports_streaming=supports_streaming,
271
307
  )
272
308
 
273
309
  elif provider_enum == ProviderType.ANTHROPIC:
@@ -277,8 +313,9 @@ async def get_provider_model(
277
313
 
278
314
  # Use requested model or default to claude-haiku-4-5
279
315
  model_name = requested_model if requested_model else ModelName.CLAUDE_HAIKU_4_5
316
+ # Gracefully fall back if model doesn't exist
280
317
  if model_name not in MODEL_SPECS:
281
- raise ValueError(f"Model '{model_name.value}' not found")
318
+ model_name = ModelName.CLAUDE_HAIKU_4_5
282
319
  spec = MODEL_SPECS[model_name]
283
320
 
284
321
  # Create fully configured ModelConfig
@@ -298,8 +335,9 @@ async def get_provider_model(
298
335
 
299
336
  # Use requested model or default to gemini-2.5-pro
300
337
  model_name = requested_model if requested_model else ModelName.GEMINI_2_5_PRO
338
+ # Gracefully fall back if model doesn't exist
301
339
  if model_name not in MODEL_SPECS:
302
- raise ValueError(f"Model '{model_name.value}' not found")
340
+ model_name = ModelName.GEMINI_2_5_PRO
303
341
  spec = MODEL_SPECS[model_name]
304
342
 
305
343
  # Create fully configured ModelConfig
@@ -0,0 +1,119 @@
1
+ """Utility for testing streaming capability of OpenAI models."""
2
+
3
+ import logging
4
+
5
+ import httpx
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ # Maximum number of attempts to test streaming capability
10
+ MAX_STREAMING_TEST_ATTEMPTS = 3
11
+
12
+ # Timeout for each streaming test attempt (in seconds)
13
+ STREAMING_TEST_TIMEOUT = 10.0
14
+
15
+
16
+ async def check_streaming_capability(
17
+ api_key: str, model_name: str, max_attempts: int = MAX_STREAMING_TEST_ATTEMPTS
18
+ ) -> bool:
19
+ """Check if the given OpenAI model supports streaming with this API key.
20
+
21
+ Retries multiple times to handle transient network issues. Only returns False
22
+ if streaming definitively fails after all retry attempts.
23
+
24
+ Args:
25
+ api_key: The OpenAI API key to test
26
+ model_name: The model name (e.g., "gpt-5", "gpt-5-mini")
27
+ max_attempts: Maximum number of attempts (default: 3)
28
+
29
+ Returns:
30
+ True if streaming is supported, False if it definitively fails
31
+ """
32
+ url = "https://api.openai.com/v1/chat/completions"
33
+ headers = {
34
+ "Authorization": f"Bearer {api_key}",
35
+ "Content-Type": "application/json",
36
+ }
37
+ # GPT-5 family uses max_completion_tokens instead of max_tokens
38
+ payload = {
39
+ "model": model_name,
40
+ "messages": [{"role": "user", "content": "test"}],
41
+ "stream": True,
42
+ "max_completion_tokens": 10,
43
+ }
44
+
45
+ last_error = None
46
+
47
+ for attempt in range(1, max_attempts + 1):
48
+ logger.debug(
49
+ f"Streaming test attempt {attempt}/{max_attempts} for {model_name}"
50
+ )
51
+
52
+ try:
53
+ async with httpx.AsyncClient(timeout=STREAMING_TEST_TIMEOUT) as client:
54
+ async with client.stream(
55
+ "POST", url, json=payload, headers=headers
56
+ ) as response:
57
+ # Check if we get a successful response
58
+ if response.status_code != 200:
59
+ last_error = f"HTTP {response.status_code}"
60
+ logger.warning(
61
+ f"Streaming test attempt {attempt} failed for {model_name}: {last_error}"
62
+ )
63
+
64
+ # For definitive errors (403 Forbidden, 404 Not Found), don't retry
65
+ if response.status_code in (403, 404):
66
+ logger.info(
67
+ f"Streaming definitively unsupported for {model_name} (HTTP {response.status_code})"
68
+ )
69
+ return False
70
+
71
+ # For other errors, retry
72
+ continue
73
+
74
+ # Try to read at least one chunk from the stream
75
+ try:
76
+ async for _ in response.aiter_bytes():
77
+ # Successfully received streaming data
78
+ logger.info(
79
+ f"Streaming test passed for {model_name} (attempt {attempt})"
80
+ )
81
+ return True
82
+ except Exception as e:
83
+ last_error = str(e)
84
+ logger.warning(
85
+ f"Streaming test attempt {attempt} failed for {model_name} while reading stream: {e}"
86
+ )
87
+ continue
88
+
89
+ except httpx.TimeoutException:
90
+ last_error = "timeout"
91
+ logger.warning(
92
+ f"Streaming test attempt {attempt} timed out for {model_name}"
93
+ )
94
+ continue
95
+ except httpx.HTTPStatusError as e:
96
+ last_error = str(e)
97
+ logger.warning(
98
+ f"Streaming test attempt {attempt} failed for {model_name}: {e}"
99
+ )
100
+ continue
101
+ except Exception as e:
102
+ last_error = str(e)
103
+ logger.warning(
104
+ f"Streaming test attempt {attempt} failed for {model_name} with unexpected error: {e}"
105
+ )
106
+ continue
107
+
108
+ # If we got here without reading any chunks, streaming didn't work
109
+ last_error = "no data received"
110
+ logger.warning(
111
+ f"Streaming test attempt {attempt} failed for {model_name}: no data received"
112
+ )
113
+
114
+ # All attempts exhausted
115
+ logger.error(
116
+ f"Streaming test failed for {model_name} after {max_attempts} attempts. "
117
+ f"Last error: {last_error}. Assuming streaming is NOT supported."
118
+ )
119
+ return False
@@ -15,8 +15,12 @@ from pydantic_ai.messages import (
15
15
  )
16
16
 
17
17
  from shotgun.agents.config.models import ModelConfig
18
- from shotgun.agents.history.token_counting.utils import count_tokens_from_messages
19
- from shotgun.agents.history.token_estimation import estimate_tokens_from_messages
18
+ from shotgun.agents.conversation.history.token_counting.utils import (
19
+ count_tokens_from_messages,
20
+ )
21
+ from shotgun.agents.conversation.history.token_estimation import (
22
+ estimate_tokens_from_messages,
23
+ )
20
24
  from shotgun.agents.messages import AgentSystemPrompt, SystemStatusPrompt
21
25
  from shotgun.logging_config import get_logger
22
26
  from shotgun.tui.screens.chat_screen.hint_message import HintMessage
@@ -0,0 +1,18 @@
1
+ """Conversation module for managing conversation history and persistence."""
2
+
3
+ from .filters import (
4
+ filter_incomplete_messages,
5
+ filter_orphaned_tool_responses,
6
+ is_tool_call_complete,
7
+ )
8
+ from .manager import ConversationManager
9
+ from .models import ConversationHistory, ConversationState
10
+
11
+ __all__ = [
12
+ "ConversationHistory",
13
+ "ConversationManager",
14
+ "ConversationState",
15
+ "filter_incomplete_messages",
16
+ "filter_orphaned_tool_responses",
17
+ "is_tool_call_complete",
18
+ ]
@@ -0,0 +1,164 @@
1
+ """Filter functions for conversation message validation."""
2
+
3
+ import json
4
+ import logging
5
+
6
+ from pydantic_ai.messages import (
7
+ ModelMessage,
8
+ ModelRequest,
9
+ ModelRequestPart,
10
+ ModelResponse,
11
+ ToolCallPart,
12
+ ToolReturnPart,
13
+ )
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def is_tool_call_complete(tool_call: ToolCallPart) -> bool:
19
+ """Check if a tool call has valid, complete JSON arguments.
20
+
21
+ Args:
22
+ tool_call: The tool call part to validate
23
+
24
+ Returns:
25
+ True if the tool call args are valid JSON, False otherwise
26
+ """
27
+ if tool_call.args is None:
28
+ return True # No args is valid
29
+
30
+ if isinstance(tool_call.args, dict):
31
+ return True # Already parsed dict is valid
32
+
33
+ if not isinstance(tool_call.args, str):
34
+ return False
35
+
36
+ # Try to parse the JSON string
37
+ try:
38
+ json.loads(tool_call.args)
39
+ return True
40
+ except (json.JSONDecodeError, ValueError) as e:
41
+ # Log incomplete tool call detection
42
+ args_preview = (
43
+ tool_call.args[:100] + "..."
44
+ if len(tool_call.args) > 100
45
+ else tool_call.args
46
+ )
47
+ logger.info(
48
+ "Detected incomplete tool call in validation",
49
+ extra={
50
+ "tool_name": tool_call.tool_name,
51
+ "tool_call_id": tool_call.tool_call_id,
52
+ "args_preview": args_preview,
53
+ "error": str(e),
54
+ },
55
+ )
56
+ return False
57
+
58
+
59
+ def filter_incomplete_messages(messages: list[ModelMessage]) -> list[ModelMessage]:
60
+ """Filter out messages with incomplete tool calls.
61
+
62
+ Args:
63
+ messages: List of messages to filter
64
+
65
+ Returns:
66
+ List of messages with only complete tool calls
67
+ """
68
+ filtered: list[ModelMessage] = []
69
+ filtered_count = 0
70
+ filtered_tool_names: list[str] = []
71
+
72
+ for message in messages:
73
+ # Only check ModelResponse messages for tool calls
74
+ if not isinstance(message, ModelResponse):
75
+ filtered.append(message)
76
+ continue
77
+
78
+ # Check if any tool calls are incomplete
79
+ has_incomplete_tool_call = False
80
+ for part in message.parts:
81
+ if isinstance(part, ToolCallPart) and not is_tool_call_complete(part):
82
+ has_incomplete_tool_call = True
83
+ filtered_tool_names.append(part.tool_name)
84
+ break
85
+
86
+ # Only include messages without incomplete tool calls
87
+ if not has_incomplete_tool_call:
88
+ filtered.append(message)
89
+ else:
90
+ filtered_count += 1
91
+
92
+ # Log if any messages were filtered
93
+ if filtered_count > 0:
94
+ logger.info(
95
+ "Filtered incomplete messages before saving",
96
+ extra={
97
+ "filtered_count": filtered_count,
98
+ "total_messages": len(messages),
99
+ "filtered_tool_names": filtered_tool_names,
100
+ },
101
+ )
102
+
103
+ return filtered
104
+
105
+
106
+ def filter_orphaned_tool_responses(messages: list[ModelMessage]) -> list[ModelMessage]:
107
+ """Filter out tool responses without corresponding tool calls.
108
+
109
+ This ensures message history is valid for OpenAI API which requires
110
+ tool responses to follow their corresponding tool calls.
111
+
112
+ Args:
113
+ messages: List of messages to filter
114
+
115
+ Returns:
116
+ List of messages with orphaned tool responses removed
117
+ """
118
+ # Collect all tool_call_ids from ToolCallPart in ModelResponse
119
+ valid_tool_call_ids: set[str] = set()
120
+ for msg in messages:
121
+ if isinstance(msg, ModelResponse):
122
+ for part in msg.parts:
123
+ if isinstance(part, ToolCallPart) and part.tool_call_id:
124
+ valid_tool_call_ids.add(part.tool_call_id)
125
+
126
+ # Filter out orphaned ToolReturnPart from ModelRequest
127
+ filtered: list[ModelMessage] = []
128
+ orphaned_count = 0
129
+ orphaned_tool_names: list[str] = []
130
+
131
+ for msg in messages:
132
+ if isinstance(msg, ModelRequest):
133
+ # Filter parts, removing orphaned ToolReturnPart
134
+ filtered_parts: list[ModelRequestPart] = []
135
+ request_part: ModelRequestPart
136
+ for request_part in msg.parts:
137
+ if isinstance(request_part, ToolReturnPart):
138
+ if request_part.tool_call_id in valid_tool_call_ids:
139
+ filtered_parts.append(request_part)
140
+ else:
141
+ # Skip orphaned tool response
142
+ orphaned_count += 1
143
+ orphaned_tool_names.append(request_part.tool_name or "unknown")
144
+ else:
145
+ filtered_parts.append(request_part)
146
+
147
+ # Only add if there are remaining parts
148
+ if filtered_parts:
149
+ filtered.append(ModelRequest(parts=filtered_parts))
150
+ else:
151
+ filtered.append(msg)
152
+
153
+ # Log if any tool responses were filtered
154
+ if orphaned_count > 0:
155
+ logger.info(
156
+ "Filtered orphaned tool responses",
157
+ extra={
158
+ "orphaned_count": orphaned_count,
159
+ "total_messages": len(messages),
160
+ "orphaned_tool_names": orphaned_tool_names,
161
+ },
162
+ )
163
+
164
+ return filtered