pdd-cli 0.0.90__py3-none-any.whl → 0.0.118__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.
- pdd/__init__.py +38 -6
- pdd/agentic_bug.py +323 -0
- pdd/agentic_bug_orchestrator.py +497 -0
- pdd/agentic_change.py +231 -0
- pdd/agentic_change_orchestrator.py +526 -0
- pdd/agentic_common.py +521 -786
- pdd/agentic_e2e_fix.py +319 -0
- pdd/agentic_e2e_fix_orchestrator.py +426 -0
- pdd/agentic_fix.py +118 -3
- pdd/agentic_update.py +25 -8
- pdd/architecture_sync.py +565 -0
- pdd/auth_service.py +210 -0
- pdd/auto_deps_main.py +63 -53
- pdd/auto_include.py +185 -3
- pdd/auto_update.py +125 -47
- pdd/bug_main.py +195 -23
- pdd/cmd_test_main.py +345 -197
- pdd/code_generator.py +4 -2
- pdd/code_generator_main.py +118 -32
- pdd/commands/__init__.py +6 -0
- pdd/commands/analysis.py +87 -29
- pdd/commands/auth.py +309 -0
- pdd/commands/connect.py +290 -0
- pdd/commands/fix.py +136 -113
- pdd/commands/maintenance.py +3 -2
- pdd/commands/misc.py +8 -0
- pdd/commands/modify.py +190 -164
- pdd/commands/sessions.py +284 -0
- pdd/construct_paths.py +334 -32
- pdd/context_generator_main.py +167 -170
- pdd/continue_generation.py +6 -3
- pdd/core/__init__.py +33 -0
- pdd/core/cli.py +27 -3
- pdd/core/cloud.py +237 -0
- pdd/core/errors.py +4 -0
- pdd/core/remote_session.py +61 -0
- pdd/crash_main.py +219 -23
- pdd/data/llm_model.csv +4 -4
- pdd/docs/prompting_guide.md +864 -0
- pdd/docs/whitepaper_with_benchmarks/data_and_functions/benchmark_analysis.py +495 -0
- pdd/docs/whitepaper_with_benchmarks/data_and_functions/creation_compare.py +528 -0
- pdd/fix_code_loop.py +208 -34
- pdd/fix_code_module_errors.py +6 -2
- pdd/fix_error_loop.py +291 -38
- pdd/fix_main.py +204 -4
- pdd/fix_verification_errors_loop.py +235 -26
- pdd/fix_verification_main.py +269 -83
- pdd/frontend/dist/assets/index-B5DZHykP.css +1 -0
- pdd/frontend/dist/assets/index-DQ3wkeQ2.js +449 -0
- pdd/frontend/dist/index.html +376 -0
- pdd/frontend/dist/logo.svg +33 -0
- pdd/generate_output_paths.py +46 -5
- pdd/generate_test.py +212 -151
- pdd/get_comment.py +19 -44
- pdd/get_extension.py +8 -9
- pdd/get_jwt_token.py +309 -20
- pdd/get_language.py +8 -7
- pdd/get_run_command.py +7 -5
- pdd/insert_includes.py +2 -1
- pdd/llm_invoke.py +459 -95
- pdd/load_prompt_template.py +15 -34
- pdd/path_resolution.py +140 -0
- pdd/postprocess.py +4 -1
- pdd/preprocess.py +68 -12
- pdd/preprocess_main.py +33 -1
- pdd/prompts/agentic_bug_step10_pr_LLM.prompt +182 -0
- pdd/prompts/agentic_bug_step1_duplicate_LLM.prompt +73 -0
- pdd/prompts/agentic_bug_step2_docs_LLM.prompt +129 -0
- pdd/prompts/agentic_bug_step3_triage_LLM.prompt +95 -0
- pdd/prompts/agentic_bug_step4_reproduce_LLM.prompt +97 -0
- pdd/prompts/agentic_bug_step5_root_cause_LLM.prompt +123 -0
- pdd/prompts/agentic_bug_step6_test_plan_LLM.prompt +107 -0
- pdd/prompts/agentic_bug_step7_generate_LLM.prompt +172 -0
- pdd/prompts/agentic_bug_step8_verify_LLM.prompt +119 -0
- pdd/prompts/agentic_bug_step9_e2e_test_LLM.prompt +289 -0
- pdd/prompts/agentic_change_step10_identify_issues_LLM.prompt +1006 -0
- pdd/prompts/agentic_change_step11_fix_issues_LLM.prompt +984 -0
- pdd/prompts/agentic_change_step12_create_pr_LLM.prompt +131 -0
- pdd/prompts/agentic_change_step1_duplicate_LLM.prompt +73 -0
- pdd/prompts/agentic_change_step2_docs_LLM.prompt +101 -0
- pdd/prompts/agentic_change_step3_research_LLM.prompt +126 -0
- pdd/prompts/agentic_change_step4_clarify_LLM.prompt +164 -0
- pdd/prompts/agentic_change_step5_docs_change_LLM.prompt +981 -0
- pdd/prompts/agentic_change_step6_devunits_LLM.prompt +1005 -0
- pdd/prompts/agentic_change_step7_architecture_LLM.prompt +1044 -0
- pdd/prompts/agentic_change_step8_analyze_LLM.prompt +1027 -0
- pdd/prompts/agentic_change_step9_implement_LLM.prompt +1077 -0
- pdd/prompts/agentic_e2e_fix_step1_unit_tests_LLM.prompt +90 -0
- pdd/prompts/agentic_e2e_fix_step2_e2e_tests_LLM.prompt +91 -0
- pdd/prompts/agentic_e2e_fix_step3_root_cause_LLM.prompt +89 -0
- pdd/prompts/agentic_e2e_fix_step4_fix_e2e_tests_LLM.prompt +96 -0
- pdd/prompts/agentic_e2e_fix_step5_identify_devunits_LLM.prompt +91 -0
- pdd/prompts/agentic_e2e_fix_step6_create_unit_tests_LLM.prompt +106 -0
- pdd/prompts/agentic_e2e_fix_step7_verify_tests_LLM.prompt +116 -0
- pdd/prompts/agentic_e2e_fix_step8_run_pdd_fix_LLM.prompt +120 -0
- pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt +146 -0
- pdd/prompts/agentic_fix_primary_LLM.prompt +2 -2
- pdd/prompts/agentic_update_LLM.prompt +192 -338
- pdd/prompts/auto_include_LLM.prompt +22 -0
- pdd/prompts/change_LLM.prompt +3093 -1
- pdd/prompts/detect_change_LLM.prompt +571 -14
- pdd/prompts/fix_code_module_errors_LLM.prompt +8 -0
- pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +1 -0
- pdd/prompts/generate_test_LLM.prompt +20 -1
- pdd/prompts/generate_test_from_example_LLM.prompt +115 -0
- pdd/prompts/insert_includes_LLM.prompt +262 -252
- pdd/prompts/prompt_code_diff_LLM.prompt +119 -0
- pdd/prompts/prompt_diff_LLM.prompt +82 -0
- pdd/remote_session.py +876 -0
- pdd/server/__init__.py +52 -0
- pdd/server/app.py +335 -0
- pdd/server/click_executor.py +587 -0
- pdd/server/executor.py +338 -0
- pdd/server/jobs.py +661 -0
- pdd/server/models.py +241 -0
- pdd/server/routes/__init__.py +31 -0
- pdd/server/routes/architecture.py +451 -0
- pdd/server/routes/auth.py +364 -0
- pdd/server/routes/commands.py +929 -0
- pdd/server/routes/config.py +42 -0
- pdd/server/routes/files.py +603 -0
- pdd/server/routes/prompts.py +1322 -0
- pdd/server/routes/websocket.py +473 -0
- pdd/server/security.py +243 -0
- pdd/server/terminal_spawner.py +209 -0
- pdd/server/token_counter.py +222 -0
- pdd/summarize_directory.py +236 -237
- pdd/sync_animation.py +8 -4
- pdd/sync_determine_operation.py +329 -47
- pdd/sync_main.py +272 -28
- pdd/sync_orchestration.py +136 -75
- pdd/template_expander.py +161 -0
- pdd/templates/architecture/architecture_json.prompt +41 -46
- pdd/trace.py +1 -1
- pdd/track_cost.py +0 -13
- pdd/unfinished_prompt.py +2 -1
- pdd/update_main.py +23 -5
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/METADATA +15 -10
- pdd_cli-0.0.118.dist-info/RECORD +227 -0
- pdd_cli-0.0.90.dist-info/RECORD +0 -153
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/WHEEL +0 -0
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/entry_points.txt +0 -0
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/licenses/LICENSE +0 -0
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/top_level.txt +0 -0
pdd/llm_invoke.py
CHANGED
|
@@ -84,11 +84,11 @@ from pathlib import Path
|
|
|
84
84
|
from typing import Optional, Dict, List, Any, Type, Union, Tuple
|
|
85
85
|
from pydantic import BaseModel, ValidationError
|
|
86
86
|
import openai # Import openai for exception handling as LiteLLM maps to its types
|
|
87
|
-
from langchain_core.prompts import PromptTemplate
|
|
88
87
|
import warnings
|
|
89
88
|
import time as time_module # Alias to avoid conflict with 'time' parameter
|
|
90
89
|
# Import the default model constant
|
|
91
90
|
from pdd import DEFAULT_LLM_MODEL
|
|
91
|
+
from pdd.path_resolution import get_default_resolver
|
|
92
92
|
|
|
93
93
|
# Opt-in to future pandas behavior regarding downcasting
|
|
94
94
|
try:
|
|
@@ -98,6 +98,242 @@ except pd._config.config.OptionError:
|
|
|
98
98
|
pass
|
|
99
99
|
|
|
100
100
|
|
|
101
|
+
# --- Custom Exceptions ---
|
|
102
|
+
|
|
103
|
+
class SchemaValidationError(Exception):
|
|
104
|
+
"""Raised when LLM response fails Pydantic/JSON schema validation.
|
|
105
|
+
|
|
106
|
+
This exception triggers model fallback when caught at the outer exception
|
|
107
|
+
handler level, allowing the next candidate model to be tried.
|
|
108
|
+
|
|
109
|
+
Issue #168: Previously, validation errors only logged an error and continued
|
|
110
|
+
to the next batch item, never triggering model fallback.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(self, message: str, raw_response: Any = None, item_index: int = 0):
|
|
114
|
+
super().__init__(message)
|
|
115
|
+
self.raw_response = raw_response
|
|
116
|
+
self.item_index = item_index
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class CloudFallbackError(Exception):
|
|
120
|
+
"""Raised when cloud execution fails and should fall back to local.
|
|
121
|
+
|
|
122
|
+
This exception is caught internally and triggers fallback to local execution
|
|
123
|
+
when cloud is unavailable (network errors, timeouts, auth failures).
|
|
124
|
+
"""
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class CloudInvocationError(Exception):
|
|
129
|
+
"""Raised when cloud invocation fails with a non-recoverable error.
|
|
130
|
+
|
|
131
|
+
This exception indicates a cloud error that should not fall back to local,
|
|
132
|
+
such as validation errors returned by the cloud endpoint.
|
|
133
|
+
"""
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class InsufficientCreditsError(Exception):
|
|
138
|
+
"""Raised when user has insufficient credits for cloud execution.
|
|
139
|
+
|
|
140
|
+
This exception is raised when the cloud returns 402 (Payment Required)
|
|
141
|
+
and should NOT fall back to local execution - the user needs to know.
|
|
142
|
+
"""
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# --- Cloud Execution Helpers ---
|
|
147
|
+
|
|
148
|
+
def _ensure_all_properties_required(schema: Dict[str, Any]) -> Dict[str, Any]:
|
|
149
|
+
"""Ensure ALL properties are in the required array (OpenAI strict mode requirement).
|
|
150
|
+
|
|
151
|
+
OpenAI's strict mode requires that all properties in a JSON schema are listed
|
|
152
|
+
in the 'required' array. Pydantic's model_json_schema() only includes fields
|
|
153
|
+
without default values in 'required', which causes OpenAI to reject the schema.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
schema: A JSON schema dictionary
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
The schema with all properties added to 'required'
|
|
160
|
+
"""
|
|
161
|
+
if 'properties' in schema:
|
|
162
|
+
schema['required'] = list(schema['properties'].keys())
|
|
163
|
+
return schema
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _pydantic_to_json_schema(pydantic_class: Type[BaseModel]) -> Dict[str, Any]:
|
|
167
|
+
"""Convert a Pydantic model class to JSON Schema for cloud transport.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
pydantic_class: A Pydantic BaseModel subclass
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
JSON Schema dictionary that can be serialized and sent to cloud
|
|
174
|
+
"""
|
|
175
|
+
schema = pydantic_class.model_json_schema()
|
|
176
|
+
# Ensure all properties are in required array (OpenAI strict mode requirement)
|
|
177
|
+
_ensure_all_properties_required(schema)
|
|
178
|
+
# Include class name for debugging/logging purposes
|
|
179
|
+
schema['__pydantic_class_name__'] = pydantic_class.__name__
|
|
180
|
+
return schema
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _validate_with_pydantic(
|
|
184
|
+
result: Any,
|
|
185
|
+
pydantic_class: Type[BaseModel]
|
|
186
|
+
) -> BaseModel:
|
|
187
|
+
"""Validate cloud response using original Pydantic class.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
result: The result from cloud (dict or JSON string)
|
|
191
|
+
pydantic_class: The Pydantic model to validate against
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Validated Pydantic model instance
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
ValidationError: If validation fails
|
|
198
|
+
"""
|
|
199
|
+
if isinstance(result, dict):
|
|
200
|
+
return pydantic_class.model_validate(result)
|
|
201
|
+
elif isinstance(result, str):
|
|
202
|
+
return pydantic_class.model_validate_json(result)
|
|
203
|
+
elif isinstance(result, pydantic_class):
|
|
204
|
+
# Already validated
|
|
205
|
+
return result
|
|
206
|
+
raise ValueError(f"Cannot validate result type {type(result)} with Pydantic model")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _llm_invoke_cloud(
|
|
210
|
+
prompt: Optional[str],
|
|
211
|
+
input_json: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]],
|
|
212
|
+
strength: float,
|
|
213
|
+
temperature: float,
|
|
214
|
+
verbose: bool,
|
|
215
|
+
output_pydantic: Optional[Type[BaseModel]],
|
|
216
|
+
output_schema: Optional[Dict[str, Any]],
|
|
217
|
+
time: float,
|
|
218
|
+
use_batch_mode: bool,
|
|
219
|
+
messages: Optional[Union[List[Dict[str, str]], List[List[Dict[str, str]]]]],
|
|
220
|
+
language: Optional[str],
|
|
221
|
+
) -> Dict[str, Any]:
|
|
222
|
+
"""Execute llm_invoke via cloud endpoint.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
All parameters match llm_invoke signature
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Dictionary with 'result', 'cost', 'model_name', 'thinking_output'
|
|
229
|
+
|
|
230
|
+
Raises:
|
|
231
|
+
CloudFallbackError: For recoverable errors (network, timeout, auth)
|
|
232
|
+
InsufficientCreditsError: For 402 Payment Required
|
|
233
|
+
CloudInvocationError: For non-recoverable cloud errors
|
|
234
|
+
"""
|
|
235
|
+
import requests
|
|
236
|
+
from rich.console import Console
|
|
237
|
+
|
|
238
|
+
# Lazy import to avoid circular dependency
|
|
239
|
+
from pdd.core.cloud import CloudConfig
|
|
240
|
+
|
|
241
|
+
console = Console()
|
|
242
|
+
CLOUD_TIMEOUT = 300 # 5 minutes
|
|
243
|
+
|
|
244
|
+
# Get JWT token
|
|
245
|
+
jwt_token = CloudConfig.get_jwt_token(verbose=verbose)
|
|
246
|
+
if not jwt_token:
|
|
247
|
+
raise CloudFallbackError("Could not authenticate with cloud")
|
|
248
|
+
|
|
249
|
+
# Prepare payload
|
|
250
|
+
payload: Dict[str, Any] = {
|
|
251
|
+
"strength": strength,
|
|
252
|
+
"temperature": temperature,
|
|
253
|
+
"time": time,
|
|
254
|
+
"verbose": verbose,
|
|
255
|
+
"useBatchMode": use_batch_mode,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if language:
|
|
259
|
+
payload["language"] = language
|
|
260
|
+
|
|
261
|
+
# Add prompt/messages
|
|
262
|
+
if messages:
|
|
263
|
+
payload["messages"] = messages
|
|
264
|
+
else:
|
|
265
|
+
payload["prompt"] = prompt
|
|
266
|
+
payload["inputJson"] = input_json
|
|
267
|
+
|
|
268
|
+
# Handle output schema
|
|
269
|
+
if output_pydantic:
|
|
270
|
+
payload["outputSchema"] = _pydantic_to_json_schema(output_pydantic)
|
|
271
|
+
elif output_schema:
|
|
272
|
+
payload["outputSchema"] = output_schema
|
|
273
|
+
|
|
274
|
+
# Make request
|
|
275
|
+
headers = {
|
|
276
|
+
"Authorization": f"Bearer {jwt_token}",
|
|
277
|
+
"Content-Type": "application/json"
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
cloud_url = CloudConfig.get_endpoint_url("llmInvoke")
|
|
281
|
+
|
|
282
|
+
if verbose:
|
|
283
|
+
logger.debug(f"Cloud llm_invoke request to: {cloud_url}")
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
response = requests.post(
|
|
287
|
+
cloud_url,
|
|
288
|
+
json=payload,
|
|
289
|
+
headers=headers,
|
|
290
|
+
timeout=CLOUD_TIMEOUT
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
if response.status_code == 200:
|
|
294
|
+
data = response.json()
|
|
295
|
+
result = data.get("result")
|
|
296
|
+
|
|
297
|
+
# Validate with Pydantic if specified
|
|
298
|
+
if output_pydantic and result:
|
|
299
|
+
try:
|
|
300
|
+
result = _validate_with_pydantic(result, output_pydantic)
|
|
301
|
+
except (ValidationError, ValueError) as e:
|
|
302
|
+
logger.warning(f"Cloud response validation failed: {e}")
|
|
303
|
+
# Return raw result if validation fails
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
"result": result,
|
|
308
|
+
"cost": data.get("totalCost", 0.0),
|
|
309
|
+
"model_name": data.get("modelName", "cloud_model"),
|
|
310
|
+
"thinking_output": data.get("thinkingOutput"),
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
elif response.status_code == 402:
|
|
314
|
+
error_msg = response.json().get("error", "Insufficient credits")
|
|
315
|
+
raise InsufficientCreditsError(error_msg)
|
|
316
|
+
|
|
317
|
+
elif response.status_code in (401, 403):
|
|
318
|
+
error_msg = response.json().get("error", f"Authentication failed ({response.status_code})")
|
|
319
|
+
raise CloudFallbackError(error_msg)
|
|
320
|
+
|
|
321
|
+
elif response.status_code >= 500:
|
|
322
|
+
error_msg = response.json().get("error", f"Server error ({response.status_code})")
|
|
323
|
+
raise CloudFallbackError(error_msg)
|
|
324
|
+
|
|
325
|
+
else:
|
|
326
|
+
error_msg = response.json().get("error", f"HTTP {response.status_code}")
|
|
327
|
+
raise CloudInvocationError(f"Cloud llm_invoke failed: {error_msg}")
|
|
328
|
+
|
|
329
|
+
except requests.exceptions.Timeout:
|
|
330
|
+
raise CloudFallbackError("Cloud request timed out")
|
|
331
|
+
except requests.exceptions.ConnectionError as e:
|
|
332
|
+
raise CloudFallbackError(f"Cloud connection failed: {e}")
|
|
333
|
+
except requests.exceptions.RequestException as e:
|
|
334
|
+
raise CloudFallbackError(f"Cloud request failed: {e}")
|
|
335
|
+
|
|
336
|
+
|
|
101
337
|
def _is_wsl_environment() -> bool:
|
|
102
338
|
"""
|
|
103
339
|
Detect if we're running in WSL (Windows Subsystem for Linux) environment.
|
|
@@ -170,49 +406,26 @@ def _get_environment_info() -> Dict[str, str]:
|
|
|
170
406
|
|
|
171
407
|
# --- Constants and Configuration ---
|
|
172
408
|
|
|
173
|
-
# Determine project root:
|
|
174
|
-
PROJECT_ROOT = None
|
|
409
|
+
# Determine project root: use PathResolver to ignore package-root PDD_PATH values.
|
|
175
410
|
PDD_PATH_ENV = os.getenv("PDD_PATH")
|
|
176
|
-
|
|
177
411
|
if PDD_PATH_ENV:
|
|
178
|
-
_path_from_env = Path(PDD_PATH_ENV)
|
|
179
|
-
if _path_from_env.is_dir():
|
|
180
|
-
PROJECT_ROOT = _path_from_env.resolve()
|
|
181
|
-
logger.debug(f"Using PROJECT_ROOT from PDD_PATH: {PROJECT_ROOT}")
|
|
182
|
-
else:
|
|
183
|
-
warnings.warn(f"PDD_PATH environment variable ('{PDD_PATH_ENV}') is set but not a valid directory. Attempting auto-detection.")
|
|
184
|
-
|
|
185
|
-
if PROJECT_ROOT is None: # If PDD_PATH wasn't set or was invalid
|
|
186
412
|
try:
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
has_data = (current_dir / "data").is_dir()
|
|
195
|
-
has_dotenv = (current_dir / ".env").exists()
|
|
196
|
-
|
|
197
|
-
if has_git or has_pyproject or has_data or has_dotenv:
|
|
198
|
-
PROJECT_ROOT = current_dir
|
|
199
|
-
logger.debug(f"Determined PROJECT_ROOT by marker search from CWD: {PROJECT_ROOT}")
|
|
200
|
-
break
|
|
201
|
-
|
|
202
|
-
parent_dir = current_dir.parent
|
|
203
|
-
if parent_dir == current_dir: # Reached filesystem root
|
|
204
|
-
break
|
|
205
|
-
current_dir = parent_dir
|
|
413
|
+
_path_from_env = Path(PDD_PATH_ENV).expanduser().resolve()
|
|
414
|
+
if not _path_from_env.is_dir():
|
|
415
|
+
warnings.warn(
|
|
416
|
+
f"PDD_PATH environment variable ('{PDD_PATH_ENV}') is set but not a valid directory. Attempting auto-detection."
|
|
417
|
+
)
|
|
418
|
+
except Exception as e:
|
|
419
|
+
warnings.warn(f"Error validating PDD_PATH environment variable: {e}")
|
|
206
420
|
|
|
207
|
-
|
|
208
|
-
|
|
421
|
+
resolver = get_default_resolver()
|
|
422
|
+
PROJECT_ROOT = resolver.resolve_project_root()
|
|
423
|
+
PROJECT_ROOT_FROM_ENV = resolver.pdd_path_env is not None and PROJECT_ROOT == resolver.pdd_path_env
|
|
424
|
+
logger.debug(f"Using PROJECT_ROOT: {PROJECT_ROOT}")
|
|
209
425
|
|
|
210
|
-
if PROJECT_ROOT is None: # Fallback to CWD if no method succeeded
|
|
211
|
-
PROJECT_ROOT = Path.cwd().resolve()
|
|
212
|
-
warnings.warn(f"Could not determine project root automatically. Using current working directory: {PROJECT_ROOT}. Ensure this is the intended root or set the PDD_PATH environment variable.")
|
|
213
426
|
|
|
427
|
+
# ENV_PATH is set after _is_env_path_package_dir is defined (see below)
|
|
214
428
|
|
|
215
|
-
ENV_PATH = PROJECT_ROOT / ".env"
|
|
216
429
|
# --- Determine LLM_MODEL_CSV_PATH ---
|
|
217
430
|
# Prioritize ~/.pdd/llm_model.csv, then a project .pdd from the current CWD,
|
|
218
431
|
# then PROJECT_ROOT (which may be set from PDD_PATH), else fall back to package.
|
|
@@ -272,11 +485,19 @@ def _is_env_path_package_dir(env_path: Path) -> bool:
|
|
|
272
485
|
except Exception:
|
|
273
486
|
return False
|
|
274
487
|
|
|
488
|
+
# ENV_PATH: Use CWD-based project root when PDD_PATH points to package directory
|
|
489
|
+
# This ensures .env is written to the user's project, not the installed package location
|
|
490
|
+
if _is_env_path_package_dir(PROJECT_ROOT):
|
|
491
|
+
ENV_PATH = project_root_from_cwd / ".env"
|
|
492
|
+
logger.debug(f"PDD_PATH points to package; using ENV_PATH from CWD: {ENV_PATH}")
|
|
493
|
+
else:
|
|
494
|
+
ENV_PATH = PROJECT_ROOT / ".env"
|
|
495
|
+
|
|
275
496
|
# Selection order
|
|
276
497
|
if user_model_csv_path.is_file():
|
|
277
498
|
LLM_MODEL_CSV_PATH = user_model_csv_path
|
|
278
499
|
logger.info(f"Using user-specific LLM model CSV: {LLM_MODEL_CSV_PATH}")
|
|
279
|
-
elif
|
|
500
|
+
elif PROJECT_ROOT_FROM_ENV and project_csv_from_env.is_file():
|
|
280
501
|
# Honor an explicitly-set PDD_PATH pointing to a real project directory
|
|
281
502
|
LLM_MODEL_CSV_PATH = project_csv_from_env
|
|
282
503
|
logger.info(f"Using project-specific LLM model CSV (from PDD_PATH): {LLM_MODEL_CSV_PATH}")
|
|
@@ -787,6 +1008,45 @@ def _sanitize_api_key(key_value: str) -> str:
|
|
|
787
1008
|
return sanitized
|
|
788
1009
|
|
|
789
1010
|
|
|
1011
|
+
def _save_key_to_env_file(key_name: str, value: str, env_path: Path) -> None:
|
|
1012
|
+
"""Save or update a key in the .env file.
|
|
1013
|
+
|
|
1014
|
+
- Replaces existing key in-place (no comment + append)
|
|
1015
|
+
- Removes old commented versions of the same key (Issue #183)
|
|
1016
|
+
- Preserves all other content
|
|
1017
|
+
"""
|
|
1018
|
+
lines = []
|
|
1019
|
+
if env_path.exists():
|
|
1020
|
+
with open(env_path, 'r') as f:
|
|
1021
|
+
lines = f.readlines()
|
|
1022
|
+
|
|
1023
|
+
new_lines = []
|
|
1024
|
+
key_replaced = False
|
|
1025
|
+
prefix = f"{key_name}="
|
|
1026
|
+
prefix_spaced = f"{key_name} ="
|
|
1027
|
+
|
|
1028
|
+
for line in lines:
|
|
1029
|
+
stripped = line.strip()
|
|
1030
|
+
# Skip old commented versions of this key (cleanup accumulation)
|
|
1031
|
+
if stripped.startswith(f"# {prefix}") or stripped.startswith(f"# {prefix_spaced}"):
|
|
1032
|
+
continue
|
|
1033
|
+
elif stripped.startswith(prefix) or stripped.startswith(prefix_spaced):
|
|
1034
|
+
# Replace in-place
|
|
1035
|
+
new_lines.append(f'{key_name}="{value}"\n')
|
|
1036
|
+
key_replaced = True
|
|
1037
|
+
else:
|
|
1038
|
+
new_lines.append(line)
|
|
1039
|
+
|
|
1040
|
+
# Add key if not found
|
|
1041
|
+
if not key_replaced:
|
|
1042
|
+
if new_lines and not new_lines[-1].endswith('\n'):
|
|
1043
|
+
new_lines.append('\n')
|
|
1044
|
+
new_lines.append(f'{key_name}="{value}"\n')
|
|
1045
|
+
|
|
1046
|
+
with open(env_path, 'w') as f:
|
|
1047
|
+
f.writelines(new_lines)
|
|
1048
|
+
|
|
1049
|
+
|
|
790
1050
|
def _ensure_api_key(model_info: Dict[str, Any], newly_acquired_keys: Dict[str, bool], verbose: bool) -> bool:
|
|
791
1051
|
"""Checks for API key in env, prompts user if missing, and updates .env."""
|
|
792
1052
|
key_name = model_info.get('api_key')
|
|
@@ -807,6 +1067,12 @@ def _ensure_api_key(model_info: Dict[str, Any], newly_acquired_keys: Dict[str, b
|
|
|
807
1067
|
return True
|
|
808
1068
|
else:
|
|
809
1069
|
logger.warning(f"API key environment variable '{key_name}' for model '{model_info.get('model')}' is not set.")
|
|
1070
|
+
|
|
1071
|
+
# Skip prompting if --force flag is set (non-interactive mode)
|
|
1072
|
+
if os.environ.get('PDD_FORCE'):
|
|
1073
|
+
logger.error(f"API key '{key_name}' not set. In --force mode, skipping interactive prompt.")
|
|
1074
|
+
return False
|
|
1075
|
+
|
|
810
1076
|
try:
|
|
811
1077
|
# Interactive prompt
|
|
812
1078
|
user_provided_key = input(f"Please enter the API key for {key_name}: ").strip()
|
|
@@ -824,39 +1090,7 @@ def _ensure_api_key(model_info: Dict[str, Any], newly_acquired_keys: Dict[str, b
|
|
|
824
1090
|
|
|
825
1091
|
# Update .env file
|
|
826
1092
|
try:
|
|
827
|
-
|
|
828
|
-
if ENV_PATH.exists():
|
|
829
|
-
with open(ENV_PATH, 'r') as f:
|
|
830
|
-
lines = f.readlines()
|
|
831
|
-
|
|
832
|
-
new_lines = []
|
|
833
|
-
# key_updated = False
|
|
834
|
-
prefix = f"{key_name}="
|
|
835
|
-
prefix_spaced = f"{key_name} =" # Handle potential spaces
|
|
836
|
-
|
|
837
|
-
for line in lines:
|
|
838
|
-
stripped_line = line.strip()
|
|
839
|
-
if stripped_line.startswith(prefix) or stripped_line.startswith(prefix_spaced):
|
|
840
|
-
# Comment out the old key
|
|
841
|
-
new_lines.append(f"# {line}")
|
|
842
|
-
# key_updated = True # Indicates we found an old line to comment
|
|
843
|
-
elif stripped_line.startswith(f"# {prefix}") or stripped_line.startswith(f"# {prefix_spaced}"):
|
|
844
|
-
# Keep already commented lines as they are
|
|
845
|
-
new_lines.append(line)
|
|
846
|
-
else:
|
|
847
|
-
new_lines.append(line)
|
|
848
|
-
|
|
849
|
-
# Append the new key, ensuring quotes for robustness
|
|
850
|
-
new_key_line = f'{key_name}="{user_provided_key}"\n'
|
|
851
|
-
# Add newline before if file not empty and doesn't end with newline
|
|
852
|
-
if new_lines and not new_lines[-1].endswith('\n'):
|
|
853
|
-
new_lines.append('\n')
|
|
854
|
-
new_lines.append(new_key_line)
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
with open(ENV_PATH, 'w') as f:
|
|
858
|
-
f.writelines(new_lines)
|
|
859
|
-
|
|
1093
|
+
_save_key_to_env_file(key_name, user_provided_key, ENV_PATH)
|
|
860
1094
|
logger.info(f"API key '{key_name}' saved to {ENV_PATH}.")
|
|
861
1095
|
logger.warning("SECURITY WARNING: The API key has been saved to your .env file. "
|
|
862
1096
|
"Ensure this file is kept secure and is included in your .gitignore.")
|
|
@@ -878,7 +1112,6 @@ def _ensure_api_key(model_info: Dict[str, Any], newly_acquired_keys: Dict[str, b
|
|
|
878
1112
|
def _format_messages(prompt: str, input_data: Union[Dict[str, Any], List[Dict[str, Any]]], use_batch_mode: bool) -> Union[List[Dict[str, str]], List[List[Dict[str, str]]]]:
|
|
879
1113
|
"""Formats prompt and input into LiteLLM message format."""
|
|
880
1114
|
try:
|
|
881
|
-
prompt_template = PromptTemplate.from_template(prompt)
|
|
882
1115
|
if use_batch_mode:
|
|
883
1116
|
if not isinstance(input_data, list):
|
|
884
1117
|
raise ValueError("input_json must be a list of dictionaries when use_batch_mode is True.")
|
|
@@ -886,16 +1119,16 @@ def _format_messages(prompt: str, input_data: Union[Dict[str, Any], List[Dict[st
|
|
|
886
1119
|
for item in input_data:
|
|
887
1120
|
if not isinstance(item, dict):
|
|
888
1121
|
raise ValueError("Each item in input_json list must be a dictionary for batch mode.")
|
|
889
|
-
formatted_prompt =
|
|
1122
|
+
formatted_prompt = prompt.format(**item)
|
|
890
1123
|
all_messages.append([{"role": "user", "content": formatted_prompt}])
|
|
891
1124
|
return all_messages
|
|
892
1125
|
else:
|
|
893
1126
|
if not isinstance(input_data, dict):
|
|
894
1127
|
raise ValueError("input_json must be a dictionary when use_batch_mode is False.")
|
|
895
|
-
formatted_prompt =
|
|
1128
|
+
formatted_prompt = prompt.format(**input_data)
|
|
896
1129
|
return [{"role": "user", "content": formatted_prompt}]
|
|
897
1130
|
except KeyError as e:
|
|
898
|
-
raise ValueError(f"Prompt formatting error: Missing key {e} in input_json for prompt
|
|
1131
|
+
raise ValueError(f"Prompt formatting error: Missing key {e} in input_json for prompt string.") from e
|
|
899
1132
|
except Exception as e:
|
|
900
1133
|
raise ValueError(f"Error formatting prompt: {e}") from e
|
|
901
1134
|
|
|
@@ -956,6 +1189,31 @@ def _looks_like_python_code(s: str) -> bool:
|
|
|
956
1189
|
return any(indicator in s for indicator in code_indicators)
|
|
957
1190
|
|
|
958
1191
|
|
|
1192
|
+
# Field names known to contain prose text, not Python code
|
|
1193
|
+
# These are skipped during syntax validation to avoid false positives
|
|
1194
|
+
_PROSE_FIELD_NAMES = frozenset({
|
|
1195
|
+
'reasoning', # PromptAnalysis - completeness reasoning
|
|
1196
|
+
'explanation', # TrimResultsOutput, FixerOutput - prose explanations
|
|
1197
|
+
'analysis', # DiffAnalysis, CodePatchResult - analysis text
|
|
1198
|
+
'change_instructions', # ChangeInstruction, ConflictChange - instructions
|
|
1199
|
+
'change_description', # DiffAnalysis - description of changes
|
|
1200
|
+
'planned_modifications', # CodePatchResult - modification plans
|
|
1201
|
+
'details', # VerificationOutput - issue details
|
|
1202
|
+
'description', # General prose descriptions
|
|
1203
|
+
'focus', # Focus descriptions
|
|
1204
|
+
'file_summary', # FileSummary - prose summaries of file contents
|
|
1205
|
+
})
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
def _is_prose_field_name(field_name: str) -> bool:
|
|
1209
|
+
"""Check if a field name indicates it contains prose, not code.
|
|
1210
|
+
|
|
1211
|
+
Used to skip syntax validation on prose fields that may contain
|
|
1212
|
+
Python keywords (like 'return' or 'import') but are not actual code.
|
|
1213
|
+
"""
|
|
1214
|
+
return field_name.lower() in _PROSE_FIELD_NAMES
|
|
1215
|
+
|
|
1216
|
+
|
|
959
1217
|
def _repair_python_syntax(code: str) -> str:
|
|
960
1218
|
"""
|
|
961
1219
|
Validate Python code syntax and attempt repairs if invalid.
|
|
@@ -1222,15 +1480,19 @@ def _unescape_code_newlines(obj: Any) -> Any:
|
|
|
1222
1480
|
return obj
|
|
1223
1481
|
|
|
1224
1482
|
|
|
1225
|
-
def _has_invalid_python_code(obj: Any) -> bool:
|
|
1483
|
+
def _has_invalid_python_code(obj: Any, field_name: str = "") -> bool:
|
|
1226
1484
|
"""
|
|
1227
1485
|
Check if any code-like string fields have invalid Python syntax.
|
|
1228
1486
|
|
|
1229
1487
|
This is used after _unescape_code_newlines to detect if repair failed
|
|
1230
1488
|
and we should retry with cache disabled.
|
|
1231
1489
|
|
|
1490
|
+
Skips fields in _PROSE_FIELD_NAMES to avoid false positives on prose
|
|
1491
|
+
text that mentions code patterns (e.g., "ends on a return statement").
|
|
1492
|
+
|
|
1232
1493
|
Args:
|
|
1233
1494
|
obj: A Pydantic model, dict, list, or primitive value
|
|
1495
|
+
field_name: The name of the field being validated (used to skip prose)
|
|
1234
1496
|
|
|
1235
1497
|
Returns:
|
|
1236
1498
|
True if there are invalid code fields that couldn't be repaired
|
|
@@ -1241,6 +1503,9 @@ def _has_invalid_python_code(obj: Any) -> bool:
|
|
|
1241
1503
|
return False
|
|
1242
1504
|
|
|
1243
1505
|
if isinstance(obj, str):
|
|
1506
|
+
# Skip validation for known prose fields
|
|
1507
|
+
if _is_prose_field_name(field_name):
|
|
1508
|
+
return False
|
|
1244
1509
|
if _looks_like_python_code(obj):
|
|
1245
1510
|
try:
|
|
1246
1511
|
ast.parse(obj)
|
|
@@ -1250,21 +1515,22 @@ def _has_invalid_python_code(obj: Any) -> bool:
|
|
|
1250
1515
|
return False
|
|
1251
1516
|
|
|
1252
1517
|
if isinstance(obj, BaseModel):
|
|
1253
|
-
for
|
|
1254
|
-
value = getattr(obj,
|
|
1255
|
-
if _has_invalid_python_code(value):
|
|
1518
|
+
for name in obj.model_fields:
|
|
1519
|
+
value = getattr(obj, name)
|
|
1520
|
+
if _has_invalid_python_code(value, field_name=name):
|
|
1256
1521
|
return True
|
|
1257
1522
|
return False
|
|
1258
1523
|
|
|
1259
1524
|
if isinstance(obj, dict):
|
|
1260
|
-
for value in obj.
|
|
1261
|
-
if
|
|
1525
|
+
for key, value in obj.items():
|
|
1526
|
+
fname = key if isinstance(key, str) else ""
|
|
1527
|
+
if _has_invalid_python_code(value, field_name=fname):
|
|
1262
1528
|
return True
|
|
1263
1529
|
return False
|
|
1264
1530
|
|
|
1265
1531
|
if isinstance(obj, list):
|
|
1266
1532
|
for item in obj:
|
|
1267
|
-
if _has_invalid_python_code(item):
|
|
1533
|
+
if _has_invalid_python_code(item, field_name=field_name):
|
|
1268
1534
|
return True
|
|
1269
1535
|
return False
|
|
1270
1536
|
|
|
@@ -1281,9 +1547,11 @@ def llm_invoke(
|
|
|
1281
1547
|
verbose: bool = False,
|
|
1282
1548
|
output_pydantic: Optional[Type[BaseModel]] = None,
|
|
1283
1549
|
output_schema: Optional[Dict[str, Any]] = None,
|
|
1284
|
-
time: float = 0.25,
|
|
1550
|
+
time: Optional[float] = 0.25,
|
|
1285
1551
|
use_batch_mode: bool = False,
|
|
1286
1552
|
messages: Optional[Union[List[Dict[str, str]], List[List[Dict[str, str]]]]] = None,
|
|
1553
|
+
language: Optional[str] = None,
|
|
1554
|
+
use_cloud: Optional[bool] = None,
|
|
1287
1555
|
) -> Dict[str, Any]:
|
|
1288
1556
|
"""
|
|
1289
1557
|
Runs a prompt with given input using LiteLLM, handling model selection,
|
|
@@ -1301,6 +1569,7 @@ def llm_invoke(
|
|
|
1301
1569
|
time: Relative thinking time (0-1, default 0.25).
|
|
1302
1570
|
use_batch_mode: Use batch completion if True.
|
|
1303
1571
|
messages: Pre-formatted list of messages (or list of lists for batch). If provided, ignores prompt and input_json.
|
|
1572
|
+
use_cloud: None=auto-detect (cloud if enabled, local if PDD_FORCE_LOCAL=1), True=force cloud, False=force local.
|
|
1304
1573
|
|
|
1305
1574
|
Returns:
|
|
1306
1575
|
Dictionary containing 'result', 'cost', 'model_name', 'thinking_output'.
|
|
@@ -1309,6 +1578,7 @@ def llm_invoke(
|
|
|
1309
1578
|
ValueError: For invalid inputs or prompt formatting errors.
|
|
1310
1579
|
FileNotFoundError: If llm_model.csv is missing.
|
|
1311
1580
|
RuntimeError: If all candidate models fail.
|
|
1581
|
+
InsufficientCreditsError: If cloud execution fails due to insufficient credits.
|
|
1312
1582
|
openai.*Error: If LiteLLM encounters API errors after retries.
|
|
1313
1583
|
"""
|
|
1314
1584
|
# Set verbose logging if requested
|
|
@@ -1325,6 +1595,58 @@ def llm_invoke(
|
|
|
1325
1595
|
logger.debug(f" time: {time}")
|
|
1326
1596
|
logger.debug(f" use_batch_mode: {use_batch_mode}")
|
|
1327
1597
|
logger.debug(f" messages: {'provided' if messages else 'None'}")
|
|
1598
|
+
logger.debug(f" use_cloud: {use_cloud}")
|
|
1599
|
+
|
|
1600
|
+
# --- 0. Cloud Execution Path ---
|
|
1601
|
+
# Determine cloud usage: explicit param > environment > default (local)
|
|
1602
|
+
if use_cloud is None:
|
|
1603
|
+
# Check environment for cloud preference
|
|
1604
|
+
# PDD_FORCE_LOCAL=1 forces local execution
|
|
1605
|
+
force_local = os.environ.get("PDD_FORCE_LOCAL", "").lower() in ("1", "true", "yes")
|
|
1606
|
+
if force_local:
|
|
1607
|
+
use_cloud = False
|
|
1608
|
+
else:
|
|
1609
|
+
# Try to use cloud if credentials are configured
|
|
1610
|
+
try:
|
|
1611
|
+
from pdd.core.cloud import CloudConfig
|
|
1612
|
+
use_cloud = CloudConfig.is_cloud_enabled()
|
|
1613
|
+
except ImportError:
|
|
1614
|
+
use_cloud = False
|
|
1615
|
+
|
|
1616
|
+
if use_cloud:
|
|
1617
|
+
from rich.console import Console
|
|
1618
|
+
console = Console()
|
|
1619
|
+
|
|
1620
|
+
if verbose:
|
|
1621
|
+
logger.debug("Attempting cloud execution...")
|
|
1622
|
+
|
|
1623
|
+
try:
|
|
1624
|
+
return _llm_invoke_cloud(
|
|
1625
|
+
prompt=prompt,
|
|
1626
|
+
input_json=input_json,
|
|
1627
|
+
strength=strength,
|
|
1628
|
+
temperature=temperature,
|
|
1629
|
+
verbose=verbose,
|
|
1630
|
+
output_pydantic=output_pydantic,
|
|
1631
|
+
output_schema=output_schema,
|
|
1632
|
+
time=time,
|
|
1633
|
+
use_batch_mode=use_batch_mode,
|
|
1634
|
+
messages=messages,
|
|
1635
|
+
language=language,
|
|
1636
|
+
)
|
|
1637
|
+
except CloudFallbackError as e:
|
|
1638
|
+
# Notify user and fall back to local execution
|
|
1639
|
+
console.print(f"[yellow]Cloud execution failed ({e}), falling back to local execution...[/yellow]")
|
|
1640
|
+
logger.warning(f"Cloud fallback: {e}")
|
|
1641
|
+
# Continue to local execution below
|
|
1642
|
+
except InsufficientCreditsError:
|
|
1643
|
+
# Re-raise credit errors - user needs to know
|
|
1644
|
+
raise
|
|
1645
|
+
except CloudInvocationError as e:
|
|
1646
|
+
# Non-recoverable cloud error - notify and fall back
|
|
1647
|
+
console.print(f"[yellow]Cloud error ({e}), falling back to local execution...[/yellow]")
|
|
1648
|
+
logger.warning(f"Cloud invocation error: {e}")
|
|
1649
|
+
# Continue to local execution below
|
|
1328
1650
|
|
|
1329
1651
|
# --- 1. Load Environment & Validate Inputs ---
|
|
1330
1652
|
# .env loading happens at module level
|
|
@@ -1349,6 +1671,10 @@ def llm_invoke(
|
|
|
1349
1671
|
else:
|
|
1350
1672
|
raise ValueError("Either 'messages' or both 'prompt' and 'input_json' must be provided.")
|
|
1351
1673
|
|
|
1674
|
+
# Handle None time (means "no reasoning requested")
|
|
1675
|
+
if time is None:
|
|
1676
|
+
time = 0.0
|
|
1677
|
+
|
|
1352
1678
|
if not (0.0 <= strength <= 1.0):
|
|
1353
1679
|
raise ValueError("'strength' must be between 0.0 and 1.0.")
|
|
1354
1680
|
if not (0.0 <= temperature <= 2.0): # Common range for temperature
|
|
@@ -1454,6 +1780,8 @@ def llm_invoke(
|
|
|
1454
1780
|
"messages": formatted_messages,
|
|
1455
1781
|
# Use a local adjustable temperature to allow provider-specific fallbacks
|
|
1456
1782
|
"temperature": current_temperature,
|
|
1783
|
+
# Retry on transient network errors (APIError, TimeoutError, ServiceUnavailableError)
|
|
1784
|
+
"num_retries": 2,
|
|
1457
1785
|
}
|
|
1458
1786
|
|
|
1459
1787
|
api_key_name_from_csv = model_info.get('api_key') # From CSV
|
|
@@ -1586,11 +1914,20 @@ def llm_invoke(
|
|
|
1586
1914
|
if output_pydantic:
|
|
1587
1915
|
if verbose:
|
|
1588
1916
|
logger.info(f"[INFO] Requesting structured output (Pydantic: {output_pydantic.__name__}) for {model_name_litellm}")
|
|
1589
|
-
# Use
|
|
1590
|
-
#
|
|
1917
|
+
# Use json_schema with strict=True to enforce ALL required fields are present
|
|
1918
|
+
# This prevents LLMs from omitting required fields when they think they're not needed
|
|
1919
|
+
schema = output_pydantic.model_json_schema()
|
|
1920
|
+
# Ensure all properties are in required array (OpenAI strict mode requirement)
|
|
1921
|
+
_ensure_all_properties_required(schema)
|
|
1922
|
+
# Add additionalProperties: false for strict mode (required by OpenAI)
|
|
1923
|
+
schema["additionalProperties"] = False
|
|
1591
1924
|
response_format = {
|
|
1592
|
-
"type": "
|
|
1593
|
-
"
|
|
1925
|
+
"type": "json_schema",
|
|
1926
|
+
"json_schema": {
|
|
1927
|
+
"name": output_pydantic.__name__,
|
|
1928
|
+
"schema": schema,
|
|
1929
|
+
"strict": True
|
|
1930
|
+
}
|
|
1594
1931
|
}
|
|
1595
1932
|
else: # output_schema is set
|
|
1596
1933
|
if verbose:
|
|
@@ -1608,7 +1945,9 @@ def llm_invoke(
|
|
|
1608
1945
|
"strict": False
|
|
1609
1946
|
}
|
|
1610
1947
|
}
|
|
1611
|
-
|
|
1948
|
+
# Add additionalProperties: false for strict mode (required by OpenAI)
|
|
1949
|
+
response_format["json_schema"]["schema"]["additionalProperties"] = False
|
|
1950
|
+
|
|
1612
1951
|
litellm_kwargs["response_format"] = response_format
|
|
1613
1952
|
|
|
1614
1953
|
# LM Studio requires "json_schema" format, not "json_object"
|
|
@@ -1792,6 +2131,8 @@ def llm_invoke(
|
|
|
1792
2131
|
schema = output_schema
|
|
1793
2132
|
name = "response"
|
|
1794
2133
|
|
|
2134
|
+
# Ensure all properties are in required array (OpenAI strict mode requirement)
|
|
2135
|
+
_ensure_all_properties_required(schema)
|
|
1795
2136
|
# Add additionalProperties: false for strict mode (required by OpenAI)
|
|
1796
2137
|
schema['additionalProperties'] = False
|
|
1797
2138
|
|
|
@@ -1941,6 +2282,12 @@ def llm_invoke(
|
|
|
1941
2282
|
if verbose:
|
|
1942
2283
|
logger.info(f"[SUCCESS] Invocation successful for {model_name_litellm} (took {end_time - start_time:.2f}s)")
|
|
1943
2284
|
|
|
2285
|
+
# Build retry kwargs with provider credentials from litellm_kwargs
|
|
2286
|
+
# Issue #185: Retry calls were missing vertex_location, vertex_project, etc.
|
|
2287
|
+
retry_provider_kwargs = {k: v for k, v in litellm_kwargs.items()
|
|
2288
|
+
if k in ('vertex_credentials', 'vertex_project', 'vertex_location',
|
|
2289
|
+
'api_key', 'base_url', 'api_base')}
|
|
2290
|
+
|
|
1944
2291
|
# --- 7. Process Response ---
|
|
1945
2292
|
results = []
|
|
1946
2293
|
thinking_outputs = []
|
|
@@ -1991,7 +2338,8 @@ def llm_invoke(
|
|
|
1991
2338
|
messages=retry_messages,
|
|
1992
2339
|
temperature=current_temperature,
|
|
1993
2340
|
response_format=response_format,
|
|
1994
|
-
**time_kwargs
|
|
2341
|
+
**time_kwargs,
|
|
2342
|
+
**retry_provider_kwargs # Issue #185: Pass Vertex AI credentials
|
|
1995
2343
|
)
|
|
1996
2344
|
# Re-enable cache - restore original configured cache (restore to original state, even if None)
|
|
1997
2345
|
litellm.cache = configured_cache
|
|
@@ -2030,7 +2378,8 @@ def llm_invoke(
|
|
|
2030
2378
|
messages=retry_messages,
|
|
2031
2379
|
temperature=current_temperature,
|
|
2032
2380
|
response_format=response_format,
|
|
2033
|
-
**time_kwargs
|
|
2381
|
+
**time_kwargs,
|
|
2382
|
+
**retry_provider_kwargs # Issue #185: Pass Vertex AI credentials
|
|
2034
2383
|
)
|
|
2035
2384
|
# Re-enable cache
|
|
2036
2385
|
litellm.cache = original_cache
|
|
@@ -2237,16 +2586,22 @@ def llm_invoke(
|
|
|
2237
2586
|
logger.error(f"[ERROR] Failed to parse response into {target_name} for item {i}: {parse_error}")
|
|
2238
2587
|
# Use the string that was last attempted for parsing in the error message
|
|
2239
2588
|
error_content = json_string_to_parse if json_string_to_parse is not None else raw_result
|
|
2240
|
-
logger.error("[ERROR] Content attempted for parsing: %s", repr(error_content))
|
|
2241
|
-
|
|
2242
|
-
continue
|
|
2589
|
+
logger.error("[ERROR] Content attempted for parsing: %s", repr(error_content))
|
|
2590
|
+
# Issue #168: Raise SchemaValidationError to trigger model fallback
|
|
2591
|
+
# Previously this used `continue` which only skipped to the next batch item
|
|
2592
|
+
raise SchemaValidationError(
|
|
2593
|
+
f"Failed to parse response into {target_name}: {parse_error}",
|
|
2594
|
+
raw_response=raw_result,
|
|
2595
|
+
item_index=i
|
|
2596
|
+
) from parse_error
|
|
2243
2597
|
|
|
2244
2598
|
# Post-process: unescape newlines and repair Python syntax
|
|
2245
2599
|
_unescape_code_newlines(parsed_result)
|
|
2246
2600
|
|
|
2247
2601
|
# Check if code fields still have invalid Python syntax after repair
|
|
2248
2602
|
# If so, retry without cache to get a fresh response
|
|
2249
|
-
|
|
2603
|
+
# Skip validation for non-Python languages to avoid false positives
|
|
2604
|
+
if language in (None, "python") and _has_invalid_python_code(parsed_result):
|
|
2250
2605
|
logger.warning(f"[WARNING] Detected invalid Python syntax in code fields for item {i} after repair. Retrying with cache bypass...")
|
|
2251
2606
|
if not use_batch_mode and prompt and input_json is not None:
|
|
2252
2607
|
# Add a small variation to bypass cache
|
|
@@ -2261,7 +2616,8 @@ def llm_invoke(
|
|
|
2261
2616
|
messages=retry_messages,
|
|
2262
2617
|
temperature=current_temperature,
|
|
2263
2618
|
response_format=response_format,
|
|
2264
|
-
**time_kwargs
|
|
2619
|
+
**time_kwargs,
|
|
2620
|
+
**retry_provider_kwargs # Issue #185: Pass Vertex AI credentials
|
|
2265
2621
|
)
|
|
2266
2622
|
# Re-enable cache
|
|
2267
2623
|
litellm.cache = original_cache
|
|
@@ -2377,6 +2733,14 @@ def llm_invoke(
|
|
|
2377
2733
|
logger.warning(f"[AUTH ERROR] Authentication failed for {model_name_litellm} using existing key '{api_key_name}'. Trying next model.")
|
|
2378
2734
|
break # Break inner loop, try next model candidate
|
|
2379
2735
|
|
|
2736
|
+
except SchemaValidationError as e:
|
|
2737
|
+
# Issue #168: Schema validation failures now trigger model fallback
|
|
2738
|
+
last_exception = e
|
|
2739
|
+
logger.warning(f"[SCHEMA ERROR] Validation failed for {model_name_litellm}: {e}. Trying next model.")
|
|
2740
|
+
if verbose:
|
|
2741
|
+
logger.debug(f"Raw response that failed validation: {repr(e.raw_response)}")
|
|
2742
|
+
break # Break inner loop, try next model candidate
|
|
2743
|
+
|
|
2380
2744
|
except (openai.RateLimitError, openai.APITimeoutError, openai.APIConnectionError,
|
|
2381
2745
|
openai.APIStatusError, openai.BadRequestError, openai.InternalServerError,
|
|
2382
2746
|
Exception) as e: # Catch generic Exception last
|