shotgun-sh 0.1.16.dev2__py3-none-any.whl → 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of shotgun-sh might be problematic. Click here for more details.

Files changed (55) hide show
  1. shotgun/agents/common.py +4 -5
  2. shotgun/agents/config/constants.py +23 -6
  3. shotgun/agents/config/manager.py +239 -76
  4. shotgun/agents/config/models.py +74 -84
  5. shotgun/agents/config/provider.py +174 -85
  6. shotgun/agents/history/compaction.py +1 -1
  7. shotgun/agents/history/history_processors.py +18 -9
  8. shotgun/agents/history/token_counting/__init__.py +31 -0
  9. shotgun/agents/history/token_counting/anthropic.py +89 -0
  10. shotgun/agents/history/token_counting/base.py +67 -0
  11. shotgun/agents/history/token_counting/openai.py +80 -0
  12. shotgun/agents/history/token_counting/sentencepiece_counter.py +119 -0
  13. shotgun/agents/history/token_counting/tokenizer_cache.py +90 -0
  14. shotgun/agents/history/token_counting/utils.py +147 -0
  15. shotgun/agents/history/token_estimation.py +12 -12
  16. shotgun/agents/llm.py +62 -0
  17. shotgun/agents/models.py +2 -2
  18. shotgun/agents/tools/web_search/__init__.py +42 -15
  19. shotgun/agents/tools/web_search/anthropic.py +54 -50
  20. shotgun/agents/tools/web_search/gemini.py +31 -20
  21. shotgun/agents/tools/web_search/openai.py +4 -4
  22. shotgun/build_constants.py +2 -2
  23. shotgun/cli/config.py +34 -63
  24. shotgun/cli/feedback.py +4 -2
  25. shotgun/cli/models.py +2 -2
  26. shotgun/codebase/core/ingestor.py +47 -8
  27. shotgun/codebase/core/manager.py +7 -3
  28. shotgun/codebase/models.py +4 -4
  29. shotgun/llm_proxy/__init__.py +16 -0
  30. shotgun/llm_proxy/clients.py +39 -0
  31. shotgun/llm_proxy/constants.py +8 -0
  32. shotgun/main.py +6 -0
  33. shotgun/posthog_telemetry.py +15 -11
  34. shotgun/sentry_telemetry.py +3 -3
  35. shotgun/shotgun_web/__init__.py +19 -0
  36. shotgun/shotgun_web/client.py +138 -0
  37. shotgun/shotgun_web/constants.py +17 -0
  38. shotgun/shotgun_web/models.py +47 -0
  39. shotgun/telemetry.py +7 -4
  40. shotgun/tui/app.py +26 -8
  41. shotgun/tui/screens/chat.py +2 -8
  42. shotgun/tui/screens/chat_screen/command_providers.py +118 -11
  43. shotgun/tui/screens/chat_screen/history.py +3 -1
  44. shotgun/tui/screens/feedback.py +2 -2
  45. shotgun/tui/screens/model_picker.py +327 -0
  46. shotgun/tui/screens/provider_config.py +118 -28
  47. shotgun/tui/screens/shotgun_auth.py +295 -0
  48. shotgun/tui/screens/welcome.py +176 -0
  49. shotgun/utils/env_utils.py +12 -0
  50. {shotgun_sh-0.1.16.dev2.dist-info → shotgun_sh-0.2.1.dist-info}/METADATA +2 -2
  51. {shotgun_sh-0.1.16.dev2.dist-info → shotgun_sh-0.2.1.dist-info}/RECORD +54 -37
  52. shotgun/agents/history/token_counting.py +0 -429
  53. {shotgun_sh-0.1.16.dev2.dist-info → shotgun_sh-0.2.1.dist-info}/WHEEL +0 -0
  54. {shotgun_sh-0.1.16.dev2.dist-info → shotgun_sh-0.2.1.dist-info}/entry_points.txt +0 -0
  55. {shotgun_sh-0.1.16.dev2.dist-info → shotgun_sh-0.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,16 +1,12 @@
1
1
  """Pydantic models for configuration."""
2
2
 
3
- from enum import Enum
4
- from typing import Any
3
+ from enum import StrEnum
5
4
 
6
5
  from pydantic import BaseModel, Field, PrivateAttr, SecretStr
7
- from pydantic_ai.direct import model_request
8
- from pydantic_ai.messages import ModelMessage, ModelResponse
9
6
  from pydantic_ai.models import Model
10
- from pydantic_ai.settings import ModelSettings
11
7
 
12
8
 
13
- class ProviderType(str, Enum):
9
+ class ProviderType(StrEnum):
14
10
  """Provider types for AI services."""
15
11
 
16
12
  OPENAI = "openai"
@@ -18,20 +14,42 @@ class ProviderType(str, Enum):
18
14
  GOOGLE = "google"
19
15
 
20
16
 
17
+ class KeyProvider(StrEnum):
18
+ """Authentication method for accessing AI models."""
19
+
20
+ BYOK = "byok" # Bring Your Own Key (individual provider keys)
21
+ SHOTGUN = "shotgun" # Shotgun Account (unified LiteLLM proxy)
22
+
23
+
24
+ class ModelName(StrEnum):
25
+ """Available AI model names."""
26
+
27
+ GPT_5 = "gpt-5"
28
+ GPT_5_MINI = "gpt-5-mini"
29
+ CLAUDE_OPUS_4_1 = "claude-opus-4-1"
30
+ CLAUDE_SONNET_4_5 = "claude-sonnet-4-5"
31
+ GEMINI_2_5_PRO = "gemini-2.5-pro"
32
+ GEMINI_2_5_FLASH = "gemini-2.5-flash"
33
+
34
+
21
35
  class ModelSpec(BaseModel):
22
36
  """Static specification for a model - just metadata."""
23
37
 
24
- name: str # Model identifier (e.g., "gpt-5", "claude-opus-4-1")
38
+ name: ModelName # Model identifier
25
39
  provider: ProviderType
26
40
  max_input_tokens: int
27
41
  max_output_tokens: int
42
+ litellm_proxy_model_name: (
43
+ str # LiteLLM format (e.g., "openai/gpt-5", "gemini/gemini-2-pro")
44
+ )
28
45
 
29
46
 
30
47
  class ModelConfig(BaseModel):
31
48
  """A fully configured model with API key and settings."""
32
49
 
33
- name: str # Model identifier (e.g., "gpt-5", "claude-opus-4-1")
34
- provider: ProviderType
50
+ name: ModelName # Model identifier
51
+ provider: ProviderType # Actual LLM provider (openai, anthropic, google)
52
+ key_provider: KeyProvider # Authentication method (byok or shotgun)
35
53
  max_input_tokens: int
36
54
  max_output_tokens: int
37
55
  api_key: str
@@ -47,7 +65,7 @@ class ModelConfig(BaseModel):
47
65
  from .provider import get_or_create_model
48
66
 
49
67
  self._model_instance = get_or_create_model(
50
- self.provider, self.name, self.api_key
68
+ self.provider, self.key_provider, self.name, self.api_key
51
69
  )
52
70
  return self._model_instance
53
71
 
@@ -61,54 +79,50 @@ class ModelConfig(BaseModel):
61
79
  }
