Merge pull request 'Refactored api key management' (#1) from refactor-api-management into main

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-01-27 09:43:53 +01:00
4 changed files with 263 additions and 192 deletions

53
AGENTS.md Normal file
View File

@@ -0,0 +1,53 @@
# Sample AGENTS.md file
## General agent workflow.
During one run in Plan mode, any part of the program on any scope can be discussed.
During one run in Build mode, **scope of no more than one commit should be covered.** On the scope of commit, see "Git instructions" below.
## Dev environment
- Use `uv` to manage python versions and packages.
- `uv add [package list]` to add packages
- `uv run [file]` to run programs.
- Do not ever use `pip` and `python` directly. **Every time virtual environment isolation is broken, a kitten is thrown into a blender.**
- Use `ruff` to check for lint and formatting.
- Use `basedpyright`.
## Configuration
- Do not ever use environment variables to manage settings of the software you are developing.
- To manage credentials, use the system credential manager.
- To manage software configuration, use configuration files.
- Note, that this excludes all forms of `.env` files, or other types of workarounds for using environment variables.
- To let users temporarily disable features without modifying the configuration files, use command line parameters.
- **This rule only applies to software you are writing. That is, if an external tool requires managing environment variables, you are allowed to use them.**
## Code style conventions
Prefer snake case, unless directly inconsistent with external tools' code style.
Prefer descriptive but short names, that **express their purpose that is not directly obvious from the code.** For example, if your code contains a variable that represents a SD image generation pipeline:
- Naming it `p` is too short and ambiguous, and is considered prohibited practice.
- Naming it `pipe` or `pipeline` is useful to indicate the type of the variable (since python is a weakly typed language), but will likely result in confusion when multiple pipelines are present in code. Also, it is likely that it's usage in code will immediately indicate that it is a pipeline. This is considered a bad practice. (Note: it is considered a good practice to make use of python typing when type is ambiguous, or needs to be enforced).
- Naming it `sd_pipeline` is good to indicate type and specify that it uses Stable Diffusion, which is likely not obvious. However, due to the abundance of either actual or potential future usages of SD pipelines for purposes other than image generation (e.g. upscaling), this naming is ambiguous and does not provide sufficient information to the developer reading the code. This is considered undesirable practice.
- Naming it `img_gen_pipeline` is great to establish the purpose of the object, that likely isn't immediately inferable from it's usage. Although it does not disclose the type, it is likely obvious that image generator uses SD. This is considered good practice.
Writing comments is generally to be avoided, unless the code itself isn't expressive enough to convey the **purpose for it's existence.** **YOU SHOULD NEVER WRITE COMMENTS THAT DESCRIBE WHAT CODE DOES, OR HOW IT WORKS.**
## Code architecture conventions
- Procedural programming is encouraged.
- Domain Driven Design is encouraged.
- All architectural decisions should be discussed with a human developer before implementation.
## Git instructions
- Every commit should:
- Be logically atomic code change (that is, the one that cannot be separated in smaller steps).
- Contain brief description of the purpose of the change.
- Be formatted with `ruff`.
- *There is no requirement for code correctness on commit level.*
- Every PR should:
- Provide a complete working feature/fix.
- Should pass all checks with `ruff`.
- Should pass all checks with `basedpyright`.
- The only exceptions are expressions that:
- Are valid, due to quirks of used libraries/tools.
- When fixed, sacrifice code readability and/or conciseness.
- Should pass all tests, if present.

View File

@@ -1,210 +1,45 @@
"""Provider-agnostic API management using system keyring."""
"""Secure API credential 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:
def store_credentials(api_key: str, base_url: str = DEFAULT_BASE_URL) -> None:
"""Store API credentials securely in system keyring."""
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:
def get_credentials() -> tuple[str, str]:
"""Retrieve API credentials from system keyring."""
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:
if api_key is None or base_url is None:
raise ValueError("No API credentials found in keyring")
return api_key, base_url
def has_credentials() -> bool:
"""Check if API credentials exist in keyring."""
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:
def delete_credentials() -> None:
"""Delete API credentials from system keyring."""
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
def get_openai_client() -> OpenAI:
"""Create OpenAI client with stored credentials."""
api_key, base_url = get_credentials()
return OpenAI(base_url=base_url, api_key=api_key)

183
taletorrent/cli.py Normal file
View File

@@ -0,0 +1,183 @@
"""CLI interface for taletorrent role-playing game engine."""
import typer
from rich.console import Console
from rich.prompt import Prompt
from rich.panel import Panel
from openai import OpenAI
from .api_key import (
store_credentials,
get_credentials,
has_credentials,
delete_credentials,
DEFAULT_BASE_URL,
)
app = typer.Typer(help="Interactive role-playing game engine using AI")
console = Console()
def validate_api_config(api_key: str, base_url: str) -> bool:
"""Validate API configuration by testing connection."""
try:
client = OpenAI(base_url=base_url, api_key=api_key)
client.models.list()
return True
except Exception:
return False
@app.command()
def config(
api_key: str | None = typer.Option(
None, "--api-key", "-k", help="OpenAI-compatible API key"
),
base_url: str = typer.Option(
DEFAULT_BASE_URL, "--base-url", "-u", help="API base URL"
),
interactive: bool = typer.Option(
False, "--interactive", "-i", help="Interactive setup"
),
):
"""Configure API credentials for taletorrent."""
if interactive:
setup_wizard()
return
if api_key is None:
if has_credentials():
console.print(" [yellow]API credentials already configured[/yellow]")
console.print(
"Use [cyan]taletorrent info[/cyan] to view current configuration"
)
console.print(
"Use [cyan]taletorrent clear[/cyan] to remove existing credentials"
)
raise typer.Exit(0)
else:
console.print(
"❌ [red]No API key provided and no existing credentials found[/red]"
)
console.print(
"Use [cyan]--api-key[/cyan] option or [cyan]--interactive[/cyan] flag"
)
raise typer.Exit(1)
console.print("🔄 [blue]Validating API configuration...[/blue]")
if validate_api_config(api_key, base_url):
store_credentials(api_key, base_url)
console.print("✅ [green]API configuration stored successfully[/green]")
else:
console.print(
"❌ [red]API validation failed. Check your credentials and URL.[/red]"
)
raise typer.Exit(1)
def setup_wizard():
"""Interactive wizard for 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",
)
)
base_url = Prompt.ask("Enter API base URL", default=DEFAULT_BASE_URL)
api_key = Prompt.ask("Enter API key", password=True)
if not api_key.strip():
console.print("❌ [red]API key cannot be empty[/red]")
raise typer.Exit(1)
console.print("🔄 [blue]Validating API configuration...[/blue]")
if validate_api_config(api_key, base_url):
store_credentials(api_key, base_url)
console.print("✅ [green]API configuration stored successfully[/green]")
else:
console.print(
"❌ [red]API validation failed. Check your credentials and URL.[/red]"
)
choice = Prompt.ask(
"Store anyway? [y/N]",
choices=["y", "n"],
default="n",
)
if choice == "y":
store_credentials(api_key, base_url)
console.print("⚠️ [yellow]Configuration stored without validation[/yellow]")
else:
console.print("[yellow]Setup cancelled.[/yellow]")
raise typer.Exit(1)
@app.command()
def info():
"""Display current API configuration."""
if not has_credentials():
console.print("❌ [red]No API configuration found[/red]")
console.print("Run [cyan]taletorrent config --interactive[/cyan] to set up")
raise typer.Exit(1)
try:
api_key, base_url = get_credentials()
masked_key = (
api_key[:8] + "*" * (len(api_key) - 12) + api_key[-4:]
if len(api_key) > 12
else "*" * len(api_key)
)
console.print(
Panel.fit(
f"[bold]Base URL:[/bold] {base_url}\n"
f"[bold]API Key:[/bold] {masked_key}",
title="API Configuration",
)
)
except ValueError as e:
console.print(f"❌ [red]{e}[/red]")
raise typer.Exit(1)
@app.command()
def clear():
"""Clear stored API credentials."""
if not has_credentials():
console.print(" [yellow]No API credentials to clear[/yellow]")
return
choice = Prompt.ask(
"Are you sure you want to clear API credentials? [y/N]",
choices=["y", "n"],
default="n",
)
if choice == "y":
delete_credentials()
console.print("✅ [green]API credentials cleared[/green]")
else:
console.print("[yellow]Operation cancelled.[/yellow]")
@app.command()
def play():
"""Start a new role-playing game session."""
if not has_credentials():
console.print("❌ [red]No API configuration found[/red]")
console.print("Run [cyan]taletorrent config --interactive[/cyan] to set up")
raise typer.Exit(1)
console.print("🎮 [green]Starting role-playing game...[/green]")
console.print("⚠️ [yellow]Game engine not yet implemented[/yellow]")
if __name__ == "__main__":
app()

View File

@@ -224,7 +224,7 @@ def dm_turn(
{world.plot}
"""
messages.append({"role": "system", "content": narrator_prompt})
messages.append({"role": "system", "content": narrator_system_prompt})
for turn in chat:
if turn.character_name != "narrator":