From a72719a5d4ada765c248d4880d8415d8a7f6008b Mon Sep 17 00:00:00 2001 From: fedir Date: Sat, 17 Jan 2026 14:38:11 +0100 Subject: [PATCH] Added api key credential storage --- taletorrent/api_key.py | 210 +++++++++++++++++++++++++++++++++++++++++ taletorrent/main.py | 9 +- 2 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 taletorrent/api_key.py diff --git a/taletorrent/api_key.py b/taletorrent/api_key.py new file mode 100644 index 0000000..7182e61 --- /dev/null +++ b/taletorrent/api_key.py @@ -0,0 +1,210 @@ +"""Provider-agnostic API management using system keyring.""" + +import keyring +import typer +from rich.console import Console +from rich.prompt import Prompt +from rich.panel import Panel +from openai import OpenAI +import sys + +console = Console() + +# Service name for keyring +SERVICE_NAME = "taletorrent" +API_KEY_ITEM = "api_key" +BASE_URL_ITEM = "base_url" + +# Default configuration +DEFAULT_BASE_URL = "https://openrouter.ai/api/v1" + + +class KeyringManager: + """Manages secure storage of API keys and base URLs using system keyring.""" + + @staticmethod + def store_api_config(api_key: str, base_url: str) -> None: + """Store API key and base URL securely in system keyring.""" + try: + keyring.set_password(SERVICE_NAME, API_KEY_ITEM, api_key) + keyring.set_password(SERVICE_NAME, BASE_URL_ITEM, base_url) + console.print( + "✅ [green]API configuration stored securely in system keyring[/green]" + ) + except Exception as e: + console.print(f"❌ [red]Error storing API configuration: {e}[/red]") + raise typer.Exit(1) + + @staticmethod + def get_api_key() -> str: + """Retrieve API key from system keyring.""" + try: + api_key = keyring.get_password(SERVICE_NAME, API_KEY_ITEM) + if api_key is None: + console.print("❌ [red]No API key found in system keyring[/red]") + console.print( + "Run [cyan]taletorrent config[/cyan] to set up your API configuration" + ) + raise typer.Exit(1) + return api_key + except Exception as e: + console.print(f"❌ [red]Error accessing keyring: {e}[/red]") + raise typer.Exit(1) + + @staticmethod + def get_base_url() -> str: + """Retrieve base URL from system keyring.""" + try: + base_url = keyring.get_password(SERVICE_NAME, BASE_URL_ITEM) + if base_url is None: + console.print("❌ [red]No base URL found in system keyring[/red]") + console.print( + "Run [cyan]taletorrent config[/cyan] to set up your API configuration" + ) + raise typer.Exit(1) + return base_url + except Exception as e: + console.print(f"❌ [red]Error accessing keyring: {e}[/red]") + raise typer.Exit(1) + + @staticmethod + def has_api_config() -> bool: + """Check if API configuration exists in keyring.""" + try: + api_key = keyring.get_password(SERVICE_NAME, API_KEY_ITEM) + base_url = keyring.get_password(SERVICE_NAME, BASE_URL_ITEM) + return api_key is not None and base_url is not None + except Exception: + return False + + @staticmethod + def delete_api_config() -> None: + """Delete API configuration from system keyring.""" + try: + keyring.delete_password(SERVICE_NAME, API_KEY_ITEM) + keyring.delete_password(SERVICE_NAME, BASE_URL_ITEM) + console.print( + "✅ [green]API configuration removed from system keyring[/green]" + ) + except Exception as e: + console.print(f"❌ [red]Error deleting API configuration: {e}[/red]") + raise typer.Exit(1) + + +def validate_api_config(api_key: str, base_url: str) -> bool: + """Validate API configuration by testing it against endpoint.""" + try: + client = OpenAI( + base_url=base_url, + api_key=api_key, + ) + + # Test with a minimal request + response = client.models.list() + return True + except Exception as e: + console.print(f"❌ Connection failed: {str(e)}") + return False + + +def setup_api_config_wizard() -> None: + """Interactive wizard for setting up API configuration.""" + console.print( + Panel.fit( + "[bold cyan]API Configuration Setup[/bold cyan]\n\n" + "Configure any OpenAI-compatible API endpoint.\n" + "Your credentials will be stored securely in your system keyring.", + title="API Provider Setup", + ) + ) + + # Get base URL + base_url = Prompt.ask("Enter API base URL", default=DEFAULT_BASE_URL) + + # Get API key + api_key = Prompt.ask("Enter API key", password=True) + + while True: + if not api_key.strip(): + console.print("❌ [red]API key cannot be empty[/red]") + api_key = Prompt.ask("Enter API key", password=True) + continue + + console.print("🔄 [blue]Validating API configuration...[/blue]") + + if validate_api_config(api_key, base_url): + KeyringManager.store_api_config(api_key, base_url) + console.print( + "\n✅ [green]API configuration setup complete! You can now start playing.[/green]" + ) + break + else: + console.print("⚠️ [yellow]Warning: API validation failed[/yellow]") + console.print( + "[dim]This may be due to network issues, invalid credentials, or backend compatibility issues.[/dim]" + ) + + choice = Prompt.ask( + "What would you like to do? [retry/force/cancel]", + choices=["retry", "force", "cancel"], + default="retry", + ) + + if choice == "retry": + base_url = Prompt.ask("Enter API base URL", default=base_url) + api_key = Prompt.ask("Enter API key", password=True) + continue + elif choice == "force": + console.print( + "[yellow]⚠️ Storing configuration without validation[/yellow]" + ) + console.print( + "[dim]Note: You may encounter errors when using taletorrent if the configuration is invalid.[/dim]" + ) + KeyringManager.store_api_config(api_key, base_url) + console.print( + "\n✅ [green]API configuration stored (validation skipped)[/green]" + ) + break + else: # cancel + console.print("[yellow]Setup cancelled.[/yellow]") + break + + +def get_openai_client(): + """Get configured OpenAI client with secure API credentials.""" + api_key = KeyringManager.get_api_key() + base_url = KeyringManager.get_base_url() + return OpenAI( + base_url=base_url, + api_key=api_key, + ) + + +def ensure_api_config() -> None: + """Ensure API configuration exists, prompt for setup if missing.""" + if not KeyringManager.has_api_config(): + console.print("❌ [red]No API configuration found[/red]") + console.print( + "Run [cyan]taletorrent config[/cyan] to set up your API configuration" + ) + raise typer.Exit(1) + + +def get_api_info() -> dict: + """Get current API configuration info for display.""" + try: + if not KeyringManager.has_api_config(): + return None + + api_key = KeyringManager.get_api_key() + base_url = KeyringManager.get_base_url() + + return { + "base_url": base_url, + "api_key": api_key[:8] + "*" * (len(api_key) - 12) + api_key[-4:] + if len(api_key) > 12 + else "*" * len(api_key), + } + except Exception: + return None diff --git a/taletorrent/main.py b/taletorrent/main.py index cd68792..94a2f3c 100644 --- a/taletorrent/main.py +++ b/taletorrent/main.py @@ -1,7 +1,9 @@ from dataclasses import dataclass from typing import List, Optional, Tuple + from openai import OpenAI -from .api_key import or_api_key + +from .api_key import get_openai_client # SETTINGS @@ -71,10 +73,7 @@ Description of the caracter you play is provided below. creative_model = "deepseek/deepseek-r1-0528:free" pedantic_model = "qwen/qwen3-30b-a3b-instruct-2507" -openai_client = OpenAI( - base_url="https://openrouter.ai/api/v1", - api_key=or_api_key, -) +openai_client: OpenAI = get_openai_client() @dataclass