62
80
  return f"{provider_prefix[self.provider]}:{self.name}"
63
81
 
64
- def get_model_settings(self, max_tokens: int | None = None) -> ModelSettings:
65
- """Get ModelSettings with optional token override.
66
-
67
- This provides flexibility for specific use cases that need different
68
- token limits while defaulting to maximum utilization.
69
-
70
- Args:
71
- max_tokens: Optional override for max_tokens. If None, uses max_output_tokens
72
-
73
- Returns:
74
- ModelSettings configured with specified or maximum tokens
75
- """
76
- return ModelSettings(
77
- max_tokens=max_tokens if max_tokens is not None else self.max_output_tokens
78
- )
79
-
80
82
 
81
83
  # Model specifications registry (static metadata)
82
- MODEL_SPECS: dict[str, ModelSpec] = {
83
- "gpt-5": ModelSpec(
84
- name="gpt-5",
84
+ MODEL_SPECS: dict[ModelName, ModelSpec] = {
85
+ ModelName.GPT_5: ModelSpec(
86
+ name=ModelName.GPT_5,
85
87
  provider=ProviderType.OPENAI,
86
88
  max_input_tokens=400_000,
87
89
  max_output_tokens=128_000,
90
+ litellm_proxy_model_name="openai/gpt-5",
88
91
  ),
89
- "gpt-4o": ModelSpec(
90
- name="gpt-4o",
92
+ ModelName.GPT_5_MINI: ModelSpec(
93
+ name=ModelName.GPT_5_MINI,
91
94
  provider=ProviderType.OPENAI,
92
- max_input_tokens=128_000,
93
- max_output_tokens=16_000,
95
+ max_input_tokens=400_000,
96
+ max_output_tokens=128_000,
97
+ litellm_proxy_model_name="openai/gpt-5-mini",
94
98
  ),
95
- "claude-opus-4-1": ModelSpec(
96
- name="claude-opus-4-1",
99
+ ModelName.CLAUDE_OPUS_4_1: ModelSpec(
100
+ name=ModelName.CLAUDE_OPUS_4_1,
97
101
  provider=ProviderType.ANTHROPIC,
98
102
  max_input_tokens=200_000,
99
103
  max_output_tokens=32_000,
104
+ litellm_proxy_model_name="anthropic/claude-opus-4-1",
100
105
  ),
101
- "claude-3-5-sonnet-latest": ModelSpec(
102
- name="claude-3-5-sonnet-latest",
106
+ ModelName.CLAUDE_SONNET_4_5: ModelSpec(
107
+ name=ModelName.CLAUDE_SONNET_4_5,
103
108
  provider=ProviderType.ANTHROPIC,
104
109
  max_input_tokens=200_000,
105
- max_output_tokens=8_192,
110
+ max_output_tokens=16_000,
111
+ litellm_proxy_model_name="anthropic/claude-sonnet-4-5",
112
+ ),
113
+ ModelName.GEMINI_2_5_PRO: ModelSpec(
114
+ name=ModelName.GEMINI_2_5_PRO,
115
+ provider=ProviderType.GOOGLE,
116
+ max_input_tokens=1_000_000,
117
+ max_output_tokens=64_000,
118
+ litellm_proxy_model_name="gemini/gemini-2.5-pro",
106
119
  ),
107
- "gemini-2.5-pro": ModelSpec(
108
- name="gemini-2.5-pro",
120
+ ModelName.GEMINI_2_5_FLASH: ModelSpec(
121
+ name=ModelName.GEMINI_2_5_FLASH,
109
122
  provider=ProviderType.GOOGLE,
110
123
  max_input_tokens=1_000_000,
111
124
  max_output_tokens=64_000,
125
+ litellm_proxy_model_name="gemini/gemini-2.5-flash",
112
126
  ),
113
127
  }
114
128
 
@@ -131,55 +145,31 @@ class GoogleConfig(BaseModel):
131
145
  api_key: SecretStr | None = None
132
146
 
133
147
 
148
+ class ShotgunAccountConfig(BaseModel):
149
+ """Configuration for Shotgun Account (LiteLLM proxy)."""
150
+
151
+ api_key: SecretStr | None = None
152
+ supabase_jwt: SecretStr | None = Field(
153
+ default=None, description="Supabase authentication JWT"
154
+ )
155
+
156
+
134
157
  class ShotgunConfig(BaseModel):
135
158
  """Main configuration for Shotgun CLI."""
136
159
 
137
160
  openai: OpenAIConfig = Field(default_factory=OpenAIConfig)
138
161
  anthropic: AnthropicConfig = Field(default_factory=AnthropicConfig)
139
162
  google: GoogleConfig = Field(default_factory=GoogleConfig)
140
- default_provider: ProviderType = Field(
141
- default=ProviderType.OPENAI, description="Default AI provider to use"
163
+ shotgun: ShotgunAccountConfig = Field(default_factory=ShotgunAccountConfig)
164
+ selected_model: ModelName | None = Field(
165
+ default=None,
166
+ description="User-selected model",
167
+ )
168
+ shotgun_instance_id: str = Field(
169
+ description="Unique shotgun instance identifier (also used for anonymous telemetry)",
142
170
  )
143
- user_id: str = Field(description="Unique anonymous user identifier")
144
- config_version: int = Field(default=1, description="Configuration schema version")
145
-
146
-
147
- async def shotgun_model_request(
148
- model_config: ModelConfig,
149
- messages: list[ModelMessage],
150
- max_tokens: int | None = None,
151
- **kwargs: Any,
152
- ) -> ModelResponse:
153
- """Model request wrapper that uses full token capacity by default.
154
-
155
- This wrapper ensures all LLM calls in Shotgun use the maximum available
156
- token capacity of each model, improving response quality and completeness.
157
- The most common issue this fixes is truncated summaries that were cut off
158
- at default token limits (e.g., 4096 for Claude models).
159
-
160
- Args:
161
- model_config: ModelConfig instance with model settings and API key
162
- messages: Messages to send to the model
163
- max_tokens: Optional override for max_tokens. If None, uses model's max_output_tokens
164
- **kwargs: Additional arguments passed to model_request
165
-
166
- Returns:
167
- ModelResponse from the model
168
-
169
- Example:
170
- # Uses full token capacity (e.g., 4096 for Claude, 128k for GPT-5)
171
- response = await shotgun_model_request(model_config, messages)
172
-
173
- # Override for specific use case
174
- response = await shotgun_model_request(model_config, messages, max_tokens=1000)
175
- """
176
- # Get properly configured ModelSettings with maximum or overridden token limit
177
- model_settings = model_config.get_model_settings(max_tokens)
178
-
179
- # Make the model request with full token utilization
180
- return await model_request(
181
- model=model_config.model_instance,
182
- messages=messages,
183
- model_settings=model_settings,
184
- **kwargs,
171
+ config_version: int = Field(default=3, description="Configuration schema version")
172
+ shown_welcome_screen: bool = Field(
173
+ default=False,
174
+ description="Whether the welcome screen has been shown to the user",
185
175
  )
@@ -1,7 +1,5 @@
1
1
  """Provider management for LLM configuration."""
2
2
 
3
- import os
4
-
5
3
  from pydantic import SecretStr
6
4
  from pydantic_ai.models import Model
7
5
  from pydantic_ai.models.anthropic import AnthropicModel, AnthropicModelSettings
@@ -12,27 +10,36 @@ from pydantic_ai.providers.google import GoogleProvider
12
10
  from pydantic_ai.providers.openai import OpenAIProvider
13
11
  from pydantic_ai.settings import ModelSettings
14
12
 
13
+ from shotgun.llm_proxy import create_litellm_provider
15
14
  from shotgun.logging_config import get_logger
16
15
 
17
- from .constants import (
18
- ANTHROPIC_API_KEY_ENV,
19
- GEMINI_API_KEY_ENV,
20
- OPENAI_API_KEY_ENV,
21
- )
22
16
  from .manager import get_config_manager
23
- from .models import MODEL_SPECS, ModelConfig, ProviderType
17
+ from .models import (
18
+ MODEL_SPECS,
19
+ KeyProvider,
20
+ ModelConfig,
21
+ ModelName,
22
+ ProviderType,
23
+ ShotgunConfig,
24
+ )
24
25
 
25
26
  logger = get_logger(__name__)
26
27
 
27
28
  # Global cache for Model instances (singleton pattern)
28
- _model_cache: dict[tuple[ProviderType, str, str], Model] = {}
29
+ _model_cache: dict[tuple[ProviderType, KeyProvider, ModelName, str], Model] = {}
29
30
 
30
31
 
31
- def get_or_create_model(provider: ProviderType, model_name: str, api_key: str) -> Model:
32
+ def get_or_create_model(
33
+ provider: ProviderType,
34
+ key_provider: "KeyProvider",
35
+ model_name: ModelName,
36
+ api_key: str,
37
+ ) -> Model:
32
38
  """Get or create a singleton Model instance.
33
39
 
34
40
  Args:
35
- provider: Provider type
41
+ provider: Actual LLM provider (openai, anthropic, google)
42
+ key_provider: Authentication method (byok or shotgun)
36
43
  model_name: Name of the model
37
44
  api_key: API key for the provider
38
45
 
@@ -42,66 +49,88 @@ def get_or_create_model(provider: ProviderType, model_name: str, api_key: str) -
42
49
  Raises:
43
50
  ValueError: If provider is not supported
44
51
  """
45
- cache_key = (provider, model_name, api_key)
52
+ cache_key = (provider, key_provider, model_name, api_key)
46
53
 
47
54
  if cache_key not in _model_cache:
48
- logger.debug("Creating new %s model instance: %s", provider.value, model_name)
55
+ logger.debug(
56
+ "Creating new %s model instance via %s: %s",
57
+ provider.value,
58
+ key_provider.value,
59
+ model_name,
60
+ )
49
61
 
50
- if provider == ProviderType.OPENAI:
51
- # Get max_tokens from MODEL_SPECS to use full capacity
62
+ # Get max_tokens from MODEL_SPECS
63
+ if model_name in MODEL_SPECS:
64
+ max_tokens = MODEL_SPECS[model_name].max_output_tokens
65
+ else:
66
+ # Fallback defaults based on provider
67
+ max_tokens = {
68
+ ProviderType.OPENAI: 16_000,
69
+ ProviderType.ANTHROPIC: 32_000,
70
+ ProviderType.GOOGLE: 64_000,
71
+ }.get(provider, 16_000)
72
+
73
+ # Use LiteLLM proxy for Shotgun Account, native providers for BYOK
74
+ if key_provider == KeyProvider.SHOTGUN:
75
+ # Shotgun Account uses LiteLLM proxy for any model
52
76
  if model_name in MODEL_SPECS:
53
- max_tokens = MODEL_SPECS[model_name].max_output_tokens
77
+ litellm_model_name = MODEL_SPECS[model_name].litellm_proxy_model_name
54
78
  else:
55
- max_tokens = 16_000 # Default for GPT models
79
+ # Fallback for unmapped models
80
+ litellm_model_name = f"openai/{model_name.value}"
56
81
 
57
- openai_provider = OpenAIProvider(api_key=api_key)
82
+ litellm_provider = create_litellm_provider(api_key)
58
83
  _model_cache[cache_key] = OpenAIChatModel(
59
- model_name,
60
- provider=openai_provider,
84
+ litellm_model_name,
85
+ provider=litellm_provider,
61
86
  settings=ModelSettings(max_tokens=max_tokens),
62
87
  )
63
- elif provider == ProviderType.ANTHROPIC:
64
- # Get max_tokens from MODEL_SPECS to use full capacity
65
- if model_name in MODEL_SPECS:
66
- max_tokens = MODEL_SPECS[model_name].max_output_tokens
88
+ elif key_provider == KeyProvider.BYOK:
89
+ # Use native provider implementations with user's API keys
90
+ if provider == ProviderType.OPENAI:
91
+ openai_provider = OpenAIProvider(api_key=api_key)
92
+ _model_cache[cache_key] = OpenAIChatModel(
93
+ model_name,
94
+ provider=openai_provider,
95
+ settings=ModelSettings(max_tokens=max_tokens),
96
+ )
97
+ elif provider == ProviderType.ANTHROPIC:
98
+ anthropic_provider = AnthropicProvider(api_key=api_key)
99
+ _model_cache[cache_key] = AnthropicModel(
100
+ model_name,
101
+ provider=anthropic_provider,
102
+ settings=AnthropicModelSettings(
103
+ max_tokens=max_tokens,
104
+ timeout=600, # 10 minutes timeout for large responses
105
+ ),
106
+ )
107
+ elif provider == ProviderType.GOOGLE:
108
+ google_provider = GoogleProvider(api_key=api_key)
109
+ _model_cache[cache_key] = GoogleModel(
110
+ model_name,
111
+ provider=google_provider,
112
+ settings=ModelSettings(max_tokens=max_tokens),
113
+ )
67
114
  else:
68
- max_tokens = 32_000 # Default for Claude models
69
-
70
- anthropic_provider = AnthropicProvider(api_key=api_key)
71
- _model_cache[cache_key] = AnthropicModel(
72
- model_name,
73
- provider=anthropic_provider,
74
- settings=AnthropicModelSettings(
75
- max_tokens=max_tokens,
76
- timeout=600, # 10 minutes timeout for large responses
77
- ),
78
- )
79
- elif provider == ProviderType.GOOGLE:
80
- # Get max_tokens from MODEL_SPECS to use full capacity
81
- if model_name in MODEL_SPECS:
82
- max_tokens = MODEL_SPECS[model_name].max_output_tokens
83
- else:
84
- max_tokens = 64_000 # Default for Gemini models
85
-
86
- google_provider = GoogleProvider(api_key=api_key)
87
- _model_cache[cache_key] = GoogleModel(
88
- model_name,
89
- provider=google_provider,
90
- settings=ModelSettings(max_tokens=max_tokens),
91
- )
115
+ raise ValueError(f"Unsupported provider: {provider}")
92
116
  else:
93
- raise ValueError(f"Unsupported provider: {provider}")
117
+ raise ValueError(f"Unsupported key provider: {key_provider}")
94
118
  else:
95
119
  logger.debug("Reusing cached %s model instance: %s", provider.value, model_name)
96
120
 
97
121
  return _model_cache[cache_key]
98
122
 
99
123
 
100
- def get_provider_model(provider: ProviderType | None = None) -> ModelConfig:
124
+ def get_provider_model(
125
+ provider_or_model: ProviderType | ModelName | None = None,
126
+ ) -> ModelConfig:
101
127
  """Get a fully configured ModelConfig with API key and Model instance.
102
128
 
103
129
  Args:
104
- provider: Provider to get model for. If None, uses default provider
130
+ provider_or_model: Either a ProviderType, ModelName, or None.
131
+ - If ModelName: returns that specific model with appropriate API key
132
+ - If ProviderType: returns default model for that provider (backward compatible)
133
+ - If None: uses default provider with its default model
105
134
 
106
135
  Returns:
107
136
  ModelConfig with API key configured and lazy Model instance
@@ -110,77 +139,119 @@ def get_provider_model(provider: ProviderType | None = None) -> ModelConfig:
110
139
  ValueError: If provider is not configured properly or model not found
111
140
  """
112
141
  config_manager = get_config_manager()
113
- config = config_manager.load()
114
- # Convert string to ProviderType enum if needed
115
- provider_enum = (
116
- provider
117
- if isinstance(provider, ProviderType)
118
- else ProviderType(provider)
119
- if provider
120
- else config.default_provider
121
- )
142
+ # Use cached config for read-only access (performance)
143
+ config = config_manager.load(force_reload=False)
144
+
145
+ # Priority 1: Check if Shotgun key exists - if so, use it for ANY model
146
+ shotgun_api_key = _get_api_key(config.shotgun.api_key)
147
+ if shotgun_api_key:
148
+ # Use selected model or default to claude-sonnet-4-5
149
+ model_name = config.selected_model or ModelName.CLAUDE_SONNET_4_5
150
+ if model_name not in MODEL_SPECS:
151
+ raise ValueError(f"Model '{model_name.value}' not found")
152
+ spec = MODEL_SPECS[model_name]
153
+
154
+ # Use Shotgun Account with selected model (provider = actual LLM provider)
155
+ return ModelConfig(
156
+ name=spec.name,
157
+ provider=spec.provider, # Actual LLM provider (OPENAI/ANTHROPIC/GOOGLE)
158
+ key_provider=KeyProvider.SHOTGUN, # Authenticated via Shotgun Account
159
+ max_input_tokens=spec.max_input_tokens,
160
+ max_output_tokens=spec.max_output_tokens,
161
+ api_key=shotgun_api_key,
162
+ )
163
+
164
+ # Priority 2: Fall back to individual provider keys
165
+
166
+ # Check if a specific model was requested
167
+ if isinstance(provider_or_model, ModelName):
168
+ # Look up the model spec
169
+ if provider_or_model not in MODEL_SPECS:
170
+ raise ValueError(f"Model '{provider_or_model.value}' not found")
171
+ spec = MODEL_SPECS[provider_or_model]
172
+ provider_enum = spec.provider
173
+ requested_model = provider_or_model
174
+ else:
175
+ # Convert string to ProviderType enum if needed (backward compatible)
176
+ if provider_or_model:
177
+ provider_enum = (
178
+ provider_or_model
179
+ if isinstance(provider_or_model, ProviderType)
180
+ else ProviderType(provider_or_model)
181
+ )
182
+ else:
183
+ # No provider specified - find first available provider with a key
184
+ provider_enum = None
185
+ for provider in ProviderType:
186
+ if _has_provider_key(config, provider):
187
+ provider_enum = provider
188
+ break
189
+
190
+ if provider_enum is None:
191
+ raise ValueError(
192
+ "No provider keys configured. Set via environment variables or config."
193
+ )
194
+
195
+ requested_model = None # Will use provider's default model
122
196
 
123
197
  if provider_enum == ProviderType.OPENAI:
124
- api_key = _get_api_key(config.openai.api_key, OPENAI_API_KEY_ENV)
198
+ api_key = _get_api_key(config.openai.api_key)
125
199
  if not api_key:
126
- raise ValueError(
127
- f"OpenAI API key not configured. Set via environment variable {OPENAI_API_KEY_ENV} or config."
128
- )
200
+ raise ValueError("OpenAI API key not configured. Set via config.")
129
201
 
130
- # Get model spec - hardcoded to gpt-5
131
- model_name = "gpt-5"
202
+ # Use requested model or default to gpt-5
203
+ model_name = requested_model if requested_model else ModelName.GPT_5
132
204
  if model_name not in MODEL_SPECS:
133
- raise ValueError(f"Model '{model_name}' not found")
205
+ raise ValueError(f"Model '{model_name.value}' not found")
134
206
  spec = MODEL_SPECS[model_name]
135
207
 
136
208
  # Create fully configured ModelConfig
137
209
  return ModelConfig(
138
210
  name=spec.name,
139
211
  provider=spec.provider,
212
+ key_provider=KeyProvider.BYOK,
140
213
  max_input_tokens=spec.max_input_tokens,
141
214
  max_output_tokens=spec.max_output_tokens,
142
215
  api_key=api_key,
143
216
  )
144
217
 
145
218
  elif provider_enum == ProviderType.ANTHROPIC:
146
- api_key = _get_api_key(config.anthropic.api_key, ANTHROPIC_API_KEY_ENV)
219
+ api_key = _get_api_key(config.anthropic.api_key)
147
220
  if not api_key:
148
- raise ValueError(
149
- f"Anthropic API key not configured. Set via environment variable {ANTHROPIC_API_KEY_ENV} or config."
150
- )
221
+ raise ValueError("Anthropic API key not configured. Set via config.")
151
222
 
152
- # Get model spec - hardcoded to claude-opus-4-1
153
- model_name = "claude-opus-4-1"
223
+ # Use requested model or default to claude-sonnet-4-5
224
+ model_name = requested_model if requested_model else ModelName.CLAUDE_SONNET_4_5
154
225
  if model_name not in MODEL_SPECS:
155
- raise ValueError(f"Model '{model_name}' not found")
226
+ raise ValueError(f"Model '{model_name.value}' not found")
156
227
  spec = MODEL_SPECS[model_name]
157
228
 
158
229
  # Create fully configured ModelConfig
159
230
  return ModelConfig(
160
231
  name=spec.name,
161
232
  provider=spec.provider,
233
+ key_provider=KeyProvider.BYOK,
162
234
  max_input_tokens=spec.max_input_tokens,
163
235
  max_output_tokens=spec.max_output_tokens,
164
236
  api_key=api_key,
165
237
  )
166
238
 
167
239
  elif provider_enum == ProviderType.GOOGLE:
168
- api_key = _get_api_key(config.google.api_key, GEMINI_API_KEY_ENV)
240
+ api_key = _get_api_key(config.google.api_key)
169
241
  if not api_key:
170
- raise ValueError(
171
- f"Gemini API key not configured. Set via environment variable {GEMINI_API_KEY_ENV} or config."
172
- )
242
+ raise ValueError("Gemini API key not configured. Set via config.")
173
243
 
174
- # Get model spec - hardcoded to gemini-2.5-pro
175
- model_name = "gemini-2.5-pro"
244
+ # Use requested model or default to gemini-2.5-pro
245
+ model_name = requested_model if requested_model else ModelName.GEMINI_2_5_PRO
176
246
  if model_name not in MODEL_SPECS:
177
- raise ValueError(f"Model '{model_name}' not found")
247
+ raise ValueError(f"Model '{model_name.value}' not found")
178
248
  spec = MODEL_SPECS[model_name]
179
249
 
180
250
  # Create fully configured ModelConfig
181
251
  return ModelConfig(
182
252
  name=spec.name,
183
253
  provider=spec.provider,
254
+ key_provider=KeyProvider.BYOK,
184
255
  max_input_tokens=spec.max_input_tokens,
185
256
  max_output_tokens=spec.max_output_tokens,
186
257
  api_key=api_key,
@@ -190,12 +261,30 @@ def get_provider_model(provider: ProviderType | None = None) -> ModelConfig:
190
261
  raise ValueError(f"Unsupported provider: {provider_enum}")
191
262
 
192
263
 
193
- def _get_api_key(config_key: SecretStr | None, env_var: str) -> str | None:
194
- """Get API key from config or environment variable.
264
+ def _has_provider_key(config: "ShotgunConfig", provider: ProviderType) -> bool:
265
+ """Check if a provider has a configured API key.
266
+
267
+ Args:
268
+ config: Shotgun configuration
269
+ provider: Provider to check
270
+
271
+ Returns:
272
+ True if provider has a configured API key
273
+ """
274
+ if provider == ProviderType.OPENAI:
275
+ return bool(_get_api_key(config.openai.api_key))
276
+ elif provider == ProviderType.ANTHROPIC:
277
+ return bool(_get_api_key(config.anthropic.api_key))
278
+ elif provider == ProviderType.GOOGLE:
279
+ return bool(_get_api_key(config.google.api_key))
280
+ return False
281
+
282
+
283
+ def _get_api_key(config_key: SecretStr | None) -> str | None:
284
+ """Get API key from config.
195
285
 
196
286
  Args:
197
287
  config_key: API key from configuration
198
- env_var: Environment variable name to check
199
288
 
200
289
  Returns:
201
290
  API key string or None
@@ -203,4 +292,4 @@ def _get_api_key(config_key: SecretStr | None, env_var: str) -> str | None:
203
292
  if config_key is not None:
204
293
  return config_key.get_secret_value()
205
294
 
206
- return os.getenv(env_var)
295
+ return None
@@ -31,7 +31,7 @@ async def apply_persistent_compaction(
31
31
 
32
32
  try:
33
33
  # Count actual token usage using shared utility
34
- estimated_tokens = estimate_tokens_from_messages(messages, deps.llm_model)
34
+ estimated_tokens = await estimate_tokens_from_messages(messages, deps.llm_model)
35
35
 
36
36
  # Create minimal usage info for compaction check
37
37
  usage = RequestUsage(