weco 0.2.19__py3-none-any.whl → 0.2.22__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.
- weco/__init__.py +2 -8
- weco/api.py +245 -48
- weco/auth.py +164 -3
- weco/chatbot.py +797 -0
- weco/cli.py +129 -685
- weco/optimizer.py +479 -0
- weco/panels.py +59 -10
- weco/utils.py +31 -3
- {weco-0.2.19.dist-info → weco-0.2.22.dist-info}/METADATA +110 -32
- weco-0.2.22.dist-info/RECORD +14 -0
- {weco-0.2.19.dist-info → weco-0.2.22.dist-info}/WHEEL +1 -1
- weco-0.2.19.dist-info/RECORD +0 -12
- {weco-0.2.19.dist-info → weco-0.2.22.dist-info}/entry_points.txt +0 -0
- {weco-0.2.19.dist-info → weco-0.2.22.dist-info}/licenses/LICENSE +0 -0
- {weco-0.2.19.dist-info → weco-0.2.22.dist-info}/top_level.txt +0 -0
weco/__init__.py
CHANGED
|
@@ -5,11 +5,5 @@ import importlib.metadata
|
|
|
5
5
|
__pkg_version__ = importlib.metadata.version("weco")
|
|
6
6
|
__api_version__ = "v1"
|
|
7
7
|
|
|
8
|
-
__base_url__ = f"https://api.weco.ai/{__api_version__}"
|
|
9
|
-
|
|
10
|
-
if os.environ.get("WECO_BASE_URL"):
|
|
11
|
-
__base_url__ = os.environ.get("WECO_BASE_URL")
|
|
12
|
-
|
|
13
|
-
__dashboard_url__ = "https://dashboard.weco.ai"
|
|
14
|
-
if os.environ.get("WECO_DASHBOARD_URL"):
|
|
15
|
-
__dashboard_url__ = os.environ.get("WECO_DASHBOARD_URL")
|
|
8
|
+
__base_url__ = os.environ.get("WECO_BASE_URL", f"https://api.weco.ai/{__api_version__}")
|
|
9
|
+
__dashboard_url__ = os.environ.get("WECO_DASHBOARD_URL", "https://dashboard.weco.ai")
|
weco/api.py
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
|
-
from typing import Dict, Any, Optional
|
|
2
|
-
import rich
|
|
3
|
-
import requests
|
|
4
|
-
from weco import __pkg_version__, __base_url__
|
|
5
1
|
import sys
|
|
2
|
+
from typing import Dict, Any, Optional, Union, Tuple, List
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
from requests.adapters import HTTPAdapter
|
|
6
|
+
from urllib3.util.retry import Retry
|
|
6
7
|
from rich.console import Console
|
|
7
8
|
|
|
9
|
+
from weco import __pkg_version__, __base_url__
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# --- Session Configuration ---
|
|
13
|
+
def _get_weco_session() -> requests.Session:
|
|
14
|
+
session = requests.Session()
|
|
15
|
+
retry_strategy = Retry(
|
|
16
|
+
total=3,
|
|
17
|
+
status_forcelist=[429, 500, 502, 503, 504], # Retry on these server errors and rate limiting
|
|
18
|
+
allowed_methods=["HEAD", "GET", "PUT", "POST", "DELETE", "OPTIONS"], # Case-insensitive
|
|
19
|
+
backoff_factor=1, # e.g., sleep for 0s, 2s, 4s between retries (factor * (2 ** ({number of total retries} - 1)))
|
|
20
|
+
)
|
|
21
|
+
adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
22
|
+
session.mount("http://", adapter)
|
|
23
|
+
session.mount("https://", adapter)
|
|
24
|
+
return session
|
|
25
|
+
|
|
8
26
|
|
|
9
|
-
def handle_api_error(e: requests.exceptions.HTTPError, console:
|
|
27
|
+
def handle_api_error(e: requests.exceptions.HTTPError, console: Console) -> None:
|
|
10
28
|
"""Extract and display error messages from API responses in a structured format."""
|
|
11
29
|
try:
|
|
12
30
|
detail = e.response.json()["detail"]
|
|
@@ -17,8 +35,8 @@ def handle_api_error(e: requests.exceptions.HTTPError, console: rich.console.Con
|
|
|
17
35
|
# sys.exit(1)
|
|
18
36
|
|
|
19
37
|
|
|
20
|
-
def
|
|
21
|
-
console:
|
|
38
|
+
def start_optimization_run(
|
|
39
|
+
console: Console,
|
|
22
40
|
source_code: str,
|
|
23
41
|
evaluation_command: str,
|
|
24
42
|
metric_name: str,
|
|
@@ -29,14 +47,15 @@ def start_optimization_session(
|
|
|
29
47
|
search_policy_config: Dict[str, Any],
|
|
30
48
|
additional_instructions: str = None,
|
|
31
49
|
api_keys: Dict[str, Any] = {},
|
|
32
|
-
auth_headers: dict = {},
|
|
33
|
-
timeout: int = 800,
|
|
50
|
+
auth_headers: dict = {},
|
|
51
|
+
timeout: Union[int, Tuple[int, int]] = 800,
|
|
34
52
|
) -> Dict[str, Any]:
|
|
35
|
-
"""Start the optimization
|
|
53
|
+
"""Start the optimization run."""
|
|
36
54
|
with console.status("[bold green]Starting Optimization..."):
|
|
37
55
|
try:
|
|
38
|
-
|
|
39
|
-
|
|
56
|
+
session = _get_weco_session()
|
|
57
|
+
response = session.post(
|
|
58
|
+
f"{__base_url__}/runs",
|
|
40
59
|
json={
|
|
41
60
|
"source_code": source_code,
|
|
42
61
|
"additional_instructions": additional_instructions,
|
|
@@ -49,37 +68,38 @@ def start_optimization_session(
|
|
|
49
68
|
},
|
|
50
69
|
"metadata": {"client_name": "cli", "client_version": __pkg_version__, **api_keys},
|
|
51
70
|
},
|
|
52
|
-
headers=auth_headers,
|
|
71
|
+
headers=auth_headers,
|
|
53
72
|
timeout=timeout,
|
|
54
73
|
)
|
|
55
74
|
response.raise_for_status()
|
|
56
75
|
return response.json()
|
|
57
76
|
except requests.exceptions.HTTPError as e:
|
|
58
77
|
handle_api_error(e, console)
|
|
59
|
-
sys.exit(1)
|
|
78
|
+
sys.exit(1)
|
|
60
79
|
except requests.exceptions.RequestException as e:
|
|
61
|
-
console.print(f"[bold red]Network Error starting
|
|
80
|
+
console.print(f"[bold red]Network Error starting run: {e}[/]")
|
|
62
81
|
sys.exit(1)
|
|
63
82
|
|
|
64
83
|
|
|
65
84
|
def evaluate_feedback_then_suggest_next_solution(
|
|
66
|
-
|
|
85
|
+
run_id: str,
|
|
67
86
|
execution_output: str,
|
|
68
87
|
additional_instructions: str = None,
|
|
69
88
|
api_keys: Dict[str, Any] = {},
|
|
70
|
-
auth_headers: dict = {},
|
|
71
|
-
timeout: int = 800,
|
|
89
|
+
auth_headers: dict = {},
|
|
90
|
+
timeout: Union[int, Tuple[int, int]] = 800,
|
|
72
91
|
) -> Dict[str, Any]:
|
|
73
92
|
"""Evaluate the feedback and suggest the next solution."""
|
|
74
93
|
try:
|
|
75
|
-
|
|
76
|
-
|
|
94
|
+
session = _get_weco_session()
|
|
95
|
+
response = session.post(
|
|
96
|
+
f"{__base_url__}/runs/{run_id}/suggest",
|
|
77
97
|
json={
|
|
78
98
|
"execution_output": execution_output,
|
|
79
99
|
"additional_instructions": additional_instructions,
|
|
80
100
|
"metadata": {**api_keys},
|
|
81
101
|
},
|
|
82
|
-
headers=auth_headers,
|
|
102
|
+
headers=auth_headers,
|
|
83
103
|
timeout=timeout,
|
|
84
104
|
)
|
|
85
105
|
response.raise_for_status()
|
|
@@ -93,16 +113,14 @@ def evaluate_feedback_then_suggest_next_solution(
|
|
|
93
113
|
raise # Re-raise the exception
|
|
94
114
|
|
|
95
115
|
|
|
96
|
-
def
|
|
97
|
-
|
|
116
|
+
def get_optimization_run_status(
|
|
117
|
+
run_id: str, include_history: bool = False, auth_headers: dict = {}, timeout: Union[int, Tuple[int, int]] = 800
|
|
98
118
|
) -> Dict[str, Any]:
|
|
99
|
-
"""Get the current status of the optimization
|
|
119
|
+
"""Get the current status of the optimization run."""
|
|
100
120
|
try:
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
params={"include_history": include_history},
|
|
104
|
-
headers=auth_headers,
|
|
105
|
-
timeout=timeout,
|
|
121
|
+
session = _get_weco_session()
|
|
122
|
+
response = session.get(
|
|
123
|
+
f"{__base_url__}/runs/{run_id}", params={"include_history": include_history}, headers=auth_headers, timeout=timeout
|
|
106
124
|
)
|
|
107
125
|
response.raise_for_status()
|
|
108
126
|
return response.json()
|
|
@@ -114,42 +132,37 @@ def get_optimization_session_status(
|
|
|
114
132
|
raise # Re-raise
|
|
115
133
|
|
|
116
134
|
|
|
117
|
-
def send_heartbeat(
|
|
118
|
-
session_id: str,
|
|
119
|
-
auth_headers: dict = {},
|
|
120
|
-
timeout: int = 10, # Shorter timeout for non-critical heartbeat
|
|
121
|
-
) -> bool:
|
|
135
|
+
def send_heartbeat(run_id: str, auth_headers: dict = {}, timeout: Union[int, Tuple[int, int]] = 10) -> bool:
|
|
122
136
|
"""Send a heartbeat signal to the backend."""
|
|
123
137
|
try:
|
|
124
|
-
|
|
125
|
-
response
|
|
138
|
+
session = _get_weco_session()
|
|
139
|
+
response = session.put(f"{__base_url__}/runs/{run_id}/heartbeat", headers=auth_headers, timeout=timeout)
|
|
140
|
+
response.raise_for_status()
|
|
126
141
|
return True
|
|
127
142
|
except requests.exceptions.HTTPError as e:
|
|
128
|
-
# Log non-critical errors like 409 Conflict (session not running)
|
|
129
143
|
if e.response.status_code == 409:
|
|
130
|
-
print(f"Heartbeat ignored:
|
|
144
|
+
print(f"Heartbeat ignored: Run {run_id} is not running.", file=sys.stderr)
|
|
131
145
|
else:
|
|
132
|
-
print(f"Heartbeat failed for
|
|
133
|
-
# Don't exit, just report failure
|
|
146
|
+
print(f"Heartbeat failed for run {run_id}: HTTP {e.response.status_code}", file=sys.stderr)
|
|
134
147
|
return False
|
|
135
148
|
except requests.exceptions.RequestException as e:
|
|
136
|
-
|
|
137
|
-
print(f"Heartbeat network error for session {session_id}: {e}", file=sys.stderr)
|
|
149
|
+
print(f"Heartbeat network error for run {run_id}: {e}", file=sys.stderr)
|
|
138
150
|
return False
|
|
139
151
|
|
|
140
152
|
|
|
141
153
|
def report_termination(
|
|
142
|
-
|
|
154
|
+
run_id: str,
|
|
143
155
|
status_update: str,
|
|
144
156
|
reason: str,
|
|
145
157
|
details: Optional[str] = None,
|
|
146
158
|
auth_headers: dict = {},
|
|
147
|
-
timeout: int = 30,
|
|
159
|
+
timeout: Union[int, Tuple[int, int]] = 30,
|
|
148
160
|
) -> bool:
|
|
149
161
|
"""Report the termination reason to the backend."""
|
|
150
162
|
try:
|
|
151
|
-
|
|
152
|
-
|
|
163
|
+
session = _get_weco_session()
|
|
164
|
+
response = session.post(
|
|
165
|
+
f"{__base_url__}/runs/{run_id}/terminate",
|
|
153
166
|
json={"status_update": status_update, "termination_reason": reason, "termination_details": details},
|
|
154
167
|
headers=auth_headers,
|
|
155
168
|
timeout=timeout,
|
|
@@ -157,6 +170,190 @@ def report_termination(
|
|
|
157
170
|
response.raise_for_status()
|
|
158
171
|
return True
|
|
159
172
|
except requests.exceptions.RequestException as e:
|
|
160
|
-
|
|
161
|
-
print(f"Warning: Failed to report termination to backend for session {session_id}: {e}", file=sys.stderr)
|
|
173
|
+
print(f"Warning: Failed to report termination to backend for run {run_id}: {e}", file=sys.stderr)
|
|
162
174
|
return False
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# --- Chatbot API Functions ---
|
|
178
|
+
def _determine_model_and_api_key() -> tuple[str, dict[str, str]]:
|
|
179
|
+
"""Determine the model and API key to use based on available environment variables.
|
|
180
|
+
|
|
181
|
+
Uses the shared model selection logic to maintain consistency.
|
|
182
|
+
Returns (model_name, api_key_dict)
|
|
183
|
+
"""
|
|
184
|
+
from .utils import read_api_keys_from_env, determine_default_model
|
|
185
|
+
|
|
186
|
+
llm_api_keys = read_api_keys_from_env()
|
|
187
|
+
model = determine_default_model(llm_api_keys)
|
|
188
|
+
|
|
189
|
+
# Create API key dictionary with only the key for the selected model
|
|
190
|
+
if model == "o4-mini":
|
|
191
|
+
api_key_dict = {"OPENAI_API_KEY": llm_api_keys["OPENAI_API_KEY"]}
|
|
192
|
+
elif model == "claude-sonnet-4-0":
|
|
193
|
+
api_key_dict = {"ANTHROPIC_API_KEY": llm_api_keys["ANTHROPIC_API_KEY"]}
|
|
194
|
+
elif model == "gemini-2.5-pro":
|
|
195
|
+
api_key_dict = {"GEMINI_API_KEY": llm_api_keys["GEMINI_API_KEY"]}
|
|
196
|
+
else:
|
|
197
|
+
# This should never happen if determine_default_model works correctly
|
|
198
|
+
raise ValueError(f"Unknown model returned: {model}")
|
|
199
|
+
|
|
200
|
+
return model, api_key_dict
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def get_optimization_suggestions_from_codebase(
|
|
204
|
+
gitingest_summary: str,
|
|
205
|
+
gitingest_tree: str,
|
|
206
|
+
gitingest_content_str: str,
|
|
207
|
+
console: Console,
|
|
208
|
+
auth_headers: dict = {},
|
|
209
|
+
timeout: Union[int, Tuple[int, int]] = 800,
|
|
210
|
+
) -> Optional[List[Dict[str, Any]]]:
|
|
211
|
+
"""Analyze codebase and get optimization suggestions using the model-agnostic backend API."""
|
|
212
|
+
try:
|
|
213
|
+
model, api_key_dict = _determine_model_and_api_key()
|
|
214
|
+
session = _get_weco_session()
|
|
215
|
+
response = session.post(
|
|
216
|
+
f"{__base_url__}/onboard/analyze-codebase",
|
|
217
|
+
json={
|
|
218
|
+
"gitingest_summary": gitingest_summary,
|
|
219
|
+
"gitingest_tree": gitingest_tree,
|
|
220
|
+
"gitingest_content": gitingest_content_str,
|
|
221
|
+
"model": model,
|
|
222
|
+
"metadata": api_key_dict,
|
|
223
|
+
},
|
|
224
|
+
headers=auth_headers,
|
|
225
|
+
timeout=timeout,
|
|
226
|
+
)
|
|
227
|
+
response.raise_for_status()
|
|
228
|
+
result = response.json()
|
|
229
|
+
return [option for option in result.get("options", [])]
|
|
230
|
+
|
|
231
|
+
except requests.exceptions.HTTPError as e:
|
|
232
|
+
handle_api_error(e, console)
|
|
233
|
+
return None
|
|
234
|
+
except requests.exceptions.RequestException as e:
|
|
235
|
+
console.print(f"[bold red]Network Error getting optimization suggestions: {e}[/]")
|
|
236
|
+
return None
|
|
237
|
+
except Exception as e:
|
|
238
|
+
console.print(f"[bold red]Error calling backend API: {e}[/]")
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def generate_evaluation_script_and_metrics(
|
|
243
|
+
target_file: str,
|
|
244
|
+
description: str,
|
|
245
|
+
gitingest_content_str: str,
|
|
246
|
+
console: Console,
|
|
247
|
+
auth_headers: dict = {},
|
|
248
|
+
timeout: Union[int, Tuple[int, int]] = 800,
|
|
249
|
+
) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
|
|
250
|
+
"""Generate evaluation script and determine metrics using the model-agnostic backend API."""
|
|
251
|
+
try:
|
|
252
|
+
model, api_key_dict = _determine_model_and_api_key()
|
|
253
|
+
session = _get_weco_session()
|
|
254
|
+
response = session.post(
|
|
255
|
+
f"{__base_url__}/onboard/generate-script",
|
|
256
|
+
json={
|
|
257
|
+
"target_file": target_file,
|
|
258
|
+
"description": description,
|
|
259
|
+
"gitingest_content": gitingest_content_str,
|
|
260
|
+
"model": model,
|
|
261
|
+
"metadata": api_key_dict,
|
|
262
|
+
},
|
|
263
|
+
headers=auth_headers,
|
|
264
|
+
timeout=timeout,
|
|
265
|
+
)
|
|
266
|
+
response.raise_for_status()
|
|
267
|
+
result = response.json()
|
|
268
|
+
return result.get("script_content"), result.get("metric_name"), result.get("goal"), result.get("reasoning")
|
|
269
|
+
|
|
270
|
+
except requests.exceptions.HTTPError as e:
|
|
271
|
+
handle_api_error(e, console)
|
|
272
|
+
return None, None, None, None
|
|
273
|
+
except requests.exceptions.RequestException as e:
|
|
274
|
+
console.print(f"[bold red]Network Error generating evaluation script: {e}[/]")
|
|
275
|
+
return None, None, None, None
|
|
276
|
+
except Exception as e:
|
|
277
|
+
console.print(f"[bold red]Error calling backend API: {e}[/]")
|
|
278
|
+
return None, None, None, None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def analyze_evaluation_environment(
|
|
282
|
+
target_file: str,
|
|
283
|
+
description: str,
|
|
284
|
+
gitingest_summary: str,
|
|
285
|
+
gitingest_tree: str,
|
|
286
|
+
gitingest_content_str: str,
|
|
287
|
+
console: Console,
|
|
288
|
+
auth_headers: dict = {},
|
|
289
|
+
timeout: Union[int, Tuple[int, int]] = 800,
|
|
290
|
+
) -> Optional[Dict[str, Any]]:
|
|
291
|
+
"""Analyze existing evaluation scripts and environment using the model-agnostic backend API."""
|
|
292
|
+
try:
|
|
293
|
+
model, api_key_dict = _determine_model_and_api_key()
|
|
294
|
+
session = _get_weco_session()
|
|
295
|
+
response = session.post(
|
|
296
|
+
f"{__base_url__}/onboard/analyze-environment",
|
|
297
|
+
json={
|
|
298
|
+
"target_file": target_file,
|
|
299
|
+
"description": description,
|
|
300
|
+
"gitingest_summary": gitingest_summary,
|
|
301
|
+
"gitingest_tree": gitingest_tree,
|
|
302
|
+
"gitingest_content": gitingest_content_str,
|
|
303
|
+
"model": model,
|
|
304
|
+
"metadata": api_key_dict,
|
|
305
|
+
},
|
|
306
|
+
headers=auth_headers,
|
|
307
|
+
timeout=timeout,
|
|
308
|
+
)
|
|
309
|
+
response.raise_for_status()
|
|
310
|
+
return response.json()
|
|
311
|
+
|
|
312
|
+
except requests.exceptions.HTTPError as e:
|
|
313
|
+
handle_api_error(e, console)
|
|
314
|
+
return None
|
|
315
|
+
except requests.exceptions.RequestException as e:
|
|
316
|
+
console.print(f"[bold red]Network Error analyzing evaluation environment: {e}[/]")
|
|
317
|
+
return None
|
|
318
|
+
except Exception as e:
|
|
319
|
+
console.print(f"[bold red]Error calling backend API: {e}[/]")
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def analyze_script_execution_requirements(
|
|
324
|
+
script_content: str,
|
|
325
|
+
script_path: str,
|
|
326
|
+
target_file: str,
|
|
327
|
+
console: Console,
|
|
328
|
+
auth_headers: dict = {},
|
|
329
|
+
timeout: Union[int, Tuple[int, int]] = 800,
|
|
330
|
+
) -> Optional[str]:
|
|
331
|
+
"""Analyze script to determine proper execution command using the model-agnostic backend API."""
|
|
332
|
+
try:
|
|
333
|
+
model, api_key_dict = _determine_model_and_api_key()
|
|
334
|
+
session = _get_weco_session()
|
|
335
|
+
response = session.post(
|
|
336
|
+
f"{__base_url__}/onboard/analyze-script",
|
|
337
|
+
json={
|
|
338
|
+
"script_content": script_content,
|
|
339
|
+
"script_path": script_path,
|
|
340
|
+
"target_file": target_file,
|
|
341
|
+
"model": model,
|
|
342
|
+
"metadata": api_key_dict,
|
|
343
|
+
},
|
|
344
|
+
headers=auth_headers,
|
|
345
|
+
timeout=timeout,
|
|
346
|
+
)
|
|
347
|
+
response.raise_for_status()
|
|
348
|
+
result = response.json()
|
|
349
|
+
return result.get("command", f"python {script_path}")
|
|
350
|
+
|
|
351
|
+
except requests.exceptions.HTTPError as e:
|
|
352
|
+
handle_api_error(e, console)
|
|
353
|
+
return f"python {script_path}"
|
|
354
|
+
except requests.exceptions.RequestException as e:
|
|
355
|
+
console.print(f"[bold red]Network Error analyzing script execution: {e}[/]")
|
|
356
|
+
return f"python {script_path}"
|
|
357
|
+
except Exception as e:
|
|
358
|
+
console.print(f"[bold red]Error calling backend API: {e}[/]")
|
|
359
|
+
return f"python {script_path}"
|
weco/auth.py
CHANGED
|
@@ -3,6 +3,13 @@ import os
|
|
|
3
3
|
import pathlib
|
|
4
4
|
import json
|
|
5
5
|
import stat
|
|
6
|
+
import time
|
|
7
|
+
import requests
|
|
8
|
+
import webbrowser
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.live import Live
|
|
11
|
+
from rich.prompt import Prompt
|
|
12
|
+
from . import __base_url__
|
|
6
13
|
|
|
7
14
|
CONFIG_DIR = pathlib.Path.home() / ".config" / "weco"
|
|
8
15
|
CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
|
|
@@ -27,10 +34,8 @@ def save_api_key(api_key: str):
|
|
|
27
34
|
json.dump(credentials, f)
|
|
28
35
|
# Set file permissions to read/write for owner only (600)
|
|
29
36
|
os.chmod(CREDENTIALS_FILE, stat.S_IRUSR | stat.S_IWUSR)
|
|
30
|
-
except IOError as e:
|
|
31
|
-
print(f"Error: Could not write credentials file at {CREDENTIALS_FILE}: {e}")
|
|
32
37
|
except OSError as e:
|
|
33
|
-
print(f"
|
|
38
|
+
print(f"Error: Could not write credentials file or set permissions on {CREDENTIALS_FILE}: {e}")
|
|
34
39
|
|
|
35
40
|
|
|
36
41
|
def load_weco_api_key() -> str | None:
|
|
@@ -62,3 +67,159 @@ def clear_api_key():
|
|
|
62
67
|
print(f"Error: Could not remove credentials file at {CREDENTIALS_FILE}: {e}")
|
|
63
68
|
else:
|
|
64
69
|
print("Already logged out.")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def perform_login(console: Console):
|
|
73
|
+
"""Handles the device login flow."""
|
|
74
|
+
try:
|
|
75
|
+
# 1. Initiate device login
|
|
76
|
+
console.print("Initiating login...")
|
|
77
|
+
init_response = requests.post(f"{__base_url__}/auth/device/initiate")
|
|
78
|
+
init_response.raise_for_status()
|
|
79
|
+
init_data = init_response.json()
|
|
80
|
+
|
|
81
|
+
device_code = init_data["device_code"]
|
|
82
|
+
verification_uri = init_data["verification_uri"]
|
|
83
|
+
expires_in = init_data["expires_in"]
|
|
84
|
+
interval = init_data["interval"]
|
|
85
|
+
|
|
86
|
+
# 2. Display instructions
|
|
87
|
+
console.print("\n[bold yellow]Action Required:[/]")
|
|
88
|
+
console.print("Please open the following URL in your browser to authenticate:")
|
|
89
|
+
console.print(f"[link={verification_uri}]{verification_uri}[/link]")
|
|
90
|
+
console.print(f"This request will expire in {expires_in // 60} minutes.")
|
|
91
|
+
console.print("Attempting to open the authentication page in your default browser...") # Notify user
|
|
92
|
+
|
|
93
|
+
# Automatically open the browser
|
|
94
|
+
try:
|
|
95
|
+
if not webbrowser.open(verification_uri):
|
|
96
|
+
console.print("[yellow]Could not automatically open the browser. Please open the link manually.[/]")
|
|
97
|
+
except Exception as browser_err:
|
|
98
|
+
console.print(
|
|
99
|
+
f"[yellow]Could not automatically open the browser ({browser_err}). Please open the link manually.[/]"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
console.print("Waiting for authentication...", end="")
|
|
103
|
+
|
|
104
|
+
# 3. Poll for token
|
|
105
|
+
start_time = time.time()
|
|
106
|
+
# Use a simple text update instead of Spinner within Live for potentially better compatibility
|
|
107
|
+
polling_status = "Waiting..."
|
|
108
|
+
with Live(polling_status, refresh_per_second=1, transient=True, console=console) as live_status:
|
|
109
|
+
while True:
|
|
110
|
+
# Check for timeout
|
|
111
|
+
if time.time() - start_time > expires_in:
|
|
112
|
+
console.print("\n[bold red]Error:[/] Login request timed out.")
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
time.sleep(interval)
|
|
116
|
+
live_status.update("Waiting... (checking status)")
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
token_response = requests.post(
|
|
120
|
+
f"{__base_url__}/auth/device/token",
|
|
121
|
+
json={"grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": device_code},
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Check for 202 Accepted - Authorization Pending
|
|
125
|
+
if token_response.status_code == 202:
|
|
126
|
+
token_data = token_response.json()
|
|
127
|
+
if token_data.get("error") == "authorization_pending":
|
|
128
|
+
live_status.update("Waiting... (authorization pending)")
|
|
129
|
+
continue # Continue polling
|
|
130
|
+
else:
|
|
131
|
+
# Unexpected 202 response format
|
|
132
|
+
console.print(f"\n[bold red]Error:[/] Received unexpected 202 response: {token_data}")
|
|
133
|
+
return False
|
|
134
|
+
# Check for standard OAuth2 errors (often 400 Bad Request)
|
|
135
|
+
elif token_response.status_code == 400:
|
|
136
|
+
token_data = token_response.json()
|
|
137
|
+
error_code = token_data.get("error", "unknown_error")
|
|
138
|
+
if error_code == "slow_down":
|
|
139
|
+
interval += 5 # Increase polling interval if instructed
|
|
140
|
+
live_status.update(f"Waiting... (slowing down polling to {interval}s)")
|
|
141
|
+
continue
|
|
142
|
+
elif error_code == "expired_token":
|
|
143
|
+
console.print("\n[bold red]Error:[/] Login request expired.")
|
|
144
|
+
return False
|
|
145
|
+
elif error_code == "access_denied":
|
|
146
|
+
console.print("\n[bold red]Error:[/] Authorization denied by user.")
|
|
147
|
+
return False
|
|
148
|
+
else: # invalid_grant, etc.
|
|
149
|
+
error_desc = token_data.get("error_description", "Unknown error during polling.")
|
|
150
|
+
console.print(f"\n[bold red]Error:[/] {error_desc} ({error_code})")
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
# Check for other non-200/non-202/non-400 HTTP errors
|
|
154
|
+
token_response.raise_for_status()
|
|
155
|
+
# If successful (200 OK and no 'error' field)
|
|
156
|
+
token_data = token_response.json()
|
|
157
|
+
if "access_token" in token_data:
|
|
158
|
+
api_key = token_data["access_token"]
|
|
159
|
+
save_api_key(api_key)
|
|
160
|
+
console.print("\n[bold green]Login successful![/]")
|
|
161
|
+
return True
|
|
162
|
+
else:
|
|
163
|
+
# Unexpected successful response format
|
|
164
|
+
console.print("\n[bold red]Error:[/] Received unexpected response from server during polling.")
|
|
165
|
+
print(token_data)
|
|
166
|
+
return False
|
|
167
|
+
except requests.exceptions.RequestException as e:
|
|
168
|
+
# Handle network errors during polling gracefully
|
|
169
|
+
live_status.update("Waiting... (network error, retrying)")
|
|
170
|
+
console.print(f"\n[bold yellow]Warning:[/] Network error during polling: {e}. Retrying...")
|
|
171
|
+
time.sleep(interval * 2) # Simple backoff
|
|
172
|
+
except requests.exceptions.HTTPError as e:
|
|
173
|
+
from .api import handle_api_error # Import here to avoid circular imports
|
|
174
|
+
|
|
175
|
+
handle_api_error(e, console)
|
|
176
|
+
except requests.exceptions.RequestException as e:
|
|
177
|
+
# Catch other request errors
|
|
178
|
+
console.print(f"\n[bold red]Network Error:[/] {e}")
|
|
179
|
+
return False
|
|
180
|
+
except Exception as e:
|
|
181
|
+
console.print(f"\n[bold red]An unexpected error occurred during login:[/] {e}")
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def handle_authentication(console: Console, llm_api_keys: dict) -> tuple[str | None, dict]:
|
|
186
|
+
"""
|
|
187
|
+
Handle the complete authentication flow.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
tuple: (weco_api_key, auth_headers)
|
|
191
|
+
"""
|
|
192
|
+
weco_api_key = load_weco_api_key()
|
|
193
|
+
|
|
194
|
+
if not weco_api_key:
|
|
195
|
+
login_choice = Prompt.ask(
|
|
196
|
+
"Log in to Weco to save run history or use anonymously? ([bold]L[/]ogin / [bold]S[/]kip)",
|
|
197
|
+
choices=["l", "s"],
|
|
198
|
+
default="s",
|
|
199
|
+
).lower()
|
|
200
|
+
|
|
201
|
+
if login_choice == "l":
|
|
202
|
+
console.print("[cyan]Starting login process...[/]")
|
|
203
|
+
if not perform_login(console):
|
|
204
|
+
console.print("[bold red]Login process failed or was cancelled.[/]")
|
|
205
|
+
return None, {}
|
|
206
|
+
|
|
207
|
+
weco_api_key = load_weco_api_key()
|
|
208
|
+
if not weco_api_key:
|
|
209
|
+
console.print("[bold red]Error: Login completed but failed to retrieve API key.[/]")
|
|
210
|
+
return None, {}
|
|
211
|
+
|
|
212
|
+
elif login_choice == "s":
|
|
213
|
+
console.print("[yellow]Proceeding anonymously. LLM API keys must be provided via environment variables.[/]")
|
|
214
|
+
if not llm_api_keys:
|
|
215
|
+
console.print(
|
|
216
|
+
"[bold red]Error:[/] No LLM API keys found in environment (e.g., OPENAI_API_KEY). Cannot proceed anonymously."
|
|
217
|
+
)
|
|
218
|
+
return None, {}
|
|
219
|
+
|
|
220
|
+
# Build auth headers
|
|
221
|
+
auth_headers = {}
|
|
222
|
+
if weco_api_key:
|
|
223
|
+
auth_headers["Authorization"] = f"Bearer {weco_api_key}"
|
|
224
|
+
|
|
225
|
+
return weco_api_key, auth_headers
|