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 +5 -0
- ffai/__main__.py +5 -0
- ffai/cli.py +97 -0
- ffai/command_builder.py +287 -0
- ffai/config.py +251 -0
- ffai/errors.py +31 -0
- ffai/executor.py +255 -0
- ffai/i18n.py +400 -0
- ffai/llm.py +181 -0
- ffai/models.py +454 -0
- ffai/pandoc_builder.py +254 -0
- ffai/parser.py +112 -0
- ffai/prompt.py +296 -0
- ffai/providers.py +122 -0
- ffai/repl.py +933 -0
- ffai/state.py +291 -0
- ffai/tools.py +177 -0
- ffai/utils.py +91 -0
- ffai_cli-0.2.0.dist-info/METADATA +620 -0
- ffai_cli-0.2.0.dist-info/RECORD +24 -0
- ffai_cli-0.2.0.dist-info/WHEEL +5 -0
- ffai_cli-0.2.0.dist-info/entry_points.txt +2 -0
- ffai_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- ffai_cli-0.2.0.dist-info/top_level.txt +1 -0
ffai/__init__.py
ADDED
ffai/__main__.py
ADDED
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())
|
ffai/command_builder.py
ADDED
|
@@ -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."""
|