ostruct-cli 0.8.2__py3-none-any.whl → 0.8.3__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.
@@ -29,6 +29,56 @@ CommandDecorator = Callable[[F], Command]
29
29
  DecoratedCommand = Union[Command, Callable[..., Any]]
30
30
 
31
31
 
32
+ def parse_feature_flags(
33
+ enabled_features: tuple[str, ...], disabled_features: tuple[str, ...]
34
+ ) -> dict[str, str]:
35
+ """Parse feature flags from CLI arguments.
36
+
37
+ Args:
38
+ enabled_features: Tuple of feature names to enable
39
+ disabled_features: Tuple of feature names to disable
40
+
41
+ Returns:
42
+ Dictionary mapping feature names to "on" or "off"
43
+
44
+ Raises:
45
+ click.BadParameter: If flag format is invalid or conflicts exist
46
+ """
47
+ parsed = {}
48
+
49
+ # Process enabled features
50
+ for feature in enabled_features:
51
+ feature = feature.strip()
52
+ if not feature:
53
+ raise click.BadParameter("Feature name cannot be empty")
54
+
55
+ # Validate known feature flags
56
+ if feature == "ci-download-hack":
57
+ parsed[feature] = "on"
58
+ else:
59
+ raise click.BadParameter(f"Unknown feature: {feature}")
60
+
61
+ # Process disabled features
62
+ for feature in disabled_features:
63
+ feature = feature.strip()
64
+ if not feature:
65
+ raise click.BadParameter("Feature name cannot be empty")
66
+
67
+ # Check for conflicts
68
+ if feature in parsed:
69
+ raise click.BadParameter(
70
+ f"Feature '{feature}' cannot be both enabled and disabled"
71
+ )
72
+
73
+ # Validate known feature flags
74
+ if feature == "ci-download-hack":
75
+ parsed[feature] = "off"
76
+ else:
77
+ raise click.BadParameter(f"Unknown feature: {feature}")
78
+
79
+ return parsed
80
+
81
+
32
82
  def debug_options(f: Union[Command, Callable[..., Any]]) -> Command:
33
83
  """Add debug-related CLI options."""
34
84
  # Initial conversion to Command if needed
@@ -573,6 +623,31 @@ def code_interpreter_options(f: Union[Command, Callable[..., Any]]) -> Command:
573
623
  help="""Clean up uploaded files after execution to save storage quota.""",
574
624
  )(cmd)
575
625
 
626
+ # Feature flags for experimental features
627
+ cmd = click.option(
628
+ "--enable-feature",
629
+ "enabled_features",
630
+ multiple=True,
631
+ metavar="<FEATURE>",
632
+ help="""🔧 [EXPERIMENTAL] Enable experimental features.
633
+ Available features:
634
+ • ci-download-hack - Enable two-pass sentinel mode for reliable Code Interpreter
635
+ file downloads with structured output. Overrides config file setting.
636
+ Example: --enable-feature ci-download-hack""",
637
+ )(cmd)
638
+
639
+ cmd = click.option(
640
+ "--disable-feature",
641
+ "disabled_features",
642
+ multiple=True,
643
+ metavar="<FEATURE>",
644
+ help="""🔧 [EXPERIMENTAL] Disable experimental features.
645
+ Available features:
646
+ • ci-download-hack - Force single-pass mode for Code Interpreter downloads.
647
+ Overrides config file setting.
648
+ Example: --disable-feature ci-download-hack""",
649
+ )(cmd)
650
+
576
651
  return cast(Command, cmd)
577
652
 
578
653
 
@@ -685,13 +760,17 @@ def web_search_options(f: Union[Command, Callable[..., Any]]) -> Command:
685
760
  is_flag=True,
