pdd-cli 0.0.90__py3-none-any.whl → 0.0.121__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 (151) hide show
  1. pdd/__init__.py +38 -6
  2. pdd/agentic_bug.py +323 -0
  3. pdd/agentic_bug_orchestrator.py +506 -0
  4. pdd/agentic_change.py +231 -0
  5. pdd/agentic_change_orchestrator.py +537 -0
  6. pdd/agentic_common.py +533 -770
  7. pdd/agentic_crash.py +2 -1
  8. pdd/agentic_e2e_fix.py +319 -0
  9. pdd/agentic_e2e_fix_orchestrator.py +582 -0
  10. pdd/agentic_fix.py +118 -3
  11. pdd/agentic_update.py +27 -9
  12. pdd/agentic_verify.py +3 -2
  13. pdd/architecture_sync.py +565 -0
  14. pdd/auth_service.py +210 -0
  15. pdd/auto_deps_main.py +63 -53
  16. pdd/auto_include.py +236 -3
  17. pdd/auto_update.py +125 -47
  18. pdd/bug_main.py +195 -23
  19. pdd/cmd_test_main.py +345 -197
  20. pdd/code_generator.py +4 -2
  21. pdd/code_generator_main.py +118 -32
  22. pdd/commands/__init__.py +6 -0
  23. pdd/commands/analysis.py +113 -48
  24. pdd/commands/auth.py +309 -0
  25. pdd/commands/connect.py +358 -0
  26. pdd/commands/fix.py +155 -114
  27. pdd/commands/generate.py +5 -0
  28. pdd/commands/maintenance.py +3 -2
  29. pdd/commands/misc.py +8 -0
  30. pdd/commands/modify.py +225 -163
  31. pdd/commands/sessions.py +284 -0
  32. pdd/commands/utility.py +12 -7
  33. pdd/construct_paths.py +334 -32
  34. pdd/context_generator_main.py +167 -170
  35. pdd/continue_generation.py +6 -3
  36. pdd/core/__init__.py +33 -0
  37. pdd/core/cli.py +44 -7
  38. pdd/core/cloud.py +237 -0
  39. pdd/core/dump.py +68 -20
  40. pdd/core/errors.py +4 -0
  41. pdd/core/remote_session.py +61 -0
  42. pdd/crash_main.py +219 -23
  43. pdd/data/llm_model.csv +4 -4
  44. pdd/docs/prompting_guide.md +864 -0
  45. pdd/docs/whitepaper_with_benchmarks/data_and_functions/benchmark_analysis.py +495 -0
  46. pdd/docs/whitepaper_with_benchmarks/data_and_functions/creation_compare.py +528 -0
  47. pdd/fix_code_loop.py +208 -34
  48. pdd/fix_code_module_errors.py +6 -2
  49. pdd/fix_error_loop.py +291 -38
  50. pdd/fix_main.py +208 -6
  51. pdd/fix_verification_errors_loop.py +235 -26
  52. pdd/fix_verification_main.py +269 -83
  53. pdd/frontend/dist/assets/index-B5DZHykP.css +1 -0
  54. pdd/frontend/dist/assets/index-CUWd8al1.js +450 -0
  55. pdd/frontend/dist/index.html +376 -0
  56. pdd/frontend/dist/logo.svg +33 -0
  57. pdd/generate_output_paths.py +46 -5
  58. pdd/generate_test.py +212 -151
  59. pdd/get_comment.py +19 -44
  60. pdd/get_extension.py +8 -9
  61. pdd/get_jwt_token.py +309 -20
  62. pdd/get_language.py +8 -7
  63. pdd/get_run_command.py +7 -5
  64. pdd/insert_includes.py +2 -1
  65. pdd/llm_invoke.py +531 -97
  66. pdd/load_prompt_template.py +15 -34
  67. pdd/operation_log.py +342 -0
  68. pdd/path_resolution.py +140 -0
  69. pdd/postprocess.py +122 -97
  70. pdd/preprocess.py +68 -12
  71. pdd/preprocess_main.py +33 -1
  72. pdd/prompts/agentic_bug_step10_pr_LLM.prompt +182 -0
  73. pdd/prompts/agentic_bug_step1_duplicate_LLM.prompt +73 -0
  74. pdd/prompts/agentic_bug_step2_docs_LLM.prompt +129 -0
  75. pdd/prompts/agentic_bug_step3_triage_LLM.prompt +95 -0
  76. pdd/prompts/agentic_bug_step4_reproduce_LLM.prompt +97 -0
  77. pdd/prompts/agentic_bug_step5_root_cause_LLM.prompt +123 -0
  78. pdd/prompts/agentic_bug_step6_test_plan_LLM.prompt +107 -0
  79. pdd/prompts/agentic_bug_step7_generate_LLM.prompt +172 -0
  80. pdd/prompts/agentic_bug_step8_verify_LLM.prompt +119 -0
  81. pdd/prompts/agentic_bug_step9_e2e_test_LLM.prompt +289 -0
  82. pdd/prompts/agentic_change_step10_identify_issues_LLM.prompt +1006 -0
  83. pdd/prompts/agentic_change_step11_fix_issues_LLM.prompt +984 -0
  84. pdd/prompts/agentic_change_step12_create_pr_LLM.prompt +140 -0
  85. pdd/prompts/agentic_change_step1_duplicate_LLM.prompt +73 -0
  86. pdd/prompts/agentic_change_step2_docs_LLM.prompt +101 -0
  87. pdd/prompts/agentic_change_step3_research_LLM.prompt +126 -0
  88. pdd/prompts/agentic_change_step4_clarify_LLM.prompt +164 -0
  89. pdd/prompts/agentic_change_step5_docs_change_LLM.prompt +981 -0
  90. pdd/prompts/agentic_change_step6_devunits_LLM.prompt +1005 -0
  91. pdd/prompts/agentic_change_step7_architecture_LLM.prompt +1044 -0
  92. pdd/prompts/agentic_change_step8_analyze_LLM.prompt +1027 -0
  93. pdd/prompts/agentic_change_step9_implement_LLM.prompt +1077 -0
  94. pdd/prompts/agentic_e2e_fix_step1_unit_tests_LLM.prompt +90 -0
  95. pdd/prompts/agentic_e2e_fix_step2_e2e_tests_LLM.prompt +91 -0
  96. pdd/prompts/agentic_e2e_fix_step3_root_cause_LLM.prompt +89 -0
  97. pdd/prompts/agentic_e2e_fix_step4_fix_e2e_tests_LLM.prompt +96 -0
  98. pdd/prompts/agentic_e2e_fix_step5_identify_devunits_LLM.prompt +91 -0
  99. pdd/prompts/agentic_e2e_fix_step6_create_unit_tests_LLM.prompt +106 -0
  100. pdd/prompts/agentic_e2e_fix_step7_verify_tests_LLM.prompt +116 -0
  101. pdd/prompts/agentic_e2e_fix_step8_run_pdd_fix_LLM.prompt +120 -0
  102. pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt +146 -0
  103. pdd/prompts/agentic_fix_primary_LLM.prompt +2 -2
  104. pdd/prompts/agentic_update_LLM.prompt +192 -338
  105. pdd/prompts/auto_include_LLM.prompt +22 -0
  106. pdd/prompts/change_LLM.prompt +3093 -1
  107. pdd/prompts/detect_change_LLM.prompt +571 -14
  108. pdd/prompts/fix_code_module_errors_LLM.prompt +8 -0
  109. pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +1 -0
  110. pdd/prompts/generate_test_LLM.prompt +19 -1
  111. pdd/prompts/generate_test_from_example_LLM.prompt +366 -0
  112. pdd/prompts/insert_includes_LLM.prompt +262 -252
  113. pdd/prompts/prompt_code_diff_LLM.prompt +123 -0
  114. pdd/prompts/prompt_diff_LLM.prompt +82 -0
  115. pdd/remote_session.py +876 -0
  116. pdd/server/__init__.py +52 -0
  117. pdd/server/app.py +335 -0
  118. pdd/server/click_executor.py +587 -0
  119. pdd/server/executor.py +338 -0
  120. pdd/server/jobs.py +661 -0
  121. pdd/server/models.py +241 -0
  122. pdd/server/routes/__init__.py +31 -0
  123. pdd/server/routes/architecture.py +451 -0
  124. pdd/server/routes/auth.py +364 -0
  125. pdd/server/routes/commands.py +929 -0
  126. pdd/server/routes/config.py +42 -0
  127. pdd/server/routes/files.py +603 -0
  128. pdd/server/routes/prompts.py +1347 -0
  129. pdd/server/routes/websocket.py +473 -0
  130. pdd/server/security.py +243 -0
  131. pdd/server/terminal_spawner.py +217 -0
  132. pdd/server/token_counter.py +222 -0
  133. pdd/summarize_directory.py +236 -237
  134. pdd/sync_animation.py +8 -4
  135. pdd/sync_determine_operation.py +329 -47
  136. pdd/sync_main.py +272 -28
  137. pdd/sync_orchestration.py +289 -211
  138. pdd/sync_order.py +304 -0
  139. pdd/template_expander.py +161 -0
  140. pdd/templates/architecture/architecture_json.prompt +41 -46
  141. pdd/trace.py +1 -1
  142. pdd/track_cost.py +0 -13
  143. pdd/unfinished_prompt.py +2 -1
  144. pdd/update_main.py +68 -26
  145. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/METADATA +15 -10
  146. pdd_cli-0.0.121.dist-info/RECORD +229 -0
  147. pdd_cli-0.0.90.dist-info/RECORD +0 -153
  148. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/WHEEL +0 -0
  149. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/entry_points.txt +0 -0
  150. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/licenses/LICENSE +0 -0
  151. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.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,310 @@ 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
