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.
- shotgun/agents/agent_manager.py +219 -37
- shotgun/agents/common.py +79 -78
- shotgun/agents/config/README.md +89 -0
- shotgun/agents/config/__init__.py +10 -1
- shotgun/agents/config/manager.py +364 -53
- shotgun/agents/config/models.py +101 -21
- shotgun/agents/config/provider.py +51 -13
- shotgun/agents/config/streaming_test.py +119 -0
- shotgun/agents/context_analyzer/analyzer.py +6 -2
- shotgun/agents/conversation/__init__.py +18 -0
- shotgun/agents/conversation/filters.py +164 -0
- shotgun/agents/conversation/history/chunking.py +278 -0
- shotgun/agents/{history → conversation/history}/compaction.py +27 -1
- shotgun/agents/{history → conversation/history}/constants.py +5 -0
- shotgun/agents/conversation/history/file_content_deduplication.py +239 -0
- shotgun/agents/{history → conversation/history}/history_processors.py +267 -3
- shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +8 -0
- shotgun/agents/{conversation_manager.py → conversation/manager.py} +1 -1
- shotgun/agents/{conversation_history.py → conversation/models.py} +8 -94
- shotgun/agents/error/__init__.py +11 -0
- shotgun/agents/error/models.py +19 -0
- shotgun/agents/export.py +12 -13
- shotgun/agents/models.py +66 -1
- shotgun/agents/plan.py +12 -13
- shotgun/agents/research.py +13 -10
- shotgun/agents/router/__init__.py +47 -0
- shotgun/agents/router/models.py +376 -0
- shotgun/agents/router/router.py +185 -0
- shotgun/agents/router/tools/__init__.py +18 -0
- shotgun/agents/router/tools/delegation_tools.py +503 -0
- shotgun/agents/router/tools/plan_tools.py +322 -0
- shotgun/agents/runner.py +230 -0
- shotgun/agents/specify.py +12 -13
- shotgun/agents/tasks.py +12 -13
- shotgun/agents/tools/file_management.py +49 -1
- shotgun/agents/tools/registry.py +2 -0
- shotgun/agents/tools/web_search/__init__.py +1 -2
- shotgun/agents/tools/web_search/gemini.py +1 -3
- shotgun/agents/tools/web_search/openai.py +1 -1
- shotgun/build_constants.py +2 -2
- shotgun/cli/clear.py +1 -1
- shotgun/cli/compact.py +5 -3
- shotgun/cli/context.py +44 -1
- shotgun/cli/error_handler.py +24 -0
- shotgun/cli/export.py +34 -34
- shotgun/cli/plan.py +34 -34
- shotgun/cli/research.py +17 -9
- shotgun/cli/spec/__init__.py +5 -0
- shotgun/cli/spec/backup.py +81 -0
- shotgun/cli/spec/commands.py +132 -0
- shotgun/cli/spec/models.py +48 -0
- shotgun/cli/spec/pull_service.py +219 -0
- shotgun/cli/specify.py +20 -19
- shotgun/cli/tasks.py +34 -34
- shotgun/codebase/core/change_detector.py +1 -1
- shotgun/codebase/core/ingestor.py +154 -8
- shotgun/codebase/core/manager.py +1 -1
- shotgun/codebase/models.py +2 -0
- shotgun/exceptions.py +325 -0
- shotgun/llm_proxy/__init__.py +17 -0
- shotgun/llm_proxy/client.py +215 -0
- shotgun/llm_proxy/models.py +137 -0
- shotgun/logging_config.py +42 -0
- shotgun/main.py +4 -0
- shotgun/posthog_telemetry.py +1 -1
- shotgun/prompts/agents/export.j2 +2 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +23 -3
- shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
- shotgun/prompts/agents/partials/router_delegation_mode.j2 +36 -0
- shotgun/prompts/agents/plan.j2 +29 -1
- shotgun/prompts/agents/research.j2 +75 -23
- shotgun/prompts/agents/router.j2 +440 -0
- shotgun/prompts/agents/specify.j2 +80 -4
- shotgun/prompts/agents/state/system_state.j2 +15 -8
- shotgun/prompts/agents/tasks.j2 +63 -23
- shotgun/prompts/history/chunk_summarization.j2 +34 -0
- shotgun/prompts/history/combine_summaries.j2 +53 -0
- shotgun/sdk/codebase.py +14 -3
- shotgun/settings.py +5 -0
- shotgun/shotgun_web/__init__.py +67 -1
- shotgun/shotgun_web/client.py +42 -1
- shotgun/shotgun_web/constants.py +46 -0
- shotgun/shotgun_web/exceptions.py +29 -0
- shotgun/shotgun_web/models.py +390 -0
- shotgun/shotgun_web/shared_specs/__init__.py +32 -0
- shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
- shotgun/shotgun_web/shared_specs/hasher.py +83 -0
- shotgun/shotgun_web/shared_specs/models.py +71 -0
- shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
- shotgun/shotgun_web/shared_specs/utils.py +34 -0
- shotgun/shotgun_web/specs_client.py +703 -0
- shotgun/shotgun_web/supabase_client.py +31 -0
- shotgun/tui/app.py +78 -15
- shotgun/tui/components/mode_indicator.py +120 -25
- shotgun/tui/components/status_bar.py +2 -2
- shotgun/tui/containers.py +1 -1
- shotgun/tui/dependencies.py +64 -9
- shotgun/tui/layout.py +5 -0
- shotgun/tui/protocols.py +37 -0
- shotgun/tui/screens/chat/chat.tcss +9 -1
- shotgun/tui/screens/chat/chat_screen.py +1015 -106
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +196 -17
- shotgun/tui/screens/chat_screen/command_providers.py +13 -89
- shotgun/tui/screens/chat_screen/hint_message.py +76 -1
- shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
- shotgun/tui/screens/chat_screen/history/chat_history.py +12 -0
- shotgun/tui/screens/chat_screen/history/formatters.py +53 -15
- shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
- shotgun/tui/screens/chat_screen/messages.py +219 -0
- shotgun/tui/screens/confirmation_dialog.py +40 -0
- shotgun/tui/screens/directory_setup.py +45 -41
- shotgun/tui/screens/feedback.py +10 -3
- shotgun/tui/screens/github_issue.py +11 -2
- shotgun/tui/screens/model_picker.py +28 -8
- shotgun/tui/screens/onboarding.py +179 -26
- shotgun/tui/screens/pipx_migration.py +58 -6
- shotgun/tui/screens/provider_config.py +66 -8
- shotgun/tui/screens/shared_specs/__init__.py +21 -0
- shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
- shotgun/tui/screens/shared_specs/models.py +56 -0
- shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
- shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
- shotgun/tui/screens/shotgun_auth.py +110 -16
- shotgun/tui/screens/spec_pull.py +288 -0
- shotgun/tui/screens/welcome.py +123 -0
- shotgun/tui/services/conversation_service.py +5 -2
- shotgun/tui/utils/mode_progress.py +20 -86
- shotgun/tui/widgets/__init__.py +2 -1
- shotgun/tui/widgets/approval_widget.py +152 -0
- shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
- shotgun/tui/widgets/plan_panel.py +129 -0
- shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
- shotgun/tui/widgets/widget_coordinator.py +1 -1
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/METADATA +11 -4
- shotgun_sh-0.4.0.dev1.dist-info/RECORD +242 -0
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/WHEEL +1 -1
- shotgun_sh-0.2.17.dist-info/RECORD +0 -194
- /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
- /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
- /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/base.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/openai.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/licenses/LICENSE +0 -0
shotgun/agents/config/models.py
CHANGED
|
@@ -25,13 +25,17 @@ class KeyProvider(StrEnum):
|
|
|
25
25
|
class ModelName(StrEnum):
|
|
26
26
|
"""Available AI model names."""
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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.
|
|
89
|
-
name=ModelName.
|
|
105
|
+
ModelName.GPT_5_1: ModelSpec(
|
|
106
|
+
name=ModelName.GPT_5_1,
|
|
90
107
|
provider=ProviderType.OPENAI,
|
|
91
|
-
max_input_tokens=
|
|
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.
|
|
97
|
-
name=ModelName.
|
|
113
|
+
ModelName.GPT_5_1_CODEX: ModelSpec(
|
|
114
|
+
name=ModelName.GPT_5_1_CODEX,
|
|
98
115
|
provider=ProviderType.OPENAI,
|
|
99
|
-
max_input_tokens=
|
|
116
|
+
max_input_tokens=272_000,
|
|
100
117
|
max_output_tokens=128_000,
|
|
101
|
-
litellm_proxy_model_name="openai/gpt-5-
|
|
102
|
-
short_name="GPT-5
|
|
118
|
+
litellm_proxy_model_name="openai/gpt-5.1-codex",
|
|
119
|
+
short_name="GPT-5.1 Codex",
|
|
103
120
|
),
|
|
104
|
-
ModelName.
|
|
105
|
-
name=ModelName.
|
|
106
|
-
provider=ProviderType.
|
|
107
|
-
max_input_tokens=
|
|
108
|
-
max_output_tokens=
|
|
109
|
-
litellm_proxy_model_name="
|
|
110
|
-
short_name="
|
|
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=
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
19
|
-
|
|
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
|