686
761
  help="""🌐 [WEB SEARCH] Enable OpenAI web search tool for up-to-date information.
687
762
  Allows the model to search the web for current events, recent updates, and real-time data.
688
- Note: Search queries may be sent to external services via OpenAI.""",
763
+ Note: Search queries may be sent to external services via OpenAI.
764
+
765
+ ⚠️ DEPRECATED: Use --enable-tool web-search instead. Will be removed in v0.9.0.""",
689
766
  )(cmd)
690
767
 
691
768
  cmd = click.option(
692
769
  "--no-web-search",
693
770
  is_flag=True,
694
- help="""Explicitly disable web search even if enabled by default in configuration.""",
771
+ help="""Explicitly disable web search even if enabled by default in configuration.
772
+
773
+ ⚠️ DEPRECATED: Use --disable-tool web-search instead. Will be removed in v0.9.0.""",
695
774
  )(cmd)
696
775
 
697
776
  cmd = click.option(
@@ -725,6 +804,35 @@ def web_search_options(f: Union[Command, Callable[..., Any]]) -> Command:
725
804
  return cast(Command, cmd)
726
805
 
727
806
 
807
+ def tool_toggle_options(f: Union[Command, Callable[..., Any]]) -> Command:
808
+ """Add universal tool toggle CLI options."""
809
+ cmd: Any = f if isinstance(f, Command) else f
810
+
811
+ cmd = click.option(
812
+ "--enable-tool",
813
+ "enabled_tools",
814
+ multiple=True,
815
+ metavar="<TOOL>",
816
+ help="""🔧 [TOOL TOGGLES] Enable a tool for this run (repeatable).
817
+ Overrides configuration file and implicit activation.
818
+ Available tools: code-interpreter, file-search, web-search, mcp
819
+ Example: --enable-tool code-interpreter --enable-tool web-search""",
820
+ )(cmd)
821
+
822
+ cmd = click.option(
823
+ "--disable-tool",
824
+ "disabled_tools",
825
+ multiple=True,
826
+ metavar="<TOOL>",
827
+ help="""🔧 [TOOL TOGGLES] Disable a tool for this run (repeatable).
828
+ Overrides configuration file and implicit activation.
829
+ Available tools: code-interpreter, file-search, web-search, mcp
830
+ Example: --disable-tool web-search --disable-tool mcp""",
831
+ )(cmd)
832
+
833
+ return cast(Command, cmd)
834
+
835
+
728
836
  def debug_progress_options(f: Union[Command, Callable[..., Any]]) -> Command:
729
837
  """Add debugging and progress CLI options."""
730
838
  cmd: Any = f if isinstance(f, Command) else f
@@ -746,12 +854,6 @@ def debug_progress_options(f: Union[Command, Callable[..., Any]]) -> Command:
746
854
  "--verbose", is_flag=True, help="Enable verbose logging"
747
855
  )(cmd)
748
856
 
749
- cmd = click.option(
750
- "--debug-openai-stream",
751
- is_flag=True,
752
- help="Debug OpenAI streaming process",
753
- )(cmd)
754
-
755
857
  cmd = click.option(
756
858
  "--timeout",
757
859
  type=int,
@@ -777,6 +879,7 @@ def all_options(f: Union[Command, Callable[..., Any]]) -> Command:
777
879
  cmd = code_interpreter_options(cmd)
778
880
  cmd = file_search_options(cmd)
779
881
  cmd = web_search_options(cmd)
882
+ cmd = tool_toggle_options(cmd)
780
883
  cmd = debug_options(cmd)
781
884
  cmd = debug_progress_options(cmd)
782
885
 
@@ -7,7 +7,7 @@ and integrating code execution capabilities with the OpenAI Responses API.
7
7
  import logging
8
8
  import os
9
9
  from pathlib import Path
10
- from typing import Any, Dict, List
10
+ from typing import Any, Dict, List, Optional
11
11
 
12
12
  from openai import AsyncOpenAI
13
13
 
@@ -17,14 +17,18 @@ logger = logging.getLogger(__name__)
17
17
  class CodeInterpreterManager:
18
18
  """Manager for Code Interpreter file uploads and tool integration."""
19
19
 
20
- def __init__(self, client: AsyncOpenAI):
20
+ def __init__(
21
+ self, client: AsyncOpenAI, config: Optional[Dict[str, Any]] = None
22
+ ):
21
23
  """Initialize Code Interpreter manager.
22
24
 
23
25
  Args:
24
26
  client: AsyncOpenAI client instance
