Spaces:
Paused
Paused
File size: 40,791 Bytes
37501b2 682bbea d5acbd4 1ce8eba d5acbd4 c5716c1 1ce8eba d5acbd4 1ce8eba c5716c1 d5acbd4 c5716c1 1ce8eba 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 682bbea 37501b2 c5716c1 37501b2 682bbea 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 92211ea 37501b2 64859d9 c5716c1 37501b2 c5716c1 92211ea 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 64859d9 c5716c1 64859d9 c5716c1 64859d9 c5716c1 64859d9 c5716c1 64859d9 c5716c1 64859d9 c5716c1 64859d9 37501b2 c5716c1 37501b2 682bbea a42743f c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 1ce8eba c5716c1 92211ea 37501b2 c5716c1 37501b2 c5716c1 a42743f c5716c1 0fd5b85 37501b2 c5716c1 0fd5b85 c5716c1 37501b2 c5716c1 0fd5b85 c5716c1 0fd5b85 c5716c1 37501b2 92211ea c5716c1 92211ea c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 90d4836 8b4ff52 c5716c1 37501b2 c5716c1 8b4ff52 c5716c1 37501b2 8b4ff52 682bbea 37501b2 a42743f 8b4ff52 37501b2 c5716c1 37501b2 1ce8eba c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 1ce8eba c5716c1 92211ea 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 64859d9 c5716c1 37501b2 c5716c1 92211ea 64859d9 92211ea c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 2b065a1 c5716c1 2b065a1 1ce8eba c5716c1 2b065a1 c5716c1 2b065a1 c5716c1 2b065a1 c5716c1 2b065a1 c5716c1 2b065a1 c5716c1 2b065a1 37501b2 682bbea c5716c1 37501b2 92211ea c5716c1 37501b2 682bbea c5716c1 8b4ff52 a42743f 1ce8eba c5716c1 a42743f c5716c1 a42743f c5716c1 a42743f c5716c1 a42743f c5716c1 a42743f c5716c1 a42743f c5716c1 37501b2 1ce8eba c5716c1 37501b2 c5716c1 37501b2 682bbea 37501b2 682bbea c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 1ce8eba c5716c1 d5acbd4 a42743f c5716c1 d5acbd4 c5716c1 37501b2 c5716c1 37501b2 c5716c1 37501b2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 | """
Interactive TUI launcher for the LLM API Key Proxy.
Provides a beautiful Rich-based interface for configuration and execution.
"""
import json
import os
import sys
from pathlib import Path
from rich.console import Console
from rich.prompt import IntPrompt, Prompt
from rich.panel import Panel
from rich.text import Text
from dotenv import load_dotenv, set_key
console = Console()
def _get_env_file() -> Path:
"""
Get .env file path (lightweight - no heavy imports).
Returns:
Path to .env file - EXE directory if frozen, else current working directory
"""
if getattr(sys, "frozen", False):
# Running as PyInstaller EXE - use EXE's directory
return Path(sys.executable).parent / ".env"
# Running as script - use current working directory
return Path.cwd() / ".env"
def clear_screen(subtitle: str = ""):
"""
Cross-platform terminal clear with optional header.
Uses native OS commands instead of ANSI escape sequences:
- Windows (conhost & Windows Terminal): cls
- Unix-like systems (Linux, Mac): clear
Args:
subtitle: If provided, displays a header panel with this subtitle.
If empty/None, just clears the screen.
"""
os.system("cls" if os.name == "nt" else "clear")
if subtitle:
console.print(
Panel(
f"[bold cyan]{subtitle}[/bold cyan]",
title="--- API Key Proxy ---",
)
)
class LauncherConfig:
"""Manages launcher_config.json (host, port, logging only)"""
def __init__(self, config_path: Path = Path("launcher_config.json")):
self.config_path = config_path
self.defaults = {
"host": "127.0.0.1",
"port": 8000,
"enable_request_logging": False,
}
self.config = self.load()
def load(self) -> dict:
"""Load config from file or create with defaults."""
if self.config_path.exists():
try:
with open(self.config_path, "r") as f:
config = json.load(f)
# Merge with defaults for any missing keys
for key, value in self.defaults.items():
if key not in config:
config[key] = value
return config
except (json.JSONDecodeError, IOError):
return self.defaults.copy()
return self.defaults.copy()
def save(self):
"""Save current config to file."""
import datetime
self.config["last_updated"] = datetime.datetime.now().isoformat()
try:
with open(self.config_path, "w") as f:
json.dump(self.config, f, indent=2)
except IOError as e:
console.print(f"[red]Error saving config: {e}[/red]")
def update(self, **kwargs):
"""Update config values."""
self.config.update(kwargs)
self.save()
@staticmethod
def update_proxy_api_key(new_key: str):
"""Update PROXY_API_KEY in .env only"""
env_file = _get_env_file()
set_key(str(env_file), "PROXY_API_KEY", new_key)
load_dotenv(dotenv_path=env_file, override=True)
class SettingsDetector:
"""Detects settings from .env for display"""
@staticmethod
def _load_local_env() -> dict:
"""Load environment variables from local .env file only"""
env_file = _get_env_file()
env_dict = {}
if not env_file.exists():
return env_dict
try:
with open(env_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, _, value = line.partition("=")
key, value = key.strip(), value.strip()
if value and value[0] in ('"', "'") and value[-1] == value[0]:
value = value[1:-1]
env_dict[key] = value
except (IOError, OSError):
pass
return env_dict
@staticmethod
def get_all_settings() -> dict:
"""Returns comprehensive settings overview (includes provider_settings which triggers heavy imports)"""
return {
"credentials": SettingsDetector.detect_credentials(),
"custom_bases": SettingsDetector.detect_custom_api_bases(),
"model_definitions": SettingsDetector.detect_model_definitions(),
"concurrency_limits": SettingsDetector.detect_concurrency_limits(),
"model_filters": SettingsDetector.detect_model_filters(),
"provider_settings": SettingsDetector.detect_provider_settings(),
}
@staticmethod
def get_basic_settings() -> dict:
"""Returns basic settings overview without provider_settings (avoids heavy imports)"""
return {
"credentials": SettingsDetector.detect_credentials(),
"custom_bases": SettingsDetector.detect_custom_api_bases(),
"model_definitions": SettingsDetector.detect_model_definitions(),
"concurrency_limits": SettingsDetector.detect_concurrency_limits(),
"model_filters": SettingsDetector.detect_model_filters(),
}
@staticmethod
def detect_credentials() -> dict:
"""Detect API keys and OAuth credentials"""
from pathlib import Path
providers = {}
# Scan for API keys
env_vars = SettingsDetector._load_local_env()
for key, value in env_vars.items():
if "_API_KEY" in key and key != "PROXY_API_KEY":
provider = key.split("_API_KEY")[0].lower()
if provider not in providers:
providers[provider] = {"api_keys": 0, "oauth": 0, "custom": False}
providers[provider]["api_keys"] += 1
# Scan for OAuth credentials
oauth_dir = Path("oauth_credentials")
if oauth_dir.exists():
for file in oauth_dir.glob("*_oauth_*.json"):
provider = file.name.split("_oauth_")[0]
if provider not in providers:
providers[provider] = {"api_keys": 0, "oauth": 0, "custom": False}
providers[provider]["oauth"] += 1
# Mark custom providers (have API_BASE set)
for provider in providers:
if os.getenv(f"{provider.upper()}_API_BASE"):
providers[provider]["custom"] = True
return providers
@staticmethod
def detect_custom_api_bases() -> dict:
"""Detect custom API base URLs (not in hardcoded map)"""
from proxy_app.provider_urls import PROVIDER_URL_MAP
bases = {}
env_vars = SettingsDetector._load_local_env()
for key, value in env_vars.items():
if key.endswith("_API_BASE"):
provider = key.replace("_API_BASE", "").lower()
# Only include if NOT in hardcoded map
if provider not in PROVIDER_URL_MAP:
bases[provider] = value
return bases
@staticmethod
def detect_model_definitions() -> dict:
"""Detect provider model definitions"""
models = {}
env_vars = SettingsDetector._load_local_env()
for key, value in env_vars.items():
if key.endswith("_MODELS"):
provider = key.replace("_MODELS", "").lower()
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
models[provider] = len(parsed)
elif isinstance(parsed, list):
models[provider] = len(parsed)
except (json.JSONDecodeError, ValueError):
pass
return models
@staticmethod
def detect_concurrency_limits() -> dict:
"""Detect max concurrent requests per key"""
limits = {}
env_vars = SettingsDetector._load_local_env()
for key, value in env_vars.items():
if key.startswith("MAX_CONCURRENT_REQUESTS_PER_KEY_"):
provider = key.replace("MAX_CONCURRENT_REQUESTS_PER_KEY_", "").lower()
try:
limits[provider] = int(value)
except (json.JSONDecodeError, ValueError):
pass
return limits
@staticmethod
def detect_model_filters() -> dict:
"""Detect active model filters (basic info only: defined or not)"""
filters = {}
env_vars = SettingsDetector._load_local_env()
for key, value in env_vars.items():
if key.startswith("IGNORE_MODELS_") or key.startswith("WHITELIST_MODELS_"):
filter_type = "ignore" if key.startswith("IGNORE") else "whitelist"
provider = key.replace(f"{filter_type.upper()}_MODELS_", "").lower()
if provider not in filters:
filters[provider] = {"has_ignore": False, "has_whitelist": False}
if filter_type == "ignore":
filters[provider]["has_ignore"] = True
else:
filters[provider]["has_whitelist"] = True
return filters
@staticmethod
def detect_provider_settings() -> dict:
"""Detect provider-specific settings (Antigravity, Gemini CLI)"""
try:
from proxy_app.settings_tool import PROVIDER_SETTINGS_MAP
except ImportError:
# Fallback for direct execution or testing
from .settings_tool import PROVIDER_SETTINGS_MAP
provider_settings = {}
env_vars = SettingsDetector._load_local_env()
for provider, definitions in PROVIDER_SETTINGS_MAP.items():
modified_count = 0
for key, definition in definitions.items():
env_value = env_vars.get(key)
if env_value is not None:
# Check if value differs from default
default = definition.get("default")
setting_type = definition.get("type", "str")
try:
if setting_type == "bool":
current = env_value.lower() in ("true", "1", "yes")
elif setting_type == "int":
current = int(env_value)
else:
current = env_value
if current != default:
modified_count += 1
except (ValueError, AttributeError):
pass
if modified_count > 0:
provider_settings[provider] = modified_count
return provider_settings
class LauncherTUI:
"""Main launcher interface"""
def __init__(self):
self.console = Console()
self.config = LauncherConfig()
self.running = True
self.env_file = _get_env_file()
# Load .env file to ensure environment variables are available
load_dotenv(dotenv_path=self.env_file, override=True)
def needs_onboarding(self) -> bool:
"""Check if onboarding is needed"""
return not self.env_file.exists() or not os.getenv("PROXY_API_KEY")
def run(self):
"""Main TUI loop"""
while self.running:
self.show_main_menu()
def show_main_menu(self):
"""Display main menu and handle selection"""
clear_screen()
# Detect basic settings (excludes provider_settings to avoid heavy imports)
settings = SettingsDetector.get_basic_settings()
credentials = settings["credentials"]
custom_bases = settings["custom_bases"]
# Check if setup is needed
show_warning = self.needs_onboarding()
# Build title with GitHub link
self.console.print(
Panel.fit(
"[bold cyan]π LLM API Key Proxy - Interactive Launcher[/bold cyan]",
border_style="cyan",
)
)
self.console.print(
"[dim]GitHub: [blue underline]https://github.com/Mirrowel/LLM-API-Key-Proxy[/blue underline][/dim]"
)
# Show warning if .env file doesn't exist
if show_warning:
self.console.print()
self.console.print(
Panel(
Text.from_markup(
"β οΈ [bold yellow]INITIAL SETUP REQUIRED[/bold yellow]\n\n"
"The proxy needs initial configuration:\n"
" β No .env file found\n\n"
"Why this matters:\n"
" β’ The .env file stores your credentials and settings\n"
" β’ PROXY_API_KEY protects your proxy from unauthorized access\n"
" β’ Provider API keys enable LLM access\n\n"
"What to do:\n"
' 1. Select option "3. Manage Credentials" to launch the credential tool\n'
" 2. The tool will create .env and set up PROXY_API_KEY automatically\n"
" 3. You can add provider credentials (API keys or OAuth)\n\n"
"β οΈ Note: The credential tool adds PROXY_API_KEY by default.\n"
" You can remove it later if you want an unsecured proxy."
),
border_style="yellow",
expand=False,
)
)
# Show security warning if PROXY_API_KEY is missing (but .env exists)
elif not os.getenv("PROXY_API_KEY"):
self.console.print()
self.console.print(
Panel(
Text.from_markup(
"β οΈ [bold red]SECURITY WARNING: PROXY_API_KEY Not Set[/bold red]\n\n"
"Your proxy is currently UNSECURED!\n"
"Anyone can access it without authentication.\n\n"
"This is a serious security risk if your proxy is accessible\n"
"from the internet or untrusted networks.\n\n"
"π [bold]Recommended:[/bold] Set PROXY_API_KEY in .env file\n"
' Use option "2. Configure Proxy Settings" β "3. Set Proxy API Key"\n'
' or option "3. Manage Credentials"'
),
border_style="red",
expand=False,
)
)
# Show config
self.console.print()
self.console.print("[bold]π Proxy Configuration[/bold]")
self.console.print("β" * 70)
self.console.print(f" Host: {self.config.config['host']}")
self.console.print(f" Port: {self.config.config['port']}")
self.console.print(
f" Request Logging: {'β
Enabled' if self.config.config['enable_request_logging'] else 'β Disabled'}"
)
# Show actual API key value
proxy_key = os.getenv("PROXY_API_KEY")
if proxy_key:
self.console.print(f" Proxy API Key: {proxy_key}")
else:
self.console.print(" Proxy API Key: [red]Not Set (INSECURE!)[/red]")
# Show status summary
self.console.print()
self.console.print("[bold]π Status Summary[/bold]")
self.console.print("β" * 70)
provider_count = len(credentials)
custom_count = len(custom_bases)
self.console.print(f" Providers: {provider_count} configured")
self.console.print(f" Custom Providers: {custom_count} configured")
# Note: provider_settings detection is deferred to avoid heavy imports on startup
has_advanced = bool(
settings["model_definitions"]
or settings["concurrency_limits"]
or settings["model_filters"]
)
self.console.print(
f" Advanced Settings: {'Active (view in menu 4)' if has_advanced else 'None (view menu 4 for details)'}"
)
# Show menu
self.console.print()
self.console.print("β" * 70)
self.console.print()
self.console.print("[bold]π― Main Menu[/bold]")
self.console.print()
if show_warning:
self.console.print(" 1. βΆοΈ Run Proxy Server")
self.console.print(" 2. βοΈ Configure Proxy Settings")
self.console.print(
" 3. π Manage Credentials β¬
οΈ [bold yellow]Start here![/bold yellow]"
)
else:
self.console.print(" 1. βΆοΈ Run Proxy Server")
self.console.print(" 2. βοΈ Configure Proxy Settings")
self.console.print(" 3. π Manage Credentials")
self.console.print(" 4. π View Provider & Advanced Settings")
self.console.print(" 5. π View Quota & Usage Stats (Alpha)")
self.console.print(" 6. π Reload Configuration")
self.console.print(" 7. βΉοΈ About")
self.console.print(" 8. πͺ Exit")
self.console.print()
self.console.print("β" * 70)
self.console.print()
choice = Prompt.ask(
"Select option",
choices=["1", "2", "3", "4", "5", "6", "7", "8"],
show_choices=False,
)
if choice == "1":
self.run_proxy()
elif choice == "2":
self.show_config_menu()
elif choice == "3":
self.launch_credential_tool()
elif choice == "4":
self.show_provider_settings_menu()
elif choice == "5":
self.launch_quota_viewer()
elif choice == "6":
load_dotenv(dotenv_path=_get_env_file(), override=True)
self.config = LauncherConfig() # Reload config
self.console.print("\n[green]β
Configuration reloaded![/green]")
elif choice == "7":
self.show_about()
elif choice == "8":
self.running = False
sys.exit(0)
def confirm_setting_change(self, setting_name: str, warning_lines: list) -> bool:
"""
Display a warning and require Y/N (case-sensitive) confirmation.
Re-prompts until user enters exactly 'Y' or 'N'.
Returns True only if user enters 'Y'.
"""
clear_screen()
self.console.print()
self.console.print(
Panel(
Text.from_markup(
f"[bold yellow]β οΈ WARNING: You are about to change the {setting_name}[/bold yellow]\n\n"
+ "\n".join(warning_lines)
+ "\n\n[bold]If you are not sure about changing this - don't.[/bold]"
),
border_style="yellow",
expand=False,
)
)
while True:
response = Prompt.ask(
"Enter [bold]Y[/bold] to confirm, [bold]N[/bold] to cancel (case-sensitive)"
)
if response == "Y":
return True
elif response == "N":
self.console.print("\n[dim]Operation cancelled.[/dim]")
return False
else:
self.console.print(
"[red]Please enter exactly 'Y' or 'N' (case-sensitive)[/red]"
)
def show_config_menu(self):
"""Display configuration sub-menu"""
while True:
clear_screen()
self.console.print(
Panel.fit(
"[bold cyan]βοΈ Proxy Configuration[/bold cyan]", border_style="cyan"
)
)
self.console.print()
self.console.print("[bold]π Current Settings[/bold]")
self.console.print("β" * 70)
self.console.print(f" Host: {self.config.config['host']}")
self.console.print(f" Port: {self.config.config['port']}")
self.console.print(
f" Request Logging: {'β
Enabled' if self.config.config['enable_request_logging'] else 'β Disabled'}"
)
self.console.print(
f" Proxy API Key: {'β
Set' if os.getenv('PROXY_API_KEY') else 'β Not Set'}"
)
self.console.print()
self.console.print("β" * 70)
self.console.print()
self.console.print("[bold]βοΈ Configuration Options[/bold]")
self.console.print()
self.console.print(" 1. π Set Host IP")
self.console.print(" 2. π Set Port")
self.console.print(" 3. π Set Proxy API Key")
self.console.print(" 4. π Toggle Request Logging")
self.console.print(" 5. π Reset to Default Settings")
self.console.print(" 6. β©οΈ Back to Main Menu")
self.console.print()
self.console.print("β" * 70)
self.console.print()
choice = Prompt.ask(
"Select option",
choices=["1", "2", "3", "4", "5", "6"],
show_choices=False,
)
if choice == "1":
# Show warning and require confirmation
confirmed = self.confirm_setting_change(
"Host IP",
[
"Changing the host IP affects which network interfaces the proxy listens on:",
" β’ [cyan]127.0.0.1[/cyan] = Local access only (recommended for development)",
" β’ [cyan]0.0.0.0[/cyan] = Accessible from all network interfaces",
"",
"Applications configured to connect to the old host may fail to connect.",
],
)
if not confirmed:
continue
new_host = Prompt.ask(
"Enter new host IP", default=self.config.config["host"]
)
self.config.update(host=new_host)
self.console.print(f"\n[green]β
Host updated to: {new_host}[/green]")
elif choice == "2":
# Show warning and require confirmation
confirmed = self.confirm_setting_change(
"Port",
[
"Changing the port will affect all applications currently configured",
"to connect to your proxy on the existing port.",
"",
"Applications using the old port will fail to connect.",
],
)
if not confirmed:
continue
new_port = IntPrompt.ask(
"Enter new port", default=self.config.config["port"]
)
if 1 <= new_port <= 65535:
self.config.update(port=new_port)
self.console.print(
f"\n[green]β
Port updated to: {new_port}[/green]"
)
else:
self.console.print("\n[red]β Port must be between 1-65535[/red]")
elif choice == "3":
# Show warning and require confirmation
confirmed = self.confirm_setting_change(
"Proxy API Key",
[
"This is the authentication key that applications use to access your proxy.",
"",
"[bold red]β οΈ Changing this will BREAK all applications currently configured",
" with the existing API key![/bold red]",
"",
"[bold cyan]π‘ If you want to add provider API keys (OpenAI, Gemini, etc.),",
' go to "3. π Manage Credentials" in the main menu instead.[/bold cyan]',
],
)
if not confirmed:
continue
current = os.getenv("PROXY_API_KEY", "")
new_key = Prompt.ask(
"Enter new Proxy API Key (leave empty to disable authentication)",
default=current,
)
if new_key != current:
# If setting to empty, show additional warning
if not new_key:
self.console.print(
"\n[bold red]β οΈ Authentication will be DISABLED - anyone can access your proxy![/bold red]"
)
Prompt.ask("Press Enter to continue", default="")
LauncherConfig.update_proxy_api_key(new_key)
if new_key:
self.console.print(
"\n[green]β
Proxy API Key updated successfully![/green]"
)
self.console.print(" Updated in .env file")
else:
self.console.print(
"\n[yellow]β οΈ Proxy API Key cleared - authentication disabled![/yellow]"
)
self.console.print(" Updated in .env file")
else:
self.console.print("\n[yellow]No changes made[/yellow]")
elif choice == "4":
current = self.config.config["enable_request_logging"]
self.config.update(enable_request_logging=not current)
self.console.print(
f"\n[green]β
Request Logging {'enabled' if not current else 'disabled'}![/green]"
)
elif choice == "5":
# Reset to Default Settings
# Define defaults
default_host = "127.0.0.1"
default_port = 8000
default_logging = False
default_api_key = "VerysecretKey"
# Get current values
current_host = self.config.config["host"]
current_port = self.config.config["port"]
current_logging = self.config.config["enable_request_logging"]
current_api_key = os.getenv("PROXY_API_KEY", "")
# Build comparison table
warning_lines = [
"This will reset ALL proxy settings to their defaults:",
"",
"[bold] Setting Current Value β Default Value[/bold]",
" " + "β" * 62,
f" Host IP {current_host:20} β {default_host}",
f" Port {str(current_port):20} β {default_port}",
f" Request Logging {'Enabled':20} β Disabled"
if current_logging
else f" Request Logging {'Disabled':20} β Disabled",
f" Proxy API Key {current_api_key[:20]:20} β {default_api_key}",
"",
"[bold red]β οΈ This may break applications configured with current settings![/bold red]",
]
confirmed = self.confirm_setting_change(
"Settings (Reset to Defaults)", warning_lines
)
if not confirmed:
continue
# Apply defaults
self.config.update(
host=default_host,
port=default_port,
enable_request_logging=default_logging,
)
LauncherConfig.update_proxy_api_key(default_api_key)
self.console.print(
"\n[green]β
All settings have been reset to defaults![/green]"
)
self.console.print(f" Host: {default_host}")
self.console.print(f" Port: {default_port}")
self.console.print(f" Request Logging: Disabled")
self.console.print(f" Proxy API Key: {default_api_key}")
elif choice == "6":
break
def show_provider_settings_menu(self):
"""Display provider/advanced settings (read-only + launch tool)"""
clear_screen()
# Use basic settings to avoid heavy imports - provider_settings deferred to Settings Tool
settings = SettingsDetector.get_basic_settings()
credentials = settings["credentials"]
custom_bases = settings["custom_bases"]
model_defs = settings["model_definitions"]
concurrency = settings["concurrency_limits"]
filters = settings["model_filters"]
self.console.print(
Panel.fit(
"[bold cyan]π Provider & Advanced Settings[/bold cyan]",
border_style="cyan",
)
)
# Configured Providers
self.console.print()
self.console.print("[bold]π Configured Providers[/bold]")
self.console.print("β" * 70)
if credentials:
for provider, info in credentials.items():
provider_name = provider.title()
parts = []
if info["api_keys"] > 0:
parts.append(
f"{info['api_keys']} API key{'s' if info['api_keys'] > 1 else ''}"
)
if info["oauth"] > 0:
parts.append(
f"{info['oauth']} OAuth credential{'s' if info['oauth'] > 1 else ''}"
)
display = " + ".join(parts)
if info["custom"]:
display += " (Custom)"
self.console.print(f" β
{provider_name:20} {display}")
else:
self.console.print(" [dim]No providers configured[/dim]")
# Custom API Bases
if custom_bases:
self.console.print()
self.console.print("[bold]π Custom API Bases[/bold]")
self.console.print("β" * 70)
for provider, base in custom_bases.items():
self.console.print(f" β’ {provider:15} {base}")
# Model Definitions
if model_defs:
self.console.print()
self.console.print("[bold]π¦ Provider Model Definitions[/bold]")
self.console.print("β" * 70)
for provider, count in model_defs.items():
self.console.print(
f" β’ {provider:15} {count} model{'s' if count > 1 else ''} configured"
)
# Concurrency Limits
if concurrency:
self.console.print()
self.console.print("[bold]β‘ Concurrency Limits[/bold]")
self.console.print("β" * 70)
for provider, limit in concurrency.items():
self.console.print(f" β’ {provider:15} {limit} requests/key")
self.console.print(" β’ Default: 1 request/key (all others)")
# Model Filters (basic info only)
if filters:
self.console.print()
self.console.print("[bold]π― Model Filters[/bold]")
self.console.print("β" * 70)
for provider, filter_info in filters.items():
status_parts = []
if filter_info["has_whitelist"]:
status_parts.append("Whitelist")
if filter_info["has_ignore"]:
status_parts.append("Ignore list")
status = " + ".join(status_parts) if status_parts else "None"
self.console.print(f" β’ {provider:15} β
{status}")
# Provider-Specific Settings (deferred to Settings Tool to avoid heavy imports)
self.console.print()
self.console.print("[bold]π¬ Provider-Specific Settings[/bold]")
self.console.print("β" * 70)
self.console.print(
" [dim]Launch Settings Tool to view/configure provider-specific settings[/dim]"
)
# Actions
self.console.print()
self.console.print("β" * 70)
self.console.print()
self.console.print("[bold]π‘ Actions[/bold]")
self.console.print()
self.console.print(
" 1. π§ Launch Settings Tool (configure advanced settings)"
)
self.console.print(" 2. β©οΈ Back to Main Menu")
self.console.print()
self.console.print("β" * 70)
self.console.print(
"[dim]βΉοΈ Advanced settings are stored in .env file.\n Use the Settings Tool to configure them interactively.[/dim]"
)
self.console.print()
self.console.print(
"[dim]β οΈ Note: Settings Tool supports only common configuration types.\n For complex settings, edit .env directly.[/dim]"
)
self.console.print()
choice = Prompt.ask("Select option", choices=["1", "2"], show_choices=False)
if choice == "1":
self.launch_settings_tool()
# choice == "2" returns to main menu
def launch_credential_tool(self):
"""Launch credential management tool"""
import time
# CRITICAL: Show full loading UI to replace the 6-7 second blank wait
clear_screen()
_start_time = time.time()
# Show the same header as standalone mode
self.console.print("β" * 70)
self.console.print("Interactive Credential Setup Tool")
self.console.print("GitHub: https://github.com/Mirrowel/LLM-API-Key-Proxy")
self.console.print("β" * 70)
self.console.print("Loading credential management components...")
# Now import with spinner (this is where the 6-7 second delay happens)
with self.console.status("Initializing credential tool...", spinner="dots"):
from rotator_library.credential_tool import (
run_credential_tool,
_ensure_providers_loaded,
)
_, PROVIDER_PLUGINS = _ensure_providers_loaded()
self.console.print("β Credential tool initialized")
_elapsed = time.time() - _start_time
self.console.print(
f"β Tool ready in {_elapsed:.2f}s ({len(PROVIDER_PLUGINS)} providers available)"
)
# Small delay to let user see the ready message
time.sleep(0.5)
# Run the tool with from_launcher=True to skip duplicate loading screen
run_credential_tool(from_launcher=True)
# Reload environment after credential tool
load_dotenv(dotenv_path=_get_env_file(), override=True)
def launch_settings_tool(self):
"""Launch settings configuration tool"""
import time
clear_screen()
self.console.print("β" * 70)
self.console.print("Advanced Settings Configuration Tool")
self.console.print("β" * 70)
_start_time = time.time()
with self.console.status("Initializing settings tool...", spinner="dots"):
from proxy_app.settings_tool import run_settings_tool
_elapsed = time.time() - _start_time
self.console.print(f"β Settings tool ready in {_elapsed:.2f}s")
time.sleep(0.3)
run_settings_tool()
# Reload environment after settings tool
load_dotenv(dotenv_path=_get_env_file(), override=True)
def launch_quota_viewer(self):
"""Launch the quota stats viewer"""
clear_screen()
self.console.print("β" * 70)
self.console.print("Quota & Usage Statistics Viewer")
self.console.print("β" * 70)
self.console.print()
# Import the lightweight viewer (no heavy imports)
from proxy_app.quota_viewer import run_quota_viewer
run_quota_viewer()
def show_about(self):
"""Display About page with project information"""
clear_screen()
self.console.print(
Panel.fit(
"[bold cyan]βΉοΈ About LLM API Key Proxy[/bold cyan]", border_style="cyan"
)
)
self.console.print()
self.console.print("[bold]π¦ Project Information[/bold]")
self.console.print("β" * 70)
self.console.print(" [bold cyan]LLM API Key Proxy[/bold cyan]")
self.console.print(
" A lightweight, high-performance proxy server for managing"
)
self.console.print(" LLM API keys with automatic rotation and OAuth support")
self.console.print()
self.console.print(
" [dim]GitHub:[/dim] [blue underline]https://github.com/Mirrowel/LLM-API-Key-Proxy[/blue underline]"
)
self.console.print()
self.console.print("[bold]β¨ Key Features[/bold]")
self.console.print("β" * 70)
self.console.print(
" β’ [green]Smart Key Rotation[/green] - Automatic rotation across multiple API keys"
)
self.console.print(
" β’ [green]OAuth Support[/green] - Automated OAuth flows for supported providers"
)
self.console.print(
" β’ [green]Multiple Providers[/green] - Support for 10+ LLM providers"
)
self.console.print(
" β’ [green]Custom Providers[/green] - Easy integration of custom OpenAI-compatible APIs"
)
self.console.print(
" β’ [green]Advanced Filtering[/green] - Model whitelists and ignore lists per provider"
)
self.console.print(
" β’ [green]Concurrency Control[/green] - Per-key rate limiting and request management"
)
self.console.print(
" β’ [green]Cost Tracking[/green] - Track usage and costs across all providers"
)
self.console.print(
" β’ [green]Interactive TUI[/green] - Beautiful terminal interface for easy configuration"
)
self.console.print()
self.console.print("[bold]π License & Credits[/bold]")
self.console.print("β" * 70)
self.console.print(" Made with β€οΈ by the community")
self.console.print(" Open source - contributions welcome!")
self.console.print()
self.console.print("β" * 70)
self.console.print()
Prompt.ask("Press Enter to return to main menu", default="")
def run_proxy(self):
"""Prepare and launch proxy in same window"""
# Check if forced onboarding needed
if self.needs_onboarding():
clear_screen()
self.console.print(
Panel(
Text.from_markup(
"β οΈ [bold yellow]Setup Required[/bold yellow]\n\n"
"Cannot start without .env.\n"
"Launching credential tool..."
),
border_style="yellow",
)
)
# Force credential tool
from rotator_library.credential_tool import (
ensure_env_defaults,
run_credential_tool,
)
ensure_env_defaults()
load_dotenv(dotenv_path=_get_env_file(), override=True)
run_credential_tool()
load_dotenv(dotenv_path=_get_env_file(), override=True)
# Check again after credential tool
if not os.getenv("PROXY_API_KEY"):
self.console.print(
"\n[red]β PROXY_API_KEY still not set. Cannot start proxy.[/red]"
)
return
# Clear console and modify sys.argv
clear_screen()
self.console.print(
f"\n[bold green]π Starting proxy on {self.config.config['host']}:{self.config.config['port']}...[/bold green]\n"
)
# Brief pause so user sees the message before main.py takes over
import time
time.sleep(0.5)
# Reconstruct sys.argv for main.py
sys.argv = [
"main.py",
"--host",
self.config.config["host"],
"--port",
str(self.config.config["port"]),
]
if self.config.config["enable_request_logging"]:
sys.argv.append("--enable-request-logging")
# Exit TUI - main.py will continue execution
self.running = False
def run_launcher_tui():
"""Entry point for launcher TUI"""
tui = LauncherTUI()
tui.run()
|