Mirrowel commited on
Commit
37501b2
·
1 Parent(s): 34e560e

feat(ui): ✨ add interactive launcher TUI and advanced settings tool

Browse files

Add a new Rich-based interactive launcher TUI (proxy_app/launcher_tui.py) and an advanced settings tool (proxy_app/settings_tool.py). Integrate the launcher into proxy_app/main.py so the TUI is shown when no CLI args are provided and can modify sys.argv to start the proxy.

- LauncherTUI: onboarding checks, credential management entrypoint, proxy configuration menu, provider/advanced settings viewer, and in-place proxy start flow.
- SettingsTool: interactive management of .env advanced settings (custom provider API bases, provider model definitions, concurrency limits) with staging/saving and undo support.
- AdvancedSettings, CustomProviderManager, ModelDefinitionManager, ConcurrencyManager utilities to stage and persist .env changes; settings tools reload the environment after changes.
- Ensure PROXY_API_KEY management and .env loading/reloading across tools; credential tool integration preserved.

No breaking changes expected.

src/proxy_app/launcher_tui.py ADDED
@@ -0,0 +1,547 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Interactive TUI launcher for the LLM API Key Proxy.
3
+ Provides a beautiful Rich-based interface for configuration and execution.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+ from rich.console import Console
11
+ from rich.prompt import IntPrompt, Prompt
12
+ from rich.panel import Panel
13
+ from rich.text import Text
14
+ from dotenv import load_dotenv, set_key
15
+
16
+ console = Console()
17
+
18
+
19
+ class LauncherConfig:
20
+ """Manages launcher_config.json (host, port, logging only)"""
21
+
22
+ def __init__(self, config_path: Path = Path("launcher_config.json")):
23
+ self.config_path = config_path
24
+ self.defaults = {
25
+ "host": "127.0.0.1",
26
+ "port": 8000,
27
+ "enable_request_logging": False
28
+ }
29
+ self.config = self.load()
30
+
31
+ def load(self) -> dict:
32
+ """Load config from file or create with defaults."""
33
+ if self.config_path.exists():
34
+ try:
35
+ with open(self.config_path, 'r') as f:
36
+ config = json.load(f)
37
+ # Merge with defaults for any missing keys
38
+ for key, value in self.defaults.items():
39
+ if key not in config:
40
+ config[key] = value
41
+ return config
42
+ except (json.JSONDecodeError, IOError):
43
+ return self.defaults.copy()
44
+ return self.defaults.copy()
45
+
46
+ def save(self):
47
+ """Save current config to file."""
48
+ import datetime
49
+ self.config["last_updated"] = datetime.datetime.now().isoformat()
50
+ try:
51
+ with open(self.config_path, 'w') as f:
52
+ json.dump(self.config, f, indent=2)
53
+ except IOError as e:
54
+ console.print(f"[red]Error saving config: {e}[/red]")
55
+
56
+ def update(self, **kwargs):
57
+ """Update config values."""
58
+ self.config.update(kwargs)
59
+ self.save()
60
+
61
+ @staticmethod
62
+ def update_proxy_api_key(new_key: str):
63
+ """Update PROXY_API_KEY in .env only"""
64
+ env_file = Path.cwd() / ".env"
65
+ set_key(str(env_file), "PROXY_API_KEY", new_key)
66
+ load_dotenv(dotenv_path=env_file, override=True)
67
+
68
+
69
+ class SettingsDetector:
70
+ """Detects settings from .env for display"""
71
+
72
+ @staticmethod
73
+ def _load_local_env() -> dict:
74
+ """Load environment variables from local .env file only"""
75
+ env_file = Path.cwd() / ".env"
76
+ env_dict = {}
77
+ if not env_file.exists():
78
+ return env_dict
79
+ try:
80
+ with open(env_file, 'r', encoding='utf-8') as f:
81
+ for line in f:
82
+ line = line.strip()
83
+ if not line or line.startswith('#'):
84
+ continue
85
+ if '=' in line:
86
+ key, _, value = line.partition('=')
87
+ key, value = key.strip(), value.strip()
88
+ if value and value[0] in ('"', "'") and value[-1] == value[0]:
89
+ value = value[1:-1]
90
+ env_dict[key] = value
91
+ except (IOError, OSError):
92
+ pass
93
+ return env_dict
94
+
95
+ @staticmethod
96
+ def get_all_settings() -> dict:
97
+ """Returns comprehensive settings overview"""
98
+ return {
99
+ "credentials": SettingsDetector.detect_credentials(),
100
+ "custom_bases": SettingsDetector.detect_custom_api_bases(),
101
+ "model_definitions": SettingsDetector.detect_model_definitions(),
102
+ "concurrency_limits": SettingsDetector.detect_concurrency_limits(),
103
+ "model_filters": SettingsDetector.detect_model_filters()
104
+ }
105
+
106
+ @staticmethod
107
+ def detect_credentials() -> dict:
108
+ """Detect API keys and OAuth credentials"""
109
+ from pathlib import Path
110
+
111
+ providers = {}
112
+
113
+ # Scan for API keys
114
+ env_vars = SettingsDetector._load_local_env()
115
+ for key, value in env_vars.items():
116
+ if "_API_KEY" in key and key != "PROXY_API_KEY":
117
+ provider = key.split("_API_KEY")[0].lower()
118
+ if provider not in providers:
119
+ providers[provider] = {"api_keys": 0, "oauth": 0, "custom": False}
120
+ providers[provider]["api_keys"] += 1
121
+
122
+ # Scan for OAuth credentials
123
+ oauth_dir = Path("oauth_credentials")
124
+ if oauth_dir.exists():
125
+ for file in oauth_dir.glob("*_oauth_*.json"):
126
+ provider = file.name.split("_oauth_")[0]
127
+ if provider not in providers:
128
+ providers[provider] = {"api_keys": 0, "oauth": 0, "custom": False}
129
+ providers[provider]["oauth"] += 1
130
+
131
+ # Mark custom providers (have API_BASE set)
132
+ for provider in providers:
133
+ if os.getenv(f"{provider.upper()}_API_BASE"):
134
+ providers[provider]["custom"] = True
135
+
136
+ return providers
137
+
138
+ @staticmethod
139
+ def detect_custom_api_bases() -> dict:
140
+ """Detect custom API base URLs (not in hardcoded map)"""
141
+ from proxy_app.provider_urls import PROVIDER_URL_MAP
142
+
143
+ bases = {}
144
+ env_vars = SettingsDetector._load_local_env()
145
+ for key, value in env_vars.items():
146
+ if key.endswith("_API_BASE"):
147
+ provider = key.replace("_API_BASE", "").lower()
148
+ # Only include if NOT in hardcoded map
149
+ if provider not in PROVIDER_URL_MAP:
150
+ bases[provider] = value
151
+ return bases
152
+
153
+ @staticmethod
154
+ def detect_model_definitions() -> dict:
155
+ """Detect provider model definitions"""
156
+ models = {}
157
+ env_vars = SettingsDetector._load_local_env()
158
+ for key, value in env_vars.items():
159
+ if key.endswith("_MODELS"):
160
+ provider = key.replace("_MODELS", "").lower()
161
+ try:
162
+ parsed = json.loads(value)
163
+ if isinstance(parsed, dict):
164
+ models[provider] = len(parsed)
165
+ elif isinstance(parsed, list):
166
+ models[provider] = len(parsed)
167
+ except (json.JSONDecodeError, ValueError):
168
+ pass
169
+ return models
170
+
171
+ @staticmethod
172
+ def detect_concurrency_limits() -> dict:
173
+ """Detect max concurrent requests per key"""
174
+ limits = {}
175
+ env_vars = SettingsDetector._load_local_env()
176
+ for key, value in env_vars.items():
177
+ if key.startswith("MAX_CONCURRENT_REQUESTS_PER_KEY_"):
178
+ provider = key.replace("MAX_CONCURRENT_REQUESTS_PER_KEY_", "").lower()
179
+ try:
180
+ limits[provider] = int(value)
181
+ except (json.JSONDecodeError, ValueError):
182
+ pass
183
+ return limits
184
+
185
+ @staticmethod
186
+ def detect_model_filters() -> dict:
187
+ """Detect active model filters (basic info only: defined or not)"""
188
+ filters = {}
189
+ env_vars = SettingsDetector._load_local_env()
190
+ for key, value in env_vars.items():
191
+ if key.startswith("IGNORE_MODELS_") or key.startswith("WHITELIST_MODELS_"):
192
+ filter_type = "ignore" if key.startswith("IGNORE") else "whitelist"
193
+ provider = key.replace(f"{filter_type.upper()}_MODELS_", "").lower()
194
+ if provider not in filters:
195
+ filters[provider] = {"has_ignore": False, "has_whitelist": False}
196
+ if filter_type == "ignore":
197
+ filters[provider]["has_ignore"] = True
198
+ else:
199
+ filters[provider]["has_whitelist"] = True
200
+ return filters
201
+
202
+
203
+ class LauncherTUI:
204
+ """Main launcher interface"""
205
+
206
+ def __init__(self):
207
+ self.console = Console()
208
+ self.config = LauncherConfig()
209
+ self.running = True
210
+ self.env_file = Path.cwd() / ".env"
211
+
212
+ def needs_onboarding(self) -> bool:
213
+ """Check if onboarding is needed"""
214
+ return not self.env_file.exists() or not os.getenv("PROXY_API_KEY")
215
+
216
+ def run(self):
217
+ """Main TUI loop"""
218
+ while self.running:
219
+ self.show_main_menu()
220
+
221
+ def show_main_menu(self):
222
+ """Display main menu and handle selection"""
223
+ self.console.clear()
224
+
225
+ # Detect all settings
226
+ settings = SettingsDetector.get_all_settings()
227
+ credentials = settings["credentials"]
228
+ custom_bases = settings["custom_bases"]
229
+
230
+ # Check if setup is needed
231
+ show_warning = self.needs_onboarding()
232
+
233
+ # Build title
234
+ self.console.print(Panel.fit(
235
+ "[bold cyan]🚀 LLM API Key Proxy - Interactive Launcher[/bold cyan]",
236
+ border_style="cyan"
237
+ ))
238
+
239
+ # Show warning if needed
240
+ if show_warning:
241
+ self.console.print()
242
+ self.console.print(Panel(
243
+ Text.from_markup(
244
+ "⚠️ [bold yellow]CONFIGURATION REQUIRED[/bold yellow]\n\n"
245
+ "The proxy cannot start because:\n"
246
+ " ❌ No .env file found (or)\n"
247
+ " ❌ PROXY_API_KEY is not set in .env\n\n"
248
+ "Why this matters:\n"
249
+ " • The .env file stores your proxy's authentication key\n"
250
+ " • The PROXY_API_KEY protects your proxy from unauthorized access\n"
251
+ " • Without it, the proxy cannot securely start\n\n"
252
+ "What to do:\n"
253
+ " 1. Select option \"3. Manage Credentials\" to launch the credential tool\n"
254
+ " 2. The tool will create .env and set up PROXY_API_KEY automatically\n"
255
+ " 3. You can also add LLM provider credentials while you're there\n\n"
256
+ "⚠️ Important: While provider credentials are optional for startup,\n"
257
+ " the proxy won't do anything useful without them. See README.md\n"
258
+ " for supported providers and setup instructions."
259
+ ),
260
+ border_style="yellow",
261
+ expand=False
262
+ ))
263
+
264
+ # Show config
265
+ self.console.print()
266
+ self.console.print("[bold]📋 Proxy Configuration[/bold]")
267
+ self.console.print("━" * 70)
268
+ self.console.print(f" Host: {self.config.config['host']}")
269
+ self.console.print(f" Port: {self.config.config['port']}")
270
+ self.console.print(f" Request Logging: {'✅ Enabled' if self.config.config['enable_request_logging'] else '❌ Disabled'}")
271
+ self.console.print(f" Proxy API Key: {'✅ Set' if os.getenv('PROXY_API_KEY') else '❌ Not Set'}")
272
+
273
+ # Show status summary
274
+ self.console.print()
275
+ self.console.print("[bold]📊 Status Summary[/bold]")
276
+ self.console.print("━" * 70)
277
+ provider_count = len(credentials)
278
+ custom_count = len(custom_bases)
279
+ has_advanced = bool(settings["model_definitions"] or settings["concurrency_limits"] or settings["model_filters"])
280
+
281
+ self.console.print(f" Providers: {provider_count} configured")
282
+ self.console.print(f" Custom Providers: {custom_count} configured")
283
+ self.console.print(f" Advanced Settings: {'Active (view in menu 4)' if has_advanced else 'None'}")
284
+
285
+ # Show menu
286
+ self.console.print()
287
+ self.console.print("━" * 70)
288
+ self.console.print()
289
+ self.console.print("[bold]🎯 Main Menu[/bold]")
290
+ self.console.print()
291
+
292
+ if show_warning:
293
+ self.console.print(" 1. ▶️ Run Proxy Server")
294
+ self.console.print(" 2. ⚙️ Configure Proxy Settings")
295
+ self.console.print(" 3. 🔑 Manage Credentials ⬅️ [bold yellow]Start here![/bold yellow]")
296
+ else:
297
+ self.console.print(" 1. ▶️ Run Proxy Server")
298
+ self.console.print(" 2. ⚙️ Configure Proxy Settings")
299
+ self.console.print(" 3. 🔑 Manage Credentials")
300
+
301
+ self.console.print(" 4. 📊 View Provider & Advanced Settings")
302
+ self.console.print(" 5. 🔄 Reload Configuration")
303
+ self.console.print(" 6. 🚪 Exit")
304
+
305
+ self.console.print()
306
+ self.console.print("━" * 70)
307
+ self.console.print()
308
+
309
+ choice = Prompt.ask("Select option", choices=["1", "2", "3", "4", "5", "6"], show_choices=False)
310
+
311
+ if choice == "1":
312
+ self.run_proxy()
313
+ elif choice == "2":
314
+ self.show_config_menu()
315
+ elif choice == "3":
316
+ self.launch_credential_tool()
317
+ elif choice == "4":
318
+ self.show_provider_settings_menu()
319
+ elif choice == "5":
320
+ load_dotenv(dotenv_path=Path.cwd() / ".env",override=True)
321
+ self.config = LauncherConfig() # Reload config
322
+ self.console.print("\n[green]✅ Configuration reloaded![/green]")
323
+ elif choice == "6":
324
+ self.running = False
325
+ sys.exit(0)
326
+
327
+ def show_config_menu(self):
328
+ """Display configuration sub-menu"""
329
+ while True:
330
+ self.console.clear()
331
+
332
+ self.console.print(Panel.fit(
333
+ "[bold cyan]⚙️ Proxy Configuration[/bold cyan]",
334
+ border_style="cyan"
335
+ ))
336
+
337
+ self.console.print()
338
+ self.console.print("[bold]📋 Current Settings[/bold]")
339
+ self.console.print("━" * 70)
340
+ self.console.print(f" Host: {self.config.config['host']}")
341
+ self.console.print(f" Port: {self.config.config['port']}")
342
+ self.console.print(f" Request Logging: {'✅ Enabled' if self.config.config['enable_request_logging'] else '❌ Disabled'}")
343
+ self.console.print(f" Proxy API Key: {'✅ Set' if os.getenv('PROXY_API_KEY') else '❌ Not Set'}")
344
+
345
+ self.console.print()
346
+ self.console.print("━" * 70)
347
+ self.console.print()
348
+ self.console.print("[bold]⚙️ Configuration Options[/bold]")
349
+ self.console.print()
350
+ self.console.print(" 1. 🌐 Set Host IP")
351
+ self.console.print(" 2. 🔌 Set Port")
352
+ self.console.print(" 3. 🔑 Set Proxy API Key")
353
+ self.console.print(" 4. 📝 Toggle Request Logging")
354
+ self.console.print(" 5. ↩️ Back to Main Menu")
355
+
356
+ self.console.print()
357
+ self.console.print("━" * 70)
358
+ self.console.print()
359
+
360
+ choice = Prompt.ask("Select option", choices=["1", "2", "3", "4", "5"], show_choices=False)
361
+
362
+ if choice == "1":
363
+ new_host = Prompt.ask("Enter new host IP", default=self.config.config["host"])
364
+ self.config.update(host=new_host)
365
+ self.console.print(f"\n[green]✅ Host updated to: {new_host}[/green]")
366
+ elif choice == "2":
367
+ new_port = IntPrompt.ask("Enter new port", default=self.config.config["port"])
368
+ if 1 <= new_port <= 65535:
369
+ self.config.update(port=new_port)
370
+ self.console.print(f"\n[green]✅ Port updated to: {new_port}[/green]")
371
+ else:
372
+ self.console.print("\n[red]❌ Port must be between 1-65535[/red]")
373
+ elif choice == "3":
374
+ current = os.getenv("PROXY_API_KEY", "")
375
+ new_key = Prompt.ask("Enter new Proxy API Key", default=current)
376
+ if new_key and new_key != current:
377
+ LauncherConfig.update_proxy_api_key(new_key)
378
+ self.console.print("\n[green]✅ Proxy API Key updated successfully![/green]")
379
+ self.console.print(" Updated in .env file")
380
+ else:
381
+ self.console.print("\n[yellow]No changes made[/yellow]")
382
+ elif choice == "4":
383
+ current = self.config.config["enable_request_logging"]
384
+ self.config.update(enable_request_logging=not current)
385
+ self.console.print(f"\n[green]✅ Request Logging {'enabled' if not current else 'disabled'}![/green]")
386
+ elif choice == "5":
387
+ break
388
+
389
+ def show_provider_settings_menu(self):
390
+ """Display provider/advanced settings (read-only + launch tool)"""
391
+ self.console.clear()
392
+
393
+ settings = SettingsDetector.get_all_settings()
394
+ credentials = settings["credentials"]
395
+ custom_bases = settings["custom_bases"]
396
+ model_defs = settings["model_definitions"]
397
+ concurrency = settings["concurrency_limits"]
398
+ filters = settings["model_filters"]
399
+
400
+ self.console.print(Panel.fit(
401
+ "[bold cyan]📊 Provider & Advanced Settings[/bold cyan]",
402
+ border_style="cyan"
403
+ ))
404
+
405
+ # Configured Providers
406
+ self.console.print()
407
+ self.console.print("[bold]📊 Configured Providers[/bold]")
408
+ self.console.print("━" * 70)
409
+ if credentials:
410
+ for provider, info in credentials.items():
411
+ provider_name = provider.title()
412
+ parts = []
413
+ if info["api_keys"] > 0:
414
+ parts.append(f"{info['api_keys']} API key{'s' if info['api_keys'] > 1 else ''}")
415
+ if info["oauth"] > 0:
416
+ parts.append(f"{info['oauth']} OAuth credential{'s' if info['oauth'] > 1 else ''}")
417
+
418
+ display = " + ".join(parts)
419
+ if info["custom"]:
420
+ display += " (Custom)"
421
+
422
+ self.console.print(f" ✅ {provider_name:20} {display}")
423
+ else:
424
+ self.console.print(" [dim]No providers configured[/dim]")
425
+
426
+ # Custom API Bases
427
+ if custom_bases:
428
+ self.console.print()
429
+ self.console.print("[bold]🌐 Custom API Bases[/bold]")
430
+ self.console.print("━" * 70)
431
+ for provider, base in custom_bases.items():
432
+ self.console.print(f" • {provider:15} {base}")
433
+
434
+ # Model Definitions
435
+ if model_defs:
436
+ self.console.print()
437
+ self.console.print("[bold]📦 Provider Model Definitions[/bold]")
438
+ self.console.print("━" * 70)
439
+ for provider, count in model_defs.items():
440
+ self.console.print(f" • {provider:15} {count} model{'s' if count > 1 else ''} configured")
441
+
442
+ # Concurrency Limits
443
+ if concurrency:
444
+ self.console.print()
445
+ self.console.print("[bold]⚡ Concurrency Limits[/bold]")
446
+ self.console.print("━" * 70)
447
+ for provider, limit in concurrency.items():
448
+ self.console.print(f" • {provider:15} {limit} requests/key")
449
+ self.console.print(f" • Default: 1 request/key (all others)")
450
+
451
+ # Model Filters (basic info only)
452
+ if filters:
453
+ self.console.print()
454
+ self.console.print("[bold]🎯 Model Filters[/bold]")
455
+ self.console.print("━" * 70)
456
+ for provider, filter_info in filters.items():
457
+ status_parts = []
458
+ if filter_info["has_whitelist"]:
459
+ status_parts.append("Whitelist")
460
+ if filter_info["has_ignore"]:
461
+ status_parts.append("Ignore list")
462
+ status = " + ".join(status_parts) if status_parts else "None"
463
+ self.console.print(f" • {provider:15} ✅ {status}")
464
+
465
+ # Actions
466
+ self.console.print()
467
+ self.console.print("━" * 70)
468
+ self.console.print()
469
+ self.console.print("[bold]💡 Actions[/bold]")
470
+ self.console.print()
471
+ self.console.print(" 1. 🔧 Launch Settings Tool (configure advanced settings)")
472
+ self.console.print(" 2. ↩️ Back to Main Menu")
473
+
474
+ self.console.print()
475
+ self.console.print("━" * 70)
476
+ self.console.print("[dim]ℹ️ Advanced settings are stored in .env file.\n Use the Settings Tool to configure them interactively.[/dim]")
477
+ self.console.print()
478
+ self.console.print("[dim]⚠️ Note: Settings Tool supports only common configuration types.\n For complex settings, edit .env directly.[/dim]")
479
+ self.console.print()
480
+
481
+ choice = Prompt.ask("Select option", choices=["1", "2"], show_choices=False)
482
+
483
+ if choice == "1":
484
+ self.launch_settings_tool()
485
+ # choice == "2" returns to main menu
486
+
487
+ def launch_credential_tool(self):
488
+ """Launch credential management tool"""
489
+ from rotator_library.credential_tool import run_credential_tool
490
+ run_credential_tool()
491
+ # Reload environment after credential tool
492
+ load_dotenv(dotenv_path=Path.cwd() / ".env", override=True)
493
+
494
+ def launch_settings_tool(self):
495
+ """Launch settings configuration tool"""
496
+ from proxy_app.settings_tool import run_settings_tool
497
+ run_settings_tool()
498
+ # Reload environment after settings tool
499
+ load_dotenv(dotenv_path=Path.cwd() / ".env", override=True)
500
+
501
+ def run_proxy(self):
502
+ """Prepare and launch proxy in same window"""
503
+ # Check if forced onboarding needed
504
+ if self.needs_onboarding():
505
+ self.console.clear()
506
+ self.console.print(Panel(
507
+ Text.from_markup(
508
+ "⚠️ [bold yellow]Setup Required[/bold yellow]\n\n"
509
+ "Cannot start without .env and PROXY_API_KEY.\n"
510
+ "Launching credential tool..."
511
+ ),
512
+ border_style="yellow"
513
+ ))
514
+
515
+ # Force credential tool
516
+ from rotator_library.credential_tool import ensure_env_defaults, run_credential_tool
517
+ ensure_env_defaults()
518
+ load_dotenv(dotenv_path=Path.cwd() / ".env", override=True)
519
+ run_credential_tool()
520
+ load_dotenv(dotenv_path=Path.cwd() / ".env", override=True)
521
+
522
+ # Check again after credential tool
523
+ if not os.getenv("PROXY_API_KEY"):
524
+ self.console.print("\n[red]❌ PROXY_API_KEY still not set. Cannot start proxy.[/red]")
525
+ return
526
+
527
+ # Clear console and modify sys.argv
528
+ self.console.clear()
529
+ self.console.print(f"\n[bold green]🚀 Starting proxy on {self.config.config['host']}:{self.config.config['port']}...[/bold green]\n")
530
+
531
+ # Reconstruct sys.argv for main.py
532
+ sys.argv = [
533
+ "main.py",
534
+ "--host", self.config.config["host"],
535
+ "--port", str(self.config.config["port"])
536
+ ]
537
+ if self.config.config["enable_request_logging"]:
538
+ sys.argv.append("--enable-request-logging")
539
+
540
+ # Exit TUI - main.py will continue execution
541
+ self.running = False
542
+
543
+
544
+ def run_launcher_tui():
545
+ """Entry point for launcher TUI"""
546
+ tui = LauncherTUI()
547
+ tui.run()
src/proxy_app/main.py CHANGED
@@ -761,6 +761,16 @@ if __name__ == "__main__":
761
  # Define ENV_FILE for onboarding checks
