ostruct-cli 0.8.2__py3-none-any.whl → 0.8.4__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.
- ostruct/cli/cli.py +4 -0
- ostruct/cli/click_options.py +113 -16
- ostruct/cli/code_interpreter.py +210 -17
- ostruct/cli/commands/run.py +56 -0
- ostruct/cli/config.py +20 -1
- ostruct/cli/errors.py +2 -30
- ostruct/cli/file_info.py +55 -20
- ostruct/cli/file_utils.py +19 -3
- ostruct/cli/json_extract.py +75 -0
- ostruct/cli/model_creation.py +1 -1
- ostruct/cli/runner.py +476 -195
- ostruct/cli/sentinel.py +29 -0
- ostruct/cli/template_optimizer.py +11 -7
- ostruct/cli/template_processor.py +243 -115
- ostruct/cli/template_rendering.py +41 -1
- ostruct/cli/template_validation.py +41 -3
- ostruct/cli/types.py +14 -1
- {ostruct_cli-0.8.2.dist-info → ostruct_cli-0.8.4.dist-info}/METADATA +96 -2
- {ostruct_cli-0.8.2.dist-info → ostruct_cli-0.8.4.dist-info}/RECORD +22 -20
- {ostruct_cli-0.8.2.dist-info → ostruct_cli-0.8.4.dist-info}/LICENSE +0 -0
- {ostruct_cli-0.8.2.dist-info → ostruct_cli-0.8.4.dist-info}/WHEEL +0 -0
- {ostruct_cli-0.8.2.dist-info → ostruct_cli-0.8.4.dist-info}/entry_points.txt +0 -0
ostruct/cli/cli.py
CHANGED
@@ -4,6 +4,7 @@ import sys
|
|
4
4
|
from typing import Optional
|
5
5
|
|
6
6
|
import click
|
7
|
+
from dotenv import load_dotenv
|
7
8
|
|
8
9
|
from .. import __version__
|
9
10
|
from .commands import create_command_group
|
@@ -107,6 +108,9 @@ def create_cli() -> click.Command:
|
|
107
108
|
|
108
109
|
def main() -> None:
|
109
110
|
"""Main entry point for the CLI."""
|
111
|
+
# Load environment variables from .env file
|
112
|
+
load_dotenv()
|
113
|
+
|
110
114
|
try:
|
111
115
|
cli(standalone_mode=False)
|
112
116
|
except (
|
ostruct/cli/click_options.py
CHANGED
@@ -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
|
@@ -442,12 +492,13 @@ def api_options(f: Union[Command, Callable[..., Any]]) -> Command:
|
|
442
492
|
environment variable.""",
|
443
493
|
)(cmd)
|
444
494
|
|
495
|
+
# API timeout for OpenAI calls
|
445
496
|
cmd = click.option(
|
446
497
|
"--timeout",
|
447
498
|
type=click.FloatRange(1.0, None),
|
448
499
|
default=60.0,
|
449
500
|
show_default=True,
|
450
|
-
help="
|
501
|
+
help="Timeout in seconds for OpenAI API calls.",
|
451
502
|
)(cmd)
|
452
503
|
|
453
504
|
return cast(Command, cmd)
|
@@ -573,6 +624,31 @@ def code_interpreter_options(f: Union[Command, Callable[..., Any]]) -> Command:
|
|
573
624
|
help="""Clean up uploaded files after execution to save storage quota.""",
|
574
625
|
)(cmd)
|
575
626
|
|
627
|
+
# Feature flags for experimental features
|
628
|
+
cmd = click.option(
|
629
|
+
"--enable-feature",
|
630
|
+
"enabled_features",
|
631
|
+
multiple=True,
|
632
|
+
metavar="<FEATURE>",
|
633
|
+
help="""🔧 [EXPERIMENTAL] Enable experimental features.
|
634
|
+
Available features:
|
635
|
+
• ci-download-hack - Enable two-pass sentinel mode for reliable Code Interpreter
|
636
|
+
file downloads with structured output. Overrides config file setting.
|
637
|
+
Example: --enable-feature ci-download-hack""",
|
638
|
+
)(cmd)
|
639
|
+
|
640
|
+
cmd = click.option(
|
641
|
+
"--disable-feature",
|
642
|
+
"disabled_features",
|
643
|
+
multiple=True,
|
644
|
+
metavar="<FEATURE>",
|
645
|
+
help="""🔧 [EXPERIMENTAL] Disable experimental features.
|
646
|
+
Available features:
|
647
|
+
• ci-download-hack - Force single-pass mode for Code Interpreter downloads.
|
648
|
+
Overrides config file setting.
|
649
|
+
Example: --disable-feature ci-download-hack""",
|
650
|
+
)(cmd)
|
651
|
+
|
576
652
|
return cast(Command, cmd)
|
577
653
|
|
578
654
|
|
@@ -685,13 +761,17 @@ def web_search_options(f: Union[Command, Callable[..., Any]]) -> Command:
|
|
685
761
|
is_flag=True,
|
686
762
|
help="""🌐 [WEB SEARCH] Enable OpenAI web search tool for up-to-date information.
|
687
763
|
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.
|
764
|
+
Note: Search queries may be sent to external services via OpenAI.
|
765
|
+
|
766
|
+
⚠️ DEPRECATED: Use --enable-tool web-search instead. Will be removed in v0.9.0.""",
|
689
767
|
)(cmd)
|
690
768
|
|
691
769
|
cmd = click.option(
|
692
770
|
"--no-web-search",
|
693
771
|
is_flag=True,
|
694
|
-
help="""Explicitly disable web search even if enabled by default in configuration.
|
772
|
+
help="""Explicitly disable web search even if enabled by default in configuration.
|
773
|
+
|
774
|
+
⚠️ DEPRECATED: Use --disable-tool web-search instead. Will be removed in v0.9.0.""",
|
695
775
|
)(cmd)
|
696
776
|
|
697
777
|
cmd = click.option(
|
@@ -725,6 +805,35 @@ def web_search_options(f: Union[Command, Callable[..., Any]]) -> Command:
|
|
725
805
|
return cast(Command, cmd)
|
726
806
|
|
727
807
|
|
808
|
+
def tool_toggle_options(f: Union[Command, Callable[..., Any]]) -> Command:
|
809
|
+
"""Add universal tool toggle CLI options."""
|
810
|
+
cmd: Any = f if isinstance(f, Command) else f
|
811
|
+
|
812
|
+
cmd = click.option(
|
813
|
+
"--enable-tool",
|
814
|
+
"enabled_tools",
|
815
|
+
multiple=True,
|
816
|
+
metavar="<TOOL>",
|
817
|
+
help="""🔧 [TOOL TOGGLES] Enable a tool for this run (repeatable).
|
818
|
+
Overrides configuration file and implicit activation.
|
819
|
+
Available tools: code-interpreter, file-search, web-search, mcp
|
820
|
+
Example: --enable-tool code-interpreter --enable-tool web-search""",
|
821
|
+
)(cmd)
|
822
|
+
|
823
|
+
cmd = click.option(
|
824
|
+
"--disable-tool",
|
825
|
+
"disabled_tools",
|
826
|
+
multiple=True,
|
827
|
+
metavar="<TOOL>",
|
828
|
+
help="""🔧 [TOOL TOGGLES] Disable a tool for this run (repeatable).
|
829
|
+
Overrides configuration file and implicit activation.
|
830
|
+
Available tools: code-interpreter, file-search, web-search, mcp
|
831
|
+
Example: --disable-tool web-search --disable-tool mcp""",
|
832
|
+
)(cmd)
|
833
|
+
|
834
|
+
return cast(Command, cmd)
|
835
|
+
|
836
|
+
|
728
837
|
def debug_progress_options(f: Union[Command, Callable[..., Any]]) -> Command:
|
729
838
|
"""Add debugging and progress CLI options."""
|
730
839
|
cmd: Any = f if isinstance(f, Command) else f
|
@@ -746,19 +855,6 @@ def debug_progress_options(f: Union[Command, Callable[..., Any]]) -> Command:
|
|
746
855
|
"--verbose", is_flag=True, help="Enable verbose logging"
|
747
856
|
)(cmd)
|
748
857
|
|
749
|
-
cmd = click.option(
|
750
|
-
"--debug-openai-stream",
|
751
|
-
is_flag=True,
|
752
|
-
help="Debug OpenAI streaming process",
|
753
|
-
)(cmd)
|
754
|
-
|
755
|
-
cmd = click.option(
|
756
|
-
"--timeout",
|
757
|
-
type=int,
|
758
|
-
default=3600,
|
759
|
-
help="Operation timeout in seconds (default: 3600 = 1 hour)",
|
760
|
-
)(cmd)
|
761
|
-
|
762
858
|
return cast(Command, cmd)
|
763
859
|
|
764
860
|
|
@@ -777,6 +873,7 @@ def all_options(f: Union[Command, Callable[..., Any]]) -> Command:
|
|
777
873
|
cmd = code_interpreter_options(cmd)
|
778
874
|
cmd = file_search_options(cmd)
|
779
875
|
cmd = web_search_options(cmd)
|
876
|
+
cmd = tool_toggle_options(cmd)
|
780
877
|
cmd = debug_options(cmd)
|
781
878
|
cmd = debug_progress_options(cmd)
|
782
879
|
|
ostruct/cli/code_interpreter.py
CHANGED
@@ -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__(
|
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,
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
121
|
-
|
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
|
-
#
|
127
|
-
|
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
|
298
|
+
f.write(file_content)
|
133
299
|
|
134
300
|
downloaded_paths.append(str(local_path))
|
135
|
-
logger.
|
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
|
-
|
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
|
|
ostruct/cli/commands/run.py
CHANGED
@@ -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
|
-
|
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",
|