27
+ config: Code interpreter configuration dict
25
28
  """
26
29
  self.client = client
27
30
  self.uploaded_file_ids: List[str] = []
31
+ self.config = config or {}
28
32
 
29
33
  async def upload_files_for_code_interpreter(
30
34
  self, files: List[str]
@@ -96,13 +100,75 @@ class CodeInterpreterManager:
96
100
  "container": {"type": "auto", "file_ids": file_ids},
97
101
  }
98
102
 
103
+ def _collect_file_annotations(self, resp: Any) -> List[Dict[str, Any]]:
104
+ """Collect file annotations from Responses API output.
105
+
106
+ Based on IMPLEMENTATION_NOTES.md findings:
107
+ - resp.output is a list of ResponseCodeInterpreterToolCall and ResponseOutputMessage
108
+ - Annotations can be in ResponseOutputMessage.content[].annotations
109
+ - Also check ResponseCodeInterpreterToolCall for file outputs
110
+ - Look for container_file_citation type
111
+
112
+ Returns:
113
+ List of annotation dicts with file_id, container_id, filename
114
+ """
115
+ annotations = []
116
+
117
+ for item in resp.output:
118
+ # Check messages for annotations
119
+ if getattr(item, "type", None) == "message":
120
+ for blk in item.content or []:
121
+ if hasattr(blk, "annotations"):
122
+ for ann in blk.annotations or []:
123
+ # Look specifically for container_file_citation type
124
+ if (
125
+ getattr(ann, "type", None)
126
+ == "container_file_citation"
127
+ ):
128
+ annotations.append(
129
+ {
130
+ "file_id": ann.file_id,
131
+ "container_id": getattr(
132
+ ann, "container_id", None
133
+ ),
134
+ "filename": getattr(
135
+ ann, "filename", None
136
+ ),
137
+ "type": ann.type,
138
+ }
139
+ )
140
+
141
+ # Check code interpreter tool calls for file outputs
142
+ elif getattr(item, "type", None) == "code_interpreter_call":
143
+ # Check if the tool call has outputs with files
144
+ if hasattr(item, "outputs"):
145
+ for output in item.outputs or []:
146
+ # Look for file outputs
147
+ if hasattr(output, "type") and output.type == "file":
148
+ file_id = getattr(output, "file_id", None)
149
+ filename = getattr(output, "filename", None)
150
+ if file_id:
151
+ annotations.append(
152
+ {
153
+ "file_id": file_id,
154
+ "container_id": None,
155
+ "filename": filename or file_id,
156
+ "type": "code_interpreter_file",
157
+ }
158
+ )
159
+
160
+ return annotations
161
+
99
162
  async def download_generated_files(
100
- self, response_file_ids: List[str], output_dir: str = "."
163
+ self, response: Any, output_dir: str = "."
101
164
  ) -> List[str]:
102
- """Download files generated by Code Interpreter.
165
+ """Download files generated by Code Interpreter using annotations.
166
+
167
+ Updated to use container_file_citation annotations instead of
168
+ deprecated message.file_ids field.
103
169
 
104
170
  Args:
105
- response_file_ids: List of file IDs from Code Interpreter response
171
+ response: Response from client.responses.create()
106
172
  output_dir: Directory to save downloaded files
107
173
 
108
174
  Returns:
@@ -111,35 +177,162 @@ class CodeInterpreterManager:
111
177
  Raises:
112
178
  Exception: If download fails
113
179
  """
114
- downloaded_paths = []
180
+ if not response:
181
+ return []
182
+
183
+ # Check if auto_download is enabled
184
+ if not self.config.get("auto_download", True):
185
+ logger.debug("Auto-download disabled in configuration")
186
+ return []
187
+
188
+ # Ensure output directory exists
115
189
  output_path = Path(output_dir)
116
190
  output_path.mkdir(exist_ok=True)
117
191
 
118
- for file_id in response_file_ids:
192
+ # Collect file annotations using new method
193
+ annotations = self._collect_file_annotations(response)
194
+ logger.debug(
195
+ f"Found {len(annotations)} file annotations: {annotations}"
196
+ )
197
+
198
+ if not annotations:
199
+ logger.debug("No file annotations found in response")
200
+ return []
201
+
202
+ downloaded_paths = []
203
+
204
+ for ann in annotations:
119
205
  try:
