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.
Files changed (143) hide show
  1. teddy_cli-0.1.0.dist-info/LICENSE +677 -0
  2. teddy_cli-0.1.0.dist-info/METADATA +33 -0
  3. teddy_cli-0.1.0.dist-info/RECORD +143 -0
  4. teddy_cli-0.1.0.dist-info/WHEEL +4 -0
  5. teddy_cli-0.1.0.dist-info/entry_points.txt +3 -0
  6. teddy_executor/__init__.py +1 -0
  7. teddy_executor/__main__.py +335 -0
  8. teddy_executor/adapters/__init__.py +0 -0
  9. teddy_executor/adapters/inbound/__init__.py +0 -0
  10. teddy_executor/adapters/inbound/cli_formatter.py +107 -0
  11. teddy_executor/adapters/inbound/cli_helpers.py +249 -0
  12. teddy_executor/adapters/inbound/console_plan_reviewer.py +69 -0
  13. teddy_executor/adapters/inbound/session_cli_handlers.py +366 -0
  14. teddy_executor/adapters/inbound/textual_plan_reviewer.py +78 -0
  15. teddy_executor/adapters/inbound/textual_plan_reviewer_app.py +367 -0
  16. teddy_executor/adapters/inbound/textual_plan_reviewer_editor.py +281 -0
  17. teddy_executor/adapters/inbound/textual_plan_reviewer_execution.py +213 -0
  18. teddy_executor/adapters/inbound/textual_plan_reviewer_helpers.py +308 -0
  19. teddy_executor/adapters/inbound/textual_plan_reviewer_logic.py +345 -0
  20. teddy_executor/adapters/inbound/textual_plan_reviewer_previews.py +227 -0
  21. teddy_executor/adapters/inbound/textual_plan_reviewer_widgets.py +246 -0
  22. teddy_executor/adapters/outbound/__init__.py +7 -0
  23. teddy_executor/adapters/outbound/console_interactor.py +212 -0
  24. teddy_executor/adapters/outbound/console_interactor_ask_loop.py +121 -0
  25. teddy_executor/adapters/outbound/console_interactor_helpers.py +95 -0
  26. teddy_executor/adapters/outbound/console_tooling.py +62 -0
  27. teddy_executor/adapters/outbound/filesystem_helpers.py +61 -0
  28. teddy_executor/adapters/outbound/litellm_adapter.py +462 -0
  29. teddy_executor/adapters/outbound/local_file_system_adapter.py +300 -0
  30. teddy_executor/adapters/outbound/local_repo_tree_generator.py +96 -0
  31. teddy_executor/adapters/outbound/openrouter_hydrator.py +89 -0
  32. teddy_executor/adapters/outbound/shell_adapter.py +344 -0
  33. teddy_executor/adapters/outbound/shell_command_builder.py +105 -0
  34. teddy_executor/adapters/outbound/system_environment_adapter.py +62 -0
  35. teddy_executor/adapters/outbound/system_environment_inspector.py +54 -0
  36. teddy_executor/adapters/outbound/system_time_adapter.py +22 -0
  37. teddy_executor/adapters/outbound/web_scraper_adapter.py +346 -0
  38. teddy_executor/adapters/outbound/web_searcher_adapter.py +122 -0
  39. teddy_executor/adapters/outbound/yaml_config_adapter.py +105 -0
  40. teddy_executor/container.py +333 -0
  41. teddy_executor/core/__init__.py +0 -0
  42. teddy_executor/core/domain/__init__.py +0 -0
  43. teddy_executor/core/domain/models/__init__.py +44 -0
  44. teddy_executor/core/domain/models/action_ports.py +28 -0
  45. teddy_executor/core/domain/models/change_set.py +10 -0
  46. teddy_executor/core/domain/models/exceptions.py +40 -0
  47. teddy_executor/core/domain/models/execution_report.py +65 -0
  48. teddy_executor/core/domain/models/orchestrator_ports.py +26 -0
  49. teddy_executor/core/domain/models/plan.py +85 -0
  50. teddy_executor/core/domain/models/planning_ports.py +43 -0
  51. teddy_executor/core/domain/models/project_context.py +56 -0
  52. teddy_executor/core/domain/models/report_assembly_data.py +18 -0
  53. teddy_executor/core/domain/models/session.py +17 -0
  54. teddy_executor/core/domain/models/shell_output.py +12 -0
  55. teddy_executor/core/domain/models/web_search_results.py +26 -0
  56. teddy_executor/core/ports/__init__.py +0 -0
  57. teddy_executor/core/ports/inbound/__init__.py +0 -0
  58. teddy_executor/core/ports/inbound/edit_simulator.py +33 -0
  59. teddy_executor/core/ports/inbound/get_context_use_case.py +32 -0
  60. teddy_executor/core/ports/inbound/init.py +15 -0
  61. teddy_executor/core/ports/inbound/plan_parser.py +52 -0
  62. teddy_executor/core/ports/inbound/plan_reviewer.py +44 -0
  63. teddy_executor/core/ports/inbound/plan_validator.py +26 -0
  64. teddy_executor/core/ports/inbound/planning_use_case.py +30 -0
  65. teddy_executor/core/ports/inbound/run_plan_use_case.py +60 -0
  66. teddy_executor/core/ports/outbound/__init__.py +34 -0
  67. teddy_executor/core/ports/outbound/config_service.py +29 -0
  68. teddy_executor/core/ports/outbound/environment_inspector.py +30 -0
  69. teddy_executor/core/ports/outbound/execution_report_assembler.py +19 -0
  70. teddy_executor/core/ports/outbound/file_system_manager.py +131 -0
  71. teddy_executor/core/ports/outbound/llm_client.py +90 -0
  72. teddy_executor/core/ports/outbound/markdown_report_formatter.py +26 -0
  73. teddy_executor/core/ports/outbound/prompt_manager.py +55 -0
  74. teddy_executor/core/ports/outbound/repo_tree_generator.py +17 -0
  75. teddy_executor/core/ports/outbound/session_loop_guard.py +16 -0
  76. teddy_executor/core/ports/outbound/session_manager.py +97 -0
  77. teddy_executor/core/ports/outbound/session_repository.py +65 -0
  78. teddy_executor/core/ports/outbound/shell_executor.py +24 -0
  79. teddy_executor/core/ports/outbound/system_environment.py +25 -0
  80. teddy_executor/core/ports/outbound/time_service.py +28 -0
  81. teddy_executor/core/ports/outbound/user_interactor.py +126 -0
  82. teddy_executor/core/ports/outbound/web_scraper.py +24 -0
  83. teddy_executor/core/ports/outbound/web_searcher.py +25 -0
  84. teddy_executor/core/services/__init__.py +0 -0
  85. teddy_executor/core/services/action_changeset_builder.py +90 -0
  86. teddy_executor/core/services/action_diff_manager.py +110 -0
  87. teddy_executor/core/services/action_dispatcher.py +142 -0
  88. teddy_executor/core/services/action_executor.py +209 -0
  89. teddy_executor/core/services/action_factory.py +197 -0
  90. teddy_executor/core/services/action_parser_complex.py +216 -0
  91. teddy_executor/core/services/action_parser_strategies.py +84 -0
  92. teddy_executor/core/services/context_service.py +437 -0
  93. teddy_executor/core/services/edit_simulator.py +128 -0
  94. teddy_executor/core/services/execution_orchestrator.py +295 -0
  95. teddy_executor/core/services/execution_report_assembler.py +62 -0
  96. teddy_executor/core/services/init_service.py +80 -0
  97. teddy_executor/core/services/markdown_plan_parser.py +309 -0
  98. teddy_executor/core/services/markdown_report_formatter.py +143 -0
  99. teddy_executor/core/services/parser_infrastructure.py +222 -0
  100. teddy_executor/core/services/parser_metadata.py +153 -0
  101. teddy_executor/core/services/parser_reporting.py +267 -0
  102. teddy_executor/core/services/plan_validator.py +82 -0
  103. teddy_executor/core/services/planning_service.py +242 -0
  104. teddy_executor/core/services/prompt_manager.py +146 -0
  105. teddy_executor/core/services/session_lifecycle_manager.py +228 -0
  106. teddy_executor/core/services/session_loop_guard.py +46 -0
  107. teddy_executor/core/services/session_orchestrator.py +538 -0
  108. teddy_executor/core/services/session_planner.py +43 -0
  109. teddy_executor/core/services/session_pruning_service.py +438 -0
  110. teddy_executor/core/services/session_replanner.py +105 -0
  111. teddy_executor/core/services/session_repository.py +194 -0
  112. teddy_executor/core/services/session_service.py +529 -0
  113. teddy_executor/core/services/templates/execution_report.md.j2 +290 -0
  114. teddy_executor/core/services/validation_rules/__init__.py +4 -0
  115. teddy_executor/core/services/validation_rules/edit.py +207 -0
  116. teddy_executor/core/services/validation_rules/edit_matcher.py +247 -0
  117. teddy_executor/core/services/validation_rules/edit_matcher_heuristics.py +84 -0
  118. teddy_executor/core/services/validation_rules/execute.py +37 -0
  119. teddy_executor/core/services/validation_rules/filesystem.py +73 -0
  120. teddy_executor/core/services/validation_rules/helpers.py +178 -0
  121. teddy_executor/core/services/validation_rules/message.py +29 -0
  122. teddy_executor/core/utils/__init__.py +1 -0
  123. teddy_executor/core/utils/diff.py +57 -0
  124. teddy_executor/core/utils/io.py +75 -0
  125. teddy_executor/core/utils/markdown.py +131 -0
  126. teddy_executor/core/utils/serialization.py +39 -0
  127. teddy_executor/core/utils/string.py +351 -0
  128. teddy_executor/prompts.py +45 -0
  129. teddy_executor/registries/__init__.py +1 -0
  130. teddy_executor/registries/infrastructure.py +147 -0
  131. teddy_executor/registries/reviewer.py +57 -0
  132. teddy_executor/registries/validators.py +47 -0
  133. teddy_executor/resources/__init__.py +1 -0
  134. teddy_executor/resources/config/.gitignore +2 -0
  135. teddy_executor/resources/config/__init__.py +1 -0
  136. teddy_executor/resources/config/config.yaml +49 -0
  137. teddy_executor/resources/config/init.context +5 -0
  138. teddy_executor/resources/config/prompts/architect.xml +462 -0
  139. teddy_executor/resources/config/prompts/assistant.xml +336 -0
  140. teddy_executor/resources/config/prompts/debugger.xml +456 -0
  141. teddy_executor/resources/config/prompts/developer.xml +481 -0
  142. teddy_executor/resources/config/prompts/pathfinder.xml +502 -0
  143. 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