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.
- pdd/__init__.py +38 -6
- pdd/agentic_bug.py +323 -0
- pdd/agentic_bug_orchestrator.py +506 -0
- pdd/agentic_change.py +231 -0
- pdd/agentic_change_orchestrator.py +537 -0
- pdd/agentic_common.py +533 -770
- pdd/agentic_crash.py +2 -1
- pdd/agentic_e2e_fix.py +319 -0
- pdd/agentic_e2e_fix_orchestrator.py +582 -0
- pdd/agentic_fix.py +118 -3
- pdd/agentic_update.py +27 -9
- pdd/agentic_verify.py +3 -2
- pdd/architecture_sync.py +565 -0
- pdd/auth_service.py +210 -0
- pdd/auto_deps_main.py +63 -53
- pdd/auto_include.py +236 -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 +113 -48
- pdd/commands/auth.py +309 -0
- pdd/commands/connect.py +358 -0
- pdd/commands/fix.py +155 -114
- pdd/commands/generate.py +5 -0
- pdd/commands/maintenance.py +3 -2
- pdd/commands/misc.py +8 -0
- pdd/commands/modify.py +225 -163
- pdd/commands/sessions.py +284 -0
- pdd/commands/utility.py +12 -7
- 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 +44 -7
- pdd/core/cloud.py +237 -0
- pdd/core/dump.py +68 -20
- 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 +208 -6
- 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-CUWd8al1.js +450 -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 +531 -97
- pdd/load_prompt_template.py +15 -34
- pdd/operation_log.py +342 -0
- pdd/path_resolution.py +140 -0
- pdd/postprocess.py +122 -97
- 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 +140 -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 +19 -1
- pdd/prompts/generate_test_from_example_LLM.prompt +366 -0
- pdd/prompts/insert_includes_LLM.prompt +262 -252
- pdd/prompts/prompt_code_diff_LLM.prompt +123 -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 +1347 -0
- pdd/server/routes/websocket.py +473 -0
- pdd/server/security.py +243 -0
- pdd/server/terminal_spawner.py +217 -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 +289 -211
- pdd/sync_order.py +304 -0
- 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 +68 -26
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/METADATA +15 -10
- pdd_cli-0.0.121.dist-info/RECORD +229 -0
- pdd_cli-0.0.90.dist-info/RECORD +0 -153
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/WHEEL +0 -0
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/entry_points.txt +0 -0
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/licenses/LICENSE +0 -0
- {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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
208
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
1254
|
-
value = getattr(obj,
|
|
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.
|
|
1261
|
-
if
|
|
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
|
|
1590
|
-
#
|
|
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": "
|
|
1593
|
-
"
|
|
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
|
-
#
|
|
1796
|
-
schema
|
|
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))
|
|
2241
|
-
|
|
2242
|
-
continue
|
|
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
|
-
|
|
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
|