hte-cli 0.1.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.
- hte_cli/__init__.py +24 -0
- hte_cli/__main__.py +6 -0
- hte_cli/api_client.py +228 -0
- hte_cli/cli.py +496 -0
- hte_cli/config.py +109 -0
- hte_cli/errors.py +91 -0
- hte_cli/events.py +128 -0
- hte_cli/runner.py +315 -0
- hte_cli-0.1.0.dist-info/METADATA +82 -0
- hte_cli-0.1.0.dist-info/RECORD +12 -0
- hte_cli-0.1.0.dist-info/WHEEL +4 -0
- hte_cli-0.1.0.dist-info/entry_points.txt +2 -0
hte_cli/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""HTE-CLI: Human Time-to-Completion Evaluation CLI.
|
|
2
|
+
|
|
3
|
+
A standalone CLI for experts to run assigned tasks via Docker + Inspect's human_cli,
|
|
4
|
+
with results synced back to the web API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
# Minimum API version required
|
|
12
|
+
MIN_API_VERSION = "0.1.0"
|
|
13
|
+
|
|
14
|
+
# API URL - configurable via HTE_API_URL env var for custom deployments
|
|
15
|
+
# Default points to production deployment
|
|
16
|
+
_DEFAULT_API_URL = "https://hte.lyptus.dev/api/v1/cli"
|
|
17
|
+
API_BASE_URL = os.environ.get("HTE_API_URL", _DEFAULT_API_URL)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main():
|
|
21
|
+
"""Entry point for hte-cli command."""
|
|
22
|
+
from hte_cli.cli import cli
|
|
23
|
+
|
|
24
|
+
cli()
|
hte_cli/__main__.py
ADDED
hte_cli/api_client.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""HTTP API client for HTE web backend.
|
|
2
|
+
|
|
3
|
+
Handles authentication, retry logic, and API calls.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import logging
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from hte_cli import API_BASE_URL, __version__
|
|
14
|
+
from hte_cli.config import Config
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Timeouts
|
|
19
|
+
DEFAULT_TIMEOUT = 30.0 # seconds
|
|
20
|
+
UPLOAD_TIMEOUT = 300.0 # 5 minutes for large eval log uploads
|
|
21
|
+
|
|
22
|
+
# Retry configuration
|
|
23
|
+
MAX_RETRIES = 3
|
|
24
|
+
RETRY_DELAYS = [1, 5, 30] # seconds
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class APIError(Exception):
|
|
28
|
+
"""API request error."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.status_code = status_code
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class APIClient:
|
|
36
|
+
"""HTTP client for HTE API."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, config: Config):
|
|
39
|
+
self.config = config
|
|
40
|
+
self.base_url = config.api_url or API_BASE_URL
|
|
41
|
+
self._client: httpx.Client | None = None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def client(self) -> httpx.Client:
|
|
45
|
+
"""Get or create HTTP client."""
|
|
46
|
+
if self._client is None:
|
|
47
|
+
self._client = httpx.Client(
|
|
48
|
+
base_url=self.base_url,
|
|
49
|
+
timeout=DEFAULT_TIMEOUT,
|
|
50
|
+
headers={
|
|
51
|
+
"User-Agent": f"hte-cli/{__version__}",
|
|
52
|
+
"X-CLI-Version": __version__,
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
return self._client
|
|
56
|
+
|
|
57
|
+
def _get_auth_headers(self) -> dict[str, str]:
|
|
58
|
+
"""Get authentication headers."""
|
|
59
|
+
if not self.config.api_key:
|
|
60
|
+
raise APIError("Not authenticated. Run: hte-cli auth login")
|
|
61
|
+
return {"Authorization": f"Bearer {self.config.api_key}"}
|
|
62
|
+
|
|
63
|
+
def _handle_response(self, response: httpx.Response) -> Any:
|
|
64
|
+
"""Handle API response, raising appropriate errors."""
|
|
65
|
+
if response.status_code == 401:
|
|
66
|
+
raise APIError("Authentication failed. Run: hte-cli auth login", 401)
|
|
67
|
+
|
|
68
|
+
if response.status_code == 403:
|
|
69
|
+
raise APIError("Access denied", 403)
|
|
70
|
+
|
|
71
|
+
if response.status_code == 404:
|
|
72
|
+
raise APIError("Resource not found", 404)
|
|
73
|
+
|
|
74
|
+
if response.status_code == 413:
|
|
75
|
+
raise APIError("Upload too large (max 200MB)", 413)
|
|
76
|
+
|
|
77
|
+
if response.status_code == 426:
|
|
78
|
+
raise APIError(
|
|
79
|
+
"CLI version too old. Run: pip install --upgrade hte-cli",
|
|
80
|
+
426,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if response.status_code >= 400:
|
|
84
|
+
try:
|
|
85
|
+
detail = response.json().get("detail", response.text)
|
|
86
|
+
except Exception:
|
|
87
|
+
detail = response.text
|
|
88
|
+
raise APIError(f"API error: {detail}", response.status_code)
|
|
89
|
+
|
|
90
|
+
if response.status_code == 204:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
return response.json()
|
|
94
|
+
|
|
95
|
+
def get(self, path: str, **kwargs) -> Any:
|
|
96
|
+
"""Make GET request."""
|
|
97
|
+
headers = self._get_auth_headers()
|
|
98
|
+
headers.update(kwargs.pop("headers", {}))
|
|
99
|
+
|
|
100
|
+
response = self.client.get(path, headers=headers, **kwargs)
|
|
101
|
+
return self._handle_response(response)
|
|
102
|
+
|
|
103
|
+
def post(self, path: str, json: dict | None = None, **kwargs) -> Any:
|
|
104
|
+
"""Make POST request with retry for transient failures."""
|
|
105
|
+
headers = self._get_auth_headers()
|
|
106
|
+
headers.update(kwargs.pop("headers", {}))
|
|
107
|
+
|
|
108
|
+
timeout = kwargs.pop("timeout", DEFAULT_TIMEOUT)
|
|
109
|
+
|
|
110
|
+
last_error = None
|
|
111
|
+
for attempt, delay in enumerate(RETRY_DELAYS):
|
|
112
|
+
try:
|
|
113
|
+
response = self.client.post(
|
|
114
|
+
path,
|
|
115
|
+
json=json,
|
|
116
|
+
headers=headers,
|
|
117
|
+
timeout=timeout,
|
|
118
|
+
**kwargs,
|
|
119
|
+
)
|
|
120
|
+
return self._handle_response(response)
|
|
121
|
+
except httpx.TimeoutException as e:
|
|
122
|
+
last_error = APIError(f"Request timed out: {e}")
|
|
123
|
+
logger.warning(f"Attempt {attempt + 1} failed: timeout. Retrying in {delay}s...")
|
|
124
|
+
except httpx.NetworkError as e:
|
|
125
|
+
last_error = APIError(f"Network error: {e}")
|
|
126
|
+
logger.warning(f"Attempt {attempt + 1} failed: network. Retrying in {delay}s...")
|
|
127
|
+
except APIError as e:
|
|
128
|
+
# Don't retry client errors (4xx)
|
|
129
|
+
if e.status_code and 400 <= e.status_code < 500:
|
|
130
|
+
raise
|
|
131
|
+
last_error = e
|
|
132
|
+
logger.warning(f"Attempt {attempt + 1} failed: {e}. Retrying in {delay}s...")
|
|
133
|
+
|
|
134
|
+
import time
|
|
135
|
+
|
|
136
|
+
time.sleep(delay)
|
|
137
|
+
|
|
138
|
+
raise last_error or APIError("Request failed after retries")
|
|
139
|
+
|
|
140
|
+
def get_raw(self, path: str, **kwargs) -> bytes:
|
|
141
|
+
"""Make GET request returning raw bytes (for file downloads)."""
|
|
142
|
+
headers = self._get_auth_headers()
|
|
143
|
+
headers.update(kwargs.pop("headers", {}))
|
|
144
|
+
|
|
145
|
+
response = self.client.get(path, headers=headers, **kwargs)
|
|
146
|
+
|
|
147
|
+
if response.status_code >= 400:
|
|
148
|
+
self._handle_response(response) # Will raise
|
|
149
|
+
|
|
150
|
+
return response.content
|
|
151
|
+
|
|
152
|
+
def close(self) -> None:
|
|
153
|
+
"""Close the HTTP client."""
|
|
154
|
+
if self._client:
|
|
155
|
+
self._client.close()
|
|
156
|
+
self._client = None
|
|
157
|
+
|
|
158
|
+
# =========================================================================
|
|
159
|
+
# API Methods
|
|
160
|
+
# =========================================================================
|
|
161
|
+
|
|
162
|
+
def exchange_code_for_token(self, code: str) -> dict:
|
|
163
|
+
"""Exchange one-time code for API key."""
|
|
164
|
+
# This endpoint doesn't require auth
|
|
165
|
+
response = self.client.post(
|
|
166
|
+
"/token",
|
|
167
|
+
json={"code": code},
|
|
168
|
+
headers={"X-CLI-Version": __version__},
|
|
169
|
+
)
|
|
170
|
+
return self._handle_response(response)
|
|
171
|
+
|
|
172
|
+
def get_assignments(self) -> list[dict]:
|
|
173
|
+
"""Get pending assignments for current user."""
|
|
174
|
+
return self.get("/assignments")
|
|
175
|
+
|
|
176
|
+
def get_assignment_files(self, assignment_id: str) -> bytes:
|
|
177
|
+
"""Download task files as zip."""
|
|
178
|
+
return self.get_raw(f"/assignments/{assignment_id}/files")
|
|
179
|
+
|
|
180
|
+
def get_assignment_compose(self, assignment_id: str) -> str:
|
|
181
|
+
"""Get compose.yaml content."""
|
|
182
|
+
content = self.get_raw(f"/assignments/{assignment_id}/compose")
|
|
183
|
+
return content.decode("utf-8")
|
|
184
|
+
|
|
185
|
+
def start_session(self, assignment_id: str) -> dict:
|
|
186
|
+
"""Start a session for an assignment.
|
|
187
|
+
|
|
188
|
+
Returns session info including session_id.
|
|
189
|
+
If session already exists, returns that session.
|
|
190
|
+
"""
|
|
191
|
+
return self.post(f"/assignments/{assignment_id}/start")
|
|
192
|
+
|
|
193
|
+
def post_event(self, session_id: str, event_type: str, event_data: dict | None = None) -> dict:
|
|
194
|
+
"""Post a session event."""
|
|
195
|
+
return self.post(
|
|
196
|
+
f"/sessions/{session_id}/events",
|
|
197
|
+
json={
|
|
198
|
+
"event_type": event_type,
|
|
199
|
+
"event_data": event_data,
|
|
200
|
+
"client_timestamp": datetime.utcnow().isoformat() + "Z",
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def upload_result(
|
|
205
|
+
self,
|
|
206
|
+
session_id: str,
|
|
207
|
+
answer: str,
|
|
208
|
+
client_active_seconds: float,
|
|
209
|
+
eval_log_bytes: bytes | None = None,
|
|
210
|
+
score: float | None = None,
|
|
211
|
+
score_binarized: int | None = None,
|
|
212
|
+
) -> dict:
|
|
213
|
+
"""Upload task result with optional eval log."""
|
|
214
|
+
payload = {
|
|
215
|
+
"answer": answer,
|
|
216
|
+
"client_active_seconds": client_active_seconds,
|
|
217
|
+
"score": score,
|
|
218
|
+
"score_binarized": score_binarized,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if eval_log_bytes:
|
|
222
|
+
payload["eval_log_base64"] = base64.b64encode(eval_log_bytes).decode("ascii")
|
|
223
|
+
|
|
224
|
+
return self.post(
|
|
225
|
+
f"/sessions/{session_id}/result",
|
|
226
|
+
json=payload,
|
|
227
|
+
timeout=UPLOAD_TIMEOUT,
|
|
228
|
+
)
|