120
- # Get file info
121
- file_info = await self.client.files.retrieve(file_id)
122
- filename = (
123
- file_info.filename or f"generated_file_{file_id[:8]}.dat"
124
- )
206
+ file_id = ann["file_id"]
207
+ container_id = ann.get("container_id")
208
+ filename = ann.get("filename") or file_id
125
209
 
126
- # Download file content
127
- file_content = await self.client.files.content(file_id)
210
+ # Use container-specific API for cfile_* IDs
211
+ if file_id.startswith("cfile_") and container_id:
212
+ logger.debug(
213
+ f"Using container API for {file_id} in container {container_id}"
214
+ )
215
+
216
+ # Try different approaches to access the Container Files API
217
+ file_content = None
218
+
219
+ # Approach 1: Direct method call (should work in v1.84.0+)
220
+ try:
221
+ logger.debug(
222
+ "Attempting direct containers.files.content() call"
223
+ )
224
+ # Note: type: ignore[operator] is needed due to mypy false positive
225
+ # The OpenAI SDK's type annotations incorrectly suggest AsyncContent is not callable
226
+ # but the method works correctly at runtime and returns an object with .content property
227
+ result = await self.client.containers.files.content( # type: ignore[operator]
228
+ file_id, container_id=container_id
229
+ )
230
+
231
+ # Based on expert guidance: in v1.83.0+, result should have .content property
232
+ if hasattr(result, "content"):
233
+ file_content = result.content
234
+ logger.debug(
235
+ f"✓ Got content via result.content: {len(file_content)} bytes"
236
+ )
237
+ elif hasattr(result, "response") and hasattr(
238
+ result.response, "content"
239
+ ):
240
+ file_content = result.response.content
241
+ logger.debug(
242
+ f"✓ Got content via result.response.content: {len(file_content)} bytes"
243
+ )
244
+ else:
245
+ logger.debug(
246
+ f"Result type: {type(result)}, available attrs: {[a for a in dir(result) if not a.startswith('_')]}"
247
+ )
248
+
249
+ except Exception as e:
250
+ logger.debug(f"Direct method call failed: {e}")
251
+
252
+ # Approach 2: Try using the raw HTTP client if direct method fails
253
+ if file_content is None:
254
+ try:
255
+ logger.debug(
256
+ "Attempting raw HTTP request to container files endpoint"
257
+ )
258
+ import httpx
259
+
260
+ # Construct the URL manually
261
+ base_url = str(self.client.base_url).rstrip("/")
262
+ url = f"{base_url}/containers/{container_id}/files/{file_id}/content"
263
+
264
+ # Use the client's HTTP client with auth headers
265
+ headers = {
266
+ "Authorization": f"Bearer {self.client.api_key}",
267
+ "User-Agent": "ostruct/container-files-client",
268
+ }
269
+
270
+ async with httpx.AsyncClient() as http_client:
271
+ response = await http_client.get(
272
+ url, headers=headers
273
+ )
274
+ response.raise_for_status()
275
+ file_content = response.content
276
+ logger.debug(
277
+ f"✓ Got content via raw HTTP: {len(file_content)} bytes"
278
+ )
279
+
280
+ except Exception as e:
281
+ logger.debug(f"Raw HTTP request failed: {e}")
282
+
283
+ if file_content is None:
284
+ raise Exception(
285
+ f"Failed to download container file {file_id} using both direct API and raw HTTP methods"
286
+ )
287
+ else:
288
+ logger.debug(f"Using standard Files API for {file_id}")
289
+ # Use standard Files API for regular uploaded files
290
+ file_content_resp = await self.client.files.content(
291
+ file_id
292
+ )
293
+ file_content = file_content_resp.read()
128
294
 
129
295
  # Save to local file
130
296
  local_path = output_path / filename
131
297
  with open(local_path, "wb") as f:
132
- f.write(file_content.read())
298
+ f.write(file_content)
133
299
 
134
300
  downloaded_paths.append(str(local_path))
135
- logger.debug(f"Downloaded generated file: {local_path}")
301
+ logger.info(f"Downloaded generated file: {local_path}")
136
302
 
137
303
  except Exception as e:
