orizu 0.3.1 → 0.3.2

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,314 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ import urllib.error
7
+ import urllib.parse
8
+ import urllib.request
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from .optimizer import DatasetRow, PromptContext
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class OrizuClient:
17
+ api_url: str
18
+ token: str
19
+ project: str | None = None
20
+ sequence: int = 0
21
+
22
+ @classmethod
23
+ def from_env(cls) -> "OrizuClient":
24
+ api_url = (os.environ.get("ORIZU_API_URL") or "").rstrip("/")
25
+ token = os.environ.get("ORIZU_TOKEN") or ""
26
+ if not api_url or not token:
27
+ raise RuntimeError("ORIZU_API_URL and ORIZU_TOKEN are required. Run: eval \"$(orizu env --project <team>/<project>)\"")
28
+ return cls(api_url=api_url, token=token, project=os.environ.get("ORIZU_PROJECT"))
29
+
30
+ def _request(
31
+ self,
32
+ method: str,
33
+ path: str,
34
+ body: dict[str, Any] | None = None,
35
+ ) -> Any:
36
+ data = None if body is None else json.dumps(body).encode("utf-8")
37
+ request = urllib.request.Request(
38
+ f"{self.api_url}{path}",
39
+ data=data,
40
+ method=method,
41
+ headers={
42
+ "Authorization": f"Bearer {self.token}",
43
+ "Content-Type": "application/json",
44
+ },
45
+ )
46
+ try:
47
+ with urllib.request.urlopen(request, timeout=120) as response:
48
+ raw = response.read().decode("utf-8")
49
+ return json.loads(raw) if raw else {}
50
+ except urllib.error.HTTPError as error:
51
+ detail = error.read().decode("utf-8", errors="replace")
52
+ raise RuntimeError(f"{method} {path} failed: {error.code} {detail}") from error
53
+
54
+ def _request_bytes(self, path: str) -> bytes:
55
+ request = urllib.request.Request(
56
+ f"{self.api_url}{path}",
57
+ method="GET",
58
+ headers={"Authorization": f"Bearer {self.token}"},
59
+ )
60
+ try:
61
+ with urllib.request.urlopen(request, timeout=120) as response:
62
+ return response.read()
63
+ except urllib.error.HTTPError as error:
64
+ detail = error.read().decode("utf-8", errors="replace")
65
+ raise RuntimeError(f"GET {path} failed: {error.code} {detail}") from error
66
+
67
+ def start_run(
68
+ self,
69
+ *,
70
+ project: str,
71
+ optimizer_version_id: str,
72
+ prompt_version_id: str,
73
+ scorer_version_id: str,
74
+ dataset_version_id: str,
75
+ split_set_id: str,
76
+ train_split: str,
77
+ validation_split: str,
78
+ metadata: dict[str, Any] | None = None,
79
+ ) -> str:
80
+ query = urllib.parse.urlencode({"project": project})
81
+ data = self._request("POST", f"/api/cli/optimization-runs?{query}", {
82
+ "optimizerVersionId": optimizer_version_id,
83
+ "promptVersionIds": [prompt_version_id],
84
+ "scorers": [
85
+ {
86
+ "scorerVersionId": scorer_version_id,
87
+ "role": "selection",
88
+ },
89
+ {
90
+ "scorerVersionId": scorer_version_id,
91
+ "role": "reflection",
92
+ },
93
+ ],
94
+ "datasetVersionId": dataset_version_id,
95
+ "splitSetId": split_set_id,
96
+ "trainSplitName": train_split,
97
+ "validationSplitName": validation_split,
98
+ "metadata": metadata or {},
99
+ })
100
+ return data["optimization_run_id"]
101
+
102
+ def fetch_exec_context(
103
+ self,
104
+ *,
105
+ prompt_version_id: str,
106
+ runner_version_id: str,
107
+ dataset_version_id: str,
108
+ split_set_id: str,
109
+ split: str,
110
+ ) -> tuple[PromptContext, list[DatasetRow]]:
111
+ query = urllib.parse.urlencode({
112
+ "promptVersion": prompt_version_id,
113
+ "runnerVersion": runner_version_id,
114
+ "datasetVersion": dataset_version_id,
115
+ "splitSet": split_set_id,
116
+ "split": split,
117
+ })
118
+ data = self._request("GET", f"/api/cli/runners/exec-context?{query}")
119
+ prompt = data["prompt"]
120
+ rows = [
121
+ DatasetRow(id=item["id"], row=item["row"])
122
+ for item in data.get("rows", [])
123
+ ]
124
+ return PromptContext(
125
+ body=prompt.get("body"),
126
+ body_kind=prompt.get("bodyKind") or "text",
127
+ provider_settings=prompt.get("providerSettings") or {},
128
+ prompt_version_id=prompt["promptVersionId"],
129
+ runner_version_id=prompt["runnerVersionId"],
130
+ prompt_id=prompt.get("promptId"),
131
+ ), rows
132
+
133
+ def fetch_scorer_exec_context(
134
+ self,
135
+ *,
136
+ scorer_version_id: str,
137
+ runner_version_id: str | None,
138
+ dataset_version_id: str,
139
+ split_set_id: str,
140
+ split: str,
141
+ ) -> tuple[PromptContext, list[DatasetRow]]:
142
+ params = {
143
+ "scorerVersion": scorer_version_id,
144
+ "datasetVersion": dataset_version_id,
145
+ "splitSet": split_set_id,
146
+ "split": split,
147
+ }
148
+ if runner_version_id:
149
+ params["runnerVersion"] = runner_version_id
150
+ query = urllib.parse.urlencode(params)
151
+ data = self._request("GET", f"/api/cli/runners/exec-context?{query}")
152
+ prompt = data["prompt"]
153
+ scorer = data.get("scorer") or {}
154
+ higher_is_better = scorer.get("higherIsBetter")
155
+ if not isinstance(higher_is_better, bool):
156
+ higher_is_better = scorer.get("higher_is_better")
157
+ if not isinstance(higher_is_better, bool):
158
+ higher_is_better = True
159
+ rows = [
160
+ DatasetRow(id=item["id"], row=item["row"])
161
+ for item in data.get("rows", [])
162
+ ]
163
+ return PromptContext(
164
+ body=prompt.get("body"),
165
+ body_kind=prompt.get("bodyKind") or "text",
166
+ provider_settings=prompt.get("providerSettings") or {},
167
+ prompt_version_id=prompt["promptVersionId"],
168
+ runner_version_id=prompt["runnerVersionId"],
169
+ prompt_id=prompt.get("promptId"),
170
+ scorer_version_id=scorer_version_id,
171
+ metric_key=scorer.get("metricKey") or scorer.get("metric_key"),
172
+ higher_is_better=higher_is_better,
173
+ ), rows
174
+
175
+ def log_event(
176
+ self,
177
+ run_id: str,
178
+ *,
179
+ sequence: int,
180
+ event_type: str,
181
+ payload: dict[str, Any] | None = None,
182
+ event_layer: str = "core",
183
+ optimizer_family: str = "gepa",
184
+ iteration: int | None = None,
185
+ candidate_id: str | None = None,
186
+ parent_candidate_id: str | None = None,
187
+ child_candidate_id: str | None = None,
188
+ ) -> None:
189
+ event_id = f"orizu-gepa-{run_id}-{sequence}"
190
+ self._request("POST", f"/api/cli/optimization-runs/{urllib.parse.quote(run_id)}/events", {
191
+ "eventId": event_id,
192
+ "sequence": sequence,
193
+ "eventType": event_type,
194
+ "eventLayer": event_layer,
195
+ "optimizerFamily": optimizer_family,
196
+ "iteration": iteration,
197
+ "candidateId": candidate_id,
198
+ "parentCandidateId": parent_candidate_id,
199
+ "childCandidateId": child_candidate_id,
200
+ "payload": payload or {},
201
+ })
202
+
203
+ def promote_candidate(
204
+ self,
205
+ run_id: str,
206
+ *,
207
+ candidate_id: str,
208
+ prompt_id: str,
209
+ parent_prompt_version_id: str,
210
+ body: str,
211
+ body_kind: str,
212
+ provider_settings: dict[str, Any],
213
+ runner_version_id: str,
214
+ label: str | None,
215
+ ) -> str:
216
+ data = self._request("POST", f"/api/cli/optimization-runs/{urllib.parse.quote(run_id)}/promote", {
217
+ "candidateId": candidate_id,
218
+ "promptId": prompt_id,
219
+ "parentPromptVersionId": parent_prompt_version_id,
220
+ "body": body,
221
+ "bodyKind": body_kind,
222
+ "providerSettings": provider_settings,
223
+ "runnerVersionId": runner_version_id,
224
+ "label": label,
225
+ })
226
+ return data["promptVersionId"]
227
+
228
+ def update_run(
229
+ self,
230
+ run_id: str,
231
+ *,
232
+ status: str,
233
+ best_score: float | None = None,
234
+ best_candidate_id: str | None = None,
235
+ result_prompt_version_id: str | None = None,
236
+ metadata: dict[str, Any] | None = None,
237
+ ) -> None:
238
+ body: dict[str, Any] = {"status": status}
239
+ if best_score is not None:
240
+ body["bestScore"] = best_score
241
+ if best_candidate_id:
242
+ body["bestCandidateId"] = best_candidate_id
243
+ if result_prompt_version_id:
244
+ body["resultPromptVersionId"] = result_prompt_version_id
245
+ if metadata:
246
+ body["metadata"] = metadata
247
+ self._request("PATCH", f"/api/cli/optimization-runs/{urllib.parse.quote(run_id)}", body)
248
+
249
+
250
+ class OrizuEventSink:
251
+ def __init__(
252
+ self,
253
+ client: OrizuClient,
254
+ run_id: str,
255
+ *,
256
+ fail_on_log_error: bool = True,
257
+ max_log_retries: int = 2,
258
+ ):
259
+ self.client = client
260
+ self.run_id = run_id
261
+ self.sequence = 0
262
+ self.fail_on_log_error = fail_on_log_error
263
+ self.max_log_retries = max(0, max_log_retries)
264
+
265
+ def log_event(
266
+ self,
267
+ event_type: str,
268
+ payload: dict[str, Any] | None = None,
269
+ *,
270
+ event_layer: str = "core",
271
+ optimizer_family: str = "gepa",
272
+ iteration: int | None = None,
273
+ candidate_id: str | None = None,
274
+ parent_candidate_id: str | None = None,
275
+ child_candidate_id: str | None = None,
276
+ ) -> None:
277
+ self.sequence += 1
278
+ last_error: Exception | None = None
279
+ for attempt in range(self.max_log_retries + 1):
280
+ try:
281
+ self.client.log_event(
282
+ self.run_id,
283
+ sequence=self.sequence,
284
+ event_type=event_type,
285
+ payload=payload,
286
+ event_layer=event_layer,
287
+ optimizer_family=optimizer_family,
288
+ iteration=iteration,
289
+ candidate_id=candidate_id,
290
+ parent_candidate_id=parent_candidate_id,
291
+ child_candidate_id=child_candidate_id,
292
+ )
293
+ print(f"[orizu-gepa] {self.sequence:03d} {event_type}", flush=True)
294
+ return
295
+ except Exception as exc:
296
+ last_error = exc
297
+ if attempt < self.max_log_retries:
298
+ time.sleep(0.25 * (2 ** attempt))
299
+
300
+ if self.fail_on_log_error and last_error is not None:
301
+ raise last_error
302
+ print(
303
+ f"[orizu-gepa] {self.sequence:03d} {event_type} log failed: {last_error}",
304
+ flush=True,
305
+ )
306
+
307
+ def promote_candidate(self, **kwargs: Any) -> str:
308
+ prompt_version_id = self.client.promote_candidate(self.run_id, **kwargs)
309
+ # Promotion writes a system event at max(sequence)+1 in Postgres.
310
+ self.sequence += 1
311
+ return prompt_version_id
312
+
313
+ def finish_run(self, **kwargs: Any) -> None:
314
+ self.client.update_run(self.run_id, **kwargs)