ostruct-cli 0.7.1__py3-none-any.whl → 0.8.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.
- ostruct/cli/__init__.py +21 -3
- ostruct/cli/base_errors.py +1 -1
- ostruct/cli/cli.py +66 -1983
- ostruct/cli/click_options.py +460 -28
- ostruct/cli/code_interpreter.py +238 -0
- ostruct/cli/commands/__init__.py +32 -0
- ostruct/cli/commands/list_models.py +128 -0
- ostruct/cli/commands/quick_ref.py +50 -0
- ostruct/cli/commands/run.py +137 -0
- ostruct/cli/commands/update_registry.py +71 -0
- ostruct/cli/config.py +277 -0
- ostruct/cli/cost_estimation.py +134 -0
- ostruct/cli/errors.py +310 -6
- ostruct/cli/exit_codes.py +1 -0
- ostruct/cli/explicit_file_processor.py +548 -0
- ostruct/cli/field_utils.py +69 -0
- ostruct/cli/file_info.py +42 -9
- ostruct/cli/file_list.py +301 -102
- ostruct/cli/file_search.py +455 -0
- ostruct/cli/file_utils.py +47 -13
- ostruct/cli/mcp_integration.py +541 -0
- ostruct/cli/model_creation.py +150 -1
- ostruct/cli/model_validation.py +204 -0
- ostruct/cli/progress_reporting.py +398 -0
- ostruct/cli/registry_updates.py +14 -9
- ostruct/cli/runner.py +1418 -0
- ostruct/cli/schema_utils.py +113 -0
- ostruct/cli/services.py +626 -0
- ostruct/cli/template_debug.py +748 -0
- ostruct/cli/template_debug_help.py +162 -0
- ostruct/cli/template_env.py +15 -6
- ostruct/cli/template_filters.py +55 -3
- ostruct/cli/template_optimizer.py +474 -0
- ostruct/cli/template_processor.py +1080 -0
- ostruct/cli/template_rendering.py +69 -34
- ostruct/cli/token_validation.py +286 -0
- ostruct/cli/types.py +78 -0
- ostruct/cli/unattended_operation.py +269 -0
- ostruct/cli/validators.py +386 -3
- {ostruct_cli-0.7.1.dist-info → ostruct_cli-0.8.0.dist-info}/LICENSE +2 -0
- ostruct_cli-0.8.0.dist-info/METADATA +633 -0
- ostruct_cli-0.8.0.dist-info/RECORD +69 -0
- {ostruct_cli-0.7.1.dist-info → ostruct_cli-0.8.0.dist-info}/WHEEL +1 -1
- ostruct_cli-0.7.1.dist-info/METADATA +0 -369
- ostruct_cli-0.7.1.dist-info/RECORD +0 -45
- {ostruct_cli-0.7.1.dist-info → ostruct_cli-0.8.0.dist-info}/entry_points.txt +0 -0
ostruct/cli/cli.py
CHANGED
@@ -1,2014 +1,99 @@
|
|
1
|
-
"""
|
1
|
+
"""Minimal CLI entry point for ostruct."""
|
2
2
|
|
3
|
-
import asyncio
|
4
|
-
import json
|
5
|
-
import logging
|
6
|
-
import os
|
7
3
|
import sys
|
8
|
-
from typing import
|
9
|
-
Any,
|
10
|
-
AsyncGenerator,
|
11
|
-
Dict,
|
12
|
-
List,
|
13
|
-
Literal,
|
14
|
-
Optional,
|
15
|
-
Set,
|
16
|
-
Tuple,
|
17
|
-
Type,
|
18
|
-
TypedDict,
|
19
|
-
TypeVar,
|
20
|
-
Union,
|
21
|
-
cast,
|
22
|
-
overload,
|
23
|
-
)
|
24
|
-
|
25
|
-
if sys.version_info >= (3, 11):
|
26
|
-
pass
|
27
|
-
|
28
|
-
from datetime import date, datetime, time
|
29
|
-
from pathlib import Path
|
4
|
+
from typing import Optional
|
30
5
|
|
31
6
|
import click
|
32
|
-
import jinja2
|
33
|
-
import yaml
|
34
|
-
from openai import AsyncOpenAI
|
35
|
-
from openai_structured.client import (
|
36
|
-
async_openai_structured_stream,
|
37
|
-
supports_structured_output,
|
38
|
-
)
|
39
|
-
from openai_structured.errors import (
|
40
|
-
APIResponseError,
|
41
|
-
EmptyResponseError,
|
42
|
-
InvalidResponseFormatError,
|
43
|
-
ModelNotSupportedError,
|
44
|
-
ModelVersionError,
|
45
|
-
OpenAIClientError,
|
46
|
-
StreamBufferError,
|
47
|
-
)
|
48
|
-
from openai_structured.model_registry import (
|
49
|
-
ModelRegistry,
|
50
|
-
RegistryUpdateStatus,
|
51
|
-
)
|
52
|
-
from pydantic import AnyUrl, BaseModel, EmailStr, Field
|
53
|
-
from pydantic.fields import FieldInfo as FieldInfoType
|
54
|
-
from pydantic.functional_validators import BeforeValidator
|
55
|
-
from pydantic.types import constr
|
56
|
-
from typing_extensions import TypeAlias
|
57
|
-
|
58
|
-
from ostruct.cli.click_options import all_options
|
59
|
-
from ostruct.cli.exit_codes import ExitCode
|
60
7
|
|
61
|
-
from .. import __version__
|
8
|
+
from .. import __version__
|
9
|
+
from .commands import create_command_group
|
10
|
+
from .config import OstructConfig
|
62
11
|
from .errors import (
|
63
12
|
CLIError,
|
64
|
-
DirectoryNotFoundError,
|
65
13
|
InvalidJSONError,
|
66
|
-
ModelCreationError,
|
67
|
-
OstructFileNotFoundError,
|
68
|
-
PathSecurityError,
|
69
14
|
SchemaFileError,
|
70
15
|
SchemaValidationError,
|
71
|
-
|
72
|
-
StreamParseError,
|
73
|
-
TaskTemplateSyntaxError,
|
74
|
-
TaskTemplateVariableError,
|
75
|
-
VariableNameError,
|
76
|
-
VariableValueError,
|
16
|
+
handle_error,
|
77
17
|
)
|
78
|
-
from .
|
79
|
-
from .model_creation import _create_enum_type, create_dynamic_model
|
80
|
-
from .path_utils import validate_path_mapping
|
18
|
+
from .exit_codes import ExitCode
|
81
19
|
from .registry_updates import get_update_notification
|
82
|
-
from .security import SecurityManager
|
83
|
-
from .serialization import LogSerializer
|
84
|
-
from .template_env import create_jinja_env
|
85
|
-
from .template_utils import (
|
86
|
-
SystemPromptError,
|
87
|
-
render_template,
|
88
|
-
validate_json_schema,
|
89
|
-
)
|
90
|
-
from .token_utils import estimate_tokens_with_encoding
|
91
|
-
|
92
|
-
# Constants
|
93
|
-
DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant."
|
94
|
-
|
95
|
-
|
96
|
-
# Validation functions
|
97
|
-
def pattern(regex: str) -> Any:
|
98
|
-
return constr(pattern=regex)
|
99
|
-
|
100
|
-
|
101
|
-
def min_length(length: int) -> Any:
|
102
|
-
return BeforeValidator(lambda v: v if len(str(v)) >= length else None)
|
103
|
-
|
104
|
-
|
105
|
-
def max_length(length: int) -> Any:
|
106
|
-
return BeforeValidator(lambda v: v if len(str(v)) <= length else None)
|
107
|
-
|
108
|
-
|
109
|
-
def ge(value: Union[int, float]) -> Any:
|
110
|
-
return BeforeValidator(lambda v: v if float(v) >= value else None)
|
111
|
-
|
112
|
-
|
113
|
-
def le(value: Union[int, float]) -> Any:
|
114
|
-
return BeforeValidator(lambda v: v if float(v) <= value else None)
|
115
|
-
|
116
|
-
|
117
|
-
def gt(value: Union[int, float]) -> Any:
|
118
|
-
return BeforeValidator(lambda v: v if float(v) > value else None)
|
119
|
-
|
120
|
-
|
121
|
-
def lt(value: Union[int, float]) -> Any:
|
122
|
-
return BeforeValidator(lambda v: v if float(v) < value else None)
|
123
|
-
|
124
|
-
|
125
|
-
def multiple_of(value: Union[int, float]) -> Any:
|
126
|
-
return BeforeValidator(lambda v: v if float(v) % value == 0 else None)
|
127
|
-
|
128
|
-
|
129
|
-
def create_template_context(
|
130
|
-
files: Optional[
|
131
|
-
Dict[str, Union[FileInfoList, str, List[str], Dict[str, str]]]
|
132
|
-
] = None,
|
133
|
-
variables: Optional[Dict[str, str]] = None,
|
134
|
-
json_variables: Optional[Dict[str, Any]] = None,
|
135
|
-
security_manager: Optional[SecurityManager] = None,
|
136
|
-
stdin_content: Optional[str] = None,
|
137
|
-
) -> Dict[str, Any]:
|
138
|
-
"""Create template context from files and variables."""
|
139
|
-
context: Dict[str, Any] = {}
|
140
20
|
|
141
|
-
# Add file variables
|
142
|
-
if files:
|
143
|
-
for name, file_list in files.items():
|
144
|
-
context[name] = file_list # Always keep FileInfoList wrapper
|
145
21
|
|
146
|
-
|
147
|
-
|
148
|
-
context.update(variables)
|
149
|
-
|
150
|
-
# Add JSON variables
|
151
|
-
if json_variables:
|
152
|
-
context.update(json_variables)
|
153
|
-
|
154
|
-
# Add stdin if provided
|
155
|
-
if stdin_content is not None:
|
156
|
-
context["stdin"] = stdin_content
|
157
|
-
|
158
|
-
return context
|
159
|
-
|
160
|
-
|
161
|
-
class CLIParams(TypedDict, total=False):
|
162
|
-
"""Type-safe CLI parameters."""
|
163
|
-
|
164
|
-
files: List[
|
165
|
-
Tuple[str, str]
|
166
|
-
] # List of (name, path) tuples from Click's nargs=2
|
167
|
-
dir: List[
|
168
|
-
Tuple[str, str]
|
169
|
-
] # List of (name, dir) tuples from Click's nargs=2
|
170
|
-
patterns: List[
|
171
|
-
Tuple[str, str]
|
172
|
-
] # List of (name, pattern) tuples from Click's nargs=2
|
173
|
-
allowed_dirs: List[str]
|
174
|
-
base_dir: str
|
175
|
-
allowed_dir_file: Optional[str]
|
176
|
-
recursive: bool
|
177
|
-
var: List[str]
|
178
|
-
json_var: List[str]
|
179
|
-
system_prompt: Optional[str]
|
180
|
-
system_prompt_file: Optional[str]
|
181
|
-
ignore_task_sysprompt: bool
|
182
|
-
model: str
|
183
|
-
timeout: float
|
184
|
-
output_file: Optional[str]
|
185
|
-
dry_run: bool
|
186
|
-
no_progress: bool
|
187
|
-
api_key: Optional[str]
|
188
|
-
verbose: bool
|
189
|
-
debug_openai_stream: bool
|
190
|
-
show_model_schema: bool
|
191
|
-
debug_validation: bool
|
192
|
-
temperature: Optional[float]
|
193
|
-
max_output_tokens: Optional[int]
|
194
|
-
top_p: Optional[float]
|
195
|
-
frequency_penalty: Optional[float]
|
196
|
-
presence_penalty: Optional[float]
|
197
|
-
reasoning_effort: Optional[str]
|
198
|
-
progress_level: str
|
199
|
-
task_file: Optional[str]
|
200
|
-
task: Optional[str]
|
201
|
-
schema_file: str
|
202
|
-
|
203
|
-
|
204
|
-
# Set up logging
|
205
|
-
logger = logging.getLogger(__name__)
|
206
|
-
|
207
|
-
# Configure openai_structured logging based on debug flag
|
208
|
-
openai_logger = logging.getLogger("openai_structured")
|
209
|
-
openai_logger.setLevel(logging.DEBUG) # Allow all messages through to handlers
|
210
|
-
openai_logger.propagate = False # Prevent propagation to root logger
|
211
|
-
|
212
|
-
# Remove any existing handlers
|
213
|
-
for handler in openai_logger.handlers:
|
214
|
-
openai_logger.removeHandler(handler)
|
215
|
-
|
216
|
-
# Create a file handler for openai_structured logger that captures all levels
|
217
|
-
log_dir = os.path.expanduser("~/.ostruct/logs")
|
218
|
-
os.makedirs(log_dir, exist_ok=True)
|
219
|
-
openai_file_handler = logging.FileHandler(
|
220
|
-
os.path.join(log_dir, "openai_stream.log")
|
221
|
-
)
|
222
|
-
openai_file_handler.setLevel(logging.DEBUG) # Always capture debug in file
|
223
|
-
openai_file_handler.setFormatter(
|
224
|
-
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
225
|
-
)
|
226
|
-
openai_logger.addHandler(openai_file_handler)
|
227
|
-
|
228
|
-
# Create a file handler for the main logger that captures all levels
|
229
|
-
ostruct_file_handler = logging.FileHandler(
|
230
|
-
os.path.join(log_dir, "ostruct.log")
|
231
|
-
)
|
232
|
-
ostruct_file_handler.setLevel(logging.DEBUG) # Always capture debug in file
|
233
|
-
ostruct_file_handler.setFormatter(
|
234
|
-
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
235
|
-
)
|
236
|
-
logger.addHandler(ostruct_file_handler)
|
237
|
-
|
238
|
-
|
239
|
-
# Type aliases
|
240
|
-
FieldType = (
|
241
|
-
Any # Changed from Type[Any] to allow both concrete types and generics
|
242
|
-
)
|
243
|
-
FieldDefinition = Tuple[FieldType, FieldInfoType]
|
244
|
-
ModelType = TypeVar("ModelType", bound=BaseModel)
|
245
|
-
ItemType: TypeAlias = Type[BaseModel]
|
246
|
-
ValueType: TypeAlias = Type[Any]
|
247
|
-
|
248
|
-
|
249
|
-
def _create_field(**kwargs: Any) -> FieldInfoType:
|
250
|
-
"""Create a Pydantic Field with the given kwargs."""
|
251
|
-
field: FieldInfoType = Field(**kwargs)
|
252
|
-
return field
|
253
|
-
|
254
|
-
|
255
|
-
def _get_type_with_constraints(
|
256
|
-
field_schema: Dict[str, Any], field_name: str, base_name: str
|
257
|
-
) -> FieldDefinition:
|
258
|
-
"""Get type with constraints from field schema.
|
259
|
-
|
260
|
-
Args:
|
261
|
-
field_schema: Field schema dict
|
262
|
-
field_name: Name of the field
|
263
|
-
base_name: Base name for nested models
|
264
|
-
|
265
|
-
Returns:
|
266
|
-
Tuple of (type, field)
|
267
|
-
"""
|
268
|
-
field_kwargs: Dict[str, Any] = {}
|
22
|
+
def create_cli_group() -> click.Group:
|
23
|
+
"""Create the main CLI group with all commands."""
|
269
24
|
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
field_kwargs["default"] = field_schema["default"]
|
277
|
-
if "readOnly" in field_schema:
|
278
|
-
field_kwargs["frozen"] = field_schema["readOnly"]
|
279
|
-
|
280
|
-
field_type = field_schema.get("type")
|
281
|
-
|
282
|
-
# Handle array type
|
283
|
-
if field_type == "array":
|
284
|
-
items_schema = field_schema.get("items", {})
|
285
|
-
if not items_schema:
|
286
|
-
return (List[Any], Field(**field_kwargs))
|
287
|
-
|
288
|
-
# Create nested model for object items
|
289
|
-
if (
|
290
|
-
isinstance(items_schema, dict)
|
291
|
-
and items_schema.get("type") == "object"
|
292
|
-
):
|
293
|
-
array_item_model = create_dynamic_model(
|
294
|
-
items_schema,
|
295
|
-
base_name=f"{base_name}_{field_name}_Item",
|
296
|
-
show_schema=False,
|
297
|
-
debug_validation=False,
|
298
|
-
)
|
299
|
-
array_type: Type[List[Any]] = List[array_item_model] # type: ignore
|
300
|
-
return (array_type, Field(**field_kwargs))
|
301
|
-
|
302
|
-
# For non-object items, use the type directly
|
303
|
-
item_type = items_schema.get("type", "string")
|
304
|
-
if item_type == "string":
|
305
|
-
return (List[str], Field(**field_kwargs))
|
306
|
-
elif item_type == "integer":
|
307
|
-
return (List[int], Field(**field_kwargs))
|
308
|
-
elif item_type == "number":
|
309
|
-
return (List[float], Field(**field_kwargs))
|
310
|
-
elif item_type == "boolean":
|
311
|
-
return (List[bool], Field(**field_kwargs))
|
312
|
-
else:
|
313
|
-
return (List[Any], Field(**field_kwargs))
|
314
|
-
|
315
|
-
# Handle object type
|
316
|
-
if field_type == "object":
|
317
|
-
# Create nested model with explicit type annotation
|
318
|
-
object_model = create_dynamic_model(
|
319
|
-
field_schema,
|
320
|
-
base_name=f"{base_name}_{field_name}",
|
321
|
-
show_schema=False,
|
322
|
-
debug_validation=False,
|
323
|
-
)
|
324
|
-
return (object_model, Field(**field_kwargs))
|
325
|
-
|
326
|
-
# Handle additionalProperties
|
327
|
-
if "additionalProperties" in field_schema and isinstance(
|
328
|
-
field_schema["additionalProperties"], dict
|
329
|
-
):
|
330
|
-
# Create nested model with explicit type annotation
|
331
|
-
dict_value_model = create_dynamic_model(
|
332
|
-
field_schema["additionalProperties"],
|
333
|
-
base_name=f"{base_name}_{field_name}_Value",
|
334
|
-
show_schema=False,
|
335
|
-
debug_validation=False,
|
336
|
-
)
|
337
|
-
dict_type: Type[Dict[str, Any]] = Dict[str, dict_value_model] # type: ignore[valid-type]
|
338
|
-
return (dict_type, Field(**field_kwargs))
|
339
|
-
|
340
|
-
# Handle other types
|
341
|
-
if field_type == "string":
|
342
|
-
field_type_cls: Type[Any] = str
|
343
|
-
|
344
|
-
# Add string-specific constraints to field_kwargs
|
345
|
-
if "pattern" in field_schema:
|
346
|
-
field_kwargs["pattern"] = field_schema["pattern"]
|
347
|
-
if "minLength" in field_schema:
|
348
|
-
field_kwargs["min_length"] = field_schema["minLength"]
|
349
|
-
if "maxLength" in field_schema:
|
350
|
-
field_kwargs["max_length"] = field_schema["maxLength"]
|
351
|
-
|
352
|
-
# Handle special string formats
|
353
|
-
if "format" in field_schema:
|
354
|
-
if field_schema["format"] == "date-time":
|
355
|
-
field_type_cls = datetime
|
356
|
-
elif field_schema["format"] == "date":
|
357
|
-
field_type_cls = date
|
358
|
-
elif field_schema["format"] == "time":
|
359
|
-
field_type_cls = time
|
360
|
-
elif field_schema["format"] == "email":
|
361
|
-
field_type_cls = EmailStr
|
362
|
-
elif field_schema["format"] == "uri":
|
363
|
-
field_type_cls = AnyUrl
|
364
|
-
|
365
|
-
return (field_type_cls, Field(**field_kwargs))
|
366
|
-
|
367
|
-
if field_type == "number":
|
368
|
-
field_type_cls = float
|
369
|
-
|
370
|
-
# Add number-specific constraints to field_kwargs
|
371
|
-
if "minimum" in field_schema:
|
372
|
-
field_kwargs["ge"] = field_schema["minimum"]
|
373
|
-
if "maximum" in field_schema:
|
374
|
-
field_kwargs["le"] = field_schema["maximum"]
|
375
|
-
if "exclusiveMinimum" in field_schema:
|
376
|
-
field_kwargs["gt"] = field_schema["exclusiveMinimum"]
|
377
|
-
if "exclusiveMaximum" in field_schema:
|
378
|
-
field_kwargs["lt"] = field_schema["exclusiveMaximum"]
|
379
|
-
if "multipleOf" in field_schema:
|
380
|
-
field_kwargs["multiple_of"] = field_schema["multipleOf"]
|
381
|
-
|
382
|
-
return (field_type_cls, Field(**field_kwargs))
|
383
|
-
|
384
|
-
if field_type == "integer":
|
385
|
-
field_type_cls = int
|
386
|
-
|
387
|
-
# Add integer-specific constraints to field_kwargs
|
388
|
-
if "minimum" in field_schema:
|
389
|
-
field_kwargs["ge"] = field_schema["minimum"]
|
390
|
-
if "maximum" in field_schema:
|
391
|
-
field_kwargs["le"] = field_schema["maximum"]
|
392
|
-
if "exclusiveMinimum" in field_schema:
|
393
|
-
field_kwargs["gt"] = field_schema["exclusiveMinimum"]
|
394
|
-
if "exclusiveMaximum" in field_schema:
|
395
|
-
field_kwargs["lt"] = field_schema["exclusiveMaximum"]
|
396
|
-
if "multipleOf" in field_schema:
|
397
|
-
field_kwargs["multiple_of"] = field_schema["multipleOf"]
|
398
|
-
|
399
|
-
return (field_type_cls, Field(**field_kwargs))
|
400
|
-
|
401
|
-
if field_type == "boolean":
|
402
|
-
return (bool, Field(**field_kwargs))
|
403
|
-
|
404
|
-
if field_type == "null":
|
405
|
-
return (type(None), Field(**field_kwargs))
|
406
|
-
|
407
|
-
# Handle enum
|
408
|
-
if "enum" in field_schema:
|
409
|
-
enum_type = _create_enum_type(field_schema["enum"], field_name)
|
410
|
-
return (cast(Type[Any], enum_type), Field(**field_kwargs))
|
411
|
-
|
412
|
-
# Default to Any for unknown types
|
413
|
-
return (Any, Field(**field_kwargs))
|
414
|
-
|
415
|
-
|
416
|
-
T = TypeVar("T")
|
417
|
-
K = TypeVar("K")
|
418
|
-
V = TypeVar("V")
|
419
|
-
|
420
|
-
|
421
|
-
def validate_token_limits(
|
422
|
-
model: str, total_tokens: int, max_token_limit: Optional[int] = None
|
423
|
-
) -> None:
|
424
|
-
"""Validate token counts against model limits."""
|
425
|
-
registry = ModelRegistry()
|
426
|
-
capabilities = registry.get_capabilities(model)
|
427
|
-
context_limit = capabilities.context_window
|
428
|
-
output_limit = (
|
429
|
-
max_token_limit
|
430
|
-
if max_token_limit is not None
|
431
|
-
else capabilities.max_output_tokens
|
25
|
+
@click.group()
|
26
|
+
@click.version_option(version=__version__)
|
27
|
+
@click.option(
|
28
|
+
"--config",
|
29
|
+
type=click.Path(exists=True),
|
30
|
+
help="Configuration file path (default: ostruct.yaml)",
|
432
31
|
)
|
32
|
+
@click.pass_context
|
33
|
+
def cli_group(ctx: click.Context, config: Optional[str] = None) -> None:
|
34
|
+
"""ostruct - AI-powered structured output with multi-tool integration.
|
433
35
|
|
434
|
-
|
435
|
-
|
436
|
-
|
437
|
-
f"Total tokens ({total_tokens:,}) exceed model's context window limit "
|
438
|
-
f"of {context_limit:,} tokens"
|
439
|
-
)
|
36
|
+
ostruct transforms unstructured inputs into structured JSON using OpenAI APIs,
|
37
|
+
Jinja2 templates, and powerful tool integrations including Code Interpreter,
|
38
|
+
File Search, and MCP servers.
|
440
39
|
|
441
|
-
|
442
|
-
|
443
|
-
if remaining_tokens < output_limit:
|
444
|
-
raise ValueError(
|
445
|
-
f"Only {remaining_tokens:,} tokens remaining in context window, but "
|
446
|
-
f"output may require up to {output_limit:,} tokens"
|
447
|
-
)
|
40
|
+
🚀 QUICK START:
|
41
|
+
ostruct run template.j2 schema.json -V name=value
|
448
42
|
|
43
|
+
📁 FILE ROUTING (explicit tool assignment):
|
44
|
+
-ft/--file-for-template Template access only
|
45
|
+
-fc/--file-for-code-interpreter Code execution & analysis
|
46
|
+
-fs/--file-for-file-search Document search & retrieval
|
449
47
|
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
system_prompt_file: Optional[str],
|
454
|
-
template_context: Dict[str, Any],
|
455
|
-
env: jinja2.Environment,
|
456
|
-
ignore_task_sysprompt: bool = False,
|
457
|
-
) -> str:
|
458
|
-
"""Process system prompt from various sources.
|
48
|
+
⚡ EXAMPLES:
|
49
|
+
# Basic usage (unchanged)
|
50
|
+
ostruct run template.j2 schema.json -f config.yaml
|
459
51
|
|
460
|
-
|
461
|
-
|
462
|
-
system_prompt: Optional system prompt string
|
463
|
-
system_prompt_file: Optional path to system prompt file
|
464
|
-
template_context: Template context for rendering
|
465
|
-
env: Jinja2 environment
|
466
|
-
ignore_task_sysprompt: Whether to ignore system prompt in task template
|
52
|
+
# Multi-tool explicit routing
|
53
|
+
ostruct run analysis.j2 schema.json -fc data.csv -fs docs.pdf -ft config.yaml
|
467
54
|
|
468
|
-
|
469
|
-
|
55
|
+
# Advanced routing with --file-for
|
56
|
+
ostruct run task.j2 schema.json --file-for code-interpreter shared.json --file-for file-search shared.json
|
470
57
|
|
471
|
-
|
472
|
-
|
473
|
-
FileNotFoundError: If a prompt file does not exist
|
474
|
-
PathSecurityError: If a prompt file path violates security constraints
|
475
|
-
"""
|
476
|
-
# Default system prompt
|
477
|
-
default_prompt = "You are a helpful assistant."
|
58
|
+
# MCP server integration
|
59
|
+
ostruct run template.j2 schema.json --mcp-server deepwiki@https://mcp.deepwiki.com/sse
|
478
60
|
|
479
|
-
|
480
|
-
|
481
|
-
|
482
|
-
"Cannot specify both --system-prompt and --system-prompt-file"
|
483
|
-
)
|
484
|
-
|
485
|
-
# Try to get system prompt from CLI argument first
|
486
|
-
if system_prompt_file is not None:
|
61
|
+
📖 For detailed documentation: https://ostruct.readthedocs.io
|
62
|
+
"""
|
63
|
+
# Load configuration
|
487
64
|
try:
|
488
|
-
|
489
|
-
|
490
|
-
|
491
|
-
with open(path, "r", encoding="utf-8") as f:
|
492
|
-
system_prompt = f.read().strip()
|
493
|
-
except OstructFileNotFoundError as e:
|
494
|
-
raise SystemPromptError(
|
495
|
-
f"Failed to load system prompt file: {e}"
|
496
|
-
) from e
|
497
|
-
except PathSecurityError as e:
|
498
|
-
raise SystemPromptError(f"Invalid system prompt file: {e}") from e
|
499
|
-
|
500
|
-
if system_prompt is not None:
|
501
|
-
# Render system prompt with template context
|
502
|
-
try:
|
503
|
-
template = env.from_string(system_prompt)
|
504
|
-
return cast(str, template.render(**template_context).strip())
|
505
|
-
except jinja2.TemplateError as e:
|
506
|
-
raise SystemPromptError(f"Error rendering system prompt: {e}")
|
507
|
-
|
508
|
-
# If not ignoring task template system prompt, try to extract it
|
509
|
-
if not ignore_task_sysprompt:
|
510
|
-
try:
|
511
|
-
# Extract YAML frontmatter
|
512
|
-
if task_template.startswith("---\n"):
|
513
|
-
end = task_template.find("\n---\n", 4)
|
514
|
-
if end != -1:
|
515
|
-
frontmatter = task_template[4:end]
|
516
|
-
try:
|
517
|
-
metadata = yaml.safe_load(frontmatter)
|
518
|
-
if (
|
519
|
-
isinstance(metadata, dict)
|
520
|
-
and "system_prompt" in metadata
|
521
|
-
):
|
522
|
-
system_prompt = str(metadata["system_prompt"])
|
523
|
-
# Render system prompt with template context
|
524
|
-
try:
|
525
|
-
template = env.from_string(system_prompt)
|
526
|
-
return cast(
|
527
|
-
str,
|
528
|
-
template.render(
|
529
|
-
**template_context
|
530
|
-
).strip(),
|
531
|
-
)
|
532
|
-
except jinja2.TemplateError as e:
|
533
|
-
raise SystemPromptError(
|
534
|
-
f"Error rendering system prompt: {e}"
|
535
|
-
)
|
536
|
-
except yaml.YAMLError as e:
|
537
|
-
raise SystemPromptError(
|
538
|
-
f"Invalid YAML frontmatter: {e}"
|
539
|
-
)
|
540
|
-
|
65
|
+
app_config = OstructConfig.load(config)
|
66
|
+
ctx.ensure_object(dict)
|
67
|
+
ctx.obj["config"] = app_config
|
541
68
|
except Exception as e:
|
542
|
-
|
543
|
-
f"
|
544
|
-
|
545
|
-
|
546
|
-
# Fall back to default
|
547
|
-
return default_prompt
|
548
|
-
|
549
|
-
|
550
|
-
def validate_variable_mapping(
|
551
|
-
mapping: str, is_json: bool = False
|
552
|
-
) -> tuple[str, Any]:
|
553
|
-
"""Validate a variable mapping in name=value format."""
|
554
|
-
try:
|
555
|
-
name, value = mapping.split("=", 1)
|
556
|
-
if not name:
|
557
|
-
raise VariableNameError(
|
558
|
-
f"Empty name in {'JSON ' if is_json else ''}variable mapping"
|
69
|
+
click.secho(
|
70
|
+
f"Warning: Failed to load configuration: {e}",
|
71
|
+
fg="yellow",
|
72
|
+
err=True,
|
559
73
|
)
|
74
|
+
# Use default configuration
|
75
|
+
ctx.ensure_object(dict)
|
76
|
+
ctx.obj["config"] = OstructConfig()
|
560
77
|
|
561
|
-
|
562
|
-
try:
|
563
|
-
value = json.loads(value)
|
564
|
-
except json.JSONDecodeError as e:
|
565
|
-
raise InvalidJSONError(
|
566
|
-
f"Invalid JSON value for variable {name!r}: {value!r}",
|
567
|
-
context={"variable_name": name},
|
568
|
-
) from e
|
569
|
-
|
570
|
-
return name, value
|
571
|
-
|
572
|
-
except ValueError as e:
|
573
|
-
if "not enough values to unpack" in str(e):
|
574
|
-
raise VariableValueError(
|
575
|
-
f"Invalid {'JSON ' if is_json else ''}variable mapping "
|
576
|
-
f"(expected name=value format): {mapping!r}"
|
577
|
-
)
|
578
|
-
raise
|
579
|
-
|
580
|
-
|
581
|
-
@overload
|
582
|
-
def _validate_path_mapping_internal(
|
583
|
-
mapping: str,
|
584
|
-
is_dir: Literal[True],
|
585
|
-
base_dir: Optional[str] = None,
|
586
|
-
security_manager: Optional[SecurityManager] = None,
|
587
|
-
) -> Tuple[str, str]: ...
|
588
|
-
|
589
|
-
|
590
|
-
@overload
|
591
|
-
def _validate_path_mapping_internal(
|
592
|
-
mapping: str,
|
593
|
-
is_dir: Literal[False] = False,
|
594
|
-
base_dir: Optional[str] = None,
|
595
|
-
security_manager: Optional[SecurityManager] = None,
|
596
|
-
) -> Tuple[str, str]: ...
|
597
|
-
|
598
|
-
|
599
|
-
def _validate_path_mapping_internal(
|
600
|
-
mapping: str,
|
601
|
-
is_dir: bool = False,
|
602
|
-
base_dir: Optional[str] = None,
|
603
|
-
security_manager: Optional[SecurityManager] = None,
|
604
|
-
) -> Tuple[str, str]:
|
605
|
-
"""Validate a path mapping in the format "name=path".
|
606
|
-
|
607
|
-
Args:
|
608
|
-
mapping: The path mapping string (e.g., "myvar=/path/to/file").
|
609
|
-
is_dir: Whether the path is expected to be a directory (True) or file (False).
|
610
|
-
base_dir: Optional base directory to resolve relative paths against.
|
611
|
-
security_manager: Optional security manager to validate paths.
|
612
|
-
|
613
|
-
Returns:
|
614
|
-
A (name, path) tuple.
|
615
|
-
|
616
|
-
Raises:
|
617
|
-
VariableNameError: If the variable name portion is empty or invalid.
|
618
|
-
DirectoryNotFoundError: If is_dir=True and the path is not a directory or doesn't exist.
|
619
|
-
FileNotFoundError: If is_dir=False and the path is not a file or doesn't exist.
|
620
|
-
PathSecurityError: If the path is inaccessible or outside the allowed directory.
|
621
|
-
ValueError: If the format is invalid (missing "=").
|
622
|
-
OSError: If there is an underlying OS error (permissions, etc.).
|
623
|
-
"""
|
624
|
-
logger = logging.getLogger(__name__)
|
625
|
-
logger.debug("Starting path validation for mapping: %r", mapping)
|
626
|
-
logger.debug("Parameters - is_dir: %r, base_dir: %r", is_dir, base_dir)
|
627
|
-
|
628
|
-
try:
|
629
|
-
if not mapping or "=" not in mapping:
|
630
|
-
logger.debug("Invalid mapping format: %r", mapping)
|
631
|
-
raise ValueError(
|
632
|
-
"Invalid path mapping format. Expected format: name=path"
|
633
|
-
)
|
634
|
-
|
635
|
-
name, path = mapping.split("=", 1)
|
636
|
-
logger.debug("Split mapping - name: %r, path: %r", name, path)
|
637
|
-
|
638
|
-
if not name:
|
639
|
-
logger.debug("Empty name in mapping")
|
640
|
-
raise VariableNameError(
|
641
|
-
f"Empty name in {'directory' if is_dir else 'file'} mapping"
|
642
|
-
)
|
643
|
-
|
644
|
-
if not path:
|
645
|
-
logger.debug("Empty path in mapping")
|
646
|
-
raise VariableValueError("Path cannot be empty")
|
647
|
-
|
648
|
-
# Convert to Path object and resolve against base_dir if provided
|
649
|
-
logger.debug("Creating Path object for: %r", path)
|
650
|
-
path_obj = Path(path)
|
651
|
-
if base_dir:
|
652
|
-
logger.debug("Resolving against base_dir: %r", base_dir)
|
653
|
-
path_obj = Path(base_dir) / path_obj
|
654
|
-
logger.debug("Path object created: %r", path_obj)
|
655
|
-
|
656
|
-
# Resolve the path to catch directory traversal attempts
|
657
|
-
try:
|
658
|
-
logger.debug("Attempting to resolve path: %r", path_obj)
|
659
|
-
resolved_path = path_obj.resolve()
|
660
|
-
logger.debug("Resolved path: %r", resolved_path)
|
661
|
-
except OSError as e:
|
662
|
-
logger.error("Failed to resolve path: %s", e)
|
663
|
-
raise OSError(f"Failed to resolve path: {e}")
|
664
|
-
|
665
|
-
# Check for directory traversal
|
666
|
-
try:
|
667
|
-
base_path = (
|
668
|
-
Path.cwd() if base_dir is None else Path(base_dir).resolve()
|
669
|
-
)
|
670
|
-
if not str(resolved_path).startswith(str(base_path)):
|
671
|
-
raise PathSecurityError(
|
672
|
-
f"Path {str(path)!r} resolves to {str(resolved_path)!r} which is outside "
|
673
|
-
f"base directory {str(base_path)!r}"
|
674
|
-
)
|
675
|
-
except OSError as e:
|
676
|
-
raise OSError(f"Failed to resolve base path: {e}")
|
677
|
-
|
678
|
-
# Check if path exists
|
679
|
-
if not resolved_path.exists():
|
680
|
-
if is_dir:
|
681
|
-
raise DirectoryNotFoundError(f"Directory not found: {path!r}")
|
682
|
-
else:
|
683
|
-
raise FileNotFoundError(f"File not found: {path!r}")
|
684
|
-
|
685
|
-
# Check if path is correct type
|
686
|
-
if is_dir and not resolved_path.is_dir():
|
687
|
-
raise DirectoryNotFoundError(f"Path is not a directory: {path!r}")
|
688
|
-
elif not is_dir and not resolved_path.is_file():
|
689
|
-
raise FileNotFoundError(f"Path is not a file: {path!r}")
|
690
|
-
|
691
|
-
# Check if path is accessible
|
692
|
-
try:
|
693
|
-
if is_dir:
|
694
|
-
os.listdir(str(resolved_path))
|
695
|
-
else:
|
696
|
-
with open(str(resolved_path), "r", encoding="utf-8") as f:
|
697
|
-
f.read(1)
|
698
|
-
except OSError as e:
|
699
|
-
if e.errno == 13: # Permission denied
|
700
|
-
raise PathSecurityError(
|
701
|
-
f"Permission denied accessing path: {path!r}",
|
702
|
-
error_logged=True,
|
703
|
-
)
|
704
|
-
raise
|
705
|
-
|
706
|
-
if security_manager:
|
707
|
-
try:
|
708
|
-
security_manager.validate_path(str(resolved_path))
|
709
|
-
except PathSecurityError:
|
710
|
-
raise PathSecurityError.from_expanded_paths(
|
711
|
-
original_path=str(path),
|
712
|
-
expanded_path=str(resolved_path),
|
713
|
-
base_dir=str(security_manager.base_dir),
|
714
|
-
allowed_dirs=[
|
715
|
-
str(d) for d in security_manager.allowed_dirs
|
716
|
-
],
|
717
|
-
error_logged=True,
|
718
|
-
)
|
719
|
-
|
720
|
-
# Return the original path to maintain relative paths in the output
|
721
|
-
return name, path
|
722
|
-
|
723
|
-
except ValueError as e:
|
724
|
-
if "not enough values to unpack" in str(e):
|
725
|
-
raise VariableValueError(
|
726
|
-
f"Invalid {'directory' if is_dir else 'file'} mapping "
|
727
|
-
f"(expected name=path format): {mapping!r}"
|
728
|
-
)
|
729
|
-
raise
|
730
|
-
|
731
|
-
|
732
|
-
def validate_task_template(
|
733
|
-
task: Optional[str], task_file: Optional[str]
|
734
|
-
) -> str:
|
735
|
-
"""Validate and load a task template.
|
736
|
-
|
737
|
-
Args:
|
738
|
-
task: The task template string
|
739
|
-
task_file: Path to task template file
|
740
|
-
|
741
|
-
Returns:
|
742
|
-
The task template string
|
743
|
-
|
744
|
-
Raises:
|
745
|
-
TaskTemplateVariableError: If neither task nor task_file is provided, or if both are provided
|
746
|
-
TaskTemplateSyntaxError: If the template has invalid syntax
|
747
|
-
FileNotFoundError: If the template file does not exist
|
748
|
-
PathSecurityError: If the template file path violates security constraints
|
749
|
-
"""
|
750
|
-
if task is not None and task_file is not None:
|
751
|
-
raise TaskTemplateVariableError(
|
752
|
-
"Cannot specify both --task and --task-file"
|
753
|
-
)
|
754
|
-
|
755
|
-
if task is None and task_file is None:
|
756
|
-
raise TaskTemplateVariableError(
|
757
|
-
"Must specify either --task or --task-file"
|
758
|
-
)
|
759
|
-
|
760
|
-
template_content: str
|
761
|
-
if task_file is not None:
|
762
|
-
try:
|
763
|
-
with open(task_file, "r", encoding="utf-8") as f:
|
764
|
-
template_content = f.read()
|
765
|
-
except FileNotFoundError:
|
766
|
-
raise TaskTemplateVariableError(
|
767
|
-
f"Task template file not found: {task_file}"
|
768
|
-
)
|
769
|
-
except PermissionError:
|
770
|
-
raise TaskTemplateVariableError(
|
771
|
-
f"Permission denied reading task template file: {task_file}"
|
772
|
-
)
|
773
|
-
except Exception as e:
|
774
|
-
raise TaskTemplateVariableError(
|
775
|
-
f"Error reading task template file: {e}"
|
776
|
-
)
|
777
|
-
else:
|
778
|
-
template_content = task # type: ignore # We know task is str here due to the checks above
|
779
|
-
|
780
|
-
try:
|
781
|
-
env = jinja2.Environment(undefined=jinja2.StrictUndefined)
|
782
|
-
env.parse(template_content)
|
783
|
-
return template_content
|
784
|
-
except jinja2.TemplateSyntaxError as e:
|
785
|
-
raise TaskTemplateSyntaxError(
|
786
|
-
f"Invalid task template syntax at line {e.lineno}: {e.message}"
|
787
|
-
)
|
788
|
-
|
789
|
-
|
790
|
-
def validate_schema_file(
|
791
|
-
path: str,
|
792
|
-
verbose: bool = False,
|
793
|
-
) -> Dict[str, Any]:
|
794
|
-
"""Validate and load a JSON schema file.
|
795
|
-
|
796
|
-
Args:
|
797
|
-
path: Path to schema file
|
798
|
-
verbose: Whether to enable verbose logging
|
799
|
-
|
800
|
-
Returns:
|
801
|
-
The validated schema
|
802
|
-
|
803
|
-
Raises:
|
804
|
-
SchemaFileError: When file cannot be read
|
805
|
-
InvalidJSONError: When file contains invalid JSON
|
806
|
-
SchemaValidationError: When schema is invalid
|
807
|
-
"""
|
808
|
-
if verbose:
|
809
|
-
logger.info("Validating schema file: %s", path)
|
810
|
-
|
811
|
-
try:
|
812
|
-
logger.debug("Opening schema file: %s", path)
|
813
|
-
with open(path, "r", encoding="utf-8") as f:
|
814
|
-
logger.debug("Loading JSON from schema file")
|
815
|
-
try:
|
816
|
-
schema = json.load(f)
|
817
|
-
logger.debug(
|
818
|
-
"Successfully loaded JSON: %s",
|
819
|
-
json.dumps(schema, indent=2),
|
820
|
-
)
|
821
|
-
except json.JSONDecodeError as e:
|
822
|
-
logger.error("JSON decode error in %s: %s", path, str(e))
|
823
|
-
logger.debug(
|
824
|
-
"Error details - line: %d, col: %d, msg: %s",
|
825
|
-
e.lineno,
|
826
|
-
e.colno,
|
827
|
-
e.msg,
|
828
|
-
)
|
829
|
-
raise InvalidJSONError(
|
830
|
-
f"Invalid JSON in schema file {path}: {e}",
|
831
|
-
context={"schema_path": path},
|
832
|
-
) from e
|
833
|
-
except FileNotFoundError:
|
834
|
-
msg = f"Schema file not found: {path}"
|
835
|
-
logger.error(msg)
|
836
|
-
raise SchemaFileError(msg, schema_path=path)
|
837
|
-
except PermissionError:
|
838
|
-
msg = f"Permission denied reading schema file: {path}"
|
839
|
-
logger.error(msg)
|
840
|
-
raise SchemaFileError(msg, schema_path=path)
|
841
|
-
except Exception as e:
|
842
|
-
if isinstance(e, (InvalidJSONError, SchemaValidationError)):
|
843
|
-
raise
|
844
|
-
msg = f"Failed to read schema file {path}: {e}"
|
845
|
-
logger.error(msg)
|
846
|
-
logger.debug("Unexpected error details: %s", str(e))
|
847
|
-
raise SchemaFileError(msg, schema_path=path) from e
|
848
|
-
|
849
|
-
# Pre-validation structure checks
|
850
|
-
if verbose:
|
851
|
-
logger.info("Performing pre-validation structure checks")
|
852
|
-
logger.debug("Loaded schema: %s", json.dumps(schema, indent=2))
|
853
|
-
|
854
|
-
if not isinstance(schema, dict):
|
855
|
-
msg = f"Schema in {path} must be a JSON object"
|
856
|
-
logger.error(msg)
|
857
|
-
raise SchemaValidationError(
|
858
|
-
msg,
|
859
|
-
context={
|
860
|
-
"validation_type": "schema",
|
861
|
-
"schema_path": path,
|
862
|
-
},
|
863
|
-
)
|
864
|
-
|
865
|
-
# Validate schema structure
|
866
|
-
if "schema" in schema:
|
867
|
-
if verbose:
|
868
|
-
logger.debug("Found schema wrapper, validating inner schema")
|
869
|
-
inner_schema = schema["schema"]
|
870
|
-
if not isinstance(inner_schema, dict):
|
871
|
-
msg = f"Inner schema in {path} must be a JSON object"
|
872
|
-
logger.error(msg)
|
873
|
-
raise SchemaValidationError(
|
874
|
-
msg,
|
875
|
-
context={
|
876
|
-
"validation_type": "schema",
|
877
|
-
"schema_path": path,
|
878
|
-
},
|
879
|
-
)
|
880
|
-
if verbose:
|
881
|
-
logger.debug("Inner schema validated successfully")
|
882
|
-
logger.debug(
|
883
|
-
"Inner schema: %s", json.dumps(inner_schema, indent=2)
|
884
|
-
)
|
885
|
-
else:
|
886
|
-
if verbose:
|
887
|
-
logger.debug("No schema wrapper found, using schema as-is")
|
888
|
-
logger.debug("Schema: %s", json.dumps(schema, indent=2))
|
889
|
-
|
890
|
-
# Additional schema validation
|
891
|
-
if "type" not in schema.get("schema", schema):
|
892
|
-
msg = f"Schema in {path} must specify a type"
|
893
|
-
logger.error(msg)
|
894
|
-
raise SchemaValidationError(
|
895
|
-
msg,
|
896
|
-
context={
|
897
|
-
"validation_type": "schema",
|
898
|
-
"schema_path": path,
|
899
|
-
},
|
900
|
-
)
|
901
|
-
|
902
|
-
# Validate schema against JSON Schema spec
|
903
|
-
try:
|
904
|
-
validate_json_schema(schema)
|
905
|
-
except SchemaValidationError as e:
|
906
|
-
logger.error("Schema validation error: %s", str(e))
|
907
|
-
raise # Re-raise to preserve error chain
|
908
|
-
|
909
|
-
# Return the full schema including wrapper
|
910
|
-
return schema
|
911
|
-
|
912
|
-
|
913
|
-
def collect_template_files(
|
914
|
-
args: CLIParams,
|
915
|
-
security_manager: SecurityManager,
|
916
|
-
) -> Dict[str, Union[FileInfoList, str, List[str], Dict[str, str]]]:
|
917
|
-
"""Collect files from command line arguments.
|
918
|
-
|
919
|
-
Args:
|
920
|
-
args: Command line arguments
|
921
|
-
security_manager: Security manager for path validation
|
922
|
-
|
923
|
-
Returns:
|
924
|
-
Dictionary mapping variable names to file info objects
|
925
|
-
|
926
|
-
Raises:
|
927
|
-
PathSecurityError: If any file paths violate security constraints
|
928
|
-
ValueError: If file mappings are invalid or files cannot be accessed
|
929
|
-
"""
|
930
|
-
try:
|
931
|
-
# Get files, directories, and patterns from args - they are already tuples from Click's nargs=2
|
932
|
-
files = list(
|
933
|
-
args.get("files", [])
|
934
|
-
) # List of (name, path) tuples from Click
|
935
|
-
dirs = args.get("dir", []) # List of (name, dir) tuples from Click
|
936
|
-
patterns = args.get(
|
937
|
-
"patterns", []
|
938
|
-
) # List of (name, pattern) tuples from Click
|
939
|
-
|
940
|
-
# Collect files from directories and patterns
|
941
|
-
dir_files = collect_files(
|
942
|
-
file_mappings=cast(List[Tuple[str, Union[str, Path]]], files),
|
943
|
-
dir_mappings=cast(List[Tuple[str, Union[str, Path]]], dirs),
|
944
|
-
pattern_mappings=cast(
|
945
|
-
List[Tuple[str, Union[str, Path]]], patterns
|
946
|
-
),
|
947
|
-
dir_recursive=args.get("recursive", False),
|
948
|
-
security_manager=security_manager,
|
949
|
-
)
|
950
|
-
|
951
|
-
# Combine results
|
952
|
-
return cast(
|
953
|
-
Dict[str, Union[FileInfoList, str, List[str], Dict[str, str]]],
|
954
|
-
dir_files,
|
955
|
-
)
|
956
|
-
except PathSecurityError:
|
957
|
-
# Let PathSecurityError propagate without wrapping
|
958
|
-
raise
|
959
|
-
except (FileNotFoundError, DirectoryNotFoundError) as e:
|
960
|
-
# Convert FileNotFoundError to OstructFileNotFoundError
|
961
|
-
if isinstance(e, FileNotFoundError):
|
962
|
-
raise OstructFileNotFoundError(str(e))
|
963
|
-
# Let DirectoryNotFoundError propagate
|
964
|
-
raise
|
965
|
-
except Exception as e:
|
966
|
-
# Don't wrap InvalidJSONError
|
967
|
-
if isinstance(e, InvalidJSONError):
|
968
|
-
raise
|
969
|
-
# Check if this is a wrapped security error
|
970
|
-
if isinstance(e.__cause__, PathSecurityError):
|
971
|
-
raise e.__cause__
|
972
|
-
# Wrap other errors
|
973
|
-
raise ValueError(f"Error collecting files: {e}")
|
974
|
-
|
975
|
-
|
976
|
-
def collect_simple_variables(args: CLIParams) -> Dict[str, str]:
|
977
|
-
"""Collect simple string variables from --var arguments.
|
978
|
-
|
979
|
-
Args:
|
980
|
-
args: Command line arguments
|
981
|
-
|
982
|
-
Returns:
|
983
|
-
Dictionary mapping variable names to string values
|
984
|
-
|
985
|
-
Raises:
|
986
|
-
VariableNameError: If a variable name is invalid or duplicate
|
987
|
-
"""
|
988
|
-
variables: Dict[str, str] = {}
|
989
|
-
all_names: Set[str] = set()
|
990
|
-
|
991
|
-
if args.get("var"):
|
992
|
-
for mapping in args["var"]:
|
993
|
-
try:
|
994
|
-
# Handle both tuple format and string format
|
995
|
-
if isinstance(mapping, tuple):
|
996
|
-
name, value = mapping
|
997
|
-
else:
|
998
|
-
name, value = mapping.split("=", 1)
|
999
|
-
|
1000
|
-
if not name.isidentifier():
|
1001
|
-
raise VariableNameError(f"Invalid variable name: {name}")
|
1002
|
-
if name in all_names:
|
1003
|
-
raise VariableNameError(f"Duplicate variable name: {name}")
|
1004
|
-
variables[name] = value
|
1005
|
-
all_names.add(name)
|
1006
|
-
except ValueError:
|
1007
|
-
raise VariableNameError(
|
1008
|
-
f"Invalid variable mapping (expected name=value format): {mapping!r}"
|
1009
|
-
)
|
1010
|
-
|
1011
|
-
return variables
|
1012
|
-
|
1013
|
-
|
1014
|
-
def collect_json_variables(args: CLIParams) -> Dict[str, Any]:
|
1015
|
-
"""Collect JSON variables from --json-var arguments.
|
1016
|
-
|
1017
|
-
Args:
|
1018
|
-
args: Command line arguments
|
1019
|
-
|
1020
|
-
Returns:
|
1021
|
-
Dictionary mapping variable names to parsed JSON values
|
1022
|
-
|
1023
|
-
Raises:
|
1024
|
-
VariableNameError: If a variable name is invalid or duplicate
|
1025
|
-
InvalidJSONError: If a JSON value is invalid
|
1026
|
-
"""
|
1027
|
-
variables: Dict[str, Any] = {}
|
1028
|
-
all_names: Set[str] = set()
|
1029
|
-
|
1030
|
-
if args.get("json_var"):
|
1031
|
-
for mapping in args["json_var"]:
|
1032
|
-
try:
|
1033
|
-
# Handle both tuple format and string format
|
1034
|
-
if isinstance(mapping, tuple):
|
1035
|
-
name, value = (
|
1036
|
-
mapping # Value is already parsed by Click validator
|
1037
|
-
)
|
1038
|
-
else:
|
1039
|
-
try:
|
1040
|
-
name, json_str = mapping.split("=", 1)
|
1041
|
-
except ValueError:
|
1042
|
-
raise VariableNameError(
|
1043
|
-
f"Invalid JSON variable mapping format: {mapping}. Expected name=json"
|
1044
|
-
)
|
1045
|
-
try:
|
1046
|
-
value = json.loads(json_str)
|
1047
|
-
except json.JSONDecodeError as e:
|
1048
|
-
raise InvalidJSONError(
|
1049
|
-
f"Invalid JSON value for variable '{name}': {json_str}",
|
1050
|
-
context={"variable_name": name},
|
1051
|
-
) from e
|
1052
|
-
|
1053
|
-
if not name.isidentifier():
|
1054
|
-
raise VariableNameError(f"Invalid variable name: {name}")
|
1055
|
-
if name in all_names:
|
1056
|
-
raise VariableNameError(f"Duplicate variable name: {name}")
|
1057
|
-
|
1058
|
-
variables[name] = value
|
1059
|
-
all_names.add(name)
|
1060
|
-
except (VariableNameError, InvalidJSONError):
|
1061
|
-
raise
|
1062
|
-
|
1063
|
-
return variables
|
1064
|
-
|
1065
|
-
|
1066
|
-
async def create_template_context_from_args(
|
1067
|
-
args: CLIParams,
|
1068
|
-
security_manager: SecurityManager,
|
1069
|
-
) -> Dict[str, Any]:
|
1070
|
-
"""Create template context from command line arguments.
|
1071
|
-
|
1072
|
-
Args:
|
1073
|
-
args: Command line arguments
|
1074
|
-
security_manager: Security manager for path validation
|
1075
|
-
|
1076
|
-
Returns:
|
1077
|
-
Template context dictionary
|
1078
|
-
|
1079
|
-
Raises:
|
1080
|
-
PathSecurityError: If any file paths violate security constraints
|
1081
|
-
VariableError: If variable mappings are invalid
|
1082
|
-
ValueError: If file mappings are invalid or files cannot be accessed
|
1083
|
-
"""
|
1084
|
-
try:
|
1085
|
-
# Collect files from arguments
|
1086
|
-
files = collect_template_files(args, security_manager)
|
1087
|
-
|
1088
|
-
# Collect simple variables
|
1089
|
-
variables = collect_simple_variables(args)
|
1090
|
-
|
1091
|
-
# Collect JSON variables
|
1092
|
-
json_variables = collect_json_variables(args)
|
1093
|
-
|
1094
|
-
# Get stdin content if available
|
1095
|
-
stdin_content = None
|
78
|
+
# Check for registry updates in a non-intrusive way
|
1096
79
|
try:
|
1097
|
-
|
1098
|
-
|
1099
|
-
|
1100
|
-
|
80
|
+
update_message = get_update_notification()
|
81
|
+
if update_message:
|
82
|
+
click.secho(f"Note: {update_message}", fg="blue", err=True)
|
83
|
+
except Exception:
|
84
|
+
# Ensure any errors don't affect normal operation
|
1101
85
|
pass
|
1102
86
|
|
1103
|
-
|
1104
|
-
|
1105
|
-
|
1106
|
-
|
1107
|
-
security_manager=security_manager,
|
1108
|
-
stdin_content=stdin_content,
|
1109
|
-
)
|
1110
|
-
|
1111
|
-
# Add current model to context
|
1112
|
-
context["current_model"] = args["model"]
|
1113
|
-
|
1114
|
-
return context
|
1115
|
-
|
1116
|
-
except PathSecurityError:
|
1117
|
-
# Let PathSecurityError propagate without wrapping
|
1118
|
-
raise
|
1119
|
-
except (FileNotFoundError, DirectoryNotFoundError) as e:
|
1120
|
-
# Convert FileNotFoundError to OstructFileNotFoundError
|
1121
|
-
if isinstance(e, FileNotFoundError):
|
1122
|
-
raise OstructFileNotFoundError(str(e))
|
1123
|
-
# Let DirectoryNotFoundError propagate
|
1124
|
-
raise
|
1125
|
-
except Exception as e:
|
1126
|
-
# Don't wrap InvalidJSONError
|
1127
|
-
if isinstance(e, InvalidJSONError):
|
1128
|
-
raise
|
1129
|
-
# Check if this is a wrapped security error
|
1130
|
-
if isinstance(e.__cause__, PathSecurityError):
|
1131
|
-
raise e.__cause__
|
1132
|
-
# Wrap other errors
|
1133
|
-
raise ValueError(f"Error collecting files: {e}")
|
1134
|
-
|
1135
|
-
|
1136
|
-
def validate_security_manager(
|
1137
|
-
base_dir: Optional[str] = None,
|
1138
|
-
allowed_dirs: Optional[List[str]] = None,
|
1139
|
-
allowed_dir_file: Optional[str] = None,
|
1140
|
-
) -> SecurityManager:
|
1141
|
-
"""Validate and create security manager.
|
1142
|
-
|
1143
|
-
Args:
|
1144
|
-
base_dir: Base directory for file access. Defaults to current working directory.
|
1145
|
-
allowed_dirs: Optional list of additional allowed directories
|
1146
|
-
allowed_dir_file: Optional file containing allowed directories
|
1147
|
-
|
1148
|
-
Returns:
|
1149
|
-
Configured SecurityManager instance
|
1150
|
-
|
1151
|
-
Raises:
|
1152
|
-
PathSecurityError: If any paths violate security constraints
|
1153
|
-
DirectoryNotFoundError: If any directories do not exist
|
1154
|
-
"""
|
1155
|
-
# Use current working directory if base_dir is None
|
1156
|
-
if base_dir is None:
|
1157
|
-
base_dir = os.getcwd()
|
1158
|
-
|
1159
|
-
# Create security manager with base directory
|
1160
|
-
security_manager = SecurityManager(base_dir)
|
1161
|
-
|
1162
|
-
# Add explicitly allowed directories
|
1163
|
-
if allowed_dirs:
|
1164
|
-
for dir_path in allowed_dirs:
|
1165
|
-
security_manager.add_allowed_directory(dir_path)
|
1166
|
-
|
1167
|
-
# Add directories from file if specified
|
1168
|
-
if allowed_dir_file:
|
1169
|
-
try:
|
1170
|
-
with open(allowed_dir_file, "r", encoding="utf-8") as f:
|
1171
|
-
for line in f:
|
1172
|
-
line = line.strip()
|
1173
|
-
if line and not line.startswith("#"):
|
1174
|
-
security_manager.add_allowed_directory(line)
|
1175
|
-
except OSError as e:
|
1176
|
-
raise DirectoryNotFoundError(
|
1177
|
-
f"Failed to read allowed directories file: {e}"
|
1178
|
-
)
|
1179
|
-
|
1180
|
-
return security_manager
|
1181
|
-
|
1182
|
-
|
1183
|
-
def parse_var(var_str: str) -> Tuple[str, str]:
|
1184
|
-
"""Parse a simple variable string in the format 'name=value'.
|
1185
|
-
|
1186
|
-
Args:
|
1187
|
-
var_str: Variable string in format 'name=value'
|
1188
|
-
|
1189
|
-
Returns:
|
1190
|
-
Tuple of (name, value)
|
1191
|
-
|
1192
|
-
Raises:
|
1193
|
-
VariableNameError: If variable name is empty or invalid
|
1194
|
-
VariableValueError: If variable format is invalid
|
1195
|
-
"""
|
1196
|
-
try:
|
1197
|
-
name, value = var_str.split("=", 1)
|
1198
|
-
if not name:
|
1199
|
-
raise VariableNameError("Empty name in variable mapping")
|
1200
|
-
if not name.isidentifier():
|
1201
|
-
raise VariableNameError(
|
1202
|
-
f"Invalid variable name: {name}. Must be a valid Python identifier"
|
1203
|
-
)
|
1204
|
-
return name, value
|
1205
|
-
except ValueError as e:
|
1206
|
-
if "not enough values to unpack" in str(e):
|
1207
|
-
raise VariableValueError(
|
1208
|
-
f"Invalid variable mapping (expected name=value format): {var_str!r}"
|
1209
|
-
)
|
1210
|
-
raise
|
1211
|
-
|
87
|
+
# Add all commands from the command module
|
88
|
+
command_group = create_command_group()
|
89
|
+
for command in command_group.commands.values():
|
90
|
+
cli_group.add_command(command)
|
1212
91
|
|
1213
|
-
|
1214
|
-
"""Parse a JSON variable string in the format 'name=json_value'.
|
1215
|
-
|
1216
|
-
Args:
|
1217
|
-
var_str: Variable string in format 'name=json_value'
|
1218
|
-
|
1219
|
-
Returns:
|
1220
|
-
Tuple of (name, parsed_value)
|
1221
|
-
|
1222
|
-
Raises:
|
1223
|
-
VariableNameError: If variable name is empty or invalid
|
1224
|
-
VariableValueError: If variable format is invalid
|
1225
|
-
InvalidJSONError: If JSON value is invalid
|
1226
|
-
"""
|
1227
|
-
try:
|
1228
|
-
name, json_str = var_str.split("=", 1)
|
1229
|
-
if not name:
|
1230
|
-
raise VariableNameError("Empty name in JSON variable mapping")
|
1231
|
-
if not name.isidentifier():
|
1232
|
-
raise VariableNameError(
|
1233
|
-
f"Invalid variable name: {name}. Must be a valid Python identifier"
|
1234
|
-
)
|
1235
|
-
|
1236
|
-
try:
|
1237
|
-
value = json.loads(json_str)
|
1238
|
-
except json.JSONDecodeError as e:
|
1239
|
-
raise InvalidJSONError(
|
1240
|
-
f"Error parsing JSON for variable '{name}': {str(e)}. Input was: {json_str}",
|
1241
|
-
context={"variable_name": name},
|
1242
|
-
)
|
1243
|
-
|
1244
|
-
return name, value
|
1245
|
-
|
1246
|
-
except ValueError as e:
|
1247
|
-
if "not enough values to unpack" in str(e):
|
1248
|
-
raise VariableValueError(
|
1249
|
-
f"Invalid JSON variable mapping (expected name=json format): {var_str!r}"
|
1250
|
-
)
|
1251
|
-
raise
|
1252
|
-
|
1253
|
-
|
1254
|
-
def handle_error(e: Exception) -> None:
|
1255
|
-
"""Handle CLI errors and display appropriate messages.
|
1256
|
-
|
1257
|
-
Maintains specific error type handling while reducing duplication.
|
1258
|
-
Provides enhanced debug logging for CLI errors.
|
1259
|
-
"""
|
1260
|
-
# 1. Determine error type and message
|
1261
|
-
if isinstance(e, SchemaValidationError):
|
1262
|
-
msg = str(e) # Already formatted in SchemaValidationError
|
1263
|
-
exit_code = e.exit_code
|
1264
|
-
elif isinstance(e, ModelCreationError):
|
1265
|
-
# Unwrap ModelCreationError that might wrap SchemaValidationError
|
1266
|
-
if isinstance(e.__cause__, SchemaValidationError):
|
1267
|
-
return handle_error(e.__cause__)
|
1268
|
-
msg = f"Model creation error: {str(e)}"
|
1269
|
-
exit_code = ExitCode.SCHEMA_ERROR
|
1270
|
-
elif isinstance(e, click.UsageError):
|
1271
|
-
msg = f"Usage error: {str(e)}"
|
1272
|
-
exit_code = ExitCode.USAGE_ERROR
|
1273
|
-
elif isinstance(e, SchemaFileError):
|
1274
|
-
msg = str(e) # Use existing __str__ formatting
|
1275
|
-
exit_code = ExitCode.SCHEMA_ERROR
|
1276
|
-
elif isinstance(e, (InvalidJSONError, json.JSONDecodeError)):
|
1277
|
-
msg = f"Invalid JSON error: {str(e)}"
|
1278
|
-
exit_code = ExitCode.DATA_ERROR
|
1279
|
-
elif isinstance(e, CLIError):
|
1280
|
-
msg = str(e) # Use existing __str__ formatting
|
1281
|
-
exit_code = ExitCode(e.exit_code) # Convert int to ExitCode
|
1282
|
-
else:
|
1283
|
-
msg = f"Unexpected error: {str(e)}"
|
1284
|
-
exit_code = ExitCode.INTERNAL_ERROR
|
1285
|
-
|
1286
|
-
# 2. Debug logging
|
1287
|
-
if isinstance(e, CLIError) and logger.isEnabledFor(logging.DEBUG):
|
1288
|
-
# Format context fields with lowercase keys and simple values
|
1289
|
-
context_str = ""
|
1290
|
-
if hasattr(e, "context") and e.context:
|
1291
|
-
for key, value in sorted(e.context.items()):
|
1292
|
-
if key not in {
|
1293
|
-
"timestamp",
|
1294
|
-
"host",
|
1295
|
-
"version",
|
1296
|
-
"python_version",
|
1297
|
-
}:
|
1298
|
-
if isinstance(value, dict):
|
1299
|
-
context_str += (
|
1300
|
-
f"{key.lower()}:\n{json.dumps(value, indent=2)}\n"
|
1301
|
-
)
|
1302
|
-
else:
|
1303
|
-
context_str += f"{key.lower()}: {value}\n"
|
1304
|
-
|
1305
|
-
logger.debug(
|
1306
|
-
"Error details:\n"
|
1307
|
-
f"Type: {type(e).__name__}\n"
|
1308
|
-
f"{context_str.rstrip()}"
|
1309
|
-
)
|
1310
|
-
elif not isinstance(e, click.UsageError):
|
1311
|
-
logger.error(msg, exc_info=True)
|
1312
|
-
else:
|
1313
|
-
logger.error(msg)
|
92
|
+
return cli_group
|
1314
93
|
|
1315
|
-
# 3. User output
|
1316
|
-
click.secho(msg, fg="red", err=True)
|
1317
|
-
sys.exit(exit_code)
|
1318
94
|
|
1319
|
-
|
1320
|
-
|
1321
|
-
"""Validate model parameters against model capabilities.
|
1322
|
-
|
1323
|
-
Args:
|
1324
|
-
model: The model name to validate parameters for
|
1325
|
-
params: Dictionary of parameter names and values to validate
|
1326
|
-
|
1327
|
-
Raises:
|
1328
|
-
CLIError: If any parameters are not supported by the model
|
1329
|
-
"""
|
1330
|
-
try:
|
1331
|
-
capabilities = ModelRegistry().get_capabilities(model)
|
1332
|
-
for param_name, value in params.items():
|
1333
|
-
try:
|
1334
|
-
capabilities.validate_parameter(param_name, value)
|
1335
|
-
except OpenAIClientError as e:
|
1336
|
-
logger.error(
|
1337
|
-
"Validation failed for model %s: %s", model, str(e)
|
1338
|
-
)
|
1339
|
-
raise CLIError(
|
1340
|
-
str(e),
|
1341
|
-
exit_code=ExitCode.VALIDATION_ERROR,
|
1342
|
-
context={
|
1343
|
-
"model": model,
|
1344
|
-
"param": param_name,
|
1345
|
-
"value": value,
|
1346
|
-
},
|
1347
|
-
)
|
1348
|
-
except (ModelNotSupportedError, ModelVersionError) as e:
|
1349
|
-
logger.error("Model validation failed: %s", str(e))
|
1350
|
-
raise CLIError(
|
1351
|
-
str(e),
|
1352
|
-
exit_code=ExitCode.VALIDATION_ERROR,
|
1353
|
-
context={"model": model},
|
1354
|
-
)
|
1355
|
-
|
1356
|
-
|
1357
|
-
async def stream_structured_output(
|
1358
|
-
client: AsyncOpenAI,
|
1359
|
-
model: str,
|
1360
|
-
system_prompt: str,
|
1361
|
-
user_prompt: str,
|
1362
|
-
output_schema: Type[BaseModel],
|
1363
|
-
output_file: Optional[str] = None,
|
1364
|
-
**kwargs: Any,
|
1365
|
-
) -> AsyncGenerator[BaseModel, None]:
|
1366
|
-
"""Stream structured output from OpenAI API.
|
1367
|
-
|
1368
|
-
This function follows the guide's recommendation for a focused async streaming function.
|
1369
|
-
It handles the core streaming logic and resource cleanup.
|
1370
|
-
|
1371
|
-
Args:
|
1372
|
-
client: The OpenAI client to use
|
1373
|
-
model: The model to use
|
1374
|
-
system_prompt: The system prompt to use
|
1375
|
-
user_prompt: The user prompt to use
|
1376
|
-
output_schema: The Pydantic model to validate responses against
|
1377
|
-
output_file: Optional file to write output to
|
1378
|
-
**kwargs: Additional parameters to pass to the API
|
1379
|
-
|
1380
|
-
Returns:
|
1381
|
-
An async generator yielding validated model instances
|
1382
|
-
|
1383
|
-
Raises:
|
1384
|
-
ValueError: If the model does not support structured output or parameters are invalid
|
1385
|
-
StreamInterruptedError: If the stream is interrupted
|
1386
|
-
APIResponseError: If there is an API error
|
1387
|
-
"""
|
1388
|
-
try:
|
1389
|
-
# Check if model supports structured output using openai_structured's function
|
1390
|
-
if not supports_structured_output(model):
|
1391
|
-
raise ValueError(
|
1392
|
-
f"Model {model} does not support structured output with json_schema response format. "
|
1393
|
-
"Please use a model that supports structured output."
|
1394
|
-
)
|
1395
|
-
|
1396
|
-
# Extract non-model parameters
|
1397
|
-
on_log = kwargs.pop("on_log", None)
|
1398
|
-
|
1399
|
-
# Handle model-specific parameters
|
1400
|
-
stream_kwargs = {}
|
1401
|
-
registry = ModelRegistry()
|
1402
|
-
capabilities = registry.get_capabilities(model)
|
1403
|
-
|
1404
|
-
# Validate and include supported parameters
|
1405
|
-
for param_name, value in kwargs.items():
|
1406
|
-
if param_name in capabilities.supported_parameters:
|
1407
|
-
# Validate the parameter value
|
1408
|
-
capabilities.validate_parameter(param_name, value)
|
1409
|
-
stream_kwargs[param_name] = value
|
1410
|
-
else:
|
1411
|
-
logger.warning(
|
1412
|
-
f"Parameter {param_name} is not supported by model {model} and will be ignored"
|
1413
|
-
)
|
1414
|
-
|
1415
|
-
# Log the API request details
|
1416
|
-
logger.debug("Making OpenAI API request with:")
|
1417
|
-
logger.debug("Model: %s", model)
|
1418
|
-
logger.debug("System prompt: %s", system_prompt)
|
1419
|
-
logger.debug("User prompt: %s", user_prompt)
|
1420
|
-
logger.debug("Parameters: %s", json.dumps(stream_kwargs, indent=2))
|
1421
|
-
logger.debug("Schema: %s", output_schema.model_json_schema())
|
1422
|
-
|
1423
|
-
# Use the async generator from openai_structured directly
|
1424
|
-
async for chunk in async_openai_structured_stream(
|
1425
|
-
client=client,
|
1426
|
-
model=model,
|
1427
|
-
system_prompt=system_prompt,
|
1428
|
-
user_prompt=user_prompt,
|
1429
|
-
output_schema=output_schema,
|
1430
|
-
on_log=on_log, # Pass non-model parameters directly to the function
|
1431
|
-
**stream_kwargs, # Pass only validated model parameters
|
1432
|
-
):
|
1433
|
-
yield chunk
|
1434
|
-
|
1435
|
-
except APIResponseError as e:
|
1436
|
-
if "Invalid schema for response_format" in str(
|
1437
|
-
e
|
1438
|
-
) and 'type: "array"' in str(e):
|
1439
|
-
error_msg = (
|
1440
|
-
"OpenAI API Schema Error: The schema must have a root type of 'object', not 'array'. "
|
1441
|
-
"To fix this:\n"
|
1442
|
-
"1. Wrap your array in an object property, e.g.:\n"
|
1443
|
-
" {\n"
|
1444
|
-
' "type": "object",\n'
|
1445
|
-
' "properties": {\n'
|
1446
|
-
' "items": {\n'
|
1447
|
-
' "type": "array",\n'
|
1448
|
-
' "items": { ... your array items schema ... }\n'
|
1449
|
-
" }\n"
|
1450
|
-
" }\n"
|
1451
|
-
" }\n"
|
1452
|
-
"2. Make sure to update your template to handle the wrapper object."
|
1453
|
-
)
|
1454
|
-
logger.error(error_msg)
|
1455
|
-
raise InvalidResponseFormatError(error_msg)
|
1456
|
-
logger.error(f"API error: {e}")
|
1457
|
-
raise
|
1458
|
-
except (
|
1459
|
-
StreamInterruptedError,
|
1460
|
-
StreamBufferError,
|
1461
|
-
StreamParseError,
|
1462
|
-
EmptyResponseError,
|
1463
|
-
InvalidResponseFormatError,
|
1464
|
-
) as e:
|
1465
|
-
logger.error("Stream error: %s", str(e))
|
1466
|
-
raise
|
1467
|
-
finally:
|
1468
|
-
# Always ensure client is properly closed
|
1469
|
-
await client.close()
|
1470
|
-
|
1471
|
-
|
1472
|
-
@click.group()
|
1473
|
-
@click.version_option(version=__version__)
|
1474
|
-
def cli() -> None:
|
1475
|
-
"""ostruct CLI - Make structured OpenAI API calls.
|
1476
|
-
|
1477
|
-
ostruct allows you to invoke OpenAI Structured Output to produce structured JSON
|
1478
|
-
output using templates and JSON schemas. It provides support for file handling, variable
|
1479
|
-
substitution, and output validation.
|
1480
|
-
|
1481
|
-
For detailed documentation, visit: https://ostruct.readthedocs.io
|
1482
|
-
|
1483
|
-
Examples:
|
1484
|
-
|
1485
|
-
# Basic usage with a template and schema
|
1486
|
-
|
1487
|
-
ostruct run task.j2 schema.json -V name=value
|
1488
|
-
|
1489
|
-
# Process files with recursive directory scanning
|
1490
|
-
|
1491
|
-
ostruct run template.j2 schema.json -f code main.py -d src ./src -R
|
1492
|
-
|
1493
|
-
# Use JSON variables and custom model parameters
|
1494
|
-
|
1495
|
-
ostruct run task.j2 schema.json -J config='{"env":"prod"}' -m o3-mini
|
1496
|
-
"""
|
1497
|
-
# Check for registry updates in a non-intrusive way
|
1498
|
-
try:
|
1499
|
-
update_message = get_update_notification()
|
1500
|
-
if update_message:
|
1501
|
-
click.secho(f"Note: {update_message}", fg="blue", err=True)
|
1502
|
-
except Exception:
|
1503
|
-
# Ensure any errors don't affect normal operation
|
1504
|
-
pass
|
1505
|
-
|
1506
|
-
|
1507
|
-
@cli.command()
|
1508
|
-
@click.argument("task_template", type=click.Path(exists=True))
|
1509
|
-
@click.argument("schema_file", type=click.Path(exists=True))
|
1510
|
-
@all_options
|
1511
|
-
@click.pass_context
|
1512
|
-
def run(
|
1513
|
-
ctx: click.Context,
|
1514
|
-
task_template: str,
|
1515
|
-
schema_file: str,
|
1516
|
-
**kwargs: Any,
|
1517
|
-
) -> None:
|
1518
|
-
"""Run a structured task with template and schema.
|
1519
|
-
|
1520
|
-
Args:
|
1521
|
-
ctx: Click context
|
1522
|
-
task_template: Path to task template file
|
1523
|
-
schema_file: Path to schema file
|
1524
|
-
**kwargs: Additional CLI options
|
1525
|
-
"""
|
1526
|
-
try:
|
1527
|
-
# Convert Click parameters to typed dict
|
1528
|
-
params: CLIParams = {
|
1529
|
-
"task_file": task_template,
|
1530
|
-
"task": None,
|
1531
|
-
"schema_file": schema_file,
|
1532
|
-
}
|
1533
|
-
# Add only valid keys from kwargs
|
1534
|
-
valid_keys = set(CLIParams.__annotations__.keys())
|
1535
|
-
for k, v in kwargs.items():
|
1536
|
-
if k in valid_keys:
|
1537
|
-
params[k] = v # type: ignore[literal-required]
|
1538
|
-
|
1539
|
-
# Run the async function synchronously
|
1540
|
-
loop = asyncio.new_event_loop()
|
1541
|
-
asyncio.set_event_loop(loop)
|
1542
|
-
try:
|
1543
|
-
exit_code = loop.run_until_complete(run_cli_async(params))
|
1544
|
-
sys.exit(int(exit_code))
|
1545
|
-
except SchemaValidationError as e:
|
1546
|
-
# Log the error with full context
|
1547
|
-
logger.error("Schema validation error: %s", str(e))
|
1548
|
-
if e.context:
|
1549
|
-
logger.debug(
|
1550
|
-
"Error context: %s", json.dumps(e.context, indent=2)
|
1551
|
-
)
|
1552
|
-
# Re-raise to preserve error chain and exit code
|
1553
|
-
raise
|
1554
|
-
except (CLIError, InvalidJSONError, SchemaFileError) as e:
|
1555
|
-
handle_error(e)
|
1556
|
-
sys.exit(
|
1557
|
-
e.exit_code
|
1558
|
-
if hasattr(e, "exit_code")
|
1559
|
-
else ExitCode.INTERNAL_ERROR
|
1560
|
-
)
|
1561
|
-
except click.UsageError as e:
|
1562
|
-
handle_error(e)
|
1563
|
-
sys.exit(ExitCode.USAGE_ERROR)
|
1564
|
-
except Exception as e:
|
1565
|
-
handle_error(e)
|
1566
|
-
sys.exit(ExitCode.INTERNAL_ERROR)
|
1567
|
-
finally:
|
1568
|
-
loop.close()
|
1569
|
-
except KeyboardInterrupt:
|
1570
|
-
logger.info("Operation cancelled by user")
|
1571
|
-
raise
|
1572
|
-
|
1573
|
-
|
1574
|
-
@cli.command("update-registry")
|
1575
|
-
@click.option(
|
1576
|
-
"--url",
|
1577
|
-
help="URL to fetch the registry from. Defaults to official repository.",
|
1578
|
-
default=None,
|
1579
|
-
)
|
1580
|
-
@click.option(
|
1581
|
-
"--force",
|
1582
|
-
is_flag=True,
|
1583
|
-
help="Force update even if the registry is already up to date.",
|
1584
|
-
default=False,
|
1585
|
-
)
|
1586
|
-
def update_registry(url: Optional[str] = None, force: bool = False) -> None:
|
1587
|
-
"""Update the model registry with the latest model definitions.
|
1588
|
-
|
1589
|
-
This command fetches the latest model registry from the official repository
|
1590
|
-
or a custom URL if provided, and updates the local registry file.
|
1591
|
-
|
1592
|
-
Example:
|
1593
|
-
ostruct update-registry
|
1594
|
-
ostruct update-registry --url https://example.com/models.yml
|
1595
|
-
"""
|
1596
|
-
try:
|
1597
|
-
registry = ModelRegistry()
|
1598
|
-
|
1599
|
-
# Show current registry config path
|
1600
|
-
config_path = registry._config_path
|
1601
|
-
click.echo(f"Current registry file: {config_path}")
|
1602
|
-
|
1603
|
-
if force:
|
1604
|
-
click.echo("Forcing registry update...")
|
1605
|
-
success = registry.refresh_from_remote(url)
|
1606
|
-
if success:
|
1607
|
-
click.echo("✅ Registry successfully updated!")
|
1608
|
-
else:
|
1609
|
-
click.echo(
|
1610
|
-
"❌ Failed to update registry. See logs for details."
|
1611
|
-
)
|
1612
|
-
sys.exit(ExitCode.SUCCESS.value)
|
1613
|
-
|
1614
|
-
if config_path is None or not os.path.exists(config_path):
|
1615
|
-
click.echo("Registry file not found. Creating new one...")
|
1616
|
-
success = registry.refresh_from_remote(url)
|
1617
|
-
if success:
|
1618
|
-
click.echo("✅ Registry successfully created!")
|
1619
|
-
else:
|
1620
|
-
click.echo(
|
1621
|
-
"❌ Failed to create registry. See logs for details."
|
1622
|
-
)
|
1623
|
-
sys.exit(ExitCode.SUCCESS.value)
|
1624
|
-
|
1625
|
-
# Use the built-in update checking functionality
|
1626
|
-
click.echo("Checking for updates...")
|
1627
|
-
update_result = registry.check_for_updates()
|
1628
|
-
|
1629
|
-
if update_result.status == RegistryUpdateStatus.UPDATE_AVAILABLE:
|
1630
|
-
click.echo(
|
1631
|
-
f"{click.style('✓', fg='green')} {update_result.message}"
|
1632
|
-
)
|
1633
|
-
exit_code = ExitCode.SUCCESS
|
1634
|
-
elif update_result.status == RegistryUpdateStatus.ALREADY_CURRENT:
|
1635
|
-
click.echo(
|
1636
|
-
f"{click.style('✓', fg='green')} Registry is up to date"
|
1637
|
-
)
|
1638
|
-
exit_code = ExitCode.SUCCESS
|
1639
|
-
else:
|
1640
|
-
click.echo("❓ Unable to determine if updates are available.")
|
1641
|
-
|
1642
|
-
sys.exit(exit_code)
|
1643
|
-
except Exception as e:
|
1644
|
-
click.echo(f"❌ Error updating registry: {str(e)}")
|
1645
|
-
sys.exit(ExitCode.API_ERROR.value)
|
1646
|
-
|
1647
|
-
|
1648
|
-
async def validate_model_params(args: CLIParams) -> Dict[str, Any]:
|
1649
|
-
"""Validate model parameters and return a dictionary of valid parameters.
|
1650
|
-
|
1651
|
-
Args:
|
1652
|
-
args: Command line arguments
|
1653
|
-
|
1654
|
-
Returns:
|
1655
|
-
Dictionary of validated model parameters
|
1656
|
-
|
1657
|
-
Raises:
|
1658
|
-
CLIError: If model parameters are invalid
|
1659
|
-
"""
|
1660
|
-
params = {
|
1661
|
-
"temperature": args.get("temperature"),
|
1662
|
-
"max_output_tokens": args.get("max_output_tokens"),
|
1663
|
-
"top_p": args.get("top_p"),
|
1664
|
-
"frequency_penalty": args.get("frequency_penalty"),
|
1665
|
-
"presence_penalty": args.get("presence_penalty"),
|
1666
|
-
"reasoning_effort": args.get("reasoning_effort"),
|
1667
|
-
}
|
1668
|
-
# Remove None values
|
1669
|
-
params = {k: v for k, v in params.items() if v is not None}
|
1670
|
-
validate_model_parameters(args["model"], params)
|
1671
|
-
return params
|
1672
|
-
|
1673
|
-
|
1674
|
-
async def validate_inputs(
|
1675
|
-
args: CLIParams,
|
1676
|
-
) -> Tuple[
|
1677
|
-
SecurityManager, str, Dict[str, Any], Dict[str, Any], jinja2.Environment
|
1678
|
-
]:
|
1679
|
-
"""Validate all input parameters and return validated components.
|
1680
|
-
|
1681
|
-
Args:
|
1682
|
-
args: Command line arguments
|
1683
|
-
|
1684
|
-
Returns:
|
1685
|
-
Tuple containing:
|
1686
|
-
- SecurityManager instance
|
1687
|
-
- Task template string
|
1688
|
-
- Schema dictionary
|
1689
|
-
- Template context dictionary
|
1690
|
-
- Jinja2 environment
|
1691
|
-
|
1692
|
-
Raises:
|
1693
|
-
CLIError: For various validation errors
|
1694
|
-
SchemaValidationError: When schema is invalid
|
1695
|
-
"""
|
1696
|
-
logger.debug("=== Input Validation Phase ===")
|
1697
|
-
security_manager = validate_security_manager(
|
1698
|
-
base_dir=args.get("base_dir"),
|
1699
|
-
allowed_dirs=args.get("allowed_dirs"),
|
1700
|
-
allowed_dir_file=args.get("allowed_dir_file"),
|
1701
|
-
)
|
1702
|
-
|
1703
|
-
task_template = validate_task_template(
|
1704
|
-
args.get("task"), args.get("task_file")
|
1705
|
-
)
|
1706
|
-
|
1707
|
-
# Load and validate schema
|
1708
|
-
logger.debug("Validating schema from %s", args["schema_file"])
|
1709
|
-
try:
|
1710
|
-
schema = validate_schema_file(
|
1711
|
-
args["schema_file"], args.get("verbose", False)
|
1712
|
-
)
|
1713
|
-
|
1714
|
-
# Validate schema structure before any model creation
|
1715
|
-
validate_json_schema(
|
1716
|
-
schema
|
1717
|
-
) # This will raise SchemaValidationError if invalid
|
1718
|
-
except SchemaValidationError as e:
|
1719
|
-
logger.error("Schema validation error: %s", str(e))
|
1720
|
-
raise # Re-raise the SchemaValidationError to preserve the error chain
|
1721
|
-
|
1722
|
-
template_context = await create_template_context_from_args(
|
1723
|
-
args, security_manager
|
1724
|
-
)
|
1725
|
-
env = create_jinja_env()
|
1726
|
-
|
1727
|
-
return security_manager, task_template, schema, template_context, env
|
1728
|
-
|
1729
|
-
|
1730
|
-
async def process_templates(
|
1731
|
-
args: CLIParams,
|
1732
|
-
task_template: str,
|
1733
|
-
template_context: Dict[str, Any],
|
1734
|
-
env: jinja2.Environment,
|
1735
|
-
) -> Tuple[str, str]:
|
1736
|
-
"""Process system prompt and user prompt templates.
|
1737
|
-
|
1738
|
-
Args:
|
1739
|
-
args: Command line arguments
|
1740
|
-
task_template: Validated task template
|
1741
|
-
template_context: Template context dictionary
|
1742
|
-
env: Jinja2 environment
|
1743
|
-
|
1744
|
-
Returns:
|
1745
|
-
Tuple of (system_prompt, user_prompt)
|
1746
|
-
|
1747
|
-
Raises:
|
1748
|
-
CLIError: For template processing errors
|
1749
|
-
"""
|
1750
|
-
logger.debug("=== Template Processing Phase ===")
|
1751
|
-
system_prompt = process_system_prompt(
|
1752
|
-
task_template,
|
1753
|
-
args.get("system_prompt"),
|
1754
|
-
args.get("system_prompt_file"),
|
1755
|
-
template_context,
|
1756
|
-
env,
|
1757
|
-
args.get("ignore_task_sysprompt", False),
|
1758
|
-
)
|
1759
|
-
user_prompt = render_template(task_template, template_context, env)
|
1760
|
-
return system_prompt, user_prompt
|
1761
|
-
|
1762
|
-
|
1763
|
-
async def validate_model_and_schema(
|
1764
|
-
args: CLIParams,
|
1765
|
-
schema: Dict[str, Any],
|
1766
|
-
system_prompt: str,
|
1767
|
-
user_prompt: str,
|
1768
|
-
) -> Tuple[Type[BaseModel], List[Dict[str, str]], int, ModelRegistry]:
|
1769
|
-
"""Validate model compatibility and schema, and check token limits.
|
1770
|
-
|
1771
|
-
Args:
|
1772
|
-
args: Command line arguments
|
1773
|
-
schema: Schema dictionary
|
1774
|
-
system_prompt: Processed system prompt
|
1775
|
-
user_prompt: Processed user prompt
|
1776
|
-
|
1777
|
-
Returns:
|
1778
|
-
Tuple of (output_model, messages, total_tokens, registry)
|
1779
|
-
|
1780
|
-
Raises:
|
1781
|
-
CLIError: For validation errors
|
1782
|
-
ModelCreationError: When model creation fails
|
1783
|
-
SchemaValidationError: When schema is invalid
|
1784
|
-
"""
|
1785
|
-
logger.debug("=== Model & Schema Validation Phase ===")
|
1786
|
-
try:
|
1787
|
-
output_model = create_dynamic_model(
|
1788
|
-
schema,
|
1789
|
-
show_schema=args.get("show_model_schema", False),
|
1790
|
-
debug_validation=args.get("debug_validation", False),
|
1791
|
-
)
|
1792
|
-
logger.debug("Successfully created output model")
|
1793
|
-
except (
|
1794
|
-
SchemaFileError,
|
1795
|
-
InvalidJSONError,
|
1796
|
-
SchemaValidationError,
|
1797
|
-
ModelCreationError,
|
1798
|
-
) as e:
|
1799
|
-
logger.error("Schema error: %s", str(e))
|
1800
|
-
# Pass through the error without additional wrapping
|
1801
|
-
raise
|
1802
|
-
|
1803
|
-
if not supports_structured_output(args["model"]):
|
1804
|
-
msg = f"Model {args['model']} does not support structured output"
|
1805
|
-
logger.error(msg)
|
1806
|
-
raise ModelNotSupportedError(msg)
|
1807
|
-
|
1808
|
-
messages = [
|
1809
|
-
{"role": "system", "content": system_prompt},
|
1810
|
-
{"role": "user", "content": user_prompt},
|
1811
|
-
]
|
1812
|
-
|
1813
|
-
total_tokens = estimate_tokens_with_encoding(messages, args["model"])
|
1814
|
-
registry = ModelRegistry()
|
1815
|
-
capabilities = registry.get_capabilities(args["model"])
|
1816
|
-
context_limit = capabilities.context_window
|
1817
|
-
|
1818
|
-
if total_tokens > context_limit:
|
1819
|
-
msg = f"Total tokens ({total_tokens}) exceeds model context limit ({context_limit})"
|
1820
|
-
logger.error(msg)
|
1821
|
-
raise CLIError(
|
1822
|
-
msg,
|
1823
|
-
context={
|
1824
|
-
"total_tokens": total_tokens,
|
1825
|
-
"context_limit": context_limit,
|
1826
|
-
},
|
1827
|
-
)
|
1828
|
-
|
1829
|
-
return output_model, messages, total_tokens, registry
|
1830
|
-
|
1831
|
-
|
1832
|
-
async def execute_model(
|
1833
|
-
args: CLIParams,
|
1834
|
-
params: Dict[str, Any],
|
1835
|
-
output_model: Type[BaseModel],
|
1836
|
-
system_prompt: str,
|
1837
|
-
user_prompt: str,
|
1838
|
-
) -> ExitCode:
|
1839
|
-
"""Execute the model and handle the response.
|
1840
|
-
|
1841
|
-
Args:
|
1842
|
-
args: Command line arguments
|
1843
|
-
params: Validated model parameters
|
1844
|
-
output_model: Generated Pydantic model
|
1845
|
-
system_prompt: Processed system prompt
|
1846
|
-
user_prompt: Processed user prompt
|
1847
|
-
|
1848
|
-
Returns:
|
1849
|
-
Exit code indicating success or failure
|
1850
|
-
|
1851
|
-
Raises:
|
1852
|
-
CLIError: For execution errors
|
1853
|
-
"""
|
1854
|
-
logger.debug("=== Execution Phase ===")
|
1855
|
-
api_key = args.get("api_key") or os.getenv("OPENAI_API_KEY")
|
1856
|
-
if not api_key:
|
1857
|
-
msg = "No API key provided. Set OPENAI_API_KEY environment variable or use --api-key"
|
1858
|
-
logger.error(msg)
|
1859
|
-
raise CLIError(msg, exit_code=ExitCode.API_ERROR)
|
1860
|
-
|
1861
|
-
client = AsyncOpenAI(api_key=api_key, timeout=args.get("timeout", 60.0))
|
1862
|
-
|
1863
|
-
# Create detailed log callback
|
1864
|
-
def log_callback(level: int, message: str, extra: dict[str, Any]) -> None:
|
1865
|
-
if args.get("debug_openai_stream", False):
|
1866
|
-
if extra:
|
1867
|
-
extra_str = LogSerializer.serialize_log_extra(extra)
|
1868
|
-
if extra_str:
|
1869
|
-
logger.debug("%s\nExtra:\n%s", message, extra_str)
|
1870
|
-
else:
|
1871
|
-
logger.debug("%s\nExtra: Failed to serialize", message)
|
1872
|
-
else:
|
1873
|
-
logger.debug(message)
|
1874
|
-
|
1875
|
-
try:
|
1876
|
-
# Create output buffer
|
1877
|
-
output_buffer = []
|
1878
|
-
|
1879
|
-
# Stream the response
|
1880
|
-
async for response in stream_structured_output(
|
1881
|
-
client=client,
|
1882
|
-
model=args["model"],
|
1883
|
-
system_prompt=system_prompt,
|
1884
|
-
user_prompt=user_prompt,
|
1885
|
-
output_schema=output_model,
|
1886
|
-
output_file=args.get("output_file"),
|
1887
|
-
on_log=log_callback,
|
1888
|
-
):
|
1889
|
-
output_buffer.append(response)
|
1890
|
-
|
1891
|
-
# Handle final output
|
1892
|
-
output_file = args.get("output_file")
|
1893
|
-
if output_file:
|
1894
|
-
with open(output_file, "w") as f:
|
1895
|
-
if len(output_buffer) == 1:
|
1896
|
-
f.write(output_buffer[0].model_dump_json(indent=2))
|
1897
|
-
else:
|
1898
|
-
# Build complete JSON array as a single string
|
1899
|
-
json_output = "[\n"
|
1900
|
-
for i, response in enumerate(output_buffer):
|
1901
|
-
if i > 0:
|
1902
|
-
json_output += ",\n"
|
1903
|
-
json_output += " " + response.model_dump_json(
|
1904
|
-
indent=2
|
1905
|
-
).replace("\n", "\n ")
|
1906
|
-
json_output += "\n]"
|
1907
|
-
f.write(json_output)
|
1908
|
-
else:
|
1909
|
-
# Write to stdout when no output file is specified
|
1910
|
-
if len(output_buffer) == 1:
|
1911
|
-
print(output_buffer[0].model_dump_json(indent=2))
|
1912
|
-
else:
|
1913
|
-
# Build complete JSON array as a single string
|
1914
|
-
json_output = "[\n"
|
1915
|
-
for i, response in enumerate(output_buffer):
|
1916
|
-
if i > 0:
|
1917
|
-
json_output += ",\n"
|
1918
|
-
json_output += " " + response.model_dump_json(
|
1919
|
-
indent=2
|
1920
|
-
).replace("\n", "\n ")
|
1921
|
-
json_output += "\n]"
|
1922
|
-
print(json_output)
|
1923
|
-
|
1924
|
-
return ExitCode.SUCCESS
|
1925
|
-
|
1926
|
-
except (
|
1927
|
-
StreamInterruptedError,
|
1928
|
-
StreamBufferError,
|
1929
|
-
StreamParseError,
|
1930
|
-
APIResponseError,
|
1931
|
-
EmptyResponseError,
|
1932
|
-
InvalidResponseFormatError,
|
1933
|
-
) as e:
|
1934
|
-
logger.error("Stream error: %s", str(e))
|
1935
|
-
raise CLIError(str(e), exit_code=ExitCode.API_ERROR)
|
1936
|
-
except Exception as e:
|
1937
|
-
logger.exception("Unexpected error during streaming")
|
1938
|
-
raise CLIError(str(e), exit_code=ExitCode.UNKNOWN_ERROR)
|
1939
|
-
finally:
|
1940
|
-
await client.close()
|
1941
|
-
|
1942
|
-
|
1943
|
-
async def run_cli_async(args: CLIParams) -> ExitCode:
|
1944
|
-
"""Async wrapper for CLI operations.
|
1945
|
-
|
1946
|
-
Args:
|
1947
|
-
args: CLI parameters.
|
1948
|
-
|
1949
|
-
Returns:
|
1950
|
-
Exit code.
|
1951
|
-
|
1952
|
-
Raises:
|
1953
|
-
CLIError: For errors during CLI operations.
|
1954
|
-
"""
|
1955
|
-
try:
|
1956
|
-
# 0. Model Parameter Validation
|
1957
|
-
logger.debug("=== Model Parameter Validation ===")
|
1958
|
-
params = await validate_model_params(args)
|
1959
|
-
|
1960
|
-
# 1. Input Validation Phase (includes schema validation)
|
1961
|
-
security_manager, task_template, schema, template_context, env = (
|
1962
|
-
await validate_inputs(args)
|
1963
|
-
)
|
1964
|
-
|
1965
|
-
# 2. Template Processing Phase
|
1966
|
-
system_prompt, user_prompt = await process_templates(
|
1967
|
-
args, task_template, template_context, env
|
1968
|
-
)
|
1969
|
-
|
1970
|
-
# 3. Model & Schema Validation Phase
|
1971
|
-
output_model, messages, total_tokens, registry = (
|
1972
|
-
await validate_model_and_schema(
|
1973
|
-
args, schema, system_prompt, user_prompt
|
1974
|
-
)
|
1975
|
-
)
|
1976
|
-
|
1977
|
-
# 4. Dry Run Output Phase - Moved after all validations
|
1978
|
-
if args.get("dry_run", False):
|
1979
|
-
logger.info("\n=== Dry Run Summary ===")
|
1980
|
-
# Only log success if we got this far (no validation errors)
|
1981
|
-
logger.info("✓ Template rendered successfully")
|
1982
|
-
logger.info("✓ Schema validation passed")
|
1983
|
-
|
1984
|
-
if args.get("verbose", False):
|
1985
|
-
logger.info("\nSystem Prompt:")
|
1986
|
-
logger.info("-" * 40)
|
1987
|
-
logger.info(system_prompt)
|
1988
|
-
logger.info("\nRendered Template:")
|
1989
|
-
logger.info("-" * 40)
|
1990
|
-
logger.info(user_prompt)
|
1991
|
-
|
1992
|
-
# Return success only if we got here (no validation errors)
|
1993
|
-
return ExitCode.SUCCESS
|
1994
|
-
|
1995
|
-
# 5. Execution Phase
|
1996
|
-
return await execute_model(
|
1997
|
-
args, params, output_model, system_prompt, user_prompt
|
1998
|
-
)
|
1999
|
-
|
2000
|
-
except KeyboardInterrupt:
|
2001
|
-
logger.info("Operation cancelled by user")
|
2002
|
-
raise
|
2003
|
-
except SchemaValidationError as e:
|
2004
|
-
# Ensure schema validation errors are properly propagated with the correct exit code
|
2005
|
-
logger.error("Schema validation error: %s", str(e))
|
2006
|
-
raise # Re-raise the SchemaValidationError to preserve the error chain
|
2007
|
-
except Exception as e:
|
2008
|
-
if isinstance(e, CLIError):
|
2009
|
-
raise # Let our custom errors propagate
|
2010
|
-
logger.exception("Unexpected error")
|
2011
|
-
raise CLIError(str(e), context={"error_type": type(e).__name__})
|
95
|
+
# Create the main cli object using the factory
|
96
|
+
cli = create_cli_group()
|
2012
97
|
|
2013
98
|
|
2014
99
|
def create_cli() -> click.Command:
|
@@ -2017,7 +102,7 @@ def create_cli() -> click.Command:
|
|
2017
102
|
Returns:
|
2018
103
|
click.Command: The CLI command object
|
2019
104
|
"""
|
2020
|
-
return cli
|
105
|
+
return cli
|
2021
106
|
|
2022
107
|
|
2023
108
|
def main() -> None:
|
@@ -2042,15 +127,13 @@ def main() -> None:
|
|
2042
127
|
sys.exit(ExitCode.INTERNAL_ERROR)
|
2043
128
|
|
2044
129
|
|
130
|
+
# Re-export ExitCode for compatibility
|
131
|
+
|
2045
132
|
# Export public API
|
2046
133
|
__all__ = [
|
2047
134
|
"ExitCode",
|
2048
|
-
"estimate_tokens_with_encoding",
|
2049
|
-
"parse_json_var",
|
2050
|
-
"create_dynamic_model",
|
2051
|
-
"validate_path_mapping",
|
2052
|
-
"create_cli",
|
2053
135
|
"main",
|
136
|
+
"create_cli",
|
2054
137
|
]
|
2055
138
|
|
2056
139
|
|