weco 0.2.28__py3-none-any.whl → 0.3.1__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/api.py CHANGED
@@ -4,22 +4,98 @@ import requests
4
4
  from rich.console import Console
5
5
 
6
6
  from weco import __pkg_version__, __base_url__
7
- from .constants import DEFAULT_API_TIMEOUT
8
- from .utils import truncate_output
7
+ from .utils import truncate_output, determine_model_for_onboarding
9
8
 
10
9
 
11
10
  def handle_api_error(e: requests.exceptions.HTTPError, console: Console) -> None:
12
11
  """Extract and display error messages from API responses in a structured format."""
12
+ status = getattr(e.response, "status_code", None)
13
13
  try:
14
- detail = e.response.json()["detail"]
15
- except (ValueError, KeyError): # Handle cases where response is not JSON or detail key is missing
16
- detail = f"HTTP {e.response.status_code} Error: {e.response.text}"
17
- console.print(f"[bold red]{detail}[/]")
14
+ payload = e.response.json()
15
+ detail = payload.get("detail", payload)
16
+ except (ValueError, AttributeError):
17
+ detail = getattr(e.response, "text", "") or f"HTTP {status} Error"
18
+
19
+ def _render(detail_obj: Any) -> None:
20
+ if isinstance(detail_obj, str):
21
+ console.print(f"[bold red]{detail_obj}[/]")
22
+ elif isinstance(detail_obj, dict):
23
+ # Try common message keys in order of preference
24
+ message_keys = ("message", "error", "msg", "detail")
25
+ message = next((detail_obj.get(key) for key in message_keys if detail_obj.get(key)), None)
26
+ suggestion = detail_obj.get("suggestion")
27
+ if message:
28
+ console.print(f"[bold red]{message}[/]")
29
+ else:
30
+ console.print(f"[bold red]HTTP {status} Error[/]")
31
+ if suggestion:
32
+ console.print(f"[yellow]{suggestion}[/]")
33
+ extras = {
34
+ k: v
35
+ for k, v in detail_obj.items()
36
+ if k not in {"message", "error", "msg", "detail", "suggestion"} and v not in (None, "")
37
+ }
38
+ for key, value in extras.items():
39
+ console.print(f"[dim]{key}: {value}[/]")
40
+ elif isinstance(detail_obj, list) and detail_obj:
41
+ _render(detail_obj[0])
42
+ for extra in detail_obj[1:]:
43
+ console.print(f"[yellow]{extra}[/]")
44
+ else:
45
+ console.print(f"[bold red]{detail_obj or f'HTTP {status} Error'}[/]")
46
+
47
+ _render(detail)
48
+
49
+
50
+ def _recover_suggest_after_transport_error(
51
+ console: Console, run_id: str, step: int, auth_headers: dict
52
+ ) -> Optional[Dict[str, Any]]:
53
+ """
54
+ Try to reconstruct the /suggest response after a transport error (ReadTimeout/502/RemoteDisconnected)
55
+ by fetching run status and using the latest nodes.
56
+
57
+ Args:
58
+ console: The console object to use for logging.
59
+ run_id: The ID of the run to recover.
60
+ step: The step of the solution to recover.
61
+ auth_headers: The authentication headers to use for the request.
62
+
63
+ Returns:
64
+ The recovered response if the run is in a valid state, otherwise None.
65
+ """
66
+ run_status_recovery_response = get_optimization_run_status(
67
+ console=console, run_id=run_id, include_history=True, auth_headers=auth_headers
68
+ )
69
+ current_step = run_status_recovery_response.get("current_step")
70
+ current_status = run_status_recovery_response.get("status")
71
+ # The run should be "running" and the current step should correspond to the solution step we are attempting to generate
72
+ is_valid_run_state = current_status is not None and current_status == "running"
73
+ is_valid_step = current_step is not None and current_step == step
74
+ if is_valid_run_state and is_valid_step:
75
+ nodes = run_status_recovery_response.get("nodes") or []
76
+ # We need at least 2 nodes to reconstruct the expected response i.e., the last two nodes
77
+ if len(nodes) >= 2:
78
+ nodes_sorted_ascending = sorted(nodes, key=lambda n: n["step"])
79
+ latest_node = nodes_sorted_ascending[-1]
80
+ penultimate_node = nodes_sorted_ascending[-2]
81
+ # If the server finished generating the next candidate, it should be exactly this step
82
+ if latest_node and latest_node["step"] == step:
83
+ # Try to reconstruct the expected response from the /suggest endpoint using the run status info
84
+ return {
85
+ "run_id": run_id,
86
+ "previous_solution_metric_value": penultimate_node.get("metric_value"),
87
+ "solution_id": latest_node.get("solution_id"),
88
+ "code": latest_node.get("code"),
89
+ "plan": latest_node.get("plan"),
90
+ "is_done": False,
91
+ }
92
+ return None
18
93
 