+ """Recursively ensure ALL properties are in the required array (OpenAI strict mode).
150
+
151
+ OpenAI's strict mode requires that all properties at ALL levels of a JSON schema
152
+ are listed in the 'required' array. Pydantic's model_json_schema() only includes
153
+ fields without default values in 'required', which causes OpenAI to reject the schema.
154
+
155
+ This function walks the entire schema tree and ensures every object type has all
156
+ its properties in the 'required' array.
157
+
158
+ Args:
159
+ schema: A JSON schema dictionary
160
+
161
+ Returns:
162
+ The schema with all properties added to 'required' at all nesting levels
163
+ """
164
+ if not isinstance(schema, dict):
165
+ return schema
166
+
167
+ # If this is an object with properties, make all properties required
168
+ if schema.get('type') == 'object' and 'properties' in schema:
169
+ schema['required'] = list(schema['properties'].keys())
170
+ # Recurse into each property
171
+ for prop_schema in schema['properties'].values():
172
+ _ensure_all_properties_required(prop_schema)
173
+
174
+ # Handle array items
175
+ if schema.get('type') == 'array' and 'items' in schema:
176
+ _ensure_all_properties_required(schema['items'])
177
+
178
+ # Handle anyOf/oneOf/allOf
179
+ for key in ('anyOf', 'oneOf', 'allOf'):
180
+ if key in schema:
181
+ for sub_schema in schema[key]:
182
+ _ensure_all_properties_required(sub_schema)
183
+
184
+ # Handle $defs
185
+ if '$defs' in schema:
186
+ for def_schema in schema['$defs'].values():
187
+ _ensure_all_properties_required(def_schema)
188
+
189
+ return schema
190
+
191
+
192
+ def _add_additional_properties_false(schema: Dict[str, Any]) -> Dict[str, Any]:
193
+ """Recursively add additionalProperties: false to all object schemas.
194
+
195
+ OpenAI's strict mode requires additionalProperties: false on ALL object
196
+ schemas, including nested ones. This function walks the schema tree and
197
+ adds the property to every object type.
198
+
199
+ Args:
200
+ schema: A JSON schema dictionary
201
+
202
+ Returns:
203
+ The schema with additionalProperties: false on all objects
204
+ """
205
+ if not isinstance(schema, dict):
206
+ return schema
207
+
208
+ # If this is an object type, add additionalProperties: false
209
+ if schema.get('type') == 'object':
210
+ schema['additionalProperties'] = False
211
+ # Recursively process properties
212
+ if 'properties' in schema:
213
+ for prop_name, prop_schema in schema['properties'].items():
214
+ _add_additional_properties_false(prop_schema)
215
+
216
+ # Handle arrays - process items schema
217
+ if schema.get('type') == 'array' and 'items' in schema:
218
+ _add_additional_properties_false(schema['items'])
219
+
220
+ # Handle anyOf, oneOf, allOf
221
+ for key in ('anyOf', 'oneOf', 'allOf'):
222
+ if key in schema:
223
+ for sub_schema in schema[key]:
224
+ _add_additional_properties_false(sub_schema)
225
+
226
+ # Handle $defs (Pydantic's reference definitions)
227
+ if '$defs' in schema:
228
+ for def_name, def_schema in schema['$defs'].items():
229
+ _add_additional_properties_false(def_schema)
230
+
231
+ return schema
232
+
233
+
234
+ def _pydantic_to_json_schema(pydantic_class: Type[BaseModel]) -> Dict[str, Any]:
235
+ """Convert a Pydantic model class to JSON Schema for cloud transport.
236
+
237
+ Args:
238
+ pydantic_class: A Pydantic BaseModel subclass
239
+
240
+ Returns:
241
+ JSON Schema dictionary that can be serialized and sent to cloud
242
+ """
243
+ schema = pydantic_class.model_json_schema()
244
+ # Ensure all properties are in required array (OpenAI strict mode requirement)
245
+ _ensure_all_properties_required(schema)
246
+ # Include class name for debugging/logging purposes
247
+ schema['__pydantic_class_name__'] = pydantic_class.__name__
248
+ return schema
249
+
250
+
251
+ def _validate_with_pydantic(
252
+ result: Any,
253
+ pydantic_class: Type[BaseModel]
254
+ ) -> BaseModel:
255
+ """Validate cloud response using original Pydantic class.
256
+
257
+ Args:
258
+ result: The result from cloud (dict or JSON string)
259
+ pydantic_class: The Pydantic model to validate against
260
+
261
+ Returns:
262
+ Validated Pydantic model instance
263
+
264
+ Raises:
265
+ ValidationError: If validation fails
266
+ """
267
+ if isinstance(result, dict):
268
+ return pydantic_class.model_validate(result)
269
+ elif isinstance(result, str):
270
+ return pydantic_class.model_validate_json(result)
271
+ elif isinstance(result, pydantic_class):
272
+ # Already validated
273
+ return result
274
+ raise ValueError(f"Cannot validate result type {type(result)} with Pydantic model")
275
+
276
+
277
+ def _llm_invoke_cloud(
278
+ prompt: Optional[str],
279
+ input_json: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]],
280
+ strength: float,
281
+ temperature: float,
282
+ verbose: bool,
283
+ output_pydantic: Optional[Type[BaseModel]],
284
+ output_schema: Optional[Dict[str, Any]],
285
+ time: float,
286
+ use_batch_mode: bool,
287
+ messages: Optional[Union[List[Dict[str, str]], List[List[Dict[str, str]]]]],
288
+ language: Optional[str],
289
+ ) -> Dict[str, Any]:
290
+ """Execute llm_invoke via cloud endpoint.
291
+
292
+ Args:
293
+ All parameters match llm_invoke signature
294
+
295
+ Returns:
296
+ Dictionary with 'result', 'cost', 'model_name', 'thinking_output'
297
+
298
+ Raises:
299
+ CloudFallbackError: For recoverable errors (network, timeout, auth)
300
+ InsufficientCreditsError: For 402 Payment Required
301
+ CloudInvocationError: For non-recoverable cloud errors
302
+ """
303
+ import requests
304
+ from rich.console import Console
305
+
306
+ # Lazy import to avoid circular dependency
307
+ from pdd.core.cloud import CloudConfig
308
+
309
+ console = Console()
310
+ CLOUD_TIMEOUT = 300 # 5 minutes
311
+
312
+ # Get JWT token
313
+ jwt_token = CloudConfig.get_jwt_token(verbose=verbose)
314
+ if not jwt_token:
315
+ raise CloudFallbackError("Could not authenticate with cloud")
316
+
317
+ # Prepare payload
318
+ payload: Dict[str, Any] = {
319
+ "strength": strength,
320
+ "temperature": temperature,
321
+ "time": time,
322
+ "verbose": verbose,
323
+ "useBatchMode": use_batch_mode,
324
+ }
325
+
326
+ if language:
327
+ payload["language"] = language
328
+
329
+ # Add prompt/messages
330
+ if messages:
331
+ payload["messages"] = messages
332
+ else:
333
+ payload["prompt"] = prompt
334
+ payload["inputJson"] = input_json
335
+
336
+ # Handle output schema
337
+ if output_pydantic:
338
+ payload["outputSchema"] = _pydantic_to_json_schema(output_pydantic)
339
+ elif output_schema:
340
+ payload["outputSchema"] = output_schema
341
+
342
+ # Make request
343
+ headers = {
344
+ "Authorization": f"Bearer {jwt_token}",
345
+ "Content-Type": "application/json"
346
+ }
347
+
348
+ cloud_url = CloudConfig.get_endpoint_url("llmInvoke")
349
+
350
+ if verbose:
351
+ logger.debug(f"Cloud llm_invoke request to: {cloud_url}")
352
+
353
+ try:
354
+ response = requests.post(
355
+ cloud_url,
356
+ json=payload,
357
+ headers=headers,
358
+ timeout=CLOUD_TIMEOUT
359
+ )
360
+
361
+ if response.status_code == 200:
362
+ data = response.json()
363
+ result = data.get("result")
364
+
365
+ # Validate with Pydantic if specified
366
+ if output_pydantic and result:
367
+ try:
368
+ result = _validate_with_pydantic(result, output_pydantic)
369
+ except (ValidationError, ValueError) as e:
370
+ logger.warning(f"Cloud response validation failed: {e}")
371
+ # Return raw result if validation fails
372
+ pass
373
+
374
+ return {
375
+ "result": result,
376
+ "cost": data.get("totalCost", 0.0),
377
+ "model_name": data.get("modelName", "cloud_model"),
378
+ "thinking_output": data.get("thinkingOutput"),
379
+ }
380
+
381
+ elif response.status_code == 402:
382
+ error_msg = response.json().get("error", "Insufficient credits")
383
+ raise InsufficientCreditsError(error_msg)
384
+
385
+ elif response.status_code in (401, 403):
386
+ error_msg = response.json().get("error", f"Authentication failed ({response.status_code})")
387
+ raise CloudFallbackError(error_msg)
388
+
389
+ elif response.status_code >= 500:
390
+ error_msg = response.json().get("error", f"Server error ({response.status_code})")
391
+ raise CloudFallbackError(error_msg)
392
+
393
+ else:
394
+ error_msg = response.json().get("error", f"HTTP {response.status_code}")
395
+ raise CloudInvocationError(f"Cloud llm_invoke failed: {error_msg}")
396
+
397
+ except requests.exceptions.Timeout:
398
+ raise CloudFallbackError("Cloud request timed out")
399
+ except requests.exceptions.ConnectionError as e:
400
+ raise CloudFallbackError(f"Cloud connection failed: {e}")
401
+ except requests.exceptions.RequestException as e:
402
+ raise CloudFallbackError(f"Cloud request failed: {e}")
403
+
404
+
101
405
  def _is_wsl_environment() -> bool:
