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 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
@@ -0,0 +1,6 @@
1
+ """Allow running as python -m hte_cli."""
2
+
3
+ from hte_cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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
+ )