19
94
 
20
95
  def start_optimization_run(
21
96
  console: Console,
22
97
  source_code: str,
98
+ source_path: str,
23
99
  evaluation_command: str,
24
100
  metric_name: str,
25
101
  maximize: bool,
@@ -28,9 +104,11 @@ def start_optimization_run(
28
104
  evaluator_config: Dict[str, Any],
29
105
  search_policy_config: Dict[str, Any],
30
106
  additional_instructions: str = None,
31
- api_keys: Dict[str, Any] = {},
107
+ eval_timeout: Optional[int] = None,
108
+ save_logs: bool = False,
109
+ log_dir: str = ".runs",
32
110
  auth_headers: dict = {},
33
- timeout: Union[int, Tuple[int, int]] = DEFAULT_API_TIMEOUT,
111
+ timeout: Union[int, Tuple[int, int]] = (10, 3650),
34
112
  ) -> Optional[Dict[str, Any]]:
35
113
  """Start the optimization run."""
36
114
  with console.status("[bold green]Starting Optimization..."):
@@ -39,6 +117,7 @@ def start_optimization_run(
39
117
  f"{__base_url__}/runs/",
40
118
  json={
41
119
  "source_code": source_code,
120
+ "source_path": source_path,
42
121
  "additional_instructions": additional_instructions,
43
122
  "objective": {"evaluation_command": evaluation_command, "metric_name": metric_name, "maximize": maximize},
44
123
  "optimizer": {
@@ -47,7 +126,10 @@ def start_optimization_run(
47
126
  "evaluator": evaluator_config,
48
127
  "search_policy": search_policy_config,
49
128
  },
50
- "metadata": {"client_name": "cli", "client_version": __pkg_version__, **api_keys},
129
+ "eval_timeout": eval_timeout,
130
+ "save_logs": save_logs,
131
+ "log_dir": log_dir,
132
+ "metadata": {"client_name": "cli", "client_version": __pkg_version__},
51
133
  },
52
134
  headers=auth_headers,
53
135
  timeout=timeout,
@@ -68,14 +150,37 @@ def start_optimization_run(
68
150
  return None
69
151
 
70
152
 
153
+ def resume_optimization_run(
154
+ console: Console, run_id: str, auth_headers: dict = {}, timeout: Union[int, Tuple[int, int]] = (5, 10)
155
+ ) -> Optional[Dict[str, Any]]:
156
+ """Request the backend to resume an interrupted run."""
157
+ with console.status("[bold green]Resuming run..."):
158
+ try:
159
+ response = requests.post(
160
+ f"{__base_url__}/runs/{run_id}/resume",
161
+ json={"metadata": {"client_name": "cli", "client_version": __pkg_version__}},
162
+ headers=auth_headers,
163
+ timeout=timeout,
164
+ )
165
+ response.raise_for_status()
166
+ result = response.json()
167
+ return result
168
+ except requests.exceptions.HTTPError as e:
169
+ handle_api_error(e, console)
170
+ return None
171
+ except Exception as e:
172
+ console.print(f"[bold red]Error resuming run: {e}[/]")
173
+ return None
174
+
175
+
71
176
  def evaluate_feedback_then_suggest_next_solution(
72
177
  console: Console,
73
178
  run_id: str,
179
+ step: int,
74
180
  execution_output: str,
75
181
  additional_instructions: str = None,
76
- api_keys: Dict[str, Any] = {},
77
182
  auth_headers: dict = {},
78
- timeout: Union[int, Tuple[int, int]] = DEFAULT_API_TIMEOUT,
183
+ timeout: Union[int, Tuple[int, int]] = (10, 3650),
79
184
  ) -> Dict[str, Any]:
80
185
  """Evaluate the feedback and suggest the next solution."""
81
186
  try:
@@ -84,11 +189,7 @@ def evaluate_feedback_then_suggest_next_solution(
84
189
 
85
190
  response = requests.post(
86
191
  f"{__base_url__}/runs/{run_id}/suggest",
87
- json={
88
- "execution_output": truncated_output,
89
- "additional_instructions": additional_instructions,
90
- "metadata": {**api_keys},
91
- },
192
+ json={"execution_output": truncated_output, "additional_instructions": additional_instructions, "metadata": {}},
92
193
  headers=auth_headers,
93
194
  timeout=timeout,
94
195
  )
@@ -99,10 +200,40 @@ def evaluate_feedback_then_suggest_next_solution(
99
200
  result["plan"] = ""
100
201
  if result.get("code") is None:
101
202
  result["code"] = ""
102
-
103
203
  return result
204
+ except requests.exceptions.ReadTimeout as e:
205
+ # ReadTimeout can mean either:
206
+ # 1) the server truly didn't finish before the client's read timeout, or
207
+ # 2) the server finished but an intermediary (proxy/LB) dropped the response.
208
+ # We only try to recover case (2): fetch run status to confirm the step completed and reconstruct the response.
209
+ recovered = _recover_suggest_after_transport_error(
210
+ console=console, run_id=run_id, step=step, auth_headers=auth_headers
211
+ )
212
+ if recovered is not None:
213
+ return recovered
214
+ # If we cannot confirm completion, bubble up the timeout so the caller can resume later.
215
+ raise requests.exceptions.ReadTimeout(e)
104
216
  except requests.exceptions.HTTPError as e:
105
- # Allow caller to handle suggest errors, maybe retry or terminate
217
+ # Treat only 502 Bad Gateway as a transient transport/gateway issue (akin to a dropped response).
218
+ # For 502, attempt the status-based recovery method used for ReadTimeout errors; otherwise render the HTTP error normally.
219
+ if (resp := getattr(e, "response", None)) is not None and resp.status_code == 502:
220
+ recovered = _recover_suggest_after_transport_error(
221
+ console=console, run_id=run_id, step=step, auth_headers=auth_headers
222
+ )
223
+ if recovered is not None:
224
+ return recovered
225
+ # Surface non-502 HTTP errors to the user.
226
+ handle_api_error(e, console)
227
+ raise
228
+ except requests.exceptions.ConnectionError as e:
229
+ # Covers connection resets with no HTTP response (e.g., RemoteDisconnected).
230
+ # Treat as a potential "response lost after completion": try status-based recovery first similar to how ReadTimeout errors are handled.
231
+ recovered = _recover_suggest_after_transport_error(
232
+ console=console, run_id=run_id, step=step, auth_headers=auth_headers
233
+ )
234
+ if recovered is not None:
235
+ return recovered
236
+ # Surface the connection error to the user.
106
237
  handle_api_error(e, console)
107
238
  raise
108
239
  except Exception as e:
@@ -115,7 +246,7 @@ def get_optimization_run_status(
115
246
  run_id: str,
116
247
  include_history: bool = False,
117
248
  auth_headers: dict = {},
118
- timeout: Union[int, Tuple[int, int]] = DEFAULT_API_TIMEOUT,
249
+ timeout: Union[int, Tuple[int, int]] = (5, 10),
119
250
  ) -> Dict[str, Any]:
120
251
  """Get the current status of the optimization run."""
121
252
  try:
@@ -146,7 +277,7 @@ def get_optimization_run_status(
146
277
  raise
147
278
 
148
279
 
149
- def send_heartbeat(run_id: str, auth_headers: dict = {}, timeout: Union[int, Tuple[int, int]] = (10, 10)) -> bool:
280
+ def send_heartbeat(run_id: str, auth_headers: dict = {}, timeout: Union[int, Tuple[int, int]] = (5, 10)) -> bool:
150
281
  """Send a heartbeat signal to the backend."""
151
282
  try:
152
283
  response = requests.put(f"{__base_url__}/runs/{run_id}/heartbeat", headers=auth_headers, timeout=timeout)
@@ -169,7 +300,7 @@ def report_termination(
169
300
  reason: str,
170
301
  details: Optional[str] = None,
171
302
  auth_headers: dict = {},
172
- timeout: Union[int, Tuple[int, int]] = (10, 30),
303
+ timeout: Union[int, Tuple[int, int]] = (5, 10),
173
304
  ) -> bool:
174
305
  """Report the termination reason to the backend."""
175
306
  try:
@@ -186,43 +317,17 @@ def report_termination(
186
317
  return False
187
318
 
188
319
 
189
- # --- Chatbot API Functions ---
190
- def _determine_model_and_api_key() -> tuple[str, dict[str, str]]:
191
- """Determine the model and API key to use based on available environment variables.
192
-
193
- Uses the shared model selection logic to maintain consistency.
194
- Returns (model_name, api_key_dict)
195
- """
196
- from .utils import read_api_keys_from_env, determine_default_model
197
-
198
- llm_api_keys = read_api_keys_from_env()
199
- model = determine_default_model(llm_api_keys)
200
-
201
- # Create API key dictionary with only the key for the selected model
202
- if model == "o4-mini":
203
- api_key_dict = {"OPENAI_API_KEY": llm_api_keys["OPENAI_API_KEY"]}
204
- elif model == "claude-sonnet-4-0":
205
- api_key_dict = {"ANTHROPIC_API_KEY": llm_api_keys["ANTHROPIC_API_KEY"]}
206
- elif model == "gemini-2.5-pro":
207
- api_key_dict = {"GEMINI_API_KEY": llm_api_keys["GEMINI_API_KEY"]}
208
- else:
209
- # This should never happen if determine_default_model works correctly
210
- raise ValueError(f"Unknown default model choice: {model}")
211
-
212
- return model, api_key_dict
213
-
214
-
215
320
  def get_optimization_suggestions_from_codebase(
216
321
  console: Console,
217
322
  gitingest_summary: str,
218
323
  gitingest_tree: str,
219
324
  gitingest_content_str: str,
220
325
  auth_headers: dict = {},
221
- timeout: Union[int, Tuple[int, int]] = DEFAULT_API_TIMEOUT,
326
+ timeout: Union[int, Tuple[int, int]] = (10, 3650),
222
327
  ) -> Optional[List[Dict[str, Any]]]:
223
328
  """Analyze codebase and get optimization suggestions using the model-agnostic backend API."""
224
329
  try:
225
- model, api_key_dict = _determine_model_and_api_key()
330
+ model = determine_model_for_onboarding()
226
331
  response = requests.post(
227
332
  f"{__base_url__}/onboard/analyze-codebase",
228
333
  json={
@@ -230,7 +335,7 @@ def get_optimization_suggestions_from_codebase(
230
335
  "gitingest_tree": gitingest_tree,
231
336
  "gitingest_content": gitingest_content_str,
232
337
  "model": model,
233
- "metadata": api_key_dict,
338
+ "metadata": {},
234
339
  },
235
340
  headers=auth_headers,
236
341
  timeout=timeout,
@@ -253,11 +358,11 @@ def generate_evaluation_script_and_metrics(
253
358
  description: str,
254
359
  gitingest_content_str: str,
255
360
  auth_headers: dict = {},
256
- timeout: Union[int, Tuple[int, int]] = DEFAULT_API_TIMEOUT,
361
+ timeout: Union[int, Tuple[int, int]] = (10, 3650),
257
362
  ) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
258
363
  """Generate evaluation script and determine metrics using the model-agnostic backend API."""
259
364
  try:
260
- model, api_key_dict = _determine_model_and_api_key()
365
+ model = determine_model_for_onboarding()
261
366
  response = requests.post(
262
367
  f"{__base_url__}/onboard/generate-script",
263
368
  json={
@@ -265,7 +370,7 @@ def generate_evaluation_script_and_metrics(
265
370
  "description": description,
266
371
  "gitingest_content": gitingest_content_str,
267
372
  "model": model,
268
- "metadata": api_key_dict,
373
+ "metadata": {},
269
374
  },
270
375
  headers=auth_headers,
271
376
  timeout=timeout,
@@ -289,11 +394,11 @@ def analyze_evaluation_environment(
289
394
  gitingest_tree: str,
290
395
  gitingest_content_str: str,
291
396
  auth_headers: dict = {},
292
- timeout: Union[int, Tuple[int, int]] = DEFAULT_API_TIMEOUT,
397
+ timeout: Union[int, Tuple[int, int]] = (10, 3650),
293
398
  ) -> Optional[Dict[str, Any]]:
294
399
  """Analyze existing evaluation scripts and environment using the model-agnostic backend API."""
295
400
  try:
296
- model, api_key_dict = _determine_model_and_api_key()
401
+ model = determine_model_for_onboarding()
297
402
  response = requests.post(
298
403
  f"{__base_url__}/onboard/analyze-environment",
299
404
  json={
@@ -303,7 +408,7 @@ def analyze_evaluation_environment(
303
408
  "gitingest_tree": gitingest_tree,
304
409
  "gitingest_content": gitingest_content_str,
305
410
  "model": model,
306
- "metadata": api_key_dict,
411
+ "metadata": {},
307
412
  },
308
413
  headers=auth_headers,
309
414
  timeout=timeout,
@@ -325,11 +430,11 @@ def analyze_script_execution_requirements(
325
430
  script_path: str,
326
431
  target_file: str,
327
432
  auth_headers: dict = {},
328
- timeout: Union[int, Tuple[int, int]] = DEFAULT_API_TIMEOUT,
433
+ timeout: Union[int, Tuple[int, int]] = (10, 3650),
329
434
  ) -> Optional[str]:
330
435
  """Analyze script to determine proper execution command using the model-agnostic backend API."""
331
436
  try:
332
- model, api_key_dict = _determine_model_and_api_key()
437
+ model = determine_model_for_onboarding()
333
438
  response = requests.post(
334
439
  f"{__base_url__}/onboard/analyze-script",
335
440
  json={
@@ -337,7 +442,7 @@ def analyze_script_execution_requirements(
337
442
  "script_path": script_path,
338
443
  "target_file": target_file,
339
444
  "model": model,
340
- "metadata": api_key_dict,
445
+ "metadata": {},
341
446
  },
342
447
  headers=auth_headers,
343
448
  timeout=timeout,
weco/auth.py CHANGED
@@ -184,9 +184,9 @@ def perform_login(console: Console):
184
184
  return False
185
185
 
186
186
 
187
- def handle_authentication(console: Console, llm_api_keys: dict) -> tuple[str | None, dict]:
187
+ def handle_authentication(console: Console) -> tuple[str | None, dict]:
188
188
  """
189
- Handle the complete authentication flow.
189
+ Handle the complete authentication flow. Authentication is now mandatory.
190
190
 
191
191
  Returns:
192
192
  tuple: (weco_api_key, auth_headers)
@@ -194,13 +194,16 @@ def handle_authentication(console: Console, llm_api_keys: dict) -> tuple[str | N
194
194
  weco_api_key = load_weco_api_key()
195
195
 
196
196
  if not weco_api_key:
197
+ console.print("[bold yellow]Authentication Required[/]")
198
+ console.print("With our new credit-based billing system, authentication is required to use Weco.")
199
+ console.print("You'll receive free credits to get started!")
200
+ console.print("")
201
+
197
202
  login_choice = Prompt.ask(
198
- "Log in to Weco to save run history or use anonymously? ([bold]L[/]ogin / [bold]S[/]kip)",
199
- choices=["l", "s"],
200
- default="s",
203
+ "Would you like to log in now? ([bold]Y[/]es / [bold]N[/]o)", choices=["y", "n"], default="y"
201
204
  ).lower()
202
205
 
203
- if login_choice == "l":
206
+ if login_choice == "y":
204
207
  console.print("[cyan]Starting login process...[/]")
205
208
  if not perform_login(console):
206
209
  console.print("[bold red]Login process failed or was cancelled.[/]")
@@ -210,14 +213,9 @@ def handle_authentication(console: Console, llm_api_keys: dict) -> tuple[str | N
210
213
  if not weco_api_key:
211
214
  console.print("[bold red]Error: Login completed but failed to retrieve API key.[/]")
212
215
  return None, {}
213
-
214
- elif login_choice == "s":
215
- console.print("[yellow]Proceeding anonymously. LLM API keys must be provided via environment variables.[/]")
216
- if not llm_api_keys:
217
- console.print(
218
- "[bold red]Error:[/] No LLM API keys found in environment (e.g., OPENAI_API_KEY). Cannot proceed anonymously."
219
- )
220
- return None, {}
216
+ else:
217
+ console.print("[yellow]Authentication is required to use Weco. Please run 'weco' again when ready to log in.[/]")
218
+ return None, {}
221
219
 
222
220
  # Build auth headers
223
221
  auth_headers = {}
weco/chatbot.py CHANGED
@@ -220,6 +220,7 @@ class Chatbot:
220
220
  gitingest_summary=self.gitingest_summary,
221
221
  gitingest_tree=self.gitingest_tree,
222
222
  gitingest_content_str=self.gitingest_content_str,
223
+ auth_headers=getattr(self, "auth_headers", {}),
223
224
  )
224
225
 
225
226
  if result and isinstance(result, list):
@@ -332,6 +333,7 @@ class Chatbot:
332
333
  target_file=selected_option["target_file"],
333
334
  description=selected_option["description"],
334
335
  gitingest_content_str=self.gitingest_content_str,
336
+ auth_headers=getattr(self, "auth_headers", {}),
335
337
  )
336
338
  if result and result[0]:
337
339
  eval_script_content, metric_name, goal, reasoning = result
@@ -381,6 +383,7 @@ class Chatbot:
381
383
  script_content=eval_script_content,
382
384
  script_path=eval_script_path_str,
383
385
  target_file=selected_option["target_file"],
386
+ auth_headers=getattr(self, "auth_headers", {}),
384
387
  )
385
388
 
386
389
  return {
@@ -401,6 +404,7 @@ class Chatbot:
401
404
  gitingest_summary=self.gitingest_summary,
402
405
  gitingest_tree=self.gitingest_tree,
403
406
  gitingest_content_str=self.gitingest_content_str,
407
+ auth_headers=getattr(self, "auth_headers", {}),
404
408
  )
405
409
 
406
410
  if not analysis:
@@ -542,6 +546,7 @@ class Chatbot:
542
546
  script_content=script_content,
543
547
  script_path=script_path,
544
548
  target_file=selected_option["target_file"],
549
+ auth_headers=getattr(self, "auth_headers", {}),
545
550
  )
546
551
 
547
552
  self.current_step = "confirmation"
@@ -749,10 +754,9 @@ class Chatbot:
749
754
  self.resolved_model = self.user_specified_model
750
755
  else:
751
756
  # Use same default model selection as weco run
752
- from .utils import determine_default_model, read_api_keys_from_env
757
+ from .utils import determine_model_for_onboarding
753
758
 
754
- llm_api_keys = read_api_keys_from_env()
755
- self.resolved_model = determine_default_model(llm_api_keys)
759
+ self.resolved_model = determine_model_for_onboarding()
756
760
 
757
761
  target_file = selected_option["target_file"]
758
762
  additional_instructions = selected_option["description"]
@@ -766,6 +770,17 @@ class Chatbot:
766
770
  self.console.print("[bold cyan]Welcome to Weco![/]")
767
771
  self.console.print(f"Let's optimize your codebase in: [cyan]{self.project_path}[/]\n")
768
772
 
773
+ # Mandatory authentication as per PLAN.md
774
+ from .auth import handle_authentication
775
+
776
+ weco_api_key, auth_headers = handle_authentication(self.console)
777
+ if not weco_api_key:
778
+ self.console.print("[yellow]Authentication is required to use Weco. Exiting...[/]")
779
+ return
780
+
781
+ # Store auth headers for API calls
782
+ self.auth_headers = auth_headers
783
+
769
784
  options = self.analyze_codebase_and_get_optimization_options()
770
785
  if not options:
771
786
  return
weco/cli.py CHANGED
@@ -74,6 +74,52 @@ def configure_run_parser(run_parser: argparse.ArgumentParser) -> None:
74
74
  )
75
75
 
76
76
 
77
+ def configure_credits_parser(credits_parser: argparse.ArgumentParser) -> None:
78
+ """Configure the credits command parser and all its subcommands."""
79
+ credits_subparsers = credits_parser.add_subparsers(dest="credits_command", help="Credit management commands")
80
+
81
+ # Credits balance command
82
+ _ = credits_subparsers.add_parser("balance", help="Check your current credit balance")
83
+
84
+ # Coerce CLI input into a float with two decimal precision for the API payload.
85
+ def _parse_credit_amount(value: str) -> float:
86
+ try:
87
+ amount = float(value)
88
+ except ValueError as exc:
89
+ raise argparse.ArgumentTypeError("Amount must be a number.") from exc
90
+
91
+ return round(amount, 2)
92
+
93
+ # Credits topup command
94
+ topup_parser = credits_subparsers.add_parser("topup", help="Purchase additional credits")
95
+ topup_parser.add_argument(
96
+ "amount",
97
+ nargs="?",
98
+ type=_parse_credit_amount,
99
+ default=_parse_credit_amount("10"),
100
+ metavar="CREDITS",
101
+ help="Amount of credits to purchase (minimum 2, defaults to 10)",
102
+ )
103
+
104
+ # Credits autotopup command
105
+ autotopup_parser = credits_subparsers.add_parser("autotopup", help="Configure automatic top-up")
106
+ autotopup_parser.add_argument("--enable", action="store_true", help="Enable automatic top-up")
107
+ autotopup_parser.add_argument("--disable", action="store_true", help="Disable automatic top-up")
108
+ autotopup_parser.add_argument(
109
+ "--threshold", type=float, default=4.0, help="Balance threshold to trigger auto top-up (default: 4.0 credits)"
110
+ )
111
+ autotopup_parser.add_argument(
112
+ "--amount", type=float, default=50.0, help="Amount to top up when threshold is reached (default: 50.0 credits)"
113
+ )
114
+
115
+
116
+ def configure_resume_parser(resume_parser: argparse.ArgumentParser) -> None:
117
+ """Configure arguments for the resume command."""
118
+ resume_parser.add_argument(
119
+ "run_id", type=str, help="The UUID of the run to resume (e.g., '0002e071-1b67-411f-a514-36947f0c4b31')"
120
+ )
121
+
122
+
77
123
  def execute_run_command(args: argparse.Namespace) -> None:
78
124
  """Execute the 'weco run' command with all its logic."""
79
125
  from .optimizer import execute_optimization
@@ -95,6 +141,14 @@ def execute_run_command(args: argparse.Namespace) -> None:
95
141
  sys.exit(exit_code)
96
142
 
97
143
 
144
+ def execute_resume_command(args: argparse.Namespace) -> None:
145
+ """Execute the 'weco resume' command with all its logic."""
146
+ from .optimizer import resume_optimization
147
+
148
+ success = resume_optimization(run_id=args.run_id, console=console)
149
+ sys.exit(0 if success else 1)
150
+
151
+
98
152
  def main() -> None:
99
153
  """Main function for the Weco CLI."""
100
154
  check_for_cli_updates()
@@ -126,6 +180,19 @@ def main() -> None:
126
180
  # --- Logout Command Parser Setup ---
127
181
  _ = subparsers.add_parser("logout", help="Log out from Weco and clear saved API key.")
128
182
 
183
+ # --- Credits Command Parser Setup ---
184
+ credits_parser = subparsers.add_parser("credits", help="Manage your Weco credits")
185
+ configure_credits_parser(credits_parser) # Use the helper to add subcommands and arguments
186
+
187
+ # --- Resume Command Parser Setup ---
188
+ resume_parser = subparsers.add_parser(
189
+ "resume",
190
+ help="Resume an interrupted optimization run",
191
+ formatter_class=argparse.RawDescriptionHelpFormatter,
192
+ allow_abbrev=False,
193
+ )
194
+ configure_resume_parser(resume_parser)
195
+
129
196
  # Check if we should run the chatbot
130
197
  # This logic needs to be robust. If 'run' or 'logout' is present, or -h/--help, don't run chatbot.
131
198
  # Otherwise, if it's just 'weco' or 'weco <path>' (with optional --model), run chatbot.
@@ -157,7 +224,7 @@ def main() -> None:
157
224
  return None
158
225
 
159
226
  first_non_option = get_first_non_option_arg()
160
- is_known_command = first_non_option in ["run", "logout"]
227
+ is_known_command = first_non_option in ["run", "logout", "credits"]
161
228
  is_help_command = len(sys.argv) > 1 and sys.argv[1] in ["-h", "--help"] # Check for global help
162
229
 
163
230
  should_run_chatbot_result = should_run_chatbot(sys.argv[1:])
@@ -208,6 +275,13 @@ def main() -> None:
208
275
  sys.exit(0)
209
276
  elif args.command == "run":
210
277
  execute_run_command(args)
278
+ elif args.command == "credits":
279
+ from .credits import handle_credits_command
280
+
281
+ handle_credits_command(args, console)
282
+ sys.exit(0)
283
+ elif args.command == "resume":
284
+ execute_resume_command(args)
211
285
  else:
212
286
  # This case should be hit if 'weco' is run alone and chatbot logic didn't catch it,
213
287
  # or if an invalid command is provided.
weco/constants.py CHANGED
@@ -3,9 +3,12 @@
3
3
  Constants for the Weco CLI package.
4
4
  """
5
5
 
6
- # API timeout configuration (connect_timeout, read_timeout) in seconds
7
- DEFAULT_API_TIMEOUT = (10, 3650)
8
-
9
6
  # Output truncation configuration
10
7
  TRUNCATION_THRESHOLD = 51000 # Maximum length before truncation
11
8
  TRUNCATION_KEEP_LENGTH = 25000 # Characters to keep from beginning and end
9
+
10
+ # Default model configuration
11
+ DEFAULT_MODEL = "o4-mini"
12
+
13
+ # Supported file extensions for additional instructions
14
+ SUPPORTED_FILE_EXTENSIONS = [".md", ".txt", ".rst"]