138
304
  logger.error(f"Failed to download file {file_id}: {e}")
139
- raise
305
+ # Continue with other files instead of raising
306
+ continue
140
307
 
141
308
  return downloaded_paths
142
309
 
310
+ def _extract_filename_from_message(self, msg: Any) -> str:
311
+ """Extract filename from message content if available.
312
+
313
+ Args:
314
+ msg: Message object that might contain filename references
315
+
316
+ Returns:
317
+ Extracted filename or empty string if not found
318
+ """
319
+ try:
320
+ # Try to extract filename from markdown links in message content
321
+ if hasattr(msg, "content") and msg.content:
322
+ import re
323
+
324
+ # Look for patterns like [filename.ext](sandbox:/mnt/data/filename.ext)
325
+ content_str = str(msg.content)
326
+ match = re.search(
327
+ r"\[([^\]]+\.[a-zA-Z0-9]+)\]\(sandbox:/mnt/data/[^)]+\)",
328
+ content_str,
329
+ )
330
+ if match:
331
+ return match.group(1)
332
+ except Exception:
333
+ pass
334
+ return ""
335
+
143
336
  async def cleanup_uploaded_files(self) -> None:
144
337
  """Clean up uploaded files from OpenAI storage.
145
338
 
@@ -24,6 +24,28 @@ from ..types import CLIParams
24
24
  logger = logging.getLogger(__name__)
25
25
 
26
26
 
27
+ def _emit_deprecation_warnings(params: CLIParams) -> None:
28
+ """Emit deprecation warnings for legacy tool-specific flags."""
29
+ import warnings
30
+
31
+ # Web Search flags
32
+ if params.get("web_search"):
33
+ warnings.warn(
34
+ "The --web-search flag is deprecated and will be removed in v0.9.0. "
35
+ "Use --enable-tool web-search instead.",
36
+ DeprecationWarning,
37
+ stacklevel=3,
38
+ )
39
+
40
+ if params.get("no_web_search"):
41
+ warnings.warn(
42
+ "The --no-web-search flag is deprecated and will be removed in v0.9.0. "
43
+ "Use --disable-tool web-search instead.",
44
+ DeprecationWarning,
45
+ stacklevel=3,
46
+ )
47
+
48
+
27
49
  @click.command()
28
50
  @click.argument("task_template", type=click.Path(exists=True))
29
51
  @click.argument("schema_file", type=click.Path(exists=True))
@@ -91,6 +113,40 @@ def run(
91
113
  for k, v in kwargs.items():
92
114
  params[k] = v # type: ignore[literal-required]
93
115
 
116
+ # Process tool toggle flags (Step 2: Conflict guard & normalisation)
117
+ from typing import Tuple
118
+
119
+ enabled_tools_raw: Tuple[str, ...] = params.get("enabled_tools", ()) # type: ignore[assignment]
120
+ disabled_tools_raw: Tuple[str, ...] = params.get("disabled_tools", ()) # type: ignore[assignment]
121
+
122
+ logger.debug(f"Raw enabled tools: {enabled_tools_raw}")
123
+ logger.debug(f"Raw disabled tools: {disabled_tools_raw}")
124
+
125
+ # Ensure we have lists to iterate over (Click returns tuples for multiple=True)
126
+ enabled_list: list[str] = list(enabled_tools_raw)
127
+ disabled_list: list[str] = list(disabled_tools_raw)
128
+
129
+ enabled_tools = {t.lower() for t in enabled_list}
130
+ disabled_tools = {t.lower() for t in disabled_list}
131
+
132
+ logger.debug(f"Enabled tools normalized: {enabled_tools}")
133
+ logger.debug(f"Disabled tools normalized: {disabled_tools}")
134
+
135
+ # Check for conflicts
136
+ dupes = enabled_tools & disabled_tools
137
+ if dupes:
138
+ logger.error(f"Tool conflict detected: {dupes}")
139
+ raise click.UsageError(
140
+ f"--enable-tool and --disable-tool both specified for: {', '.join(sorted(dupes))}"
141
+ )
142
+
143
+ # Store normalized tool toggles for later stages
144
+ params["_enabled_tools"] = enabled_tools # type: ignore[typeddict-unknown-key]
145
+ params["_disabled_tools"] = disabled_tools # type: ignore[typeddict-unknown-key]
146
+
147
+ # Emit deprecation warnings for legacy tool-specific flags
148
+ _emit_deprecation_warnings(params)
149
+
94
150
  # Apply configuration defaults if values not explicitly provided
95
151
  # Check for command-level config option first, then group-level
96
152
  command_config = kwargs.get("config")
ostruct/cli/config.py CHANGED
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
  from typing import Any, Dict, Optional, Union
7
7
 
8
8
  import yaml
9
- from pydantic import BaseModel, Field, field_validator
9
+ from pydantic import BaseModel, Field, field_validator, model_validator
10
10
 
11
11
  logger = logging.getLogger(__name__)
12
12
 
@@ -43,6 +43,7 @@ class ToolsConfig(BaseModel):
43
43
  default_factory=lambda: {
44
44
  "auto_download": True,
45
45
  "output_directory": "./output",
46
+ "download_strategy": "single_pass", # "single_pass" | "two_pass_sentinel"
46
47
  }
47
48
  )
48
49
  file_search: Dict[str, Any] = Field(
@@ -91,6 +92,24 @@ class OstructConfig(BaseModel):
91
92
  operation: OperationConfig = Field(default_factory=OperationConfig)
92
93
  limits: LimitsConfig = Field(default_factory=LimitsConfig)
93
94
 
95
+ @model_validator(mode="before")
96
+ @classmethod
97
+ def _validate_download_strategy(cls, values: Any) -> Any:
98
+ """Validate download_strategy in code_interpreter config."""
99
+ if isinstance(values, dict):
100
+ tools_config = values.get("tools", {})
101
+ if isinstance(tools_config, dict):
102
+ ci_config = tools_config.get("code_interpreter", {})
103
+ if isinstance(ci_config, dict):
104
+ strategy = ci_config.get(
105
+ "download_strategy", "single_pass"
106
+ )
107
+ if strategy not in {"single_pass", "two_pass_sentinel"}:
108
+ raise ValueError(
109
+ "download_strategy must be 'single_pass' or 'two_pass_sentinel'"
110
+ )
111
+ return values
112
+
94
113
  @classmethod
95
114
  def load(
96
115
  cls, config_path: Optional[Union[str, Path]] = None
ostruct/cli/errors.py CHANGED
@@ -392,24 +392,6 @@ class ModelNotSupportedError(CLIError):
392
392
  pass
393
393
 
394
394
 
395
- class StreamInterruptedError(CLIError):
396
- """Exception raised when a stream is interrupted."""
397
-
398
- pass
399
-
400
-
401
- class StreamBufferError(CLIError):
402
- """Exception raised when there's an error with the stream buffer."""
403
-
404
- pass
405
-
406
-
407
- class StreamParseError(CLIError):
408
- """Exception raised when there's an error parsing the stream."""
409
-
410
- pass
411
-
412
-
413
395
  class APIResponseError(CLIError):
414
396
  """Exception raised when there's an error with the API response."""
415
397
 
@@ -788,15 +770,8 @@ def handle_error(e: Exception) -> None:
788
770
  logger.debug(
789
771
  f"Error details:\nType: {type(e).__name__}\n{context_str.rstrip()}"
790
772
  )
791
- elif not isinstance(
792
- e,
793
- (
794
- click.UsageError,
795
- DuplicateFileMappingError,
796
- VariableNameError,
797
- VariableValueError,
798
- ),
799
- ):
773
+ elif not isinstance(e, (CLIError, click.UsageError)):
774
+ # Only show tracebacks for truly unexpected errors (not CLIError subclasses)
800
775
  logger.error(msg, exc_info=True)
801
776
 
802
777
  # 3. User output
@@ -820,9 +795,6 @@ __all__ = [
820
795
  "InvalidJSONError",
821
796
  "ModelCreationError",
822
797
  "ModelNotSupportedError",
823
- "StreamInterruptedError",
824
- "StreamBufferError",
825
- "StreamParseError",
826
798
  "APIResponseError",
827
799
  "EmptyResponseError",
828
800
  "InvalidResponseFormatError",