ostruct-cli 0.8.8__py3-none-any.whl → 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. ostruct/cli/__init__.py +3 -15
  2. ostruct/cli/attachment_processor.py +455 -0
  3. ostruct/cli/attachment_template_bridge.py +973 -0
  4. ostruct/cli/cli.py +187 -33
  5. ostruct/cli/click_options.py +775 -692
  6. ostruct/cli/code_interpreter.py +195 -12
  7. ostruct/cli/commands/__init__.py +0 -3
  8. ostruct/cli/commands/run.py +289 -62
  9. ostruct/cli/config.py +23 -22
  10. ostruct/cli/constants.py +89 -0
  11. ostruct/cli/errors.py +191 -6
  12. ostruct/cli/explicit_file_processor.py +0 -15
  13. ostruct/cli/file_info.py +118 -14
  14. ostruct/cli/file_list.py +82 -1
  15. ostruct/cli/file_search.py +68 -2
  16. ostruct/cli/help_json.py +235 -0
  17. ostruct/cli/mcp_integration.py +13 -16
  18. ostruct/cli/params.py +217 -0
  19. ostruct/cli/plan_assembly.py +335 -0
  20. ostruct/cli/plan_printing.py +385 -0
  21. ostruct/cli/progress_reporting.py +8 -56
  22. ostruct/cli/quick_ref_help.py +128 -0
  23. ostruct/cli/rich_config.py +299 -0
  24. ostruct/cli/runner.py +397 -190
  25. ostruct/cli/security/__init__.py +2 -0
  26. ostruct/cli/security/allowed_checker.py +41 -0
  27. ostruct/cli/security/normalization.py +13 -9
  28. ostruct/cli/security/security_manager.py +558 -17
  29. ostruct/cli/security/types.py +15 -0
  30. ostruct/cli/template_debug.py +283 -261
  31. ostruct/cli/template_debug_help.py +233 -142
  32. ostruct/cli/template_env.py +46 -5
  33. ostruct/cli/template_filters.py +415 -8
  34. ostruct/cli/template_processor.py +240 -619
  35. ostruct/cli/template_rendering.py +49 -73
  36. ostruct/cli/template_validation.py +2 -1
  37. ostruct/cli/token_validation.py +35 -15
  38. ostruct/cli/types.py +15 -19
  39. ostruct/cli/unicode_compat.py +283 -0
  40. ostruct/cli/upload_manager.py +448 -0
  41. ostruct/cli/utils.py +30 -0
  42. ostruct/cli/validators.py +272 -54
  43. {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/METADATA +292 -126
  44. ostruct_cli-1.0.0.dist-info/RECORD +80 -0
  45. ostruct/cli/commands/quick_ref.py +0 -54
  46. ostruct/cli/template_optimizer.py +0 -478
  47. ostruct_cli-0.8.8.dist-info/RECORD +0 -71
  48. {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/LICENSE +0 -0
  49. {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/WHEEL +0 -0
  50. {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/entry_points.txt +0 -0
ostruct/cli/runner.py CHANGED
@@ -4,8 +4,10 @@ import copy
4
4
  import json
5
5
  import logging
6
6
  import os
7
+ import re
7
8
  from pathlib import Path
8
9
  from typing import Any, Dict, List, Optional, Type, Union
10
+ from urllib.parse import urlparse
9
11
 
10
12
  from openai import AsyncOpenAI, OpenAIError
11
13
  from openai_model_registry import ModelRegistry
@@ -14,11 +16,7 @@ from pydantic import BaseModel
14
16
  from .code_interpreter import CodeInterpreterManager
15
17
  from .config import OstructConfig
16
18
  from .cost_estimation import calculate_cost_estimate, format_cost_breakdown
17
- from .errors import (
18
- APIErrorMapper,
19
- CLIError,
20
- SchemaValidationError,
21
- )
19
+ from .errors import APIErrorMapper, CLIError, SchemaValidationError
22
20
  from .exit_codes import ExitCode
23
21
  from .explicit_file_processor import ProcessingResult
24
22
  from .file_search import FileSearchManager
@@ -257,8 +255,15 @@ async def process_code_interpreter_configuration(
257
255
  config = OstructConfig.load(config_path)
258
256
  ci_config = config.get_code_interpreter_config()
259
257
 
258
+ # Apply CLI parameter overrides to config
259
+ effective_ci_config = dict(ci_config) # Make a copy
260
+
261
+ # Override duplicate_outputs if CLI flag is provided
262
+ if args.get("ci_duplicate_outputs") is not None:
263
+ effective_ci_config["duplicate_outputs"] = args["ci_duplicate_outputs"]
264
+
260
265
  # Create Code Interpreter manager
261
- manager = CodeInterpreterManager(client, ci_config)
266
+ manager = CodeInterpreterManager(client, effective_ci_config)
262
267
 
263
268
  # Validate files before upload
264
269
  validation_errors = manager.validate_files_for_upload(files_to_upload)
@@ -372,11 +377,9 @@ async def process_file_search_configuration(
372
377
 
373
378
  try:
374
379
  # Get configuration parameters
375
- vector_store_name = args.get(
376
- "file_search_vector_store_name", "ostruct_search"
377
- )
378
- retry_count = args.get("file_search_retry_count", 3)
379
- timeout = args.get("file_search_timeout", 60.0)
380
+ vector_store_name = args.get("fs_store_name", "ostruct_search")
381
+ retry_count = args.get("fs_retries", 3)
382
+ timeout = args.get("fs_timeout", 60.0)
380
383
 
381
384
  # Create vector store with retry logic
382
385
  logger.debug(
@@ -560,8 +563,44 @@ async def create_structured_output(
560
563
  data, markdown_text = split_json_and_text(content)
561
564
  except ValueError:
562
565
  # Fallback to original parsing for non-fenced JSON
563
- data = json.loads(content.strip())
564
- markdown_text = ""
566
+ try:
567
+ data = json.loads(content.strip())
568
+ markdown_text = ""
569
+ except json.JSONDecodeError as json_error:
570
+ # DEFENSIVE PARSING: Handle Code Interpreter + Structured Outputs compatibility issue
571
+ # OpenAI's Code Interpreter can append commentary after valid JSON when using strict schemas,
572
+ # causing json.loads() to fail. This is a known intermittent issue documented in OpenAI forums.
573
+ # We extract the JSON portion and warn the user that the workaround was applied.
574
+ if tools and any(
575
+ tool.get("type") == "code_interpreter"
576
+ for tool in tools
577
+ ):
578
+ logger.debug(
579
+ "Code Interpreter detected with JSON parsing failure, attempting defensive parsing"
580
+ )
581
+
582
+ # Try to extract JSON from the beginning of the response
583
+ json_match = re.search(
584
+ r"\{.*?\}", content.strip(), re.DOTALL
585
+ )
586
+ if json_match:
587
+ try:
588
+ data = json.loads(json_match.group(0))
589
+ markdown_text = ""
590
+ logger.warning(
591
+ "Code Interpreter added extra content after JSON. "
592
+ "Extracted JSON successfully using defensive parsing. "
593
+ "This is a known intermittent issue with OpenAI's API."
594
+ )
595
+ except json.JSONDecodeError:
596
+ # Even defensive parsing failed, re-raise original error
597
+ raise json_error
598
+ else:
599
+ # No JSON pattern found, re-raise original error
600
+ raise json_error
601
+ else:
602
+ # Not using Code Interpreter, re-raise original error
603
+ raise json_error
565
604
 
566
605
  validated = output_schema.model_validate(data)
567
606
 
@@ -587,6 +626,10 @@ async def create_structured_output(
587
626
  logger.error(f"OpenAI API error mapped: {mapped_error}")
588
627
  raise mapped_error
589
628
 
629
+ # Re-raise already-mapped CLIError types (like SchemaValidationError)
630
+ if isinstance(e, CLIError):
631
+ raise
632
+
590
633
  # Handle special schema array error with detailed guidance
591
634
  if "Invalid schema for response_format" in str(
592
635
  e
@@ -709,10 +752,12 @@ async def _execute_two_pass_sentinel(
709
752
  if code_interpreter_info and code_interpreter_info.get("manager"):
710
753
  cm = code_interpreter_info["manager"]
711
754
  # Use output directory from config, fallback to args, then default
755
+ from .constants import DefaultPaths
756
+
712
757
  download_dir = (
713
758
  ci_config.get("output_directory")
714
- or args.get("code_interpreter_download_dir")
715
- or "./downloads"
759
+ or args.get("ci_download_dir")
760
+ or DefaultPaths.CODE_INTERPRETER_OUTPUT_DIR
716
761
  )
717
762
  logger.debug(f"Downloading files to: {download_dir}")
718
763
  downloaded_files = await cm.download_generated_files(
@@ -760,8 +805,34 @@ async def _execute_two_pass_sentinel(
760
805
  data_final, markdown_text = split_json_and_text(content)
761
806
  except ValueError:
762
807
  # Fallback to original parsing for non-fenced JSON
763
- data_final = json.loads(content.strip())
764
- markdown_text = ""
808
+ try:
809
+ data_final = json.loads(content.strip())
810
+ markdown_text = ""
811
+ except json.JSONDecodeError as json_error:
812
+ # DEFENSIVE PARSING: Handle Code Interpreter + Structured Outputs compatibility issue
813
+ # In two-pass mode, the second pass should be clean, but apply defensive parsing
814
+ # as a safety net in case the issue still occurs.
815
+ logger.debug(
816
+ "JSON parsing failure in two-pass mode, attempting defensive parsing"
817
+ )
818
+
819
+ # Try to extract JSON from the beginning of the response
820
+ json_match = re.search(r"\{.*?\}", content.strip(), re.DOTALL)
821
+ if json_match:
822
+ try:
823
+ data_final = json.loads(json_match.group(0))
824
+ markdown_text = ""
825
+ logger.warning(
826
+ "Two-pass mode: Extra content found after JSON in structured response. "
827
+ "Extracted JSON successfully using defensive parsing. "
828
+ "This should not happen in pass 2 - please report this issue."
829
+ )
830
+ except json.JSONDecodeError:
831
+ # Even defensive parsing failed, re-raise original error
832
+ raise json_error
833
+ else:
834
+ # No JSON pattern found, re-raise original error
835
+ raise json_error
765
836
 
766
837
  validated = output_model.model_validate(data_final)
767
838
 
@@ -917,6 +988,42 @@ async def execute_model(
917
988
  enabled_tools: set[str] = args.get("_enabled_tools", set()) # type: ignore[assignment]
918
989
  disabled_tools: set[str] = args.get("_disabled_tools", set()) # type: ignore[assignment]
919
990
 
991
+ # Initialize shared upload manager for multi-tool file sharing (T3.2)
992
+ shared_upload_manager = None
993
+
994
+ # Check if we have new attachment system with multi-tool attachments
995
+ from .attachment_processor import (
996
+ _extract_attachments_from_args,
997
+ _has_new_attachment_syntax,
998
+ )
999
+
1000
+ if _has_new_attachment_syntax(args):
1001
+ logger.debug(
1002
+ "Initializing shared upload manager for new attachment system"
1003
+ )
1004
+ from .attachment_processor import AttachmentProcessor
1005
+ from .upload_manager import SharedUploadManager
1006
+ from .validators import validate_security_manager
1007
+
1008
+ shared_upload_manager = SharedUploadManager(client)
1009
+
1010
+ # Get security manager for file validation
1011
+ security_manager = validate_security_manager(
1012
+ base_dir=args.get("base_dir"),
1013
+ allowed_dirs=args.get("allowed_dirs"),
1014
+ allowed_dir_file=args.get("allowed_dir_file"),
1015
+ )
1016
+
1017
+ # Process and register attachments
1018
+ processor = AttachmentProcessor(security_manager)
1019
+ attachments = _extract_attachments_from_args(args)
1020
+ processed_attachments = processor.process_attachments(attachments)
1021
+
1022
+ # Register all attachments with the shared manager
1023
+ shared_upload_manager.register_attachments(processed_attachments)
1024
+
1025
+ logger.debug("Registered attachments with shared upload manager")
1026
+
920
1027
  # Process MCP configuration if provided
921
1028
  # Apply universal tool toggle overrides for mcp
922
1029
  mcp_enabled_by_config = services.is_configured("mcp")
@@ -973,51 +1080,86 @@ async def execute_model(
973
1080
  # Fall back to routing-based enablement
974
1081
  ci_should_enable = bool(ci_enabled_by_routing)
975
1082
 
976
- if ci_should_enable and routing_result_typed:
977
- code_interpreter_files = routing_result_typed.validated_files.get(
978
- "code-interpreter", []
979
- )
980
- if code_interpreter_files:
981
- # Override args with routed files for Code Interpreter processing
982
- ci_args = dict(args)
983
- ci_args["code_interpreter_files"] = code_interpreter_files
984
- ci_args["code_interpreter_dirs"] = (
985
- []
986
- ) # Files already expanded from dirs
987
- ci_args["code_interpreter"] = (
988
- True # Enable for service container
1083
+ if ci_should_enable:
1084
+ code_interpreter_manager = None
1085
+ file_ids = []
1086
+
1087
+ if shared_upload_manager:
1088
+ # Use shared upload manager for new attachment system
1089
+ logger.debug(
1090
+ "Using shared upload manager for Code Interpreter"
989
1091
  )
1092
+ from .code_interpreter import CodeInterpreterManager
990
1093
 
991
- # Create temporary service container with updated args
992
- ci_services = ServiceContainer(client, ci_args) # type: ignore[arg-type]
993
- code_interpreter_manager = (
994
- await ci_services.get_code_interpreter_manager()
1094
+ code_interpreter_manager = CodeInterpreterManager(
1095
+ client, upload_manager=shared_upload_manager
995
1096
  )
996
- if code_interpreter_manager:
997
- # Get the uploaded file IDs from the manager
998
- if (
999
- hasattr(code_interpreter_manager, "uploaded_file_ids")
1000
- and code_interpreter_manager.uploaded_file_ids
1001
- ):
1002
- file_ids = code_interpreter_manager.uploaded_file_ids
1003
- # Cast to concrete CodeInterpreterManager to access build_tool_config
1004
- concrete_ci_manager = code_interpreter_manager
1005
- if hasattr(concrete_ci_manager, "build_tool_config"):
1006
- ci_tool_config = (
1007
- concrete_ci_manager.build_tool_config(file_ids)
1008
- )
1009
- logger.debug(
1010
- f"Code Interpreter tool config: {ci_tool_config}"
1097
+
1098
+ # Get file IDs from shared manager
1099
+ file_ids = (
1100
+ await code_interpreter_manager.get_files_from_shared_manager()
1101
+ )
1102
+ logger.debug(
1103
+ f"Got {len(file_ids)} file IDs from shared manager for CI"
1104
+ )
1105
+
1106
+ elif routing_result_typed:
1107
+ # Legacy routing system - process individual files
1108
+ code_interpreter_files = (
1109
+ routing_result_typed.validated_files.get(
1110
+ "code-interpreter", []
1111
+ )
1112
+ )
1113
+ if code_interpreter_files:
1114
+ # Override args with routed files for Code Interpreter processing
1115
+ ci_args = dict(args)
1116
+ ci_args["code_interpreter_files"] = code_interpreter_files
1117
+ ci_args["code_interpreter_dirs"] = (
1118
+ []
1119
+ ) # Files already expanded from dirs
1120
+ ci_args["code_interpreter"] = (
1121
+ True # Enable for service container
1122
+ )
1123
+
1124
+ # Create temporary service container with updated args
1125
+ ci_services = ServiceContainer(client, ci_args) # type: ignore[arg-type]
1126
+ code_interpreter_manager_protocol = (
1127
+ await ci_services.get_code_interpreter_manager()
1128
+ )
1129
+ # Cast to the concrete type we know we're getting
1130
+ ci_manager: Optional[CodeInterpreterManager] = None
1131
+ if code_interpreter_manager_protocol:
1132
+ from .code_interpreter import CodeInterpreterManager
1133
+
1134
+ ci_manager = code_interpreter_manager_protocol # type: ignore[assignment]
1135
+ if ci_manager:
1136
+ # Get the uploaded file IDs from the manager
1137
+ if (
1138
+ hasattr(ci_manager, "uploaded_file_ids")
1139
+ and ci_manager.uploaded_file_ids
1140
+ ):
1141
+ file_ids = ci_manager.uploaded_file_ids
1142
+ else:
1143
+ logger.warning(
1144
+ "Code Interpreter manager has no uploaded file IDs"
1011
1145
  )
1012
- code_interpreter_info = {
1013
- "manager": code_interpreter_manager,
1014
- "tool_config": ci_tool_config,
1015
- }
1016
- tools.append(ci_tool_config)
1017
- else:
1018
- logger.warning(
1019
- "Code Interpreter manager has no uploaded file IDs"
1020
- )
1146
+
1147
+ # Build tool configuration if we have a manager and files
1148
+ if code_interpreter_manager and file_ids:
1149
+ # Cast to concrete CodeInterpreterManager to access build_tool_config
1150
+ concrete_ci_manager = code_interpreter_manager
1151
+ if hasattr(concrete_ci_manager, "build_tool_config"):
1152
+ ci_tool_config = concrete_ci_manager.build_tool_config(
1153
+ file_ids
1154
+ )
1155
+ logger.debug(
1156
+ f"Code Interpreter tool config: {ci_tool_config}"
1157
+ )
1158
+ code_interpreter_info = {
1159
+ "manager": code_interpreter_manager,
1160
+ "tool_config": ci_tool_config,
1161
+ }
1162
+ tools.append(ci_tool_config)
1021
1163
 
1022
1164
  # Process File Search configuration if enabled
1023
1165
  # Apply universal tool toggle overrides for file-search
@@ -1042,61 +1184,86 @@ async def execute_model(
1042
1184
  # Fall back to routing-based enablement
1043
1185
  fs_should_enable = bool(fs_enabled_by_routing)
1044
1186
 
1045
- if fs_should_enable and routing_result_typed:
1046
- file_search_files = routing_result_typed.validated_files.get(
1047
- "file-search", []
1048
- )
1049
- if file_search_files:
1050
- # Override args with routed files for File Search processing
1051
- fs_args = dict(args)
1052
- fs_args["file_search_files"] = file_search_files
1053
- fs_args["file_search_dirs"] = (
1054
- []
1055
- ) # Files already expanded from dirs
1056
- fs_args["file_search"] = True # Enable for service container
1057
-
1058
- # Create temporary service container with updated args
1059
- fs_services = ServiceContainer(client, fs_args) # type: ignore[arg-type]
1060
- file_search_manager = (
1061
- await fs_services.get_file_search_manager()
1187
+ if fs_should_enable:
1188
+ file_search_manager = None
1189
+ vector_store_id = None
1190
+
1191
+ if shared_upload_manager:
1192
+ # Use shared upload manager for new attachment system
1193
+ logger.debug("Using shared upload manager for File Search")
1194
+ from .file_search import FileSearchManager
1195
+
1196
+ file_search_manager = FileSearchManager(
1197
+ client, upload_manager=shared_upload_manager
1062
1198
  )
1063
- if file_search_manager:
1064
- # Get the vector store ID from the manager's created vector stores
1065
- # The most recent one should be the one we need
1066
- if (
1067
- hasattr(file_search_manager, "created_vector_stores")
1068
- and file_search_manager.created_vector_stores
1069
- ):
1070
- vector_store_id = (
1071
- file_search_manager.created_vector_stores[-1]
1072
- )
1073
- # Cast to concrete FileSearchManager to access build_tool_config
1074
- concrete_fs_manager = file_search_manager
1075
- if hasattr(concrete_fs_manager, "build_tool_config"):
1076
- fs_tool_config = (
1077
- concrete_fs_manager.build_tool_config(
1078
- vector_store_id
1079
- )
1080
- )
1081
- logger.debug(
1082
- f"File Search tool config: {fs_tool_config}"
1199
+
1200
+ # Create vector store with files from shared manager
1201
+ vector_store_id = await file_search_manager.create_vector_store_from_shared_manager(
1202
+ "ostruct_vector_store"
1203
+ )
1204
+ logger.debug(
1205
+ f"Created vector store {vector_store_id} from shared manager"
1206
+ )
1207
+
1208
+ elif routing_result_typed:
1209
+ # Legacy routing system - process individual files
1210
+ file_search_files = routing_result_typed.validated_files.get(
1211
+ "file-search", []
1212
+ )
1213
+ if file_search_files:
1214
+ # Override args with routed files for File Search processing
1215
+ fs_args = dict(args)
1216
+ fs_args["file_search_files"] = file_search_files
1217
+ fs_args["file_search_dirs"] = (
1218
+ []
1219
+ ) # Files already expanded from dirs
1220
+ fs_args["file_search"] = (
1221
+ True # Enable for service container
1222
+ )
1223
+
1224
+ # Create temporary service container with updated args
1225
+ fs_services = ServiceContainer(client, fs_args) # type: ignore[arg-type]
1226
+ file_search_manager_protocol = (
1227
+ await fs_services.get_file_search_manager()
1228
+ )
1229
+ # Cast to the concrete type we know we're getting
1230
+ fs_manager: Optional[FileSearchManager] = None
1231
+ if file_search_manager_protocol:
1232
+ from .file_search import FileSearchManager
1233
+
1234
+ fs_manager = file_search_manager_protocol # type: ignore[assignment]
1235
+ if fs_manager:
1236
+ # Get the vector store ID from the manager's created vector stores
1237
+ # The most recent one should be the one we need
1238
+ if (
1239
+ hasattr(fs_manager, "created_vector_stores")
1240
+ and fs_manager.created_vector_stores
1241
+ ):
1242
+ vector_store_id = fs_manager.created_vector_stores[
1243
+ -1
1244
+ ]
1245
+ else:
1246
+ logger.warning(
1247
+ "File Search manager has no created vector stores"
1083
1248
  )
1084
- file_search_info = {
1085
- "manager": file_search_manager,
1086
- "tool_config": fs_tool_config,
1087
- }
1088
- tools.append(fs_tool_config)
1089
- else:
1090
- logger.warning(
1091
- "File Search manager has no created vector stores"
1092
- )
1093
1249
 
1094
- # Process Web Search configuration if enabled
1095
- # Check CLI flags first, then fall back to config defaults
1096
- web_search_from_cli = args.get("web_search", False)
1097
- no_web_search_from_cli = args.get("no_web_search", False)
1250
+ # Build tool configuration if we have a manager and vector store
1251
+ if file_search_manager and vector_store_id:
1252
+ # Cast to concrete FileSearchManager to access build_tool_config
1253
+ concrete_fs_manager = file_search_manager
1254
+ if hasattr(concrete_fs_manager, "build_tool_config"):
1255
+ fs_tool_config = concrete_fs_manager.build_tool_config(
1256
+ vector_store_id
1257
+ )
1258
+ logger.debug(f"File Search tool config: {fs_tool_config}")
1259
+ file_search_info = {
1260
+ "manager": file_search_manager,
1261
+ "tool_config": fs_tool_config,
1262
+ }
1263
+ tools.append(fs_tool_config)
1098
1264
 
1099
- # Load configuration to check defaults
1265
+ # Process Web Search configuration if enabled
1266
+ # Apply universal tool toggle overrides for web-search
1100
1267
  from typing import cast
1101
1268
 
1102
1269
  config_path = cast(Union[str, Path, None], args.get("config"))
@@ -1113,12 +1280,6 @@ async def execute_model(
1113
1280
  # Universal --disable-tool web-search takes highest precedence
1114
1281
  web_search_enabled = False
1115
1282
  logger.debug("Web search disabled via --disable-tool")
1116
- elif web_search_from_cli:
1117
- # Explicit --web-search flag takes precedence
1118
- web_search_enabled = True
1119
- elif no_web_search_from_cli:
1120
- # Explicit --no-web-search flag disables
1121
- web_search_enabled = False
1122
1283
  else:
1123
1284
  # Use config default
1124
1285
  web_search_enabled = web_search_config.enable_by_default
@@ -1138,7 +1299,8 @@ async def execute_model(
1138
1299
 
1139
1300
  # Check for Azure OpenAI endpoint guard-rail
1140
1301
  api_base = os.getenv("OPENAI_API_BASE", "")
1141
- if "azure.com" in api_base.lower():
1302
+ hostname = urlparse(api_base).hostname or ""
1303
+ if hostname.endswith("azure.com"):
1142
1304
  logger.warning(
1143
1305
  "Web search is not currently supported or may be unreliable with Azure OpenAI endpoints and has been disabled."
1144
1306
  )
@@ -1147,40 +1309,37 @@ async def execute_model(
1147
1309
  "type": "web_search_preview"
1148
1310
  }
1149
1311
 
1150
- # Add user_location if provided via CLI or config
1151
- user_country = args.get("user_country")
1152
- user_city = args.get("user_city")
1153
- user_region = args.get("user_region")
1312
+ # Get user location from CLI args or config
1313
+ ws_country = args.get("ws_country")
1314
+ ws_city = args.get("ws_city")
1315
+ ws_region = args.get("ws_region")
1154
1316
 
1155
- # Fall back to config if not provided via CLI
1317
+ # Use config defaults if CLI args not provided
1156
1318
  if (
1157
- not any([user_country, user_city, user_region])
1319
+ not any([ws_country, ws_city, ws_region])
1158
1320
  and web_search_config.user_location
1159
1321
  ):
1160
- user_country = web_search_config.user_location.country
1161
- user_city = web_search_config.user_location.city
1162
- user_region = web_search_config.user_location.region
1322
+ ws_country = web_search_config.user_location.country
1323
+ ws_city = web_search_config.user_location.city
1324
+ ws_region = web_search_config.user_location.region
1163
1325
 
1164
- if user_country or user_city or user_region:
1326
+ if ws_country or ws_city or ws_region:
1165
1327
  user_location: Dict[str, Any] = {"type": "approximate"}
1166
- if user_country:
1167
- user_location["country"] = user_country
1168
- if user_city:
1169
- user_location["city"] = user_city
1170
- if user_region:
1171
- user_location["region"] = user_region
1172
-
1328
+ if ws_country:
1329
+ user_location["country"] = ws_country
1330
+ if ws_city:
1331
+ user_location["city"] = ws_city
1332
+ if ws_region:
1333
+ user_location["region"] = ws_region
1173
1334
  web_tool_config["user_location"] = user_location
1174
1335
 
1175
- # Add search_context_size if provided via CLI or config
1176
- search_context_size = (
1177
- args.get("search_context_size")
1336
+ # Add ws_context_size if provided via CLI or config
1337
+ ws_context_size = (
1338
+ args.get("ws_context_size")
1178
1339
  or web_search_config.search_context_size
1179
1340
  )
1180
- if search_context_size:
1181
- web_tool_config["search_context_size"] = (
1182
- search_context_size
1183
- )
1341
+ if ws_context_size:
1342
+ web_tool_config["search_context_size"] = ws_context_size
1184
1343
 
1185
1344
  tools.append(web_tool_config)
1186
1345
  logger.debug(f"Web Search tool config: {web_tool_config}")
@@ -1286,8 +1445,11 @@ async def execute_model(
1286
1445
  api_response = getattr(last_response, "_api_response")
1287
1446
  # Responses API has 'output' attribute, not 'messages'
1288
1447
  if hasattr(api_response, "output"):
1448
+ from .constants import DefaultPaths
1449
+
1289
1450
  download_dir = args.get(
1290
- "code_interpreter_download_dir", "./downloads"
1451
+ "ci_download_dir",
1452
+ DefaultPaths.CODE_INTERPRETER_OUTPUT_DIR,
1291
1453
  )
1292
1454
  manager = code_interpreter_info["manager"]
1293
1455
 
@@ -1366,14 +1528,15 @@ async def execute_model(
1366
1528
  ) as e:
1367
1529
  logger.error("API error: %s", str(e))
1368
1530
  raise CLIError(str(e), exit_code=ExitCode.API_ERROR)
1531
+ except CLIError:
1532
+ # Re-raise CLIError types (like SchemaValidationError) without wrapping
1533
+ raise
1369
1534
  except Exception as e:
1370
1535
  logger.exception("Unexpected error during execution")
1371
1536
  raise CLIError(str(e), exit_code=ExitCode.UNKNOWN_ERROR)
1372
1537
  finally:
1373
1538
  # Clean up Code Interpreter files if requested
1374
- if code_interpreter_info and args.get(
1375
- "code_interpreter_cleanup", True
1376
- ):
1539
+ if code_interpreter_info and args.get("ci_cleanup", True):
1377
1540
  try:
1378
1541
  manager = code_interpreter_info["manager"]
1379
1542
  # Type ignore since we know this is a CodeInterpreterManager
@@ -1385,7 +1548,7 @@ async def execute_model(
1385
1548
  )
1386
1549
 
1387
1550
  # Clean up File Search resources if requested
1388
- if file_search_info and args.get("file_search_cleanup", True):
1551
+ if file_search_info and args.get("fs_cleanup", True):
1389
1552
  try:
1390
1553
  manager = file_search_info["manager"]
1391
1554
  # Type ignore since we know this is a FileSearchManager
@@ -1424,21 +1587,15 @@ async def run_cli_async(args: CLIParams) -> ExitCode:
1424
1587
 
1425
1588
  configure_debug_logging(
1426
1589
  verbose=bool(args.get("verbose", False)),
1427
- debug=bool(args.get("debug", False))
1428
- or bool(args.get("debug_templates", False)),
1590
+ debug=bool(args.get("debug", False)),
1429
1591
  )
1430
1592
 
1431
- # 0a. Handle Debug Help Request
1432
- if args.get("help_debug", False):
1433
- from .template_debug_help import show_template_debug_help
1434
-
1435
- show_template_debug_help()
1436
- return ExitCode.SUCCESS
1593
+ # 0a. Help Debug Request is now handled by callback in click_options.py
1437
1594
 
1438
1595
  # 0. Configure Progress Reporting
1439
1596
  configure_progress_reporter(
1440
1597
  verbose=args.get("verbose", False),
1441
- progress_level=args.get("progress_level", "basic"),
1598
+ progress=args.get("progress", "basic"),
1442
1599
  )
1443
1600
  progress_reporter = get_progress_reporter()
1444
1601
 
@@ -1527,20 +1684,6 @@ async def run_cli_async(args: CLIParams) -> ExitCode:
1527
1684
  token_limit=128000, # Default fallback
1528
1685
  )
1529
1686
 
1530
- # 3a. Web Search Compatibility Validation
1531
- if args.get("web_search", False) and not args.get(
1532
- "no_web_search", False
1533
- ):
1534
- from .model_validation import validate_web_search_compatibility
1535
-
1536
- compatibility_warning = validate_web_search_compatibility(
1537
- args["model"], True
1538
- )
1539
- if compatibility_warning:
1540
- logger.warning(compatibility_warning)
1541
- # For production usage, consider making this an error instead of warning
1542
- # raise CLIError(compatibility_warning, exit_code=ExitCode.VALIDATION_ERROR)
1543
-
1544
1687
  # 4. Dry Run Output Phase - Moved after all validations
1545
1688
  if args.get("dry_run", False):
1546
1689
  report_success(
@@ -1576,16 +1719,16 @@ async def run_cli_async(args: CLIParams) -> ExitCode:
1576
1719
  show_template_content(
1577
1720
  system_prompt=system_prompt,
1578
1721
  user_prompt=user_prompt,
1579
- show_templates=bool(args.get("show_templates", False)),
1580
- debug=bool(args.get("debug", False))
1581
- or bool(args.get("debug_templates", False)),
1722
+ debug=bool(args.get("debug", False)),
1582
1723
  )
1583
1724
 
1584
1725
  # Legacy verbose support for backward compatibility
1726
+ from .template_debug import TDCap, is_capacity_active
1727
+
1585
1728
  if (
1586
1729
  args.get("verbose", False)
1587
1730
  and not args.get("debug", False)
1588
- and not args.get("show_templates", False)
1731
+ and not is_capacity_active(TDCap.POST_EXPAND)
1589
1732
  ):
1590
1733
  logger.info("\nSystem Prompt:")
1591
1734
  logger.info("-" * 40)
@@ -1677,23 +1820,87 @@ class OstructRunner:
1677
1820
  Returns:
1678
1821
  Dictionary containing configuration information
1679
1822
  """
1823
+ # Check for new attachment system
1824
+ has_new_attachments = self._has_new_attachment_syntax()
1825
+
1826
+ if has_new_attachments:
1827
+ attachment_summary = self._get_attachment_summary()
1828
+ return {
1829
+ "model": self.args.get("model"),
1830
+ "dry_run": self.args.get("dry_run", False),
1831
+ "verbose": self.args.get("verbose", False),
1832
+ "mcp_servers": len(self.args.get("mcp_servers", [])),
1833
+ "attachment_system": "new",
1834
+ "attachments": attachment_summary,
1835
+ "template_source": (
1836
+ "file" if self.args.get("task_file") else "string"
1837
+ ),
1838
+ "schema_source": (
1839
+ "file" if self.args.get("schema_file") else "inline"
1840
+ ),
1841
+ }
1842
+ else:
1843
+ # Legacy configuration summary
1844
+ return {
1845
+ "model": self.args.get("model"),
1846
+ "dry_run": self.args.get("dry_run", False),
1847
+ "verbose": self.args.get("verbose", False),
1848
+ "mcp_servers": len(self.args.get("mcp_servers", [])),
1849
+ "attachment_system": "legacy",
1850
+ "code_interpreter_enabled": bool(
1851
+ self.args.get("code_interpreter_files")
1852
+ or self.args.get("code_interpreter_dirs")
1853
+ ),
1854
+ "file_search_enabled": bool(
1855
+ self.args.get("file_search_files")
1856
+ or self.args.get("file_search_dirs")
1857
+ ),
1858
+ "template_source": (
1859
+ "file" if self.args.get("task_file") else "string"
1860
+ ),
1861
+ "schema_source": (
1862
+ "file" if self.args.get("schema_file") else "inline"
1863
+ ),
1864
+ }
1865
+
1866
+ def _has_new_attachment_syntax(self) -> bool:
1867
+ """Check if CLI args contain new attachment syntax.
1868
+
1869
+ Returns:
1870
+ True if new attachment syntax is present
1871
+ """
1872
+ new_syntax_keys = ["attaches", "dirs", "collects"]
1873
+ return any(self.args.get(key) for key in new_syntax_keys)
1874
+
1875
+ def _get_attachment_summary(self) -> Dict[str, Any]:
1876
+ """Get summary of new-style attachments.
1877
+
1878
+ Returns:
1879
+ Dictionary containing attachment summary
1880
+ """
1881
+ total_attachments = 0
1882
+ targets_used = set()
1883
+
1884
+ for key in ["attaches", "dirs", "collects"]:
1885
+ attachments = self.args.get(key, [])
1886
+ if isinstance(attachments, list):
1887
+ total_attachments += len(attachments)
1888
+
1889
+ for attachment in attachments:
1890
+ if isinstance(attachment, dict):
1891
+ targets = attachment.get("targets", [])
1892
+ if isinstance(targets, list):
1893
+ targets_used.update(targets)
1894
+
1895
+ # Helper function to safely get list length
1896
+ def safe_len(key: str) -> int:
1897
+ value = self.args.get(key, [])
1898
+ return len(value) if isinstance(value, list) else 0
1899
+
1680
1900
  return {
1681
- "model": self.args.get("model"),
1682
- "dry_run": self.args.get("dry_run", False),
1683
- "verbose": self.args.get("verbose", False),
1684
- "mcp_servers": len(self.args.get("mcp_servers", [])),
1685
- "code_interpreter_enabled": bool(
1686
- self.args.get("code_interpreter_files")
1687
- or self.args.get("code_interpreter_dirs")
1688
- ),
1689
- "file_search_enabled": bool(
1690
- self.args.get("file_search_files")
1691
- or self.args.get("file_search_dirs")
1692
- ),
1693
- "template_source": (
1694
- "file" if self.args.get("task_file") else "string"
1695
- ),
1696
- "schema_source": (
1697
- "file" if self.args.get("schema_file") else "inline"
1698
- ),
1901
+ "total_attachments": total_attachments,
1902
+ "targets_used": list(targets_used),
1903
+ "attach_count": safe_len("attaches"),
1904
+ "dir_count": safe_len("dirs"),
1905
+ "collect_count": safe_len("collects"),
1699
1906
  }