orizu 0.3.1 → 0.3.3

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,177 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import ssl
6
+ import subprocess
7
+ import tempfile
8
+ import urllib.error
9
+ import urllib.request
10
+ from typing import Any
11
+
12
+ from .optimizer import ReflectionResult, RowEvaluation, TextGepaConfig, build_reflection_prompt, extract_candidate_text
13
+
14
+
15
+ def _read_anthropic_response_with_curl(api_key: str, payload: dict[str, Any]) -> dict[str, Any]:
16
+ with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".json") as body_file:
17
+ json.dump(payload, body_file)
18
+ body_file.flush()
19
+ curl_config = "\n".join([
20
+ 'url = "https://api.anthropic.com/v1/messages"',
21
+ 'request = "POST"',
22
+ 'header = "anthropic-version: 2023-06-01"',
23
+ 'header = "content-type: application/json"',
24
+ f'header = "x-api-key: {api_key}"',
25
+ f'data-binary = "@{body_file.name}"',
26
+ "fail-with-body",
27
+ "silent",
28
+ "show-error",
29
+ ])
30
+ result = subprocess.run(
31
+ ["curl", "--config", "-"],
32
+ input=curl_config,
33
+ text=True,
34
+ capture_output=True,
35
+ check=False,
36
+ )
37
+ if result.returncode != 0:
38
+ detail = "\n".join(part for part in [result.stderr.strip(), result.stdout.strip()] if part)
39
+ raise RuntimeError(f"Reflection LM failed via curl: {detail}")
40
+ return json.loads(result.stdout)
41
+
42
+
43
+ def _merge_provider_settings(
44
+ payload: dict[str, Any],
45
+ settings: dict[str, Any],
46
+ *,
47
+ reserved_keys: set[str],
48
+ ) -> dict[str, Any]:
49
+ blocked = reserved_keys.intersection(settings)
50
+ if blocked:
51
+ raise RuntimeError(
52
+ f"reflection_provider_settings cannot override reserved request keys: {', '.join(sorted(blocked))}"
53
+ )
54
+ return {**payload, **settings}
55
+
56
+
57
+ def _add_temperature(payload: dict[str, Any], config: TextGepaConfig) -> dict[str, Any]:
58
+ if config.reflection_temperature is None:
59
+ return payload
60
+ if "temperature" in payload and payload["temperature"] != config.reflection_temperature:
61
+ raise RuntimeError("reflection_temperature conflicts with reflection_provider_settings.temperature")
62
+ return {**payload, "temperature": config.reflection_temperature}
63
+
64
+
65
+ def build_anthropic_reflection_payload(model: str, prompt: str, config: TextGepaConfig) -> dict[str, Any]:
66
+ payload = _merge_provider_settings({
67
+ "model": model,
68
+ "max_tokens": config.reflection_max_tokens,
69
+ "messages": [{"role": "user", "content": prompt}],
70
+ }, config.reflection_provider_settings, reserved_keys={"model", "max_tokens", "messages"})
71
+ payload = _add_temperature(payload, config)
72
+ if "temperature" in payload and "thinking" in payload:
73
+ raise RuntimeError("reflection_temperature cannot be combined with Anthropic thinking")
74
+ return payload
75
+
76
+
77
+ def build_openai_reflection_payload(model: str, prompt: str, config: TextGepaConfig) -> dict[str, Any]:
78
+ payload = _merge_provider_settings({
79
+ "model": model,
80
+ "input": [{"role": "user", "content": prompt}],
81
+ "max_output_tokens": config.reflection_max_tokens,
82
+ }, config.reflection_provider_settings, reserved_keys={"model", "input", "max_output_tokens"})
83
+ return _add_temperature(payload, config)
84
+
85
+
86
+ def _extract_openai_output_text(data: dict[str, Any]) -> str:
87
+ output_text = data.get("output_text")
88
+ if isinstance(output_text, str):
89
+ return output_text
90
+ text_parts: list[str] = []
91
+ for item in data.get("output", []):
92
+ if not isinstance(item, dict) or item.get("type") != "message":
93
+ continue
94
+ for content in item.get("content", []):
95
+ if isinstance(content, dict) and content.get("type") == "output_text":
96
+ text = content.get("text")
97
+ if isinstance(text, str):
98
+ text_parts.append(text)
99
+ return "".join(text_parts)
100
+
101
+
102
+ def reflect_with_anthropic(parent_text: str, parent_results: list[RowEvaluation], config: TextGepaConfig) -> ReflectionResult:
103
+ api_key = os.environ.get("ANTHROPIC_API_KEY")
104
+ if not api_key:
105
+ raise RuntimeError("ANTHROPIC_API_KEY is required for the default reflective LM")
106
+ model = config.reflection_model
107
+ if model.startswith("anthropic/"):
108
+ model = model.split("/", 1)[1]
109
+ prompt = build_reflection_prompt(parent_text, parent_results, config)
110
+ payload = build_anthropic_reflection_payload(model, prompt, config)
111
+ request = urllib.request.Request(
112
+ "https://api.anthropic.com/v1/messages",
113
+ data=json.dumps(payload).encode("utf-8"),
114
+ method="POST",
115
+ headers={
116
+ "x-api-key": api_key,
117
+ "anthropic-version": "2023-06-01",
118
+ "content-type": "application/json",
119
+ },
120
+ )
121
+ try:
122
+ with urllib.request.urlopen(request, timeout=180) as response:
123
+ data = json.loads(response.read().decode("utf-8"))
124
+ except urllib.error.HTTPError as error:
125
+ detail = error.read().decode("utf-8", errors="replace")
126
+ raise RuntimeError(f"Reflection LM failed: {error.code} {detail}") from error
127
+ except urllib.error.URLError as error:
128
+ if not isinstance(error.reason, ssl.SSLCertVerificationError):
129
+ raise
130
+ data = _read_anthropic_response_with_curl(api_key, payload)
131
+ parts = data.get("content") or []
132
+ text = "".join(part.get("text", "") for part in parts if isinstance(part, dict))
133
+ return ReflectionResult(
134
+ prompt=prompt,
135
+ response=text,
136
+ candidate_text=extract_candidate_text(text),
137
+ )
138
+
139
+
140
+ def reflect_with_openai(parent_text: str, parent_results: list[RowEvaluation], config: TextGepaConfig) -> ReflectionResult:
141
+ api_key = os.environ.get("OPENAI_API_KEY")
142
+ if not api_key:
143
+ raise RuntimeError("OPENAI_API_KEY is required for OpenAI reflection models")
144
+ model = config.reflection_model
145
+ if model.startswith("openai/"):
146
+ model = model.split("/", 1)[1]
147
+ prompt = build_reflection_prompt(parent_text, parent_results, config)
148
+ payload = build_openai_reflection_payload(model, prompt, config)
149
+ request = urllib.request.Request(
150
+ "https://api.openai.com/v1/responses",
151
+ data=json.dumps(payload).encode("utf-8"),
152
+ method="POST",
153
+ headers={
154
+ "Authorization": f"Bearer {api_key}",
155
+ "Content-Type": "application/json",
156
+ },
157
+ )
158
+ try:
159
+ with urllib.request.urlopen(request, timeout=180) as response:
160
+ data = json.loads(response.read().decode("utf-8"))
161
+ except urllib.error.HTTPError as error:
162
+ detail = error.read().decode("utf-8", errors="replace")
163
+ raise RuntimeError(f"OpenAI reflection LM failed: {error.code} {detail}") from error
164
+ text = _extract_openai_output_text(data)
165
+ if not text:
166
+ raise RuntimeError("OpenAI reflection LM returned no output text")
167
+ return ReflectionResult(
168
+ prompt=prompt,
169
+ response=text,
170
+ candidate_text=extract_candidate_text(text),
171
+ )
172
+
173
+
174
+ def reflect_with_provider(parent_text: str, parent_results: list[RowEvaluation], config: TextGepaConfig) -> ReflectionResult:
175
+ if config.reflection_model.startswith("openai/"):
176
+ return reflect_with_openai(parent_text, parent_results, config)
177
+ return reflect_with_anthropic(parent_text, parent_results, config)
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import tempfile
7
+ import zipfile
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .client import OrizuClient
12
+ from .optimizer import DatasetRow, PromptContext, RunnerCallResult
13
+
14
+ MAX_RUNNER_ZIP_BYTES = 25 * 1024 * 1024
15
+ MAX_RUNNER_ZIP_ENTRIES = 1000
16
+ MAX_RUNNER_UNCOMPRESSED_BYTES = 75 * 1024 * 1024
17
+ MAX_RUNNER_OUTPUT_BYTES = 2 * 1024 * 1024
18
+ DEFAULT_RUNNER_TIMEOUT_SECONDS = 120
19
+
20
+ ALLOWED_RUNNER_ENV_KEYS = {
21
+ "PATH",
22
+ "SystemRoot",
23
+ "WINDIR",
24
+ "HOME",
25
+ "TMPDIR",
26
+ "TEMP",
27
+ "TMP",
28
+ "LANG",
29
+ "LC_ALL",
30
+ "PYTHONPATH",
31
+ "NODE_PATH",
32
+ "ANTHROPIC_API_KEY",
33
+ "OPENAI_API_KEY",
34
+ "GEMINI_API_KEY",
35
+ "GOOGLE_API_KEY",
36
+ }
37
+
38
+
39
+ def read_manifest(runner_dir: str | Path) -> dict[str, Any]:
40
+ path = Path(runner_dir) / "manifest.json"
41
+ return json.loads(path.read_text())
42
+
43
+
44
+ def _safe_extract_zip(zip_path: Path, destination: Path) -> None:
45
+ if zip_path.stat().st_size > MAX_RUNNER_ZIP_BYTES:
46
+ raise RuntimeError("Runner artifact exceeds the maximum zip size")
47
+
48
+ with zipfile.ZipFile(zip_path) as archive:
49
+ infos = archive.infolist()
50
+ if len(infos) > MAX_RUNNER_ZIP_ENTRIES:
51
+ raise RuntimeError("Runner artifact contains too many files")
52
+
53
+ total_uncompressed = 0
54
+ destination_resolved = destination.resolve()
55
+ for info in infos:
56
+ total_uncompressed += info.file_size
57
+ if total_uncompressed > MAX_RUNNER_UNCOMPRESSED_BYTES:
58
+ raise RuntimeError("Runner artifact exceeds the maximum expanded size")
59
+
60
+ target = (destination / info.filename).resolve()
61
+ if os.path.commonpath([str(destination_resolved), str(target)]) != str(destination_resolved):
62
+ raise RuntimeError("Runner artifact contains an unsafe path")
63
+
64
+ archive.extractall(destination)
65
+
66
+
67
+ def _runner_env(input_path: Path, output_path: Path) -> dict[str, str]:
68
+ env = {
69
+ key: value
70
+ for key, value in os.environ.items()
71
+ if key in ALLOWED_RUNNER_ENV_KEYS
72
+ }
73
+ env["ORIZU_RUNNER_INPUT_PATH"] = str(input_path)
74
+ env["ORIZU_RUNNER_OUTPUT_PATH"] = str(output_path)
75
+ return env
76
+
77
+
78
+ def _bounded_stream(value: str) -> str:
79
+ encoded = value.encode("utf-8", errors="replace")
80
+ if len(encoded) <= MAX_RUNNER_OUTPUT_BYTES:
81
+ return value
82
+ return encoded[:MAX_RUNNER_OUTPUT_BYTES].decode("utf-8", errors="replace") + "\n[truncated]"
83
+
84
+
85
+ def materialize_runner_version(client: OrizuClient, runner_version_id: str) -> tempfile.TemporaryDirectory[str]:
86
+ temp_dir = tempfile.TemporaryDirectory(prefix="orizu-gepa-runner-")
87
+ data = client._request_bytes(f"/api/cli/runner-versions/{runner_version_id}/download")
88
+ zip_path = Path(temp_dir.name) / "runner.zip"
89
+ runner_dir = Path(temp_dir.name) / "runner"
90
+ zip_path.write_bytes(data)
91
+ _safe_extract_zip(zip_path, runner_dir)
92
+ return temp_dir
93
+
94
+
95
+ def run_file_contract_runner(
96
+ *,
97
+ runner_dir: str | Path,
98
+ row: dict[str, Any],
99
+ prompt_body: str | None,
100
+ body_kind: str,
101
+ provider_settings: dict[str, Any],
102
+ prompt_version_id: str,
103
+ runner_version_id: str,
104
+ run_id: str | None,
105
+ timeout_seconds: int = DEFAULT_RUNNER_TIMEOUT_SECONDS,
106
+ ) -> RunnerCallResult:
107
+ runner_path = Path(runner_dir)
108
+ manifest = read_manifest(runner_path)
109
+ command = manifest.get("command")
110
+ if not isinstance(command, list) or not all(isinstance(item, str) for item in command):
111
+ raise RuntimeError(f"Runner manifest at {runner_path / 'manifest.json'} must include command: string[]")
112
+
113
+ with tempfile.TemporaryDirectory(prefix="orizu-gepa-call-") as temp_dir:
114
+ input_path = Path(temp_dir) / "input.json"
115
+ output_path = Path(temp_dir) / "output.json"
116
+ input_path.write_text(json.dumps({
117
+ "row": row,
118
+ "prompt": {
119
+ "body": prompt_body,
120
+ "body_kind": body_kind,
121
+ "provider_settings": provider_settings,
122
+ },
123
+ "prompt_version_id": prompt_version_id,
124
+ "runner_version_id": runner_version_id,
125
+ "run_id": run_id,
126
+ }, ensure_ascii=False))
127
+
128
+ try:
129
+ result = subprocess.run(
130
+ command,
131
+ cwd=runner_path,
132
+ env=_runner_env(input_path, output_path),
133
+ text=True,
134
+ capture_output=True,
135
+ check=False,
136
+ timeout=timeout_seconds,
137
+ )
138
+ except subprocess.TimeoutExpired as exc:
139
+ stdout = _bounded_stream(exc.stdout or "")
140
+ stderr = _bounded_stream(exc.stderr or "")
141
+ raise RuntimeError(
142
+ f"Runner timed out after {timeout_seconds}s: {stderr or stdout}"
143
+ ) from exc
144
+
145
+ if result.returncode != 0:
146
+ stdout = _bounded_stream(result.stdout or "")
147
+ stderr = _bounded_stream(result.stderr or "")
148
+ raise RuntimeError(
149
+ f"Runner failed with exit code {result.returncode}: {stderr or stdout}"
150
+ )
151
+ data = json.loads(output_path.read_text())
152
+ known = {
153
+ "model_response",
154
+ "raw_api_response",
155
+ "token_in",
156
+ "token_out",
157
+ "latency_ms",
158
+ "cost_usd",
159
+ "error",
160
+ }
161
+ return RunnerCallResult(
162
+ model_response=data.get("model_response"),
163
+ raw_api_response=data.get("raw_api_response"),
164
+ token_in=data.get("token_in"),
165
+ token_out=data.get("token_out"),
166
+ latency_ms=data.get("latency_ms"),
167
+ cost_usd=data.get("cost_usd"),
168
+ error=data.get("error"),
169
+ extra={key: value for key, value in data.items() if key not in known},
170
+ )
171
+
172
+
173
+ def make_candidate_runner(runner_dir: str | Path, run_id: str | None):
174
+ def _run(candidate_text: str, row: DatasetRow, prompt_context: PromptContext, candidate_id: str) -> RunnerCallResult:
175
+ return run_file_contract_runner(
176
+ runner_dir=runner_dir,
177
+ row=row.row,
178
+ prompt_body=candidate_text,
179
+ body_kind=prompt_context.body_kind,
180
+ provider_settings=prompt_context.provider_settings,
181
+ prompt_version_id=prompt_context.prompt_version_id,
182
+ runner_version_id=prompt_context.runner_version_id,
183
+ run_id=run_id,
184
+ )
185
+ return _run
186
+
187
+
188
+ def make_scorer_runner(runner_dir: str | Path, run_id: str | None):
189
+ def _run(row: DatasetRow, candidate_result: RunnerCallResult, scorer_context: PromptContext, candidate_id: str) -> RunnerCallResult:
190
+ scorer_row = {
191
+ "source_row": row.row,
192
+ "candidate_id": candidate_id,
193
+ "candidate_output": candidate_result.model_response,
194
+ "candidate_raw_response": candidate_result.raw_api_response,
195
+ "candidate_error": candidate_result.error,
196
+ }
197
+ return run_file_contract_runner(
198
+ runner_dir=runner_dir,
199
+ row=scorer_row,
200
+ prompt_body=scorer_context.body,
201
+ body_kind=scorer_context.body_kind,
202
+ provider_settings=scorer_context.provider_settings,
203
+ prompt_version_id=scorer_context.prompt_version_id,
204
+ runner_version_id=scorer_context.runner_version_id,
205
+ run_id=run_id,
206
+ )
207
+ return _run