762
  ENV_FILE = Path.cwd() / ".env"
763
 
 
 
 
 
 
 
 
 
 
 
764
  def needs_onboarding() -> bool:
765
  """
766
  Check if the proxy needs onboarding (first-time setup).
 
761
  # Define ENV_FILE for onboarding checks
762
  ENV_FILE = Path.cwd() / ".env"
763
 
764
+ # Check if launcher TUI should be shown (no arguments provided)
765
+ if len(sys.argv) == 1:
766
+ # No arguments - show launcher TUI
767
+ from proxy_app.launcher_tui import run_launcher_tui
768
+ run_launcher_tui()
769
+ # Launcher modifies sys.argv and returns, or exits if user chose Exit
770
+ # If we get here, user chose "Run Proxy" and sys.argv is modified
771
+ # Re-parse arguments with modified sys.argv
772
+ args = parser.parse_args()
773
+
774
  def needs_onboarding() -> bool:
775
  """
776
  Check if the proxy needs onboarding (first-time setup).
src/proxy_app/settings_tool.py ADDED
@@ -0,0 +1,790 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Advanced settings configuration tool for the LLM API Key Proxy.
3
+ Provides interactive configuration for custom providers, model definitions, and concurrency limits.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Dict, Any, Optional, List
10
+ from rich.console import Console
11
+ from rich.prompt import Prompt, IntPrompt, Confirm
12
+ from rich.panel import Panel
13
+ from dotenv import set_key, unset_key
14
+
15
+ console = Console()
16
+
17
+
18
+ class AdvancedSettings:
19
+ """Manages pending changes to .env"""
20
+
21
+ def __init__(self):
22
+ self.env_file = Path.cwd() / ".env"
23
+ self.pending_changes = {} # key -> value (None means delete)
24
+ self.load_current_settings()
25
+
26
+ def load_current_settings(self):
27
+ """Load current .env values into env vars"""
28
+ from dotenv import load_dotenv
29
+ load_dotenv(override=True)
30
+
31
+ def set(self, key: str, value: str):
32
+ """Stage a change"""
33
+ self.pending_changes[key] = value
34
+
35
+ def remove(self, key: str):
36
+ """Stage a removal"""
37
+ self.pending_changes[key] = None
38
+
39
+ def save(self):
40
+ """Write pending changes to .env"""
41
+ for key, value in self.pending_changes.items():
42
+ if value is None:
43
+ # Remove key
44
+ unset_key(str(self.env_file), key)
45
+ else:
46
+ # Set key
47
+ set_key(str(self.env_file), key, value)
48
+
49
+ self.pending_changes.clear()
50
+ self.load_current_settings()
51
+
52
+ def discard(self):
53
+ """Discard pending changes"""
54
+ self.pending_changes.clear()
55
+
56
+ def has_pending(self) -> bool:
57
+ """Check if there are pending changes"""
58
+ return bool(self.pending_changes)
59
+
60
+
61
+ class CustomProviderManager:
62
+ """Manages custom provider API bases"""
63
+
64
+ def __init__(self, settings: AdvancedSettings):
65
+ self.settings = settings
66
+
67
+ def get_current_providers(self) -> Dict[str, str]:
68
+ """Get currently configured custom providers"""
69
+ from proxy_app.provider_urls import PROVIDER_URL_MAP
70
+
71
+ providers = {}
72
+ for key, value in os.environ.items():
73
+ if key.endswith("_API_BASE"):
74
+ provider = key.replace("_API_BASE", "").lower()
75
+ # Only include if NOT in hardcoded map
76
+ if provider not in PROVIDER_URL_MAP:
77
+ providers[provider] = value
78
+ return providers
79
+
80
+ def add_provider(self, name: str, api_base: str):
81
+ """Add PROVIDER_API_BASE"""
82
+ key = f"{name.upper()}_API_BASE"
83
+ self.settings.set(key, api_base)
84
+
85
+ def edit_provider(self, name: str, api_base: str):
86
+ """Edit PROVIDER_API_BASE"""
87
+ self.add_provider(name, api_base)
88
+
89
+ def remove_provider(self, name: str):
90
+ """Remove PROVIDER_API_BASE"""
91
+ key = f"{name.upper()}_API_BASE"
92
+ self.settings.remove(key)
93
+
94
+
95
+ class ModelDefinitionManager:
96
+ """Manages PROVIDER_MODELS"""
97
+
98
+ def __init__(self, settings: AdvancedSettings):
99
+ self.settings = settings
100
+
101
+ def get_current_provider_models(self, provider: str) -> Optional[Dict]:
102
+ """Get currently configured models for a provider"""
103
+ key = f"{provider.upper()}_MODELS"
104
+ value = os.getenv(key)
105
+ if value:
106
+ try:
107
+ return json.loads(value)
108
+ except (json.JSONDecodeError, ValueError):
109
+ return None
110
+ return None
111
+
112
+ def get_all_providers_with_models(self) -> Dict[str, int]:
113
+ """Get all providers with model definitions"""
114
+ providers = {}
115
+ for key, value in os.environ.items():
116
+ if key.endswith("_MODELS"):
117
+ provider = key.replace("_MODELS", "").lower()
118
+ try:
119
+ parsed = json.loads(value)
120
+ if isinstance(parsed, dict):
121
+ providers[provider] = len(parsed)
122
+ elif isinstance(parsed, list):
123
+ providers[provider] = len(parsed)
124
+ except (json.JSONDecodeError, ValueError):
125
+ pass
126
+ return providers
127
+
128
+ def set_models(self, provider: str, models: Dict[str, Dict[str, Any]]):
129
+ """Set PROVIDER_MODELS"""
130
+ key = f"{provider.upper()}_MODELS"
131
+ value = json.dumps(models)
132
+ self.settings.set(key, value)
133
+
134
+ def remove_models(self, provider: str):
135
+ """Remove PROVIDER_MODELS"""
136
+ key = f"{provider.upper()}_MODELS"
137
+ self.settings.remove(key)
138
+
139
+
140
+ class ConcurrencyManager:
141
+ """Manages MAX_CONCURRENT_REQUESTS_PER_KEY_PROVIDER"""
142
+
143
+ def __init__(self, settings: AdvancedSettings):
144
+ self.settings = settings
145
+
146
+ def get_current_limits(self) -> Dict[str, int]:
147
+ """Get currently configured concurrency limits"""
148
+ limits = {}
149
+ for key, value in os.environ.items():
150
+ if key.startswith("MAX_CONCURRENT_REQUESTS_PER_KEY_"):
151
+ provider = key.replace("MAX_CONCURRENT_REQUESTS_PER_KEY_", "").lower()
152
+ try:
153
+ limits[provider] = int(value)
154
+ except (json.JSONDecodeError, ValueError):
155
+ pass
156
+ return limits
157
+
158
+ def set_limit(self, provider: str, limit: int):
159
+ """Set concurrency limit"""
160
+ key = f"MAX_CONCURRENT_REQUESTS_PER_KEY_{provider.upper()}"
161
+ self.settings.set(key, str(limit))
162
+
163
+ def remove_limit(self, provider: str):
164
+ """Remove concurrency limit (reset to default)"""
165
+ key = f"MAX_CONCURRENT_REQUESTS_PER_KEY_{provider.upper()}"
166
+ self.settings.remove(key)
167
+
168
+
169
+ class SettingsTool:
170
+ """Main settings tool TUI"""
171
+
172
+ def __init__(self):
173
+ self.console = Console()
174
+ self.settings = AdvancedSettings()
175
+ self.provider_mgr = CustomProviderManager(self.settings)
176
+ self.model_mgr = ModelDefinitionManager(self.settings)
177
+ self.concurrency_mgr = ConcurrencyManager(self.settings)
178
+ self.running = True
179
+
180
+ def get_available_providers(self) -> List[str]:
181
+ """Get list of providers that have credentials configured"""
182
+ env_file = Path.cwd() / ".env"
183
+ providers = set()
184
+
185
+ # Scan for providers with API keys from local .env
186
+ if env_file.exists():
187
+ try:
188
+ with open(env_file, 'r', encoding='utf-8') as f:
189
+ for line in f:
190
+ line = line.strip()
191
+ if "_API_KEY" in line and "PROXY_API_KEY" not in line and "=" in line:
192
+ provider = line.split("_API_KEY")[0].strip().lower()
193
+ providers.add(provider)
194
+ except (IOError, OSError):
195
+ pass
196
+
197
+ # Also check for OAuth providers from files
198
+ oauth_dir = Path("oauth_credentials")
199
+ if oauth_dir.exists():
200
+ for file in oauth_dir.glob("*_oauth_*.json"):
201
+ provider = file.name.split("_oauth_")[0]
202
+ providers.add(provider)
203
+
204
+ return sorted(list(providers))
205
+
206
+ def run(self):
207
+ """Main loop"""
208
+ while self.running:
209
+ self.show_main_menu()
210
+
211
+ def show_main_menu(self):
212
+ """Display settings categories"""
213
+ self.console.clear()
214
+
215
+ self.console.print(Panel.fit(
216
+ "[bold cyan]🔧 Advanced Settings Configuration[/bold cyan]",
217
+ border_style="cyan"
218
+ ))
219
+
220
+ self.console.print()
221
+ self.console.print("[bold]⚙️ Configuration Categories[/bold]")
222
+ self.console.print()
223
+ self.console.print(" 1. 🌐 Custom Provider API Bases")
224
+ self.console.print(" 2. 📦 Provider Model Definitions")
225
+ self.console.print(" 3. ⚡ Concurrency Limits")
226
+ self.console.print(" 4. 💾 Save & Exit")
227
+ self.console.print(" 5. 🚫 Exit Without Saving")
228
+
229
+ self.console.print()
230
+ self.console.print("━" * 70)
231
+
232
+ if self.settings.has_pending():
233
+ self.console.print("[yellow]ℹ️ Changes are pending until you select \"Save & Exit\"[/yellow]")
234
+ else:
235
+ self.console.print("[dim]ℹ️ No pending changes[/dim]")
236
+
237
+ self.console.print()
238
+ self.console.print("[dim]⚠️ Model filters not supported - edit .env for IGNORE_MODELS_* / WHITELIST_MODELS_*[/dim]")
239
+ self.console.print()
240
+
241
+ choice = Prompt.ask("Select option", choices=["1", "2", "3", "4", "5"], show_choices=False)
242
+
243
+ if choice == "1":
244
+ self.manage_custom_providers()
245
+ elif choice == "2":
246
+ self.manage_model_definitions()
247
+ elif choice == "3":
248
+ self.manage_concurrency_limits()
249
+ elif choice == "4":
250
+ self.save_and_exit()
251
+ elif choice == "5":
252
+ self.exit_without_saving()
253
+
254
+ def manage_custom_providers(self):
255
+ """Manage custom provider API bases"""
256
+ while True:
257
+ self.console.clear()
258
+
259
+ providers = self.provider_mgr.get_current_providers()
260
+
261
+ self.console.print(Panel.fit(
262
+ "[bold cyan]🌐 Custom Provider API Bases[/bold cyan]",
263
+ border_style="cyan"
264
+ ))
265
+
266
+ self.console.print()
267
+ self.console.print("[bold]📋 Configured Custom Providers[/bold]")
268
+ self.console.print("━" * 70)
269
+
270
+ if providers:
271
+ for name, base in providers.items():
272
+ self.console.print(f" • {name:15} {base}")
273
+ else:
274
+ self.console.print(" [dim]No custom providers configured[/dim]")
275
+
276
+ self.console.print()
277
+ self.console.print("━" * 70)
278
+ self.console.print()
279
+ self.console.print("[bold]⚙️ Actions[/bold]")
280
+ self.console.print()
281
+ self.console.print(" 1. ➕ Add New Custom Provider")
282
+ self.console.print(" 2. ✏️ Edit Existing Provider")
283
+ self.console.print(" 3. 🗑️ Remove Provider")
284
+ self.console.print(" 4. ↩️ Back to Settings Menu")
285
+
286
+ self.console.print()
287
+ self.console.print("━" * 70)
288
+ self.console.print()
289
+
290
+ choice = Prompt.ask("Select option", choices=["1", "2", "3", "4"], show_choices=False)
291
+
292
+ if choice == "1":
293
+ name = Prompt.ask("Provider name (e.g., 'opencode')").strip().lower()
294
+ if name:
295
+ api_base = Prompt.ask("API Base URL").strip()
296
+ if api_base:
297
+ self.provider_mgr.add_provider(name, api_base)
298
+ self.console.print(f"\n[green]✅ Custom provider '{name}' configured![/green]")
299
+ self.console.print(f" To use: set {name.upper()}_API_KEY in credentials")
300
+ input("\nPress Enter to continue...")
301
+
302
+ elif choice == "2":
303
+ if not providers:
304
+ self.console.print("\n[yellow]No providers to edit[/yellow]")
305
+ input("\nPress Enter to continue...")
306
+ continue
307
+
308
+ # Show numbered list
309
+ self.console.print("\n[bold]Select provider to edit:[/bold]")
310
+ providers_list = list(providers.keys())
311
+ for idx, prov in enumerate(providers_list, 1):
312
+ self.console.print(f" {idx}. {prov}")
313
+
314
+ choice_idx = IntPrompt.ask("Select option", choices=[str(i) for i in range(1, len(providers_list) + 1)])
315
+ name = providers_list[choice_idx - 1]
316
+ current_base = providers.get(name, "")
317
+
318
+ self.console.print(f"\nCurrent API Base: {current_base}")
319
+ new_base = Prompt.ask("New API Base [press Enter to keep current]", default=current_base).strip()
320
+
321
+ if new_base and new_base != current_base:
322
+ self.provider_mgr.edit_provider(name, new_base)
323
+ self.console.print(f"\n[green]✅ Custom provider '{name}' updated![/green]")
324
+ else:
325
+ self.console.print("\n[yellow]No changes made[/yellow]")
326
+ input("\nPress Enter to continue...")
327
+
328
+ elif choice == "3":
329
+ if not providers:
330
+ self.console.print("\n[yellow]No providers to remove[/yellow]")
331
+ input("\nPress Enter to continue...")
332
+ continue
333
+
334
+ # Show numbered list
335
+ self.console.print("\n[bold]Select provider to remove:[/bold]")
336
+ providers_list = list(providers.keys())
337
+ for idx, prov in enumerate(providers_list, 1):
338
+ self.console.print(f" {idx}. {prov}")
339
+
340
+ choice_idx = IntPrompt.ask("Select option", choices=[str(i) for i in range(1, len(providers_list) + 1)])
341
+ name = providers_list[choice_idx - 1]
342
+
343
+ if Confirm.ask(f"Remove '{name}'?"):
344
+ self.provider_mgr.remove_provider(name)
345
+ self.console.print(f"\n[green]✅ Provider '{name}' removed![/green]")
346
+ input("\nPress Enter to continue...")
347
+
348
+ elif choice == "4":
349
+ break
350
+
351
+ def manage_model_definitions(self):
352
+ """Manage provider model definitions"""
353
+ while True:
354
+ self.console.clear()
355
+
356
+ all_providers = self.model_mgr.get_all_providers_with_models()
357
+
358
+ self.console.print(Panel.fit(
359
+ "[bold cyan]📦 Provider Model Definitions[/bold cyan]",
360
+ border_style="cyan"
361
+ ))
362
+
363
+ self.console.print()
364
+ self.console.print("[bold]📋 Configured Provider Models[/bold]")
365
+ self.console.print("━" * 70)
366
+
367
+ if all_providers:
368
+ for provider, count in all_providers.items():
369
+ self.console.print(f" • {provider:15} {count} model{'s' if count > 1 else ''}")
370
+ else:
371
+ self.console.print(" [dim]No model definitions configured[/dim]")
372
+
373
+ self.console.print()
374
+ self.console.print("━" * 70)
375
+ self.console.print()
376
+ self.console.print("[bold]⚙️ Actions[/bold]")
377
+ self.console.print()
378
+ self.console.print(" 1. ➕ Add Models for Provider")
379
+ self.console.print(" 2. ✏️ Edit Provider Models")
380
+ self.console.print(" 3. 👁️ View Provider Models")
381
+ self.console.print(" 4. 🗑️ Remove Provider Models")
382
+ self.console.print(" 5. ↩️ Back to Settings Menu")
383
+
384
+ self.console.print()
385
+ self.console.print("━" * 70)
386
+ self.console.print()
387
+
388
+ choice = Prompt.ask("Select option", choices=["1", "2", "3", "4", "5"], show_choices=False)
389
+
390
+ if choice == "1":
391
+ self.add_model_definitions()
392
+ elif choice == "2":
393
+ if not all_providers:
394
+ self.console.print("\n[yellow]No providers to edit[/yellow]")
395
+ input("\nPress Enter to continue...")
396
+ continue
397
+ self.edit_model_definitions(list(all_providers.keys()))
398
+ elif choice == "3":
399
+ if not all_providers:
400
+ self.console.print("\n[yellow]No providers to view[/yellow]")
401
+ input("\nPress Enter to continue...")
402
+ continue
403
+ self.view_model_definitions(list(all_providers.keys()))
404
+ elif choice == "4":
405
+ if not all_providers:
406
+ self.console.print("\n[yellow]No providers to remove[/yellow]")
407
+ input("\nPress Enter to continue...")
408
+ continue
409
+
410
+ # Show numbered list
411
+ self.console.print("\n[bold]Select provider to remove models from:[/bold]")
412
+ providers_list = list(all_providers.keys())
413
+ for idx, prov in enumerate(providers_list, 1):
414
+ self.console.print(f" {idx}. {prov}")
415
+
416
+ choice_idx = IntPrompt.ask("Select option", choices=[str(i) for i in range(1, len(providers_list) + 1)])
417
+ provider = providers_list[choice_idx - 1]
418
+
419
+ if Confirm.ask(f"Remove all model definitions for '{provider}'?"):
420
+ self.model_mgr.remove_models(provider)
421
+ self.console.print(f"\n[green]✅ Model definitions removed for '{provider}'![/green]")
422
+ input("\nPress Enter to continue...")
423
+ elif choice == "5":
424
+ break
425
+
426
+ def add_model_definitions(self):
427
+ """Add model definitions for a provider"""
428
+ # Get available providers from credentials
429
+ available_providers = self.get_available_providers()
430
+
431
+ if not available_providers:
432
+ self.console.print("\n[yellow]No providers with credentials found. Please add credentials first.[/yellow]")
433
+ input("\nPress Enter to continue...")
434
+ return
435
+
436
+ # Show provider selection menu
437
+ self.console.print("\n[bold]Select provider:[/bold]")
438
+ for idx, prov in enumerate(available_providers, 1):
439
+ self.console.print(f" {idx}. {prov}")
440
+ self.console.print(f" {len(available_providers) + 1}. Enter custom provider name")
441
+
442
+ choice = IntPrompt.ask("Select option", choices=[str(i) for i in range(1, len(available_providers) + 2)])
443
+
444
+ if choice == len(available_providers) + 1:
445
+ provider = Prompt.ask("Provider name").strip().lower()
446
+ else:
447
+ provider = available_providers[choice - 1]
448
+
449
+ if not provider:
450
+ return
451
+
452
+ self.console.print("\nHow would you like to define models?")
453
+ self.console.print(" 1. Simple list (names only)")
454
+ self.console.print(" 2. Advanced (names with IDs and options)")
455
+
456
+ mode = Prompt.ask("Select mode", choices=["1", "2"], show_choices=False)
457
+
458
+ models = {}
459
+
460
+ if mode == "1":
461
+ # Simple mode
462
+ while True:
463
+ name = Prompt.ask("\nModel name (or 'done' to finish)").strip()
464
+ if name.lower() == "done":
465
+ break
466
+ if name:
467
+ models[name] = {}
468
+ else:
469
+ # Advanced mode
470
+ while True:
471
+ name = Prompt.ask("\nModel name (or 'done' to finish)").strip()
472
+ if name.lower() == "done":
473
+ break
474
+ if name:
475
+ model_def = {}
476
+ model_id = Prompt.ask(f"Model ID [press Enter to use '{name}']", default=name).strip()
477
+ if model_id and model_id != name:
478
+ model_def["id"] = model_id
479
+
480
+ # Optional: model options
481
+ if Confirm.ask("Add model options (e.g., temperature limits)?", default=False):
482
+ self.console.print("\nEnter options as key=value pairs (one per line, 'done' to finish):")
483
+ options = {}
484
+ while True:
485
+ opt = Prompt.ask("Option").strip()
486
+ if opt.lower() == "done":
487
+ break
488
+ if "=" in opt:
489
+ key, value = opt.split("=", 1)
490
+ value = value.strip()
491
+ # Try to convert to number if possible
492
+ try:
493
+ value = float(value) if "." in value else int(value)
494
+ except (ValueError, TypeError):
495
+ pass
496
+ options[key.strip()] = value
497
+ if options:
498
+ model_def["options"] = options
499
+
500
+ models[name] = model_def
501
+
502
+ if models:
503
+ self.model_mgr.set_models(provider, models)
504
+ self.console.print(f"\n[green]✅ Model definitions saved for '{provider}'![/green]")
505
+ else:
506
+ self.console.print("\n[yellow]No models added[/yellow]")
507
+
508
+ input("\nPress Enter to continue...")
509
+
510
+ def edit_model_definitions(self, providers: List[str]):
511
+ """Edit existing model definitions"""
512
+ # Show numbered list
513
+ self.console.print("\n[bold]Select provider to edit:[/bold]")
514
+ for idx, prov in enumerate(providers, 1):
515
+ self.console.print(f" {idx}. {prov}")
516
+
517
+ choice_idx = IntPrompt.ask("Select option", choices=[str(i) for i in range(1, len(providers) + 1)])
518
+ provider = providers[choice_idx - 1]
519
+
520
+ current_models = self.model_mgr.get_current_provider_models(provider)
521
+ if not current_models:
522
+ self.console.print(f"\n[yellow]No models found for '{provider}'[/yellow]")
523
+ input("\nPress Enter to continue...")
524
+ return
525
+
526
+ # Convert to dict if list
527
+ if isinstance(current_models, list):
528
+ current_models = {m: {} for m in current_models}
529
+
530
+ while True:
531
+ self.console.clear()
532
+ self.console.print(f"[bold]Editing models for: {provider}[/bold]\n")
533
+ self.console.print("Current models:")
534
+ for i, (name, definition) in enumerate(current_models.items(), 1):
535
+ model_id = definition.get("id", name) if isinstance(definition, dict) else name
536
+ self.console.print(f" {i}. {name} (ID: {model_id})")
537
+
538
+ self.console.print("\nOptions:")
539
+ self.console.print(" 1. Add new model")
540
+ self.console.print(" 2. Edit existing model")
541
+ self.console.print(" 3. Remove model")
542
+ self.console.print(" 4. Done")
543
+
544
+ choice = Prompt.ask("\nSelect option", choices=["1", "2", "3", "4"], show_choices=False)
545
+
546
+ if choice == "1":
547
+ name = Prompt.ask("New model name").strip()
548
+ if name and name not in current_models:
549
+ model_id = Prompt.ask("Model ID", default=name).strip()
550
+ current_models[name] = {"id": model_id} if model_id != name else {}
551
+
552
+ elif choice == "2":
553
+ # Show numbered list
554
+ models_list = list(current_models.keys())
555
+ self.console.print("\n[bold]Select model to edit:[/bold]")
556
+ for idx, model_name in enumerate(models_list, 1):
557
+ self.console.print(f" {idx}. {model_name}")
558
+
559
+ model_idx = IntPrompt.ask("Select option", choices=[str(i) for i in range(1, len(models_list) + 1)])
560
+ name = models_list[model_idx - 1]
561
+
562
+ current_def = current_models[name]
563
+ current_id = current_def.get("id", name) if isinstance(current_def, dict) else name
564
+
565
+ new_id = Prompt.ask("Model ID", default=current_id).strip()
566
+ current_models[name] = {"id": new_id} if new_id != name else {}
567
+
568
+ elif choice == "3":
569
+ # Show numbered list
570
+ models_list = list(current_models.keys())
571
+ self.console.print("\n[bold]Select model to remove:[/bold]")
572
+ for idx, model_name in enumerate(models_list, 1):
573
+ self.console.print(f" {idx}. {model_name}")
574
+
575
+ model_idx = IntPrompt.ask("Select option", choices=[str(i) for i in range(1, len(models_list) + 1)])
576
+ name = models_list[model_idx - 1]
577
+
578
+ if Confirm.ask(f"Remove '{name}'?"):
579
+ del current_models[name]
580
+
581
+ elif choice == "4":
582
+ break
583
+
584
+ if current_models:
585
+ self.model_mgr.set_models(provider, current_models)
586
+ self.console.print(f"\n[green]✅ Models updated for '{provider}'![/green]")
587
+ else:
588
+ self.console.print("\n[yellow]No models left - removing definition[/yellow]")
589
+ self.model_mgr.remove_models(provider)
590
+
591
+ input("\nPress Enter to continue...")
592
+
593
+ def view_model_definitions(self, providers: List[str]):
594
+ """View model definitions for a provider"""
595
+ # Show numbered list
596
+ self.console.print("\n[bold]Select provider to view:[/bold]")
597
+ for idx, prov in enumerate(providers, 1):
598
+ self.console.print(f" {idx}. {prov}")
599
+
600
+ choice_idx = IntPrompt.ask("Select option", choices=[str(i) for i in range(1, len(providers) + 1)])
601
+ provider = providers[choice_idx - 1]
602
+
603
+ models = self.model_mgr.get_current_provider_models(provider)
604
+ if not models:
605
+ self.console.print(f"\n[yellow]No models found for '{provider}'[/yellow]")
606
+ input("\nPress Enter to continue...")
607
+ return
608
+
609
+ self.console.clear()
610
+ self.console.print(f"[bold]Provider: {provider}[/bold]\n")
611
+ self.console.print("[bold]📦 Configured Models:[/bold]")
612
+ self.console.print("━" * 50)
613
+
614
+ # Handle both dict and list formats
615
+ if isinstance(models, dict):
616
+ for name, definition in models.items():
617
+ if isinstance(definition, dict):
618
+ model_id = definition.get("id", name)
619
+ self.console.print(f" Name: {name}")
620
+ self.console.print(f" ID: {model_id}")
621
+ if "options" in definition:
622
+ self.console.print(f" Options: {definition['options']}")
623
+ self.console.print()
624
+ else:
625
+ self.console.print(f" Name: {name}")
626
+ self.console.print()
627
+ elif isinstance(models, list):
628
+ for name in models:
629
+ self.console.print(f" Name: {name}")
630
+ self.console.print()
631
+
632
+ input("Press Enter to return...")
633
+
634
+ def manage_concurrency_limits(self):
635
+ """Manage concurrency limits"""
636
+ while True:
637
+ self.console.clear()
638
+
639
+ limits = self.concurrency_mgr.get_current_limits()
640
+
641
+ self.console.print(Panel.fit(
642
+ "[bold cyan]⚡ Concurrency Limits Configuration[/bold cyan]",
643
+ border_style="cyan"
644
+ ))
645
+
646
+ self.console.print()
647
+ self.console.print("[bold]📋 Current Concurrency Settings[/bold]")
648
+ self.console.print("━" * 70)
649
+
650
+ if limits:
651
+ for provider, limit in limits.items():
652
+ self.console.print(f" • {provider:15} {limit} requests/key")
653
+ self.console.print(f" • Default: 1 request/key (all others)")
654
+ else:
655
+ self.console.print(" • Default: 1 request/key (all providers)")
656
+
657
+ self.console.print()
658
+ self.console.print("━" * 70)
659
+ self.console.print()
660
+ self.console.print("[bold]⚙️ Actions[/bold]")
661
+ self.console.print()
662
+ self.console.print(" 1. ➕ Add Concurrency Limit for Provider")
663
+ self.console.print(" 2. ✏️ Edit Existing Limit")
664
+ self.console.print(" 3. 🗑️ Remove Limit (reset to default)")
665
+ self.console.print(" 4. ↩️ Back to Settings Menu")
666
+
667
+ self.console.print()
668
+ self.console.print("━" * 70)
669
+ self.console.print()
670
+
671
+ choice = Prompt.ask("Select option", choices=["1", "2", "3", "4"], show_choices=False)
672
+
673
+ if choice == "1":
674
+ # Get available providers
675
+ available_providers = self.get_available_providers()
676
+
677
+ if not available_providers:
678
+ self.console.print("\n[yellow]No providers with credentials found. Please add credentials first.[/yellow]")
679
+ input("\nPress Enter to continue...")
680
+ continue
681
+
682
+ # Show provider selection menu
683
+ self.console.print("\n[bold]Select provider:[/bold]")
684
+ for idx, prov in enumerate(available_providers, 1):
685
+ self.console.print(f" {idx}. {prov}")
686
+ self.console.print(f" {len(available_providers) + 1}. Enter custom provider name")
687
+
688
+ choice_idx = IntPrompt.ask("Select option", choices=[str(i) for i in range(1, len(available_providers) + 2)])
689
+
690
+ if choice_idx == len(available_providers) + 1:
691
+ provider = Prompt.ask("Provider name").strip().lower()
692
+ else:
693
+ provider = available_providers[choice_idx - 1]
694
+
695
+ if provider:
696
+ limit = IntPrompt.ask("Max concurrent requests per key (1-100)", default=1)
697
+ if 1 <= limit <= 100:
698
+ self.concurrency_mgr.set_limit(provider, limit)
699
+ self.console.print(f"\n[green]✅ Concurrency limit set for '{provider}': {limit} requests/key[/green]")
700
+ else:
701
+ self.console.print("\n[red]❌ Limit must be between 1-100[/red]")
702
+ input("\nPress Enter to continue...")
703
+
704
+ elif choice == "2":
705
+ if not limits:
706
+ self.console.print("\n[yellow]No limits to edit[/yellow]")
707
+ input("\nPress Enter to continue...")
708
+ continue
709
+
710
+ # Show numbered list
711
+ self.console.print("\n[bold]Select provider to edit:[/bold]")
712
+ limits_list = list(limits.keys())
713
+ for idx, prov in enumerate(limits_list, 1):
714
+ self.console.print(f" {idx}. {prov}")
715
+
716
+ choice_idx = IntPrompt.ask("Select option", choices=[str(i) for i in range(1, len(limits_list) + 1)])
717
+ provider = limits_list[choice_idx - 1]
718
+ current_limit = limits.get(provider, 1)
719
+
720
+ self.console.print(f"\nCurrent limit: {current_limit} requests/key")
721
+ new_limit = IntPrompt.ask("New limit (1-100) [press Enter to keep current]", default=current_limit)
722
+
723
+ if 1 <= new_limit <= 100:
724
+ if new_limit != current_limit:
725
+ self.concurrency_mgr.set_limit(provider, new_limit)
726
+ self.console.print(f"\n[green]✅ Concurrency limit updated for '{provider}': {new_limit} requests/key[/green]")
727
+ else:
728
+ self.console.print("\n[yellow]No changes made[/yellow]")
729
+ else:
730
+ self.console.print("\n[red]Limit must be between 1-100[/red]")
731
+ input("\nPress Enter to continue...")
732
+
733
+ elif choice == "3":
734
+ if not limits:
735
+ self.console.print("\n[yellow]No limits to remove[/yellow]")
736
+ input("\nPress Enter to continue...")
737
+ continue
738
+
739
+ # Show numbered list
740
+ self.console.print("\n[bold]Select provider to remove limit from:[/bold]")
741
+ limits_list = list(limits.keys())
742
+ for idx, prov in enumerate(limits_list, 1):
743
+ self.console.print(f" {idx}. {prov}")
744
+
745
+ choice_idx = IntPrompt.ask("Select option", choices=[str(i) for i in range(1, len(limits_list) + 1)])
746
+ provider = limits_list[choice_idx - 1]
747
+
748
+ if Confirm.ask(f"Remove concurrency limit for '{provider}' (reset to default 1)?"):
749
+ self.concurrency_mgr.remove_limit(provider)
750
+ self.console.print(f"\n[green]✅ Limit removed for '{provider}' - using default (1 request/key)[/green]")
751
+ input("\nPress Enter to continue...")
752
+
753
+ elif choice == "4":
754
+ break
755
+
756
+ def save_and_exit(self):
757
+ """Save pending changes and exit"""
758
+ if self.settings.has_pending():
759
+ if Confirm.ask("\n[bold yellow]Save all pending changes?[/bold yellow]"):
760
+ self.settings.save()
761
+ self.console.print("\n[green]✅ All changes saved to .env![/green]")
762
+ input("\nPress Enter to return to launcher...")
763
+ else:
764
+ self.console.print("\n[yellow]Changes not saved[/yellow]")
765
+ input("\nPress Enter to continue...")
766
+ return
767
+ else:
768
+ self.console.print("\n[dim]No changes to save[/dim]")
769
+ input("\nPress Enter to return to launcher...")
770
+
771
+ self.running = False
772
+
773
+ def exit_without_saving(self):
774
+ """Exit without saving"""
775
+ if self.settings.has_pending():
776
+ if Confirm.ask("\n[bold red]Discard all pending changes?[/bold red]"):
777
+ self.settings.discard()
778
+ self.console.print("\n[yellow]Changes discarded[/yellow]")
779
+ input("\nPress Enter to return to launcher...")
780
+ self.running = False
781
+ else:
782
+ return
783
+ else:
784
+ self.running = False
785
+
786
+
787
+ def run_settings_tool():
788
+ """Entry point for settings tool"""
789
+ tool = SettingsTool()
790
+ tool.run()