npc-clients-python/gumyum_npc_sync.py

1623 lines
55 KiB
Python

#!/usr/bin/env python3
"""
GumYum NPC API Python SDK - Synchronous Version
Cross-engine AI-powered NPC dialogue and quest system client
Example usage:
from gumyum_npc_sync import GumYumClient
def main():
client = GumYumClient("https://npc.gumyum.com")
# Authenticate
user = client.auth.register("username", "password", "email@example.com")
# Copy a public universe
universe_id = client.universes.copy_public("blade-runner", "My World")
# Spawn an NPC
npc = client.npcs.spawn(universe_id, seed=12345)
# Chat with the NPC
response = client.chat.completions(
npc_id=npc.npc_id,
messages=[{"role": "user", "content": "Hello!"}]
)
print(response.choices[0].message.content)
main()
Version: 0.1.0
Author: GumYum NPC API Team
Contact: timehexon@gumyum.com
"""
import json
import time
import threading
import re
import base64
import hashlib
import logging
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Dict, List, Optional, Any, Union, Iterator, Callable, Tuple
from urllib.parse import urljoin
from functools import wraps
try:
import requests
except ImportError:
requests = None
try:
from pydantic import BaseModel, Field
except ImportError:
BaseModel = None
Field = None
# Import shared data models and exceptions
from gumyum_models import (
# Exceptions
GumYumError,
GumYumAPIError,
GumYumAuthError,
GumYumNotFoundError,
GumYumValidationError,
GumYumServerError,
GumYumNetworkError,
GumYumTimeoutError,
GumYumRateLimitError,
# Models
ChatRole,
ChatMessage,
ChatChoice,
ChatCompletion,
SpawnedData,
NPCProfile,
NPC as AsyncNPC, # Rename to avoid conflict
UniverseThemeInfo,
UniverseListItem,
UniverseData,
AuthToken,
UserProfile,
APIUsage,
NPCStats,
PublicUniverse,
ApiKey,
ApiKeyPair,
MoodData,
MoodItem,
MoodUploadResponse,
)
# ============================================================================
# SYNC-SPECIFIC DATA MODELS
# ============================================================================
class NPCChatProxy:
"""Proxy object that provides chat.completions() interface for NPC"""
def __init__(self, npc: "NPC"):
self.npc = npc
def completions(
self,
messages: List[Union[ChatMessage, dict]],
model: str = "gumyum-npc",
temperature: float = 0.8,
max_tokens: Optional[int] = None,
stream: bool = False,
**kwargs,
) -> Union[ChatCompletion, Iterator[dict]]:
"""
Generate AI chat completion with this NPC's context
Args:
messages: Chat message history
model: AI model to use
temperature: Response randomness (0.0-2.0)
max_tokens: Maximum response length
stream: Enable streaming responses
**kwargs: Additional OpenAI-compatible parameters
Returns:
Chat completion response or iterator for streaming
Examples:
# Basic chat
response = npc.chat.completions(
messages=[{"role": "user", "content": "Hello!"}]
)
print(response.choices[0].message.content)
# With streaming
for chunk in npc.chat.completions(
messages=[{"role": "user", "content": "Tell me a story"}],
stream=True
):
print(chunk["choices"][0]["delta"]["content"], end="")
"""
if not self.npc.client:
raise ValueError("NPC has no client reference. Was it spawned properly?")
# Build npc_params with the NPC's context
npc_params = {
"universe_id": self.npc.universe_id,
"world_seed": self.npc.seed,
"npc_id": self.npc.npc_id,
}
# If not streaming, wrap response to track history
if not stream:
response = self.npc.client.chat.completions(
npc_params=npc_params,
messages=messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
stream=stream,
**kwargs,
)
# Track history
if response and hasattr(response, "choices") and response.choices:
# Add user message to history
if messages and messages[-1].get("role") == "user":
self.npc.chat_history.append(messages[-1])
# Add assistant response to history
assistant_msg = response.choices[0].message
self.npc.chat_history.append(
{"role": assistant_msg.role, "content": assistant_msg.content}
)
return response
else:
# For streaming, return the stream directly
return self.npc.client.chat.completions(
npc_params=npc_params,
messages=messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
stream=stream,
**kwargs,
)
def chat_with_history(
self,
message: str,
model: Optional[str] = None,
temperature: float = 0.8,
max_tokens: int = 2000,
stream: bool = False,
**kwargs,
) -> Union[ChatCompletion, Iterator[dict]]:
"""Chat with conversation history - includes all previous messages in this session.
Args:
message: The user's message
model: Model to use (defaults to gumyum-npc)
temperature: Sampling temperature
max_tokens: Maximum tokens to generate
stream: Whether to stream the response
**kwargs: Additional API parameters
Returns:
ChatCompletion response or iterator for streaming
"""
# Build messages array with history plus new message
messages_with_history = self.npc.chat_history.copy()
messages_with_history.append({"role": "user", "content": message})
# Use regular completions which will also update history
return self.completions(
messages=messages_with_history,
model=model,
temperature=temperature,
max_tokens=max_tokens,
stream=stream,
**kwargs,
)
class NPC(AsyncNPC):
"""Synchronous version of NPC"""
model_config = {"arbitrary_types_allowed": True}
# Client reference - excluded from serialization
client: Optional["GumYumClient"] = Field(default=None, exclude=True)
# Chat proxy - created on demand
_chat: Optional[NPCChatProxy] = None
@property
def chat(self) -> NPCChatProxy:
"""Get the chat proxy for this NPC"""
if self._chat is None:
self._chat = NPCChatProxy(self)
return self._chat
def completions(
self, messages: List[Union[ChatMessage, dict]], **kwargs
) -> Union[ChatCompletion, Iterator[dict]]:
"""
Convenience alias for npc.chat.completions()
Args:
messages: Chat message history
**kwargs: Additional parameters passed to chat.completions
Returns:
Chat completion response or iterator for streaming
Example:
# Direct usage
response = npc.completions([
{"role": "user", "content": "Hello!"}
])
"""
return self.chat.completions(messages, **kwargs)
def chat_with_history(
self, message: str, **kwargs
) -> Union[ChatCompletion, Iterator[dict]]:
"""
Chat with conversation history - includes all previous messages in this session.
Args:
message: The user's message
**kwargs: Additional parameters passed to chat.completions
Returns:
Chat completion response or iterator for streaming
Example:
# Continue conversation with context
response = npc.chat_with_history("Tell me more about that")
print(response.choices[0].message.content)
"""
return self.chat.chat_with_history(message, **kwargs)
# ============================================================================
# STREAMING SUPPORT
# ============================================================================
class StreamingResponse:
"""Handle streaming Server-Sent Events responses"""
def __init__(self, response: requests.Response):
self.response = response
self._buffer = ""
def __iter__(self) -> Iterator[dict]:
"""Iterate over streaming chunks"""
try:
for line in self.response.iter_lines(decode_unicode=True):
if line.startswith("data: "):
chunk_data = line[6:] # Remove "data: " prefix
if chunk_data.strip() == "[DONE]":
break
try:
chunk = json.loads(chunk_data)
yield chunk
except json.JSONDecodeError:
continue
finally:
self.response.close()
# ============================================================================
# MANAGER CLASSES
# ============================================================================
class MoodManager:
"""Handles custom mood management operations"""
def __init__(self, client: "GumYumClient"):
self.client = client
def upload(self, mood_id: str, mood_data: dict) -> dict:
"""
Upload a custom mood JSON file
Args:
mood_id: Unique identifier for the mood (alphanumeric, underscore, hyphen only)
mood_data: Mood data with 'categories' field containing mood definitions
Returns:
Upload response with success status and categories count
Raises:
GumYumAuthError: If not authenticated
GumYumValidationError: If mood_id format is invalid or mood_data structure is wrong
"""
payload = {"mood_id": mood_id, "mood_data": mood_data}
response = self.client.post("moods/upload", json=payload)
return response
def list(self) -> List[dict]:
"""
List all available custom mood files
Returns:
List of custom moods with mood_id, categories_count, and filename
Raises:
GumYumAuthError: If not authenticated
"""
response = self.client.get("moods")
return response.get("moods", [])
def delete(self, mood_id: str) -> dict:
"""
Delete a custom mood file
Args:
mood_id: The mood ID to delete
Returns:
Deletion response with success status
Raises:
GumYumAuthError: If not authenticated
GumYumNotFoundError: If mood not found
GumYumValidationError: If trying to delete default GumYum moods
"""
response = self.client.delete(f"moods/{mood_id}")
return response
def create_simple_mood(self, mood_id: str, category_name: str, moods: dict) -> dict:
"""
Create a simple custom mood with a single category
Args:
mood_id: Unique identifier for the mood
category_name: Name of the mood category (e.g., "ENERGETIC", "CALM")
moods: Dictionary of mood_name -> description pairs
Returns:
Upload response
Example:
client.moods.create_simple_mood(
"my_energetic_moods",
"ENERGETIC",
{
"pumped": "Full of energy and ready for action",
"hyped": "Extremely excited and enthusiastic"
}
)
"""
mood_data = {
"categories": {
category_name: {
mood_name: {"description": description}
for mood_name, description in moods.items()
}
}
}
return self.upload(mood_id, mood_data)
class AuthManager:
"""Handles authentication operations"""
def __init__(self, client: "GumYumClient"):
self.client = client
def register(self, username: str, password: str, email: str) -> AuthToken:
"""Register a new user account"""
data = {"username": username, "password": password, "email": email}
response = self.client.post("auth/register", json=data)
token = AuthToken(**response)
# Automatically set token for subsequent requests
self.client.set_token(token.access_token, token.refresh_token, token.expires_in)
return token
def login(self, username: str, password: str) -> AuthToken:
"""Login with existing account"""
data = {"username": username, "password": password}
response = self.client.post("auth/login", json=data)
token = AuthToken(**response)
# Automatically set token for subsequent requests
self.client.set_token(token.access_token, token.refresh_token, token.expires_in)
return token
def refresh_token(self, refresh_token: str) -> AuthToken:
"""Refresh access token using refresh token"""
data = {"refresh_token": refresh_token}
# Use a special flag to avoid auto-auth during refresh
self.client._is_refreshing = True
try:
response = self.client.post("auth/refresh", json=data)
finally:
self.client._is_refreshing = False
# Extract new tokens and update client
new_access_token = response["access_token"]
new_refresh_token = response.get(
"refresh_token", refresh_token
) # Server may rotate refresh token
expires_in = response.get("expires_in", 3600)
# Debug: Check if server provided a new refresh token
# print(f"[DEBUG] Refresh response has refresh_token: {'refresh_token' in response}")
self.client.set_token(new_access_token, new_refresh_token, expires_in)
return AuthToken(
access_token=new_access_token,
refresh_token=new_refresh_token, # Use the new refresh token if provided
token_type=response.get("token_type", "Bearer"),
expires_in=expires_in,
user_id=response.get("user_id", ""),
username=response.get("username", ""),
)
def logout(self):
"""Logout by clearing the authentication token"""
self.client.set_token(None, None)
def get_profile(self) -> dict:
"""Get current user profile"""
return self.client.get("auth/profile")
def is_authenticated(self) -> bool:
"""Check if client has an authentication token"""
return self.client._token is not None
def create_api_key(self, name: str, scopes: List[str]) -> dict:
"""
Create a new API key
Args:
name: Human-readable name for the API key
scopes: List of permission scopes (e.g., ['npc.spawn', 'chat.completions'])
Returns:
API key details including public_key and secret_key
Raises:
GumYumAuthError: If not authenticated with JWT
"""
data = {"name": name, "scopes": scopes}
response = self.client.post("auth/api-keys", json=data)
return response
def list_api_keys(self) -> List[dict]:
"""
List all API keys for the current user
Returns:
List of API key details (without secret keys)
Raises:
GumYumAuthError: If not authenticated
"""
response = self.client.get("auth/api-keys")
return response.get("api_keys", [])
def get_api_key(self, api_key_id: str) -> dict:
"""
Get details of a specific API key
Args:
api_key_id: The API key ID
Returns:
API key details (without secret key)
Raises:
GumYumAuthError: If not authenticated
GumYumNotFoundError: If API key not found
"""
response = self.client.get(f"auth/api-keys/{api_key_id}")
return response
def update_api_key(
self,
api_key_id: str,
name: Optional[str] = None,
scopes: Optional[List[str]] = None,
) -> dict:
"""
Update API key metadata
Args:
api_key_id: The API key ID
name: New name for the API key
scopes: New scopes for the API key
Returns:
Updated API key details
Raises:
GumYumAuthError: If not authenticated
GumYumNotFoundError: If API key not found
"""
data = {}
if name is not None:
data["name"] = name
if scopes is not None:
data["scopes"] = scopes
response = self.client.put(f"auth/api-keys/{api_key_id}", json=data)
return response
def revoke_api_key(self, api_key_id: str) -> dict:
"""
Revoke (delete) an API key
Args:
api_key_id: The API key ID to revoke
Returns:
Deletion confirmation
Raises:
GumYumAuthError: If not authenticated
GumYumNotFoundError: If API key not found
"""
response = self.client.delete(f"auth/api-keys/{api_key_id}")
return response
def regenerate_api_key(self, api_key_id: str) -> dict:
"""
Regenerate an API key (creates new public/secret pair)
Args:
api_key_id: The API key ID to regenerate
Returns:
New API key details including new public_key and secret_key
Raises:
GumYumAuthError: If not authenticated
GumYumNotFoundError: If API key not found
"""
response = self.client.post(f"auth/api-keys/{api_key_id}/regenerate")
return response
def delete_account(self) -> dict:
"""
Delete the current user account (GDPR compliance)
Returns:
Deletion confirmation
Raises:
GumYumAuthError: If not authenticated
"""
response = self.client.delete("auth/account")
return response
class UniverseManager:
"""Handles universe operations"""
def __init__(self, client: "GumYumClient"):
self.client = client
def list_public(self) -> List[PublicUniverse]:
"""List all available public universes"""
response = self.client.get("public/universes")
return [PublicUniverse(**universe) for universe in response["public_universes"]]
def copy_public(
self,
public_universe_id: str,
custom_name: Optional[str] = None,
mood_id: Optional[str] = None,
) -> str:
"""Copy a public universe to user's vault"""
data = {"public_universe_id": public_universe_id}
if custom_name:
data["custom_name"] = custom_name
if mood_id:
data["mood_id"] = mood_id
response = self.client.post("public/universes/copy", json=data)
return response["universe_id"]
def list(self) -> List[dict]:
"""List user's private universes"""
response = self.client.get("universes")
return response["universes"]
# Alias for backwards compatibility
def list_user(self) -> List[dict]:
"""List user's private universes (alias for list())"""
return self.list()
def create(self, universe_data: dict, mood_id: Optional[str] = None) -> str:
"""Create a new universe in user's vault"""
if mood_id:
universe_data["mood_id"] = mood_id
response = self.client.post("universes", json=universe_data)
return response["universe_id"]
def get(self, universe_id: str) -> UniverseData:
"""Get a specific universe by ID"""
response = self.client.get(f"universes/{universe_id}")
return UniverseData(**response)
def update(self, universe_id: str, universe_data: dict) -> UniverseData:
"""Update an existing universe"""
response = self.client.put(f"universes/{universe_id}", json=universe_data)
return UniverseData(**response)
def fork(self, universe_id: str, name: Optional[str] = None) -> str:
"""Fork (copy) an existing universe"""
data = {}
if name:
data["name"] = name
response = self.client.post(f"universes/{universe_id}/fork", json=data)
return response["universe_id"]
def delete(self, universe_id: str) -> bool:
"""Delete a universe from user's vault"""
self.client.delete(f"universes/{universe_id}")
return True
class NPCManager:
"""Handles NPC operations"""
def __init__(self, client: "GumYumClient"):
self.client = client
def spawn(
self,
universe_id: str,
seed: int,
npc_id: Optional[int] = None,
location_filter: Optional[List[str]] = None,
) -> NPC:
"""Spawn a deterministic NPC"""
if npc_id is not None:
# Spawn with specific 64-bit NPC ID
params = {"universe_id": universe_id, "seed": seed, "npc_id": npc_id}
if location_filter:
params["location_filter"] = location_filter
response = self.client.get("npc", params=params)
npc = NPC(**response)
npc.client = self.client
return npc
else:
# Generate random NPC ID from seed and redirect
params = {"universe_id": universe_id, "seed": seed}
if location_filter:
params["location_filter"] = location_filter
response = self.client.get("npc/spawn", params=params)
npc = NPC(**response)
npc.client = self.client
return npc
def spawn_random(
self, universe_id: str, seed: int, location_filter: Optional[List[str]] = None
) -> NPC:
"""Spawn random NPC using deterministic seed"""
return self.spawn(universe_id, seed, None, location_filter)
def spawn_filtered(
self,
universe_id: str,
world_seed: int,
filters: Dict[str, Any],
start_npc_id: int = 1,
max_attempts: int = 10000,
) -> Optional[NPC]:
"""
Spawn NPC matching specific filters using 64-bit crawling
Args:
universe_id: Universe to spawn NPC in
world_seed: World seed for generation
filters: Dictionary of filter criteria
start_npc_id: Starting NPC ID for search
max_attempts: Maximum search attempts
Returns:
NPC if found, None if no match within attempts
Filter options:
- profession: List[str] - Specific professions
- personality_type: List[int] - Enneagram types (1-9)
- wing: List[int] - Wing types (1-9)
- stress_level: Dict[str, int] - {'min': 0, 'max': 10}
- mood: List[str] - Specific moods
- gender: List[str] - ['male', 'female', 'neutral']
- location_filter: List[str] - Location types
"""
data = {
"universe_id": universe_id,
"world_seed": world_seed,
"filters": filters,
"start_npc_id": start_npc_id,
"max_attempts": max_attempts,
}
response = self.client.post("npc/spawn/filtered", json=data)
if not response.get("found"):
return None
# Convert response to NPC
npc = NPC(
npc_id=response["npc_id"],
name=response["name"],
profession=response["profession"],
personality_type=response["personality_type"],
mood=response["mood"],
universe_id=response["universe_id"],
seed=response["world_seed"],
cached=False,
cache_url=None,
)
npc.client = self.client
return npc
def get_profile(self, npc_id: str) -> NPCProfile:
"""Get full NPC profile data"""
response = self.client.get(f"npc/saved/{npc_id}")
return NPCProfile(**response)
def save(
self,
universe_id: str,
seed: int,
npc_id: int,
custom_name: Optional[str] = None,
) -> NPCProfile:
"""Save an NPC to user's vault for persistence"""
data = {
"universe_id": universe_id,
"world_seed": seed, # API expects world_seed for save endpoint
"npc_id": npc_id,
}
if custom_name:
data["custom_name"] = custom_name
response = self.client.post("npc/save", json=data)
return NPCProfile(**response)
def list_saved(self) -> List[NPC]:
"""
List user's saved NPCs
Returns:
List of saved NPCs with client attached, ready for chat
"""
response = self.client.get("npc/list")
npcs = []
for npc_data in response["npcs"]:
# Convert list response data to NPC
npc = NPC(
npc_id=npc_data["npc_id"],
name=npc_data["name"],
profession=npc_data["profession"],
personality_type=npc_data.get(
"personality_type", 5
), # Default if missing
spawned=SpawnedData(**npc_data["spawned"]),
universe_id=npc_data["universe_id"],
seed=npc_data.get("seed", 0), # Default seed if not provided
cached=False,
cache_url=None,
)
# Attach client reference for chat functionality
npc.client = self.client
npcs.append(npc)
return npcs
def update(self, npc_id: str, updates: dict) -> NPCProfile:
"""Update a saved NPC's data"""
response = self.client.put(f"npc/saved/{npc_id}", json=updates)
return NPCProfile(**response)
def delete(self, npc_id: str) -> bool:
"""Delete a saved NPC"""
self.client.delete(f"npc/saved/{npc_id}")
return True
def get_stats(self) -> NPCStats:
"""Get user's NPC statistics"""
response = self.client.get("npc/stats")
return NPCStats(**response)
class ChatManager:
"""Handles AI chat completions with NPCs"""
def __init__(self, client: "GumYumClient"):
self.client = client
def completions(
self,
npc_id: Optional[str] = None,
messages: Optional[List[Union[ChatMessage, dict]]] = None,
npc_params: Optional[dict] = None,
model: str = "gumyum-npc",
temperature: float = 0.8,
max_tokens: Optional[int] = None,
stream: bool = False,
**kwargs,
) -> Union[ChatCompletion, StreamingResponse]:
"""
Generate AI chat completion with NPC context
Args:
npc_id: Saved NPC ID to chat with
messages: Chat message history
npc_params: Parameters to spawn temporary NPC (if npc_id not provided)
Should contain: universe_id, world_seed, npc_id
model: AI model to use
temperature: Response randomness (0.0-2.0)
max_tokens: Maximum response length
stream: Enable streaming responses
**kwargs: Additional OpenAI-compatible parameters
"""
# Build request data
data = {"model": model, "temperature": temperature, "stream": stream, **kwargs}
if max_tokens is not None:
data["max_tokens"] = max_tokens
# Add NPC context
if npc_id:
data["npc_id"] = npc_id
elif npc_params:
data["npc_params"] = npc_params
else:
raise ValueError("Either npc_id or npc_params must be provided")
# Add messages
if messages:
# Convert ChatMessage objects to dicts if needed
formatted_messages = []
for msg in messages:
if isinstance(msg, ChatMessage):
formatted_messages.append(msg.to_dict())
else:
formatted_messages.append(msg)
data["messages"] = formatted_messages
else:
data["messages"] = []
if stream:
return self._stream_completions(data)
else:
response = self.client.post("chat/completions", json=data)
# Convert response to ChatCompletion object
choices = []
for choice_data in response.get("choices", []):
message_data = choice_data.get("message", {})
message = ChatMessage(
role=message_data.get("role", "assistant"),
content=message_data.get("content", ""),
)
choice = ChatChoice(
index=choice_data.get("index", 0),
message=message,
finish_reason=choice_data.get("finish_reason"),
)
choices.append(choice)
return ChatCompletion(
id=response.get("id", ""),
created=response.get("created", int(time.time())),
model=response.get("model", model),
choices=choices,
usage=response.get("usage"),
npc_context=response.get("npc_context"),
mood_transition=response.get("mood_transition"),
)
def _stream_completions(self, data: dict) -> StreamingResponse:
"""Handle streaming chat completions"""
url = urljoin(f"{self.client.api_url}/", "chat/completions")
headers = self.client.get_headers()
response = self.client._session.post(
url, json=data, headers=headers, stream=True, timeout=self.client.timeout
)
if response.status_code >= 400:
self.client._handle_error_response(response)
# Check if we got a streaming response or a regular JSON response
content_type = response.headers.get("content-type", "")
if "event-stream" not in content_type:
# API returned non-streaming response, convert to streaming format
try:
json_response = response.json()
# Create a fake streaming response from the JSON
class FakeStreamingResponse:
def __init__(self, json_data):
self.json_data = json_data
self.response = response
def __iter__(self):
# Emit the content as chunks
if "choices" in self.json_data and self.json_data["choices"]:
content = self.json_data["choices"][0]["message"]["content"]
# Split content into words to simulate streaming
words = content.split()
import time
for i, word in enumerate(words):
chunk = {
"choices": [
{
"delta": {
"content": word
+ (" " if i < len(words) - 1 else "")
}
}
]
}
yield chunk
# Add small delay to simulate streaming
time.sleep(0.02)
return FakeStreamingResponse(json_response)
except:
# If we can't parse it, return the original streaming response
pass
return StreamingResponse(response)
# ============================================================================
# MAIN CLIENT
# ============================================================================
class GumYumClient:
"""
Synchronous client for GumYum NPC API
Example:
client = GumYumClient("https://npc.gumyum.com")
user = client.auth.register("username", "password", "email")
npc = client.npcs.spawn("universe_id", seed=12345)
response = client.chat.completions(npc.npc_id, "Hello!")
"""
def __init__(
self,
base_url: str = "http://localhost:6543",
api_key: Optional[str] = None,
timeout: float = 75.0, # Higher than server's 65s timeout
max_retries: int = 3,
username: Optional[str] = None,
password: Optional[str] = None,
public_key: Optional[str] = None,
secret_key: Optional[str] = None,
**kwargs,
):
"""
Initialize GumYum client
Args:
base_url: API base URL
api_key: Optional API key for authentication (deprecated, use public_key/secret_key)
timeout: Request timeout in seconds
max_retries: Maximum number of retries for failed requests
username: Username for login
password: Password for login
public_key: Public API key
secret_key: Secret API key
**kwargs: Additional requests session options
"""
if requests is None:
raise ImportError(
"requests is required. Install with: pip install requests"
)
self.base_url = base_url.rstrip("/")
self.api_url = f"{self.base_url}/v1"
self.timeout = timeout
self.max_retries = max_retries
# HTTP session configuration
self._session = requests.Session()
self._session.timeout = timeout
# Apply additional session options
for key, value in kwargs.items():
setattr(self._session, key, value)
self._token: Optional[str] = None
self._refresh_token: Optional[str] = None
self._token_expires_at: Optional[float] = None
self._api_key: Optional[str] = api_key
self._public_key: Optional[str] = public_key
self._secret_key: Optional[str] = secret_key
self._jwt_obtained: bool = False # Track if we've exchanged API key for JWT
self._is_refreshing: bool = False # Track if we're in a refresh operation
# Initialize managers
self.auth = AuthManager(self)
self.moods = MoodManager(self)
self.universes = UniverseManager(self)
self.npcs = NPCManager(self)
self.chat = ChatManager(self)
# Auto-authenticate if credentials provided
if username and password:
self.login(username, password)
elif public_key and secret_key:
self.set_api_key(public_key, secret_key)
elif api_key: # Legacy support
parts = api_key.split(":")
if len(parts) == 2:
self.set_api_key(parts[0], parts[1])
def __enter__(self):
"""Context manager entry"""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit"""
self.close()
def close(self):
"""Close the HTTP session"""
self._session.close()
def set_token(
self,
token: Optional[str],
refresh_token: Optional[str] = None,
expires_in: Optional[int] = None,
):
"""Set authentication token"""
self._token = token
if refresh_token is not None:
self._refresh_token = refresh_token
if expires_in is not None:
import time
self._token_expires_at = time.time() + expires_in
# Mark that user has explicitly authenticated
if token:
self._auth_attempted_by_user = True
def set_api_key(self, public_key: str, secret_key: str) -> None:
"""
Set API key credentials for authentication
Args:
public_key: The public API key
secret_key: The secret API key
"""
self._public_key = public_key
self._secret_key = secret_key
self._api_key = f"{public_key}:{secret_key}"
# Mark that user has explicitly authenticated
self._auth_attempted_by_user = True
# Exchange API key for JWT immediately
self._exchange_api_key_for_jwt()
def _exchange_api_key_for_jwt(self) -> None:
"""Exchange API key for JWT token"""
if not self._public_key or not self._secret_key:
raise GumYumAuthError("API keys not set")
url = f"{self.api_url}/auth/exchange"
headers = {
"X-API-Key-Public": self._public_key,
"X-API-Key-Secret": self._secret_key,
"Content-Type": "application/json",
"User-Agent": "GumYum-Python-SDK-Sync/0.2.0",
}
try:
response = self._session.post(url, headers=headers, timeout=self.timeout)
response.raise_for_status()
data = response.json()
self._token = data["jwt_token"]
self._refresh_token = data["refresh_token"]
self._token_expires_at = time.time() + data.get("expires_in", 3600)
self._jwt_obtained = True
logger.debug("Successfully exchanged API key for JWT")
except requests.HTTPError as e:
if e.response.status_code == 401:
raise GumYumAuthError("Invalid API key credentials")
else:
raise GumYumAPIError(f"API key exchange failed: {e}")
except Exception as e:
raise GumYumAPIError(f"API key exchange failed: {e}")
def get_headers(self) -> Dict[str, str]:
"""Get request headers with authentication"""
headers = {
"Content-Type": "application/json",
"User-Agent": "GumYum-Python-SDK-Sync/0.1.0",
}
if self._token and self._token.strip():
headers["Authorization"] = f"Bearer {self._token}"
return headers
def _ensure_authenticated(self):
"""Ensure client is authenticated, auto-login if needed"""
# Check if token needs refresh
if self._token and self._token_expires_at:
if (
time.time() >= self._token_expires_at - 60
): # Refresh 1 minute before expiry
if self._refresh_token and not self._is_refreshing:
try:
self.auth.refresh_token(self._refresh_token)
except Exception:
# If refresh fails, try to re-authenticate with API keys
if self._public_key and self._secret_key:
try:
self._exchange_api_key_for_jwt()
except Exception:
pass
# Only auto-authenticate if we've never had a token (prevents overriding explicit auth)
if not self._token or not self._token.strip():
# Try API key auth first
if self._public_key and self._secret_key and not self._jwt_obtained:
try:
self._exchange_api_key_for_jwt()
return
except Exception:
pass
if not hasattr(self, "_auth_attempted_by_user"):
# Auto-authenticate with default credentials if no token (max 2 attempts)
if not hasattr(self, "_auto_auth_attempts"):
self._auto_auth_attempts = 0
if self._auto_auth_attempts < 2:
self._auto_auth_attempts += 1
try:
# Try to login with load test credentials
import time
timestamp = int(time.time())
username = f"auto_user_{timestamp}_{self._auto_auth_attempts}"
self.auth.register(
username, "auto_password123", f"{username}@example.com"
)
except Exception:
# If registration fails, try login on second attempt
if self._auto_auth_attempts == 2:
try:
self.auth.login(username, "auto_password123")
except Exception:
# Both attempts failed - client is now permanently failed
pass
def _request(
self,
method: str,
endpoint: str,
params: Optional[Dict[str, Any]] = None,
json: Optional[Dict[str, Any]] = None,
**kwargs,
) -> Dict[str, Any]:
"""
Make HTTP request with error handling and retries
Args:
method: HTTP method
endpoint: API endpoint (relative to api_url)
params: Query parameters
json: JSON request body
**kwargs: Additional request options
Returns:
Response JSON data
Raises:
GumYumError: On API errors
"""
# Check if token needs refresh before making request
if self._token and self._token_expires_at:
import time
if (
time.time() >= self._token_expires_at - 60
): # Refresh 1 minute before expiry
# Skip refresh for auth endpoints that don't need it (to prevent recursion)
auth_endpoints_to_skip = [
"auth/refresh",
"auth/login",
"auth/register",
"auth/exchange",
"health",
"public/universes",
]
if self._refresh_token and not any(
endpoint.startswith(ep) for ep in auth_endpoints_to_skip
):
try:
self.auth.refresh_token(self._refresh_token)
except Exception:
# If refresh fails, continue anyway and let 401 handling take over
pass
# Auto-authenticate for protected endpoints
protected_endpoints = ["npc", "chat", "universes", "npcs"]
if any(endpoint.startswith(ep) for ep in protected_endpoints):
self._ensure_authenticated()
if not self._token or not self._token.strip():
raise GumYumAuthError("Authentication required for protected endpoints")
url = urljoin(f"{self.api_url}/", endpoint.lstrip("/"))
headers = self.get_headers()
# Merge headers, but sanitize Authorization to prevent empty arrays
if "headers" in kwargs:
extra_headers = kwargs.pop("headers")
for key, value in extra_headers.items():
if key.lower() == "authorization" and (
not value or value == [] or value == [""]
):
# Skip malformed Authorization headers
continue
headers[key] = value
retries = 0
while retries <= self.max_retries:
try:
# Final aggressive check: remove ANY malformed Authorization header
if "Authorization" in headers:
auth_value = headers["Authorization"]
if (
auth_value == []
or auth_value == [""]
or auth_value == ""
or auth_value is None
or (isinstance(auth_value, list) and len(auth_value) == 0)
or (isinstance(auth_value, str) and not auth_value.strip())
):
del headers["Authorization"]
# Make a clean copy of headers to avoid any mutation issues
clean_headers = {
k: v
for k, v in headers.items()
if not (
k == "Authorization"
and (
v == []
or v == [""]
or v == ""
or v is None
or (isinstance(v, list) and len(v) == 0)
or (isinstance(v, str) and not v.strip())
)
)
}
response = self._session.request(
method=method,
url=url,
params=params,
json=json,
headers=clean_headers,
timeout=self.timeout,
**kwargs,
)
# Handle successful responses
if 200 <= response.status_code < 300:
if response.headers.get("content-type", "").startswith(
"application/json"
):
return response.json()
else:
return {"data": response.text}
# Handle errors
self._handle_error_response(response)
except GumYumAuthError as e:
# Handle 401 authentication errors
if e.status_code == 401 and retries == 0:
# Try to refresh token if we have a refresh token
auth_endpoints_to_skip = [
"auth/refresh",
"auth/login",
"auth/register",
"auth/exchange",
"health",
"public/universes",
]
if self._refresh_token and not any(
endpoint.startswith(ep) for ep in auth_endpoints_to_skip
):
try:
# Call refresh endpoint
refresh_response = self.auth.refresh_token(
self._refresh_token
)
# Token is already updated by refresh_token method
# Retry the original request
retries += 1
continue
except GumYumAuthError as refresh_error:
# If refresh returns 403, the refresh token is invalid
if refresh_error.status_code == 403:
# Clear tokens - refresh token is invalid
self._token = None
self._refresh_token = None
raise GumYumAuthError(
"Refresh token is invalid or expired - please login again",
403,
)
else:
# Other refresh errors
self._token = None
self._refresh_token = None
raise GumYumAuthError(
"Authentication failed - token refresh failed",
e.status_code,
)
except Exception:
# Refresh failed, clear tokens
self._token = None
self._refresh_token = None
raise GumYumAuthError(
"Authentication failed - token refresh failed",
e.status_code,
)
# No refresh token or refresh failed
self._token = None
self._refresh_token = None
raise GumYumAuthError(
"Authentication failed - please login again", e.status_code
)
else:
# Don't retry, bubble up the auth error
raise
except GumYumServerError as e:
# Retry on 503 Service Unavailable
if e.status_code == 503 and retries < self.max_retries:
retries += 1
# Exponential backoff with jitter
time.sleep(min(2**retries + 0.1 * retries, 10))
continue
else:
raise
except requests.exceptions.Timeout as e:
if retries >= self.max_retries:
raise GumYumTimeoutError(
f"Request timeout after {self.timeout}s"
) from e
except requests.exceptions.RequestException as e:
if retries >= self.max_retries:
raise GumYumNetworkError(f"Network error: {e}") from e
retries += 1
if retries <= self.max_retries:
# Exponential backoff
time.sleep(2**retries)
raise GumYumError("Max retries exceeded")
def _handle_error_response(self, response: requests.Response):
"""Handle HTTP error responses"""
try:
error_data = response.json()
message = error_data.get("message", f"HTTP {response.status_code}")
except:
message = f"HTTP {response.status_code}: {response.text}"
error_data = {}
# Map status codes to exception types
if response.status_code == 401:
raise GumYumAuthError(message, response.status_code, error_data)
elif response.status_code == 403:
raise GumYumAuthError(message, response.status_code, error_data)
elif response.status_code == 404:
raise GumYumNotFoundError(message, response.status_code, error_data)
elif response.status_code in (400, 422):
validation_errors = error_data.get("errors", [])
raise GumYumValidationError(
message, response.status_code, error_data, validation_errors
)
elif response.status_code == 429:
retry_after = response.headers.get("Retry-After")
raise GumYumRateLimitError(
message,
response.status_code,
error_data,
int(retry_after) if retry_after else None,
)
elif 500 <= response.status_code < 600:
raise GumYumServerError(message, response.status_code, error_data)
else:
raise GumYumAPIError(message, response.status_code, error_data)
def get(self, endpoint: str, **kwargs) -> Dict[str, Any]:
"""Make GET request"""
return self._request("GET", endpoint, **kwargs)
def post(self, endpoint: str, **kwargs) -> Dict[str, Any]:
"""Make POST request"""
return self._request("POST", endpoint, **kwargs)
def put(self, endpoint: str, **kwargs) -> Dict[str, Any]:
"""Make PUT request"""
return self._request("PUT", endpoint, **kwargs)
def delete(self, endpoint: str, **kwargs) -> Dict[str, Any]:
"""Make DELETE request"""
return self._request("DELETE", endpoint, **kwargs)
def health_check(self) -> Dict[str, Any]:
"""Check API health status"""
return self.get("health")
def login(self, username: str, password: str) -> AuthToken:
"""
Login with username and password
Args:
username: User's username
password: User's password
Returns:
AuthToken with access and refresh tokens
"""
return self.auth.login(username, password)
def register(self, username: str, password: str, email: str) -> AuthToken:
"""
Register a new account
Args:
username: Desired username
password: User's password
email: User's email
Returns:
AuthToken with access and refresh tokens
"""
return self.auth.register(username, password, email)
# ============================================================================
# EXPORTS
# ============================================================================
__version__ = "0.2.0"
__author__ = "GumYum NPC API Team"
__email__ = "timehexon@gumyum.com"
logger = logging.getLogger(__name__)
__all__ = [
# Main client
"GumYumClient",
# Exceptions
"GumYumError",
"GumYumAPIError",
"GumYumAuthError",
"GumYumNotFoundError",
"GumYumValidationError",
"GumYumServerError",
"GumYumNetworkError",
"GumYumTimeoutError",
"GumYumRateLimitError",
# Models
"NPCProfile",
"ChatCompletion",
"ChatMessage",
"AuthToken",
"NPC",
"PublicUniverse",
"ChatChoice",
"ChatRole",
"StreamingResponse",
]
# ============================================================================
# EXAMPLE USAGE
# ============================================================================
if __name__ == "__main__":
def demo():
"""Example usage of the synchronous GumYum NPC SDK"""
with GumYumClient("http://localhost:6543") as client:
try:
# Register or login
print("Authenticating...")
try:
user = client.auth.login("demo_user", "demo_password123")
except GumYumAuthError:
user = client.auth.register(
"demo_user", "demo_password123", "demo@example.com"
)
print(f"Logged in as: {user.username}")
# List public universes
print("\nFetching public universes...")
universes = client.universes.list_public()
if universes:
print(f"Found {len(universes)} public universes")
print(f"Using: {universes[0].name}")
# Copy a universe
universe_id = client.universes.copy_public(
universes[0].id, "My Demo World"
)
print(f"Copied universe: {universe_id}")
# Spawn an NPC
print("\nSpawning NPC...")
npc = client.npcs.spawn(universe_id, seed=12345, npc_id=123456789)
print(f"Spawned: {npc.name} ({npc.profession})")
# Chat with the NPC
print(f"\nChatting with {npc.name}...")
response = client.chat.simple_chat(
npc.npc_id, "Hello! What's your story?"
)
print(f"{npc.name}: {response}")
# Streaming chat example
print(f"\n{npc.name} (streaming): ", end="")
stream = client.chat.completions(
npc_id=npc.npc_id,
messages=[
{"role": "user", "content": "Tell me about your day"}
],
stream=True,
)
for chunk in stream:
if "choices" in chunk and chunk["choices"]:
delta = chunk["choices"][0].get("delta", {})
content = delta.get("content", "")
if content:
print(content, end="", flush=True)
print("\n\nDemo completed!")
except Exception as e:
print(f"Error: {e}")
# Run demo if executed directly
demo()