shotgun-sh 0.2.8.dev2__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 (175) hide show
  1. shotgun/agents/agent_manager.py +382 -60
  2. shotgun/agents/common.py +15 -9
  3. shotgun/agents/config/README.md +89 -0
  4. shotgun/agents/config/__init__.py +10 -1
  5. shotgun/agents/config/constants.py +0 -6
  6. shotgun/agents/config/manager.py +383 -82
  7. shotgun/agents/config/models.py +122 -18
  8. shotgun/agents/config/provider.py +81 -15
  9. shotgun/agents/config/streaming_test.py +119 -0
  10. shotgun/agents/context_analyzer/__init__.py +28 -0
  11. shotgun/agents/context_analyzer/analyzer.py +475 -0
  12. shotgun/agents/context_analyzer/constants.py +9 -0
  13. shotgun/agents/context_analyzer/formatter.py +115 -0
  14. shotgun/agents/context_analyzer/models.py +212 -0
  15. shotgun/agents/conversation/__init__.py +18 -0
  16. shotgun/agents/conversation/filters.py +164 -0
  17. shotgun/agents/conversation/history/chunking.py +278 -0
  18. shotgun/agents/{history → conversation/history}/compaction.py +36 -5
  19. shotgun/agents/{history → conversation/history}/constants.py +5 -0
  20. shotgun/agents/conversation/history/file_content_deduplication.py +216 -0
  21. shotgun/agents/{history → conversation/history}/history_processors.py +380 -8
  22. shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +25 -1
  23. shotgun/agents/{history → conversation/history}/token_counting/base.py +14 -3
  24. shotgun/agents/{history → conversation/history}/token_counting/openai.py +11 -1
  25. shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +8 -0
  26. shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +3 -1
  27. shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -3
  28. shotgun/agents/{conversation_manager.py → conversation/manager.py} +36 -20
  29. shotgun/agents/{conversation_history.py → conversation/models.py} +8 -92
  30. shotgun/agents/error/__init__.py +11 -0
  31. shotgun/agents/error/models.py +19 -0
  32. shotgun/agents/export.py +2 -2
  33. shotgun/agents/plan.py +2 -2
  34. shotgun/agents/research.py +3 -3
  35. shotgun/agents/runner.py +230 -0
  36. shotgun/agents/specify.py +2 -2
  37. shotgun/agents/tasks.py +2 -2
  38. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  39. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  40. shotgun/agents/tools/codebase/file_read.py +11 -2
  41. shotgun/agents/tools/codebase/query_graph.py +6 -0
  42. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  43. shotgun/agents/tools/file_management.py +27 -7
  44. shotgun/agents/tools/registry.py +217 -0
  45. shotgun/agents/tools/web_search/__init__.py +8 -8
  46. shotgun/agents/tools/web_search/anthropic.py +8 -2
  47. shotgun/agents/tools/web_search/gemini.py +7 -1
  48. shotgun/agents/tools/web_search/openai.py +8 -2
  49. shotgun/agents/tools/web_search/utils.py +2 -2
  50. shotgun/agents/usage_manager.py +16 -11
  51. shotgun/api_endpoints.py +7 -3
  52. shotgun/build_constants.py +2 -2
  53. shotgun/cli/clear.py +53 -0
  54. shotgun/cli/compact.py +188 -0
  55. shotgun/cli/config.py +8 -5
  56. shotgun/cli/context.py +154 -0
  57. shotgun/cli/error_handler.py +24 -0
  58. shotgun/cli/export.py +34 -34
  59. shotgun/cli/feedback.py +4 -2
  60. shotgun/cli/models.py +1 -0
  61. shotgun/cli/plan.py +34 -34
  62. shotgun/cli/research.py +18 -10
  63. shotgun/cli/spec/__init__.py +5 -0
  64. shotgun/cli/spec/backup.py +81 -0
  65. shotgun/cli/spec/commands.py +132 -0
  66. shotgun/cli/spec/models.py +48 -0
  67. shotgun/cli/spec/pull_service.py +219 -0
  68. shotgun/cli/specify.py +20 -19
  69. shotgun/cli/tasks.py +34 -34
  70. shotgun/cli/update.py +16 -2
  71. shotgun/codebase/core/change_detector.py +5 -3
  72. shotgun/codebase/core/code_retrieval.py +4 -2
  73. shotgun/codebase/core/ingestor.py +163 -15
  74. shotgun/codebase/core/manager.py +13 -4
  75. shotgun/codebase/core/nl_query.py +1 -1
  76. shotgun/codebase/models.py +2 -0
  77. shotgun/exceptions.py +357 -0
  78. shotgun/llm_proxy/__init__.py +17 -0
  79. shotgun/llm_proxy/client.py +215 -0
  80. shotgun/llm_proxy/models.py +137 -0
  81. shotgun/logging_config.py +60 -27
  82. shotgun/main.py +77 -11
  83. shotgun/posthog_telemetry.py +38 -29
  84. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -2
  85. shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
  86. shotgun/prompts/agents/plan.j2 +16 -0
  87. shotgun/prompts/agents/research.j2 +16 -3
  88. shotgun/prompts/agents/specify.j2 +54 -1
  89. shotgun/prompts/agents/state/system_state.j2 +0 -2
  90. shotgun/prompts/agents/tasks.j2 +16 -0
  91. shotgun/prompts/history/chunk_summarization.j2 +34 -0
  92. shotgun/prompts/history/combine_summaries.j2 +53 -0
  93. shotgun/sdk/codebase.py +14 -3
  94. shotgun/sentry_telemetry.py +163 -16
  95. shotgun/settings.py +243 -0
  96. shotgun/shotgun_web/__init__.py +67 -1
  97. shotgun/shotgun_web/client.py +42 -1
  98. shotgun/shotgun_web/constants.py +46 -0
  99. shotgun/shotgun_web/exceptions.py +29 -0
  100. shotgun/shotgun_web/models.py +390 -0
  101. shotgun/shotgun_web/shared_specs/__init__.py +32 -0
  102. shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
  103. shotgun/shotgun_web/shared_specs/hasher.py +83 -0
  104. shotgun/shotgun_web/shared_specs/models.py +71 -0
  105. shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
  106. shotgun/shotgun_web/shared_specs/utils.py +34 -0
  107. shotgun/shotgun_web/specs_client.py +703 -0
  108. shotgun/shotgun_web/supabase_client.py +31 -0
  109. shotgun/telemetry.py +10 -33
  110. shotgun/tui/app.py +310 -46
  111. shotgun/tui/commands/__init__.py +1 -1
  112. shotgun/tui/components/context_indicator.py +179 -0
  113. shotgun/tui/components/mode_indicator.py +70 -0
  114. shotgun/tui/components/status_bar.py +48 -0
  115. shotgun/tui/containers.py +91 -0
  116. shotgun/tui/dependencies.py +39 -0
  117. shotgun/tui/layout.py +5 -0
  118. shotgun/tui/protocols.py +45 -0
  119. shotgun/tui/screens/chat/__init__.py +5 -0
  120. shotgun/tui/screens/chat/chat.tcss +54 -0
  121. shotgun/tui/screens/chat/chat_screen.py +1531 -0
  122. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +243 -0
  123. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  124. shotgun/tui/screens/chat/help_text.py +40 -0
  125. shotgun/tui/screens/chat/prompt_history.py +48 -0
  126. shotgun/tui/screens/chat.tcss +11 -0
  127. shotgun/tui/screens/chat_screen/command_providers.py +91 -4
  128. shotgun/tui/screens/chat_screen/hint_message.py +76 -1
  129. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  130. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  131. shotgun/tui/screens/chat_screen/history/chat_history.py +115 -0
  132. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  133. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  134. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  135. shotgun/tui/screens/confirmation_dialog.py +191 -0
  136. shotgun/tui/screens/directory_setup.py +45 -41
  137. shotgun/tui/screens/feedback.py +14 -7
  138. shotgun/tui/screens/github_issue.py +111 -0
  139. shotgun/tui/screens/model_picker.py +77 -32
  140. shotgun/tui/screens/onboarding.py +580 -0
  141. shotgun/tui/screens/pipx_migration.py +205 -0
  142. shotgun/tui/screens/provider_config.py +116 -35
  143. shotgun/tui/screens/shared_specs/__init__.py +21 -0
  144. shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
  145. shotgun/tui/screens/shared_specs/models.py +56 -0
  146. shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
  147. shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
  148. shotgun/tui/screens/shotgun_auth.py +112 -18
  149. shotgun/tui/screens/spec_pull.py +288 -0
  150. shotgun/tui/screens/welcome.py +137 -11
  151. shotgun/tui/services/__init__.py +5 -0
  152. shotgun/tui/services/conversation_service.py +187 -0
  153. shotgun/tui/state/__init__.py +7 -0
  154. shotgun/tui/state/processing_state.py +185 -0
  155. shotgun/tui/utils/mode_progress.py +14 -7
  156. shotgun/tui/widgets/__init__.py +5 -0
  157. shotgun/tui/widgets/widget_coordinator.py +263 -0
  158. shotgun/utils/file_system_utils.py +22 -2
  159. shotgun/utils/marketing.py +110 -0
  160. shotgun/utils/update_checker.py +69 -14
  161. shotgun_sh-0.3.3.dev1.dist-info/METADATA +472 -0
  162. shotgun_sh-0.3.3.dev1.dist-info/RECORD +229 -0
  163. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/WHEEL +1 -1
  164. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/entry_points.txt +1 -0
  165. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/licenses/LICENSE +1 -1
  166. shotgun/tui/screens/chat.py +0 -996
  167. shotgun/tui/screens/chat_screen/history.py +0 -335
  168. shotgun_sh-0.2.8.dev2.dist-info/METADATA +0 -126
  169. shotgun_sh-0.2.8.dev2.dist-info/RECORD +0 -155
  170. /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
  171. /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
  172. /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
  173. /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
  174. /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
  175. /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
