easyevolve 0.1.0b2__tar.gz
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.
- easyevolve-0.1.0b2/.gitignore +12 -0
- easyevolve-0.1.0b2/PKG-INFO +87 -0
- easyevolve-0.1.0b2/README.md +72 -0
- easyevolve-0.1.0b2/pyproject.toml +36 -0
- easyevolve-0.1.0b2/src/easyevolve/__init__.py +3 -0
- easyevolve-0.1.0b2/src/easyevolve/cli.py +58 -0
- easyevolve-0.1.0b2/src/easyevolve/client.py +255 -0
- easyevolve-0.1.0b2/src/easyevolve/credentials.py +35 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: easyevolve
|
|
3
|
+
Version: 0.1.0b2
|
|
4
|
+
Summary: Give us an evaluator. We'll evolve the code.
|
|
5
|
+
Project-URL: Homepage, https://easyevolve.dev
|
|
6
|
+
Project-URL: Documentation, https://easyevolve.dev/docs
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Requires-Dist: httpx<1,>=0.28
|
|
13
|
+
Requires-Dist: pydantic<3,>=2.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# EasyEvolve
|
|
17
|
+
|
|
18
|
+
**Give us an evaluator. We'll evolve the code.**
|
|
19
|
+
|
|
20
|
+
EasyEvolve is a hosted API that improves Python code with LLM-driven evolutionary search.
|
|
21
|
+
You write a function that scores a candidate program; the service generates, mutates and
|
|
22
|
+
evaluates candidates in isolated sandboxes and returns the best one.
|
|
23
|
+
|
|
24
|
+
> **Invite-only beta.** You need an invite token from the EasyEvolve team.
|
|
25
|
+
> The API, pricing and limits may change, and data may be reset during the beta.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
pip install --pre easyevolve
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Python 3.11 or later.
|
|
34
|
+
|
|
35
|
+
## Sign in with your invite token
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
easyevolve login
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Paste the token when prompted. It is stored at `~/.config/easyevolve/credentials.json`
|
|
42
|
+
with owner-only permissions. You can also set `EASYEVOLVE_TOKEN` instead.
|
|
43
|
+
|
|
44
|
+
## Write an evaluator
|
|
45
|
+
|
|
46
|
+
Expose `evaluate(candidate_path: str) -> dict` returning a finite `score` (higher is better).
|
|
47
|
+
Use the Python standard library only.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
# eval.py
|
|
51
|
+
"""Candidate contract: solve(values: list[int]) -> list[int], sorted ascending."""
|
|
52
|
+
import importlib.util
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def evaluate(candidate_path: str) -> dict:
|
|
56
|
+
spec = importlib.util.spec_from_file_location("candidate", candidate_path)
|
|
57
|
+
candidate = importlib.util.module_from_spec(spec)
|
|
58
|
+
spec.loader.exec_module(candidate)
|
|
59
|
+
cases = [[], [1], [3, 1, 2], [5, -1, 5, 0], list(range(30, 0, -1))]
|
|
60
|
+
passed = sum(candidate.solve(values.copy()) == sorted(values) for values in cases)
|
|
61
|
+
return {"score": passed / len(cases)}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Run
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from easyevolve import EasyEvolve
|
|
68
|
+
|
|
69
|
+
with EasyEvolve(evaluator="eval.py") as client:
|
|
70
|
+
print(client.estimate(iterations=5, level="low", budget=0.5))
|
|
71
|
+
result = client.run(iterations=5, level="low", budget=0.5)
|
|
72
|
+
print(result.best_score, result.cost)
|
|
73
|
+
print(result.best_code)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Or from the terminal:
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
easyevolve balance
|
|
80
|
+
easyevolve run eval.py --iterations 5 --level low --budget 0.5
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`budget` (USD) is a hard cap: the run stops before it would exceed it, and unused budget
|
|
84
|
+
returns to your balance. A client timeout does not stop the job; use
|
|
85
|
+
`client.resume(job_id)` to reconnect or `easyevolve cancel <job_id>` to stop it.
|
|
86
|
+
|
|
87
|
+
Documentation: <https://easyevolve.dev/docs>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# EasyEvolve
|
|
2
|
+
|
|
3
|
+
**Give us an evaluator. We'll evolve the code.**
|
|
4
|
+
|
|
5
|
+
EasyEvolve is a hosted API that improves Python code with LLM-driven evolutionary search.
|
|
6
|
+
You write a function that scores a candidate program; the service generates, mutates and
|
|
7
|
+
evaluates candidates in isolated sandboxes and returns the best one.
|
|
8
|
+
|
|
9
|
+
> **Invite-only beta.** You need an invite token from the EasyEvolve team.
|
|
10
|
+
> The API, pricing and limits may change, and data may be reset during the beta.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
pip install --pre easyevolve
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Python 3.11 or later.
|
|
19
|
+
|
|
20
|
+
## Sign in with your invite token
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
easyevolve login
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Paste the token when prompted. It is stored at `~/.config/easyevolve/credentials.json`
|
|
27
|
+
with owner-only permissions. You can also set `EASYEVOLVE_TOKEN` instead.
|
|
28
|
+
|
|
29
|
+
## Write an evaluator
|
|
30
|
+
|
|
31
|
+
Expose `evaluate(candidate_path: str) -> dict` returning a finite `score` (higher is better).
|
|
32
|
+
Use the Python standard library only.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
# eval.py
|
|
36
|
+
"""Candidate contract: solve(values: list[int]) -> list[int], sorted ascending."""
|
|
37
|
+
import importlib.util
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def evaluate(candidate_path: str) -> dict:
|
|
41
|
+
spec = importlib.util.spec_from_file_location("candidate", candidate_path)
|
|
42
|
+
candidate = importlib.util.module_from_spec(spec)
|
|
43
|
+
spec.loader.exec_module(candidate)
|
|
44
|
+
cases = [[], [1], [3, 1, 2], [5, -1, 5, 0], list(range(30, 0, -1))]
|
|
45
|
+
passed = sum(candidate.solve(values.copy()) == sorted(values) for values in cases)
|
|
46
|
+
return {"score": passed / len(cases)}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Run
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from easyevolve import EasyEvolve
|
|
53
|
+
|
|
54
|
+
with EasyEvolve(evaluator="eval.py") as client:
|
|
55
|
+
print(client.estimate(iterations=5, level="low", budget=0.5))
|
|
56
|
+
result = client.run(iterations=5, level="low", budget=0.5)
|
|
57
|
+
print(result.best_score, result.cost)
|
|
58
|
+
print(result.best_code)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Or from the terminal:
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
easyevolve balance
|
|
65
|
+
easyevolve run eval.py --iterations 5 --level low --budget 0.5
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`budget` (USD) is a hard cap: the run stops before it would exceed it, and unused budget
|
|
69
|
+
returns to your balance. A client timeout does not stop the job; use
|
|
70
|
+
`client.resume(job_id)` to reconnect or `easyevolve cancel <job_id>` to stop it.
|
|
71
|
+
|
|
72
|
+
Documentation: <https://easyevolve.dev/docs>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "easyevolve"
|
|
7
|
+
version = "0.1.0b2"
|
|
8
|
+
description = "Give us an evaluator. We'll evolve the code."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = ["httpx>=0.28,<1", "pydantic>=2.10,<3"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://easyevolve.dev"
|
|
21
|
+
Documentation = "https://easyevolve.dev/docs"
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
easyevolve = "easyevolve.cli:main"
|
|
25
|
+
|
|
26
|
+
# The SDK source lives in ../src so the service and SDK stay one tested codebase.
|
|
27
|
+
# Metadata 2.4 is accepted by every current uploader and index.
|
|
28
|
+
[tool.hatch.build.targets.sdist]
|
|
29
|
+
core-metadata-version = "2.4"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.sdist.force-include]
|
|
32
|
+
"../src/easyevolve" = "src/easyevolve"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/easyevolve"]
|
|
36
|
+
core-metadata-version = "2.4"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import getpass
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .client import EasyEvolve, EasyEvolveError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
try:
|
|
11
|
+
run_command()
|
|
12
|
+
except EasyEvolveError as exc:
|
|
13
|
+
job = f" (job {exc.result.id})" if exc.result else ""
|
|
14
|
+
sys.exit(f"Error{job}: {exc}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_command():
|
|
18
|
+
parser = argparse.ArgumentParser(description="EasyEvolve hosted code evolution")
|
|
19
|
+
parser.add_argument("--api-url")
|
|
20
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
21
|
+
run = commands.add_parser("run")
|
|
22
|
+
run.add_argument("evaluator")
|
|
23
|
+
run.add_argument("--task")
|
|
24
|
+
run.add_argument("--budget", default="2.00")
|
|
25
|
+
run.add_argument("--iterations", type=int, default=100)
|
|
26
|
+
run.add_argument("--level", choices=["low", "middle", "high"], default="middle")
|
|
27
|
+
run.add_argument("--output", default="best.py")
|
|
28
|
+
resume = commands.add_parser("resume")
|
|
29
|
+
resume.add_argument("job_id")
|
|
30
|
+
resume.add_argument("--output", default="best.py")
|
|
31
|
+
cancel = commands.add_parser("cancel")
|
|
32
|
+
cancel.add_argument("job_id")
|
|
33
|
+
commands.add_parser("balance")
|
|
34
|
+
commands.add_parser("login", help="Save an invite token (read from stdin or a prompt)")
|
|
35
|
+
args = parser.parse_args()
|
|
36
|
+
if args.command == "login":
|
|
37
|
+
token = sys.stdin.readline() if not sys.stdin.isatty() else getpass.getpass("Token: ")
|
|
38
|
+
with EasyEvolve(base_url=args.api_url) as client:
|
|
39
|
+
available = client.login(token)
|
|
40
|
+
print(f"Saved token. Available credit: ${available}")
|
|
41
|
+
return
|
|
42
|
+
with EasyEvolve(
|
|
43
|
+
getattr(args, "evaluator", None), task=getattr(args, "task", None), base_url=args.api_url
|
|
44
|
+
) as client:
|
|
45
|
+
if args.command == "balance":
|
|
46
|
+
print(f"Available credit: ${client.balance()}")
|
|
47
|
+
return
|
|
48
|
+
if args.command == "cancel":
|
|
49
|
+
print(client.cancel(args.job_id))
|
|
50
|
+
return
|
|
51
|
+
result = (
|
|
52
|
+
client.run(iterations=args.iterations, level=args.level, budget=args.budget)
|
|
53
|
+
if args.command == "run"
|
|
54
|
+
else client.resume(args.job_id)
|
|
55
|
+
)
|
|
56
|
+
if result.best_code is not None:
|
|
57
|
+
Path(args.output).write_text(result.best_code)
|
|
58
|
+
print(f"{result.status}: best score={result.best_score}, cost=${result.cost}")
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import secrets
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
import webbrowser
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from decimal import ROUND_CEILING, Decimal
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from .credentials import Credentials
|
|
15
|
+
|
|
16
|
+
TERMINAL = {"COMPLETED", "FAILED", "CANCELLED", "BUDGET_EXHAUSTED"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EvolutionResult(BaseModel):
|
|
20
|
+
id: str
|
|
21
|
+
status: str
|
|
22
|
+
best_code: str | None
|
|
23
|
+
best_score: float | None
|
|
24
|
+
initial_score: float | None
|
|
25
|
+
cost: Decimal
|
|
26
|
+
budget: Decimal
|
|
27
|
+
completed_iterations: int
|
|
28
|
+
error: str | None = None
|
|
29
|
+
history: list[dict] = []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EasyEvolveError(RuntimeError):
|
|
33
|
+
def __init__(
|
|
34
|
+
self, message: str, status_code: int | None = None, result: EvolutionResult | None = None
|
|
35
|
+
):
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.status_code = status_code
|
|
38
|
+
self.result = result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class EasyEvolve:
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
evaluator: str | Path | None = None,
|
|
45
|
+
*,
|
|
46
|
+
task: str | None = None,
|
|
47
|
+
initial_code: str | None = None,
|
|
48
|
+
base_url: str | None = None,
|
|
49
|
+
token: str | None = None,
|
|
50
|
+
credentials_path: str | Path | None = None,
|
|
51
|
+
poll_interval: float = 2.0,
|
|
52
|
+
client: httpx.Client | None = None,
|
|
53
|
+
):
|
|
54
|
+
self.evaluator = Path(evaluator) if evaluator is not None else None
|
|
55
|
+
self.task = task
|
|
56
|
+
self.initial_code = initial_code
|
|
57
|
+
self.base_url = (
|
|
58
|
+
base_url or os.environ.get("EASYEVOLVE_API_URL") or "https://api.easyevolve.dev"
|
|
59
|
+
).rstrip("/")
|
|
60
|
+
parsed = urlparse(self.base_url)
|
|
61
|
+
if parsed.scheme != "https" and not (
|
|
62
|
+
parsed.scheme == "http" and parsed.hostname in {"localhost", "127.0.0.1", "::1"}
|
|
63
|
+
):
|
|
64
|
+
raise ValueError("Use HTTPS, or HTTP on localhost for development")
|
|
65
|
+
if poll_interval < 0:
|
|
66
|
+
raise ValueError("poll_interval must be nonnegative")
|
|
67
|
+
self.credentials = Credentials(self.base_url, credentials_path)
|
|
68
|
+
self.token = (
|
|
69
|
+
token or os.environ.get("EASYEVOLVE_TOKEN") or self.credentials.read().get("token")
|
|
70
|
+
)
|
|
71
|
+
self.poll_interval = poll_interval
|
|
72
|
+
self.client = client or httpx.Client(base_url=self.base_url, timeout=30)
|
|
73
|
+
self.owns_client = client is None
|
|
74
|
+
self.job_id: str | None = None
|
|
75
|
+
|
|
76
|
+
def __enter__(self):
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
def __exit__(self, *_):
|
|
80
|
+
self.close()
|
|
81
|
+
|
|
82
|
+
def close(self):
|
|
83
|
+
if self.owns_client:
|
|
84
|
+
self.client.close()
|
|
85
|
+
|
|
86
|
+
def request(self, method: str, path: str, **kwargs) -> dict:
|
|
87
|
+
headers = kwargs.pop("headers", {})
|
|
88
|
+
if self.token:
|
|
89
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
90
|
+
try:
|
|
91
|
+
response = self.client.request(method, path, headers=headers, **kwargs)
|
|
92
|
+
except httpx.HTTPError as exc:
|
|
93
|
+
raise EasyEvolveError(
|
|
94
|
+
f"API connection failed. Resume job {self.job_id} if one was created."
|
|
95
|
+
) from exc
|
|
96
|
+
if response.is_error:
|
|
97
|
+
try:
|
|
98
|
+
detail = response.json().get("detail", "API request failed")
|
|
99
|
+
except ValueError:
|
|
100
|
+
detail = "API request failed"
|
|
101
|
+
raise EasyEvolveError(str(detail), response.status_code)
|
|
102
|
+
return response.json() if response.content else {}
|
|
103
|
+
|
|
104
|
+
def estimate(self, *, iterations: int = 100, level: str = "middle", budget=2.0) -> dict:
|
|
105
|
+
return self.request(
|
|
106
|
+
"POST",
|
|
107
|
+
"/v1/estimate",
|
|
108
|
+
json={
|
|
109
|
+
"iterations": iterations,
|
|
110
|
+
"level": level,
|
|
111
|
+
"budget": str(budget),
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def balance(self) -> Decimal:
|
|
116
|
+
return Decimal(self.request("GET", "/v1/billing/balance")["available"])
|
|
117
|
+
|
|
118
|
+
def purchase_credits(self, amount=5.0, *, open_browser: bool = True, timeout: float = 900):
|
|
119
|
+
stored = self.credentials.read()
|
|
120
|
+
pending = stored.get("pending_checkout")
|
|
121
|
+
if pending is None:
|
|
122
|
+
pending = self.request("POST", "/v1/auth/checkout", json={"amount": str(amount)})
|
|
123
|
+
pending["device_token"] = "ee_" + secrets.token_urlsafe(32)
|
|
124
|
+
self.credentials.write({**stored, "pending_checkout": pending})
|
|
125
|
+
print(f"Payment required. Complete Stripe Checkout:\n{pending['url']}")
|
|
126
|
+
if open_browser:
|
|
127
|
+
webbrowser.open(pending["url"])
|
|
128
|
+
deadline = time.monotonic() + timeout
|
|
129
|
+
while time.monotonic() < deadline:
|
|
130
|
+
claimed = self.request(
|
|
131
|
+
"POST",
|
|
132
|
+
f"/v1/auth/checkout/{pending['checkout_id']}/claim",
|
|
133
|
+
json={
|
|
134
|
+
"claim_token": pending["claim_token"],
|
|
135
|
+
"device_token": pending["device_token"],
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
if claimed["status"] == "paid":
|
|
139
|
+
self.token = claimed["token"]
|
|
140
|
+
self.credentials.write({"token": self.token})
|
|
141
|
+
return
|
|
142
|
+
time.sleep(max(self.poll_interval, 0.1))
|
|
143
|
+
raise EasyEvolveError(
|
|
144
|
+
"Checkout timed out. Call purchase_credits again to resume this checkout."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def submit(
|
|
148
|
+
self,
|
|
149
|
+
*,
|
|
150
|
+
iterations: int = 100,
|
|
151
|
+
level: str = "middle",
|
|
152
|
+
budget=2.0,
|
|
153
|
+
idempotency_key: str | None = None,
|
|
154
|
+
) -> str:
|
|
155
|
+
if self.evaluator is None:
|
|
156
|
+
raise ValueError("evaluator is required to submit a new job")
|
|
157
|
+
body = {
|
|
158
|
+
"evaluator": self.evaluator.read_text(encoding="utf-8"),
|
|
159
|
+
"task": self.task,
|
|
160
|
+
"initial_code": self.initial_code,
|
|
161
|
+
"iterations": iterations,
|
|
162
|
+
"level": level,
|
|
163
|
+
"budget": str(budget),
|
|
164
|
+
}
|
|
165
|
+
key = idempotency_key or str(uuid.uuid4())
|
|
166
|
+
# POST is safe to retry with the same key if the response was lost.
|
|
167
|
+
for attempt in range(2):
|
|
168
|
+
try:
|
|
169
|
+
job = self.request("POST", "/v1/jobs", json=body, headers={"Idempotency-Key": key})
|
|
170
|
+
self.job_id = job["id"]
|
|
171
|
+
print(f"Job ID: {self.job_id}")
|
|
172
|
+
return self.job_id
|
|
173
|
+
except EasyEvolveError as exc:
|
|
174
|
+
if exc.status_code is not None or attempt:
|
|
175
|
+
raise
|
|
176
|
+
raise AssertionError("Unreachable")
|
|
177
|
+
|
|
178
|
+
def progress(self, job_id: str) -> dict:
|
|
179
|
+
return self.request("GET", f"/v1/jobs/{job_id}")
|
|
180
|
+
|
|
181
|
+
def result(self, job_id: str) -> EvolutionResult:
|
|
182
|
+
return EvolutionResult.model_validate(self.request("GET", f"/v1/jobs/{job_id}/result"))
|
|
183
|
+
|
|
184
|
+
def cancel(self, job_id: str | None = None) -> dict:
|
|
185
|
+
job_id = job_id or self.job_id
|
|
186
|
+
if not job_id:
|
|
187
|
+
raise ValueError("job_id is required")
|
|
188
|
+
return self.request("POST", f"/v1/jobs/{job_id}/cancel")
|
|
189
|
+
|
|
190
|
+
def resume(
|
|
191
|
+
self,
|
|
192
|
+
job_id: str,
|
|
193
|
+
*,
|
|
194
|
+
timeout: float | None = None,
|
|
195
|
+
on_progress: Callable[[dict], None] | None = None,
|
|
196
|
+
) -> EvolutionResult:
|
|
197
|
+
self.job_id = job_id
|
|
198
|
+
start = time.monotonic()
|
|
199
|
+
last = None
|
|
200
|
+
while True:
|
|
201
|
+
job = self.progress(job_id)
|
|
202
|
+
if job != last and on_progress:
|
|
203
|
+
on_progress(job)
|
|
204
|
+
last = job
|
|
205
|
+
if job["status"] in TERMINAL:
|
|
206
|
+
result = self.result(job_id)
|
|
207
|
+
if result.status == "FAILED":
|
|
208
|
+
raise EasyEvolveError(result.error or "Evolution failed", result=result)
|
|
209
|
+
return result
|
|
210
|
+
if timeout is not None and time.monotonic() - start >= timeout:
|
|
211
|
+
raise TimeoutError(f"Job {job_id} is still running; reconnect with resume(job_id)")
|
|
212
|
+
time.sleep(self.poll_interval)
|
|
213
|
+
|
|
214
|
+
def run(
|
|
215
|
+
self,
|
|
216
|
+
*,
|
|
217
|
+
iterations: int = 100,
|
|
218
|
+
level: str = "middle",
|
|
219
|
+
budget=2.0,
|
|
220
|
+
auto_checkout: bool = True,
|
|
221
|
+
timeout: float | None = None,
|
|
222
|
+
on_progress: Callable[[dict], None] | None = None,
|
|
223
|
+
) -> EvolutionResult:
|
|
224
|
+
estimate = self.estimate(iterations=iterations, level=level, budget=budget)
|
|
225
|
+
print(f"Estimated cap: ${estimate['estimated_cost']}\nMaximum budget: ${budget}")
|
|
226
|
+
if auto_checkout and (self.token is None or self.balance() < Decimal(str(budget))):
|
|
227
|
+
amount = max(Decimal("5"), Decimal(str(budget))).quantize(
|
|
228
|
+
Decimal("0.01"), rounding=ROUND_CEILING
|
|
229
|
+
)
|
|
230
|
+
self.purchase_credits(amount)
|
|
231
|
+
job_id = self.submit(iterations=iterations, level=level, budget=budget)
|
|
232
|
+
|
|
233
|
+
def display(job):
|
|
234
|
+
print(
|
|
235
|
+
f"{job['status']} | Iteration {job['completed_iterations']}/{job['iterations']} "
|
|
236
|
+
f"| Best score: {job['best_score']} | Cost: ${job['cost']}"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
return self.resume(job_id, timeout=timeout, on_progress=on_progress or display)
|
|
240
|
+
|
|
241
|
+
def login(self, token: str) -> Decimal:
|
|
242
|
+
"""Verify an invite token and store it for this API origin."""
|
|
243
|
+
self.token = token.strip()
|
|
244
|
+
available = self.balance()
|
|
245
|
+
self.credentials.write({"token": self.token})
|
|
246
|
+
return available
|
|
247
|
+
|
|
248
|
+
def rotate_token(self):
|
|
249
|
+
self.token = self.request("POST", "/v1/auth/token/rotate")["token"]
|
|
250
|
+
self.credentials.write({"token": self.token})
|
|
251
|
+
|
|
252
|
+
def revoke_token(self):
|
|
253
|
+
self.request("POST", "/v1/auth/token/revoke")
|
|
254
|
+
self.token = None
|
|
255
|
+
self.credentials.write({})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Credentials:
|
|
8
|
+
def __init__(self, base_url: str, path: str | Path | None = None):
|
|
9
|
+
self.url = base_url.rstrip("/")
|
|
10
|
+
self.path = (
|
|
11
|
+
Path(path)
|
|
12
|
+
if path
|
|
13
|
+
else Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
|
|
14
|
+
/ "easyevolve"
|
|
15
|
+
/ "credentials.json"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
def read(self) -> dict:
|
|
19
|
+
if not self.path.exists():
|
|
20
|
+
return {}
|
|
21
|
+
return json.loads(self.path.read_text()).get(self.url, {})
|
|
22
|
+
|
|
23
|
+
def write(self, data: dict):
|
|
24
|
+
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
25
|
+
entries = json.loads(self.path.read_text()) if self.path.exists() else {}
|
|
26
|
+
entries[self.url] = data
|
|
27
|
+
fd, temporary = tempfile.mkstemp(dir=self.path.parent)
|
|
28
|
+
try:
|
|
29
|
+
os.fchmod(fd, 0o600)
|
|
30
|
+
with os.fdopen(fd, "w") as output:
|
|
31
|
+
json.dump(entries, output)
|
|
32
|
+
os.replace(temporary, self.path)
|
|
33
|
+
finally:
|
|
34
|
+
if os.path.exists(temporary):
|
|
35
|
+
os.unlink(temporary)
|