llm-conductor 1.0.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.
@@ -0,0 +1,4 @@
1
+ # -*- coding: utf-8 -*-
2
+ """LLM-Conductor: JSON localization with language models."""
3
+
4
+ __version__ = "1.0.0"
@@ -0,0 +1,7 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Allow ``python -m llm_conductor``."""
3
+
4
+ from llm_conductor.cli.main import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
File without changes
@@ -0,0 +1,84 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Exit code of a run that may hit several problems at once.
4
+
5
+ Every problem is recorded with its code; the most severe one by
6
+ EXIT_CODE_PRIORITY becomes the process exit code.
7
+ """
8
+ import threading
9
+ from typing import Iterable, List, NamedTuple
10
+
11
+ from llm_conductor.constants import EXIT_CODE_PRIORITY, EXIT_SUCCESS
12
+
13
+
14
+ def _checked(codes: Iterable[int]) -> List[int]:
15
+ """Reject codes outside the contract - an unknown code has no defined severity."""
16
+ checked = list(codes)
17
+ unknown = [code for code in checked if code not in EXIT_CODE_PRIORITY]
18
+ if unknown:
19
+ raise ValueError(f"exit codes {unknown} are not part of EXIT_CODE_PRIORITY")
20
+ return checked
21
+
22
+
23
+ def most_severe(codes: Iterable[int]) -> int:
24
+ """
25
+ The code that wins by EXIT_CODE_PRIORITY.
26
+
27
+ Args:
28
+ codes: Exit codes; each must be listed in EXIT_CODE_PRIORITY.
29
+
30
+ Returns:
31
+ The most severe code, EXIT_SUCCESS for no codes.
32
+
33
+ Raises:
34
+ ValueError: On a code missing from EXIT_CODE_PRIORITY.
35
+ """
36
+ ranks = [EXIT_CODE_PRIORITY.index(code) for code in _checked(codes)]
37
+ return EXIT_CODE_PRIORITY[min(ranks)] if ranks else EXIT_SUCCESS
38
+
39
+
40
+ class ExitRecord(NamedTuple):
41
+ """One recorded problem of a run."""
42
+
43
+ code: int
44
+ reason: str
45
+
46
+
47
+ class ExitStatus:
48
+ """
49
+ Thread-safe accumulator of the problems of one run.
50
+
51
+ record() may be called from any thread; code and records are consistent
52
+ snapshots taken under the same lock.
53
+ """
54
+
55
+ def __init__(self) -> None:
56
+ self._lock = threading.Lock()
57
+ self._records: List[ExitRecord] = []
58
+
59
+ def record(self, code: int, reason: str) -> None:
60
+ """
61
+ Record a problem.
62
+
63
+ Args:
64
+ code: Its exit code from EXIT_CODE_PRIORITY.
65
+ reason: Human-readable description for the final log line.
66
+
67
+ Raises:
68
+ ValueError: On a code missing from EXIT_CODE_PRIORITY.
69
+ """
70
+ _checked([code])
71
+ with self._lock:
72
+ self._records.append(ExitRecord(code, reason))
73
+
74
+ @property
75
+ def code(self) -> int:
76
+ """The exit code of the run: the most severe recorded one, EXIT_SUCCESS for none."""
77
+ with self._lock:
78
+ return most_severe(record.code for record in self._records)
79
+
80
+ @property
81
+ def records(self) -> List[ExitRecord]:
82
+ """A copy of the recorded problems, in recording order."""
83
+ with self._lock:
84
+ return list(self._records)
@@ -0,0 +1,94 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Where the glossary of a run comes from.
4
+
5
+ The lookup chain has two levels: --glossary PATH (a missing file is an
6
+ error), then glossary.json next to the target (file or manifest), then no
7
+ glossary at all. A glossary is project data, so there is no shared fallback
8
+ next to the executable - it would leak one project's terms into another.
9
+ """
10
+ import os
11
+ from dataclasses import dataclass, field
12
+ from typing import Dict, Optional
13
+
14
+ from llm_conductor.cli.targets import RunTarget
15
+ from llm_conductor.constants import (
16
+ GLOSSARY_FILENAME,
17
+ GLOSSARY_ORIGIN_OPTION,
18
+ GLOSSARY_ORIGIN_TARGET,
19
+ )
20
+ from llm_conductor.core.io import safe_read_file
21
+ from llm_conductor.exceptions import FileOperationError, TargetError
22
+ from llm_conductor.interfaces import FileOperationsProtocol, Logger
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class GlossarySource:
27
+ """The glossary of a run and where it was found."""
28
+
29
+ terms: Dict[str, str] = field(default_factory=dict)
30
+ path: Optional[str] = None
31
+ origin: Optional[str] = None
32
+
33
+ def describe(self) -> str:
34
+ """One log line naming the source, its level of the chain and the number of terms."""
35
+ if self.path is None:
36
+ return "Glossary: none given and no glossary.json next to the target - working without terms"
37
+ return f"Glossary: {self.path} ({self.origin}), {len(self.terms)} term(s)"
38
+
39
+
40
+ class GlossaryResolver:
41
+ """Walks the lookup chain and loads the glossary it finds."""
42
+
43
+ def __init__(self, file_ops: FileOperationsProtocol, logger: Logger):
44
+ """
45
+ Args:
46
+ file_ops: File operations for reading the glossary.
47
+ logger: Logger of the run.
48
+ """
49
+ self._file_ops = file_ops
50
+ self._logger = logger
51
+
52
+ def resolve(self, explicit_path: Optional[str], target: RunTarget) -> GlossarySource:
53
+ """
54
+ Find and load the glossary of a run.
55
+
56
+ Args:
57
+ explicit_path: Value of --glossary, relative to the working directory.
58
+ target: The run target; its directory holds the implicit glossary.json.
59
+
60
+ Returns:
61
+ The loaded glossary, or an empty one when the chain finds nothing.
62
+
63
+ Raises:
64
+ TargetError: If the explicit glossary is missing, or a found
65
+ glossary is unreadable or not an object of strings.
66
+ """
67
+ if explicit_path:
68
+ path = os.path.abspath(explicit_path)
69
+ if not os.path.isfile(path):
70
+ raise TargetError(f"Glossary {path} not found")
71
+ return self._load(path, GLOSSARY_ORIGIN_OPTION)
72
+
73
+ candidate = os.path.join(target.directory, GLOSSARY_FILENAME)
74
+ if os.path.isfile(candidate):
75
+ return self._load(candidate, GLOSSARY_ORIGIN_TARGET)
76
+ return GlossarySource()
77
+
78
+ def _load(self, path: str, origin: str) -> GlossarySource:
79
+ """
80
+ Read and check one glossary file.
81
+
82
+ Raises:
83
+ TargetError: If it is unreadable or not a {term: translation} object of strings.
84
+ """
85
+ try:
86
+ data = safe_read_file(path, f"glossary {path}", self._file_ops, self._logger,
87
+ critical=True, as_text=False)
88
+ except FileOperationError as e:
89
+ raise TargetError(str(e)) from e
90
+
91
+ if not isinstance(data, dict) or not all(
92
+ isinstance(term, str) and isinstance(translation, str) for term, translation in data.items()):
93
+ raise TargetError(f"Glossary {path} must be a JSON object mapping terms to translation strings")
94
+ return GlossarySource(data, path, origin)
@@ -0,0 +1,298 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Heartbeat JSON snapshot of a run ({cache_dir}/progress_state.json).
4
+
5
+ External monitors read this file; it is a status report, not a lock and not
6
+ the source of truth for the process exit code (that remains sys.exit in main).
7
+ Write failures degrade to a WARNING so the pipeline keeps going.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import time
14
+ from datetime import UTC, datetime
15
+ from typing import Any, Dict, List, Optional, Sequence
16
+
17
+ from llm_conductor.cli.exit_status import ExitRecord
18
+ from llm_conductor.cli.glossary import GlossarySource
19
+ from llm_conductor.cli.targets import RunTarget, TargetEntry
20
+ from llm_conductor.constants import (
21
+ HEARTBEAT_SCHEMA_VERSION,
22
+ HEARTBEAT_STATUS_COMPLETED,
23
+ HEARTBEAT_STATUS_INTERRUPTED,
24
+ HEARTBEAT_STATUS_RUNNING,
25
+ PROGRESS_STATE_FILENAME,
26
+ )
27
+ from llm_conductor.core.io import safe_write_file
28
+ from llm_conductor.exceptions import FileOperationError
29
+ from llm_conductor.interfaces import FileOperationsProtocol, Logger
30
+
31
+
32
+ class HeartbeatWriter:
33
+ """
34
+ Builds and rewrites the compact progress_state.json of one run.
35
+
36
+ Implements HeartbeatProgressProtocol (begin_stage / note_leaf) for the
37
+ processor; file and final boundaries are driven by TargetRunner.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ cache_dir: str,
43
+ interval: int,
44
+ file_ops: FileOperationsProtocol,
45
+ logger: Logger,
46
+ target: RunTarget,
47
+ profile_name: str,
48
+ stages: Sequence[str],
49
+ glossary: GlossarySource,
50
+ ):
51
+ """
52
+ Args:
53
+ cache_dir: Resolved [cache].dir (created on first write if needed).
54
+ interval: Leaf rewrite cadence; ``<= 0`` skips leaf-only flushes.
55
+ file_ops: Atomic text writer.
56
+ logger: Run logger (WARNING on a failed rewrite).
57
+ target: Resolved run target.
58
+ profile_name: ``[meta].name`` of the active llm.toml.
59
+ stages: Operation types that will run, in pipeline order.
60
+ glossary: Glossary source of the run (same facts as the INFO log).
61
+ """
62
+ self._path = os.path.join(cache_dir, PROGRESS_STATE_FILENAME)
63
+ self._cache_dir = cache_dir
64
+ self._interval = interval
65
+ self._file_ops = file_ops
66
+ self._logger = logger
67
+
68
+ self._started = False
69
+ self._final = False
70
+ self._started_at = 0.0
71
+ self._stage_started_at = 0.0
72
+ self._leaves_since_flush = 0
73
+
74
+ self._status = HEARTBEAT_STATUS_RUNNING
75
+ self._exit_code: Optional[int] = None
76
+ self._problems: List[Dict[str, Any]] = []
77
+
78
+ self._target = {
79
+ 'path': target.path,
80
+ 'name': target.name,
81
+ 'kind': 'manifest' if target.is_manifest else 'file',
82
+ }
83
+ self._profile = {'name': profile_name}
84
+ self._stages = list(stages)
85
+ self._glossary = {
86
+ 'origin': glossary.origin,
87
+ 'path': glossary.path,
88
+ 'term_count': len(glossary.terms),
89
+ }
90
+
91
+ self._files_total = len(target.entries)
92
+ self._all_relative = [entry.relative_path for entry in target.entries]
93
+ self._done: List[str] = []
94
+ self._failed: List[Dict[str, str]] = []
95
+ self._pending: List[str] = list(self._all_relative)
96
+
97
+ self._file_index = 0
98
+ self._current_file: Optional[str] = None
99
+ self._stage: Optional[str] = None
100
+ self._leaves_done = 0
101
+ self._leaves_total = 0
102
+
103
+ @property
104
+ def path(self) -> str:
105
+ """Absolute or relative path of progress_state.json."""
106
+ return self._path
107
+
108
+ @property
109
+ def started(self) -> bool:
110
+ """True after the first successful start() call."""
111
+ return self._started
112
+
113
+ @property
114
+ def finalized(self) -> bool:
115
+ """True after finalize() has written the terminal snapshot."""
116
+ return self._final
117
+
118
+ def start(self) -> None:
119
+ """Mark the run as running and write the initial snapshot."""
120
+ if self._final:
121
+ return
122
+ self._started = True
123
+ self._started_at = time.monotonic()
124
+ self._status = HEARTBEAT_STATUS_RUNNING
125
+ self._exit_code = None
126
+ self._flush()
127
+
128
+ def on_file_start(self, entry: TargetEntry, file_index: int) -> None:
129
+ """
130
+ Record that a file is about to be processed.
131
+
132
+ Args:
133
+ entry: The file being started.
134
+ file_index: 1-based index among target entries.
135
+ """
136
+ if not self._started or self._final:
137
+ return
138
+ self._file_index = file_index
139
+ self._current_file = entry.relative_path
140
+ if entry.relative_path in self._pending:
141
+ self._pending = [path for path in self._pending if path != entry.relative_path]
142
+ self._stage = None
143
+ self._leaves_done = 0
144
+ self._leaves_total = 0
145
+ self._flush()
146
+
147
+ def on_file_done(self, relative_path: str, status_name: str) -> None:
148
+ """
149
+ Record the outcome of one file and rewrite the snapshot.
150
+
151
+ Args:
152
+ relative_path: Path under the result stages.
153
+ status_name: FileStatus value (done / failed / crashed / aborted).
154
+ """
155
+ if not self._started or self._final:
156
+ return
157
+ if status_name == 'done':
158
+ if relative_path not in self._done:
159
+ self._done.append(relative_path)
160
+ else:
161
+ self._failed.append({'path': relative_path, 'status': status_name})
162
+ self._current_file = None
163
+ self._stage = None
164
+ self._leaves_done = 0
165
+ self._leaves_total = 0
166
+ self._flush()
167
+
168
+ def begin_stage(self, stage: str, leaves_total: int) -> None:
169
+ """
170
+ Start a translation or verification stage for the current file.
171
+
172
+ Args:
173
+ stage: OPERATION_TRANSLATION or OPERATION_VERIFICATION.
174
+ leaves_total: Pre-scanned string-leaf count for this stage.
175
+ """
176
+ if not self._started or self._final:
177
+ return
178
+ self._stage = stage
179
+ self._leaves_total = max(0, leaves_total)
180
+ self._leaves_done = 0
181
+ self._leaves_since_flush = 0
182
+ self._stage_started_at = time.monotonic()
183
+ self._flush()
184
+
185
+ def note_leaf(self) -> None:
186
+ """Count one finished string leaf; flush every ``interval`` leaves."""
187
+ if not self._started or self._final:
188
+ return
189
+ self._leaves_done += 1
190
+ if self._interval <= 0:
191
+ return
192
+ self._leaves_since_flush += 1
193
+ if self._leaves_since_flush >= self._interval:
194
+ self._leaves_since_flush = 0
195
+ self._flush()
196
+
197
+ def set_problems(self, records: Sequence[ExitRecord]) -> None:
198
+ """Mirror ExitStatus.records into the snapshot (usually before finalize)."""
199
+ self._problems = [{'code': record.code, 'reason': record.reason} for record in records]
200
+
201
+ def finalize(self, status: str, exit_code: int) -> None:
202
+ """
203
+ Write the terminal snapshot.
204
+
205
+ Args:
206
+ status: HEARTBEAT_STATUS_COMPLETED or HEARTBEAT_STATUS_INTERRUPTED.
207
+ exit_code: Same code the process will exit with.
208
+ """
209
+ if not self._started or self._final:
210
+ return
211
+ if status not in (HEARTBEAT_STATUS_COMPLETED, HEARTBEAT_STATUS_INTERRUPTED):
212
+ status = HEARTBEAT_STATUS_COMPLETED
213
+ self._status = status
214
+ self._exit_code = exit_code
215
+ self._current_file = None
216
+ self._stage = None
217
+ self._final = True
218
+ self._flush()
219
+
220
+ def _files_done_count(self) -> int:
221
+ """Number of files that already have an outcome."""
222
+ return len(self._done) + len(self._failed)
223
+
224
+ def _leaf_frac(self) -> float:
225
+ if self._leaves_total <= 0:
226
+ return 0.0
227
+ return min(self._leaves_done / self._leaves_total, 1.0)
228
+
229
+ def _percent(self) -> float:
230
+ total = self._files_total
231
+ if total <= 0:
232
+ return 0.0
233
+ return 100.0 * (self._files_done_count() + self._leaf_frac()) / total
234
+
235
+ def _speed_leaves_per_min(self) -> float:
236
+ if self._leaves_done <= 0 or self._stage_started_at <= 0:
237
+ return 0.0
238
+ elapsed = time.monotonic() - self._stage_started_at
239
+ if elapsed <= 0:
240
+ return 0.0
241
+ return self._leaves_done / (elapsed / 60.0)
242
+
243
+ def _elapsed_sec(self) -> float:
244
+ if self._started_at <= 0:
245
+ return 0.0
246
+ return max(0.0, time.monotonic() - self._started_at)
247
+
248
+ def _snapshot(self) -> Dict[str, Any]:
249
+ return {
250
+ 'schema_version': HEARTBEAT_SCHEMA_VERSION,
251
+ 'status': self._status,
252
+ 'exit_code': self._exit_code,
253
+ 'updated_at': datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%SZ'),
254
+ 'elapsed_sec': round(self._elapsed_sec(), 3),
255
+ 'target': dict(self._target),
256
+ 'profile': dict(self._profile),
257
+ 'stages': list(self._stages),
258
+ 'glossary': dict(self._glossary),
259
+ 'progress': {
260
+ 'percent': round(self._percent(), 2),
261
+ 'files_done': self._files_done_count(),
262
+ 'files_total': self._files_total,
263
+ 'file_index': self._file_index,
264
+ 'current_file': self._current_file,
265
+ 'stage': self._stage,
266
+ 'leaves_done': self._leaves_done,
267
+ 'leaves_total': self._leaves_total,
268
+ 'speed_leaves_per_min': round(self._speed_leaves_per_min(), 2),
269
+ },
270
+ 'files': {
271
+ 'done': list(self._done),
272
+ 'failed': [dict(item) for item in self._failed],
273
+ 'pending': list(self._pending),
274
+ },
275
+ 'problems': [dict(item) for item in self._problems],
276
+ }
277
+
278
+ def _flush(self) -> None:
279
+ """Atomically rewrite progress_state.json; never raise into the pipeline."""
280
+ try:
281
+ os.makedirs(self._cache_dir, exist_ok=True)
282
+ payload = json.dumps(self._snapshot(), ensure_ascii=False, separators=(',', ':'))
283
+ wrote = safe_write_file(
284
+ self._path, payload, 'heartbeat progress state',
285
+ self._file_ops, self._logger,
286
+ )
287
+ if not wrote:
288
+ self._logger.warning(
289
+ f"Heartbeat write to {self._path} failed; continuing the run"
290
+ )
291
+ except FileOperationError as e:
292
+ self._logger.warning(
293
+ f"Heartbeat write to {self._path} failed ({e}); continuing the run"
294
+ )
295
+ except OSError as e:
296
+ self._logger.warning(
297
+ f"Heartbeat write to {self._path} failed ({e}); continuing the run"
298
+ )
@@ -0,0 +1,156 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ The 'init' command: commented templates of conductor.toml and llm.toml.
4
+
5
+ Templates are rendered from the schema (llm_conductor/schema.py), so every key the
6
+ loader accepts appears in them with its description and default.
7
+ """
8
+ import json
9
+ import math
10
+ import os
11
+ from typing import Any, List, Optional, Tuple
12
+
13
+ from llm_conductor.core.io import FileOperations, safe_write_file
14
+ from llm_conductor.exceptions import ConfigValidationError, FileOperationError
15
+ from llm_conductor.interfaces import FileOperationsProtocol
16
+ from llm_conductor.schema import CONDUCTOR_SCHEMA, LLM_SCHEMA, FileSchema, Setting
17
+
18
+ _LITERAL_DELIMITER = "'''"
19
+ _DEL = "\x7f"
20
+
21
+
22
+ def format_toml_value(value: Any) -> str:
23
+ """
24
+ Render a scalar or a list as a TOML value.
25
+
26
+ Strings become basic (double-quoted) strings: JSON escapes are a subset
27
+ of TOML ones, and DEL, which JSON leaves raw but TOML forbids, is
28
+ escaped separately.
29
+
30
+ Args:
31
+ value: bool, int, float, str, or a list/tuple of those.
32
+
33
+ Returns:
34
+ The TOML representation.
35
+
36
+ Raises:
37
+ TypeError: For any other type (tables are rendered by the caller).
38
+ """
39
+ if isinstance(value, bool):
40
+ return "true" if value else "false"
41
+ if isinstance(value, int):
42
+ return str(value)
43
+ if isinstance(value, float):
44
+ if math.isnan(value):
45
+ return "nan"
46
+ if math.isinf(value):
47
+ return "inf" if value > 0 else "-inf"
48
+ return repr(value)
49
+ if isinstance(value, str):
50
+ return json.dumps(value, ensure_ascii=False).replace(_DEL, "\\u007f")
51
+ if isinstance(value, (list, tuple)):
52
+ return "[" + ", ".join(format_toml_value(item) for item in value) + "]"
53
+ raise TypeError(f"Cannot render {type(value).__name__} as a TOML value")
54
+
55
+
56
+ def format_toml_multiline(value: str) -> str:
57
+ """
58
+ Render a string as a multi-line literal, kept byte for byte without escaping.
59
+
60
+ Falls back to a basic string when a literal cannot hold the text
61
+ (it contains ''' or control characters other than newline and tab, or
62
+ ends with a quote that would merge with the closing delimiter).
63
+
64
+ Args:
65
+ value: The string.
66
+
67
+ Returns:
68
+ The TOML representation.
69
+ """
70
+ has_control = any((ch < " " or ch == _DEL) and ch not in "\n\t" for ch in value)
71
+ if _LITERAL_DELIMITER in value or has_control or value.endswith("'"):
72
+ return format_toml_value(value)
73
+ # The newline right after the opening delimiter is trimmed by TOML parsers.
74
+ return f"{_LITERAL_DELIMITER}\n{value}{_LITERAL_DELIMITER}"
75
+
76
+
77
+ class TomlTemplateRenderer:
78
+ """Renders a FileSchema as a commented TOML file filled with the defaults."""
79
+
80
+ def __init__(self, schema: FileSchema):
81
+ """
82
+ Args:
83
+ schema: Schema of the file to render.
84
+ """
85
+ self._schema = schema
86
+
87
+ def render(self) -> str:
88
+ """
89
+ Build the template text.
90
+
91
+ Returns:
92
+ A TOML document that parses back to the schema defaults.
93
+ """
94
+ lines: List[str] = self._comment(self._schema.header)
95
+ lines += self._comment("Generated by `llm-conductor init`. Absent keys take the defaults below; "
96
+ "unknown keys are an error.")
97
+ for section in self._schema.sections:
98
+ lines.append("")
99
+ lines += self._comment(section.description)
100
+ lines.append(f"[{section.name}]")
101
+ for setting in section.settings:
102
+ lines += self._render_setting(setting)
103
+ return "\n".join(lines) + "\n"
104
+
105
+ def _render_setting(self, setting: Setting) -> List[str]:
106
+ """Comment line(s) and the key line of one setting."""
107
+ value = setting.init_value
108
+ if setting.multiline:
109
+ return self._comment(setting.description) + [
110
+ f"{setting.key} = {format_toml_multiline(value)}"]
111
+ rendered = format_toml_value(value)
112
+ # Comment shows the absent-key default; the line may use template_default.
113
+ default_note = format_toml_value(setting.default)
114
+ return self._comment(f"{setting.description} Default: {default_note}") + [
115
+ f"{setting.key} = {rendered}"]
116
+
117
+ @staticmethod
118
+ def _comment(text: str) -> List[str]:
119
+ """Turn text into '# '-prefixed lines."""
120
+ return [f"# {line}" if line else "#" for line in text.split("\n")]
121
+
122
+
123
+ def create_config_files(conductor_path: str, llm_path: str,
124
+ file_ops: Optional[FileOperationsProtocol] = None) -> None:
125
+ """
126
+ Write fresh conductor.toml and llm.toml templates.
127
+
128
+ Existing files are never overwritten: a configured profile may hold
129
+ hand-tuned prompts and keys.
130
+
131
+ Args:
132
+ conductor_path: Destination of conductor.toml.
133
+ llm_path: Destination of llm.toml.
134
+ file_ops: File operations for writing; default FileOperations.
135
+
136
+ Raises:
137
+ ConfigValidationError: If either file already exists.
138
+ FileOperationError: If a file cannot be written.
139
+ """
140
+ targets: Tuple[Tuple[str, FileSchema], ...] = ((conductor_path, CONDUCTOR_SCHEMA), (llm_path, LLM_SCHEMA))
141
+ existing = [path for path, _ in targets if os.path.exists(path)]
142
+ if existing:
143
+ raise ConfigValidationError(
144
+ f"Refusing to overwrite existing configuration: {', '.join(existing)}. "
145
+ f"Remove or rename the file(s) to generate fresh templates.")
146
+
147
+ writer = file_ops or FileOperations()
148
+ for path, schema in targets:
149
+ text = TomlTemplateRenderer(schema).render()
150
+ if not safe_write_file(path, text, f"{schema.file_name} template", writer):
151
+ raise FileOperationError(f"Failed to write {path}")
152
+ print(f"Created {path}")
153
+
154
+ print("For OpenAI-compatible APIs (OpenAI, DeepSeek, OpenRouter), edit llm.toml:")
155
+ print(" - set type = \"openai\" and url in [translation.api] / [verification.api]")
156
+ print(" - set key_env to the environment variable holding the key (or key to the key itself)")