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.
Files changed (144) hide show
  1. pdd/__init__.py +38 -6
  2. pdd/agentic_bug.py +323 -0
  3. pdd/agentic_bug_orchestrator.py +497 -0
  4. pdd/agentic_change.py +231 -0
  5. pdd/agentic_change_orchestrator.py +526 -0
  6. pdd/agentic_common.py +521 -786
  7. pdd/agentic_e2e_fix.py +319 -0
  8. pdd/agentic_e2e_fix_orchestrator.py +426 -0
  9. pdd/agentic_fix.py +118 -3
  10. pdd/agentic_update.py +25 -8
  11. pdd/architecture_sync.py +565 -0
  12. pdd/auth_service.py +210 -0
  13. pdd/auto_deps_main.py +63 -53
  14. pdd/auto_include.py +185 -3
  15. pdd/auto_update.py +125 -47
  16. pdd/bug_main.py +195 -23
  17. pdd/cmd_test_main.py +345 -197
  18. pdd/code_generator.py +4 -2
  19. pdd/code_generator_main.py +118 -32
  20. pdd/commands/__init__.py +6 -0
  21. pdd/commands/analysis.py +87 -29
  22. pdd/commands/auth.py +309 -0
  23. pdd/commands/connect.py +290 -0
  24. pdd/commands/fix.py +136 -113
  25. pdd/commands/maintenance.py +3 -2
  26. pdd/commands/misc.py +8 -0
  27. pdd/commands/modify.py +190 -164
  28. pdd/commands/sessions.py +284 -0
  29. pdd/construct_paths.py +334 -32
  30. pdd/context_generator_main.py +167 -170
  31. pdd/continue_generation.py +6 -3
  32. pdd/core/__init__.py +33 -0
  33. pdd/core/cli.py +27 -3
  34. pdd/core/cloud.py +237 -0
  35. pdd/core/errors.py +4 -0
  36. pdd/core/remote_session.py +61 -0
  37. pdd/crash_main.py +219 -23
  38. pdd/data/llm_model.csv +4 -4
  39. pdd/docs/prompting_guide.md +864 -0
  40. pdd/docs/whitepaper_with_benchmarks/data_and_functions/benchmark_analysis.py +495 -0
  41. pdd/docs/whitepaper_with_benchmarks/data_and_functions/creation_compare.py +528 -0
  42. pdd/fix_code_loop.py +208 -34
  43. pdd/fix_code_module_errors.py +6 -2
  44. pdd/fix_error_loop.py +291 -38
  45. pdd/fix_main.py +204 -4
  46. pdd/fix_verification_errors_loop.py +235 -26
  47. pdd/fix_verification_main.py +269 -83
  48. pdd/frontend/dist/assets/index-B5DZHykP.css +1 -0
  49. pdd/frontend/dist/assets/index-DQ3wkeQ2.js +449 -0
  50. pdd/frontend/dist/index.html +376 -0
  51. pdd/frontend/dist/logo.svg +33 -0
  52. pdd/generate_output_paths.py +46 -5
  53. pdd/generate_test.py +212 -151
  54. pdd/get_comment.py +19 -44
  55. pdd/get_extension.py +8 -9
  56. pdd/get_jwt_token.py +309 -20
  57. pdd/get_language.py +8 -7
  58. pdd/get_run_command.py +7 -5
  59. pdd/insert_includes.py +2 -1
  60. pdd/llm_invoke.py +459 -95
  61. pdd/load_prompt_template.py +15 -34
  62. pdd/path_resolution.py +140 -0
  63. pdd/postprocess.py +4 -1
  64. pdd/preprocess.py +68 -12
  65. pdd/preprocess_main.py +33 -1
  66. pdd/prompts/agentic_bug_step10_pr_LLM.prompt +182 -0
  67. pdd/prompts/agentic_bug_step1_duplicate_LLM.prompt +73 -0
  68. pdd/prompts/agentic_bug_step2_docs_LLM.prompt +129 -0
  69. pdd/prompts/agentic_bug_step3_triage_LLM.prompt +95 -0
  70. pdd/prompts/agentic_bug_step4_reproduce_LLM.prompt +97 -0
  71. pdd/prompts/agentic_bug_step5_root_cause_LLM.prompt +123 -0
  72. pdd/prompts/agentic_bug_step6_test_plan_LLM.prompt +107 -0
  73. pdd/prompts/agentic_bug_step7_generate_LLM.prompt +172 -0
  74. pdd/prompts/agentic_bug_step8_verify_LLM.prompt +119 -0
  75. pdd/prompts/agentic_bug_step9_e2e_test_LLM.prompt +289 -0
  76. pdd/prompts/agentic_change_step10_identify_issues_LLM.prompt +1006 -0
  77. pdd/prompts/agentic_change_step11_fix_issues_LLM.prompt +984 -0
  78. pdd/prompts/agentic_change_step12_create_pr_LLM.prompt +131 -0
  79. pdd/prompts/agentic_change_step1_duplicate_LLM.prompt +73 -0
  80. pdd/prompts/agentic_change_step2_docs_LLM.prompt +101 -0
  81. pdd/prompts/agentic_change_step3_research_LLM.prompt +126 -0
  82. pdd/prompts/agentic_change_step4_clarify_LLM.prompt +164 -0
  83. pdd/prompts/agentic_change_step5_docs_change_LLM.prompt +981 -0
  84. pdd/prompts/agentic_change_step6_devunits_LLM.prompt +1005 -0
  85. pdd/prompts/agentic_change_step7_architecture_LLM.prompt +1044 -0
  86. pdd/prompts/agentic_change_step8_analyze_LLM.prompt +1027 -0
  87. pdd/prompts/agentic_change_step9_implement_LLM.prompt +1077 -0
  88. pdd/prompts/agentic_e2e_fix_step1_unit_tests_LLM.prompt +90 -0
  89. pdd/prompts/agentic_e2e_fix_step2_e2e_tests_LLM.prompt +91 -0
  90. pdd/prompts/agentic_e2e_fix_step3_root_cause_LLM.prompt +89 -0
  91. pdd/prompts/agentic_e2e_fix_step4_fix_e2e_tests_LLM.prompt +96 -0
  92. pdd/prompts/agentic_e2e_fix_step5_identify_devunits_LLM.prompt +91 -0
  93. pdd/prompts/agentic_e2e_fix_step6_create_unit_tests_LLM.prompt +106 -0
  94. pdd/prompts/agentic_e2e_fix_step7_verify_tests_LLM.prompt +116 -0
  95. pdd/prompts/agentic_e2e_fix_step8_run_pdd_fix_LLM.prompt +120 -0
  96. pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt +146 -0
  97. pdd/prompts/agentic_fix_primary_LLM.prompt +2 -2
  98. pdd/prompts/agentic_update_LLM.prompt +192 -338
  99. pdd/prompts/auto_include_LLM.prompt +22 -0
  100. pdd/prompts/change_LLM.prompt +3093 -1
  101. pdd/prompts/detect_change_LLM.prompt +571 -14
  102. pdd/prompts/fix_code_module_errors_LLM.prompt +8 -0
  103. pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +1 -0
  104. pdd/prompts/generate_test_LLM.prompt +20 -1
  105. pdd/prompts/generate_test_from_example_LLM.prompt +115 -0
  106. pdd/prompts/insert_includes_LLM.prompt +262 -252
  107. pdd/prompts/prompt_code_diff_LLM.prompt +119 -0
  108. pdd/prompts/prompt_diff_LLM.prompt +82 -0
  109. pdd/remote_session.py +876 -0
  110. pdd/server/__init__.py +52 -0
  111. pdd/server/app.py +335 -0
  112. pdd/server/click_executor.py +587 -0
  113. pdd/server/executor.py +338 -0
  114. pdd/server/jobs.py +661 -0
  115. pdd/server/models.py +241 -0
  116. pdd/server/routes/__init__.py +31 -0
  117. pdd/server/routes/architecture.py +451 -0
  118. pdd/server/routes/auth.py +364 -0
  119. pdd/server/routes/commands.py +929 -0
  120. pdd/server/routes/config.py +42 -0
  121. pdd/server/routes/files.py +603 -0
  122. pdd/server/routes/prompts.py +1322 -0
  123. pdd/server/routes/websocket.py +473 -0
  124. pdd/server/security.py +243 -0
  125. pdd/server/terminal_spawner.py +209 -0
  126. pdd/server/token_counter.py +222 -0
  127. pdd/summarize_directory.py +236 -237
  128. pdd/sync_animation.py +8 -4
  129. pdd/sync_determine_operation.py +329 -47
  130. pdd/sync_main.py +272 -28
  131. pdd/sync_orchestration.py +136 -75
  132. pdd/template_expander.py +161 -0
  133. pdd/templates/architecture/architecture_json.prompt +41 -46
  134. pdd/trace.py +1 -1
  135. pdd/track_cost.py +0 -13
  136. pdd/unfinished_prompt.py +2 -1
  137. pdd/update_main.py +23 -5
  138. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/METADATA +15 -10
  139. pdd_cli-0.0.118.dist-info/RECORD +227 -0
  140. pdd_cli-0.0.90.dist-info/RECORD +0 -153
  141. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/WHEEL +0 -0
  142. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/entry_points.txt +0 -0
  143. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.118.dist-info}/licenses/LICENSE +0 -0
  144. {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: 1. PDD_PATH env var, 2. Search upwards from script, 3. CWD
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
- # 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
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
- except Exception as e: # Catch potential permission errors etc.
208
- warnings.warn(f"Error during project root auto-detection from current working directory: {e}")
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 (not _is_env_path_package_dir(PROJECT_ROOT)) and project_csv_from_env.is_file():
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
- 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
-
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 = prompt_template.format(**item)
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 = prompt_template.format(**input_data)
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 template.") from e
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 field_name in obj.model_fields:
1254
- value = getattr(obj, field_name)
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.values():
1261
- if _has_invalid_python_code(value):
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 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
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": "json_object",
1593
- "response_schema": output_pydantic.model_json_schema()
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)) # 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
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
- if _has_invalid_python_code(parsed_result):
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