ffai-cli 0.2.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.
ffai/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """ffai: a natural-language interface for media and document conversion."""
2
+
3
+ __version__ = "0.2.0"
4
+
5
+ __all__ = ["__version__"]
ffai/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m ffai`` to launch the interactive REPL."""
2
+
3
+ from ffai.cli import main
4
+
5
+ raise SystemExit(main())
ffai/cli.py ADDED
@@ -0,0 +1,97 @@
1
+ """Command-line entry point for ffai."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from collections.abc import Sequence
7
+
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.text import Text
11
+
12
+ from ffai import __version__
13
+ from ffai.config import Settings
14
+ from ffai.errors import ConfigError
15
+ from ffai.i18n import Translator, resolve_language
16
+ from ffai.repl import FFaiRepl
17
+
18
+
19
+ def build_parser() -> argparse.ArgumentParser:
20
+ parser = argparse.ArgumentParser(
21
+ prog="ffai",
22
+ description=(
23
+ "Natural-language, stateful FFmpeg (media) and Pandoc (document) "
24
+ "conversion assistant."
25
+ ),
26
+ )
27
+ parser.add_argument(
28
+ "--lang",
29
+ choices=("en", "zh"),
30
+ default=None,
31
+ help="UI language (default: FFAI_LANG or system locale)",
32
+ )
33
+ parser.add_argument(
34
+ "--version",
35
+ action="version",
36
+ version=f"%(prog)s {__version__}",
37
+ )
38
+ return parser
39
+
40
+
41
+ def main(argv: Sequence[str] | None = None) -> int:
42
+ args = build_parser().parse_args(argv)
43
+ translator = Translator(resolve_language(args.lang))
44
+ console = Console(highlight=False)
45
+
46
+ try:
47
+ settings = Settings.load()
48
+ except ConfigError as exc:
49
+ console.print(
50
+ Panel(
51
+ Text(str(exc), style="red"),
52
+ title=translator("cli.config_error_title"),
53
+ border_style="red",
54
+ )
55
+ )
56
+ return 2
57
+
58
+ # Settings.load populates os.environ from .env, so re-resolve here to let
59
+ # FFAI_LANG live in either the process environment or the project .env.
60
+ translator = Translator(resolve_language(args.lang))
61
+ _configure_readline()
62
+ try:
63
+ FFaiRepl(settings, console=console, translator=translator).run()
64
+ except ConfigError as exc:
65
+ console.print(
66
+ Panel(
67
+ Text(str(exc), style="red"),
68
+ title=translator("cli.config_required_title"),
69
+ border_style="red",
70
+ )
71
+ )
72
+ return 2
73
+ except KeyboardInterrupt:
74
+ console.print()
75
+ console.print(Text(translator("cli.interrupted"), style="dim"))
76
+ return 130
77
+ except BrokenPipeError:
78
+ return 0
79
+ return 0
80
+
81
+
82
+ def _configure_readline() -> None:
83
+ try:
84
+ import readline
85
+ except ImportError:
86
+ # readline is unavailable on some platforms; Console.input still works,
87
+ # only arrow-key input history is lost.
88
+ return
89
+ try:
90
+ readline.set_history_length(1_000)
91
+ except (AttributeError, OSError):
92
+ # A stripped readline implementation may not expose history controls.
93
+ return
94
+
95
+
96
+ if __name__ == "__main__":
97
+ raise SystemExit(main())
@@ -0,0 +1,287 @@
1
+ """Build FFmpeg argument lists from validated task state.
2
+
3
+ No shell string is ever constructed. Every path and value is passed as its own
4
+ argv entry, and model-provided extra options are restricted to a small set of
5
+ common output options with known arity.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ from pathlib import Path
13
+
14
+ from ffai.errors import CommandBuildError
15
+ from ffai.models import FFmpegTask
16
+
17
+ FFMPEG_BINARY = "ffmpeg"
18
+
19
+ # Extra options are output options with a fixed number of values. Unknown
20
+ # options, stream mapping, filters, codec selection, and options that write
21
+ # auxiliary files are deliberately not accepted here; those concerns are
22
+ # represented by structured task fields and validated separately.
23
+ _EXTRA_OPTION_ARITY: dict[str, int] = {
24
+ "-aspect": 1,
25
+ "-bf": 1,
26
+ "-bufsize": 1,
27
+ "-color_primaries": 1,
28
+ "-color_range": 1,
29
+ "-color_trc": 1,
30
+ "-colorspace": 1,
31
+ "-disposition": 1,
32
+ "-f": 1,
33
+ "-flags": 1,
34
+ "-frames": 1,
35
+ "-g": 1,
36
+ "-keyint_min": 1,
37
+ "-level": 1,
38
+ "-map_chapters": 1,
39
+ "-map_metadata": 1,
40
+ "-max_muxing_queue_size": 1,
41
+ "-maxrate": 1,
42
+ "-metadata": 1,
43
+ "-minrate": 1,
44
+ "-movflags": 1,
45
+ "-pix_fmt": 1,
46
+ "-profile": 1,
47
+ "-qmax": 1,
48
+ "-qmin": 1,
49
+ "-qscale": 1,
50
+ "-r": 1,
51
+ "-rc": 1,
52
+ "-rc-lookahead": 1,
53
+ "-refs": 1,
54
+ "-s": 1,
55
+ "-sc_threshold": 1,
56
+ "-shortest": 0,
57
+ "-ss": 1,
58
+ "-svtav1-params": 1,
59
+ "-t": 1,
60
+ "-tag": 1,
61
+ "-thread_type": 1,
62
+ "-threads": 1,
63
+ "-to": 1,
64
+ "-tune": 1,
65
+ "-x264-params": 1,
66
+ "-x265-params": 1,
67
+ }
68
+
69
+ _OPTION_TOKEN = re.compile(r"^-[A-Za-z0-9][A-Za-z0-9_.:+-]*$")
70
+ # Specifier suffixes such as ":v", ":a:0", or ":s:v:0" are permitted for
71
+ # these options.
72
+ _SPECIFIER_OPTIONS = {
73
+ "-aspect",
74
+ "-disposition",
75
+ "-frames",
76
+ "-level",
77
+ "-map_chapters",
78
+ "-map_metadata",
79
+ "-metadata",
80
+ "-profile",
81
+ "-tag",
82
+ }
83
+
84
+
85
+ def build_command(task: FFmpegTask, *, overwrite: bool = True) -> list[str]:
86
+ """Validate *task* and return an FFmpeg argv list.
87
+
88
+ The current task must have at least one existing input and a usable output
89
+ path. Paths are normalized before use so an input named ``-foo.mp4`` cannot
90
+ be mistaken for an FFmpeg option.
91
+ """
92
+
93
+ inputs = [_validate_input(raw) for raw in task.inputs]
94
+ if not inputs:
95
+ raise CommandBuildError("No input files. Use 'add <file>' first.")
96
+ output = _validate_output(task.output)
97
+ _validate_no_overwrite(inputs, output)
98
+ _validate_encoding_combination(task)
99
+ _validate_filter_graph(task, input_count=len(inputs))
100
+ extra_options = _validate_extra_options(task.extra_options)
101
+
102
+ args = [FFMPEG_BINARY, "-hide_banner", "-nostdin"]
103
+ if overwrite:
104
+ args.append("-y")
105
+ for media_input in inputs:
106
+ args.extend(["-i", media_input])
107
+
108
+ if task.filter_complex:
109
+ args.extend(["-filter_complex", task.filter_complex])
110
+ for stream_map in task.maps:
111
+ args.extend(["-map", stream_map])
112
+ if task.video_filters:
113
+ args.extend(["-vf", ",".join(task.video_filters)])
114
+ if task.audio_filters:
115
+ args.extend(["-af", ",".join(task.audio_filters)])
116
+ if task.video_codec:
117
+ args.extend(["-c:v", task.video_codec])
118
+ if task.audio_codec:
119
+ args.extend(["-c:a", task.audio_codec])
120
+ if task.crf is not None:
121
+ args.extend(["-crf", str(task.crf)])
122
+ if task.preset:
123
+ args.extend(["-preset", task.preset])
124
+ if task.video_bitrate:
125
+ args.extend(["-b:v", task.video_bitrate])
126
+ if task.audio_bitrate:
127
+ args.extend(["-b:a", task.audio_bitrate])
128
+ args.extend(extra_options)
129
+ args.append(output)
130
+ return args
131
+
132
+
133
+ def describe_allowed_extra_options() -> str:
134
+ """Return a stable, human-readable allowlist for prompts and docs."""
135
+
136
+ return ", ".join(sorted(_EXTRA_OPTION_ARITY))
137
+
138
+
139
+ def _validate_input(raw_path: str) -> str:
140
+ try:
141
+ path = Path(raw_path).expanduser().resolve(strict=True)
142
+ except (OSError, RuntimeError) as exc:
143
+ raise CommandBuildError(f"Input file does not exist: {raw_path}") from exc
144
+ if not path.is_file():
145
+ raise CommandBuildError(f"Input is not a regular file: {path}")
146
+ if not os.access(path, os.R_OK):
147
+ raise CommandBuildError(f"Input file is not readable: {path}")
148
+ return str(path)
149
+
150
+
151
+ def _validate_output(raw_output: str | None) -> str:
152
+ if raw_output is None or not raw_output.strip():
153
+ raise CommandBuildError("No output file. Use 'setoutput <file>' first.")
154
+
155
+ path = Path(raw_output).expanduser()
156
+ if not path.is_absolute():
157
+ path = Path.cwd() / path
158
+ if path.name in {"", ".", ".."}:
159
+ raise CommandBuildError("Output must be a filename, not a directory")
160
+ try:
161
+ parent = path.parent.resolve(strict=True)
162
+ except (OSError, RuntimeError) as exc:
163
+ raise CommandBuildError(f"Output directory does not exist: {path.parent}") from exc
164
+ if not parent.is_dir():
165
+ raise CommandBuildError(f"Output parent is not a directory: {parent}")
166
+ if not os.access(parent, os.W_OK | os.X_OK):
167
+ raise CommandBuildError(f"Output directory is not writable: {parent}")
168
+
169
+ target = parent / path.name
170
+ if target.exists() and target.is_dir():
171
+ raise CommandBuildError(f"Output path is a directory: {target}")
172
+ return str(target)
173
+
174
+
175
+ def _validate_no_overwrite(inputs: list[str], output: str) -> None:
176
+ for media_input in inputs:
177
+ if _same_file(media_input, output):
178
+ raise CommandBuildError(
179
+ f"Output would overwrite an input file: {output}. Choose a different output."
180
+ )
181
+
182
+
183
+ def _validate_encoding_combination(task: FFmpegTask) -> None:
184
+ if task.video_codec == "copy":
185
+ conflicts = []
186
+ if task.video_filters:
187
+ conflicts.append("video filters")
188
+ if task.filter_complex:
189
+ conflicts.append("filter_complex")
190
+ if task.crf is not None:
191
+ conflicts.append("CRF")
192
+ if task.preset:
193
+ conflicts.append("preset")
194
+ if task.video_bitrate:
195
+ conflicts.append("video bitrate")
196
+ if conflicts:
197
+ raise CommandBuildError(
198
+ "Video stream copy cannot be combined with "
199
+ + ", ".join(conflicts)
200
+ + ". Clear those fields or use a real video encoder."
201
+ )
202
+
203
+ if task.audio_codec == "copy":
204
+ conflicts = []
205
+ if task.audio_filters:
206
+ conflicts.append("audio filters")
207
+ if task.audio_bitrate:
208
+ conflicts.append("audio bitrate")
209
+ if conflicts:
210
+ raise CommandBuildError(
211
+ "Audio stream copy cannot be combined with "
212
+ + ", ".join(conflicts)
213
+ + ". Clear those fields or use a real audio encoder."
214
+ )
215
+
216
+
217
+ def _validate_filter_graph(task: FFmpegTask, *, input_count: int) -> None:
218
+ if task.filter_complex and (task.video_filters or task.audio_filters):
219
+ raise CommandBuildError(
220
+ "filter_complex cannot be combined with simple video filters or "
221
+ "audio filters. Put the whole graph in filter_complex, or clear it."
222
+ )
223
+ if task.filter_complex and not task.maps:
224
+ raise CommandBuildError(
225
+ "filter_complex requires at least one -map entry (for example '[v]' and '[a]')."
226
+ )
227
+ if task.maps:
228
+ for stream_map in task.maps:
229
+ if stream_map.startswith("[") and not task.filter_complex:
230
+ raise CommandBuildError(
231
+ f"Map label {stream_map} refers to filter_complex output, "
232
+ "but filter_complex is empty."
233
+ )
234
+ if input_count > 1 and not task.maps and not task.filter_complex:
235
+ raise CommandBuildError(
236
+ "Multiple inputs need explicit maps or a filter_complex so FFmpeg does not "
237
+ "silently choose streams. Add maps such as '0:v:0' and '1:a:0'."
238
+ )
239
+
240
+
241
+ def _validate_extra_options(options: list[str]) -> list[str]:
242
+ """Expand only allowlisted options with their exact number of values."""
243
+
244
+ validated: list[str] = []
245
+ index = 0
246
+ while index < len(options):
247
+ option = options[index]
248
+ if not _OPTION_TOKEN.match(option):
249
+ raise CommandBuildError(
250
+ f"Invalid extra option token {option!r}. Each argv token must be a "
251
+ "supported '-option' followed by its values."
252
+ )
253
+ base = option.split(":", 1)[0]
254
+ if base not in _EXTRA_OPTION_ARITY:
255
+ raise CommandBuildError(
256
+ f"Unsupported extra option {option!r}. Use structured task fields for "
257
+ "codecs, filters, and stream mapping."
258
+ )
259
+ if option != base and base not in _SPECIFIER_OPTIONS:
260
+ raise CommandBuildError(f"Stream specifier is not allowed for extra option {option!r}")
261
+
262
+ arity = _EXTRA_OPTION_ARITY[base]
263
+ values = options[index + 1 : index + 1 + arity]
264
+ if len(values) != arity:
265
+ raise CommandBuildError(f"Extra option {option!r} requires {arity} value(s)")
266
+ for value in values:
267
+ if not value or value.startswith("-"):
268
+ raise CommandBuildError(
269
+ f"Invalid value {value!r} for extra option {option!r}; values must not "
270
+ "start with '-'"
271
+ )
272
+ if "\x00" in value or "\n" in value or "\r" in value:
273
+ raise CommandBuildError(f"Invalid control character in value for {option!r}")
274
+
275
+ validated.extend(options[index : index + 1 + arity])
276
+ index += 1 + arity
277
+
278
+ return validated
279
+
280
+
281
+ def _same_file(left: str | Path, right: str | Path) -> bool:
282
+ try:
283
+ return os.path.samefile(left, right)
284
+ except OSError:
285
+ # The output may not exist yet; normalized path equality still catches
286
+ # the common overwrite case.
287
+ return Path(left) == Path(right)
ffai/config.py ADDED
@@ -0,0 +1,251 @@
1
+ """Environment-backed configuration for the OpenAI-compatible client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import re
8
+ import tempfile
9
+ from collections.abc import Mapping
10
+ from pathlib import Path
11
+ from urllib.parse import urlsplit
12
+
13
+ from dotenv import find_dotenv, load_dotenv
14
+ from pydantic import BaseModel, ConfigDict, SecretStr, field_validator
15
+
16
+ from ffai.errors import ConfigError
17
+
18
+ API_KEY_ENV = "OPENAI_API_KEY"
19
+ BASE_URL_ENV = "OPENAI_BASE_URL"
20
+ MODEL_ENV = "OPENAI_MODEL"
21
+
22
+ _ENV_ASSIGNMENT = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=")
23
+
24
+ # Values shipped in .env.example are placeholders, not configuration.
25
+ _PLACEHOLDER_API_KEYS = {
26
+ "sk-your-key-here",
27
+ "sk-your-deepseek-key",
28
+ "your-api-key",
29
+ "your-key-here",
30
+ }
31
+
32
+
33
+ class LLMConfig(BaseModel):
34
+ """Validated settings required to make an LLM request."""
35
+
36
+ model_config = ConfigDict(frozen=True)
37
+
38
+ api_key: SecretStr
39
+ model: str
40
+ base_url: str | None = None
41
+
42
+
43
+ class Settings(BaseModel):
44
+ """Process configuration.
45
+
46
+ A complete LLM configuration is required before the interactive REPL
47
+ starts. ``Settings.load`` remains lenient so startup can distinguish a
48
+ missing configuration from invalid values and launch the setup wizard.
49
+ """
50
+
51
+ model_config = ConfigDict(frozen=True)
52
+
53
+ api_key: SecretStr | None = None
54
+ base_url: str | None = None
55
+ model: str | None = None
56
+
57
+ @field_validator("api_key", mode="before")
58
+ @classmethod
59
+ def _clean_api_key(cls, value: object) -> object:
60
+ if isinstance(value, str):
61
+ _reject_env_control_characters(value, API_KEY_ENV)
62
+ value = value.strip()
63
+ if value.lower() in _PLACEHOLDER_API_KEYS or value.lower().startswith("sk-your-"):
64
+ return None
65
+ return SecretStr(value) if value else None
66
+ return value
67
+
68
+ @field_validator("base_url", mode="before")
69
+ @classmethod
70
+ def _clean_base_url(cls, value: object) -> object:
71
+ if value is None:
72
+ return None
73
+ if not isinstance(value, str):
74
+ raise ValueError("OPENAI_BASE_URL must be a string")
75
+ _reject_env_control_characters(value, BASE_URL_ENV)
76
+ value = value.strip().rstrip("/")
77
+ if not value:
78
+ return None
79
+ parsed = urlsplit(value)
80
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
81
+ raise ValueError("OPENAI_BASE_URL must be an absolute http(s) URL")
82
+ if parsed.username or parsed.password:
83
+ raise ValueError("OPENAI_BASE_URL must not contain credentials")
84
+ return value
85
+
86
+ @field_validator("model", mode="before")
87
+ @classmethod
88
+ def _clean_model(cls, value: object) -> object:
89
+ if value is None:
90
+ return None
91
+ if not isinstance(value, str):
92
+ raise ValueError("OPENAI_MODEL must be a string")
93
+ _reject_env_control_characters(value, MODEL_ENV)
94
+ value = value.strip()
95
+ return value or None
96
+
97
+ @classmethod
98
+ def load(
99
+ cls,
100
+ env: Mapping[str, str] | None = None,
101
+ *,
102
+ load_env_file: bool = True,
103
+ ) -> Settings:
104
+ """Load settings from ``.env`` and the process environment.
105
+
106
+ An explicit *env* mapping bypasses dotenv loading, which keeps tests
107
+ independent of the developer's environment.
108
+ """
109
+
110
+ if env is None:
111
+ if load_env_file:
112
+ dotenv_path = find_dotenv(usecwd=True)
113
+ if dotenv_path:
114
+ load_dotenv(dotenv_path=Path(dotenv_path), override=False)
115
+ source: Mapping[str, str] = os.environ
116
+ else:
117
+ source = env
118
+
119
+ try:
120
+ return cls(
121
+ api_key=source.get(API_KEY_ENV),
122
+ base_url=source.get(BASE_URL_ENV),
123
+ model=source.get(MODEL_ENV),
124
+ )
125
+ except ValueError as exc:
126
+ raise ConfigError(f"Invalid API configuration: {exc}") from exc
127
+
128
+ @property
129
+ def llm_configured(self) -> bool:
130
+ return self.api_key is not None and self.model is not None
131
+
132
+ def require_llm(self) -> LLMConfig:
133
+ missing = []
134
+ if self.api_key is None:
135
+ missing.append(API_KEY_ENV)
136
+ if self.model is None:
137
+ missing.append(MODEL_ENV)
138
+ if missing:
139
+ raise ConfigError(
140
+ "API configuration is required. Missing "
141
+ + " and ".join(missing)
142
+ + ". Run the setup wizard or set the environment variables."
143
+ )
144
+ return LLMConfig(
145
+ api_key=self.api_key,
146
+ model=self.model,
147
+ base_url=self.base_url,
148
+ )
149
+
150
+
151
+ def env_file_path() -> Path:
152
+ """Return the configuration file written by the REPL setup wizard."""
153
+
154
+ return Path.cwd() / ".env"
155
+
156
+
157
+ def save_llm_settings(settings: Settings, *, env_path: Path | None = None) -> Path:
158
+ """Atomically persist LLM settings to an owner-only ``.env`` file.
159
+
160
+ Existing unrelated entries and comments are preserved. A missing base URL
161
+ removes any previously configured ``OPENAI_BASE_URL`` entry.
162
+ """
163
+
164
+ llm = settings.require_llm()
165
+ target = (env_path or env_file_path()).expanduser()
166
+ if not target.is_absolute():
167
+ target = Path.cwd() / target
168
+ try:
169
+ parent = target.parent.resolve(strict=True)
170
+ except OSError as exc:
171
+ raise ConfigError(f"Configuration directory does not exist: {target.parent}") from exc
172
+ target = parent / target.name
173
+
174
+ updates = {
175
+ API_KEY_ENV: llm.api_key.get_secret_value(),
176
+ MODEL_ENV: llm.model,
177
+ }
178
+ removals: set[str] = set()
179
+ if llm.base_url:
180
+ updates[BASE_URL_ENV] = llm.base_url
181
+ else:
182
+ removals.add(BASE_URL_ENV)
183
+
184
+ try:
185
+ existing = target.read_text(encoding="utf-8") if target.exists() else ""
186
+ except (OSError, UnicodeDecodeError) as exc:
187
+ raise ConfigError(f"Could not read configuration file {target}: {exc}") from exc
188
+
189
+ content = _rewrite_env(existing, updates, removals)
190
+ temp_path: Path | None = None
191
+ try:
192
+ with tempfile.NamedTemporaryFile(
193
+ mode="w",
194
+ encoding="utf-8",
195
+ dir=parent,
196
+ prefix=".env.",
197
+ delete=False,
198
+ ) as temp_file:
199
+ temp_file.write(content)
200
+ temp_file.flush()
201
+ os.fsync(temp_file.fileno())
202
+ temp_path = Path(temp_file.name)
203
+ os.chmod(temp_path, 0o600)
204
+ os.replace(temp_path, target)
205
+ temp_path = None
206
+ os.chmod(target, 0o600)
207
+ except OSError as exc:
208
+ raise ConfigError(f"Could not write configuration file {target}: {exc}") from exc
209
+ finally:
210
+ if temp_path is not None:
211
+ # The write error is already being reported; a leftover temp file
212
+ # is owner-only and cannot expose the credentials.
213
+ with contextlib.suppress(OSError):
214
+ temp_path.unlink(missing_ok=True)
215
+
216
+ return target
217
+
218
+
219
+ def _reject_env_control_characters(value: str, variable: str) -> None:
220
+ if any(char in value for char in ("\x00", "\n", "\r")):
221
+ raise ValueError(f"{variable} must not contain control characters")
222
+
223
+
224
+ def _rewrite_env(existing: str, updates: dict[str, str], removals: set[str]) -> str:
225
+ lines = existing.splitlines(keepends=True)
226
+ output: list[str] = []
227
+ written: set[str] = set()
228
+
229
+ for line in lines:
230
+ match = _ENV_ASSIGNMENT.match(line)
231
+ key = match.group(1) if match else None
232
+ if key in removals:
233
+ continue
234
+ if key in updates:
235
+ if key not in written:
236
+ output.append(f"{key}={_quote_env_value(updates[key])}\n")
237
+ written.add(key)
238
+ continue
239
+ output.append(line)
240
+
241
+ if output and not output[-1].endswith(("\n", "\r")):
242
+ output[-1] += "\n"
243
+ for key, value in updates.items():
244
+ if key not in written:
245
+ output.append(f"{key}={_quote_env_value(value)}\n")
246
+ return "".join(output)
247
+
248
+
249
+ def _quote_env_value(value: str) -> str:
250
+ escaped = value.replace("\\", "\\\\").replace("'", "\\'")
251
+ return f"'{escaped}'"
ffai/errors.py ADDED
@@ -0,0 +1,31 @@
1
+ """Typed errors shared across ffai modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class FFAIError(Exception):
7
+ """Base class for errors that can be presented to the user."""
8
+
9
+
10
+ class ConfigError(FFAIError):
11
+ """API or runtime configuration is invalid."""
12
+
13
+
14
+ class StateError(FFAIError):
15
+ """A task-state transition was rejected."""
16
+
17
+
18
+ class ParseError(FFAIError):
19
+ """Command-line input could not be parsed."""
20
+
21
+
22
+ class CommandBuildError(FFAIError):
23
+ """The current task cannot be converted into a safe tool command."""
24
+
25
+
26
+ class ExecutionError(FFAIError):
27
+ """The external conversion tool could not run or produce the requested output."""
28
+
29
+
30
+ class LLMError(FFAIError):
31
+ """The language-model request failed or returned unusable output."""