@@ -1,5 +1,6 @@
1
1
  """Pydantic models for configuration."""
2
2
 
3
+ from datetime import datetime
3
4
  from enum import StrEnum
4
5
 
5
6
  from pydantic import BaseModel, Field, PrivateAttr, SecretStr
@@ -24,12 +25,17 @@ class KeyProvider(StrEnum):
24
25
  class ModelName(StrEnum):
25
26
  """Available AI model names."""
26
27
 
27
- GPT_5 = "gpt-5"
28
- GPT_5_MINI = "gpt-5-mini"
29
- 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"
30
33
  CLAUDE_SONNET_4_5 = "claude-sonnet-4-5"
34
+ CLAUDE_HAIKU_4_5 = "claude-haiku-4-5"
31
35
  GEMINI_2_5_PRO = "gemini-2.5-pro"
32
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"
33
39
 
34
40
 
35
41
  class ModelSpec(BaseModel):
@@ -42,6 +48,7 @@ class ModelSpec(BaseModel):
42
48
  litellm_proxy_model_name: (
43
49
  str # LiteLLM format (e.g., "openai/gpt-5", "gemini/gemini-2-pro")
44
50
  )
51
+ short_name: str # Display name for UI (e.g., "Sonnet 4.5", "GPT-5")
45
52
 
