teddy-cli 0.1.0__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.
- teddy_cli-0.1.0.dist-info/LICENSE +677 -0
- teddy_cli-0.1.0.dist-info/METADATA +33 -0
- teddy_cli-0.1.0.dist-info/RECORD +143 -0
- teddy_cli-0.1.0.dist-info/WHEEL +4 -0
- teddy_cli-0.1.0.dist-info/entry_points.txt +3 -0
- teddy_executor/__init__.py +1 -0
- teddy_executor/__main__.py +335 -0
- teddy_executor/adapters/__init__.py +0 -0
- teddy_executor/adapters/inbound/__init__.py +0 -0
- teddy_executor/adapters/inbound/cli_formatter.py +107 -0
- teddy_executor/adapters/inbound/cli_helpers.py +249 -0
- teddy_executor/adapters/inbound/console_plan_reviewer.py +69 -0
- teddy_executor/adapters/inbound/session_cli_handlers.py +366 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer.py +78 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_app.py +367 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_editor.py +281 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_execution.py +213 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_helpers.py +308 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_logic.py +345 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_previews.py +227 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_widgets.py +246 -0
- teddy_executor/adapters/outbound/__init__.py +7 -0
- teddy_executor/adapters/outbound/console_interactor.py +212 -0
- teddy_executor/adapters/outbound/console_interactor_ask_loop.py +121 -0
- teddy_executor/adapters/outbound/console_interactor_helpers.py +95 -0
- teddy_executor/adapters/outbound/console_tooling.py +62 -0
- teddy_executor/adapters/outbound/filesystem_helpers.py +61 -0
- teddy_executor/adapters/outbound/litellm_adapter.py +462 -0
- teddy_executor/adapters/outbound/local_file_system_adapter.py +300 -0
- teddy_executor/adapters/outbound/local_repo_tree_generator.py +96 -0
- teddy_executor/adapters/outbound/openrouter_hydrator.py +89 -0
- teddy_executor/adapters/outbound/shell_adapter.py +344 -0
- teddy_executor/adapters/outbound/shell_command_builder.py +105 -0
- teddy_executor/adapters/outbound/system_environment_adapter.py +62 -0
- teddy_executor/adapters/outbound/system_environment_inspector.py +54 -0
- teddy_executor/adapters/outbound/system_time_adapter.py +22 -0
- teddy_executor/adapters/outbound/web_scraper_adapter.py +346 -0
- teddy_executor/adapters/outbound/web_searcher_adapter.py +122 -0
- teddy_executor/adapters/outbound/yaml_config_adapter.py +105 -0
- teddy_executor/container.py +333 -0
- teddy_executor/core/__init__.py +0 -0
- teddy_executor/core/domain/__init__.py +0 -0
- teddy_executor/core/domain/models/__init__.py +44 -0
- teddy_executor/core/domain/models/action_ports.py +28 -0
- teddy_executor/core/domain/models/change_set.py +10 -0
- teddy_executor/core/domain/models/exceptions.py +40 -0
- teddy_executor/core/domain/models/execution_report.py +65 -0
- teddy_executor/core/domain/models/orchestrator_ports.py +26 -0
- teddy_executor/core/domain/models/plan.py +85 -0
- teddy_executor/core/domain/models/planning_ports.py +43 -0
- teddy_executor/core/domain/models/project_context.py +56 -0
- teddy_executor/core/domain/models/report_assembly_data.py +18 -0
- teddy_executor/core/domain/models/session.py +17 -0
- teddy_executor/core/domain/models/shell_output.py +12 -0
- teddy_executor/core/domain/models/web_search_results.py +26 -0
- teddy_executor/core/ports/__init__.py +0 -0
- teddy_executor/core/ports/inbound/__init__.py +0 -0
- teddy_executor/core/ports/inbound/edit_simulator.py +33 -0
- teddy_executor/core/ports/inbound/get_context_use_case.py +32 -0
- teddy_executor/core/ports/inbound/init.py +15 -0
- teddy_executor/core/ports/inbound/plan_parser.py +52 -0
- teddy_executor/core/ports/inbound/plan_reviewer.py +44 -0
- teddy_executor/core/ports/inbound/plan_validator.py +26 -0
- teddy_executor/core/ports/inbound/planning_use_case.py +30 -0
- teddy_executor/core/ports/inbound/run_plan_use_case.py +60 -0
- teddy_executor/core/ports/outbound/__init__.py +34 -0
- teddy_executor/core/ports/outbound/config_service.py +29 -0
- teddy_executor/core/ports/outbound/environment_inspector.py +30 -0
- teddy_executor/core/ports/outbound/execution_report_assembler.py +19 -0
- teddy_executor/core/ports/outbound/file_system_manager.py +131 -0
- teddy_executor/core/ports/outbound/llm_client.py +90 -0
- teddy_executor/core/ports/outbound/markdown_report_formatter.py +26 -0
- teddy_executor/core/ports/outbound/prompt_manager.py +55 -0
- teddy_executor/core/ports/outbound/repo_tree_generator.py +17 -0
- teddy_executor/core/ports/outbound/session_loop_guard.py +16 -0
- teddy_executor/core/ports/outbound/session_manager.py +97 -0
- teddy_executor/core/ports/outbound/session_repository.py +65 -0
- teddy_executor/core/ports/outbound/shell_executor.py +24 -0
- teddy_executor/core/ports/outbound/system_environment.py +25 -0
- teddy_executor/core/ports/outbound/time_service.py +28 -0
- teddy_executor/core/ports/outbound/user_interactor.py +126 -0
- teddy_executor/core/ports/outbound/web_scraper.py +24 -0
- teddy_executor/core/ports/outbound/web_searcher.py +25 -0
- teddy_executor/core/services/__init__.py +0 -0
- teddy_executor/core/services/action_changeset_builder.py +90 -0
- teddy_executor/core/services/action_diff_manager.py +110 -0
- teddy_executor/core/services/action_dispatcher.py +142 -0
- teddy_executor/core/services/action_executor.py +209 -0
- teddy_executor/core/services/action_factory.py +197 -0
- teddy_executor/core/services/action_parser_complex.py +216 -0
- teddy_executor/core/services/action_parser_strategies.py +84 -0
- teddy_executor/core/services/context_service.py +437 -0
- teddy_executor/core/services/edit_simulator.py +128 -0
- teddy_executor/core/services/execution_orchestrator.py +295 -0
- teddy_executor/core/services/execution_report_assembler.py +62 -0
- teddy_executor/core/services/init_service.py +80 -0
- teddy_executor/core/services/markdown_plan_parser.py +309 -0
- teddy_executor/core/services/markdown_report_formatter.py +143 -0
- teddy_executor/core/services/parser_infrastructure.py +222 -0
- teddy_executor/core/services/parser_metadata.py +153 -0
- teddy_executor/core/services/parser_reporting.py +267 -0
- teddy_executor/core/services/plan_validator.py +82 -0
- teddy_executor/core/services/planning_service.py +242 -0
- teddy_executor/core/services/prompt_manager.py +146 -0
- teddy_executor/core/services/session_lifecycle_manager.py +228 -0
- teddy_executor/core/services/session_loop_guard.py +46 -0
- teddy_executor/core/services/session_orchestrator.py +538 -0
- teddy_executor/core/services/session_planner.py +43 -0
- teddy_executor/core/services/session_pruning_service.py +438 -0
- teddy_executor/core/services/session_replanner.py +105 -0
- teddy_executor/core/services/session_repository.py +194 -0
- teddy_executor/core/services/session_service.py +529 -0
- teddy_executor/core/services/templates/execution_report.md.j2 +290 -0
- teddy_executor/core/services/validation_rules/__init__.py +4 -0
- teddy_executor/core/services/validation_rules/edit.py +207 -0
- teddy_executor/core/services/validation_rules/edit_matcher.py +247 -0
- teddy_executor/core/services/validation_rules/edit_matcher_heuristics.py +84 -0
- teddy_executor/core/services/validation_rules/execute.py +37 -0
- teddy_executor/core/services/validation_rules/filesystem.py +73 -0
- teddy_executor/core/services/validation_rules/helpers.py +178 -0
- teddy_executor/core/services/validation_rules/message.py +29 -0
- teddy_executor/core/utils/__init__.py +1 -0
- teddy_executor/core/utils/diff.py +57 -0
- teddy_executor/core/utils/io.py +75 -0
- teddy_executor/core/utils/markdown.py +131 -0
- teddy_executor/core/utils/serialization.py +39 -0
- teddy_executor/core/utils/string.py +351 -0
- teddy_executor/prompts.py +45 -0
- teddy_executor/registries/__init__.py +1 -0
- teddy_executor/registries/infrastructure.py +147 -0
- teddy_executor/registries/reviewer.py +57 -0
- teddy_executor/registries/validators.py +47 -0
- teddy_executor/resources/__init__.py +1 -0
- teddy_executor/resources/config/.gitignore +2 -0
- teddy_executor/resources/config/__init__.py +1 -0
- teddy_executor/resources/config/config.yaml +49 -0
- teddy_executor/resources/config/init.context +5 -0
- teddy_executor/resources/config/prompts/architect.xml +462 -0
- teddy_executor/resources/config/prompts/assistant.xml +336 -0
- teddy_executor/resources/config/prompts/debugger.xml +456 -0
- teddy_executor/resources/config/prompts/developer.xml +481 -0
- teddy_executor/resources/config/prompts/pathfinder.xml +502 -0
- teddy_executor/resources/config/prompts/prototyper.xml +425 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional, Protocol
|
|
2
|
+
from teddy_executor.core.ports.outbound.config_service import IConfigService
|
|
3
|
+
from teddy_executor.core.domain.models.exceptions import ConfigurationError
|
|
4
|
+
from teddy_executor.core.ports.outbound.llm_client import ILlmClient, LlmApiError
|
|
5
|
+
from teddy_executor.core.ports.outbound.time_service import ITimeService
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class IOpenRouterHydrator(Protocol):
|
|
9
|
+
"""
|
|
10
|
+
Internal adapter-layer port for fetching live model metadata from OpenRouter.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def get_metadata(self, model_id: str) -> Optional[Dict[str, Any]]:
|
|
14
|
+
"""
|
|
15
|
+
Fetches metadata for a model from OpenRouter.
|
|
16
|
+
Returns a dict with 'context_window' and 'pricing' if found, else None.
|
|
17
|
+
"""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LiteLLMAdapter(ILlmClient):
|
|
22
|
+
"""
|
|
23
|
+
Implements ILlmClient using the litellm library, driven by configuration.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
config_service: IConfigService,
|
|
29
|
+
hydrator: Optional[IOpenRouterHydrator] = None,
|
|
30
|
+
time_service: Optional[ITimeService] = None,
|
|
31
|
+
_litellm_provider: Optional[Any] = None,
|
|
32
|
+
):
|
|
33
|
+
self._config_service = config_service
|
|
34
|
+
self._hydrator = hydrator
|
|
35
|
+
self._time_service = time_service
|
|
36
|
+
self._litellm_initialized = _litellm_provider is not None
|
|
37
|
+
self._litellm_module: Any = _litellm_provider
|
|
38
|
+
self._encoding: Any = None
|
|
39
|
+
self._encoding_model: Optional[str] = None
|
|
40
|
+
self._executor: Any = None
|
|
41
|
+
self._validated: bool = False
|
|
42
|
+
from threading import Lock
|
|
43
|
+
|
|
44
|
+
self._init_lock = Lock()
|
|
45
|
+
|
|
46
|
+
def _get_executor(self) -> Any:
|
|
47
|
+
"""Lazily creates a ThreadPoolExecutor for remote checks."""
|
|
48
|
+
if not self._executor:
|
|
49
|
+
with self._init_lock:
|
|
50
|
+
if not self._executor:
|
|
51
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
52
|
+
|
|
53
|
+
self._executor = ThreadPoolExecutor(max_workers=5)
|
|
54
|
+
return self._executor
|
|
55
|
+
|
|
56
|
+
def _get_litellm(self) -> Any:
|
|
57
|
+
"""Lazily imports and silences litellm once."""
|
|
58
|
+
if not self._litellm_initialized:
|
|
59
|
+
with self._init_lock:
|
|
60
|
+
if not self._litellm_initialized:
|
|
61
|
+
# Silence logging BEFORE import to suppress botocore warnings
|
|
62
|
+
self._ensure_silence(None)
|
|
63
|
+
import litellm
|
|
64
|
+
|
|
65
|
+
# Silence library-specific flags AFTER import
|
|
66
|
+
self._ensure_silence(litellm)
|
|
67
|
+
self._litellm_module = litellm
|
|
68
|
+
self._litellm_initialized = True
|
|
69
|
+
return self._litellm_module
|
|
70
|
+
|
|
71
|
+
def _get_encoding(self, model: str) -> Any:
|
|
72
|
+
"""Lazily retrieves and caches the tiktoken encoding for a model."""
|
|
73
|
+
if self._encoding_model != model:
|
|
74
|
+
with self._init_lock:
|
|
75
|
+
if self._encoding_model != model:
|
|
76
|
+
import tiktoken
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
self._encoding = tiktoken.encoding_for_model(model)
|
|
80
|
+
except KeyError:
|
|
81
|
+
# Fallback for unknown models
|
|
82
|
+
self._encoding = tiktoken.get_encoding("cl100k_base")
|
|
83
|
+
self._encoding_model = model
|
|
84
|
+
return self._encoding
|
|
85
|
+
|
|
86
|
+
def _ensure_silence(self, litellm_module: Any) -> None:
|
|
87
|
+
"""Internal helper to silence litellm lazily."""
|
|
88
|
+
import logging
|
|
89
|
+
import os
|
|
90
|
+
|
|
91
|
+
# 1. Prepare environment before import or reinforce after
|
|
92
|
+
os.environ["LITELLM_LOG"] = "CRITICAL"
|
|
93
|
+
logging.getLogger("LiteLLM").setLevel(logging.CRITICAL)
|
|
94
|
+
|
|
95
|
+
# 2. Configure module-specific flags if provided
|
|
96
|
+
if litellm_module:
|
|
97
|
+
litellm_module.set_verbose = False
|
|
98
|
+
litellm_module.suppress_debug_info = True
|
|
99
|
+
|
|
100
|
+
def _resolve_model(self, model_override: Optional[str] = None) -> str:
|
|
101
|
+
"""Resolves the model name from override, config, or default."""
|
|
102
|
+
resolved = model_override or self._config_service.get_setting("llm.model")
|
|
103
|
+
if not resolved:
|
|
104
|
+
# Fallback to a common encoding if no model is known yet
|
|
105
|
+
resolved = "gpt-4o"
|
|
106
|
+
return str(resolved)
|
|
107
|
+
|
|
108
|
+
def get_completion(
|
|
109
|
+
self, messages: List[Dict[str, str]], model: Optional[str] = None, **kwargs: Any
|
|
110
|
+
) -> Any:
|
|
111
|
+
"""
|
|
112
|
+
Sends a request to an LLM via litellm and returns the raw response object.
|
|
113
|
+
Values in the 'llm' section of the config are passed directly to LiteLLM.
|
|
114
|
+
"""
|
|
115
|
+
litellm = self._get_litellm()
|
|
116
|
+
|
|
117
|
+
# Lazy validation guard: validate config on first invocation
|
|
118
|
+
if not self._validated:
|
|
119
|
+
with self._init_lock:
|
|
120
|
+
if not self._validated:
|
|
121
|
+
errors = self.validate_config()
|
|
122
|
+
if errors:
|
|
123
|
+
raise ConfigurationError(errors[0])
|
|
124
|
+
self._validated = True
|
|
125
|
+
|
|
126
|
+
final_params = self._prepare_completion_params(model, **kwargs)
|
|
127
|
+
|
|
128
|
+
# Lazy startup validation: check config on first call only
|
|
129
|
+
if not self._validated:
|
|
130
|
+
with self._init_lock:
|
|
131
|
+
if not self._validated:
|
|
132
|
+
from teddy_executor.core.domain.models.exceptions import (
|
|
133
|
+
ConfigurationError as _ConfigError,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
errors = self.validate_config()
|
|
137
|
+
if errors:
|
|
138
|
+
raise _ConfigError(errors[0])
|
|
139
|
+
self._validated = True
|
|
140
|
+
|
|
141
|
+
max_attempts_val = final_params.get("max_retries")
|
|
142
|
+
max_attempts = int(max_attempts_val) if max_attempts_val is not None else 3
|
|
143
|
+
last_exception: Optional[Exception] = None
|
|
144
|
+
|
|
145
|
+
for attempt in range(max_attempts):
|
|
146
|
+
try:
|
|
147
|
+
return litellm.completion(messages=messages, **final_params)
|
|
148
|
+
except Exception as e:
|
|
149
|
+
last_exception = e
|
|
150
|
+
response = self._handle_hydration_retry(e, messages, final_params)
|
|
151
|
+
if response:
|
|
152
|
+
return response
|
|
153
|
+
|
|
154
|
+
if self._should_retry_completion(e, attempt, max_attempts):
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
self._raise_specific_completion_errors(e)
|
|
158
|
+
break
|
|
159
|
+
|
|
160
|
+
final_msg = str(last_exception) if last_exception else "Unknown error"
|
|
161
|
+
raise LlmApiError(f"LLM Completion failed: {final_msg}") from last_exception
|
|
162
|
+
|
|
163
|
+
def _prepare_completion_params(
|
|
164
|
+
self, model: Optional[str] = None, **kwargs: Any
|
|
165
|
+
) -> Dict[str, Any]:
|
|
166
|
+
"""Resolves and layers configuration for the completion request."""
|
|
167
|
+
from typing import cast
|
|
168
|
+
|
|
169
|
+
params = {**kwargs}
|
|
170
|
+
|
|
171
|
+
llm_config = cast(Dict[str, Any], self._config_service.get_setting("llm", {}))
|
|
172
|
+
params.update(llm_config)
|
|
173
|
+
|
|
174
|
+
if model:
|
|
175
|
+
params["model"] = model
|
|
176
|
+
|
|
177
|
+
# Default timeout of 300 seconds if not configured
|
|
178
|
+
if "timeout" not in params:
|
|
179
|
+
params["timeout"] = 300
|
|
180
|
+
|
|
181
|
+
if "model" not in params:
|
|
182
|
+
raise LlmApiError(
|
|
183
|
+
"No LLM model specified. Please set 'llm.model' in your config."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# OpenRouter Provider Routing
|
|
187
|
+
target_model = str(params.get("model", ""))
|
|
188
|
+
provider = params.get("provider")
|
|
189
|
+
if provider and target_model.startswith("openrouter/"):
|
|
190
|
+
params.setdefault("extra_body", {})
|
|
191
|
+
params["extra_body"]["providers"] = {"order": [provider.capitalize()]}
|
|
192
|
+
del params["provider"]
|
|
193
|
+
|
|
194
|
+
return params
|
|
195
|
+
|
|
196
|
+
def _should_retry_completion(
|
|
197
|
+
self, error: Exception, attempt: int, max_attempts: int
|
|
198
|
+
) -> bool:
|
|
199
|
+
"""Retries any completion error with exponential backoff."""
|
|
200
|
+
"""Retries on ALL exceptions with exponential backoff, since config
|
|
201
|
+
validation has already passed (so the error must be transient)."""
|
|
202
|
+
if attempt < max_attempts - 1:
|
|
203
|
+
delay = 0.5 * (2**attempt)
|
|
204
|
+
if self._time_service:
|
|
205
|
+
self._time_service.sleep(delay)
|
|
206
|
+
else:
|
|
207
|
+
import time
|
|
208
|
+
|
|
209
|
+
time.sleep(delay)
|
|
210
|
+
return True
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
def _raise_specific_completion_errors(self, error: Exception) -> None:
|
|
214
|
+
"""Identifies and raises specific errors based on exception signature."""
|
|
215
|
+
msg = str(error)
|
|
216
|
+
hints = ["API key expired", "API_KEY_INVALID", "invalid_api_key"]
|
|
217
|
+
if any(hint in msg for hint in hints):
|
|
218
|
+
clean_msg = msg.split(" - ")[-1] if " - " in msg else msg
|
|
219
|
+
raise ConfigurationError(clean_msg) from error
|
|
220
|
+
|
|
221
|
+
def get_token_count(
|
|
222
|
+
self, messages: List[Dict[str, str]], model: Optional[str] = None
|
|
223
|
+
) -> int:
|
|
224
|
+
"""Calculates the number of tokens in the payload."""
|
|
225
|
+
litellm = self._get_litellm()
|
|
226
|
+
resolved_model = self._resolve_model(model)
|
|
227
|
+
return litellm.token_counter(model=resolved_model, messages=messages)
|
|
228
|
+
|
|
229
|
+
def get_text_token_count(self, text: str, model: Optional[str] = None) -> int:
|
|
230
|
+
"""Calculates the number of tokens for a raw string using tiktoken directly."""
|
|
231
|
+
if not text:
|
|
232
|
+
return 0
|
|
233
|
+
resolved_model = self._resolve_model(model)
|
|
234
|
+
encoding = self._get_encoding(resolved_model)
|
|
235
|
+
return len(encoding.encode(text, disallowed_special=()))
|
|
236
|
+
|
|
237
|
+
def get_completion_cost(
|
|
238
|
+
self, completion_response: Any, model_override: Optional[str] = None
|
|
239
|
+
) -> float:
|
|
240
|
+
"""Calculates the precise USD cost of a completion response."""
|
|
241
|
+
litellm = self._get_litellm()
|
|
242
|
+
try:
|
|
243
|
+
return float(
|
|
244
|
+
litellm.completion_cost(completion_response=completion_response)
|
|
245
|
+
)
|
|
246
|
+
except (Exception, TypeError) as e:
|
|
247
|
+
if "This model isn't mapped yet" in str(e) and self._hydrator:
|
|
248
|
+
candidates = set()
|
|
249
|
+
if model_override:
|
|
250
|
+
candidates.add(str(model_override))
|
|
251
|
+
model_id = getattr(completion_response, "model", None)
|
|
252
|
+
if model_id:
|
|
253
|
+
candidates.add(str(model_id))
|
|
254
|
+
|
|
255
|
+
if self._hydrate_all_candidates(candidates):
|
|
256
|
+
try:
|
|
257
|
+
return float(
|
|
258
|
+
litellm.completion_cost(
|
|
259
|
+
completion_response=completion_response
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
except (Exception, TypeError):
|
|
263
|
+
return 0.0
|
|
264
|
+
|
|
265
|
+
# Graceful fallback for unmapped models or hydration failure
|
|
266
|
+
return 0.0
|
|
267
|
+
|
|
268
|
+
def validate_config(self, include_remote: bool = False) -> List[str]:
|
|
269
|
+
"""
|
|
270
|
+
Validates the LLM configuration for common errors.
|
|
271
|
+
- Checks for the default 'your-api-key' placeholder.
|
|
272
|
+
- Checks for missing provider-specific environment variables.
|
|
273
|
+
- Optionally performs a lightweight remote connectivity check.
|
|
274
|
+
"""
|
|
275
|
+
# 1. Ultra-Lazy Short-circuit: Basic configuration checks (No litellm import)
|
|
276
|
+
api_key = self._config_service.get_setting("llm.api_key")
|
|
277
|
+
is_placeholder = isinstance(api_key, str) and api_key == ""
|
|
278
|
+
|
|
279
|
+
if is_placeholder:
|
|
280
|
+
return ["'llm.api_key' is empty."]
|
|
281
|
+
|
|
282
|
+
model = self._config_service.get_setting("llm.model")
|
|
283
|
+
if not model:
|
|
284
|
+
return ["'llm.model' is not configured."]
|
|
285
|
+
|
|
286
|
+
# 2. Secondary Check: Environment/Provider requirements (Requires litellm)
|
|
287
|
+
litellm = self._get_litellm()
|
|
288
|
+
errors = []
|
|
289
|
+
validation_result = litellm.validate_environment(model=model)
|
|
290
|
+
missing_keys = validation_result.get("missing_keys", [])
|
|
291
|
+
|
|
292
|
+
# If a valid api_key is provided in config, we ignore missing *_API_KEY env vars
|
|
293
|
+
is_api_key_provided = api_key and not is_placeholder
|
|
294
|
+
|
|
295
|
+
for key in missing_keys:
|
|
296
|
+
if is_api_key_provided and "_API_KEY" in key:
|
|
297
|
+
continue
|
|
298
|
+
errors.append(f"Missing required environment variable or config: {key}")
|
|
299
|
+
|
|
300
|
+
# 3. Optional Remote Check: Verify key validity/expiration
|
|
301
|
+
if not errors and include_remote:
|
|
302
|
+
from concurrent.futures import TimeoutError
|
|
303
|
+
|
|
304
|
+
executor = self._get_executor()
|
|
305
|
+
future = executor.submit(
|
|
306
|
+
litellm.check_valid_key, model=model, api_key=api_key
|
|
307
|
+
)
|
|
308
|
+
try:
|
|
309
|
+
is_valid = future.result(timeout=10.0)
|
|
310
|
+
if not is_valid:
|
|
311
|
+
errors.append(
|
|
312
|
+
"The API key appears to be invalid, expired, or deactivated."
|
|
313
|
+
)
|
|
314
|
+
except TimeoutError:
|
|
315
|
+
errors.append(
|
|
316
|
+
"The remote connectivity check timed out after 10 seconds."
|
|
317
|
+
)
|
|
318
|
+
except Exception as e:
|
|
319
|
+
errors.append(f"Remote connectivity check failed: {str(e)}")
|
|
320
|
+
|
|
321
|
+
return errors
|
|
322
|
+
|
|
323
|
+
def get_context_window(self, model: Optional[str] = None) -> int:
|
|
324
|
+
"""
|
|
325
|
+
Returns the maximum context window size (tokens) for the specified model.
|
|
326
|
+
"""
|
|
327
|
+
litellm = self._get_litellm()
|
|
328
|
+
|
|
329
|
+
resolved_model = model or self._config_service.get_setting("llm.model")
|
|
330
|
+
if not resolved_model:
|
|
331
|
+
return 0
|
|
332
|
+
|
|
333
|
+
# Pre-emptive Hydration: If the model is unknown and we have a hydrator, try to fetch it now.
|
|
334
|
+
# This ensures that Turn 1 telemetry can display correct info even before the first AI call.
|
|
335
|
+
if str(resolved_model) not in litellm.model_cost and self._hydrator:
|
|
336
|
+
candidates = {str(resolved_model)}
|
|
337
|
+
self._hydrate_all_candidates(candidates)
|
|
338
|
+
|
|
339
|
+
model_info = litellm.model_cost.get(str(resolved_model), {})
|
|
340
|
+
|
|
341
|
+
# Heuristic: max_input_tokens is specific to the context window.
|
|
342
|
+
# max_tokens often refers to the output limit but is used as a fallback in litellm metadata.
|
|
343
|
+
return int(
|
|
344
|
+
model_info.get("max_input_tokens") or model_info.get("max_tokens") or 0
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
def validate_model(self, model: Optional[str]) -> bool:
|
|
348
|
+
"""Check if a model exists in LiteLLM's registry.
|
|
349
|
+
|
|
350
|
+
Returns True if the model is known or if no override is given
|
|
351
|
+
(the config model will be used instead of an override).
|
|
352
|
+
"""
|
|
353
|
+
if model is None:
|
|
354
|
+
return True # No override; config model will be used
|
|
355
|
+
if not model:
|
|
356
|
+
return False
|
|
357
|
+
litellm = self._get_litellm()
|
|
358
|
+
clean = model.removeprefix("openrouter/")
|
|
359
|
+
if clean in litellm.model_cost:
|
|
360
|
+
return True
|
|
361
|
+
if model in litellm.model_cost:
|
|
362
|
+
return True
|
|
363
|
+
return False
|
|
364
|
+
|
|
365
|
+
def supports_pricing(self, model: Optional[str] = None) -> bool:
|
|
366
|
+
"""
|
|
367
|
+
Returns True if the model has known pricing metadata in the registry.
|
|
368
|
+
"""
|
|
369
|
+
litellm = self._get_litellm()
|
|
370
|
+
resolved_model = model or self._config_service.get_setting("llm.model")
|
|
371
|
+
if not resolved_model:
|
|
372
|
+
return False
|
|
373
|
+
|
|
374
|
+
model_info = litellm.model_cost.get(str(resolved_model), {})
|
|
375
|
+
# input_cost_per_token is the primary indicator of pricing metadata
|
|
376
|
+
return "input_cost_per_token" in model_info
|
|
377
|
+
|
|
378
|
+
def _handle_hydration_retry(
|
|
379
|
+
self, error: Exception, messages: List[Dict[str, str]], params: Dict[str, Any]
|
|
380
|
+
) -> Optional[Any]:
|
|
381
|
+
"""Internal helper to detect NotFoundError and retry once with hydrated metadata."""
|
|
382
|
+
litellm = self._get_litellm()
|
|
383
|
+
|
|
384
|
+
if not (self._is_not_found_error(error) and self._hydrator):
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
# 1. Identify all candidate model IDs for hydration
|
|
388
|
+
candidates = self._identify_hydration_candidates(error, params)
|
|
389
|
+
if not candidates:
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
# 2. Hydrate all candidates using the first successful metadata found
|
|
393
|
+
if not self._hydrate_all_candidates(candidates):
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
# 3. Retry once
|
|
397
|
+
return litellm.completion(messages=messages, **params)
|
|
398
|
+
|
|
399
|
+
def _hydrate_all_candidates(self, candidates: set[str]) -> bool:
|
|
400
|
+
"""
|
|
401
|
+
Internal helper to fetch metadata for any candidate and broadcast it to all.
|
|
402
|
+
Returns True if any metadata was found and injected.
|
|
403
|
+
"""
|
|
404
|
+
if not self._hydrator:
|
|
405
|
+
return False
|
|
406
|
+
|
|
407
|
+
litellm = self._get_litellm()
|
|
408
|
+
shared_metadata = None
|
|
409
|
+
for m_id in candidates:
|
|
410
|
+
shared_metadata = self._hydrator.get_metadata(m_id)
|
|
411
|
+
if shared_metadata:
|
|
412
|
+
break
|
|
413
|
+
|
|
414
|
+
if not shared_metadata:
|
|
415
|
+
return False
|
|
416
|
+
|
|
417
|
+
for m_id in candidates:
|
|
418
|
+
# Update LiteLLM's internal registry for all candidate IDs
|
|
419
|
+
# Ensure pricing values are floats to prevent `float + str` crashes
|
|
420
|
+
pricing = shared_metadata.get("pricing", {})
|
|
421
|
+
safe_pricing = {}
|
|
422
|
+
for key, val in pricing.items():
|
|
423
|
+
try:
|
|
424
|
+
safe_pricing[key] = float(val)
|
|
425
|
+
except (ValueError, TypeError):
|
|
426
|
+
safe_pricing[key] = 0.0
|
|
427
|
+
litellm.model_cost[m_id] = {
|
|
428
|
+
"max_input_tokens": shared_metadata.get("context_window", 0),
|
|
429
|
+
**safe_pricing,
|
|
430
|
+
}
|
|
431
|
+
return True
|
|
432
|
+
|
|
433
|
+
def _is_not_found_error(self, error: Exception) -> bool:
|
|
434
|
+
"""Robustly checks if the error is a LiteLLM NotFoundError."""
|
|
435
|
+
litellm = self._get_litellm()
|
|
436
|
+
|
|
437
|
+
if type(error).__name__ == "NotFoundError":
|
|
438
|
+
return True
|
|
439
|
+
|
|
440
|
+
if hasattr(litellm, "NotFoundError"):
|
|
441
|
+
not_found_cls = getattr(litellm, "NotFoundError")
|
|
442
|
+
return isinstance(not_found_cls, type) and isinstance(error, not_found_cls)
|
|
443
|
+
|
|
444
|
+
return False
|
|
445
|
+
|
|
446
|
+
def _identify_hydration_candidates(
|
|
447
|
+
self, error: Exception, params: Dict[str, Any]
|
|
448
|
+
) -> set[str]:
|
|
449
|
+
"""Extracts requested and resolved model IDs from params and error message."""
|
|
450
|
+
import re
|
|
451
|
+
|
|
452
|
+
candidates = set()
|
|
453
|
+
requested_model = params.get("model")
|
|
454
|
+
if requested_model:
|
|
455
|
+
candidates.add(str(requested_model))
|
|
456
|
+
|
|
457
|
+
# Parse actual model from error message (e.g. "model=deepseek/deepseek-v4...")
|
|
458
|
+
match = re.search(r"model=([^,\s]+)", str(error))
|
|
459
|
+
if match:
|
|
460
|
+
candidates.add(match.group(1))
|
|
461
|
+
|
|
462
|
+
return candidates
|