102
406
  """
103
407
  Detect if we're running in WSL (Windows Subsystem for Linux) environment.
@@ -170,49 +474,26 @@ def _get_environment_info() -> Dict[str, str]:
170
474
 
171
475
  # --- Constants and Configuration ---
172
476
 
173
- # Determine project root: 1. PDD_PATH env var, 2. Search upwards from script, 3. CWD
174
- PROJECT_ROOT = None
477
+ # Determine project root: use PathResolver to ignore package-root PDD_PATH values.
175
478
  PDD_PATH_ENV = os.getenv("PDD_PATH")
176
-
177
479
  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
480
  try:
187
- # Start from the current working directory (where user is running PDD)
188
- current_dir = Path.cwd().resolve()
189
- # Look for project markers (e.g., .git, pyproject.toml, data/, .env)
190
- # Go up a maximum of 5 levels to prevent infinite loops
191
- for _ in range(5):
192
- has_git = (current_dir / ".git").exists()
193
- has_pyproject = (current_dir / "pyproject.toml").exists()
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
481
+ _path_from_env = Path(PDD_PATH_ENV).expanduser().resolve()
482
+ if not _path_from_env.is_dir():
483
+ warnings.warn(
484
+ f"PDD_PATH environment variable ('{PDD_PATH_ENV}') is set but not a valid directory. Attempting auto-detection."
485
+ )
486
+ except Exception as e:
487
+ warnings.warn(f"Error validating PDD_PATH environment variable: {e}")
206
488
 
207
- except Exception as e: # Catch potential permission errors etc.
208
- warnings.warn(f"Error during project root auto-detection from current working directory: {e}")
489
+ resolver = get_default_resolver()
490
+ PROJECT_ROOT = resolver.resolve_project_root()
491
+ PROJECT_ROOT_FROM_ENV = resolver.pdd_path_env is not None and PROJECT_ROOT == resolver.pdd_path_env
492
+ logger.debug(f"Using PROJECT_ROOT: {PROJECT_ROOT}")
209
493
 
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
494
 
495
+ # ENV_PATH is set after _is_env_path_package_dir is defined (see below)
214
496
 
215
- ENV_PATH = PROJECT_ROOT / ".env"
216
497
  # --- Determine LLM_MODEL_CSV_PATH ---
217
498
  # Prioritize ~/.pdd/llm_model.csv, then a project .pdd from the current CWD,
218
499
  # then PROJECT_ROOT (which may be set from PDD_PATH), else fall back to package.
@@ -272,11 +553,19 @@ def _is_env_path_package_dir(env_path: Path) -> bool:
272
553
  except Exception:
273
554
  return False
274
555
 
556
+ # ENV_PATH: Use CWD-based project root when PDD_PATH points to package directory
557
+ # This ensures .env is written to the user's project, not the installed package location
558
+ if _is_env_path_package_dir(PROJECT_ROOT):
559
+ ENV_PATH = project_root_from_cwd / ".env"
560
+ logger.debug(f"PDD_PATH points to package; using ENV_PATH from CWD: {ENV_PATH}")
561
+ else:
562
+ ENV_PATH = PROJECT_ROOT / ".env"
563
+
275
564
  # Selection order
276
565
  if user_model_csv_path.is_file():
277
566
  LLM_MODEL_CSV_PATH = user_model_csv_path
278
567
  logger.info(f"Using user-specific LLM model CSV: {LLM_MODEL_CSV_PATH}")
279
- elif (not _is_env_path_package_dir(PROJECT_ROOT)) and project_csv_from_env.is_file():
568
+ elif PROJECT_ROOT_FROM_ENV and project_csv_from_env.is_file():
280
569
  # Honor an explicitly-set PDD_PATH pointing to a real project directory
281
570
  LLM_MODEL_CSV_PATH = project_csv_from_env
282
571
  logger.info(f"Using project-specific LLM model CSV (from PDD_PATH): {LLM_MODEL_CSV_PATH}")
@@ -787,6 +1076,45 @@ def _sanitize_api_key(key_value: str) -> str:
787
1076
  return sanitized
788
1077
 
789
1078
 
1079
+ def _save_key_to_env_file(key_name: str, value: str, env_path: Path) -> None:
1080
+ """Save or update a key in the .env file.
1081
+
1082
+ - Replaces existing key in-place (no comment + append)
1083
+ - Removes old commented versions of the same key (Issue #183)
1084
+ - Preserves all other content
1085
+ """
1086
+ lines = []
1087
+ if env_path.exists():
1088
+ with open(env_path, 'r') as f:
1089
+ lines = f.readlines()
1090
+
1091
+ new_lines = []
1092
+ key_replaced = False
1093
+ prefix = f"{key_name}="
1094
+ prefix_spaced = f"{key_name} ="
1095
+
1096
+ for line in lines:
1097
+ stripped = line.strip()
1098
+ # Skip old commented versions of this key (cleanup accumulation)
1099
+ if stripped.startswith(f"# {prefix}") or stripped.startswith(f"# {prefix_spaced}"):
1100
+ continue
1101
+ elif stripped.startswith(prefix) or stripped.startswith(prefix_spaced):
1102
+ # Replace in-place
1103
+ new_lines.append(f'{key_name}="{value}"\n')
1104
+ key_replaced = True
1105
+ else:
1106
+ new_lines.append(line)
1107
+
1108
+ # Add key if not found
1109
+ if not key_replaced:
1110
+ if new_lines and not new_lines[-1].endswith('\n'):
1111
+ new_lines.append('\n')
1112
+ new_lines.append(f'{key_name}="{value}"\n')
1113
+
1114
+ with open(env_path, 'w') as f:
1115
+ f.writelines(new_lines)
1116
+
1117
+
790
1118
  def _ensure_api_key(model_info: Dict[str, Any], newly_acquired_keys: Dict[str, bool], verbose: bool) -> bool:
791
1119
  """Checks for API key in env, prompts user if missing, and updates .env."""
792
1120
  key_name = model_info.get('api_key')
@@ -807,6 +1135,12 @@ def _ensure_api_key(model_info: Dict[str, Any], newly_acquired_keys: Dict[str, b
807
1135
  return True
808
1136
  else:
809
1137
  logger.warning(f"API key environment variable '{key_name}' for model '{model_info.get('model')}' is not set.")
1138
+
1139
+ # Skip prompting if --force flag is set (non-interactive mode)
1140
+ if os.environ.get('PDD_FORCE'):
1141
+ logger.error(f"API key '{key_name}' not set. In --force mode, skipping interactive prompt.")
1142
+ return False
1143
+
810
1144
  try:
811
1145
  # Interactive prompt
812
1146
  user_provided_key = input(f"Please enter the API key for {key_name}: ").strip()
@@ -824,39 +1158,7 @@ def _ensure_api_key(model_info: Dict[str, Any], newly_acquired_keys: Dict[str, b
824
1158
 
825
1159
  # Update .env file
826
1160
  try:
827
- lines = []
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
-
1161
+ _save_key_to_env_file(key_name, user_provided_key, ENV_PATH)
860
1162
  logger.info(f"API key '{key_name}' saved to {ENV_PATH}.")
861
1163
  logger.warning("SECURITY WARNING: The API key has been saved to your .env file. "
862
1164
  "Ensure this file is kept secure and is included in your .gitignore.")
@@ -878,7 +1180,6 @@ def _ensure_api_key(model_info: Dict[str, Any], newly_acquired_keys: Dict[str, b
878
1180
  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
1181
  """Formats prompt and input into LiteLLM message format."""
880
1182
  try:
881
- prompt_template = PromptTemplate.from_template(prompt)
882
1183
  if use_batch_mode:
883
1184
  if not isinstance(input_data, list):
884
1185
  raise ValueError("input_json must be a list of dictionaries when use_batch_mode is True.")
@@ -886,16 +1187,16 @@ def _format_messages(prompt: str, input_data: Union[Dict[str, Any], List[Dict[st
886
1187
  for item in input_data:
887
1188
  if not isinstance(item, dict):
888
1189
  raise ValueError("Each item in input_json list must be a dictionary for batch mode.")
889
- formatted_prompt = prompt_template.format(**item)
1190
+ formatted_prompt = prompt.format(**item)
890
1191
  all_messages.append([{"role": "user", "content": formatted_prompt}])
891
1192
  return all_messages
892
1193
  else:
893
1194
  if not isinstance(input_data, dict):
894
1195
  raise ValueError("input_json must be a dictionary when use_batch_mode is False.")
895
- formatted_prompt = prompt_template.format(**input_data)
1196
+ formatted_prompt = prompt.format(**input_data)
896
1197
  return [{"role": "user", "content": formatted_prompt}]
897
1198
  except KeyError as e:
898
- raise ValueError(f"Prompt formatting error: Missing key {e} in input_json for prompt template.") from e
1199
+ raise ValueError(f"Prompt formatting error: Missing key {e} in input_json for prompt string.") from e
899
1200
  except Exception as e:
900
1201
  raise ValueError(f"Error formatting prompt: {e}") from e
901
1202
 
@@ -956,6 +1257,31 @@ def _looks_like_python_code(s: str) -> bool:
956
1257
  return any(indicator in s for indicator in code_indicators)
957
1258
 
958
1259
 
1260
+ # Field names known to contain prose text, not Python code
1261
+ # These are skipped during syntax validation to avoid false positives
1262
+ _PROSE_FIELD_NAMES = frozenset({
1263
+ 'reasoning', # PromptAnalysis - completeness reasoning
1264
+ 'explanation', # TrimResultsOutput, FixerOutput - prose explanations
1265
+ 'analysis', # DiffAnalysis, CodePatchResult - analysis text
1266
+ 'change_instructions', # ChangeInstruction, ConflictChange - instructions
1267
+ 'change_description', # DiffAnalysis - description of changes
1268
+ 'planned_modifications', # CodePatchResult - modification plans
1269
+ 'details', # VerificationOutput - issue details
1270
+ 'description', # General prose descriptions
1271
+ 'focus', # Focus descriptions
1272
+ 'file_summary', # FileSummary - prose summaries of file contents
1273
+ })
1274
+
1275
+
1276
+ def _is_prose_field_name(field_name: str) -> bool:
1277
+ """Check if a field name indicates it contains prose, not code.
1278
+
1279
+ Used to skip syntax validation on prose fields that may contain
1280
+ Python keywords (like 'return' or 'import') but are not actual code.
1281
+ """
1282
+ return field_name.lower() in _PROSE_FIELD_NAMES
1283
+
1284
+
959
1285
  def _repair_python_syntax(code: str) -> str:
960
1286
  """
961
1287
  Validate Python code syntax and attempt repairs if invalid.
@@ -1222,15 +1548,19 @@ def _unescape_code_newlines(obj: Any) -> Any:
1222
1548
  return obj
1223
1549
 
1224
1550
 
1225
- def _has_invalid_python_code(obj: Any) -> bool:
1551
+ def _has_invalid_python_code(obj: Any, field_name: str = "") -> bool:
1226
1552
  """
1227
1553
  Check if any code-like string fields have invalid Python syntax.
1228
1554
 
1229
1555
  This is used after _unescape_code_newlines to detect if repair failed
1230
1556
  and we should retry with cache disabled.
1231
1557
 
1558
+ Skips fields in _PROSE_FIELD_NAMES to avoid false positives on prose
1559
+ text that mentions code patterns (e.g., "ends on a return statement").
1560
+
1232
1561
  Args:
1233
1562
  obj: A Pydantic model, dict, list, or primitive value
1563
+ field_name: The name of the field being validated (used to skip prose)
1234
1564
 
1235
1565
  Returns:
1236
1566
  True if there are invalid code fields that couldn't be repaired
@@ -1241,6 +1571,9 @@ def _has_invalid_python_code(obj: Any) -> bool:
1241
1571
  return False
1242
1572
 
1243
1573
  if isinstance(obj, str):
1574
+ # Skip validation for known prose fields
1575
+ if _is_prose_field_name(field_name):
1576
+ return False
1244
1577
  if _looks_like_python_code(obj):
1245
1578
  try:
1246
1579
  ast.parse(obj)
@@ -1250,21 +1583,22 @@ def _has_invalid_python_code(obj: Any) -> bool:
1250
1583
  return False
1251
1584
 
1252
1585
  if isinstance(obj, BaseModel):
1253
- for field_name in obj.model_fields:
1254
- value = getattr(obj, field_name)
1255
- if _has_invalid_python_code(value):
1586
+ for name in obj.model_fields:
1587
+ value = getattr(obj, name)
1588
+ if _has_invalid_python_code(value, field_name=name):
1256
1589
  return True
1257
1590
  return False
1258
1591
 
1259
1592
  if isinstance(obj, dict):
1260
- for value in obj.values():
1261
- if _has_invalid_python_code(value):
1593
+ for key, value in obj.items():
1594
+ fname = key if isinstance(key, str) else ""
1595
+ if _has_invalid_python_code(value, field_name=fname):
1262
1596
  return True
1263
1597
  return False
1264
1598
 
1265
1599
  if isinstance(obj, list):
1266
1600
  for item in obj:
1267
- if _has_invalid_python_code(item):
1601
+ if _has_invalid_python_code(item, field_name=field_name):
1268
1602
  return True
1269
1603
  return False
1270
1604
 
@@ -1281,9 +1615,11 @@ def llm_invoke(
1281
1615
  verbose: bool = False,
1282
1616
  output_pydantic: Optional[Type[BaseModel]] = None,
1283
1617
  output_schema: Optional[Dict[str, Any]] = None,
1284
- time: float = 0.25,
1618
+ time: Optional[float] = 0.25,
1285
1619
  use_batch_mode: bool = False,
1286
1620
  messages: Optional[Union[List[Dict[str, str]], List[List[Dict[str, str]]]]] = None,
1621
+ language: Optional[str] = None,
1622
+ use_cloud: Optional[bool] = None,
1287
1623
  ) -> Dict[str, Any]:
1288
1624
  """
1289
1625
  Runs a prompt with given input using LiteLLM, handling model selection,
@@ -1301,6 +1637,7 @@ def llm_invoke(
1301
1637
  time: Relative thinking time (0-1, default 0.25).
1302
1638
  use_batch_mode: Use batch completion if True.
1303
1639
  messages: Pre-formatted list of messages (or list of lists for batch). If provided, ignores prompt and input_json.
1640
+ use_cloud: None=auto-detect (cloud if enabled, local if PDD_FORCE_LOCAL=1), True=force cloud, False=force local.
1304
1641
 
1305
1642
  Returns:
1306
1643
  Dictionary containing 'result', 'cost', 'model_name', 'thinking_output'.
@@ -1309,6 +1646,7 @@ def llm_invoke(
1309
1646
  ValueError: For invalid inputs or prompt formatting errors.
1310
1647
  FileNotFoundError: If llm_model.csv is missing.
1311
1648
  RuntimeError: If all candidate models fail.
1649
+ InsufficientCreditsError: If cloud execution fails due to insufficient credits.
1312
1650
  openai.*Error: If LiteLLM encounters API errors after retries.
1313
1651
  """
1314
1652
  # Set verbose logging if requested
@@ -1325,6 +1663,58 @@ def llm_invoke(
1325
1663
  logger.debug(f" time: {time}")
1326
1664
  logger.debug(f" use_batch_mode: {use_batch_mode}")
1327
1665
  logger.debug(f" messages: {'provided' if messages else 'None'}")
1666
+ logger.debug(f" use_cloud: {use_cloud}")
1667
+
1668
+ # --- 0. Cloud Execution Path ---
1669
+ # Determine cloud usage: explicit param > environment > default (local)
1670
+ if use_cloud is None:
1671
+ # Check environment for cloud preference
1672
+ # PDD_FORCE_LOCAL=1 forces local execution
1673
+ force_local = os.environ.get("PDD_FORCE_LOCAL", "").lower() in ("1", "true", "yes")
1674
+ if force_local:
1675
+ use_cloud = False
1676
+ else:
1677
+ # Try to use cloud if credentials are configured
1678
+ try:
1679
+ from pdd.core.cloud import CloudConfig
1680
+ use_cloud = CloudConfig.is_cloud_enabled()
1681
+ except ImportError:
1682
+ use_cloud = False
1683
+
1684
+ if use_cloud:
1685
+ from rich.console import Console
1686
+ console = Console()
1687
+
1688
+ if verbose:
1689
+ logger.debug("Attempting cloud execution...")
1690
+
1691
+ try:
1692
+ return _llm_invoke_cloud(
1693
+ prompt=prompt,
1694
+ input_json=input_json,
1695
+ strength=strength,
1696
+ temperature=temperature,
1697
+ verbose=verbose,
1698
+ output_pydantic=output_pydantic,
1699
+ output_schema=output_schema,
1700
+ time=time,
1701
+ use_batch_mode=use_batch_mode,
1702
+ messages=messages,
1703
+ language=language,
1704
+ )
1705
+ except CloudFallbackError as e:
1706
+ # Notify user and fall back to local execution
1707
+ console.print(f"[yellow]Cloud execution failed ({e}), falling back to local execution...[/yellow]")
1708
+ logger.warning(f"Cloud fallback: {e}")
1709
+ # Continue to local execution below
1710
+ except InsufficientCreditsError:
1711
+ # Re-raise credit errors - user needs to know
1712
+ raise
1713
+ except CloudInvocationError as e:
1714
+ # Non-recoverable cloud error - notify and fall back
1715
+ console.print(f"[yellow]Cloud error ({e}), falling back to local execution...[/yellow]")
1716
+ logger.warning(f"Cloud invocation error: {e}")
1717
+ # Continue to local execution below
1328
1718
 
1329
1719
  # --- 1. Load Environment & Validate Inputs ---
1330
1720
  # .env loading happens at module level
@@ -1349,6 +1739,10 @@ def llm_invoke(
1349
1739
  else:
1350
1740
  raise ValueError("Either 'messages' or both 'prompt' and 'input_json' must be provided.")
1351
1741
 
1742
+ # Handle None time (means "no reasoning requested")
1743
+ if time is None:
1744
+ time = 0.0
1745
+
1352
1746
  if not (0.0 <= strength <= 1.0):
1353
1747
  raise ValueError("'strength' must be between 0.0 and 1.0.")
1354
1748
  if not (0.0 <= temperature <= 2.0): # Common range for temperature
@@ -1454,6 +1848,8 @@ def llm_invoke(
1454
1848
  "messages": formatted_messages,
1455
1849
  # Use a local adjustable temperature to allow provider-specific fallbacks
1456
1850
  "temperature": current_temperature,
1851
+ # Retry on transient network errors (APIError, TimeoutError, ServiceUnavailableError)
1852
+ "num_retries": 2,
1457
1853
  }
1458
1854
 
1459
1855
  api_key_name_from_csv = model_info.get('api_key') # From CSV
@@ -1586,11 +1982,20 @@ def llm_invoke(
1586
1982
  if output_pydantic:
1587
1983
  if verbose:
1588
1984
  logger.info(f"[INFO] Requesting structured output (Pydantic: {output_pydantic.__name__}) for {model_name_litellm}")
1589
- # Use explicit json_object format with response_schema for better Gemini/Vertex AI compatibility
1590
- # Passing Pydantic class directly may not trigger native structured output for all providers
1985
+ # Use json_schema with strict=True to enforce ALL required fields are present
1986
+ # This prevents LLMs from omitting required fields when they think they're not needed
1987
+ schema = output_pydantic.model_json_schema()
1988
+ # Ensure all properties are in required array (OpenAI strict mode requirement)
1989
+ _ensure_all_properties_required(schema)
1990
+ # Add additionalProperties: false recursively for strict mode (required by OpenAI)
1991
+ _add_additional_properties_false(schema)
1591
1992
  response_format = {
1592
- "type": "json_object",
1593
- "response_schema": output_pydantic.model_json_schema()
1993
+ "type": "json_schema",
1994
+ "json_schema": {
1995
+ "name": output_pydantic.__name__,
1996
+ "schema": schema,
1997
+ "strict": True
1998
+ }
1594
1999
  }
1595
2000
  else: # output_schema is set
1596
2001
  if verbose:
@@ -1608,7 +2013,11 @@ def llm_invoke(
1608
2013
  "strict": False
1609
2014
  }
1610
2015
  }
1611
-
2016
+ # Ensure all properties are in required array (OpenAI strict mode requirement)
2017
+ _ensure_all_properties_required(response_format["json_schema"]["schema"])
2018
+ # Add additionalProperties: false recursively for strict mode (required by OpenAI)
2019
+ _add_additional_properties_false(response_format["json_schema"]["schema"])
2020
+
1612
2021
  litellm_kwargs["response_format"] = response_format
1613
2022
 
1614
2023
  # LM Studio requires "json_schema" format, not "json_object"
@@ -1792,8 +2201,10 @@ def llm_invoke(
1792
2201
  schema = output_schema
1793
2202
  name = "response"
1794
2203
 
1795
- # Add additionalProperties: false for strict mode (required by OpenAI)
1796
- schema['additionalProperties'] = False
2204
+ # Ensure all properties are in required array (OpenAI strict mode requirement)
2205
+ _ensure_all_properties_required(schema)
2206
+ # Add additionalProperties: false recursively for strict mode (required by OpenAI)
2207
+ _add_additional_properties_false(schema)
1797
2208
 
1798
2209
  # Use text.format with json_schema for structured output
1799
2210
  text_block = {
@@ -1941,6 +2352,12 @@ def llm_invoke(
1941
2352
  if verbose:
1942
2353
  logger.info(f"[SUCCESS] Invocation successful for {model_name_litellm} (took {end_time - start_time:.2f}s)")
1943
2354
 
2355
+ # Build retry kwargs with provider credentials from litellm_kwargs
2356
+ # Issue #185: Retry calls were missing vertex_location, vertex_project, etc.
2357
+ retry_provider_kwargs = {k: v for k, v in litellm_kwargs.items()
2358
+ if k in ('vertex_credentials', 'vertex_project', 'vertex_location',
2359
+ 'api_key', 'base_url', 'api_base')}
2360
+
1944
2361
  # --- 7. Process Response ---
1945
2362
  results = []
1946
2363
  thinking_outputs = []
@@ -1991,7 +2408,8 @@ def llm_invoke(
1991
2408
  messages=retry_messages,
1992
2409
  temperature=current_temperature,
1993
2410
  response_format=response_format,
1994
- **time_kwargs
2411
+ **time_kwargs,
2412
+ **retry_provider_kwargs # Issue #185: Pass Vertex AI credentials
1995
2413
  )
1996
2414
  # Re-enable cache - restore original configured cache (restore to original state, even if None)
1997
2415
  litellm.cache = configured_cache
@@ -2030,7 +2448,8 @@ def llm_invoke(
2030
2448
  messages=retry_messages,
2031
2449
  temperature=current_temperature,
2032
2450
  response_format=response_format,
2033
- **time_kwargs
2451
+ **time_kwargs,
2452
+ **retry_provider_kwargs # Issue #185: Pass Vertex AI credentials
2034
2453
  )
2035
2454
  # Re-enable cache
2036
2455
  litellm.cache = original_cache
@@ -2237,16 +2656,22 @@ def llm_invoke(
2237
2656
  logger.error(f"[ERROR] Failed to parse response into {target_name} for item {i}: {parse_error}")
2238
2657
  # Use the string that was last attempted for parsing in the error message
2239
2658
  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)) # CORRECTED (or use f-string)
2241
- results.append(f"ERROR: Failed to parse structured output. Raw: {repr(raw_result)}")
2242
- continue # Skip appending result below if parsing failed
2659
+ logger.error("[ERROR] Content attempted for parsing: %s", repr(error_content))
2660
+ # Issue #168: Raise SchemaValidationError to trigger model fallback
2661
+ # Previously this used `continue` which only skipped to the next batch item
2662
+ raise SchemaValidationError(
2663
+ f"Failed to parse response into {target_name}: {parse_error}",
2664
+ raw_response=raw_result,
2665
+ item_index=i
2666
+ ) from parse_error
2243
2667
 
2244
2668
  # Post-process: unescape newlines and repair Python syntax
2245
2669
  _unescape_code_newlines(parsed_result)
2246
2670
 
2247
2671
  # Check if code fields still have invalid Python syntax after repair
2248
2672
  # If so, retry without cache to get a fresh response
2249
- if _has_invalid_python_code(parsed_result):
2673
+ # Skip validation for non-Python languages to avoid false positives
2674
+ if language in (None, "python") and _has_invalid_python_code(parsed_result):
2250
2675
  logger.warning(f"[WARNING] Detected invalid Python syntax in code fields for item {i} after repair. Retrying with cache bypass...")
2251
2676
  if not use_batch_mode and prompt and input_json is not None:
2252
2677
  # Add a small variation to bypass cache
@@ -2261,7 +2686,8 @@ def llm_invoke(
2261
2686
  messages=retry_messages,
2262
2687
  temperature=current_temperature,
2263
2688
  response_format=response_format,
2264
- **time_kwargs
2689
+ **time_kwargs,
2690
+ **retry_provider_kwargs # Issue #185: Pass Vertex AI credentials
2265
2691
  )
2266
2692
  # Re-enable cache
2267
2693
  litellm.cache = original_cache
@@ -2377,6 +2803,14 @@ def llm_invoke(
2377
2803
  logger.warning(f"[AUTH ERROR] Authentication failed for {model_name_litellm} using existing key '{api_key_name}'. Trying next model.")
2378
2804
  break # Break inner loop, try next model candidate
2379
2805
 
2806
+ except SchemaValidationError as e:
2807
+ # Issue #168: Schema validation failures now trigger model fallback
2808
+ last_exception = e
2809
+ logger.warning(f"[SCHEMA ERROR] Validation failed for {model_name_litellm}: {e}. Trying next model.")
2810
+ if verbose:
2811
+ logger.debug(f"Raw response that failed validation: {repr(e.raw_response)}")
2812
+ break # Break inner loop, try next model candidate
2813
+
2380
2814
  except (openai.RateLimitError, openai.APITimeoutError, openai.APIConnectionError,
2381
2815
  openai.APIStatusError, openai.BadRequestError, openai.InternalServerError,
2382
2816
  Exception) as e: # Catch generic Exception last