46
53
 
47
54
  class ModelConfig(BaseModel):
@@ -53,6 +60,10 @@ class ModelConfig(BaseModel):
53
60
  max_input_tokens: int
54
61
  max_output_tokens: int
55
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
+ )
56
67
  _model_instance: Model | None = PrivateAttr(default=None)
57
68
 
58
69
  class Config:
@@ -79,29 +90,41 @@ class ModelConfig(BaseModel):
79
90
  }
80
91
  return f"{provider_prefix[self.provider]}:{self.name}"
81
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
+
82
102
 
83
103
  # Model specifications registry (static metadata)
84
104
  MODEL_SPECS: dict[ModelName, ModelSpec] = {
85
- ModelName.GPT_5: ModelSpec(
86
- name=ModelName.GPT_5,
105
+ ModelName.GPT_5_1: ModelSpec(
106
+ name=ModelName.GPT_5_1,
87
107
  provider=ProviderType.OPENAI,
88
- max_input_tokens=400_000,
108
+ max_input_tokens=272_000,
89
109
  max_output_tokens=128_000,
90
- litellm_proxy_model_name="openai/gpt-5",
110
+ litellm_proxy_model_name="openai/gpt-5.1",
111
+ short_name="GPT-5.1",
91
112
  ),
92
- ModelName.GPT_5_MINI: ModelSpec(
93
- name=ModelName.GPT_5_MINI,
113
+ ModelName.GPT_5_1_CODEX: ModelSpec(
114
+ name=ModelName.GPT_5_1_CODEX,
94
115
  provider=ProviderType.OPENAI,
95
- max_input_tokens=400_000,
116
+ max_input_tokens=272_000,
96
117
  max_output_tokens=128_000,
97
- litellm_proxy_model_name="openai/gpt-5-mini",
118
+ litellm_proxy_model_name="openai/gpt-5.1-codex",
119
+ short_name="GPT-5.1 Codex",
98
120
  ),
99
- ModelName.CLAUDE_OPUS_4_1: ModelSpec(
100
- name=ModelName.CLAUDE_OPUS_4_1,
101
- provider=ProviderType.ANTHROPIC,
102
- max_input_tokens=200_000,
103
- max_output_tokens=32_000,
104
- litellm_proxy_model_name="anthropic/claude-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",
105
128
  ),
106
129
  ModelName.CLAUDE_SONNET_4_5: ModelSpec(
107
130
  name=ModelName.CLAUDE_SONNET_4_5,
@@ -109,6 +132,15 @@ MODEL_SPECS: dict[ModelName, ModelSpec] = {
109
132
  max_input_tokens=200_000,
110
133
  max_output_tokens=16_000,
111
134
  litellm_proxy_model_name="anthropic/claude-sonnet-4-5",
135
+ short_name="Sonnet 4.5",
136
+ ),
137
+ ModelName.CLAUDE_HAIKU_4_5: ModelSpec(
138
+ name=ModelName.CLAUDE_HAIKU_4_5,
139
+ provider=ProviderType.ANTHROPIC,
140
+ max_input_tokens=200_000,
141
+ max_output_tokens=64_000,
142
+ litellm_proxy_model_name="anthropic/claude-haiku-4-5",
143
+ short_name="Haiku 4.5",
112
144
  ),
113
145
  ModelName.GEMINI_2_5_PRO: ModelSpec(
114
146
  name=ModelName.GEMINI_2_5_PRO,
@@ -116,6 +148,7 @@ MODEL_SPECS: dict[ModelName, ModelSpec] = {
116
148
  max_input_tokens=1_000_000,
117
149
  max_output_tokens=64_000,
118
150
  litellm_proxy_model_name="gemini/gemini-2.5-pro",
151
+ short_name="Gemini 2.5 Pro",
119
152
  ),
120
153
  ModelName.GEMINI_2_5_FLASH: ModelSpec(
121
154
  name=ModelName.GEMINI_2_5_FLASH,
@@ -123,6 +156,39 @@ MODEL_SPECS: dict[ModelName, ModelSpec] = {
123
156
  max_input_tokens=1_000_000,
124
157
  max_output_tokens=64_000,
125
158
  litellm_proxy_model_name="gemini/gemini-2.5-flash",
159
+ short_name="Gemini 2.5 Flash",
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",
126
192
  ),
127
193
  }
128
194
 
@@ -131,6 +197,10 @@ class OpenAIConfig(BaseModel):
131
197
  """Configuration for OpenAI provider."""
132
198
 
133
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
+ )
134
204
 
135
205
 
136
206
  class AnthropicConfig(BaseModel):
@@ -152,6 +222,24 @@ class ShotgunAccountConfig(BaseModel):
152
222
  supabase_jwt: SecretStr | None = Field(
153
223
  default=None, description="Supabase authentication JWT"
154
224
  )
225
+ workspace_id: str | None = Field(
226
+ default=None, description="Default workspace ID for shared specs"
227
+ )
228
+
229
+
230
+ class MarketingMessageRecord(BaseModel):
231
+ """Record of when a marketing message was shown to the user."""
232
+
233
+ shown_at: datetime = Field(description="Timestamp when the message was shown")
234
+
235
+
236
+ class MarketingConfig(BaseModel):
237
+ """Configuration for marketing messages shown to users."""
238
+
239
+ messages: dict[str, MarketingMessageRecord] = Field(
240
+ default_factory=dict,
241
+ description="Tracking which marketing messages have been shown. Key is message ID (e.g., 'github_star_v1')",
242
+ )
155
243
 
156
244
 
157
245
  class ShotgunConfig(BaseModel):
@@ -168,8 +256,24 @@ class ShotgunConfig(BaseModel):
168
256
  shotgun_instance_id: str = Field(
169
257
  description="Unique shotgun instance identifier (also used for anonymous telemetry)",
170
258
  )
171
- config_version: int = Field(default=3, description="Configuration schema version")
259
+ config_version: int = Field(default=5, description="Configuration schema version")
172
260
  shown_welcome_screen: bool = Field(
173
261
  default=False,
174
262
  description="Whether the welcome screen has been shown to the user",
175
263
  )
264
+ shown_onboarding_popup: datetime | None = Field(
265
+ default=None,
266
+ description="Timestamp when the onboarding popup was shown to the user (ISO8601 format)",
267
+ )
268
+ marketing: MarketingConfig = Field(
269
+ default_factory=MarketingConfig,
270
+ description="Marketing messages configuration and tracking",
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
+ )
@@ -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
 
@@ -32,6 +33,34 @@ logger = get_logger(__name__)
32
33
  _model_cache: dict[tuple[ProviderType, KeyProvider, ModelName, str], Model] = {}
33
34
 
34
35
 
36
+ def get_default_model_for_provider(config: ShotgunConfig) -> ModelName:
37
+ """Get the default model based on which provider/account is configured.
38
+
39
+ Checks API keys in priority order and returns appropriate default model.
40
+ Treats Shotgun Account as a provider context.
41
+
42
+ Args:
43
+ config: Shotgun configuration containing API keys
44
+
45
+ Returns:
46
+ Default ModelName for the configured provider/account
47
+ """
48
+ # Priority 1: Shotgun Account
49
+ if _get_api_key(config.shotgun.api_key):
50
+ return ModelName.GPT_5_1
51
+
52
+ # Priority 2: Individual provider keys
53
+ if _get_api_key(config.anthropic.api_key):
54
+ return ModelName.CLAUDE_HAIKU_4_5
55
+ if _get_api_key(config.openai.api_key):
56
+ return ModelName.GPT_5_1
57
+ if _get_api_key(config.google.api_key):
58
+ return ModelName.GEMINI_2_5_PRO
59
+
60
+ # Fallback: system-wide default
61
+ return ModelName.CLAUDE_HAIKU_4_5
62
+
63
+
35
64
  def get_or_create_model(
36
65
  provider: ProviderType,
37
66
  key_provider: "KeyProvider",
@@ -142,7 +171,7 @@ def get_or_create_model(
142
171
  return _model_cache[cache_key]
143
172
 
144
173
 
145
- def get_provider_model(
174
+ async def get_provider_model(
146
175
  provider_or_model: ProviderType | ModelName | None = None,
147
176
  ) -> ModelConfig:
148
177
  """Get a fully configured ModelConfig with API key and Model instance.
@@ -161,7 +190,7 @@ def get_provider_model(
161
190
  """
162
191
  config_manager = get_config_manager()
163
192
  # Use cached config for read-only access (performance)
164
- config = config_manager.load(force_reload=False)
193
+ config = await config_manager.load(force_reload=False)
165
194
 
166
195
  # Priority 1: Check if Shotgun key exists - if so, use it for ANY model
167
196
  shotgun_api_key = _get_api_key(config.shotgun.api_key)
@@ -172,13 +201,16 @@ def get_provider_model(
172
201
  model_name = provider_or_model
173
202
  else:
174
203
  # No specific model requested - use selected or default
175
- model_name = config.selected_model or ModelName.CLAUDE_SONNET_4_5
204
+ model_name = config.selected_model or get_default_model_for_provider(config)
176
205
 
206
+ # Gracefully fall back if the selected model doesn't exist (backwards compatibility)
177
207
  if model_name not in MODEL_SPECS:
178
- raise ValueError(f"Model '{model_name.value}' not found")
208
+ model_name = get_default_model_for_provider(config)
209
+
179
210
  spec = MODEL_SPECS[model_name]
180
211
 
181
212
  # Use Shotgun Account with determined model (provider = actual LLM provider)
213
+ # Shotgun accounts always support streaming (via LiteLLM proxy)
182
214
  return ModelConfig(
183
215
  name=spec.name,
184
216
  provider=spec.provider, # Actual LLM provider (OPENAI/ANTHROPIC/GOOGLE)
@@ -186,6 +218,7 @@ def get_provider_model(
186
218
  max_input_tokens=spec.max_input_tokens,
187
219
  max_output_tokens=spec.max_output_tokens,
188
220
  api_key=shotgun_api_key,
221
+ supports_streaming=True, # Shotgun accounts always support streaming
189
222
  )
190
223
 
191
224
  # Priority 2: Fall back to individual provider keys
@@ -194,10 +227,12 @@ def get_provider_model(
194
227
  if isinstance(provider_or_model, ModelName):
195
228
  # Look up the model spec
196
229
  if provider_or_model not in MODEL_SPECS:
197
- raise ValueError(f"Model '{provider_or_model.value}' not found")
198
- spec = MODEL_SPECS[provider_or_model]
199
- provider_enum = spec.provider
200
- 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
201
236
  else:
202
237
  # Convert string to ProviderType enum if needed (backward compatible)
203
238
  if provider_or_model:
@@ -226,12 +261,40 @@ def get_provider_model(
226
261
  if not api_key:
227
262
  raise ValueError("OpenAI API key not configured. Set via config.")
228
263
 
229
- # Use requested model or default to gpt-5
230
- 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
231
267
  if model_name not in MODEL_SPECS:
232
- raise ValueError(f"Model '{model_name.value}' not found")
268
+ model_name = ModelName.GPT_5_1
233
269
  spec = MODEL_SPECS[model_name]
234
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
+
235
298
  # Create fully configured ModelConfig
236
299
  return ModelConfig(
237
300
  name=spec.name,
@@ -240,6 +303,7 @@ def get_provider_model(
240
303
  max_input_tokens=spec.max_input_tokens,
241
304
  max_output_tokens=spec.max_output_tokens,
242
305
  api_key=api_key,
306
+ supports_streaming=supports_streaming,
243
307
  )
244
308
 
245
309
  elif provider_enum == ProviderType.ANTHROPIC:
@@ -247,10 +311,11 @@ def get_provider_model(
247
311
  if not api_key:
248
312
  raise ValueError("Anthropic API key not configured. Set via config.")
249
313
 
250
- # Use requested model or default to claude-sonnet-4-5
251
- model_name = requested_model if requested_model else ModelName.CLAUDE_SONNET_4_5
314
+ # Use requested model or default to claude-haiku-4-5
315
+ model_name = requested_model if requested_model else ModelName.CLAUDE_HAIKU_4_5
316
+ # Gracefully fall back if model doesn't exist
252
317
  if model_name not in MODEL_SPECS:
253
- raise ValueError(f"Model '{model_name.value}' not found")
318
+ model_name = ModelName.CLAUDE_HAIKU_4_5
254
319
  spec = MODEL_SPECS[model_name]
255
320
 
256
321
  # Create fully configured ModelConfig
@@ -270,8 +335,9 @@ def get_provider_model(
270
335
 
271
336
  # Use requested model or default to gemini-2.5-pro
272
337
  model_name = requested_model if requested_model else ModelName.GEMINI_2_5_PRO
338
+ # Gracefully fall back if model doesn't exist
273
339
  if model_name not in MODEL_SPECS:
274
- raise ValueError(f"Model '{model_name.value}' not found")
340
+ model_name = ModelName.GEMINI_2_5_PRO
275
341
  spec = MODEL_SPECS[model_name]
276
342
 
277
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
@@ -0,0 +1,28 @@
1
+ """Context analysis module for conversation composition statistics.
2
+
3
+ This module provides tools for analyzing conversation context usage, breaking down
4
+ token consumption by message type and tool category.
5
+ """
6
+
7
+ from .analyzer import ContextAnalyzer
8
+ from .constants import ToolCategory, get_tool_category
9
+ from .formatter import ContextFormatter
10
+ from .models import (
11
+ ContextAnalysis,
12
+ ContextAnalysisOutput,
13
+ ContextCompositionTelemetry,
14
+ MessageTypeStats,
15
+ TokenAllocation,
16
+ )
17
+
18
+ __all__ = [
19
+ "ContextAnalyzer",
20
+ "ContextAnalysis",
21
+ "ContextAnalysisOutput",
22
+ "ContextCompositionTelemetry",
23
+ "ContextFormatter",
24
+ "MessageTypeStats",
25
+ "TokenAllocation",
26
+ "ToolCategory",
27
+ "get_tool_category",
28
+ ]