weco 0.2.20__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 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
- # If user specifies a custom base URL, use that instead
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__
8
10
 
9
- def handle_api_error(e: requests.exceptions.HTTPError, console: rich.console.Console) -> None:
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
+
26
+
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"]
@@ -18,7 +36,7 @@ def handle_api_error(e: requests.exceptions.HTTPError, console: rich.console.Con
18
36
 
19
37
 
20
38
  def start_optimization_run(
21
- console: rich.console.Console,
39
+ console: Console,
22
40
  source_code: str,
23
41
  evaluation_command: str,
24
42
  metric_name: str,
@@ -30,12 +48,13 @@ def start_optimization_run(
30
48
  additional_instructions: str = None,
31
49
  api_keys: Dict[str, Any] = {},
32
50
  auth_headers: dict = {},
33
- timeout: int = 800,
51
+ timeout: Union[int, Tuple[int, int]] = 800,
34
52
  ) -> Dict[str, Any]:
35
53
  """Start the optimization run."""
36
54
  with console.status("[bold green]Starting Optimization..."):
37
55
  try:
38
- response = requests.post(
56
+ session = _get_weco_session()
57
+ response = session.post(
39
58
  f"{__base_url__}/runs",
40
59
  json={
41
60
  "source_code": source_code,
@@ -68,11 +87,12 @@ def evaluate_feedback_then_suggest_next_solution(
68
87
  additional_instructions: str = None,
69
88
  api_keys: Dict[str, Any] = {},
70
89
  auth_headers: dict = {},
71
- timeout: int = 800,
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
- response = requests.post(
94
+ session = _get_weco_session()
95
+ response = session.post(
76
96
  f"{__base_url__}/runs/{run_id}/suggest",
77
97
  json={
78
98
  "execution_output": execution_output,
@@ -94,11 +114,12 @@ def evaluate_feedback_then_suggest_next_solution(
94
114
 
95
115
 
96
116
  def get_optimization_run_status(
97
- run_id: str, include_history: bool = False, auth_headers: dict = {}, timeout: int = 800
117
+ run_id: str, include_history: bool = False, auth_headers: dict = {}, timeout: Union[int, Tuple[int, int]] = 800
98
118
  ) -> Dict[str, Any]:
99
119
  """Get the current status of the optimization run."""
100
120
  try:
101
- response = requests.get(
121
+ session = _get_weco_session()
122
+ response = session.get(
102
123
  f"{__base_url__}/runs/{run_id}", params={"include_history": include_history}, headers=auth_headers, timeout=timeout
103
124
  )
104
125
  response.raise_for_status()
@@ -111,10 +132,11 @@ def get_optimization_run_status(
111
132
  raise # Re-raise
112
133
 
113
134
 
114
- def send_heartbeat(run_id: str, auth_headers: dict = {}, timeout: int = 10) -> bool:
135
+ def send_heartbeat(run_id: str, auth_headers: dict = {}, timeout: Union[int, Tuple[int, int]] = 10) -> bool:
115
136
  """Send a heartbeat signal to the backend."""
116
137
  try:
117
- response = requests.put(f"{__base_url__}/runs/{run_id}/heartbeat", headers=auth_headers, timeout=timeout)
138
+ session = _get_weco_session()
139
+ response = session.put(f"{__base_url__}/runs/{run_id}/heartbeat", headers=auth_headers, timeout=timeout)
118
140
  response.raise_for_status()
119
141
  return True
120
142
  except requests.exceptions.HTTPError as e:
@@ -129,11 +151,17 @@ def send_heartbeat(run_id: str, auth_headers: dict = {}, timeout: int = 10) -> b
129
151
 
130
152
 
131
153
  def report_termination(
132
- run_id: str, status_update: str, reason: str, details: Optional[str] = None, auth_headers: dict = {}, timeout: int = 30
154
+ run_id: str,
155
+ status_update: str,
156
+ reason: str,
157
+ details: Optional[str] = None,
158
+ auth_headers: dict = {},
159
+ timeout: Union[int, Tuple[int, int]] = 30,
133
160
  ) -> bool:
134
161
  """Report the termination reason to the backend."""
135
162
  try:
136
- response = requests.post(
163
+ session = _get_weco_session()
164
+ response = session.post(
137
165
  f"{__base_url__}/runs/{run_id}/terminate",
138
166
  json={"status_update": status_update, "termination_reason": reason, "termination_details": details},
139
167
  headers=auth_headers,
@@ -144,3 +172,188 @@ def report_termination(
144
172
  except requests.exceptions.RequestException as e:
145
173
  print(f"Warning: Failed to report termination to backend for run {run_id}: {e}", file=sys.stderr)
146
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"Warning: Could not set permissions on {CREDENTIALS_FILE}: {e}")
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