teich 0.1.1a1__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,18 @@
1
+ """Agentic Datagen v2 - Generate training data from Codex and Pi traces."""
2
+
3
+ __version__ = "0.1.1a1"
4
+
5
+ from .config import Config, load_config
6
+ from .converter import TrainingExample, convert_trace_to_training_example, convert_traces_to_training_data
7
+ from .formatter import format_and_mask
8
+ from .loader import load_traces
9
+
10
+ __all__ = [
11
+ "Config",
12
+ "TrainingExample",
13
+ "convert_trace_to_training_example",
14
+ "convert_traces_to_training_data",
15
+ "format_and_mask",
16
+ "load_traces",
17
+ "load_config",
18
+ ]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
agentic_datagen/cli.py ADDED
@@ -0,0 +1,333 @@
1
+ """CLI for teich."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from threading import RLock
8
+
9
+ import typer
10
+ from rich.console import Console, Group
11
+ from rich.live import Live
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+
15
+ from .config import Config
16
+ from .runner import CodexRunner, PiRunner, SessionProgressUpdate, TraceMetrics
17
+ from .trace_readme import write_traces_readme
18
+
19
+ console = Console()
20
+ app = typer.Typer(
21
+ name="teich",
22
+ help="Generate agent training data using Codex or Pi",
23
+ no_args_is_help=True,
24
+ )
25
+
26
+
27
+ @app.command()
28
+ def generate(
29
+ config: Path = typer.Option(
30
+ Path("config.yaml"),
31
+ "--config", "-c",
32
+ help="Path to configuration file",
33
+ ),
34
+ output: Path = typer.Option(
35
+ None,
36
+ "--output", "-o",
37
+ help="Override output directory",
38
+ ),
39
+ concurrency: int = typer.Option(
40
+ None,
41
+ "--concurrency", "-j",
42
+ min=1,
43
+ help="Number of prompts to run in parallel",
44
+ ),
45
+ ) -> None:
46
+ """Generate training traces from prompts."""
47
+ console.print(Panel.fit("Teich", style="bold blue"))
48
+
49
+ if not config.exists():
50
+ console.print(f"[red]Config file not found: {config}[/red]")
51
+ raise typer.Exit(1)
52
+
53
+ cfg = Config.from_yaml(config)
54
+ if output:
55
+ cfg.output.traces_dir = output
56
+ if concurrency is not None:
57
+ cfg.max_concurrency = concurrency
58
+
59
+ # Ensure output dir exists
60
+ cfg.output.traces_dir.mkdir(parents=True, exist_ok=True)
61
+ prompt_inputs = cfg.get_prompt_inputs()
62
+ effective_concurrency = (
63
+ max(1, min(cfg.max_concurrency, len(prompt_inputs))) if prompt_inputs else cfg.max_concurrency
64
+ )
65
+
66
+ console.print(f"[green]Loaded config: {config}[/green]")
67
+ console.print(
68
+ f"[blue]Processing {len(prompt_inputs)} prompts with concurrency {effective_concurrency}...[/blue]\n"
69
+ )
70
+
71
+ # Run generation
72
+ try:
73
+ agent_provider = cfg.get_agent_provider()
74
+ if agent_provider == "codex":
75
+ runner = CodexRunner(cfg)
76
+ elif agent_provider == "pi":
77
+ runner = PiRunner(cfg)
78
+ else:
79
+ console.print(
80
+ f"[red]Unsupported agent provider: {agent_provider}. Supported providers: codex, pi.[/red]"
81
+ )
82
+ raise typer.Exit(1)
83
+
84
+ with BatchProgressReporter(console) as reporter:
85
+ results = runner.run_all(
86
+ max_concurrency=cfg.max_concurrency,
87
+ progress_callback=reporter.update,
88
+ )
89
+ readme_path = write_traces_readme(
90
+ cfg.output.traces_dir,
91
+ pretty_name=cfg.output.pretty_name,
92
+ tags=cfg.output.tags,
93
+ model_id=cfg.model.model,
94
+ readme_file_name=cfg.output.readme_file_name,
95
+ )
96
+ totals = reporter.snapshot_totals()
97
+
98
+ console.print(f"\n[bold green]Success! Generated {len(results)} trace files:[/bold green]")
99
+ for r in results:
100
+ console.print(f" - {r}")
101
+ console.print(
102
+ "\n[cyan]Usage:[/cyan] "
103
+ f"tokens={totals['total_tokens']} input={totals['input_tokens']} "
104
+ f"output={totals['output_tokens']} reasoning={totals['reasoning_tokens']} "
105
+ f"cache_read={totals['cache_read_tokens']}"
106
+ )
107
+ console.print(f"[cyan]API cost:[/cyan] ${totals['total_cost']:.6f}")
108
+ console.print(f"[green]Saved sandboxes: {cfg.output.sandbox_dir}[/green]")
109
+ console.print(f"\n[green]Wrote README: {readme_path}[/green]")
110
+
111
+ except Exception as e:
112
+ console.print(f"\n[red]Error: {e}[/red]")
113
+ raise typer.Exit(1)
114
+
115
+
116
+ def _format_elapsed(update: SessionProgressUpdate) -> str:
117
+ if update.started_at is None:
118
+ return "--"
119
+ finished_at = update.finished_at or datetime.now(timezone.utc)
120
+ elapsed = max(0, int((finished_at - update.started_at).total_seconds()))
121
+ minutes, seconds = divmod(elapsed, 60)
122
+ hours, minutes = divmod(minutes, 60)
123
+ if hours:
124
+ return f"{hours:d}:{minutes:02d}:{seconds:02d}"
125
+ return f"{minutes:02d}:{seconds:02d}"
126
+
127
+
128
+ def _format_api_tokens(metrics: TraceMetrics | None) -> str:
129
+ if not metrics or not metrics.total_tokens:
130
+ return "--"
131
+ return str(metrics.total_tokens)
132
+
133
+
134
+ def _format_cost(metrics: TraceMetrics | None) -> str:
135
+ if not metrics:
136
+ return "--"
137
+ return f"${metrics.total_cost:.6f}"
138
+
139
+
140
+ class BatchProgressReporter:
141
+ def __init__(self, progress_console: Console):
142
+ self.console = progress_console
143
+ self.enabled = progress_console.is_terminal
144
+ self._lock = RLock()
145
+ self._updates: dict[str, SessionProgressUpdate] = {}
146
+ self._live: Live | None = None
147
+
148
+ def __enter__(self) -> BatchProgressReporter:
149
+ if self.enabled:
150
+ self._live = Live(self._render(), console=self.console, refresh_per_second=4)
151
+ self._live.__enter__()
152
+ return self
153
+
154
+ def __exit__(self, exc_type, exc, exc_tb) -> None:
155
+ if self._live is not None:
156
+ self._live.__exit__(exc_type, exc, exc_tb)
157
+ self._live = None
158
+
159
+ def update(self, update: SessionProgressUpdate) -> None:
160
+ with self._lock:
161
+ previous = self._updates.get(update.prompt_id)
162
+ if previous and update.started_at is None:
163
+ update.started_at = previous.started_at
164
+ self._updates[update.prompt_id] = update
165
+ if self._live is not None:
166
+ self._live.update(self._render(), refresh=True)
167
+
168
+ def snapshot_totals(self) -> dict[str, float | int]:
169
+ with self._lock:
170
+ totals = {
171
+ "input_tokens": 0,
172
+ "output_tokens": 0,
173
+ "reasoning_tokens": 0,
174
+ "cache_read_tokens": 0,
175
+ "cache_write_tokens": 0,
176
+ "total_tokens": 0,
177
+ "est_total_tokens": 0,
178
+ "total_cost": 0.0,
179
+ }
180
+ for update in self._updates.values():
181
+ metrics = update.metrics
182
+ if not metrics:
183
+ continue
184
+ totals["input_tokens"] += metrics.input_tokens
185
+ totals["output_tokens"] += metrics.output_tokens
186
+ totals["reasoning_tokens"] += metrics.reasoning_tokens
187
+ totals["cache_read_tokens"] += metrics.cache_read_tokens
188
+ totals["cache_write_tokens"] += metrics.cache_write_tokens
189
+ totals["total_tokens"] += metrics.total_tokens
190
+ totals["est_total_tokens"] += metrics.est_total_tokens
191
+ totals["total_cost"] += metrics.total_cost
192
+ return totals
193
+
194
+ def _render(self):
195
+ with self._lock:
196
+ summary = Table.grid(expand=True)
197
+ summary.add_column(justify="left")
198
+ summary.add_column(justify="left")
199
+ queued = sum(1 for update in self._updates.values() if update.status == "queued")
200
+ running = sum(1 for update in self._updates.values() if update.status == "running")
201
+ completed = sum(1 for update in self._updates.values() if update.status == "completed")
202
+ failed = sum(1 for update in self._updates.values() if update.status == "failed")
203
+ totals = self.snapshot_totals()
204
+ summary.add_row(
205
+ f"queued={queued} running={running} completed={completed} failed={failed}",
206
+ f"tokens={totals['total_tokens']} cost=${totals['total_cost']:.6f}",
207
+ )
208
+
209
+ table = Table(title="Generation Progress")
210
+ table.add_column("#", justify="right", no_wrap=True)
211
+ table.add_column("status", no_wrap=True)
212
+ table.add_column("elapsed", no_wrap=True)
213
+ table.add_column("tokens", justify="right", no_wrap=True)
214
+ table.add_column("cost", justify="right", no_wrap=True)
215
+ table.add_column("model", no_wrap=True)
216
+ table.add_column("prompt")
217
+ table.add_column("details")
218
+ active_updates = [
219
+ update
220
+ for update in self._updates.values()
221
+ if update.status in {"queued", "running"}
222
+ ]
223
+ for update in sorted(active_updates, key=lambda item: item.prompt_index):
224
+ metrics = update.metrics
225
+ model = metrics.model if metrics and metrics.model else "--"
226
+ details = "--"
227
+ if update.trace_path is not None:
228
+ details = str(update.trace_path.name)
229
+ elif update.error:
230
+ details = update.error
231
+ table.add_row(
232
+ str(update.prompt_index),
233
+ update.status,
234
+ _format_elapsed(update),
235
+ _format_api_tokens(metrics),
236
+ _format_cost(metrics),
237
+ model,
238
+ update.prompt_preview,
239
+ details,
240
+ )
241
+ return Group(Panel.fit(summary, title="Batch Summary"), table)
242
+
243
+
244
+ @app.command()
245
+ def init(
246
+ path: Path = typer.Argument(Path("."), help="Directory to initialize"),
247
+ ) -> None:
248
+ """Initialize a new teich project."""
249
+ console.print(Panel.fit("Initialize Project", style="bold blue"))
250
+
251
+ path.mkdir(parents=True, exist_ok=True)
252
+
253
+ # Create config.yaml
254
+ config_path = path / "config.yaml"
255
+ if not config_path.exists():
256
+ config_path.write_text(CONFIG_TEMPLATE, encoding="utf-8")
257
+ console.print(f"[green]Created: {config_path}[/green]")
258
+ else:
259
+ console.print(f"[yellow]Already exists: {config_path}[/yellow]")
260
+
261
+ # Create prompts.csv
262
+ prompts_path = path / "prompts.csv"
263
+ if not prompts_path.exists():
264
+ prompts_path.write_text(PROMPTS_TEMPLATE, encoding="utf-8")
265
+ console.print(f"[green]Created: {prompts_path}[/green]")
266
+ else:
267
+ console.print(f"[yellow]Already exists: {prompts_path}[/yellow]")
268
+
269
+ console.print(f"\n[bold green]Initialized in {path.absolute()}[/bold green]")
270
+ console.print("\n[yellow]Next:[/yellow]")
271
+ console.print("1. Set OPENAI_API_KEY in config.yaml or env")
272
+ console.print("2. Add prompt rows to prompts.csv")
273
+ console.print("3. Run: [cyan]uvx teich generate -c config.yaml[/cyan]")
274
+
275
+
276
+ CONFIG_TEMPLATE = '''# Teich Configuration
277
+ agent:
278
+ provider: codex
279
+
280
+ model:
281
+ model: codex-mini-latest
282
+ approval_policy: never
283
+ sandbox: danger-full-access
284
+ reasoning_effort: null
285
+
286
+ # Optional: API/model provider configuration
287
+ # api:
288
+ # provider: openai
289
+ # base_url: null
290
+ # api_key: null
291
+
292
+ # Optional: MCP servers
293
+ # mcp_servers:
294
+ # - name: filesystem
295
+ # command: npx
296
+ # args: ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
297
+
298
+ # Load prompts from file (optional)
299
+ prompts_file: prompts.csv
300
+
301
+ # Or define prompts here
302
+ prompts: []
303
+
304
+ output:
305
+ traces_dir: ./output
306
+ sandbox_dir: ./sandbox
307
+ pretty_name: "My Agent Traces"
308
+ readme_file_name: README.md
309
+ tags:
310
+ - agent-traces
311
+ - codex
312
+
313
+ max_concurrency: 1
314
+ timeout_seconds: 600
315
+
316
+ # Set via env: OPENAI_API_KEY or here
317
+ openai_api_key: null
318
+ developer_instructions: null
319
+ '''
320
+
321
+ PROMPTS_TEMPLATE = '''image,github_repo,prompt
322
+ None,None,"Build a simple todo list app in React"
323
+ None,None,"Create a Python script that fetches weather data from an API"
324
+ None,armand0e/perplexica-mcp,"Add a small usability improvement and update the tests"
325
+ '''
326
+
327
+
328
+ def main():
329
+ app()
330
+
331
+
332
+ if __name__ == "__main__":
333
+ main()
@@ -0,0 +1,289 @@
1
+ """Configuration management for teich."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import os
7
+ from pathlib import Path
8
+ import re
9
+
10
+ import yaml
11
+ from pydantic import BaseModel, Field, field_validator, model_validator
12
+
13
+
14
+ GITHUB_REPO_ID_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
15
+
16
+
17
+ def _get_env_alias(*names: str) -> str | None:
18
+ for name in names:
19
+ value = os.getenv(name)
20
+ if value:
21
+ return value
22
+ return None
23
+
24
+
25
+ class MCPConfig(BaseModel):
26
+ """MCP server configuration."""
27
+ name: str
28
+ command: str | None = None
29
+ args: list[str] = Field(default_factory=list)
30
+ env: dict[str, str] = Field(default_factory=dict)
31
+ env_vars: list[str] = Field(default_factory=list)
32
+ cwd: str | None = None
33
+ url: str | None = None
34
+ bearer_token_env_var: str | None = None
35
+ http_headers: dict[str, str] = Field(default_factory=dict)
36
+ env_http_headers: dict[str, str] = Field(default_factory=dict)
37
+ startup_timeout_sec: int | None = None
38
+ tool_timeout_sec: int | None = None
39
+ enabled: bool = True
40
+ required: bool = False
41
+ enabled_tools: list[str] = Field(default_factory=list)
42
+ disabled_tools: list[str] = Field(default_factory=list)
43
+
44
+ @model_validator(mode="after")
45
+ def validate_transport(self) -> MCPConfig:
46
+ if not self.command and not self.url:
47
+ raise ValueError(
48
+ f"MCP server '{self.name}' must define either command or url"
49
+ )
50
+ if self.command and self.url:
51
+ raise ValueError(
52
+ f"MCP server '{self.name}' cannot define both command and url"
53
+ )
54
+ return self
55
+
56
+
57
+ class APIConfig(BaseModel):
58
+ """API configuration for OpenAI-compatible endpoints."""
59
+ provider: str = "openai" # openai, openrouter, azure, etc.
60
+ base_url: str | None = None # e.g., https://openrouter.ai/api/v1
61
+ api_key: str | None = None # Override global api_key for this provider
62
+ wire_api: str = "responses"
63
+
64
+
65
+ class AgentConfig(BaseModel):
66
+ """Agent runtime selection."""
67
+ provider: str = "codex"
68
+
69
+
70
+ class ModelConfig(BaseModel):
71
+ """Model configuration."""
72
+ model: str = "codex-mini-latest"
73
+ approval_policy: str = "never"
74
+ sandbox: str = "danger-full-access"
75
+ reasoning_effort: str | None = None
76
+ approval_mode: str | None = "none"
77
+ pi_model_overrides: dict[str, object] = Field(default_factory=dict)
78
+
79
+ @model_validator(mode="after")
80
+ def normalize_legacy_approval_mode(self) -> ModelConfig:
81
+ if self.approval_mode:
82
+ self.approval_policy = {
83
+ "none": "never",
84
+ "suggest": "on-request",
85
+ "auto-edit": "on-request",
86
+ "full-auto": "on-request",
87
+ }.get(self.approval_mode, self.approval_policy)
88
+ return self
89
+
90
+
91
+ class OutputConfig(BaseModel):
92
+ """Output configuration."""
93
+ traces_dir: Path = Field(default=Path("./output"))
94
+ sandbox_dir: Path = Field(default=Path("./sandbox"))
95
+ pretty_name: str = "Agentic Training Traces"
96
+ tags: list[str] = Field(default_factory=lambda: ["agent-traces", "codex"])
97
+ readme_file_name: str = "README.md"
98
+
99
+
100
+ class PromptInput(BaseModel):
101
+ """Structured prompt input row."""
102
+ image: str | None = None
103
+ github_repo: str | None = None
104
+ prompt: str
105
+
106
+ @staticmethod
107
+ def _normalize_optional_text(value: object) -> str | None:
108
+ if value is None:
109
+ return None
110
+ text = value if isinstance(value, str) else str(value)
111
+ normalized = text.strip()
112
+ if not normalized or normalized.lower() == "none":
113
+ return None
114
+ return normalized
115
+
116
+ @field_validator("image", "github_repo", mode="before")
117
+ @classmethod
118
+ def normalize_optional_fields(cls, value: object) -> str | None:
119
+ return cls._normalize_optional_text(value)
120
+
121
+ @field_validator("prompt", mode="before")
122
+ @classmethod
123
+ def normalize_prompt(cls, value: object) -> str:
124
+ if value is None:
125
+ return ""
126
+ return (value if isinstance(value, str) else str(value)).strip()
127
+
128
+ @field_validator("prompt")
129
+ @classmethod
130
+ def validate_prompt(cls, value: str) -> str:
131
+ if not value:
132
+ raise ValueError("Prompt cannot be empty")
133
+ return value
134
+
135
+ @field_validator("github_repo")
136
+ @classmethod
137
+ def validate_github_repo(cls, value: str | None) -> str | None:
138
+ if value is None:
139
+ return None
140
+ if not GITHUB_REPO_ID_PATTERN.fullmatch(value):
141
+ raise ValueError(
142
+ "github_repo must be in owner/repo format, e.g. armand0e/perplexica-mcp"
143
+ )
144
+ return value
145
+
146
+
147
+ class Config(BaseModel):
148
+ """Main configuration."""
149
+ agent: AgentConfig = Field(default_factory=AgentConfig)
150
+ model: ModelConfig = Field(default_factory=ModelConfig)
151
+ api: APIConfig = Field(default_factory=APIConfig)
152
+ mcp_servers: list[MCPConfig] = Field(default_factory=list)
153
+ prompts: list[str] = Field(default_factory=list)
154
+ prompts_file: Path | None = None
155
+ output: OutputConfig = Field(default_factory=OutputConfig)
156
+ max_concurrency: int = Field(default=1, ge=1)
157
+ timeout_seconds: int = 600
158
+ openai_api_key: str | None = None
159
+ developer_instructions: str | None = None
160
+
161
+ @field_validator("prompts_file")
162
+ @classmethod
163
+ def validate_prompts_file(cls, v: Path | None) -> Path | None:
164
+ if v is not None and not v.exists():
165
+ raise ValueError(f"Prompts file not found: {v}")
166
+ return v
167
+
168
+ @classmethod
169
+ def from_yaml(cls, path: Path) -> Config:
170
+ """Load config from YAML file.
171
+
172
+ Environment variables override config file values (for testing):
173
+ TEICH_MODEL - Override model ID
174
+ TEICH_BASE_URL - Override API base URL
175
+ TEICH_API_KEY - Override API key
176
+ TEICH_PROVIDER - Override provider name
177
+ """
178
+ with open(path, "r", encoding="utf-8") as f:
179
+ data = yaml.safe_load(f) or {}
180
+
181
+ api_config = data.get("api")
182
+ if isinstance(api_config, dict) and "model" in api_config:
183
+ raise ValueError(
184
+ "Unsupported config key 'api.model'. Set the model under 'model.model' instead."
185
+ )
186
+ prompts_file = data.get("prompts_file")
187
+ if isinstance(prompts_file, str) and prompts_file.strip():
188
+ prompts_file_path = Path(prompts_file)
189
+ if not prompts_file_path.is_absolute():
190
+ data["prompts_file"] = (path.parent / prompts_file_path).resolve()
191
+
192
+ # Apply environment variable overrides
193
+ if model_env := _get_env_alias("TEICH_MODEL", "AGENTIC_DATAGEN_MODEL"):
194
+ data.setdefault("model", {})["model"] = model_env
195
+ if base_url_env := _get_env_alias("TEICH_BASE_URL", "AGENTIC_DATAGEN_BASE_URL"):
196
+ data.setdefault("api", {})["base_url"] = base_url_env
197
+ if api_key_env := _get_env_alias("TEICH_API_KEY", "AGENTIC_DATAGEN_API_KEY"):
198
+ data.setdefault("api", {})["api_key"] = api_key_env
199
+ if provider_env := _get_env_alias("TEICH_PROVIDER", "AGENTIC_DATAGEN_PROVIDER"):
200
+ data.setdefault("api", {})["provider"] = provider_env
201
+
202
+ return cls(**data)
203
+
204
+ def get_api_key(self) -> str | None:
205
+ """Get effective API key (from api config or global)."""
206
+ return self.api.api_key or self.openai_api_key
207
+
208
+ def get_effective_model(self) -> str:
209
+ """Get effective model identifier."""
210
+ return self.model.model
211
+
212
+ def get_base_url(self) -> str | None:
213
+ """Get effective base URL."""
214
+ return self.api.base_url
215
+
216
+ def get_agent_provider(self) -> str:
217
+ """Get active agent provider."""
218
+ return self.agent.provider.strip().lower() or "codex"
219
+
220
+ def get_prompts(self) -> list[str]:
221
+ """Get prompt text only for all configured prompt inputs."""
222
+ return [prompt_input.prompt for prompt_input in self.get_prompt_inputs()]
223
+
224
+ def get_prompt_inputs(self) -> list[PromptInput]:
225
+ """Get structured prompt inputs from config and prompts_file."""
226
+ prompt_inputs = [PromptInput(prompt=prompt) for prompt in self.prompts]
227
+ if self.prompts_file:
228
+ prompt_inputs.extend(self._load_prompt_inputs_from_file(self.prompts_file))
229
+ for prompt_input in prompt_inputs:
230
+ if prompt_input.image is not None:
231
+ raise ValueError(
232
+ "Prompt image inputs are not supported yet. Leave the image column blank or set it to None."
233
+ )
234
+ return prompt_inputs
235
+
236
+ @staticmethod
237
+ def _load_prompt_inputs_from_file(path: Path) -> list[PromptInput]:
238
+ if path.suffix.lower() == ".csv":
239
+ return Config._load_prompt_inputs_from_csv(path)
240
+ return Config._load_prompt_inputs_from_text(path)
241
+
242
+ @staticmethod
243
+ def _load_prompt_inputs_from_text(path: Path) -> list[PromptInput]:
244
+ with path.open("r", encoding="utf-8") as handle:
245
+ return [
246
+ PromptInput(prompt=line.strip())
247
+ for line in handle
248
+ if line.strip() and not line.startswith("#")
249
+ ]
250
+
251
+ @staticmethod
252
+ def _load_prompt_inputs_from_csv(path: Path) -> list[PromptInput]:
253
+ with path.open("r", encoding="utf-8", newline="") as handle:
254
+ reader = csv.DictReader(handle)
255
+ fieldnames = [name.strip().lower() for name in reader.fieldnames or [] if isinstance(name, str)]
256
+ if "prompt" not in fieldnames:
257
+ raise ValueError("Prompt CSV must include a 'prompt' column")
258
+ prompt_inputs: list[PromptInput] = []
259
+ for row in reader:
260
+ normalized_row = {
261
+ key.strip().lower(): value
262
+ for key, value in row.items()
263
+ if isinstance(key, str)
264
+ }
265
+ if not any(
266
+ isinstance(value, str) and value.strip()
267
+ for value in normalized_row.values()
268
+ ):
269
+ continue
270
+ prompt_inputs.append(
271
+ PromptInput(
272
+ image=normalized_row.get("image"),
273
+ github_repo=normalized_row.get("github_repo"),
274
+ prompt=normalized_row.get("prompt") or "",
275
+ )
276
+ )
277
+ return prompt_inputs
278
+
279
+
280
+ def load_config(path: Path) -> Config:
281
+ """Load configuration from YAML file.
282
+
283
+ Environment variables (for testing):
284
+ TEICH_MODEL - Override model ID
285
+ TEICH_BASE_URL - Override API base URL
286
+ TEICH_API_KEY - Override API key
287
+ TEICH_PROVIDER - Override provider name
288
+ """
289
+ return Config.from_yaml(path)